Community project

M5Cardputer XL: A Modified Cardputer ADV With an External Display

Photo of M5Cardputer XL: A Modified Cardputer ADV With an External Display

Jan Sz.

Last updated August 11, 2026

The M5Cardputer XL extends the M5Cardputer ADV with an external ILI9341 display, creating a portable computing device with a larger screen for better visibility. This project combines the compact keyboard and processing power of the Cardputer with a 3.2-inch TFT display, enabling applications like a launcher, WiFi scanner, notes app, calculator, clock, and QR code generator.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for connecting the external display via SPI. The included firmware handles display initialization, theme management, audio feedback, and a multi-page interface with persistent state storage. Builders will learn how to integrate external displays with ESP32-based devices and customize the application menu for their own projects.

Wiring diagram

Loading diagram…

Assemble it in 5 steps

1. Odłącz zasilanie i sprawdź złącza

Wyłącz Cardputer ADV i odłącz przewód USB. Zidentyfikuj na module ILI9341 piny VCC, GND, SCK/CLK, MOSI/DIN, MISO (jeśli występuje), CS, DC/RS, RST oraz BL/LED. Nie podłączaj ekranu, gdy Cardputer jest zasilany.

  • Nazwy SCK i CLK oznaczają tę samą linię; tak samo MOSI i DIN oraz DC i RS.
  • Użyj krótkich przewodów połączeniowych, najlepiej poniżej około 15 cm, aby ograniczyć zakłócenia SPI.
  • Nie zamieniaj VCC z GND — może to trwale uszkodzić ekran albo Cardputera.
  • GPIO Cardputera pracują z logiką 3,3 V. Nie podawaj 5 V na żaden pin sygnałowy GPIO.

2. Połącz zasilanie i masę

Połącz VCC modułu ili9341_1 z wyprowadzeniem 5V OUT Cardputera ADV. Połącz GND modułu ili9341_1 z GND Cardputera. To wspólna masa dla zasilania i wszystkich sygnałów.

  • W tabeli dostarczonej przez Ciebie VCC odpowiada 5V OUT Cardputera.
  • Jeżeli Twój konkretny moduł ma wyraźny opis „3.3V only”, zamiast 5V OUT podłącz jego VCC do szyny 3,3 V — nie stosuj jednocześnie obu napięć.
  • Najpierw wykonaj GND, dopiero potem przewód VCC.
  • Nie zasilaj modułu równocześnie z Cardputera i zewnętrznego zasilacza.

3. Podłącz magistralę SPI ekranu

Podłącz SCK/CLK modułu ili9341_1 do GPIO6 Cardputera, MOSI/DIN do GPIO3, a MISO do GPIO4. MISO jest przewodem powrotnym i firmware go nie wykorzystuje do rysowania, ale jest zgodny z podanym przez Ciebie pinoutem modułu.

  • Prowadź trzy przewody SPI obok siebie i trzymaj je możliwie krótkie.
  • Jeżeli używasz modułu bez pinu MISO, zostaw GPIO4 niepodłączone — ekran nadal będzie działał.
  • Nie podłączaj SCK ani MOSI do 5 V.
  • GPIO6, GPIO3 i GPIO4 są używane jako programowe SPI zgodnie z dostarczoną tabelą; nie przekładaj ich bez równoczesnej zmiany firmware.

4. Podłącz sterowanie ILI9341

Podłącz CS modułu ili9341_1 do GPIO5 Cardputera, DC/RS do GPIO13, RST do GPIO15, a BL/LED do GPIO39. W przypadku panelu z kontrolerem dotyku XPT2046 pin TOUCH_CS pozostaw podłączony do 3,3 V, aby kontroler dotyku był stale niewybrany i nie zakłócał magistrali.

  • DC/RS nie jest masą — to cyfrowa linia wyboru danych i poleceń.
  • Jeśli na module pin BL/LED jest opisany jako LED, podłącz go dokładnie tak samo do GPIO39.
  • Jeżeli masz moduł bez dotyku, krok z TOUCH_CS pomiń.
  • Nie łącz TOUCH_CS z GPIO4: GPIO4 jest MISO i nie może być równocześnie sygnałem wyboru układu.
  • GPIO39 jest równocześnie linią MISO wbudowanego microSD Cardputera ADV. W tym układzie nie używaj równocześnie microSD, bo podświetlenie zewnętrznego ekranu może powodować konflikt.

5. Kontrola przed włączeniem

Sprawdź po kolei wszystkie osiem połączeń: 5V OUT→VCC, GND→GND, GPIO6→SCK, GPIO3→MOSI, GPIO4→MISO, GPIO5→CS, GPIO13→DC, GPIO15→RST oraz GPIO39→BL. Dopiero po kontroli podłącz USB do Cardputera.

  • Po włączeniu podświetlenie zewnętrznego ekranu powinno się uaktywnić, a terminal cyberdecka pojawi się na ekranie ILI9341.
  • W terminalu można wpisać `help`, `about`, `clear` lub `echo tekst`, a następnie nacisnąć Enter.
  • Jeśli ekran nie działa, odłącz USB przed korektą przewodów.
  • Nie wyciągaj ani nie wkładaj przewodów zasilania przy działającym urządzeniu.

Deploy the firmware

#include <Arduino.h>
#include <M5Cardputer.h>
#include <SPI.h>
#include <WiFi.h>
#include <Preferences.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <qrcode.h>

#define TFT_CS 5
#define TFT_RST 15
#define TFT_DC 13
#define TFT_MOSI 3
#define TFT_MISO 4
#define TFT_SCK 6
#define TFT_BL 39

struct Theme { uint16_t bg, panel, accent, text, dim, selected; };
enum Page { LAUNCHER, SYSTEM, WIFI, NOTES, CLOCK, CALC, CLAB, QRTEXT, SETTINGS };
struct CVar { String name; long value; };
struct CStringVar { String name; String value; };
struct CFunction { String name; String body; };


// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations
void loadCLabDemo();

bool cBuiltinNumber(const String& name, long& value);
bool cBuiltinText(const String& name, String& value);
bool isCBuiltinName(const String& name);
void cWifiScan();

int findCStringVar(const String& name);

void applyVolume();

void markStateDirty();
void loadPersistentState();
void savePersistentState();
void applyTheme();
void playMenuSound();
void playTypingSound();
void playBackspaceSound();
void playExitSound();
void playCursorSound();
void playFunctionSound();
void playEnterSound();
void playTabSound();
void setBacklight(bool on);
void applyBacklight();
void drawBootScreen();
void header(const char* title);
void footer(const char* text);
void updateLauncherIndicators();
void updateLauncherSelection(int oldSelected, int oldScroll);
void updateSystemValues();
void drawSystem();
const char* enc(wifi_auth_mode_t e);
void drawWifi();
void drawNotes();
void updateNotesLine();
void updateClockValue();
void drawClock();
void updateCalcPanel();
void drawQRTextField();
void drawQRModules();
void drawQRText();
long cAtom(String token, bool& ok);
long cExpr(String expression, bool& ok);
String cTextExpr(String expression, bool& ok);
void cLog(const String& line);
void updateBuiltinDisplay(bool force = false);
int noteCharacterCount();

void drawLauncherRow(int row); void updateLauncherIndicators(); void updateLauncherSelection(int oldSelected, int oldScroll);
void drawSystemValue(int rowIndex, const String& value, uint16_t color); void updateSystemValues();
void handleCLabCursorKeys(); void loadPersistentState(); void savePersistentState(); void markStateDirty();
void syncLauncherScroll(); void moveCLabCursor(int direction);
int findCVar(const String& name); long cAtom(String token, bool& ok); long cExpr(String expression, bool& ok); void cLog(const String& line);
String uptime(); void applyTheme(); void setBacklight(bool on); void header(const char* title); void footer(const char* text);
void drawLauncher(); void drawSystem(); const char* enc(wifi_auth_mode_t e); void drawWifi(); void drawNotes(); void drawClock();
void drawCalc(); void updateClockValue(); void updateCalcPanel(); void updateNotesLine(); void updateSettingsRow(int index);
void drawCLab(); void drawCLabQR(); void drawQRText(); void drawQRTextField(); void drawQRModules(); void drawCLabEditor(); void drawCLabCodeLine(int lineIndex);
void runCLab(); void loadCLabDemo(); String settingValue(int i); void drawSettings(); void draw(); void startScan(); void checkScan(); void calcResult();
void changeSetting(int d); void keyboard(); void clabBackspace(); void playMenuSound(); void playTypingSound(); void playBackspaceSound();

Adafruit_ILI9341 tft(&SPI, TFT_DC, TFT_CS, TFT_RST);
Preferences preferences;
bool stateDirty = false;
unsigned long stateChangedAt = 0;
constexpr int W = 320, H = 240, HEADER_H = 22, FOOTER_H = 16, CONTENT_Y = 27;

