#pragma once #include #include #include #include #include "ChannelScreen.h" #ifndef MAX_GROUP_CHANNELS #define MAX_GROUP_CHANNELS 20 #endif class UITask; // Forward declaration class MyMesh; // Forward declaration extern MyMesh the_mesh; // --------------------------------------------------------------------------- // ChannelPickerScreen // --------------------------------------------------------------------------- // A directory-style screen that lists every group channel + the DM inbox, // each with an unread-message badge. Selecting an entry jumps to the channel // messages screen pre-targeted at that channel. // // Replaces the A/D channel-cycling model in ChannelScreen. Pressing A or D // (or swiping left/right on T5S3) from the messages screen now opens the // picker instead of paging one channel at a time. // // Rendering: // T5S3 E-Paper Pro : vertical list of outlined "bubble" rows (full-width, // name left-aligned, unread badge right-aligned). // Matches the P4 channel picker aesthetic. 1-tap opens. // T-Deck Pro / MAX : vertical list with "> " cursor, unread badge, right- // aligned. Same highlight/tap convention as Contacts. // // Delete history: // Press X on a highlighted channel to enter delete confirmation mode. // Confirmation overlay asks the user to press Enter to confirm or Shift+Del to // cancel. On confirm, all messages for that channel are invalidated in // the circular buffer and persisted to SD. // // Navigation signals use a wantsExit() flag (same pattern as PathEditor) -- // UITask is only forward-declared, so the picker cannot call UITask methods // directly. main.cpp / UITask.cpp check the flag after injectKey(). // --------------------------------------------------------------------------- class ChannelPickerScreen : public UIScreen { UITask* _task; ChannelScreen* _channelScreen; // Ordered list of items. // Index 0 is always the DM inbox (channel_idx == 0xFF). // Remaining entries are populated group channels in ascending slot order. uint8_t _items[MAX_GROUP_CHANNELS + 1]; int _itemCount; int _cursor; int _scrollTop; // Scroll offset (T-Deck Pro list only) // Grid layout cache (T5S3) -- set in render(), consumed by touch hit test int _cellW; int _cellH; int _gridTop; int _gridCols; // Delete confirmation sub-menu bool _confirmDelete; // True when showing "Delete history?" overlay // Rebuild the items list from MyMesh. O(20), safe every render. void rebuildItems() { int n = 0; uint8_t tmp[MAX_GROUP_CHANNELS + 1]; tmp[n++] = 0xFF; // DM inbox always first for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails ch; if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { if (n < MAX_GROUP_CHANNELS + 1) tmp[n++] = i; } } memcpy(_items, tmp, n); _itemCount = n; if (_cursor >= _itemCount) _cursor = _itemCount - 1; if (_cursor < 0) _cursor = 0; } void getItemName(int idx, char* buf, size_t bufLen) const { if (idx < 0 || idx >= _itemCount || bufLen == 0) { if (bufLen) buf[0] = '\0'; return; } uint8_t c = _items[idx]; if (c == 0xFF) { strncpy(buf, "Direct Messages", bufLen - 1); buf[bufLen - 1] = '\0'; return; } ChannelDetails ch; if (the_mesh.getChannel(c, ch) && ch.name[0] != '\0') { strncpy(buf, ch.name, bufLen - 1); buf[bufLen - 1] = '\0'; } else { snprintf(buf, bufLen, "Ch %d", (int)c); } } int getItemUnread(int idx) const { if (idx < 0 || idx >= _itemCount || !_channelScreen) return 0; return _channelScreen->getUnreadForChannel(_items[idx]); } public: ChannelPickerScreen(UITask* task) : _task(task), _channelScreen(nullptr), _itemCount(0), _cursor(0), _scrollTop(0), _cellW(40), _cellH(12), _gridTop(14), _gridCols(3), _confirmDelete(false), _wantExit(false) { _items[0] = 0xFF; } void setChannelScreen(ChannelScreen* cs) { _channelScreen = cs; } // --- wantsExit flag -- checked by main.cpp / UITask after injectKey() --- bool _wantExit; bool wantsExit() const { return _wantExit; } // Called by UITask::gotoChannelPickerScreen(). void enter(uint8_t currentChannelIdx) { rebuildItems(); _cursor = 0; for (int i = 0; i < _itemCount; i++) { if (_items[i] == currentChannelIdx) { _cursor = i; break; } } _scrollTop = 0; _confirmDelete = false; _wantExit = false; } uint8_t getSelectedChannel() const { if (_cursor >= 0 && _cursor < _itemCount) return _items[_cursor]; return 0xFF; } // ----------------------------------------------------------------------- // Render // ----------------------------------------------------------------------- int render(DisplayDriver& display) override { rebuildItems(); // === Header === display.setCursor(0, 0); display.setTextSize(1); display.setColor(DisplayDriver::GREEN); display.print("Channels"); int totalUnread = 0; for (int i = 0; i < _itemCount; i++) totalUnread += getItemUnread(i); char tmp[24]; if (totalUnread > 0) { snprintf(tmp, sizeof(tmp), "*%d", totalUnread); } else { snprintf(tmp, sizeof(tmp), "[%d]", _itemCount); } display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); display.print(tmp); display.drawRect(0, 11, display.width(), 1); #if defined(LilyGo_T5S3_EPaper_Pro) // ================================================================= // T5S3: Vertical bubble list (matches P4 channel picker aesthetic) // Full-width outlined bubbles with channel name left-aligned and // unread badge right-aligned. 1-tap opens the channel. // ================================================================= const int headerH = 14; const int footerH = 14; const int bodyH = display.height() - headerH - footerH; const int bubbleH = 11; // Bubble height in virtual coords const int gap = 2; // Gap between bubbles const int padX = 3; // Horizontal padding from screen edge const int bubbleW = display.width() - 2 * padX; int maxVisible = bodyH / (bubbleH + gap); if (maxVisible < 3) maxVisible = 3; if (maxVisible > _itemCount) maxVisible = _itemCount; // Cache layout for touch hit test _cellW = bubbleW; _cellH = bubbleH + gap; _gridTop = headerH; _gridCols = 1; // Single column -- list mode // Centre scroll window on cursor _scrollTop = max(0, min(_cursor - maxVisible / 2, _itemCount - maxVisible)); if (_scrollTop < 0) _scrollTop = 0; int endIdx = min(_itemCount, _scrollTop + maxVisible); for (int i = _scrollTop; i < endIdx; i++) { int row = i - _scrollTop; int x = padX; int y = headerH + row * (bubbleH + gap) + 1; int w = bubbleW; int h = bubbleH; bool selected = (i == _cursor); int unread = getItemUnread(i); // Bubble: filled if selected, outlined otherwise if (selected) { display.setColor(DisplayDriver::LIGHT); display.fillRect(x, y, w, h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(x, y, w, h); // Draw a second outline 1px inset for a bolder border display.drawRect(x + 1, y + 1, w - 2, h - 2); } // Channel name -- left-aligned with inner padding char name[32]; getItemName(i, name, sizeof(name)); char filtered[32]; display.translateUTF8ToBlocks(filtered, name, sizeof(filtered)); int textY = y + (h - 9) / 2; if (textY < y + 1) textY = y + 1; int textX = x + 4; // Badge width reservation int badgeW = 0; char badge[8]; if (unread > 0) { if (unread > 99) snprintf(badge, sizeof(badge), "99+"); else snprintf(badge, sizeof(badge), "*%d", unread); badgeW = display.getTextWidth(badge) + 4; } int nameMaxW = w - 8 - badgeW; if (nameMaxW < 8) nameMaxW = 8; int nameW = display.getTextWidth(filtered); if (nameW <= nameMaxW) { display.setCursor(textX, textY); display.print(filtered); } else { display.drawTextEllipsized(textX, textY, nameMaxW, filtered); } // Unread badge -- right-aligned inside bubble if (unread > 0) { int bx = x + w - badgeW; display.setCursor(bx, textY); display.print(badge); } display.setColor(DisplayDriver::LIGHT); } // Scroll indicator (if more items than visible) if (_itemCount > maxVisible) { const int sbW = 3; int sbX = display.width() - sbW; int sbTop = headerH; int sbHeight = bodyH; display.setColor(DisplayDriver::LIGHT); display.drawRect(sbX, sbTop, sbW, sbHeight); int thumbH = (maxVisible * sbHeight) / _itemCount; if (thumbH < 4) thumbH = 4; int maxScroll = _itemCount - maxVisible; if (maxScroll < 1) maxScroll = 1; int thumbY = sbTop + (_scrollTop * (sbHeight - thumbH)) / maxScroll; display.fillRect(sbX + 1, thumbY + 1, sbW - 2, thumbH - 2); } #else // ================================================================= // T-Deck Pro / MAX: Vertical list // Uses NodePrefs font helpers for large_font compatibility. // ================================================================= NodePrefs* prefs = the_mesh.getNodePrefs(); int lineH = prefs->smallLineH(); const int headerH = 14; const int footerH = 14; int maxY = display.height() - footerH; int y = headerH; int maxVisible = (maxY - headerH) / lineH; if (maxVisible < 3) maxVisible = 3; // Centre scroll window on cursor _scrollTop = max(0, min(_cursor - maxVisible / 2, _itemCount - maxVisible)); if (_scrollTop < 0) _scrollTop = 0; int endIdx = min(_itemCount, _scrollTop + maxVisible); display.setTextSize(prefs->smallTextSize()); for (int i = _scrollTop; i < endIdx && y + lineH <= maxY; i++) { bool selected = (i == _cursor); int unread = getItemUnread(i); if (selected) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y + prefs->smallHighlightOff(), display.width(), lineH); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); // Prefix: "> " for selected, " " otherwise. "*N" badge if unread. char prefix[8]; if (unread > 0) { snprintf(prefix, sizeof(prefix), "%s*%d ", selected ? ">" : " ", unread); } else { snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " "); } display.print(prefix); // Name char name[32]; getItemName(i, name, sizeof(name)); char filtered[32]; display.translateUTF8ToBlocks(filtered, name, sizeof(filtered)); int nameX = display.getTextWidth(prefix) + 2; int nameMaxW = display.width() - nameX - 2; display.drawTextEllipsized(nameX, y, nameMaxW, filtered); y += lineH; } // Scroll indicator if (_itemCount > maxVisible) { const int sbW = 3; int sbX = display.width() - sbW; int sbTop = headerH; int sbHeight = maxY - headerH; display.setColor(DisplayDriver::LIGHT); display.drawRect(sbX, sbTop, sbW, sbHeight); int thumbH = (maxVisible * sbHeight) / _itemCount; if (thumbH < 4) thumbH = 4; int maxScroll = _itemCount - maxVisible; if (maxScroll < 1) maxScroll = 1; int thumbY = sbTop + (_scrollTop * (sbHeight - thumbH)) / maxScroll; display.fillRect(sbX + 1, thumbY + 1, sbW - 2, thumbH - 2); } #endif // ================================================================= // Delete confirmation overlay // Drawn on top of the list when _confirmDelete is active. // ================================================================= if (_confirmDelete) { // Clear a centred box and draw a border int boxW = display.width() - 16; int boxH = 42; int boxX = 8; int boxY = (display.height() - boxH) / 2; // Clear the box area display.setColor(DisplayDriver::DARK); display.fillRect(boxX, boxY, boxW, boxH); display.setColor(DisplayDriver::LIGHT); display.drawRect(boxX, boxY, boxW, boxH); display.drawRect(boxX + 1, boxY + 1, boxW - 2, boxH - 2); // Channel name display.setTextSize(1); char name[32]; getItemName(_cursor, name, sizeof(name)); char filtered[32]; display.translateUTF8ToBlocks(filtered, name, sizeof(filtered)); display.setColor(DisplayDriver::GREEN); display.drawTextEllipsized(boxX + 4, boxY + 5, boxW - 8, filtered); // "Delete history?" prompt display.setColor(DisplayDriver::LIGHT); const char* prompt = "Delete message history?"; display.setCursor(boxX + 4, boxY + 17); display.print(prompt); // Key hints display.setColor(DisplayDriver::YELLOW); #if defined(LilyGo_T5S3_EPaper_Pro) const char* hints = "Tap:Yes Boot:Cancel"; #else const char* hints = "Enter:Yes Sh+Del:Cancel"; #endif display.setCursor(boxX + 4, boxY + 29); display.print(hints); } // === Footer === display.setTextSize(1); int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, footerY); #if defined(LilyGo_T5S3_EPaper_Pro) if (_confirmDelete) { display.print("Tap:Yes"); const char* rt = "Boot:Cancel"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); } else { display.print("Tap:Open"); const char* rt = "Long Press:Del Hist Boot:Back"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); } #elif defined(LILYGO_TECHO_LITE) display.print("Q:Bk"); const char* rt = "Ent:Open"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); #else if (_confirmDelete) { display.print("Enter:Yes Sh+Del:Cancel"); } else { display.print("W/S:Nav Sh+Del:X"); const char* rt = "Ent:Open"; display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); display.print(rt); } #endif #ifdef USE_EINK return 5000; #else return 1000; #endif } // ----------------------------------------------------------------------- // Input // ----------------------------------------------------------------------- bool handleInput(char c) override { // --- Delete confirmation mode --- if (_confirmDelete) { // Enter -- confirm deletion if (c == '\r' || c == 13 || c == KEY_ENTER || c == KEY_SELECT) { if (_channelScreen && _cursor >= 0 && _cursor < _itemCount) { int cleared = _channelScreen->clearHistoryForChannel(_items[_cursor]); char name[32]; getItemName(_cursor, name, sizeof(name)); Serial.printf("ChannelPicker: Deleted %d messages for '%s'\n", cleared, name); } _confirmDelete = false; return true; } // Shift+Del -- cancel if (c == KEY_CANCEL) { _confirmDelete = false; return true; } // Consume all other keys while confirmation is showing return true; } // --- Normal picker mode --- // W / UP if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) { if (_cursor > 0) { _cursor--; return true; } return false; } // S / DOWN if (c == 's' || c == 'S' || c == 0xF1 || c == KEY_DOWN) { if (_cursor < _itemCount - 1) { _cursor++; return true; } return false; } // A / D -- consumed (no channel cycling from picker) if (c == 'a' || c == 'A' || c == KEY_LEFT) { return true; } if (c == 'd' || c == 'D' || c == KEY_RIGHT) { return true; } // X -- delete message history for highlighted channel if (c == 'x' || c == 'X') { if (_cursor >= 0 && _cursor < _itemCount) { _confirmDelete = true; } return true; } // Enter -- select the highlighted channel and signal exit if (c == '\r' || c == 13 || c == KEY_ENTER || c == KEY_SELECT) { if (_channelScreen && _cursor >= 0 && _cursor < _itemCount) { _channelScreen->setViewChannelIdx(_items[_cursor]); } _wantExit = true; return true; // Consumed -- caller checks wantsExit() and navigates } // Shift+Del -- cancel without changing channel, signal exit if (c == KEY_CANCEL) { _wantExit = true; return true; } return false; } // ----------------------------------------------------------------------- // Touch hit test (virtual coordinates) // Returns: 0=miss, 1=cursor moved, 2=activate. // T5S3 bubbles: any tap on a bubble -> 2 (direct open). // T-Deck Pro list: 1st tap -> 1 (highlight), 2nd tap same row -> 2. // ----------------------------------------------------------------------- int selectAtVxVy(int vx, int vy) { // If delete confirmation is showing: // T5S3: tap = confirm (return 2 → KEY_ENTER → handleInput confirms) // T-Deck Pro: tap = cancel (dismiss overlay, stay on picker) if (_confirmDelete) { #if defined(LilyGo_T5S3_EPaper_Pro) return 2; // Confirm — maps to KEY_ENTER in mapTouchTap #else _confirmDelete = false; return 1; // Cancel — redraw without activating #endif } #if defined(LilyGo_T5S3_EPaper_Pro) // Vertical bubble list hit test if (vy < _gridTop || _cellH == 0) return 0; int footerY = 128 - 14; if (vy >= footerY) return 0; int row = (vy - _gridTop) / _cellH; int idx = _scrollTop + row; if (idx < 0 || idx >= _itemCount) return 0; _cursor = idx; return 2; // Direct open on tap #else // T-Deck Pro / MAX list hit test -- uses NodePrefs for large_font compatibility NodePrefs* prefs = the_mesh.getNodePrefs(); int lineH = prefs->smallLineH(); const int headerH = 14; const int footerH = 14; int bodyTop = headerH + prefs->smallHighlightOff(); if (vy < bodyTop || vy >= 128 - footerH) return 0; int maxVisible = (128 - headerH - footerH) / lineH; if (maxVisible < 3) maxVisible = 3; int startIdx = max(0, min(_cursor - maxVisible / 2, _itemCount - maxVisible)); if (startIdx < 0) startIdx = 0; int tappedRow = startIdx + (vy - bodyTop) / lineH; if (tappedRow < 0 || tappedRow >= _itemCount) return 0; if (tappedRow == _cursor) return 2; _cursor = tappedRow; return 1; #endif } };