1119 lines
45 KiB
C++
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);
|
|
}
|
|
}
|
|
};
|