constexpr int THEME_COUNT = 6;
const Theme themes[THEME_COUNT] = {
  // background, panel, accent, text, dim, selected
  {ILI9341_BLACK, ILI9341_DARKCYAN, ILI9341_CYAN, ILI9341_WHITE, ILI9341_LIGHTGREY, ILI9341_BLUE},
  {ILI9341_BLACK, 0x4200, ILI9341_YELLOW, 0xFFE0, 0xBDF7, 0x7BE0},
  {0x0010, 0x001F, 0x07FF, ILI9341_WHITE, 0xBDF7, 0x401F},
  {0x1008, 0x400F, 0xF81F, 0xFFE0, 0xC618, 0x801F},
  {0x0200, 0x03E0, 0x07E0, ILI9341_WHITE, 0xBDF7, 0x05A0},
  {0x2104, 0x4208, 0xFD20, 0xFFFF, 0xC618, 0xA145}
};
const char* themeNames[THEME_COUNT] = {"CYAN", "AMBER", "BLUE", "MAGENTA", "MATRIX", "SUNSET"};
int themeIndex = 0; Theme ui = themes[0];
uint8_t displayRotation = 3;
bool backlightOn = true, sleeping = false, redrawNeeded = true, fnLast = false;
uint8_t volumeLevel = 10;  // 0..10, persisted; 0 is silent.
uint8_t brightnessLevel = 10; // 1..10, persisted; PWM duty for the ILI backlight.
constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 7;
constexpr uint16_t BACKLIGHT_PWM_HZ = 5000;
constexpr uint8_t BACKLIGHT_PWM_BITS = 8;
bool clabDeleteHeld = false;
unsigned long clabDeleteRepeatAt = 0;
const uint16_t sleepValues[] = {0, 15, 30, 60, 120};
int sleepIndex = 0; unsigned long lastActivity = 0;

Page page = LAUNCHER;
const char* appNames[] = {"SYSTEM", "WI-FI SCAN", "NOTES", "CLOCK", "CALCULATOR", "C LAB", "QR TEXT", "SETTINGS"};
const char* appInfo[] = {"battery, memory, uptime", "nearby networks", "quick text scratchpad", "local uptime clock", "basic arithmetic", "tiny C-style interpreter", "encode text as a QR", "theme and display options"};
constexpr int APP_COUNT = 8;
constexpr int APP_VISIBLE = 5;
int appSelected = 0, appScroll = 0;

String notes[15]; int noteLine = 0, noteCount = 1;
bool scanRunning = false, scanDone = false; int networkCount = 0; unsigned long scanStarted = 0;
String calcInput = "0", calcStatus = "Enter numbers, then + - * /"; float calcTotal = 0; char calcOp = 0; bool calcNew = true;
constexpr int QR_MAX_TEXT = 60;
String qrText = "https://m5stack.com";

constexpr int C_MAX_LINES = 32, C_MAX_LINE_CHARS = 48, C_CODE_VISIBLE_LINES = 8;
String cLines[C_MAX_LINES] = {
  "void setup() {", "  // put your setup code here, to run once:", "", "}", "",
  "void loop() {", "  // put your main code here, to run repeatedly:", "", "}"
};
int cLineCount = 9, cCursorLine = 0, cCursorColumn = 0, cScrollLine = 0;
constexpr int C_CODE_COLUMNS_VISIBLE = 40;
int cHorizontalScroll = 0;
bool cNavigationMode = false;
bool cLabGuideVisible = false;
String cOutput[5]; int cOutputCount = 0;
CVar cVars[12]; int cVarCount = 0;
CStringVar cStringVars[8]; int cStringVarCount = 0;
String cInputValues[4]; int cInputValueCount = 0, cInputReadIndex = 0;
bool cInputActive = false;
String cInputPrompt = "", cInputBuffer = "";
bool cLabQrActive = false;
String cLabQrPayload = "";
CFunction cFunctions[8]; int cFunctionCount = 0;

int settingSelected = 0;
const char* settingNames[] = {"Theme", "Rotation", "Backlight", "Brightness", "Sleep timeout", "Volume"};

String uptime() { unsigned long s = millis() / 1000UL; char x[18]; snprintf(x, sizeof(x), "%02lu:%02lu:%02lu", s / 3600UL, (s % 3600UL) / 60UL, s % 60UL); return String(x); }
int noteCharacterCount() { int total = 0; for (int i = 0; i < noteCount; ++i) total += notes[i].length(); return total; }

// Built-in Cardputer display: a small, independent companion panel.
// It is redrawn only when its displayed state changes.
void updateBuiltinDisplay(bool force) {
  static String previous = "";
  String title, value, detail;
  bool show = true;
  if (page == LAUNCHER) {
    title = "BATTERY";
    int battery = M5Cardputer.Power.getBatteryLevel();
    value = battery < 0 ? "--" : String(battery) + "%";
    detail = appNames[appSelected];
  } else if (page == WIFI) {
    title = "WI-FI";
    value = scanRunning ? "..." : String(networkCount);
    detail = scanRunning ? "SCANNING" : (scanDone ? "NETWORKS FOUND" : "PRESS ENTER TO SCAN");
  } else if (page == NOTES) {
    title = "NOTES";
    value = String(noteCharacterCount());
    detail = "CHARACTERS";
  } else if (page == CALC) {
    title = "CALCULATOR";
    value = calcInput;
    detail = calcStatus;
  } else if (page == CLAB) {
    title = "C LAB OUTPUT";
    value = "";
    for (int i = 0; i < cOutputCount; ++i) { if (i) value += "\n"; value += cOutput[i]; }
    if (value.isEmpty()) value = "(no output)";
    detail = "";
  } else {
    // System, Clock, QR Text and Settings intentionally leave this screen blank.
    show = false;
  }
  String signature = String((int)page) + "|" + title + "|" + value + "|" + detail + "|" + (show ? "1" : "0");
  if (!force && signature == previous) return;
  previous = signature;
  auto& screen = M5Cardputer.Display;
  screen.fillScreen(ui.bg);
  if (!show) return;
  screen.setTextWrap(false);
  screen.setTextSize(1);
  screen.setTextColor(ui.accent, ui.bg);
  screen.setCursor(8, 7);
  screen.print(title);
  screen.drawFastHLine(8, 18, 224, ui.dim);
  if (page == CLAB) {
    screen.setTextColor(ui.accent, ui.bg);
    int y = 30, start = max(0, cOutputCount - 7);
    for (int i = start; i < cOutputCount; ++i) { screen.setCursor(8, y); screen.print(cOutput[i]); y += 14; }
    if (cOutputCount == 0) { screen.setTextColor(ui.dim, ui.bg); screen.setCursor(8, 32); screen.print("(no output)"); }
  } else {
    String shown = value;
    if (shown.length() > 10) shown = shown.substring(shown.length() - 10);
    screen.setTextSize(page == CALC ? 3 : 5);
    screen.setTextColor(ui.text, ui.bg);
    screen.setCursor(8, 33);
    screen.print(shown);
    screen.setTextSize(1);
    screen.setTextColor(ui.dim, ui.bg);
    screen.setCursor(8, 108);
    screen.print(detail);
  }
}
void markStateDirty() { stateDirty = true; stateChangedAt = millis(); }

void loadPersistentState() {
  preferences.begin("cyberdeck", true);
  themeIndex = constrain(preferences.getInt("theme", themeIndex), 0, THEME_COUNT - 1);
  displayRotation = preferences.getUChar("rotation", displayRotation);
  if (displayRotation != 1 && displayRotation != 3) displayRotation = 3;
  backlightOn = preferences.getBool("backlight", backlightOn);
  brightnessLevel = constrain(preferences.getUChar("brightness", brightnessLevel), 1, 10);
  sleepIndex = constrain(preferences.getInt("sleep", sleepIndex), 0, 4);
  // Migrate the old Sound ON/OFF preference to a full-volume/default setting.
  if (preferences.isKey("volume")) volumeLevel = constrain(preferences.getUChar("volume", volumeLevel), 0, 10);
  else volumeLevel = preferences.getBool("sound", true) ? 10 : 0;
  String storedNotes = preferences.getString("notes", "");
  String storedCode = preferences.getString("code", "");
  qrText = preferences.getString("qrtext", qrText);
  preferences.end();
  if (!storedNotes.isEmpty()) {
    noteCount = 0; int start = 0;
    while (noteCount < 15) { int end = storedNotes.indexOf('\n', start); if (end < 0) { notes[noteCount++] = storedNotes.substring(start); break; } notes[noteCount++] = storedNotes.substring(start, end); start = end + 1; }
    if (noteCount == 0) noteCount = 1;
  }
  if (!storedCode.isEmpty()) {
    cLineCount = 0; int start = 0;
    while (cLineCount < C_MAX_LINES) { int end = storedCode.indexOf('\n', start); if (end < 0) { cLines[cLineCount++] = storedCode.substring(start); break; } cLines[cLineCount++] = storedCode.substring(start, end); start = end + 1; }
    if (cLineCount == 0) cLineCount = 1;
  }
}
void savePersistentState() {
  String storedNotes, storedCode;
  for (int i = 0; i < noteCount; ++i) { if (i) storedNotes += '\n'; storedNotes += notes[i]; }
  for (int i = 0; i < cLineCount; ++i) { if (i) storedCode += '\n'; storedCode += cLines[i]; }
  preferences.begin("cyberdeck", false);
  preferences.putInt("theme", themeIndex); preferences.putUChar("rotation", displayRotation); preferences.putBool("backlight", backlightOn); preferences.putUChar("brightness", brightnessLevel);
  preferences.putInt("sleep", sleepIndex); preferences.putUChar("volume", volumeLevel); preferences.putString("notes", storedNotes); preferences.putString("code", storedCode); preferences.putString("qrtext", qrText);
  preferences.end(); stateDirty = false;
}

