#pragma once #include #include #include "config.h" #include "led_ui.h" inline uint16_t resolveColor(const char* name) { if (strcmp(name, "red") == 0) return TFT_RED; if (strcmp(name, "green") == 0) return TFT_GREEN; if (strcmp(name, "blue") == 0) return TFT_BLUE; if (strcmp(name, "yellow") == 0) return TFT_YELLOW; if (strcmp(name, "cyan") == 0) return TFT_CYAN; if (strcmp(name, "magenta") == 0) return TFT_MAGENTA; if (strcmp(name, "orange") == 0) return TFT_ORANGE; return TFT_WHITE; } // All rendering for the AtomS3's 128x128 LCD. On a screenless AtomS3 Lite // (universal binary, detected at boot) every public method gates on // _present and forwards a semantic state to LedUI instead — macro_engine.h // and MacroPad.ino call the same methods on both boards and never branch. class DisplayUI { public: void begin(uint8_t orientation, bool present = true, LedUI* led = nullptr) { _present = present; _led = led; if (!_present) return; // no panel: M5.Display must not be touched M5.Display.setRotation(orientation); M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); } bool present() const { return _present; } void setOrientation(uint8_t orientation) { // Accepted-but-ignored on the Lite so the host's orientation // setting never errors against a screenless device. if (!_present) return; M5.Display.setRotation(orientation); } // ========================================================================= // Macro selector / idle screens // ========================================================================= void showMacroSelector(int slot, const char* name, int current, int total, uint16_t textColor = TFT_WHITE, bool bleMode = false) { if (!_present) { if (_led) _led->macroSelector(current, bleMode); return; } M5.Display.fillScreen(TFT_BLACK); char path[32]; snprintf(path, sizeof(path), "/m%d/icon.raw", slot); if (LittleFS.exists(path)) { drawRawImageBanner(path); int barH = 20; M5.Display.fillRect(0, SCREEN_H - barH, SCREEN_W, barH, TFT_BLACK); M5.Display.setTextColor(textColor, TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); setFontForSize(8); M5.Display.drawString(name, SCREEN_W / 2, SCREEN_H - barH / 2); } else { M5.Display.setTextColor(textColor, TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); setFontForSize(16); drawWrapped(name, SCREEN_W / 2, SCREEN_H / 2 - 20, SCREEN_W - 8, 16, 4, true); } } // Full-screen live-mode display, shown while a host is connected over // the live-keystroke channel. ``status`` is a short string the main // loop updates as the BLE state machine changes (connected, recording). // The host drives the whole session; the only on-device action is a // long-press to override into running the selected routine. void showLiveMode(const char* status, const char* label = nullptr) { if (!_present) { if (_led) _led->liveIdle(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); if (label && label[0]) { // Host gave this device a friendly label — show it prominently // so the user can tell which physical M5Stack this is. M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(12); drawWrapped(label, SCREEN_W / 2, 22, SCREEN_W - 8, 12, 2, true); M5.Display.setTextColor(TFT_RED, TFT_BLACK); setFontForSize(8); M5.Display.drawString("LIVE MODE", SCREEN_W / 2, 52); } else { M5.Display.setTextColor(TFT_RED, TFT_BLACK); setFontForSize(12); M5.Display.drawString("LIVE MODE", SCREEN_W / 2, 20); } M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(10); drawWrapped(status, SCREEN_W / 2, SCREEN_H / 2 + 12, SCREEN_W - 8, 10, 2, true); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Hold: run routine", SCREEN_W / 2, SCREEN_H - 12); } // Static Bluetooth "identify" logo. Drawn while the host is asking the // user to label this specific device in the BT Keyboard window, so the // user can tell which physical M5Stack is selected. The classic angular // Bluetooth rune: a vertical spine with two crossing diagonals out to // the right knees. void showBluetoothLogo() { if (!_present) { if (_led) _led->identify(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); const int cx = SCREEN_W / 2; const int cy = SCREEN_H / 2 - 8; // nudge up to leave room for caption const int hv = 34; // half-height (top/bottom from center) const int q = hv / 2; // quarter offset (knee height) const int hw = 22; // horizontal knee extent const uint16_t BT_BLUE = TFT_CYAN; const float w = 5.0f; // line width // Spine M5.Display.drawWideLine(cx, cy - hv, cx, cy + hv, w, BT_BLUE); // Upper: top tip -> upper-right knee -> lower-left (crosses spine) M5.Display.drawWideLine(cx, cy - hv, cx + hw, cy - q, w, BT_BLUE); M5.Display.drawWideLine(cx + hw, cy - q, cx - hw, cy + q, w, BT_BLUE); // Lower: bottom tip -> lower-right knee -> upper-left (crosses spine) M5.Display.drawWideLine(cx, cy + hv, cx + hw, cy + q, w, BT_BLUE); M5.Display.drawWideLine(cx + hw, cy + q, cx - hw, cy - q, w, BT_BLUE); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Label this one", SCREEN_W / 2, SCREEN_H - 9); } // Shown on boot when we lost power mid live-session and are immediately // re-advertising so the host reconnects. A button hold cancels. void showReconnecting() { if (!_present) { if (_led) _led->reconnecting(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(12); M5.Display.drawString("Reconnecting", SCREEN_W / 2, SCREEN_H / 2 - 16); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(8); M5.Display.drawString("to host...", SCREEN_W / 2, SCREEN_H / 2 + 4); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Hold to cancel", SCREEN_W / 2, SCREEN_H - 12); } // modeLabel (optional) names the persisted live transport ("Mesh" / // "BLE") so the user can see, at a glance on boot, which mode this // device is in. Passed nullptr to omit it. void showBoot(const char* modeLabel = nullptr) { if (!_present) { if (_led) _led->boot(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(14); M5.Display.drawString("MacroPad", SCREEN_W / 2, SCREEN_H / 2 - 18); setFontForSize(10); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); M5.Display.drawString(FW_VERSION, SCREEN_W / 2, SCREEN_H / 2 + 2); if (modeLabel) { setFontForSize(8); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); M5.Display.drawString(modeLabel, SCREEN_W / 2, SCREEN_H - 14); } } // Brief confirmation shown when the user toggles the live transport with // a long button hold. ``ble`` picks the color/name of the new mode. void showModeSwitch(bool ble) { if (!_present) { if (_led) _led->modeSwitch(ble); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(10); M5.Display.drawString("Live mode", SCREEN_W / 2, SCREEN_H / 2 - 18); M5.Display.setTextColor(ble ? TFT_BLUE : TFT_CYAN, TFT_BLACK); setFontForSize(14); M5.Display.drawString(ble ? "Bluetooth" : "ESP-NOW", SCREEN_W / 2, SCREEN_H / 2 + 6); } void showExecuting(const char* name) { if (!_present) { if (_led) _led->executing(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(10); M5.Display.drawString("Running...", SCREEN_W / 2, 10); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(14); drawWrapped(name, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 3, true); } void showMessage(const char* msg, uint16_t color = TFT_WHITE) { if (!_present) { if (_led) _led->message(color); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextColor(color, TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); setFontForSize(12); drawWrapped(msg, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 12, 4, true); } // ========================================================================= // Pause screen // ========================================================================= // Pause screen — text-box geometry is driven by the four host-configurable // margins so users can tune wrapping/truncation without re-flashing. // marginL/R → text width = SCREEN_W - L - R // marginT/B → text vertical band = [marginT, SCREEN_H - marginB) // The progress bar (when timed) sits inside the bottom margin so the // wrapping box never overlaps it. void showPauseScreen(const char* text, int fontSize, bool timed, uint32_t remainMs, uint32_t totalMs, uint16_t textColor = TFT_WHITE, uint8_t marginL = DEFAULT_PAUSE_MARGIN_LEFT, uint8_t marginR = DEFAULT_PAUSE_MARGIN_RIGHT, uint8_t marginT = DEFAULT_PAUSE_MARGIN_TOP, uint8_t marginB = DEFAULT_PAUSE_MARGIN_BOTTOM) { if (!_present) { if (_led) _led->pauseScreen(timed, remainMs); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextColor(textColor, TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); setFontForSize(fontSize); int boxW = SCREEN_W - marginL - marginR; if (boxW < 16) boxW = 16; // sanity floor int boxTop = marginT; int boxBot = SCREEN_H - marginB; int boxH = boxBot - boxTop; if (boxH < fontSize + 4) boxH = fontSize + 4; int cx = marginL + boxW / 2; int cy = boxTop + boxH / 2; // Compute how many lines fit so truncation respects the margins. int lineH = fontSize + 6; int maxLines = boxH / lineH; if (maxLines < 1) maxLines = 1; if (maxLines > 12) maxLines = 12; drawWrapped(text, cx, cy, boxW, fontSize, maxLines, true); if (timed && totalMs > 0) { // Place the progress bar inside the bottom margin (so the text // band above it stays clear). Bar gets at most marginB - 2 px // of vertical room. int barH = marginB - 2; if (barH < 2) barH = 2; if (barH > 8) barH = 8; int barY = SCREEN_H - barH - 1; int barW = (int)((float)remainMs / totalMs * boxW); if (barW < 0) barW = 0; M5.Display.fillRect(marginL, barY, boxW, barH, TFT_DARKGREY); M5.Display.fillRect(marginL, barY, barW, barH, TFT_CYAN); } } // ========================================================================= // Iteration Branch (path selected by current loop iteration) // ========================================================================= void showIterationBranch(int iter, const char* label, int pathIdx, int numPaths) { if (!_present) { if (_led) _led->iterationBranch(pathIdx); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Iteration Branch", SCREEN_W / 2, 10); // Iteration number (highlighted, middle-sized) char ibuf[24]; snprintf(ibuf, sizeof(ibuf), "Iteration %d", iter); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(10); M5.Display.drawString(ibuf, SCREEN_W / 2, 32); // Path label (most prominent) M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(14); drawWrapped(label, SCREEN_W / 2, SCREEN_H / 2 + 8, SCREEN_W - 8, 14, 2, true); // Path indicator at bottom char pbuf[24]; snprintf(pbuf, sizeof(pbuf), "Path %d / %d", pathIdx + 1, numPaths); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString(pbuf, SCREEN_W / 2, SCREEN_H - 10); } // ========================================================================= // Loop Start Selector (phase 2 — user picks which iteration to start at) // ========================================================================= void showLoopStartSelector(const char* prompt, int current, int mn, int mx, int totalCount) { if (!_present) { if (_led) _led->loopSelector(current); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); // Green header to distinguish from the count-selector (which is yellow) M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Start Iteration", SCREEN_W / 2, 10); // Subheader showing total count so user knows the range context char ctxBuf[32]; snprintf(ctxBuf, sizeof(ctxBuf), "(of %d)", totalCount); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString(ctxBuf, SCREEN_W / 2, 26); // Prompt text if (prompt && prompt[0]) { M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(8); drawWrapped(prompt, SCREEN_W / 2, 44, SCREEN_W - 8, 8, 2, true); } // Large current value char buf[16]; snprintf(buf, sizeof(buf), "%d", current); M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(20); M5.Display.drawString(buf, SCREEN_W / 2, SCREEN_H / 2 + 10); // Progress bar int span = mx - mn; if (span > 0) { int barX = 8; int barY = SCREEN_H - 32; int barW = SCREEN_W - 16; int barH = 3; M5.Display.fillRect(barX, barY, barW, barH, TFT_DARKGREY); int fillW = (int)((float)(current - mn) / span * barW); if (fillW < 0) fillW = 0; if (fillW > barW) fillW = barW; M5.Display.fillRect(barX, barY, fillW, barH, TFT_GREEN); } // Range label char rangeBuf[24]; snprintf(rangeBuf, sizeof(rangeBuf), "%d - %d", mn, mx); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString(rangeBuf, SCREEN_W / 2, SCREEN_H - 22); // Footer M5.Display.setTextColor(0x7BCF, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Click:+ Hold:OK", SCREEN_W / 2, SCREEN_H - 10); } // ========================================================================= // Loop Selector (user picks a count via button) // ========================================================================= void showLoopSelector(const char* prompt, int current, int mn, int mx) { if (!_present) { if (_led) _led->loopSelector(current); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); // Header M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Loop Count", SCREEN_W / 2, 10); // Prompt (small text below header) if (prompt && prompt[0]) { M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(8); drawWrapped(prompt, SCREEN_W / 2, 30, SCREEN_W - 8, 8, 2, true); } // Large current value in the center char buf[16]; snprintf(buf, sizeof(buf), "%d", current); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(20); M5.Display.drawString(buf, SCREEN_W / 2, SCREEN_H / 2 + 4); // Progress bar showing position within range int span = mx - mn; if (span > 0) { int barX = 8; int barY = SCREEN_H - 32; int barW = SCREEN_W - 16; int barH = 3; M5.Display.fillRect(barX, barY, barW, barH, TFT_DARKGREY); int fillW = (int)((float)(current - mn) / span * barW); if (fillW < 0) fillW = 0; if (fillW > barW) fillW = barW; M5.Display.fillRect(barX, barY, fillW, barH, TFT_CYAN); } // Range label char rangeBuf[24]; snprintf(rangeBuf, sizeof(rangeBuf), "%d - %d", mn, mx); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString(rangeBuf, SCREEN_W / 2, SCREEN_H - 22); // Footer hint M5.Display.setTextColor(0x7BCF, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Click:+ Hold:OK", SCREEN_W / 2, SCREEN_H - 10); } // ========================================================================= // Branch selector // ========================================================================= // Branch selector with scrolling + per-choice colors. // // ``colors`` may be nullptr — falls back to white for every choice. // ``scrollOffset`` is the index of the first visible row; the engine // adjusts it so the selected row stays inside the visible band. // // Layout: at item height 24 the 128px screen fits ~5 rows. When count // exceeds the visible window we draw a small triangle at the top and/or // bottom edge to indicate more items above/below, plus a 2px scroll // ribbon on the right side that shows the proportion / position of the // viewport within the full list. void showBranchSelector(const char* choices[], const uint16_t* colors, int count, int selected, int scrollOffset = 0) { if (!_present) { if (_led) _led->branchSelector(selected); return; } M5.Display.fillScreen(TFT_BLACK); setFontForSize(12); const int itemH = 24; int visible = SCREEN_H / itemH; // 128 / 24 = 5 if (visible < 1) visible = 1; if (visible > count) visible = count; // Sanity-clamp the scroll offset so first..first+visible-1 is in range. if (scrollOffset < 0) scrollOffset = 0; if (scrollOffset > count - visible) scrollOffset = count - visible; // Whether we'll need scroll affordances on the side. bool hasMore = (count > visible); const int ribbonW = hasMore ? 3 : 0; const int rightEdge = SCREEN_W - ribbonW; int startY = (SCREEN_H - visible * itemH) / 2; if (startY < 0) startY = 0; for (int row = 0; row < visible; row++) { int idx = scrollOffset + row; if (idx < 0 || idx >= count) continue; int y = startY + row * itemH; uint16_t fg = (colors != nullptr) ? colors[idx] : TFT_WHITE; if (idx == selected) { // Selected: filled in the choice's color, text inverted to // black so it stays legible regardless of which color the // user picked. M5.Display.fillRect(0, y, rightEdge, itemH, fg); M5.Display.setTextColor(TFT_BLACK, fg); } else { M5.Display.setTextColor(fg, TFT_BLACK); } // Center the text within the row, leaving room for the scroll // ribbon on the right when present. M5.Display.drawString(choices[idx], rightEdge / 2, y + itemH / 2); } if (hasMore) { // Up/down arrow indicators at the very top/bottom of the list // band, drawn as small filled triangles inside the right ribbon. int rx = SCREEN_W - 1; // Background ribbon (faint), then thumb (bright) sized to the // visible fraction. M5.Display.fillRect(rx - ribbonW + 1, startY, ribbonW, visible * itemH, TFT_DARKGREY); int thumbH = (visible * (visible * itemH)) / count; if (thumbH < 4) thumbH = 4; int thumbY = startY + (scrollOffset * (visible * itemH)) / count; M5.Display.fillRect(rx - ribbonW + 1, thumbY, ribbonW, thumbH, TFT_CYAN); if (scrollOffset > 0) { M5.Display.fillTriangle(2, startY + 4, 9, startY + 4, 5, startY - 2, TFT_CYAN); } if (scrollOffset + visible < count) { int by = startY + visible * itemH; M5.Display.fillTriangle(2, by - 4, 9, by - 4, 5, by + 2, TFT_CYAN); } } } // ========================================================================= // Per-node execution displays // ========================================================================= // --- Text node: scrolling ribbon --- // Called once per character as text is typed. // charIdx = index of character currently being typed (0-based). void showTextRibbon(const char* fullText, int charIdx) { int len = strlen(fullText); if (len == 0) return; if (!_present) { if (_led) _led->typing(charIdx, len); return; } M5.Display.fillScreen(TFT_BLACK); // Header M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Type Text", SCREEN_W / 2, 12); // Progress fraction text char prog[16]; snprintf(prog, sizeof(prog), "%d/%d", charIdx + 1, len); M5.Display.drawString(prog, SCREEN_W / 2, SCREEN_H - 12); // Small progress bar at very bottom int barY = SCREEN_H - 4; int barFill = (len > 1) ? (int)((float)charIdx / (len - 1) * (SCREEN_W - 8)) : (SCREEN_W - 8); M5.Display.fillRect(4, barY, SCREEN_W - 8, 3, TFT_DARKGREY); M5.Display.fillRect(4, barY, barFill, 3, TFT_GREEN); // Ribbon: large current char in center, smaller surrounding chars int ribbonY = SCREEN_H / 2; int centerX = SCREEN_W / 2; // Measure character widths for both font sizes setFontForSize(20); int bigW = M5.Display.textWidth("W"); setFontForSize(8); int smallW = M5.Display.textWidth("W"); // How many small chars fit on each side (from edge of big char to screen edge) int sideSpace = (SCREEN_W / 2) - (bigW / 2) - 4; // 4px padding int halfSlots = sideSpace / smallW + 1; M5.Display.setTextDatum(MC_DATUM); // Draw surrounding chars LEFT of center (right-to-left so closest is drawn last) for (int offset = -halfSlots; offset <= -1; offset++) { int idx = charIdx + offset; if (idx < 0 || idx >= len) continue; char display[2] = { fullText[idx], '\0' }; if (display[0] == '\n' || display[0] == '\r' || display[0] == '\t') display[0] = ' '; else if (display[0] < 32) display[0] = '?'; // Position: small chars packed to the left of the big char int x = centerX - (bigW / 2) - 4 + (offset + 1) * smallW - smallW / 2; if (x < -smallW || x > SCREEN_W + smallW) continue; int dist = abs(offset); uint16_t grey; if (dist <= 1) grey = 0x9CF3; // bright grey else if (dist <= 3) grey = 0x7BCF; // medium grey else if (dist <= 5) grey = 0x52AA; // dim grey else grey = 0x39C7; // dark grey setFontForSize(8); M5.Display.setTextColor(grey, TFT_BLACK); M5.Display.drawString(display, x, ribbonY); } // Draw surrounding chars RIGHT of center (left-to-right) for (int offset = 1; offset <= halfSlots; offset++) { int idx = charIdx + offset; if (idx < 0 || idx >= len) continue; char display[2] = { fullText[idx], '\0' }; if (display[0] == '\n' || display[0] == '\r' || display[0] == '\t') display[0] = ' '; else if (display[0] < 32) display[0] = '?'; int x = centerX + (bigW / 2) + 4 + (offset - 1) * smallW + smallW / 2; if (x < -smallW || x > SCREEN_W + smallW) continue; int dist = abs(offset); uint16_t grey; if (dist <= 1) grey = 0x9CF3; else if (dist <= 3) grey = 0x7BCF; else if (dist <= 5) grey = 0x52AA; else grey = 0x39C7; setFontForSize(8); M5.Display.setTextColor(grey, TFT_BLACK); M5.Display.drawString(display, x, ribbonY); } // Draw current character LAST (on top) in large font with highlight { char display[2] = { fullText[charIdx], '\0' }; if (display[0] == '\n' || display[0] == '\r' || display[0] == '\t') display[0] = ' '; else if (display[0] < 32) display[0] = '?'; setFontForSize(20); M5.Display.fillRect(centerX - bigW / 2 - 2, ribbonY - 16, bigW + 4, 32, 0x1082); M5.Display.setTextColor(TFT_WHITE, 0x1082); M5.Display.drawString(display, centerX, ribbonY); } } // --- Key Combo node --- void showKeyCombo(const char* comboStr) { if (!_present) { if (_led) _led->activityPulse(TFT_YELLOW); return; } M5.Display.fillScreen(TFT_BLACK); // Header M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Key Combo", SCREEN_W / 2, 14); // Combo text, large and centered M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); setFontForSize(14); drawWrapped(comboStr, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 3, true); } // --- Delay node: progress bar with time --- // Called every tick while in ENGINE_DELAY state. void showDelayProgress(uint32_t totalMs, uint32_t remainMs) { if (!_present) { if (_led) _led->breathe(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); // Header M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Delay", SCREEN_W / 2, 14); // Large time remaining in center char timeStr[16]; if (totalMs >= 1000) { float secs = remainMs / 1000.0f; snprintf(timeStr, sizeof(timeStr), "%.1fs", secs); } else { snprintf(timeStr, sizeof(timeStr), "%lums", (unsigned long)remainMs); } M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(20); M5.Display.drawString(timeStr, SCREEN_W / 2, SCREEN_H / 2 - 4); // Large progress bar int barH = 12; int barY = SCREEN_H - 28; int barX = 8; int barMaxW = SCREEN_W - 16; float fraction = (totalMs > 0) ? (float)(totalMs - remainMs) / totalMs : 1.0f; int barFill = (int)(fraction * barMaxW); M5.Display.fillRect(barX, barY, barMaxW, barH, TFT_DARKGREY); M5.Display.fillRect(barX, barY, barFill, barH, TFT_CYAN); M5.Display.drawRect(barX, barY, barMaxW, barH, 0x4208); } // --- Mouse click node --- void showMouseAction(const char* action, const char* button) { if (!_present) { if (_led) _led->activityPulse(TFT_ORANGE); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Mouse", SCREEN_W / 2, 14); char desc[48]; snprintf(desc, sizeof(desc), "%s %s", action, button); M5.Display.setTextColor(TFT_ORANGE, TFT_BLACK); setFontForSize(14); M5.Display.drawString(desc, SCREEN_W / 2, SCREEN_H / 2); } // --- Media key node --- void showMediaKey(const char* action) { if (!_present) { if (_led) _led->activityPulse(TFT_CYAN); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Media Key", SCREEN_W / 2, 14); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(14); M5.Display.drawString(action, SCREEN_W / 2, SCREEN_H / 2); } // --- BLE node: connection state --- void showBLEStatus(const char* msg) { if (!_present) { if (_led) _led->bleStatus(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); setFontForSize(10); M5.Display.drawString("Bluetooth", SCREEN_W / 2, 20); // BLE icon hint M5.Display.setTextColor(TFT_BLUE, TFT_BLACK); setFontForSize(20); M5.Display.drawString("*", SCREEN_W / 2, SCREEN_H / 2 - 8); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(8); drawWrapped(msg, SCREEN_W / 2, SCREEN_H / 2 + 20, SCREEN_W - 8, 8, 3, true); } // --- RS232 node: sending state --- void showRS232Sending(const char* message, int baud) { if (!_present) { if (_led) _led->activityPulse(TFT_GREEN); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); char header[32]; snprintf(header, sizeof(header), "RS232 @ %d", baud); M5.Display.drawString(header, SCREEN_W / 2, 12); M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Sending...", SCREEN_W / 2, 30); // Show truncated message M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(10); drawWrapped(message, SCREEN_W / 2, SCREEN_H / 2 + 8, SCREEN_W - 12, 10, 3, true); } // --- RS232 node: safe to lose power (post-send idle) --- void showRS232SafeIdle(const char* message, int delayMs) { if (!_present) { if (_led) _led->executing(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("RS232 Sent", SCREEN_W / 2, 12); // Big yellow-green "safe" indicator M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(14); M5.Display.drawString("Safe to", SCREEN_W / 2, SCREEN_H / 2 - 14); M5.Display.drawString("lose power", SCREEN_W / 2, SCREEN_H / 2 + 6); // Duration hint char info[32]; snprintf(info, sizeof(info), "Idling %dms", delayMs); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H - 20); // Truncated message M5.Display.setTextColor(0x4208, TFT_BLACK); setFontForSize(8); char msgBuf[24]; snprintf(msgBuf, sizeof(msgBuf), "%.20s", message); M5.Display.drawString(msgBuf, SCREEN_W / 2, SCREEN_H - 10); } // --- RS232 node: waiting for response --- void showRS232Waiting(const char* expected, uint32_t remainMs, int rxBytes) { if (!_present) { if (_led) _led->waiting(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("RS232 Waiting", SCREEN_W / 2, 12); // Timeout remaining char timeStr[24]; snprintf(timeStr, sizeof(timeStr), "Timeout: %.1fs", remainMs / 1000.0f); M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); setFontForSize(8); M5.Display.drawString(timeStr, SCREEN_W / 2, 30); // Bytes received counter char rxStr[24]; snprintf(rxStr, sizeof(rxStr), "RX: %d bytes", rxBytes); M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); M5.Display.drawString(rxStr, SCREEN_W / 2, SCREEN_H / 2); // Expected string (truncated) M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); char expLabel[80]; snprintf(expLabel, sizeof(expLabel), "Expect: %.20s", expected); M5.Display.drawString(expLabel, SCREEN_W / 2, SCREEN_H - 20); } // --- Macro recording playback --- // Shown while a "macro" node is replaying its captured key sequence. void showMacroPlayback(const char* name, int nEvents, uint32_t totalMs) { if (!_present) { if (_led) _led->executing(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Macro", SCREEN_W / 2, 10); const char* displayName = (name && name[0]) ? name : "(unnamed)"; M5.Display.setTextColor(0x3CBE, TFT_BLACK); // teal-ish, matches node color setFontForSize(12); drawWrapped(displayName, SCREEN_W / 2, SCREEN_H / 2 - 6, SCREEN_W - 8, 12, 2, true); char info[40]; float secs = totalMs / 1000.0f; snprintf(info, sizeof(info), "%d evts %.1fs", nEvents, secs); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H - 14); } void showSubroutineCall(const char* name) { if (!_present) { if (_led) _led->activityPulse(TFT_MAGENTA); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Sub-Routine", SCREEN_W / 2, 14); M5.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); setFontForSize(14); drawWrapped(name, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 2, true); } // --- Loop node --- void showLoopStatus(int iteration, int total) { if (!_present) { if (_led) _led->activityPulse(TFT_GREEN); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Loop", SCREEN_W / 2, 14); char info[32]; snprintf(info, sizeof(info), "%d / %d", iteration, total); M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(20); M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H / 2); } // --- PC Alive Check: probing step display --- void showNumLockProbing(const char* phase) { if (!_present) { if (_led) _led->probe(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); setFontForSize(8); M5.Display.drawString("PC Alive Check", SCREEN_W / 2, 14); M5.Display.setTextColor(0x4208, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Probing host...", SCREEN_W / 2, 34); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(10); M5.Display.drawString(phase, SCREEN_W / 2, SCREEN_H / 2); M5.Display.setTextColor(0x4208, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Toggle > Wait > Compare", SCREEN_W / 2, SCREEN_H - 16); } // --- PC Alive Check: result display --- void showPCAliveResult(bool condMet, int attempt, const char* condition) { if (!_present) { if (_led) _led->aliveResult(condMet); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); setFontForSize(8); M5.Display.drawString("PC Alive Check", SCREEN_W / 2, 14); // Show which condition we're checking const char* condLabel = "PC Response"; if (strcmp(condition, "numlock_on") == 0) condLabel = "Num Lock ON"; else if (strcmp(condition, "numlock_off") == 0) condLabel = "Num Lock OFF"; M5.Display.setTextColor(0x7BCF, TFT_BLACK); setFontForSize(8); M5.Display.drawString(condLabel, SCREEN_W / 2, 32); if (condMet) { M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); setFontForSize(20); M5.Display.drawString("TRUE", SCREEN_W / 2, SCREEN_H / 2 - 2); } else { M5.Display.setTextColor(TFT_RED, TFT_BLACK); setFontForSize(14); M5.Display.drawString("Waiting...", SCREEN_W / 2, SCREEN_H / 2 - 2); } M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); char info[32]; snprintf(info, sizeof(info), "Attempt %d", attempt); M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H - 16); } // --- Generic fallback for any other node type --- // FAIL screen for Get Variables when both the initial run and the silent // retry produced no matching outcome. Big red "FAIL" with a 30-second // countdown bar; when paused, the bar freezes and a "PAUSED" hint shows. void showFailScreen(uint32_t remainMs, uint32_t totalMs, bool paused) { if (!_present) { if (_led) _led->failWait(paused); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_RED, TFT_BLACK); setFontForSize(20); M5.Display.drawString("FAIL", SCREEN_W / 2, SCREEN_H / 2 - 14); if (paused) { M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); setFontForSize(10); M5.Display.drawString("PAUSED", SCREEN_W / 2, SCREEN_H / 2 + 14); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Click: retry", SCREEN_W / 2, SCREEN_H - 22); } else { M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Click: pause", SCREEN_W / 2, SCREEN_H / 2 + 16); } if (totalMs > 0) { int barH = 6; int barY = SCREEN_H - 10; int barX = 8; int barMaxW = SCREEN_W - 16; int barFill = (int)((float)remainMs / totalMs * barMaxW); if (barFill < 0) barFill = 0; if (barFill > barMaxW) barFill = barMaxW; M5.Display.fillRect(barX, barY, barMaxW, barH, TFT_DARKGREY); M5.Display.fillRect(barX, barY, barFill, barH, paused ? TFT_YELLOW : TFT_RED); } } void showGenericNode(const char* typeName) { if (!_present) { if (_led) _led->activityPulse(TFT_WHITE); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Executing", SCREEN_W / 2, 14); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(14); drawWrapped(typeName, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 2, true); } // --- ESP-NOW mesh hub mode --- // Shown while this device is the USB-attached bridge between the host // app and the rest of the mesh. The host drives everything; the screen // (or purple LED on a Lite) just tells the user which unit is the hub. void showHubMode(int nodeCount) { if (!_present) { if (_led) _led->hubMode(); return; } M5.Display.fillScreen(TFT_BLACK); M5.Display.setTextDatum(MC_DATUM); M5.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); setFontForSize(14); M5.Display.drawString("MESH HUB", SCREEN_W / 2, SCREEN_H / 2 - 18); char info[32]; snprintf(info, sizeof(info), "%d node%s", nodeCount, nodeCount == 1 ? "" : "s"); M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); setFontForSize(10); M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H / 2 + 10); M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); setFontForSize(8); M5.Display.drawString("Bridging USB <-> radio", SCREEN_W / 2, SCREEN_H - 12); } private: bool _present = true; LedUI* _led = nullptr; void drawRawImage(const char* path) { if (!_present) return; File f = LittleFS.open(path, "r"); if (!f) return; uint16_t lineBuf[SCREEN_W]; for (int y = 0; y < SCREEN_H; y++) { f.read((uint8_t*)lineBuf, SCREEN_W * 2); M5.Display.pushImage(0, y, SCREEN_W, 1, lineBuf); } f.close(); } void drawRawImageBanner(const char* path) { drawRawImage(path); } // ========================================================================= // Text utilities: word-wrapping, truncation // ========================================================================= // Draw text with word-wrapping and optional truncation. // cx, cy: center point for the text block // maxW: maximum pixel width for a line // fontSize: font size hint (passed to setFontForSize) // maxLines: maximum number of lines before truncating with "..." // center: if true, vertically center the block around cy void drawWrapped(const char* text, int cx, int cy, int maxW, int fontSize, int maxLines = 4, bool center = true) { if (!_present) return; setFontForSize(fontSize); int lineH = fontSize + 6; // Split into lines (respecting \n and word-wrap) struct Line { const char* start; int len; }; Line lines[12]; // max 12 lines int lineCount = 0; const char* p = text; while (*p && lineCount < 12) { // Find the end of this line (newline or word-wrap boundary) const char* lineStart = p; const char* lastBreak = nullptr; int linePixW = 0; while (*p && *p != '\n') { char ch[2] = { *p, '\0' }; int chW = M5.Display.textWidth(ch); if (linePixW + chW > maxW && lastBreak) { // Wrap at last space break; } if (*p == ' ') lastBreak = p; linePixW += chW; p++; } // Determine actual line end const char* lineEnd; if (*p == '\n') { lineEnd = p; p++; // skip newline } else if (*p == '\0') { lineEnd = p; } else if (lastBreak && lastBreak > lineStart) { lineEnd = lastBreak; p = lastBreak + 1; // skip space } else { // No space to break at, hard-break at maxW lineEnd = p; } lines[lineCount].start = lineStart; lines[lineCount].len = lineEnd - lineStart; lineCount++; } // Apply maxLines truncation bool truncated = false; if (lineCount > maxLines) { lineCount = maxLines; truncated = true; } // Vertically center int totalH = lineCount * lineH; int startY = center ? (cy - totalH / 2 + lineH / 2) : cy; M5.Display.setTextDatum(MC_DATUM); for (int i = 0; i < lineCount; i++) { char buf[128]; int copyLen = lines[i].len; if (copyLen >= (int)sizeof(buf)) copyLen = sizeof(buf) - 1; memcpy(buf, lines[i].start, copyLen); buf[copyLen] = '\0'; // On the last line, if truncated, add ellipsis if (truncated && i == lineCount - 1) { // Truncate to fit "..." within maxW int dotsW = M5.Display.textWidth("..."); while (copyLen > 0 && M5.Display.textWidth(buf) + dotsW > maxW) { copyLen--; buf[copyLen] = '\0'; } strcat(buf, "..."); } M5.Display.drawString(buf, cx, startY + i * lineH); } } void setFontForSize(int fontSize) { if (!_present) return; if (fontSize >= 20) { M5.Display.setFont(&fonts::FreeSansBold18pt7b); } else if (fontSize >= 14) { M5.Display.setFont(&fonts::FreeSansBold12pt7b); } else if (fontSize >= 10) { M5.Display.setFont(&fonts::FreeSansBold9pt7b); } else { M5.Display.setFont(&fonts::Font2); } } };