diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f74ec996..9a576aba 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1312,6 +1312,78 @@ static void lastHeardToggleContact() { // Home screen FIRST page: tile taps (virtual coordinate hit test) if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) { +#if defined(LilyGo_TDeck_Pro_Max) + // MAX: 2-column tile grid, 6 paired rows + full-width Phone row. + // Hit-tested in RAW PHYSICAL pixels (240x320) using the untranslated + // touch coords -- geometry must stay in sync with the render block in + // UITask.cpp. + const int tileW = 111, tileH = 32, gapX = 5, gapY = 3; + const int gridX = 6; + const int gridY = 68; + const int pitch = tileH + gapY; + + if (y >= gridY && y < gridY + 6 * pitch + tileH) { + int row = (y - gridY) / pitch; + if (row > 6) row = 6; + if (row == 6) { + #ifdef HAS_4G_MODEM + ui_task.gotoSMSScreen(); + #endif + return 0; + } + int col = (x - gridX) / (tileW + gapX); + if (col < 0) col = 0; + if (col > 1) col = 1; + switch (row * 2 + col) { + case 0: ui_task.gotoChannelPickerScreen(); return 0; + case 1: ui_task.gotoContactsScreen(); return 0; + case 2: ui_task.gotoSettingsScreen(); return 0; + case 3: ui_task.gotoDiscoveryScreen(); return 0; + case 4: ui_task.gotoTraceScreen(); return 0; + case 5: + #if HAS_GPS + ui_task.gotoMapScreen(); + #endif + return 0; + case 6: ui_task.gotoNotesScreen(); return 0; + case 7: ui_task.gotoTextReader(); return 0; + case 8: + #if !defined(HAS_4G_MODEM) || defined(MECK_AUDIO_VARIANT) + // Audiobooks: lazy-init Audio + screen on first use (mirrors 'p' key handler) + if (!ui_task.getAudiobookScreen()) { + audio = new Audio(); + AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio, the_mesh.getNodePrefs()); + abScreen->setSDReady(sdCardReady); + ui_task.setAudiobookScreen(abScreen); + } + ui_task.gotoAudiobookPlayer(); + #endif + return 0; + case 9: + #ifdef MECK_AUDIO_VARIANT + // Alarm: ensure Audio* exists (mirrors 'k' key handler) + if (!audio) { + audio = new Audio(); + } + { + AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen(); + if (alarmScr) alarmScr->setAudio(audio); + } + ui_task.gotoAlarmScreen(); + #endif + return 0; + case 10: + #ifdef MECK_WEB_READER + ui_task.gotoWebReader(); + #endif + return 0; + case 11: ui_task.gotoGamesMenu(); return 0; + } + return 0; + } + // Tap outside tiles -- left half backward, right half forward (physical) + return (x < 120) ? (char)KEY_PREV : (char)KEY_NEXT; +#else const int tileW = 40, tileH = 22, gapX = 1, gapY = 1; const int gridW = tileW * 3 + gapX * 2; const int gridX = (128 - gridW) / 2; // =3 @@ -1341,6 +1413,7 @@ static void lastHeardToggleContact() { } // Tap outside tiles — left half backward, right half forward return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT; +#endif // LilyGo_TDeck_Pro_Max } // Home screen (non-tile pages): left half taps backward, right half forward diff --git a/examples/companion_radio/ui-new/HomeIcons.h b/examples/companion_radio/ui-new/HomeIcons.h index ec95dd0f..500bd61c 100644 --- a/examples/companion_radio/ui-new/HomeIcons.h +++ b/examples/companion_radio/ui-new/HomeIcons.h @@ -81,6 +81,30 @@ static const uint8_t icon_star[] PROGMEM = { 0x1F,0x80, 0x3F,0xC0, 0x39,0xC0, 0x70,0xE0, 0x60,0x60, 0x00,0x00, }; +// Headphones (Audiobooks) -- 12x12, MSB-first, 2 bytes per row +static const uint8_t icon_headphones[] PROGMEM = { + 0x00,0x00, 0x1F,0x80, 0x20,0x40, 0x40,0x20, 0x40,0x20, 0x40,0x20, + 0xE0,0x70, 0xE0,0x70, 0xE0,0x70, 0x60,0x60, 0x00,0x00, 0x00,0x00, +}; + +// Globe (Browser) -- 12x12, MSB-first, 2 bytes per row +static const uint8_t icon_globe[] PROGMEM = { + 0x1F,0x80, 0x26,0x40, 0x46,0x20, 0x86,0x10, 0x86,0x10, 0xFF,0xF0, + 0xFF,0xF0, 0x86,0x10, 0x86,0x10, 0x46,0x20, 0x26,0x40, 0x1F,0x80, +}; + +// Bell (Alarm) -- 12x12 home tile icon, MSB-first, 2 bytes per row +static const uint8_t icon_bell[] PROGMEM = { + 0x06,0x00, 0x1F,0x80, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, + 0x3F,0xC0, 0x7F,0xE0, 0xFF,0xF0, 0x00,0x00, 0x06,0x00, 0x00,0x00, +}; + +// Phone (SMS / dialler) -- 12x12, MSB-first, 2 bytes per row +static const uint8_t icon_phone[] PROGMEM = { + 0x1F,0x80, 0x20,0x40, 0x20,0x40, 0x20,0x40, 0x20,0x40, 0x20,0x40, + 0x20,0x40, 0x20,0x40, 0x20,0x40, 0x26,0x40, 0x20,0x40, 0x1F,0x80, +}; + // 🔔 Bell -- 7x8 status bar indicator (alarm enabled) // MSB-first, 1 byte per row #define BELL_ICON_W 7 diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8bb9a514..90972ce6 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -23,7 +23,7 @@ #if defined(LilyGo_TDeck_Pro_Max) #include "DRV2605Haptic.h" // haptic motor for "Buzzer (vibrate)" channels #endif -#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT) || defined(LilyGo_TDeck_Pro_Max) #include "HomeIcons.h" #endif #if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) @@ -368,7 +368,7 @@ public: int render(DisplayDriver& display) override { char tmp[80]; -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro_Max) _task->setHomeShowingTiles(false); // Reset — only set true on FIRST page #endif @@ -453,6 +453,8 @@ public: int y = 13; // Below header #elif defined(LilyGo_T5S3_EPaper_Pro) int y = 14; // Closer to header +#elif defined(LilyGo_TDeck_Pro_Max) + int y = 8; // Tighter under header; frees room for the MSG strip above the tile grid #else int y = 14; #endif @@ -466,9 +468,100 @@ public: } if (_page == HomePage::FIRST) { -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro_Max) _task->setHomeShowingTiles(true); #endif +#if defined(LilyGo_TDeck_Pro_Max) + // ----- MAX: Touch tile grid home screen (T-Watch / P4 style) ----- + // Rendered in RAW PHYSICAL pixels (240x320) via GxEPDDisplay raw + // helpers, bypassing the 128x128 virtual scaling so icons stay 1:1 + // and the dithered grey borders read as continuous bands. + // Border shades approximate greys with 1-bit dither (0 = solid, + // 1 = dark grey, 2 = light grey), cycling so no row pairs two + // identical shades and no column stacks the same shade. + // Layout must stay in sync with the touch hit-test in main.cpp + // mapTouchTap(). + { + GxEPDDisplay* eink = static_cast(&display); + const uint16_t fg = eink->rawFgColor(); + struct MaxTile { const uint8_t* icon; const char* label; uint8_t shade; }; + static const MaxTile tiles[12] = { + { icon_envelope, "Messages", 0 }, { icon_people, "Contacts", 1 }, + { icon_gear, "Settings", 2 }, { icon_search, "Discover", 0 }, + { icon_trace, "Trace", 1 }, { icon_map, "Maps", 2 }, + { icon_notepad, "Notes", 0 }, { icon_book, "Reader", 1 }, + { icon_headphones, "Audiobooks", 2 }, { icon_bell, "Alarm", 0 }, + { icon_globe, "Browser", 1 }, { icon_gamepad, "Games", 2 }, + }; + // Physical layout matching the approved mockup: 2 cols x 6 rows + // plus a full-width Phone tile, icon stacked above the label. + // Labels honour the user font style (Classic 5x7 / Noto 7pt / + // Montserrat 7pt) via drawTextRawStyled. + const int tileW = 111, tileH = 32, gapX = 5, gapY = 3; + const int gridX = 6; + const int gridY = 68; // below the status strip at y=46 (page dots render at physical ~30-38 with the Max dot row at virtual y=8) + const int radius = 6; + const int borderT = 3; // 3px band so the dither reads as grey + + for (int i = 0; i < 12; i++) { + int row = i / 2, col = i % 2; + int tx = gridX + col * (tileW + gapX); + int ty = gridY + row * (tileH + gapY); + eink->drawRoundRectShadedRaw(tx, ty, tileW, tileH, radius, borderT, tiles[i].shade, fg); + eink->drawXbmRaw(tx + (tileW - HOME_ICON_W) / 2, ty + 5, tiles[i].icon, HOME_ICON_W, HOME_ICON_H, fg); + int lw = eink->measureTextRawStyled(tiles[i].label); + eink->drawTextRawStyled(tx + (tileW - lw) / 2, ty + 17, tiles[i].label, fg); + } + + // Full-width Phone tile (row 6) + { + int ty = gridY + 6 * (tileH + gapY); + int tw = tileW * 2 + gapX; + eink->drawRoundRectShadedRaw(gridX, ty, tw, tileH, radius, borderT, 0, fg); + eink->drawXbmRaw(gridX + (tw - HOME_ICON_W) / 2, ty + 5, icon_phone, HOME_ICON_W, HOME_ICON_H, fg); + int lw = eink->measureTextRawStyled("Phone"); + eink->drawTextRawStyled(gridX + (tw - lw) / 2, ty + 17, "Phone", fg); + } + + // Top status strip (physical, between page dots and grid): unread + // count plus connection state / WiFi IP / BLE pin + { + sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount()); + char rightBuf[40]; + rightBuf[0] = 0; + #if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) + if (_task->hasConnection()) { + strcpy(rightBuf, "< Connected >"); + } + #endif + #if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) + if (rightBuf[0] == 0) { + IPAddress ip = WiFi.localIP(); + if (ip != IPAddress(0,0,0,0)) { + snprintf(rightBuf, sizeof(rightBuf), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT); + } + } + #endif + #ifdef BLE_PIN_CODE + if (rightBuf[0] == 0 && _task->isSerialEnabled() && the_mesh.getBLEPin() != 0) { + sprintf(rightBuf, "Pin:%d", the_mesh.getBLEPin()); + } + #endif + int stripY = 46; + int mw = eink->measureTextRawStyled(tmp); + if (rightBuf[0]) { + int rw = eink->measureTextRawStyled(rightBuf); + int startX = (eink->rawWidth() - (mw + 12 + rw)) / 2; + if (startX < 0) startX = 0; + eink->drawTextRawStyled(startX, stripY, tmp, fg); + eink->drawTextRawStyled(startX + mw + 12, stripY, rightBuf, fg); + } else { + eink->drawTextRawStyled((eink->rawWidth() - mw) / 2, stripY, tmp, fg); + } + } + display.setTextSize(1); // restore driver font state after raw text + } +#else // not LilyGo_TDeck_Pro_Max #if defined(LilyGo_T5S3_EPaper_Pro) #if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) int y = 18; // Tighter spacing — connectivity info fills gap below dots @@ -755,6 +848,7 @@ public: display.setTextSize(1); // restore #endif // LILYGO_TECHO_LITE #endif +#endif // not LilyGo_TDeck_Pro_Max } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(DisplayDriver::GREEN); diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index efcc0ec7..aae79f3d 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -383,6 +383,138 @@ void GxEPDDisplay::drawRect(int x, int y, int w, int h) { display.drawRect((x+offset_x)*scale_x, (y+offset_y)*scale_y, w*scale_x, h*scale_y, _curr_color); } +// --- Rounded tile borders with 1-bit dither shading (greyscale approximation) --- +// shade: 0 = solid, 1 = dark grey (~75% dither), 2 = light grey (50% checker) +static inline bool meckDitherKeep(int px, int py, uint8_t shade) { + if (shade == 0) return true; + if (shade == 1) return ((px + py) & 3) != 3; + return ((px + py) & 1) == 0; +} + +void GxEPDDisplay::plotShadedPixel(int px, int py, uint8_t shade, uint16_t color) { + if (meckDitherKeep(px, py, shade)) display.drawPixel(px, py, color); +} + +// Rasterise a 1-physical-pixel rounded-rect outline: straight edges plus +// midpoint-circle quarter arcs, every pixel passed through the dither mask +void GxEPDDisplay::plotRoundOutline(int px, int py, int pw, int ph, int pr, uint8_t shade, uint16_t color) { + if (pw <= 0 || ph <= 0) return; + int maxr = ((pw < ph ? pw : ph) / 2) - 1; + if (pr > maxr) pr = maxr; + if (pr < 0) pr = 0; + int x0 = px, y0 = py, x1 = px + pw - 1, y1 = py + ph - 1; + + // Straight edges + for (int i = x0 + pr; i <= x1 - pr; i++) { + plotShadedPixel(i, y0, shade, color); + plotShadedPixel(i, y1, shade, color); + } + for (int j = y0 + pr; j <= y1 - pr; j++) { + plotShadedPixel(x0, j, shade, color); + plotShadedPixel(x1, j, shade, color); + } + if (pr <= 0) return; + + // Corner arcs: midpoint circle, one octant mirrored into four corners + int cx0 = x0 + pr, cy0 = y0 + pr; // top-left corner centre + int cx1 = x1 - pr, cy1 = y1 - pr; // bottom-right corner centre + int f = 1 - pr; + int ddF_x = 1; + int ddF_y = -2 * pr; + int xx = 0; + int yy = pr; + while (xx < yy) { + if (f >= 0) { yy--; ddF_y += 2; f += ddF_y; } + xx++; ddF_x += 2; f += ddF_x; + plotShadedPixel(cx0 - xx, cy0 - yy, shade, color); // top-left + plotShadedPixel(cx0 - yy, cy0 - xx, shade, color); + plotShadedPixel(cx1 + xx, cy0 - yy, shade, color); // top-right + plotShadedPixel(cx1 + yy, cy0 - xx, shade, color); + plotShadedPixel(cx0 - xx, cy1 + yy, shade, color); // bottom-left + plotShadedPixel(cx0 - yy, cy1 + xx, shade, color); + plotShadedPixel(cx1 + xx, cy1 + yy, shade, color); // bottom-right + plotShadedPixel(cx1 + yy, cy1 + xx, shade, color); + } +} + +void GxEPDDisplay::drawRoundRectShadedRaw(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint8_t thickness, uint8_t shade, uint16_t color) { + display_crc.update(x); + display_crc.update(y); + display_crc.update(w); + display_crc.update(h); + display_crc.update(r); + display_crc.update(thickness); + display_crc.update(shade); + + // Border band: concentric 1px outlines, `thickness` px deep + for (int t = 0; t < (int)thickness; t++) { + plotRoundOutline(x + t, y + t, w - 2 * t, h - 2 * t, r - t, shade, color); + } +} + +void GxEPDDisplay::drawXbmRaw(int16_t x, int16_t y, const uint8_t* bits, int w, int h, uint16_t color) { + display_crc.update(x); + display_crc.update(y); + display_crc.update(bits, ((w + 7) / 8) * h); + + uint16_t widthInBytes = (w + 7) / 8; + for (int by = 0; by < h; by++) { + for (int bx = 0; bx < w; bx++) { + uint16_t byteOffset = (by * widthInBytes) + (bx / 8); + uint8_t bitMask = 0x80 >> (bx & 7); + if (pgm_read_byte(bits + byteOffset) & bitMask) { + display.drawPixel(x + bx, y + by, color); + } + } + } +} + +void GxEPDDisplay::drawTextRawTracked(int16_t x, int16_t y, const char* text, uint16_t color) { + display_crc.update(x); + display_crc.update(y); + display_crc.update((const uint8_t*)text, strlen(text)); + drawTextRaw(x, y, text, color); +} + +int16_t GxEPDDisplay::measureTextRawStyled(const char* text) { +#ifdef HAS_MECK_FONTS + const GFXfont* f = meckGetFont(_fontStyle, 0); + if (f) { + display.setFont(f); + display.setTextSize(1); + int16_t x1, y1; + uint16_t w, h; + display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h); + return (int16_t)(w + 1); + } +#endif + return (int16_t)(6 * strlen(text)); // built-in 5x7, 6px advance +} + +void GxEPDDisplay::drawTextRawStyled(int16_t x, int16_t yTop, const char* text, uint16_t color) { +#ifdef HAS_MECK_FONTS + const GFXfont* f = meckGetFont(_fontStyle, 0); + if (f) { + display_crc.update(x); + display_crc.update(yTop); + display_crc.update((const uint8_t*)text, strlen(text)); + display_crc.update(_fontStyle); + display.setFont(f); + display.setTextSize(1); + display.setTextColor(color); + // Custom GFX fonts position by baseline: getTextBounds at (0,0) returns + // a negative y1 (box top relative to baseline), so baseline = yTop - y1 + int16_t x1, y1; + uint16_t w, h; + display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h); + display.setCursor(x, yTop - y1); + display.print(text); + return; + } +#endif + drawTextRawTracked(x, yTop, text, color); +} + void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { display_crc.update(x); display_crc.update(y); diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 5b09a1da..200d9449 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -80,6 +80,11 @@ class GxEPDDisplay : public DisplayDriver { // Render one glyph from the current 8b font at the display's cursor position void drawGlyphAtCursor(uint16_t cp); + // Helpers for drawRoundRectShadedRaw: 1px rounded outline rasteriser + // (physical coords) and dither-masked pixel plot + void plotRoundOutline(int px, int py, int pw, int ph, int pr, uint8_t shade, uint16_t color); + void plotShadedPixel(int px, int py, uint8_t shade, uint16_t color); + public: // Virtual canvas dimensions — default 128×128 (MeshCore standard). // Override for displays where physical resolution / scale < 128. @@ -134,6 +139,22 @@ public: display.print(text); } + // --- Raw (physical-pixel) tile-grid helpers for the MAX home screen --- + // Foreground colour respecting dark mode (raw draws bypass setColor mapping) + uint16_t rawFgColor() const { return _darkMode ? GxEPD_WHITE : GxEPD_BLACK; } + // 1:1 MSB-first bitmap draw, no virtual block scaling + void drawXbmRaw(int16_t x, int16_t y, const uint8_t* bits, int w, int h, uint16_t color); + // Rounded-rect border band, `thickness` px wide, with 1-bit dither shading: + // shade 0 = solid, 1 = dark grey (~75%), 2 = light grey (50% checker) + void drawRoundRectShadedRaw(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint8_t thickness, uint8_t shade, uint16_t color); + // drawTextRaw plus frame-CRC tracking so changed text triggers a refresh + void drawTextRawTracked(int16_t x, int16_t y, const char* text, uint16_t color); + // Style-honouring raw text: Classic uses the built-in 5x7, Noto/Montserrat + // use their 7pt faces. `yTop` is the top of the text box (custom GFX fonts + // position by baseline internally; this compensates). CRC tracked. + int16_t measureTextRawStyled(const char* text); + void drawTextRawStyled(int16_t x, int16_t yTop, const char* text, uint16_t color); + // Force endFrame() to push to display even if CRC unchanged // (needed because drawPixelRaw bypasses CRC tracking) void invalidateFrameCRC() { last_display_crc_value = 0; }