void applyTheme() { ui = themes[themeIndex]; }
constexpr uint16_t MENU_TONE_HZ = 1050, TYPING_TONE_HZ = 1560, BACKSPACE_TONE_HZ = 1380;
constexpr uint16_t EXIT_TONE_HZ = 720, CURSOR_TONE_HZ = 1220, FUNCTION_TONE_HZ = 940, ENTER_TONE_HZ = 1740, TAB_TONE_HZ = 1480;
constexpr uint16_t MENU_TONE_MS = 24, TYPING_TONE_MS = 10, BACKSPACE_TONE_MS = 12;
constexpr uint16_t EXIT_TONE_MS = 32, CURSOR_TONE_MS = 9, FUNCTION_TONE_MS = 14, ENTER_TONE_MS = 20, TAB_TONE_MS = 16;
void applyVolume() { M5Cardputer.Speaker.setVolume((volumeLevel * 255U) / 10U); }
void playMenuSound() { if (volumeLevel) M5Cardputer.Speaker.tone(MENU_TONE_HZ, MENU_TONE_MS); }
void playTypingSound() { if (volumeLevel) M5Cardputer.Speaker.tone(TYPING_TONE_HZ, TYPING_TONE_MS); }
void playBackspaceSound() { if (volumeLevel) M5Cardputer.Speaker.tone(BACKSPACE_TONE_HZ, BACKSPACE_TONE_MS); }
void playExitSound() { if (volumeLevel) M5Cardputer.Speaker.tone(EXIT_TONE_HZ, EXIT_TONE_MS); }
void playCursorSound() { if (volumeLevel) M5Cardputer.Speaker.tone(CURSOR_TONE_HZ, CURSOR_TONE_MS); }
void playFunctionSound() { if (volumeLevel) M5Cardputer.Speaker.tone(FUNCTION_TONE_HZ, FUNCTION_TONE_MS); }
void playEnterSound() { if (volumeLevel) M5Cardputer.Speaker.tone(ENTER_TONE_HZ, ENTER_TONE_MS); }
void playTabSound() { if (volumeLevel) M5Cardputer.Speaker.tone(TAB_TONE_HZ, TAB_TONE_MS); }
void applyBacklight() {
  // GPIO 39 drives the module's BL control at 3.3 V through 8-bit PWM.
  uint8_t duty = backlightOn ? (uint16_t(brightnessLevel) * 255U) / 10U : 0;
  ledcWrite(BACKLIGHT_PWM_CHANNEL, duty);
}
void setBacklight(bool on) { backlightOn = on; applyBacklight(); }
void drawBootScreen() {
  // Full-screen splash: each text line has its own vertical area, so text cannot overlap.
  tft.fillScreen(ui.bg);
  tft.fillRect(0, 0, W, 5, ui.accent);
  tft.fillRect(0, H - 5, W, 5, ui.accent);
  tft.drawRoundRect(18, 28, 284, 184, 10, ui.panel);
  tft.drawRoundRect(23, 33, 274, 174, 8, ui.accent);

  tft.drawFastHLine(56, 68, 208, ui.dim);

  tft.setTextSize(3);
  tft.setTextColor(ui.accent, ui.bg);
  tft.setCursor(70, 82);
  tft.print("CARDPUTER");

  tft.setTextSize(5);
  tft.setTextColor(ui.text, ui.bg);
  tft.setCursor(128, 120);
  tft.print("XL");

  tft.drawFastHLine(56, 169, 208, ui.dim);
  tft.setTextSize(1);
  tft.setTextColor(ui.dim, ui.bg);
  tft.setCursor(94, 184);
  tft.print("CYBERDECK INTERFACE");
  delay(1200);
}
void header(const char* title) {
  tft.fillRect(0, 0, W, HEADER_H, ui.panel); tft.setTextSize(1); tft.setTextColor(ui.text, ui.panel); tft.setCursor(6, 7); tft.print("CARDPUTER XL");
  tft.setTextColor(ui.accent, ui.panel); tft.print(" / "); tft.print(title);
  int b = M5Cardputer.Power.getBatteryLevel(); if (b >= 0) { char v[8]; snprintf(v, sizeof(v), "%d%%", b); tft.setTextColor(b <= 20 ? ILI9341_RED : (b <= 50 ? ILI9341_YELLOW : ILI9341_GREEN), ui.panel); tft.setCursor(W - strlen(v) * 6 - 6, 7); tft.print(v); }
}
void footer(const char* text) { tft.fillRect(0, H - FOOTER_H, W, FOOTER_H, ILI9341_DARKGREY); tft.setTextSize(1); tft.setTextColor(ILI9341_WHITE, ILI9341_DARKGREY); tft.setCursor(5, H - 12); tft.print(text); }

void drawLauncherRow(int row) {
  int i = appScroll + row, y = CONTENT_Y + row * 33;
  tft.fillRect(8, y - 2, 284, 33, ui.bg); if (i >= APP_COUNT) return;
  bool sel = i == appSelected; uint16_t fill = sel ? ui.selected : ILI9341_DARKGREY;
  tft.fillRoundRect(12, y, 276, 29, 4, fill); tft.drawRoundRect(12, y, 276, 29, 4, sel ? ui.accent : ui.dim);
  tft.setTextSize(1); tft.setTextColor(ui.text, fill); tft.setCursor(20, y + 5); tft.print(sel ? "> " : "  "); tft.print(appNames[i]);
  tft.setTextColor(sel ? ui.text : ui.dim, fill); tft.setCursor(20, y + 16); tft.print(appInfo[i]);
}
void updateLauncherIndicators() {
  tft.fillRect(294, CONTENT_Y - 1, 22, APP_VISIBLE * 33, ui.bg);
  if (appScroll > 0) { tft.setTextColor(ui.accent, ui.bg); tft.setCursor(302, CONTENT_Y); tft.print("^"); }
  if (appScroll + APP_VISIBLE < APP_COUNT) { tft.setTextColor(ui.accent, ui.bg); tft.setCursor(302, CONTENT_Y + APP_VISIBLE * 33 - 10); tft.print("v"); }
}
void updateLauncherSelection(int oldSelected, int oldScroll) {
  if (oldScroll == appScroll) { drawLauncherRow(oldSelected - appScroll); drawLauncherRow(appSelected - appScroll); }
  else for (int row = 0; row < APP_VISIBLE; ++row) drawLauncherRow(row);
  updateLauncherIndicators();
}
void drawLauncher() { tft.fillScreen(ui.bg); header("LAUNCHER"); for (int row = 0; row < APP_VISIBLE; ++row) drawLauncherRow(row); updateLauncherIndicators(); footer(",/ PREV/NEXT     ENTER OPEN"); }

