New Max Home UI design

This commit is contained in:
pelgraine
2026-07-21 19:03:52 +10:00
parent 409ab81760
commit e130019061
5 changed files with 347 additions and 3 deletions
+73
View File
@@ -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
@@ -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
+97 -3
View File
@@ -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<GxEPDDisplay*>(&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);
+132
View File
@@ -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<int16_t>(x);
display_crc.update<int16_t>(y);
display_crc.update<int16_t>(w);
display_crc.update<int16_t>(h);
display_crc.update<int16_t>(r);
display_crc.update<uint8_t>(thickness);
display_crc.update<uint8_t>(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<int16_t>(x);
display_crc.update<int16_t>(y);
display_crc.update<uint8_t>(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<int16_t>(x);
display_crc.update<int16_t>(y);
display_crc.update<uint8_t>((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<int16_t>(x);
display_crc.update<int16_t>(yTop);
display_crc.update<uint8_t>((const uint8_t*)text, strlen(text));
display_crc.update<uint8_t>(_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<int>(x);
display_crc.update<int>(y);
+21
View File
@@ -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; }