Files
Jakub 6d1e71cd3f fix(ui): one rule for value rows, one meaning for Hold Enter
Three interaction inconsistencies found while auditing the favourites work,
all of the same shape: the same gesture meaning different things depending
on which screen you were on.

Value rows in popup menus. Rows like "Notif: ON" or "Sort: Dist" show a
value the user steps through with LEFT/RIGHT, but Enter treated them as
ordinary menu picks and closed the popup, so changing two of them meant
reopening the menu in between. Trail's settings submenu was the lone
exception, working around it by rebuilding and re-selecting after each
Enter. PopupMenu now knows the difference: addValueItem() marks a row, and
Enter on it returns the new VALUE_NEXT instead of SELECTED, leaving the
menu open. Only Back closes a menu now. Applied to the Messages
contact/room/channel menus, Nodes, the Ringtone editor and Trail, which
drops its reopenSettingsAt() workaround. The LEFT/RIGHT cycling bodies
moved into one helper per menu, since Enter and RIGHT now share them.

Nodes' Fav row was the worst case: LEFT/RIGHT did nothing there at all, so
the only way to toggle a favourite was an Enter that dismissed the menu on
every flip. Its label moved to a member buffer (as the Pin row already had)
so it can be retitled in place.

Settings rows Auto-off, Low battery, GPS pwr and Battery ignored Enter,
though their options wrap exactly like the melody/keyboard/clock rows
beside them, where Enter has always stepped forward. They accept it now.
Rows that ramp between fixed ends (Brightness, Volume, TX Pwr, Timezone,
SF/BW/CR) stay LEFT/RIGHT-only -- there is nothing to wrap to.

Hold Enter no longer doubles as Back. It quietly meant "go back" on Tools,
Locator, Live Share, Repeater, Bot, Auto-Advert, GPIO, Compass, the
Dashboard config and the Messages navigate view, while elsewhere the same
long press opens a context menu. It now only ever opens a menu, or does
nothing where there is none. Same for dismissing an open popup, which it
used to do. Checked that this strands nobody: every board that can reach
these screens has a real Back key (back_btn on joystick boards, Esc on
CardKB/TCA8418/T-Deck). Single-button boards produce no KEY_ENTER at all,
so they never leave the home pages in the first place.
2026-08-31 15:43:10 +02:00

151 lines
5.9 KiB
C++

#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp just before HomeScreen.
//
// The flat 10-item list grew crowded, so tools are grouped into collapsible
// sections (Location / Comms / System) via the shared AccordionList helper —
// the same fold-in-place model as Settings. Each row carries a mini-icon;
// section headers show their cog/marker plus a fold indicator.
#include "AccordionList.h"
class ToolsScreen : public UIScreen {
UITask* _task;
enum Action {
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS
#if defined(PIN_GPIO1)
, ACT_GPIO
#endif
};
struct Tool { const char* label; const MiniIcon* icon; Action action; };
struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; };
static const Tool LOCATION_TOOLS[];
static const Tool COMMS_TOOLS[];
static const Tool SYSTEM_TOOLS[];
static const Section SECTIONS[];
static const int SECTION_COUNT = 3;
AccordionList _acc;
// Fixed icon gutter so labels line up whether or not a row has a glyph. Sized
// for the widest icon (the 7px cog) at the current font scale.
static int gutter(DisplayDriver& d) { return 7 * miniIconScale(d) + 2; }
// Was hand-rolling its own vertical centring against lineStep() (line height
// + the 2px inter-row gap) instead of just the line height itself -- 2px too
// much slack pushed a nearly-full-height icon (e.g. the 7px cog) low enough
// to clip against the row below, which is what "don't fit visually" meant.
// miniIconDraw() (icons.h) already centres correctly against getLineHeight()
// alone -- the same call drawAckGlyph uses for the message-list checkmark --
// so just delegate to it instead of keeping a second, subtly-wrong copy.
static void drawIcon(DisplayDriver& d, int x, int y, const MiniIcon* ic) {
if (!ic) return;
miniIconDraw(d, x, y, *ic);
}
void dispatch(Action a) {
switch (a) {
case ACT_NEARBY: _task->gotoNearbyScreen(); break;
case ACT_LIVESHARE: _task->gotoLiveShareScreen(); break;
case ACT_TRAIL: _task->gotoTrailScreen(); break;
case ACT_LOCATOR: _task->gotoLocatorScreen(); break;
case ACT_COMPASS: _task->gotoCompassScreen(); break;
case ACT_BOT: _task->gotoBotScreen(); break;
case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break;
case ACT_REPEATER: _task->gotoRepeaterScreen(); break;
case ACT_ADMIN: _task->pickAdminTarget(); break; // Admin is remote-only: pick a node first
case ACT_CLOCK: _task->gotoClockTools(); break;
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
#if defined(PIN_GPIO1)
case ACT_GPIO: _task->gotoGpioScreen(); break;
#endif
}
}
public:
ToolsScreen(UITask* task) : _task(task) {}
// Open folded at the section list each time Tools is entered from Home.
void onShow() override {
static uint8_t sizes[SECTION_COUNT];
for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count;
_acc.begin(sizes, SECTION_COUNT);
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("TOOLS");
const int cw = display.getCharWidth();
const int g = gutter(display);
_acc.render(display,
// Section header: "[+/-] <icon> Name"
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(collapsed ? "+" : "-");
const int icon_x = 2 + cw + 2;
drawIcon(display, icon_x, y, SECTIONS[sec].icon);
display.setCursor(icon_x + g, y);
display.print(SECTIONS[sec].name);
},
// Item: indented "<icon> Label"
[&](int sec, int item, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
const int icon_x = 2 + cw + 2; // align item icons under the header icon
drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon);
display.setCursor(icon_x + g, y);
display.print(SECTIONS[sec].tools[item].label);
});
return 500;
}
bool handleInput(char c) override {
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
switch (_acc.handleInput(c)) {
case AccordionList::ACTIVATED: {
const AccordionList::Row& r = _acc.selected();
dispatch(SECTIONS[r.sec].tools[r.item].action);
return true;
}
case AccordionList::HANDLED: return true;
case AccordionList::IGNORED: return false;
}
return false;
}
};
const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
{ "Nodes", &ICON_MAP_CONTACT, ACT_NEARBY },
{ "Live Share", &ICON_GPS, ACT_LIVESHARE },
{ "Trail", &ICON_TRAIL, ACT_TRAIL },
{ "Locator", &ICON_MAP_WAYPOINT, ACT_LOCATOR },
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
};
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
{ "Remote Bot", &ICON_BOT, ACT_BOT },
{ "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
{ "Repeater", &ICON_REPEATER, ACT_REPEATER },
{ "Admin", &ICON_KEY, ACT_ADMIN },
};
const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
{ "Clock Tools", &ICON_ALARM, ACT_CLOCK },
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
{ "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS },
#if defined(PIN_GPIO1)
{ "GPIO", &ICON_PINS, ACT_GPIO },
#endif
};
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 },
{ "System", &ICON_GEAR, SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) },
};