mirror of
https://github.com/pelgraine/Meck.git
synced 2026-06-27 13:21:14 +02:00
7df116b4f1
The repo has apparently only ever been built on case-insensitive
filesystems (macOS/Windows): every #include in the codebase uses
intended PascalCase/CamelCase header names (e.g. "SettingsScreen.h",
"WiFiMQTT.h"), but 28 of the actual files on disk were saved with
inconsistent casing (e.g. "Settingsscreen.h", "wifimqtt.h"). On a
case-sensitive filesystem (Linux) this is a hard compile failure, not
a cosmetic mismatch -- confirmed by running `pio run -e meck_audio_ble`
on Gentoo Linux, which failed immediately on "target.h: No such file
or directory" and a cascade of similar errors as each fix exposed the
next one.
Root causes, two flavors of the same underlying bug:
1. Header filename casing (29 files renamed via `git mv` to preserve
history): examples/companion_radio/ui-new/*, examples/simple_repeater/*,
and two variant-local headers (PCF85063Clock.h, TCA8418Keyboard.h x2).
Verified safe before renaming: every file has exactly one consistent
intended casing across all the places that #include it (checked via
a repo-wide scan comparing every #include against on-disk filenames,
zero conflicts found), so each rename is a pure no-op for behavior.
2. PlatformIO config paths using the wrong case for variant directories
that are actually lowercase on disk (variants/lilygo_tdeck_pro,
variants/lilygo_t5s3_epaper_pro):
- `-I variants/LilyGo_TDeck_Pro` / `-I variants/LilyGo_T5S3_EPaper_Pro`
in build_flags (3 occurrences, including lilygo_tdeck_max's
reference to TDeck Pro's shared headers) -- broke header resolution
for target.h and friends.
- `+<../variants/LilyGo_TDeck_Pro>` / `+<../variants/LilyGo_T5S3_EPaper_Pro>`
in build_src_filter (2 occurrences) -- silently excluded the board-init
.cpp files (TDeckBoard.cpp etc.) from compilation entirely, which
didn't fail until the *link* stage ("undefined reference to
radio_init()", `TDeckBoard::begin()`, etc.) since PlatformIO's glob
just matched nothing rather than erroring.
Verified fix: `pio run -e meck_audio_ble` now compiles, links, and
produces a firmware image cleanly (RAM 53.1%, Flash 49.6%).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
576 lines
19 KiB
C++
576 lines
19 KiB
C++
#pragma once
|
|
|
|
#include <helpers/ui/UIScreen.h>
|
|
#include <helpers/ui/DisplayDriver.h>
|
|
#include <MeshCore.h>
|
|
|
|
// Timestamps before this (Jan 1 2026 UTC) are treated as invalid/unsynced
|
|
#define EPOCH_2026 1735689600UL
|
|
|
|
// Forward declarations — MyMesh.h (which defines AdvertPath) is always
|
|
// included by the translation unit before this header.
|
|
class UITask;
|
|
class MyMesh;
|
|
extern MyMesh the_mesh;
|
|
|
|
class ContactsScreen : public UIScreen {
|
|
public:
|
|
// Filter modes for contact type
|
|
enum FilterMode {
|
|
FILTER_ALL = 0,
|
|
FILTER_CHAT, // Companions / Chat nodes
|
|
FILTER_REPEATER,
|
|
FILTER_ROOM, // Room servers
|
|
FILTER_SENSOR,
|
|
FILTER_FAVOURITE, // Contacts marked as favourite (any type)
|
|
FILTER_COUNT // keep last
|
|
};
|
|
|
|
private:
|
|
UITask* _task;
|
|
mesh::RTCClock* _rtc;
|
|
|
|
int _scrollPos; // Index into filtered list (top visible row)
|
|
FilterMode _filter; // Current filter mode
|
|
|
|
// Cached filtered contact indices for efficient scrolling
|
|
// We rebuild this on filter change or when entering the screen
|
|
// Arrays allocated in PSRAM when available (supports 1000+ contacts)
|
|
uint16_t* _filteredIdx; // indices into contact table
|
|
uint32_t* _filteredTs; // cached lastmod for sorting
|
|
int _filteredCount; // how many contacts match current filter
|
|
AdvertPath _hopBuf[40]; // recently heard advert paths for hop-count display
|
|
int _hopBufCount;
|
|
bool _cacheValid;
|
|
|
|
// How many rows fit on screen (computed during render)
|
|
int _rowsPerPage;
|
|
|
|
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
|
|
const uint8_t* _dmUnread = nullptr;
|
|
|
|
// --- Select mode state ---
|
|
bool _selectMode;
|
|
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
|
|
|
|
// --- helpers ---
|
|
|
|
static const char* filterLabel(FilterMode f) {
|
|
switch (f) {
|
|
case FILTER_ALL: return "All";
|
|
case FILTER_CHAT: return "Chat";
|
|
case FILTER_REPEATER: return "Rptr";
|
|
case FILTER_ROOM: return "Room";
|
|
case FILTER_SENSOR: return "Sens";
|
|
case FILTER_FAVOURITE: return "Fav";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
static const char* typeStr(uint8_t adv_type) {
|
|
switch (adv_type) {
|
|
case ADV_TYPE_CHAT: return "C";
|
|
case ADV_TYPE_REPEATER: return "R";
|
|
case ADV_TYPE_ROOM: return "RS";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
bool matchesFilter(uint8_t adv_type, uint8_t flags = 0) const {
|
|
switch (_filter) {
|
|
case FILTER_ALL: return true;
|
|
case FILTER_CHAT: return adv_type == ADV_TYPE_CHAT;
|
|
case FILTER_REPEATER: return adv_type == ADV_TYPE_REPEATER;
|
|
case FILTER_ROOM: return adv_type == ADV_TYPE_ROOM;
|
|
case FILTER_SENSOR: return (adv_type != ADV_TYPE_CHAT &&
|
|
adv_type != ADV_TYPE_REPEATER &&
|
|
adv_type != ADV_TYPE_ROOM);
|
|
case FILTER_FAVOURITE: return (flags & 0x01) != 0;
|
|
default: return true;
|
|
}
|
|
}
|
|
|
|
void rebuildCache() {
|
|
_filteredCount = 0;
|
|
uint32_t numContacts = the_mesh.getNumContacts();
|
|
ContactInfo contact;
|
|
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_CONTACTS; i++) {
|
|
if (the_mesh.getContactByIdx(i, contact)) {
|
|
if (matchesFilter(contact.type, contact.flags)) {
|
|
_filteredIdx[_filteredCount] = (uint16_t)i;
|
|
// Use lastmod (our receive time) for sort/age; pre-2026 or zero → 0 sinks to bottom
|
|
_filteredTs[_filteredCount] = (contact.lastmod >= EPOCH_2026) ? contact.lastmod : 0;
|
|
_filteredCount++;
|
|
}
|
|
}
|
|
}
|
|
// Sort by lastmod descending (most recently heard first; pre-2026/unsynced sink to bottom)
|
|
// Insertion sort — fine for up to ~1000 entries on ESP32
|
|
for (int i = 1; i < _filteredCount; i++) {
|
|
uint16_t tmpIdx = _filteredIdx[i];
|
|
uint32_t tmpTs = _filteredTs[i];
|
|
int j = i - 1;
|
|
while (j >= 0 && _filteredTs[j] < tmpTs) {
|
|
_filteredIdx[j + 1] = _filteredIdx[j];
|
|
_filteredTs[j + 1] = _filteredTs[j];
|
|
j--;
|
|
}
|
|
_filteredIdx[j + 1] = tmpIdx;
|
|
_filteredTs[j + 1] = tmpTs;
|
|
}
|
|
_cacheValid = true;
|
|
// Refresh hop-count cache from the 12 most recently heard adverts
|
|
_hopBufCount = the_mesh.getRecentlyHeard(_hopBuf, 40);
|
|
// Clamp scroll position
|
|
if (_scrollPos >= _filteredCount) {
|
|
_scrollPos = (_filteredCount > 0) ? _filteredCount - 1 : 0;
|
|
}
|
|
}
|
|
|
|
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "--"
|
|
static void formatAge(char* buf, size_t bufLen, uint32_t now, uint32_t timestamp) {
|
|
if (timestamp == 0 || timestamp < EPOCH_2026 || now < timestamp) {
|
|
strncpy(buf, "--", bufLen);
|
|
return;
|
|
}
|
|
uint32_t secs = now - timestamp;
|
|
if (secs < 60) {
|
|
snprintf(buf, bufLen, "%ds", (int)secs);
|
|
} else if (secs < 3600) {
|
|
snprintf(buf, bufLen, "%dm", (int)(secs / 60));
|
|
} else if (secs < 86400) {
|
|
snprintf(buf, bufLen, "%dh", (int)(secs / 3600));
|
|
} else {
|
|
snprintf(buf, bufLen, "%dd", (int)(secs / 86400));
|
|
}
|
|
}
|
|
|
|
// --- Bitfield helpers ---
|
|
bool isSelectedRaw(int rawIdx) const {
|
|
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return false;
|
|
return (_selectedBits[rawIdx / 8] & (1 << (rawIdx % 8))) != 0;
|
|
}
|
|
void setSelectedRaw(int rawIdx, bool sel) {
|
|
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return;
|
|
if (sel) _selectedBits[rawIdx / 8] |= (1 << (rawIdx % 8));
|
|
else _selectedBits[rawIdx / 8] &= ~(1 << (rawIdx % 8));
|
|
}
|
|
|
|
public:
|
|
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
|
|
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
|
|
_filteredCount(0), _cacheValid(false), _rowsPerPage(5),
|
|
_selectMode(false), _hopBufCount(0) {
|
|
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
|
|
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
|
|
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
|
|
_selectedBits = (uint8_t*)ps_calloc((MAX_CONTACTS + 7) / 8, 1);
|
|
#else
|
|
_filteredIdx = new uint16_t[MAX_CONTACTS]();
|
|
_filteredTs = new uint32_t[MAX_CONTACTS]();
|
|
_selectedBits = new uint8_t[(MAX_CONTACTS + 7) / 8]();
|
|
#endif
|
|
}
|
|
|
|
void invalidateCache() { _cacheValid = false; }
|
|
|
|
// Set pointer to per-contact DM unread array (called by UITask after allocation)
|
|
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnread = ptr; }
|
|
|
|
void resetScroll() {
|
|
_scrollPos = 0;
|
|
_cacheValid = false;
|
|
}
|
|
|
|
FilterMode getFilter() const { return _filter; }
|
|
|
|
// --- Select mode API ---
|
|
bool isInSelectMode() const { return _selectMode; }
|
|
|
|
void enterSelectMode() {
|
|
_selectMode = true;
|
|
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
|
|
// Pre-select the currently highlighted contact
|
|
if (_filteredCount > 0 && _scrollPos < _filteredCount) {
|
|
setSelectedRaw(_filteredIdx[_scrollPos], true);
|
|
}
|
|
}
|
|
|
|
void exitSelectMode() {
|
|
_selectMode = false;
|
|
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
|
|
}
|
|
|
|
void toggleSelected() {
|
|
if (_filteredCount == 0 || _scrollPos >= _filteredCount) return;
|
|
int rawIdx = _filteredIdx[_scrollPos];
|
|
setSelectedRaw(rawIdx, !isSelectedRaw(rawIdx));
|
|
}
|
|
|
|
void selectAll() {
|
|
for (int i = 0; i < _filteredCount; i++) {
|
|
setSelectedRaw(_filteredIdx[i], true);
|
|
}
|
|
}
|
|
|
|
void deselectAll() {
|
|
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
|
|
}
|
|
|
|
int getSelectedCount() const {
|
|
int count = 0;
|
|
for (int i = 0; i < _filteredCount; i++) {
|
|
if (isSelectedRaw(_filteredIdx[i])) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// Fill outBuf with raw contact table indices of selected contacts
|
|
int getSelectedRawIndices(uint16_t* outBuf, int maxOut) const {
|
|
int count = 0;
|
|
for (int i = 0; i < _filteredCount && count < maxOut; i++) {
|
|
if (isSelectedRaw(_filteredIdx[i])) {
|
|
outBuf[count++] = _filteredIdx[i];
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// Tap-to-select: given virtual Y, select contact row.
|
|
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
|
int selectRowAtVY(int vy) {
|
|
if (_filteredCount == 0) return 0;
|
|
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
|
const int bodyTop = headerH;
|
|
#else
|
|
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
|
#endif
|
|
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
|
|
|
int maxVisible = (128 - headerH - footerH) / lineH;
|
|
if (maxVisible < 3) maxVisible = 3;
|
|
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
|
_filteredCount - maxVisible));
|
|
|
|
int tappedRow = startIdx + (vy - bodyTop) / lineH;
|
|
if (tappedRow < 0 || tappedRow >= _filteredCount) return 0;
|
|
|
|
if (tappedRow == _scrollPos) return 2;
|
|
_scrollPos = tappedRow;
|
|
return 1;
|
|
}
|
|
|
|
// Get the raw contact table index for the currently highlighted item
|
|
// Returns -1 if no valid selection
|
|
int getSelectedContactIdx() const {
|
|
if (_filteredCount == 0) return -1;
|
|
return _filteredIdx[_scrollPos];
|
|
}
|
|
|
|
// Get the adv_type of the currently highlighted contact
|
|
// Returns 0xFF if no valid selection
|
|
uint8_t getSelectedContactType() const {
|
|
if (_filteredCount == 0) return 0xFF;
|
|
ContactInfo contact;
|
|
if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return 0xFF;
|
|
return contact.type;
|
|
}
|
|
|
|
// Copy the name of the currently highlighted contact into buf
|
|
// Returns false if no valid selection
|
|
bool getSelectedContactName(char* buf, size_t bufLen) const {
|
|
if (_filteredCount == 0) return false;
|
|
ContactInfo contact;
|
|
if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return false;
|
|
strncpy(buf, contact.name, bufLen);
|
|
buf[bufLen - 1] = '\0';
|
|
return true;
|
|
}
|
|
|
|
int render(DisplayDriver& display) override {
|
|
if (!_cacheValid) rebuildCache();
|
|
|
|
char tmp[48];
|
|
|
|
// === Header ===
|
|
display.setTextSize(1);
|
|
display.setColor(DisplayDriver::GREEN);
|
|
display.setCursor(0, 0);
|
|
if (_selectMode) {
|
|
int selCount = getSelectedCount();
|
|
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
|
|
} else {
|
|
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
|
|
}
|
|
display.print(tmp);
|
|
|
|
// Count on right: All → total/max, filtered → matched/total
|
|
if (_filter == FILTER_ALL) {
|
|
snprintf(tmp, sizeof(tmp), "%d/%d", (int)the_mesh.getNumContacts(), MAX_CONTACTS);
|
|
} else {
|
|
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
|
|
}
|
|
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
|
display.print(tmp);
|
|
|
|
// Divider
|
|
display.drawRect(0, 11, display.width(), 1);
|
|
|
|
// === Body - contact rows ===
|
|
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
|
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px gap
|
|
int headerHeight = 14;
|
|
int footerHeight = 14;
|
|
int maxY = display.height() - footerHeight;
|
|
int y = headerHeight;
|
|
|
|
uint32_t now = _rtc->getCurrentTime();
|
|
int rowsDrawn = 0;
|
|
|
|
if (_filteredCount == 0) {
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.setCursor(0, y);
|
|
display.print("No contacts");
|
|
display.setCursor(0, y + lineHeight);
|
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
|
display.print("Swipe to change filter");
|
|
#else
|
|
display.print("A/D: Change filter");
|
|
#endif
|
|
} else {
|
|
// Center visible window around selected item (TextReaderScreen pattern)
|
|
int maxVisible = (maxY - headerHeight) / lineHeight;
|
|
if (maxVisible < 3) maxVisible = 3;
|
|
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
|
_filteredCount - maxVisible));
|
|
int endIdx = min(_filteredCount, startIdx + maxVisible);
|
|
|
|
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
|
ContactInfo contact;
|
|
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
|
|
|
|
bool selected = (i == _scrollPos);
|
|
bool sel = _selectMode && isSelectedRaw(_filteredIdx[i]);
|
|
|
|
// Highlight: fill LIGHT rect first, then draw DARK text on top
|
|
if (selected) {
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
|
display.fillRect(0, y, display.width(), lineHeight);
|
|
#else
|
|
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
|
#endif
|
|
display.setColor(DisplayDriver::DARK);
|
|
} else {
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
}
|
|
|
|
// Set cursor AFTER fillRect so text draws on top of highlight
|
|
display.setCursor(0, y);
|
|
|
|
// Prefix: select mode uses * for selected, normal uses > for cursor
|
|
char prefix[5];
|
|
if (_selectMode) {
|
|
snprintf(prefix, sizeof(prefix), "%c%s",
|
|
sel ? '*' : (selected ? '>' : ' '),
|
|
typeStr(contact.type));
|
|
} else if (selected) {
|
|
snprintf(prefix, sizeof(prefix), ">%s", typeStr(contact.type));
|
|
} else {
|
|
snprintf(prefix, sizeof(prefix), " %s", typeStr(contact.type));
|
|
}
|
|
display.print(prefix);
|
|
|
|
// Contact name (truncated to fit)
|
|
char filteredName[32];
|
|
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
|
|
|
|
// Reserve space for hops + age on right side
|
|
char hopStr[6];
|
|
if (contact.out_path_len == 0xFF) {
|
|
// No confirmed direct path — look up flood hop estimate from recent advert cache
|
|
hopStr[0] = '?'; hopStr[1] = '\0'; // default
|
|
for (int h = 0; h < _hopBufCount; h++) {
|
|
if (memcmp(contact.id.pub_key, _hopBuf[h].pubkey_prefix, 7) == 0) {
|
|
uint8_t bph = (_hopBuf[h].path_len >> 6) + 1;
|
|
uint8_t hops = _hopBuf[h].path_len & 0x3F;
|
|
uint8_t max_hops = 64 / bph; // sanity cap based on path encoding
|
|
if (hops <= max_hops) {
|
|
if (hops == 0)
|
|
strcpy(hopStr, "~D");
|
|
else
|
|
snprintf(hopStr, sizeof(hopStr), "~%d", (int)hops);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} else if (contact.out_path_len == 0) {
|
|
bool customDirect = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
|
|
strcpy(hopStr, customDirect ? "D*" : "D");
|
|
} else {
|
|
int hops = contact.out_path_len & 0x3F; // lower 6 bits = hop count
|
|
bool customPath = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
|
|
if (customPath) {
|
|
snprintf(hopStr, sizeof(hopStr), "%d*", hops); // asterisk = custom/locked path
|
|
} else {
|
|
snprintf(hopStr, sizeof(hopStr), "%d", hops);
|
|
}
|
|
}
|
|
|
|
char ageStr[6];
|
|
formatAge(ageStr, sizeof(ageStr), now, contact.lastmod);
|
|
|
|
// Build right-side string: "*N hops age" if unread, else "hops age"
|
|
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
|
|
char rightStr[20];
|
|
if (dmCount > 0) {
|
|
snprintf(rightStr, sizeof(rightStr), "*%d %sh %s", dmCount, hopStr, ageStr);
|
|
} else {
|
|
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
|
|
}
|
|
int rightWidth = display.getTextWidth(rightStr) + 2;
|
|
|
|
// Name region: after prefix + small gap, before right info
|
|
int nameX = display.getTextWidth(prefix) + 2;
|
|
int nameMaxW = display.width() - nameX - rightWidth - 2;
|
|
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
|
|
|
// Right-aligned: hops + age
|
|
display.setCursor(display.width() - rightWidth, y);
|
|
display.print(rightStr);
|
|
|
|
y += lineHeight;
|
|
rowsDrawn++;
|
|
}
|
|
_rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1;
|
|
}
|
|
|
|
display.setTextSize(1); // restore for footer
|
|
|
|
// === Footer ===
|
|
int footerY = display.height() - 12;
|
|
display.drawRect(0, footerY - 2, display.width(), 1);
|
|
display.setColor(DisplayDriver::YELLOW);
|
|
|
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
|
display.setCursor(0, footerY);
|
|
if (_selectMode) {
|
|
display.print("Swipe:All/Clr");
|
|
const char* right = "Tap:Tog Hold:Exit";
|
|
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
|
display.print(right);
|
|
} else {
|
|
display.print("Swipe:Filter");
|
|
const char* right = "Hold:DM/Admin";
|
|
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
|
display.print(right);
|
|
}
|
|
#elif defined(LILYGO_TECHO_LITE)
|
|
display.setCursor(0, footerY);
|
|
if (_selectMode) {
|
|
display.print("Q:Done");
|
|
} else {
|
|
display.print("Q:Bk");
|
|
const char* right = "Ent:Sel";
|
|
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
|
display.print(right);
|
|
}
|
|
#else
|
|
display.setCursor(0, footerY);
|
|
if (_selectMode) {
|
|
display.print("A:All D:Clr");
|
|
const char* right = "X:Exp F:Fav Q:Done";
|
|
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
|
display.print(right);
|
|
} else {
|
|
display.print("Q:Bk A/D:Filter");
|
|
const char* right = "P:Path Ent:Sel";
|
|
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
|
display.print(right);
|
|
}
|
|
#endif
|
|
|
|
return 5000; // e-ink: next render after 5s
|
|
}
|
|
|
|
bool handleInput(char c) override {
|
|
// Shift+W: page up
|
|
if (c == 'W') {
|
|
int pageSize = (128 - 14 - 14) / the_mesh.getNodePrefs()->smallLineH();
|
|
if (pageSize < 3) pageSize = 3;
|
|
_scrollPos = max(0, _scrollPos - pageSize);
|
|
return true;
|
|
}
|
|
// W - scroll up (previous contact)
|
|
if (c == 'w' || c == 0xF2) {
|
|
if (_scrollPos > 0) {
|
|
_scrollPos--;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Shift+S: page down
|
|
if (c == 'S') {
|
|
int pageSize = (128 - 14 - 14) / the_mesh.getNodePrefs()->smallLineH();
|
|
if (pageSize < 3) pageSize = 3;
|
|
_scrollPos = min(_filteredCount - 1, _scrollPos + pageSize);
|
|
return true;
|
|
}
|
|
// S - scroll down (next contact)
|
|
if (c == 's' || c == 0xF1) {
|
|
if (_scrollPos < _filteredCount - 1) {
|
|
_scrollPos++;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// --- Select mode key handling ---
|
|
if (_selectMode) {
|
|
// Enter/tap: toggle selection on current contact
|
|
if (c == 13 || c == KEY_ENTER) {
|
|
toggleSelected();
|
|
return true;
|
|
}
|
|
// A: select all in current filter
|
|
if (c == 'a' || c == 'A') {
|
|
selectAll();
|
|
return true;
|
|
}
|
|
// D: deselect all
|
|
if (c == 'd' || c == 'D') {
|
|
deselectAll();
|
|
return true;
|
|
}
|
|
// Q, X, F, Backspace — handled by main.cpp (needs mesh/SD access)
|
|
return false;
|
|
}
|
|
|
|
// --- Normal mode key handling ---
|
|
|
|
// A - previous filter
|
|
if (c == 'a' || c == 'A') {
|
|
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);
|
|
_scrollPos = 0;
|
|
_cacheValid = false;
|
|
return true;
|
|
}
|
|
|
|
// D - next filter
|
|
if (c == 'd' || c == 'D') {
|
|
_filter = (FilterMode)(((int)_filter + 1) % FILTER_COUNT);
|
|
_scrollPos = 0;
|
|
_cacheValid = false;
|
|
return true;
|
|
}
|
|
|
|
// Enter - select contact (future: open RepeaterAdmin for repeaters)
|
|
if (c == 13 || c == KEY_ENTER) {
|
|
// TODO Phase 3: if selected contact is a repeater, open RepeaterAdminScreen
|
|
// For now, just acknowledge the selection
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}; |