mirror of
https://github.com/pelgraine/Meck.git
synced 2026-05-18 07:15:58 +02:00
548 lines
18 KiB
C++
548 lines
18 KiB
C++
#pragma once
|
|
|
|
#include <helpers/ui/UIScreen.h>
|
|
#include <helpers/ui/DisplayDriver.h>
|
|
#include <helpers/ChannelDetails.h>
|
|
#include <MeshCore.h>
|
|
#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 Q 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);
|
|
const char* hints = "Enter:Yes Q:Cancel";
|
|
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 = "Hold:Del 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 Q:Cancel");
|
|
} else {
|
|
display.print("W/S:Nav Q:Back");
|
|
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;
|
|
}
|
|
// Q / backspace -- cancel
|
|
if (c == 'q' || c == 'Q' || c == '\b' || 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
|
|
}
|
|
|
|
// Q / backspace -- cancel without changing channel, signal exit
|
|
if (c == 'q' || c == 'Q' || c == '\b' || 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
|
|
}
|
|
}; |