void drawSystemValue(int rowIndex, const String& value, uint16_t color) { int y = CONTENT_Y + 8 + rowIndex * 18; tft.fillRect(133, y - 1, 178, 10, ui.bg); tft.setTextSize(1); tft.setTextColor(color, ui.bg); tft.setCursor(133, y); tft.print(value); }
void updateSystemValues() { int b = M5Cardputer.Power.getBatteryLevel(); drawSystemValue(2, b < 0 ? "Unknown" : String(b) + "%", b <= 20 ? ILI9341_RED : ILI9341_GREEN); drawSystemValue(3, uptime(), ui.accent); drawSystemValue(4, String(ESP.getFreeHeap() / 1024) + " KB", ui.text); }
void drawSystem() {
  tft.fillScreen(ui.bg); header("SYSTEM"); int y = CONTENT_Y + 8, b = M5Cardputer.Power.getBatteryLevel();
  auto row = [&](const char* a, String v, uint16_t c) { tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(16, y); tft.print(a); tft.setTextColor(c, ui.bg); tft.setCursor(133, y); tft.print(v); y += 18; };
  row("DEVICE", "M5Stack Cardputer XL", ui.text); row("DISPLAY", "ILI9341 320x240", ILI9341_GREEN); row("BATTERY", b < 0 ? "Unknown" : String(b) + "%", b <= 20 ? ILI9341_RED : ILI9341_GREEN); row("UPTIME", uptime(), ui.accent); row("FREE HEAP", String(ESP.getFreeHeap() / 1024) + " KB", ui.text); row("HEAP TOTAL", String(ESP.getHeapSize() / 1024) + " KB", ui.text); row("FLASH", String(ESP.getFlashChipSize() / 1048576UL) + " MB", ui.text); row("THEME", themeNames[themeIndex], ui.accent); footer("FN BACK     ENTER REFRESH");
}
const char* enc(wifi_auth_mode_t e) { if (e == WIFI_AUTH_OPEN) return "OPEN"; if (e == WIFI_AUTH_WPA2_PSK || e == WIFI_AUTH_WPA_WPA2_PSK) return "WPA2"; if (e == WIFI_AUTH_WPA3_PSK) return "WPA3"; return "LOCK"; }
void drawWifi() {
  tft.fillScreen(ui.bg); header("WI-FI SCAN"); tft.setTextSize(1);
  if (scanRunning) { tft.setTextColor(ui.accent, ui.bg); tft.setCursor(16, CONTENT_Y + 12); tft.print("Scanning nearby networks..."); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(16, CONTENT_Y + 28); tft.print("This can take a few seconds."); footer("FN BACK"); return; }
  if (!scanDone) { tft.setTextColor(ui.dim, ui.bg); tft.setCursor(16, CONTENT_Y + 12); tft.print("No scan results yet."); tft.setTextColor(ui.accent, ui.bg); tft.setCursor(16, CONTENT_Y + 30); tft.print("Press ENTER to scan."); footer("FN BACK     ENTER SCAN"); return; }
  if (networkCount <= 0) { tft.setTextColor(ILI9341_YELLOW, ui.bg); tft.setCursor(16, CONTENT_Y + 12); tft.print("No networks found."); footer("FN BACK     ENTER RESCAN"); return; }
  tft.setTextColor(ILI9341_GREEN, ui.bg); tft.setCursor(12, CONTENT_Y); tft.printf("Found %d network(s)", networkCount);
  for (int i = 0; i < min(networkCount, 15); i++) { int y = CONTENT_Y + 16 + i * 13; String name = WiFi.SSID(i); if (name.isEmpty()) name = "<hidden>"; if (name.length() > 24) name = name.substring(0, 24); tft.setTextColor(ui.text, ui.bg); tft.setCursor(8, y); tft.printf("%02d %s", i + 1, name.c_str()); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(194, y); tft.printf("%ddB", WiFi.RSSI(i)); tft.setCursor(255, y); tft.print(enc(WiFi.encryptionType(i))); } footer("FN BACK     ENTER RESCAN");
}
void drawNotes() {
  tft.fillScreen(ui.bg); header("NOTES"); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(7, CONTENT_Y); tft.print("Saved in flash memory:"); int first = max(0, noteLine - 16);
  for (int i = first; i < noteCount; i++) { int y = CONTENT_Y + 14 + (i - first) * 11; if (y > H - FOOTER_H - 10) break; bool cur = i == noteLine; if (cur) tft.fillRect(4, y - 1, W - 8, 10, ILI9341_DARKGREY); tft.setTextColor(ui.text, cur ? ILI9341_DARKGREY : ui.bg); tft.setCursor(8, y); tft.print(notes[i]); if (cur) tft.print("_"); } footer("FN BACK  ENTER NEW LINE  DEL ERASE  CTRL+L CLEAR");
}
void updateNotesLine() { int first = max(0, noteLine - 16), y = CONTENT_Y + 14 + (noteLine - first) * 11; if (y > H - FOOTER_H - 10) { redrawNeeded = true; return; } tft.fillRect(4, y - 1, W - 8, 10, ILI9341_DARKGREY); tft.setTextSize(1); tft.setTextColor(ui.text, ILI9341_DARKGREY); tft.setCursor(8, y); tft.print(notes[noteLine]); tft.print("_"); }
void updateClockValue() { tft.fillRect(34, 70, 254, 38, ui.bg); tft.setTextSize(4); tft.setTextColor(ui.accent, ui.bg); tft.setCursor(38, 76); tft.print(uptime()); }
void drawClock() { tft.fillScreen(ui.bg); header("CLOCK"); updateClockValue(); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(56, 128); tft.print("UPTIME CLOCK"); tft.setCursor(25, 151); tft.print("Time begins at boot; no RTC sync."); footer("FN BACK"); }
void updateCalcPanel() { tft.fillRoundRect(10, CONTENT_Y + 20, 300, 43, 4, ILI9341_DARKGREY); String s = calcInput; if (s.length() > 13) s = s.substring(s.length() - 13); tft.setTextSize(3); tft.setTextColor(ui.accent, ILI9341_DARKGREY); tft.setCursor(18, CONTENT_Y + 32); tft.print(s); tft.fillRect(10, CONTENT_Y + 122, 300, 13, ui.bg); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(14, CONTENT_Y + 128); tft.print(calcStatus); }
void drawCalc() { tft.fillScreen(ui.bg); header("CALCULATOR"); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(12, CONTENT_Y + 8); tft.print("Expression:"); updateCalcPanel(); tft.setTextColor(ui.text, ui.bg); tft.setCursor(14, CONTENT_Y + 84); tft.print("Keys: 0-9 .  + - * /"); tft.setCursor(14, CONTENT_Y + 101); tft.print("ENTER = result     DEL = clear"); footer("FN BACK"); }

void drawQRTextField() { tft.fillRoundRect(8, CONTENT_Y + 4, 304, 20, 4, ILI9341_DARKGREY); String shown = qrText; if (shown.length() > 46) shown = shown.substring(shown.length() - 46); tft.setTextSize(1); tft.setTextColor(ui.text, ILI9341_DARKGREY); tft.setCursor(13, CONTENT_Y + 11); tft.print(shown); tft.print("_"); }
void drawQRModules() {
  uint8_t data[qrcode_getBufferSize(5)]; QRCode code; qrcode_initText(&code, data, 5, ECC_LOW, qrText.c_str());
  const int scale = 4, size = code.size * scale, x = (W - size) / 2, y = CONTENT_Y + 31;
  tft.fillRect(0, y, W, size, ui.bg); tft.fillRect(x - 4, y - 4, size + 8, size + 8, ILI9341_WHITE);
  for (int row = 0; row < code.size; ++row) for (int col = 0; col < code.size; ++col) if (qrcode_getModule(&code, col, row)) tft.fillRect(x + col * scale, y + row * scale, scale, scale, ILI9341_BLACK);
}
void drawQRText() { tft.fillScreen(ui.bg); header("QR TEXT"); tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, CONTENT_Y - 6); tft.print("TEXT (ASCII, max 60 characters)"); drawQRTextField(); drawQRModules(); footer("FN BACK  ENTER GENERATE  DEL ERASE"); }

int findCVar(const String& name) { for (int i = 0; i < cVarCount; i++) if (cVars[i].name == name) return i; return -1; }
int findCStringVar(const String& name) { for (int i = 0; i < cStringVarCount; i++) if (cStringVars[i].name == name) return i; return -1; }

// C LAB built-ins are refreshed whenever code runs and are deliberately read-only.
bool cBuiltinNumber(const String& name, long& value) {
  if (name == "battery") { int level = M5Cardputer.Power.getBatteryLevel(); value = level < 0 ? -1 : level; return true; }
  if (name == "uptime") { value = millis() / 1000UL; return true; }
  if (name == "free_heap_kb") { value = ESP.getFreeHeap() / 1024UL; return true; }
  if (name == "total_heap_kb") { value = ESP.getHeapSize() / 1024UL; return true; }
  if (name == "flash_mb") { value = ESP.getFlashChipSize() / 1048576UL; return true; }
  if (name == "wifi_connected") { value = WiFi.status() == WL_CONNECTED ? 1 : 0; return true; }
  if (name == "wifi_rssi") { value = WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0; return true; }
  if (name == "wifi_networks") { int result = WiFi.scanComplete(); value = result >= 0 ? result : 0; return true; }
  return false;
}
bool cBuiltinText(const String& name, String& value) {
  if (name == "device_name") { value = "M5Stack Cardputer XL"; return true; }
  if (name == "theme_name") { value = themeNames[themeIndex]; return true; }
  if (name == "uptime_text") { value = uptime(); return true; }
  if (name == "wifi_ssid") { value = WiFi.status() == WL_CONNECTED ? WiFi.SSID() : ""; return true; }
  return false;
}
bool isCBuiltinName(const String& name) { long number; String text; return cBuiltinNumber(name, number) || cBuiltinText(name, text); }

String cTextExpr(String expression, bool& ok) {
  String e = expression; e.trim(); ok = true; String result = "";
  bool inQuotes = false; int start = 0;
  for (int pos = 0; pos <= (int)e.length(); ++pos) {
    if (pos < (int)e.length() && e[pos] == '"') inQuotes = !inQuotes;
    if (pos == (int)e.length() || (!inQuotes && e[pos] == '+')) {
      String part = e.substring(start, pos); part.trim();
      if (part.isEmpty()) { ok = false; return ""; }
      if (part.length() >= 2 && part.charAt(0) == '"' && part.charAt(part.length() - 1) == '"') {
        result += part.substring(1, part.length() - 1);
      } else {
        int stringVar = findCStringVar(part);
        if (stringVar >= 0) result += cStringVars[stringVar].value;
        else { String builtinText; if (cBuiltinText(part, builtinText)) { result += builtinText; start = pos + 1; continue; }
          bool numberOk = true;
          long number = cExpr(part, numberOk);
          if (!numberOk) { ok = false; return ""; }
          result += String(number);
        }
      }
      start = pos + 1;
    }
  }
  if (inQuotes) ok = false;
  return result;
}
long cAtom(String token, bool& ok) { token.trim(); if (token.length() == 0) { ok = false; return 0; } int i = findCVar(token); if (i >= 0) return cVars[i].value; long builtinValue; if (cBuiltinNumber(token, builtinValue)) return builtinValue; char* endPtr; long n = strtol(token.c_str(), &endPtr, 10); if (*endPtr == 0) return n; ok = false; return 0; }
long cExpr(String expression, bool& ok) {
  String e = expression; e.replace(" ", ""); e.replace("\t", ""); ok = true; if (e.isEmpty()) { ok = false; return 0; } int pos = 0;
  auto readAtom = [&](long& value) -> bool { if (pos >= (int)e.length()) return false; int start = pos; if (e[pos] == '-' || e[pos] == '+') pos++; while (pos < (int)e.length() && e[pos] != '+' && e[pos] != '-' && e[pos] != '*' && e[pos] != '/' && e[pos] != '%') pos++; String token = e.substring(start, pos); bool atomOk = true; value = cAtom(token, atomOk); return atomOk; };
  long term = 0; if (!readAtom(term)) { ok = false; return 0; } long total = 0; char addOp = '+';
  while (pos < (int)e.length()) { char op = e[pos++]; long rhs = 0; if (!readAtom(rhs)) { ok = false; return 0; } if (op == '*' || op == '/' || op == '%') { if ((op == '/' || op == '%') && rhs == 0) { ok = false; return 0; } if (op == '*') term *= rhs; else if (op == '/') term /= rhs; else term %= rhs; } else if (op == '+' || op == '-') { total = addOp == '+' ? total + term : total - term; term = rhs; addOp = op; } else { ok = false; return 0; } }
  return addOp == '+' ? total + term : total - term;
}
void cLog(const String& line) { if (cOutputCount < 5) cOutput[cOutputCount++] = line; else { for (int i = 0; i < 4; i++) cOutput[i] = cOutput[i + 1]; cOutput[4] = line; } }

