Files
2026-07-17 15:29:53 -04:00

1119 lines
45 KiB
C++

#pragma once
#include <M5Unified.h>
#include <LittleFS.h>
#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);
}
}
};