// Local scan only: it never connects, sends credentials, or stores network data.
void cWifiScan() {
  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  delay(100);
  WiFi.scanDelete();
  int found = WiFi.scanNetworks();
  if (found < 0) { cLog("ERR: Wi-Fi scan failed"); return; }
  cLog("Wi-Fi: " + String(found) + " network(s)");
  for (int i = 0; i < min(found, 4); ++i) {
    String ssid = WiFi.SSID(i);
    if (ssid.isEmpty()) ssid = "<hidden>";
    if (ssid.length() > 20) ssid = ssid.substring(0, 20);
    cLog(ssid + " " + String(WiFi.RSSI(i)) + "dB");
  }
}

// Ctrl+D loads this compact, safe example. Ctrl+Enter runs it.
void loadCLabDemo() {
  const char* demo[] = {
    "// C LAB QUICK DEMO",
    "int x = 7;",
    "x++;",
    "String name = input(\"Your name?\");",
    "void greet() {",
    "  println(\"Hi \" + name + \"! x=\" + x);",
    "}",
    "greet();",
    "println(device_name);",
    "println(\"Battery: \" + battery + \"%\");",
    "println(\"Uptime: \" + uptime_text);",
    "wifi_scan();",
    "println(\"Networks: \" + wifi_networks);",
    "qrcode(device_name + \" / \" + name);"
  };
  cLineCount = sizeof(demo) / sizeof(demo[0]);
  for (int i = 0; i < cLineCount; ++i) cLines[i] = demo[i];
  for (int i = cLineCount; i < C_MAX_LINES; ++i) cLines[i] = "";
  cCursorLine = 0; cCursorColumn = 0; cScrollLine = 0; cHorizontalScroll = 0;
  cOutputCount = 0; cInputActive = false; cInputValueCount = 0; cInputReadIndex = 0;
  cLabQrActive = false; cLabQrPayload = "";
  cLog("[demo loaded: Ctrl+Enter]");
  markStateDirty();
  playFunctionSound();
  redrawNeeded = true;
}

void runCLab() {
  cOutputCount = 0; cVarCount = 0; cStringVarCount = 0; cInputReadIndex = 0; cInputActive = false; bool hasError = false; String source;
  for (int i = 0; i < cLineCount; i++) { String sourceLine = cLines[i]; int comment = sourceLine.indexOf("//"); if (comment >= 0) sourceLine.remove(comment); source += sourceLine; source += '\n'; }
  cFunctionCount = 0; int functionAt = source.indexOf("void ");
  while (functionAt >= 0 && !hasError) {
    int openParen = source.indexOf('(', functionAt + 5), closeParen = openParen < 0 ? -1 : source.indexOf(')', openParen + 1), openBrace = closeParen < 0 ? -1 : source.indexOf('{', closeParen + 1);
    if (openParen < 0 || closeParen < 0 || openBrace < 0) { cLog("ERR: bad void function"); hasError = true; break; }
    String name = source.substring(functionAt + 5, openParen), parameters = source.substring(openParen + 1, closeParen); name.trim(); parameters.trim(); int depth = 1, closeBrace = openBrace + 1;
    while (closeBrace < (int)source.length() && depth > 0) { if (source[closeBrace] == '{') depth++; else if (source[closeBrace] == '}') depth--; closeBrace++; }
    if (depth != 0 || !parameters.isEmpty() || name.isEmpty() || cFunctionCount >= 8) { cLog("ERR: void needs name() { ... }"); hasError = true; break; }
    cFunctions[cFunctionCount++] = {name, source.substring(openBrace + 1, closeBrace - 1)}; source.remove(functionAt, closeBrace - functionAt); functionAt = source.indexOf("void ");
  }
  // If Arduino-style functions are present, run setup() once and loop() once.
  // Plain statements still run directly, so both C LAB styles remain valid.
  for (int i = 0; i < cFunctionCount; ++i) {
    if (cFunctions[i].name == "setup" || cFunctions[i].name == "loop") {
      source += "\n" + cFunctions[i].body + ";";
    }
  }
  int functionCalls = 0, from = 0;
  while (from < (int)source.length() && !hasError) {
    int to = source.indexOf(';', from); if (to < 0) to = source.length(); String line = source.substring(from, to); line.trim(); from = to + 1; if (line.isEmpty()) continue;
    if (line == "clear()") { cOutputCount = 0; continue; } if (line == "help()") { cLog("String x=input(\"name?\"); println(x);"); continue; }
    if (line == "wifi_scan()") { cWifiScan(); continue; }
    if (line.startsWith("String ")) {
      if (cStringVarCount >= 8) { cLog("ERR: max 8 text variables"); hasError = true; continue; }
      String declaration = line.substring(7); declaration.trim(); int eq = declaration.indexOf('='); String name = eq < 0 ? declaration : declaration.substring(0, eq); name.trim();
      if (name.isEmpty() || isCBuiltinName(name) || findCStringVar(name) >= 0 || findCVar(name) >= 0) { cLog("ERR: reserved or bad String name"); hasError = true; continue; }
      String value = "";
      if (eq >= 0) { String initializer = declaration.substring(eq + 1); initializer.trim();
        if (initializer.startsWith("input(") && initializer.endsWith(")")) {
          String promptPart = initializer.substring(6, initializer.length() - 1); bool promptOk; String prompt = cTextExpr(promptPart, promptOk);
          if (!promptOk) { cLog("ERR: input needs text prompt"); hasError = true; continue; }
          if (cInputReadIndex >= cInputValueCount) { cInputActive = true; cInputPrompt = prompt; cInputBuffer = ""; redrawNeeded = true; return; }
          value = cInputValues[cInputReadIndex++];
        } else { bool textOk; value = cTextExpr(initializer, textOk); if (!textOk) { cLog("ERR: bad String value"); hasError = true; continue; } }
      }
      cStringVars[cStringVarCount++] = {name, value};
    }
    else if (line.startsWith("int ")) { if (cVarCount >= 12) { cLog("ERR: max 12 variables"); hasError = true; continue; } String declaration = line.substring(4); declaration.trim(); int eq = declaration.indexOf('='); String name = eq < 0 ? declaration : declaration.substring(0, eq); name.trim(); bool ok = true; long value = eq < 0 ? 0 : cExpr(declaration.substring(eq + 1), ok); if (!ok || name.isEmpty() || isCBuiltinName(name) || findCVar(name) >= 0 || findCStringVar(name) >= 0) { cLog("ERR: reserved or bad int name"); hasError = true; continue; } cVars[cVarCount++] = {name, value}; }
    else if (line.endsWith("++") || line.endsWith("--")) { String name = line.substring(0, line.length() - 2); name.trim(); int var = findCVar(name); if (var < 0) { cLog("ERR: unknown variable"); hasError = true; } else cVars[var].value += line.endsWith("++") ? 1 : -1; }
    else if (line.startsWith("qrcode(")) { int open = line.indexOf('('), close = line.lastIndexOf(')'); if (close <= open) { cLog("ERR: bad qrcode call"); hasError = true; continue; } String argument = line.substring(open + 1, close); bool ok; String text = cTextExpr(argument, ok); if (!ok || text.isEmpty() || text.length() > QR_MAX_TEXT) { cLog("ERR: qrcode needs 1-60 ASCII chars"); hasError = true; } else { bool ascii = true; for (int i = 0; i < (int)text.length(); ++i) if ((uint8_t)text[i] < 32 || (uint8_t)text[i] > 126) ascii = false; if (!ascii) { cLog("ERR: qrcode uses ASCII only"); hasError = true; } else { cLabQrPayload = text; cLabQrActive = true; cLog("[QR ready - FN returns]"); } } }
    else if (line.startsWith("print(") || line.startsWith("println(") || line.startsWith("puts(") || line.startsWith("printf(")) { int open = line.indexOf('('), close = line.lastIndexOf(')'); if (close <= open) { cLog("ERR: bad print call"); hasError = true; continue; } String argument = line.substring(open + 1, close); bool ok; String text = cTextExpr(argument, ok); if (!ok) { cLog("ERR: bad print expression"); hasError = true; } else cLog(text); }
    else if (line.startsWith("hello.world(")) { int open = line.indexOf('('), close = line.lastIndexOf(')'); String argument = close > open ? line.substring(open + 1, close) : ""; argument.trim(); if (!argument.startsWith("\"") || !argument.endsWith("\"") || argument.length() < 2) { cLog("ERR: hello.world needs text"); hasError = true; } else cLog(argument.substring(1, argument.length() - 1)); }
    else if (line.endsWith("()")) { String functionName = line.substring(0, line.length() - 2); functionName.trim(); int functionIndex = -1; for (int i = 0; i < cFunctionCount; i++) if (cFunctions[i].name == functionName) { functionIndex = i; break; } if (functionIndex < 0 || ++functionCalls > 16) { cLog(functionIndex < 0 ? "ERR: unknown function" : "ERR: function depth"); hasError = true; } else source = source.substring(0, from) + cFunctions[functionIndex].body + ";" + source.substring(from); }
    else { int eq = line.indexOf('='); if (eq <= 0) { cLog("ERR: unknown statement"); hasError = true; continue; } String name = line.substring(0, eq); name.trim(); int var = findCVar(name); bool ok; long value = cExpr(line.substring(eq + 1), ok); if (var < 0 || !ok) { cLog("ERR: bad assignment"); hasError = true; } else cVars[var].value = value; }
  }
  if (!hasError) cLog("[done]"); redrawNeeded = true;
}
void drawCLabInput() {
  tft.fillScreen(ui.bg); header("C LAB INPUT"); tft.setTextSize(1); tft.setTextColor(ui.accent, ui.bg); tft.setCursor(12, CONTENT_Y + 12); tft.print(cInputPrompt); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(12, CONTENT_Y + 30); tft.print("Type your answer and press ENTER");
  tft.fillRoundRect(8, CONTENT_Y + 42, 304, 24, 4, ILI9341_DARKGREY); tft.setTextColor(ui.text, ILI9341_DARKGREY); tft.setCursor(14, CONTENT_Y + 50); String shown = cInputBuffer; if (shown.length() > 46) shown = shown.substring(shown.length() - 46); tft.print(shown); tft.print("_"); footer("ENTER CONFIRM     DEL ERASE     FN CANCEL");
}
void drawCLabCodeLine(int lineIndex) {
  if (lineIndex < cScrollLine || lineIndex >= cScrollLine + C_CODE_VISIBLE_LINES) return;
  int row = lineIndex - cScrollLine, codeY = CONTENT_Y + 12, y = codeY + 4 + row * 11; bool active = lineIndex == cCursorLine; uint16_t bg = active ? ui.selected : ILI9341_DARKGREY;
  tft.fillRect(8, y - 1, 304, 10, bg); tft.setTextSize(1); tft.setTextColor(ui.dim, bg); tft.setCursor(10, y); tft.printf("%02d", lineIndex + 1); tft.setTextColor(ui.text, bg); tft.setCursor(30, y); tft.print(cLines[lineIndex].substring(cHorizontalScroll, cHorizontalScroll + C_CODE_COLUMNS_VISIBLE)); if (active) tft.drawFastVLine(30 + (cCursorColumn - cHorizontalScroll) * 6, y - 1, 10, ui.accent);
}
void drawCLabEditor() {
  tft.setTextSize(1); tft.fillRect(0, CONTENT_Y, W, 12, ui.bg); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, CONTENT_Y); tft.printf("CODE L%d-%d/%d  COL %d", cScrollLine + 1, min(cScrollLine + C_CODE_VISIBLE_LINES, cLineCount), cLineCount, cHorizontalScroll + 1);
  const int codeY = CONTENT_Y + 12; tft.fillRoundRect(6, codeY, 308, 91, 4, ILI9341_DARKGREY); for (int row = 0; row < C_CODE_VISIBLE_LINES; row++) { int lineIndex = cScrollLine + row; if (lineIndex >= cLineCount) break; drawCLabCodeLine(lineIndex); }
}
void drawCLabGuide() {
  tft.fillScreen(ui.bg); header("C LAB GUIDE");
  tft.setTextSize(1);
  int y = CONTENT_Y + 3;
  auto guideLine = [&](const char* text, uint16_t color) { tft.setTextColor(color, ui.bg); tft.setCursor(10, y); tft.print(text); y += 12; };
  guideLine("SAFE LOCAL C-STYLE PLAYGROUND", ui.accent);
  guideLine("", ui.text);
  guideLine("EDIT: type text | ENTER: new line | DEL: erase", ui.text);
  guideLine("TAB: navigation mode; ;/. up/down, ,/ left/right", ui.text);
  guideLine("CTRL+ENTER: run code       CTRL+D: load quick demo", ui.text);
  guideLine("", ui.text);
  guideLine("STATEMENTS", ui.accent);
  guideLine("int x = 5;   x++;   x = x * 2 + 1;", ui.text);
  guideLine("String n = input(\"Your name?\");", ui.text);
  guideLine("println(\"Hello \" + n + \"!\");", ui.text);
  guideLine("qrcode(\"hello\");  qrcode(\"Hi \" + n);", ui.text);
  guideLine("System: battery, uptime, free_heap_kb, flash_mb", ui.text);
  guideLine("Wi-Fi: wifi_scan(); wifi_networks, wifi_connected", ui.text);
  guideLine("Wi-Fi text: wifi_ssid; signal: wifi_rssi (dBm)", ui.text);
  guideLine("Text: device_name, theme_name, uptime_text", ui.text);
  guideLine("print(x);  puts(\"text\");  printf(x);", ui.text);
  guideLine("clear();  help();  // comment", ui.text);
  guideLine("", ui.text);
  guideLine("FUNCTIONS", ui.accent);
  guideLine("void hi() { print(\"hello\"); }   hi();", ui.text);
  guideLine("Operators: +  -  *  /  %   (integer values)", ui.dim);
  guideLine("No loops, pointers, files, Wi-Fi connection or GPIO.", ui.dim);
  footer("CTRL+G CLOSE     FN BACK");
}
void drawCLabQR() {
  tft.fillScreen(ui.bg); header("C LAB QR");
  tft.setTextSize(1); tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, CONTENT_Y - 6); tft.print("qrcode() OUTPUT");
  uint8_t data[qrcode_getBufferSize(5)]; QRCode code; qrcode_initText(&code, data, 5, ECC_LOW, cLabQrPayload.c_str());
  const int scale = 4, size = code.size * scale, x = (W - size) / 2, y = CONTENT_Y + 13;
  tft.fillRect(x - 4, y - 4, size + 8, size + 8, ILI9341_WHITE);
  for (int row = 0; row < code.size; ++row) for (int col = 0; col < code.size; ++col) if (qrcode_getModule(&code, col, row)) tft.fillRect(x + col * scale, y + row * scale, scale, scale, ILI9341_BLACK);
  tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, H - FOOTER_H - 12); String shown = cLabQrPayload; if (shown.length() > 48) shown = shown.substring(0, 48); tft.print(shown);
  footer("FN BACK TO C LAB");
}
void drawCLab() { if (cInputActive) { drawCLabInput(); return; } if (cLabGuideVisible) { drawCLabGuide(); return; } if (cLabQrActive) { drawCLabQR(); return; } tft.fillScreen(ui.bg); header("C LAB"); drawCLabEditor(); int outputY = CONTENT_Y + 111; tft.setTextColor(ui.dim, ui.bg); tft.setCursor(8, outputY); tft.print("OUTPUT"); tft.drawFastHLine(6, outputY + 9, 308, ui.dim); for (int i = 0; i < cOutputCount; i++) { tft.setTextColor(i == cOutputCount - 1 && cOutput[i].startsWith("ERR") ? ILI9341_RED : ILI9341_GREEN, ui.bg); tft.setCursor(10, outputY + 15 + i * 11); tft.print(cOutput[i]); } footer(cNavigationMode ? "NAV: ; UP . DOWN , LEFT / RIGHT   CTRL+G GUIDE" : "TAB NAV  CTRL+D DEMO  CTRL+G GUIDE"); }

void updateSettingsRow(int i) { int y = CONTENT_Y + 10 + i * 34; bool sel = i == settingSelected; tft.fillRect(8, y - 5, 304, 27, ui.bg); uint16_t fill = sel ? ui.selected : ui.bg; if (sel) tft.fillRoundRect(8, y - 5, 304, 27, 4, fill); tft.setTextSize(1); tft.setTextColor(ui.text, fill); tft.setCursor(16, y); tft.print(sel ? "> " : "  "); tft.print(settingNames[i]); tft.setTextColor(sel ? ui.accent : ui.dim, fill); tft.setCursor(165, y); tft.print(settingValue(i)); }
String settingValue(int i) { if (i == 0) return themeNames[themeIndex]; if (i == 1) return displayRotation == 3 ? "LANDSCAPE RIGHT" : "LANDSCAPE LEFT"; if (i == 2) return backlightOn ? "ON" : "OFF"; if (i == 3) return String(brightnessLevel * 10) + "%"; if (i == 4) return sleepValues[sleepIndex] == 0 ? "NEVER" : String(sleepValues[sleepIndex]) + " SEC"; return String(volumeLevel * 10) + "%"; }
void drawSettings() { tft.fillScreen(ui.bg); header("SETTINGS"); tft.setTextSize(1); for (int i = 0; i < 6; i++) { int y = CONTENT_Y + 10 + i * 34; bool sel = i == settingSelected; uint16_t fill = sel ? ui.selected : ui.bg; if (sel) tft.fillRoundRect(8, y - 5, 304, 27, 4, fill); tft.setTextColor(ui.text, fill); tft.setCursor(16, y); tft.print(sel ? "> " : "  "); tft.print(settingNames[i]); tft.setTextColor(sel ? ui.accent : ui.dim, fill); tft.setCursor(165, y); tft.print(settingValue(i)); } footer(";/. SELECT     ,/ CHANGE     FN BACK"); }
void draw() { if (page == LAUNCHER) drawLauncher(); else if (page == SYSTEM) drawSystem(); else if (page == WIFI) drawWifi(); else if (page == NOTES) drawNotes(); else if (page == CLOCK) drawClock(); else if (page == CALC) drawCalc(); else if (page == CLAB) drawCLab(); else if (page == QRTEXT) drawQRText(); else drawSettings(); redrawNeeded = false; updateBuiltinDisplay(true); }
void startScan() { if (scanRunning) return; WiFi.mode(WIFI_STA); WiFi.disconnect(); delay(100); WiFi.scanDelete(); WiFi.scanNetworks(true); scanRunning = true; scanDone = false; scanStarted = millis(); redrawNeeded = true; }
void checkScan() { if (!scanRunning) return; int r = WiFi.scanComplete(); if (r >= 0) { networkCount = r; scanRunning = false; scanDone = true; redrawNeeded = true; } else if (millis() - scanStarted > 20000UL) { WiFi.scanDelete(); networkCount = 0; scanRunning = false; scanDone = true; redrawNeeded = true; } }
void calcResult() { float v = calcInput.toFloat(); if (calcOp == 0) calcTotal = v; else if (calcOp == '+') calcTotal += v; else if (calcOp == '-') calcTotal -= v; else if (calcOp == '*') calcTotal *= v; else if (calcOp == '/') { if (v == 0) { calcStatus = "Error: division by zero"; calcInput = "0"; calcOp = 0; calcNew = true; updateCalcPanel(); return; } calcTotal /= v; } calcInput = String(calcTotal, 4); while (calcInput.endsWith("0")) calcInput.remove(calcInput.length() - 1); if (calcInput.endsWith(".")) calcInput.remove(calcInput.length() - 1); calcStatus = "Result"; calcOp = 0; calcNew = true; updateCalcPanel(); }
void changeSetting(int d) { if (settingSelected == 0) { themeIndex = (themeIndex + d + THEME_COUNT) % THEME_COUNT; applyTheme(); } else if (settingSelected == 1) { displayRotation = displayRotation == 3 ? 1 : 3; tft.setRotation(displayRotation); } else if (settingSelected == 2) setBacklight(!backlightOn); else if (settingSelected == 3) { brightnessLevel = (brightnessLevel + d + 9) % 10 + 1; applyBacklight(); } else if (settingSelected == 4) sleepIndex = (sleepIndex + d + 5) % 5; else { volumeLevel = (volumeLevel + d + 11) % 11; applyVolume(); } markStateDirty(); redrawNeeded = true; }
void syncLauncherScroll() { if (appSelected < appScroll) appScroll = appSelected; if (appSelected >= appScroll + APP_VISIBLE) appScroll = appSelected - APP_VISIBLE + 1; appScroll = constrain(appScroll, 0, max(0, APP_COUNT - APP_VISIBLE)); }
void clabBackspace() {
  bool changed = false, merged = false;
  if (cCursorColumn > 0) {
    cLines[cCursorLine].remove(cCursorColumn - 1, 1);
    cCursorColumn--;
    changed = true;
  } else if (cCursorLine > 0) {
    int oldLength = cLines[cCursorLine - 1].length();
    if (oldLength + (int)cLines[cCursorLine].length() <= C_MAX_LINE_CHARS) {
      cLines[cCursorLine - 1] += cLines[cCursorLine];
      for (int i = cCursorLine; i < cLineCount - 1; i++) cLines[i] = cLines[i + 1];
      cLineCount--;
      cLines[cLineCount] = "";
      cCursorLine--;
      cCursorColumn = oldLength;
      if (cCursorLine < cScrollLine) cScrollLine = cCursorLine;
      cHorizontalScroll = max(0, cCursorColumn - C_CODE_COLUMNS_VISIBLE + 1);
      changed = true;
      merged = true;
    }
  }
  if (!changed) return;
  playBackspaceSound();
  int maxHorizontalScroll = max(0, (int)cLines[cCursorLine].length() - C_CODE_COLUMNS_VISIBLE + 1);
  cHorizontalScroll = constrain(cHorizontalScroll, 0, maxHorizontalScroll);
  if (merged) drawCLabEditor(); else drawCLabCodeLine(cCursorLine);
  markStateDirty();
}
void moveCLabCursor(int direction) {
  int oldLine = cCursorLine, oldScroll = cScrollLine;
  if (direction == -2 && cCursorLine > 0) cCursorLine--; else if (direction == 2 && cCursorLine < cLineCount - 1) cCursorLine++; else if (direction == -1 && cCursorColumn > 0) cCursorColumn--; else if (direction == 1 && cCursorColumn < (int)cLines[cCursorLine].length()) cCursorColumn++; else return;
  cCursorColumn = min(cCursorColumn, (int)cLines[cCursorLine].length()); if (cCursorLine < cScrollLine) cScrollLine = cCursorLine; if (cCursorLine >= cScrollLine + C_CODE_VISIBLE_LINES) cScrollLine = cCursorLine - C_CODE_VISIBLE_LINES + 1;
  int maxHorizontalScroll = max(0, (int)cLines[cCursorLine].length() - C_CODE_COLUMNS_VISIBLE + 1);
  if (cCursorColumn < cHorizontalScroll) cHorizontalScroll = cCursorColumn;
  if (cCursorColumn >= cHorizontalScroll + C_CODE_COLUMNS_VISIBLE) cHorizontalScroll = cCursorColumn - C_CODE_COLUMNS_VISIBLE + 1;
  cHorizontalScroll = constrain(cHorizontalScroll, 0, maxHorizontalScroll);
  playCursorSound();
  if (page == CLAB && !sleeping) { if (oldScroll != cScrollLine) drawCLabEditor(); else { drawCLabCodeLine(oldLine); drawCLabCodeLine(cCursorLine); } } else redrawNeeded = true;
}
void handleCLabCursorKeys() {
  static int lastDirection = 0;
  static unsigned long nextMoveAt = 0;
  if (page != CLAB || sleeping || cLabGuideVisible) { lastDirection = 0; return; }
  auto k = M5Cardputer.Keyboard.keysState();
  if (!k.ctrl && !cNavigationMode) { lastDirection = 0; return; }
  int direction = 0;
  if (M5Cardputer.Keyboard.isKeyPressed(';') || M5Cardputer.Keyboard.isKeyPressed('w')) direction = -2;
  else if (M5Cardputer.Keyboard.isKeyPressed('.') || M5Cardputer.Keyboard.isKeyPressed('s')) direction = 2;
  else if (M5Cardputer.Keyboard.isKeyPressed(',') || M5Cardputer.Keyboard.isKeyPressed('a')) direction = -1;
  else if (M5Cardputer.Keyboard.isKeyPressed('/') || M5Cardputer.Keyboard.isKeyPressed('d')) direction = 1;
  if (direction == 0) { lastDirection = 0; return; }
  unsigned long now = millis();
  if (direction != lastDirection || now >= nextMoveAt) {
    bool firstPress = direction != lastDirection;
    moveCLabCursor(direction);
    lastDirection = direction;
    nextMoveAt = now + (firstPress ? 180UL : 70UL);
  }
}

// Helper: check if a char exists in the std::vector<char> word list
static bool wordContains(const std::vector<char>& word, char c) {
  for (char ch : word) { if (ch == c) return true; }
  return false;
}

void keyboard() {
  auto k = M5Cardputer.Keyboard.keysState(); bool event = M5Cardputer.Keyboard.isChange(), fn = k.fn;
  if (sleeping) { if (event || fn != fnLast) { sleeping = false; setBacklight(true); lastActivity = millis(); redrawNeeded = true; } fnLast = fn; return; }
  if (event || fn != fnLast) lastActivity = millis(); if (fn && !fnLast && page != LAUNCHER) { playExitSound(); if (page == CLAB && cInputActive) { cInputActive = false; cInputValueCount = 0; cInputReadIndex = 0; cInputBuffer = ""; page = LAUNCHER; } else if (page == CLAB && cLabGuideVisible) cLabGuideVisible = false; else if (page == CLAB && cLabQrActive) cLabQrActive = false; else page = LAUNCHER; redrawNeeded = true; } fnLast = fn;
  if (!event || !M5Cardputer.Keyboard.isPressed()) return;
  if (k.ctrl || k.shift || k.alt) playFunctionSound();
  if (page == CLAB && k.tab && !cLabGuideVisible) { playTabSound(); cNavigationMode = !cNavigationMode; redrawNeeded = true; return; }
  bool ctrlG = k.ctrl && (wordContains(k.word, 'g') || wordContains(k.word, 'G') || M5Cardputer.Keyboard.isKeyPressed('g') || M5Cardputer.Keyboard.isKeyPressed('G'));
  if (page == CLAB && ctrlG) { cLabGuideVisible = !cLabGuideVisible; playFunctionSound(); redrawNeeded = true; return; }
  if (page == CLAB && cLabGuideVisible) return;
  if (page == CLAB && cInputActive) {
    if (k.enter) { if (cInputValueCount < 4) cInputValues[cInputValueCount++] = cInputBuffer; playEnterSound(); runCLab(); return; }
    if (k.del) { if (!cInputBuffer.isEmpty()) { cInputBuffer.remove(cInputBuffer.length() - 1); playBackspaceSound(); drawCLabInput(); } return; }
    bool changed = false; for (char c : k.word) if (c >= 32 && c <= 126 && cInputBuffer.length() < 48) { cInputBuffer += c; changed = true; }
    if (changed) { playTypingSound(); drawCLabInput(); } return;
  }
  bool ctrlD = k.ctrl && (wordContains(k.word, 'd') || wordContains(k.word, 'D') || M5Cardputer.Keyboard.isKeyPressed('d') || M5Cardputer.Keyboard.isKeyPressed('D'));
  if (page == CLAB && ctrlD) { loadCLabDemo(); return; }
  bool ctrlL = k.ctrl && (wordContains(k.word, 'l') || wordContains(k.word, 'L') || M5Cardputer.Keyboard.isKeyPressed('l') || M5Cardputer.Keyboard.isKeyPressed('L'));
  if (ctrlL) { if (page == NOTES) { for (int i = 0; i < 15; ++i) notes[i] = ""; noteLine = 0; noteCount = 1; markStateDirty(); playFunctionSound(); redrawNeeded = true; return; } if (page == CLAB) { for (int i = 0; i < C_MAX_LINES; ++i) cLines[i] = ""; cLineCount = 1; cCursorLine = cCursorColumn = cScrollLine = cHorizontalScroll = 0; cOutputCount = 0; markStateDirty(); playFunctionSound(); redrawNeeded = true; return; } }
  if (page == CLAB && k.ctrl) { if (k.enter) { cInputValueCount = 0; cLabQrActive = false; cLabQrPayload = ""; playEnterSound(); runCLab(); } return; }
  if (page == LAUNCHER) { if (k.enter) { playMenuSound(); page = static_cast<Page>(appSelected + 1); redrawNeeded = true; return; } for (char c : k.word) { if (c == ',' || c == ';') { int oldSelected = appSelected, oldScroll = appScroll; appSelected = (appSelected + APP_COUNT - 1) % APP_COUNT; syncLauncherScroll(); playMenuSound(); updateLauncherSelection(oldSelected, oldScroll); } else if (c == '/' || c == '.') { int oldSelected = appSelected, oldScroll = appScroll; appSelected = (appSelected + 1) % APP_COUNT; syncLauncherScroll(); playMenuSound(); updateLauncherSelection(oldSelected, oldScroll); } } return; }
  if (page == SYSTEM) { if (k.enter) { playMenuSound(); updateSystemValues(); } return; }
  if (page == WIFI) { if (k.enter) { playMenuSound(); startScan(); } return; }
  if (page == CLOCK) return;
  if (page == QRTEXT) { if (k.enter) { playEnterSound(); markStateDirty(); drawQRModules(); return; } if (k.del) { if (!qrText.isEmpty()) { qrText.remove(qrText.length() - 1); markStateDirty(); playBackspaceSound(); drawQRTextField(); } return; } for (char c : k.word) if (c >= 32 && c <= 126 && qrText.length() < QR_MAX_TEXT) { qrText += c; markStateDirty(); playTypingSound(); drawQRTextField(); } return; }
  if (page == CLAB) {
    if (cNavigationMode) return;
    if (k.enter) { playEnterSound(); if (cLineCount < C_MAX_LINES) { String tail = cLines[cCursorLine].substring(cCursorColumn); cLines[cCursorLine].remove(cCursorColumn); for (int i = cLineCount; i > cCursorLine + 1; i--) cLines[i] = cLines[i - 1]; cLines[cCursorLine + 1] = tail; cLineCount++; cCursorLine++; cCursorColumn = 0; cHorizontalScroll = 0; markStateDirty(); if (cCursorLine >= cScrollLine + C_CODE_VISIBLE_LINES) cScrollLine++; drawCLabEditor(); } return; }
    if (k.del) return;
    bool codeChanged = false; for (char c : k.word) if (c >= 32 && c <= 126 && (int)cLines[cCursorLine].length() < C_MAX_LINE_CHARS) { cLines[cCursorLine] = cLines[cCursorLine].substring(0, cCursorColumn) + c + cLines[cCursorLine].substring(cCursorColumn); cCursorColumn++; codeChanged = true; }
      if (codeChanged) { int maxHorizontalScroll = max(0, (int)cLines[cCursorLine].length() - C_CODE_COLUMNS_VISIBLE + 1); if (cCursorColumn >= cHorizontalScroll + C_CODE_COLUMNS_VISIBLE) cHorizontalScroll = cCursorColumn - C_CODE_COLUMNS_VISIBLE + 1; cHorizontalScroll = constrain(cHorizontalScroll, 0, maxHorizontalScroll); playTypingSound(); drawCLabEditor(); markStateDirty(); } return;
  }
  if (page == NOTES) { if (k.enter) { if (noteLine < 14) { if (noteLine == noteCount - 1) noteCount++; noteLine++; markStateDirty(); redrawNeeded = true; } return; } if (k.del) { if (!notes[noteLine].isEmpty()) { notes[noteLine].remove(notes[noteLine].length() - 1); playTypingSound(); } else if (noteLine > 0) { noteLine--; noteCount--; } markStateDirty(); updateNotesLine(); return; } for (char c : k.word) if (c >= 32 && c <= 126 && notes[noteLine].length() < 50) { notes[noteLine] += c; playTypingSound(); markStateDirty(); updateNotesLine(); } return; }
  if (page == CALC) { if (k.enter) { playMenuSound(); calcResult(); return; } if (k.del) { calcInput = "0"; calcTotal = 0; calcOp = 0; calcNew = true; calcStatus = "Cleared"; updateCalcPanel(); return; } for (char c : k.word) { if ((c >= '0' && c <= '9') || c == '.') { if (calcNew) { calcInput = ""; calcNew = false; } if (calcInput.length() < 16 && !(c == '.' && calcInput.indexOf('.') >= 0)) { calcInput += c; playTypingSound(); updateCalcPanel(); } } else if (c == '+' || c == '-' || c == '*' || c == '/') { if (calcOp != 0 && !calcNew) calcResult(); calcTotal = calcInput.toFloat(); calcOp = c; calcNew = true; calcStatus = String("Pending operator: ") + c; playMenuSound(); updateCalcPanel(); } } return; }
  if (page == SETTINGS) for (char c : k.word) { if (c == ';') { int old = settingSelected; settingSelected = (settingSelected + 5) % 6; playMenuSound(); updateSettingsRow(old); updateSettingsRow(settingSelected); } else if (c == '.') { int old = settingSelected; settingSelected = (settingSelected + 1) % 6; playMenuSound(); updateSettingsRow(old); updateSettingsRow(settingSelected); } else if (c == ',') { playMenuSound(); changeSetting(-1); } else if (c == '/') { playMenuSound(); changeSetting(1); } }
}
void setup() {
  auto cfg = M5.config(); M5Cardputer.begin(cfg, true); loadPersistentState(); applyVolume();
  ledcSetup(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_PWM_HZ, BACKLIGHT_PWM_BITS);
  ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CHANNEL);
  applyBacklight();
  SPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS); tft.begin(); tft.setRotation(displayRotation); tft.setTextWrap(false); applyTheme(); drawBootScreen(); lastActivity = millis(); draw();
}
void loop() {
  M5Cardputer.update(); checkScan(); keyboard();
  auto keys = M5Cardputer.Keyboard.keysState();
  bool clabDeleteNow = page == CLAB && !sleeping && M5Cardputer.Keyboard.isPressed() && keys.del;
  if (clabDeleteNow) {
    unsigned long now = millis();
    if (!clabDeleteHeld) {
      clabBackspace();
      clabDeleteHeld = true;
      clabDeleteRepeatAt = now + 500UL;
    } else if (now >= clabDeleteRepeatAt) {
      clabBackspace();
      clabDeleteRepeatAt = now + 90UL;
    }
  } else {
    clabDeleteHeld = false;
  }
  handleCLabCursorKeys(); uint16_t timeout = sleepValues[sleepIndex];
  if (timeout && !sleeping && millis() - lastActivity >= (unsigned long)timeout * 1000UL) { sleeping = true; setBacklight(false); }
  static unsigned long lastClock = 0; if (page == CLOCK && !sleeping && millis() - lastClock >= 1000UL) { lastClock = millis(); updateClockValue(); }
  static unsigned long lastBuiltinCheck = 0;
  if (!sleeping && millis() - lastBuiltinCheck >= 250UL) { lastBuiltinCheck = millis(); updateBuiltinDisplay(); }
  if (redrawNeeded && !sleeping) draw(); if (stateDirty && millis() - stateChangedAt >= 2000UL) savePersistentState();
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik