From 9bfccf4cf4ce42168ef3774712f8f2d4775d31a9 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:32:04 +0200 Subject: [PATCH] feat(admin): local-device mode + reuse Nodes screen for target picking Tools > Admin now opens on a This-device / Remote-node chooser. "This device" is a login-free 2-tab carousel (System: Name/Radio/TX power/ Lat/Lon, Actions: Send advert/Reboot) mapped onto NodePrefs/sensors and reusing Settings' own apply chains -- no CommonCLI port. "Remote node..." opens Tools > Nodes in a pick-mode (borrow-another-screen's-list idiom), and Nodes gains an "Admin" Hold-Enter action; both converge on the single canonical UITask::openAdminFor() -> AdminScreen::startFor(). Also: - KeyboardWidget: opt-in PlaceholderRefreshFn hook for contextual {}-key CLI command-name completion (word-replacing); default behaviour and all other keyboard uses unchanged. - TabBar: ellipsize() re-measures the real "text..." candidate per step (+ width clamp) so dots can't spill into a neighbour/reserved icon. - ChannelsView: form rows truncate instead of wrapping. - Reboot moved off the default-selected Actions row (fires with no confirm). Co-Authored-By: Claude Opus 4.8 --- .../tools_screen/tools_screen.md | 38 +- examples/companion_radio/ui-new/AdminScreen.h | 444 +++++++++++++----- .../companion_radio/ui-new/KeyboardWidget.h | 40 +- .../companion_radio/ui-new/NearbyScreen.h | 40 +- examples/companion_radio/ui-new/TabBar.h | 46 +- examples/companion_radio/ui-new/UITask.cpp | 10 + examples/companion_radio/ui-new/UITask.h | 2 + 7 files changed, 472 insertions(+), 148 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 20977ade..e1b91571 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -41,6 +41,7 @@ Select a node to see its coordinates, distance, bearing with cardinal direction, | Ping | a public key is known for the node | | Save waypoint | selected node has GPS | | Set as target | selected node has GPS **and** a public key — pins it as the active **Locator/Nav target** right away (see **Locator**) | +| Admin | selected node is a saved **repeater or room server** contact — opens **Tools › Admin** for it directly (see **Admin**) | | Sort: Dist/Recent | browsing stored nodes — **LEFT/RIGHT** on the row flips distance ↔ last-heard in place | | Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) | @@ -516,27 +517,42 @@ Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown -Send CLI commands to a **repeater or room server you have admin permission on** and see the text reply — the on-device equivalent of the companion app's repeater-admin feature. See [CLI Commands](../../cli_commands.md) for the full command grammar. +Send commands to **this device**, or to a **repeater/room server you have admin permission on** — the on-device equivalent of the companion app's repeater-admin feature for remote nodes, plus a local shim over settings this device already has. See [CLI Commands](../../cli_commands.md) for the full remote command grammar. -1. **Select a node** — the list shows every repeater/room-server contact. Press **Enter** to pick one. -2. **Log in** — type the node's **admin password** (this is the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node" and returns to the list. +Opening **Tools › Admin** shows a two-row chooser: **This device** or **Remote node...**. + +### Remote node + +1. **Select a node** — "Remote node..." opens **Tools › Nodes** (the same screen, filters, sort and live scan as browsing it normally) so picking a node for Admin looks exactly like using Nodes for anything else; **Enter** on a repeater/room row hands it to Admin. Admin is also reachable directly from a node's own **Hold Enter** menu in Nodes, without going through the chooser at all. +2. **Log in** — type the node's **admin password** (the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node". 3. **Pick a category and a field** — a tab carousel (**LEFT/RIGHT** to switch category, **UP/DOWN** to move within it, same as Auto-Reply Bot's tabs), so common settings don't need the CLI grammar memorised: -| Tab | Rows | -| --- | ---- | -| **System** | Name, Owner info, Admin password | -| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power | -| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops | -| **Actions** | Reboot, Send advert, Send zero-hop advert, Sync clock, **Custom command...** | + | Tab | Rows | + | --- | ---- | + | **System** | Name, Owner info, Admin password | + | **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power | + | **Routing** | Repeat, Advert interval, Flood advert interval, Max hops | + | **Actions** | Send advert, Send zero-hop advert, Sync clock, Reboot, **Custom command...** | -**Enter** on a row does one of three things, depending on the field: + **Enter** on a row does one of three things, depending on the field: - Most **System/Radio/Routing** rows first **fetch** the node's current value, then open the keyboard **pre-filled** with it to edit — submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind. - **Admin password** has no fetch (there's no way to read a password back) — it opens straight to a blank keyboard. - **Actions** (Reboot, Send advert, …) send immediately, no editing step. - - **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar. + - **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar. The keyboard's **{}** key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it. 4. **Read the reply** — the text reply opens in a scrollable view (**UP/DOWN** to scroll, **Cancel/Enter** to go back to the category tabs). > [!WARNING] > This screen can run **destructive** commands on the *remote* node — `reboot`, `erase`, a new admin password, and others. That's the same capability the phone app's repeater-admin feature already exposes, not a new risk, but double-check the value and the target before sending. **Passwords are remembered across reboots**, the same self-healing behaviour as room logins in Messages: after a successful admin login the password is saved on the device, so picking that node again — even after a power cycle — logs back in silently. If a saved password stops working (e.g. it was changed on the node), the failed login forgets it, so the next pick prompts for a new one. A correct password that just lacks admin permission is left alone — retyping the same one wouldn't change the outcome. Some commands are marked **Serial Only** in the CLI reference — those reject a remote CLI request and only work over that node's own USB serial connection. + +### This device + +A lighter, login-free version of the same tab carousel, for settings this device already has: + +| Tab | Rows | +| --- | ---- | +| **System** | Name, Radio (freq, bandwidth, spreading factor, coding rate), TX power, Lat, Lon | +| **Actions** | Send advert, Reboot | + +There's no fetch step here — the current value is already known, so **Enter** opens the keyboard pre-filled with it immediately, and submitting applies the change at once (the same apply logic Settings' own Radio/TX power rows use) and saves it. Reboot and Send advert fire immediately, same as their remote counterparts. This is a fast, lightly-validated shortcut alongside Settings' own fuller editors (radio presets, chip-bounds frequency entry, etc.) — for careful bounds-checked editing, prefer Settings. diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index 1d5cb010..eda5c6fe 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -1,33 +1,28 @@ #pragma once -// Tools > Admin: send CLI commands to a repeater/room server you have admin -// permission on, and see the text reply -- the on-device equivalent of the -// companion app's "repeater admin" feature (see docs/cli_commands.md for the -// command grammar). The wire protocol already supports this in full -// (MyMesh::sendRoomLogin() / sendAdminCommand() / onCommandDataRecv()); this -// screen is just the missing UI layer. +// Tools > Admin: administer a remote repeater/room you have admin rights on +// (over the mesh -- the on-device equivalent of the app's "repeater admin", +// see docs/cli_commands.md) or this device itself. Also reachable from Tools > +// Nodes' Hold-Enter menu (NearbyScreen::ACT_ADMIN). // -// Credentials are session-only by design: the admin password is typed fresh -// each time this screen is entered for a given contact (onShow() clears the -// one-slot "logged in" memo below) rather than persisted like room chat -// passwords are -- admin actions are infrequent, and keeping them out of the -// same store as room chat credentials keeps the two concerns separate. +// CHOOSER -> "This device" (LOCAL mode) or "Remote node..." (opens Tools > Nodes +// in pick-mode -- NearbyScreen::startPickAdminTarget(), the same borrow-another- +// screen's-list idiom the channel/bot pickers use, so it looks exactly like +// normal Nodes browsing). Both the picker and the Nodes context-menu converge +// on the single canonical UITask::openAdminFor(ContactInfo) -> startFor(). // -// Once logged in, commands are organised into a category tab carousel (same -// "true carousel" idiom BotScreen uses: LEFT/RIGHT exclusively switches tabs, -// UP/DOWN moves within the active tab's rows, Enter drives everything else) -// instead of a single free-text box, so common settings don't need the CLI -// grammar memorised. Each row is one of three shapes, driven by a single -// AdminField{label, get_cmd, set_prefix} table per tab -- see fieldAt(): -// - set_prefix == nullptr -> action: get_cmd is the literal command, -// sent immediately (e.g. "reboot"). -// - get_cmd == nullptr -> set-only: opens a blank keyboard for the -// new value (there's no getter for it). -// - both set -> get-then-edit-then-set: fetches get_cmd, -// opens the keyboard pre-filled with the -// raw reply, sends " ". -// The trailing "Custom command..." row (both null) is the escape hatch: it -// opens the same free-text entry this screen originally shipped with, for -// anything not covered by a field row. +// REMOTE mode: login (session password, self-heals like room logins) unlocks a +// category tab carousel of AdminField{label, get_cmd, set_prefix} rows: +// set_prefix==null -> action: send get_cmd literally (e.g. "reboot") +// get_cmd==null -> set-only: blank keyboard, send " " +// both set -> get, pre-fill keyboard with the reply, then set +// The trailing "Custom command..." row (both null) is a free-text escape hatch +// with {}-key CLI command-name completion (CLI_COMMANDS). +// +// LOCAL mode: no login, no network -- a 2-tab carousel of LocalField{label, kind} +// rows mapped straight onto NodePrefs/sensors, reusing Settings' own apply chains +// (UITask::applyRadioParams()/applyTxPower(), MyMesh::savePrefs()) rather than a +// CLI-grammar port (this firmware has no CommonCLI -- see CODE_REVIEW.md). Fields +// pre-fill with the current value read synchronously and write+apply on submit. #include "FullscreenMsgView.h" #include "TabBar.h" @@ -36,17 +31,16 @@ class AdminScreen : public UIScreen { UITask* _task; - enum Phase : uint8_t { PICKER, LOGIN, COMMAND, REPLY }; - Phase _phase = PICKER; + enum Phase : uint8_t { CHOOSER, LOGIN, COMMAND, REPLY }; + Phase _phase = CHOOSER; - // PICKER -- contacts of type ADV_TYPE_REPEATER or ADV_TYPE_ROOM only; a - // plain chat contact can never have an admin ACL entry to log into. - int _sel = 0, _scroll = 0; - int _num = 0; - uint16_t _indices[MAX_CONTACTS]; - ContactInfo _target; + // CHOOSER -- "This device" (row 0) / "Remote node..." (row 1). Reuses _sel. + int _sel = 0; - // LOGIN + bool _local_mode = false; // COMMAND is administering this device, not _target + ContactInfo _target; // valid only when !_local_mode + + // LOGIN (remote only) char _login_pw[16] = ""; // True while waiting for the result of a login attempt with no keyboard on // screen -- either a silent retry with a remembered password, or right after @@ -57,11 +51,11 @@ class AdminScreen : public UIScreen { // One-slot memo: the last contact this screen successfully admin-logged // into this visit, so re-entering COMMAND for the same target right after // doesn't require retyping the password. Cleared in onShow() -- see the - // session-only design note above. + // session-only design note in the file header. uint8_t _admin_ok_prefix[4] = { 0, 0, 0, 0 }; bool _admin_ok = false; - // COMMAND -- category tabs + rows (see file header for the row shapes). + // ── COMMAND: remote (CLI-grammar) tabs/rows ───────────────────────────────── enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT }; static const char* TAB_LABELS[ATAB_COUNT]; @@ -81,33 +75,43 @@ class AdminScreen : public UIScreen { } } - uint8_t _tab = ATAB_SYSTEM; + // ── COMMAND: local (this-device) tabs/rows ────────────────────────────────── + static const int LOCAL_TAB_COUNT = 2; + static const char* LOCAL_TAB_LABELS[LOCAL_TAB_COUNT]; + + enum LocalKind : uint8_t { LK_NONE, LK_NAME, LK_RADIO, LK_TXPOWER, LK_LAT, LK_LON, LK_REBOOT, LK_ADVERT }; + struct LocalField { const char* label; LocalKind kind; }; + static const LocalField LOCAL_SYSTEM_FIELDS[]; + static const LocalField LOCAL_ACTION_FIELDS[]; + static const int LOCAL_ROWS_PER_TAB[LOCAL_TAB_COUNT]; + + static const LocalField& localFieldAt(int tab, int row) { + return (tab == 0) ? LOCAL_SYSTEM_FIELDS[row] : LOCAL_ACTION_FIELDS[row]; + } + + int tabCount() const { return _local_mode ? LOCAL_TAB_COUNT : (int)ATAB_COUNT; } + int rowsInTab(int tab) const { return _local_mode ? LOCAL_ROWS_PER_TAB[tab] : ROWS_PER_TAB[tab]; } + const char* rowLabel(int tab, int row) const { + return _local_mode ? localFieldAt(tab, row).label : fieldAt(tab, row).label; + } + + uint8_t _tab = 0; int _row_sel = 0, _row_scroll = 0; bool _kb_active = false; - char _cmd_text[161] = ""; - bool _waiting = false; + char _cmd_text[161] = ""; // remote: the command being built/sent + bool _waiting = false; // remote: awaiting sendAdminCommand()'s reply uint32_t _cmd_deadline_ms = 0; - bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor - const char* _pending_set_prefix = nullptr; // non-null => the edited keyboard text gets this prefix on submit + bool _fetch_for_edit = false; // remote: true while waiting on a "get" whose reply opens an editor + const char* _pending_set_prefix = nullptr; // remote: non-null => edited keyboard text gets this prefix on submit + LocalKind _pending_local_kind = LK_NONE; // local: which field the open keyboard is editing - // REPLY + // REPLY (remote only) FullscreenMsgView _reply_view; char _reply_text[200] = ""; KeyboardWidget& kb() { return _task->keyboard(); } - void buildList() { - ContactInfo c; - int total = the_mesh.getNumContacts(); - _num = 0; - for (int i = 0; i < total; i++) { - if (!the_mesh.getContactByIdx(i, c)) continue; - if (c.type != ADV_TYPE_REPEATER && c.type != ADV_TYPE_ROOM) continue; - _indices[_num++] = (uint16_t)i; - } - } - bool isAdminOk(const uint8_t* pub_key) const { return _admin_ok && memcmp(_admin_ok_prefix, pub_key, 4) == 0; } @@ -120,9 +124,9 @@ class AdminScreen : public UIScreen { _phase = LOGIN; } - // Silent retry with a remembered password (see PICKER's Enter handler) -- - // same idea as MessagesScreen's room-login flow: no keyboard shown, just a - // "Logging in..." wait for the async result. + // Silent retry with a remembered password (see startFor()) -- same idea as + // MessagesScreen's room-login flow: no keyboard shown, just a "Logging + // in..." wait for the async result. void startLoginWithSaved(const char* password) { strncpy(_login_pw, password, sizeof(_login_pw) - 1); _login_pw[sizeof(_login_pw) - 1] = '\0'; @@ -147,22 +151,47 @@ class AdminScreen : public UIScreen { _task->showAlert(_fetch_for_edit ? "Fetching..." : "Sent, waiting...", 800); } - // Open the free-text command keyboard, pre-filled with `initial`. Shared by - // the Custom-command row, set-only fields, and the get-then-edit reply path. - void openValueKb(const char* initial) { - kb().begin(initial, (int)sizeof(_cmd_text) - 1); + // Open the free-text keyboard, pre-filled with `initial`. Shared by the + // Custom-command row, remote set-only/get-then-edit fields, and every local + // field. `cli_autocomplete` wires up the {} picker to complete CLI command + // names contextually -- only meaningful for the remote Custom-command row. + void openValueKb(const char* initial, bool cli_autocomplete = false) { + kb().begin(initial, (int)sizeof(_cmd_text) - 1); // begin() resets any previous placeholder hook kb().clearPlaceholders(); // a CLI command/value is literal, not a message + if (cli_autocomplete) kb().setPlaceholderRefresh(refreshCmdPlaceholders, nullptr, "Commands:"); _kb_active = true; } - // Dispatch Enter on a COMMAND-phase row per the three field shapes (see - // file header). idx == ROWS_PER_TAB[_tab]-1 on the Actions tab is the - // "Custom command..." sentinel (both null), handled first. + // Candidate completions for the remote Custom-command row's {} picker: every + // remotely-usable command from docs/cli_commands.md (Serial-Only ones and + // the region sub-grammar's many variants excluded -- typing those out stays + // manual). Entries needing a value end in a trailing space, ready to type it. + static const char* const CLI_COMMANDS[]; + static const int CLI_COMMAND_COUNT; + + // KeyboardWidget::PlaceholderRefreshFn -- repopulates the {} list with only + // the CLI_COMMANDS entries matching the word currently being typed (the + // text since the last space), so the list narrows as you type. An empty + // word (nothing typed yet, or just after a space) matches everything. + static void refreshCmdPlaceholders(KeyboardWidget& kbw, void* /*ctx*/) { + kbw.clearPlaceholders(); + int word_start = kbw.len; + while (word_start > 0 && kbw.buf[word_start - 1] != ' ') word_start--; + const char* word = kbw.buf + word_start; + int word_len = kbw.len - word_start; + for (int i = 0; i < CLI_COMMAND_COUNT; i++) + if (word_len == 0 || strncmp(CLI_COMMANDS[i], word, word_len) == 0) + kbw.addPlaceholder(CLI_COMMANDS[i]); + } + + // Dispatch Enter on a remote COMMAND row per the three field shapes (see + // file header). The trailing "Custom command..." row (both null) is + // handled first. void activateField(const AdminField& f) { _fetch_for_edit = false; _pending_set_prefix = nullptr; if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command... - openValueKb(_cmd_text); + openValueKb(_cmd_text, true); } else if (f.set_prefix == nullptr) { // Action strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1); _cmd_text[sizeof(_cmd_text) - 1] = '\0'; @@ -187,26 +216,153 @@ class AdminScreen : public UIScreen { openValueKb(""); } + // Dispatch Enter on a local COMMAND row: actions fire immediately; value + // fields open the keyboard pre-filled with the current reading. + void activateLocalField(const LocalField& f) { + NodePrefs* p = _task->getNodePrefs(); + char buf[32]; + switch (f.kind) { + case LK_NAME: + _pending_local_kind = LK_NAME; + openValueKb(the_mesh.getNodeName()); + break; + case LK_RADIO: + if (p) snprintf(buf, sizeof(buf), "%.3f,%.0f,%d,%d", p->freq, p->bw, (int)p->sf, (int)p->cr); + else buf[0] = '\0'; + _pending_local_kind = LK_RADIO; + openValueKb(buf); + break; + case LK_TXPOWER: + snprintf(buf, sizeof(buf), "%d", p ? (int)p->tx_power_dbm : 0); + _pending_local_kind = LK_TXPOWER; + openValueKb(buf); + break; + case LK_LAT: + snprintf(buf, sizeof(buf), "%.6f", sensors.node_lat); + _pending_local_kind = LK_LAT; + openValueKb(buf); + break; + case LK_LON: + snprintf(buf, sizeof(buf), "%.6f", sensors.node_lon); + _pending_local_kind = LK_LON; + openValueKb(buf); + break; + case LK_REBOOT: + _task->showAlert("Rebooting...", 800); + board.reboot(); + break; + case LK_ADVERT: + _task->showAlert(the_mesh.advert() ? "Advert sent!" : "Advert failed", 1200); + break; + default: break; + } + } + + // Parse+apply a submitted local field value. Deliberately light validation + // (parse-success only, no chip/physical-range checks like Settings' radio + // editor does) -- this panel is a fast power-user shim over the same + // settings, not a replacement for Settings' fuller, bounds-checked editors. + void commitLocalField(LocalKind kind, const char* text) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + switch (kind) { + case LK_NAME: { + strncpy(p->node_name, text, sizeof(p->node_name) - 1); + p->node_name[sizeof(p->node_name) - 1] = '\0'; + the_mesh.savePrefs(); + _task->showAlert("Saved", 800); + break; + } + case LK_RADIO: { + float freq, bw; int sf, cr; + if (sscanf(text, "%f,%f,%d,%d", &freq, &bw, &sf, &cr) == 4) { + p->freq = freq; p->bw = bw; p->sf = (uint8_t)sf; p->cr = (uint8_t)cr; + _task->applyRadioParams(); + the_mesh.savePrefs(); + _task->showAlert("Saved", 800); + } else { + _task->showAlert("Expected freq,bw,sf,cr", 1600); + } + break; + } + case LK_TXPOWER: { + int dbm; + if (sscanf(text, "%d", &dbm) == 1) { + p->tx_power_dbm = (int8_t)dbm; + _task->applyTxPower(); + the_mesh.savePrefs(); + _task->showAlert("Saved", 800); + } else { + _task->showAlert("Invalid number", 1400); + } + break; + } + case LK_LAT: { + double v; + if (sscanf(text, "%lf", &v) == 1 && v >= -90.0 && v <= 90.0) { + sensors.node_lat = v; + the_mesh.savePrefs(); + _task->showAlert("Saved", 800); + } else { + _task->showAlert("Lat must be -90..90", 1600); + } + break; + } + case LK_LON: { + double v; + if (sscanf(text, "%lf", &v) == 1 && v >= -180.0 && v <= 180.0) { + sensors.node_lon = v; + the_mesh.savePrefs(); + _task->showAlert("Saved", 800); + } else { + _task->showAlert("Lon must be -180..180", 1600); + } + break; + } + default: break; + } + } + public: explicit AdminScreen(UITask* task) : _task(task) {} // Central per-visit reset (see UIScreen::onShow) -- always starts back at - // the target picker and forgets the admin-login memo, so credentials really - // are re-typed each time the tool is (re-)entered. + // the This-device/Remote-node chooser and forgets the admin-login memo, so + // remote credentials really are re-typed each time the tool is (re-)entered. void onShow() override { - _phase = PICKER; - _sel = _scroll = 0; - buildList(); - _tab = ATAB_SYSTEM; + _phase = CHOOSER; + _sel = 0; + _local_mode = false; + _tab = 0; _row_sel = _row_scroll = 0; _kb_active = false; _waiting = false; _fetch_for_edit = false; _pending_set_prefix = nullptr; + _pending_local_kind = LK_NONE; _login_waiting = false; _admin_ok = false; } + // Canonical entry for a specific remote target -- called by + // UITask::openAdminFor(), itself reached from NearbyScreen's "Admin" + // context-menu action or its Admin-target pick-mode. Skips straight past + // login if this contact is already admin-ok this visit. + void startFor(const ContactInfo& ci) { + _target = ci; + _local_mode = false; + if (isAdminOk(ci.id.pub_key)) { + _tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0; + _phase = COMMAND; + } else { + char saved_pw[sizeof(_login_pw)]; + if (the_mesh.getRoomPassword(ci.id.pub_key, saved_pw, sizeof(saved_pw))) + startLoginWithSaved(saved_pw); + else + startLogin(); + } + } + // Async result of the on-device login, routed here from UITask::onRoomLoginResult() // only while this screen is current (see that routing for why). void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { @@ -223,13 +379,13 @@ public: } else if (success) { // Correct password, just insufficient permission -- leave any saved // password alone, retyping the same one won't change the outcome. - _phase = PICKER; + _phase = CHOOSER; _sel = 0; _task->showAlert("Not admin on this node", 1600); } else { // Wrong/stale password -- forget it so the next attempt prompts fresh, // same self-healing behaviour as a room login. the_mesh.forgetRoomPassword(pub_key); - _phase = PICKER; + _phase = CHOOSER; _sel = 0; _task->showAlert("Login failed", 1400); } } @@ -266,20 +422,13 @@ public: display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - if (_phase == PICKER) { - display.drawCenteredHeader("ADMIN: SELECT NODE"); - if (_num == 0) { - display.drawTextCentered(display.width() / 2, display.height() / 2, "No repeaters/rooms"); - return 5000; - } - drawList(display, _num, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { + if (_phase == CHOOSER) { + display.drawCenteredHeader("ADMIN"); + static const char* const ROWS[2] = { "This device", "Remote node..." }; + int scroll = 0; + drawList(display, 2, _sel, scroll, [&](int i, int y, bool sel, int reserve) { drawRowSelection(display, y, sel, reserve); - ContactInfo c; - if (the_mesh.getContactByIdx(_indices[idx], c)) { - char filtered[sizeof(c.name)]; - display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); - display.drawTextEllipsized(2, y, display.width() - 4 - reserve, filtered); - } + display.drawTextEllipsized(2, y, display.width() - 4 - reserve, ROWS[i]); }); return 2000; } @@ -304,11 +453,11 @@ public: display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply..."); return 500; } - tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab); - int n = ROWS_PER_TAB[_tab]; + tabbar::draw(display, _local_mode ? LOCAL_TAB_LABELS : TAB_LABELS, tabCount(), _tab); + int n = rowsInTab(_tab); drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) { drawRowSelection(display, y, sel, reserve); - display.drawTextEllipsized(2, y, display.width() - 4 - reserve, fieldAt(_tab, i).label); + display.drawTextEllipsized(2, y, display.width() - 4 - reserve, rowLabel(_tab, i)); }); return 2000; } @@ -318,25 +467,17 @@ public: } bool handleInput(char c) override { - if (_phase == PICKER) { + if (_phase == CHOOSER) { if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } - if (c == KEY_UP && _num > 0) { _sel = (_sel > 0) ? _sel - 1 : _num - 1; return true; } - if (c == KEY_DOWN && _num > 0) { _sel = (_sel < _num - 1) ? _sel + 1 : 0; return true; } - if (c == KEY_ENTER && _num > 0) { - if (the_mesh.getContactByIdx(_indices[_sel], _target)) { - if (isAdminOk(_target.id.pub_key)) { - _tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0; - _phase = COMMAND; - } else { - // Known password from an earlier successful admin login (this boot - // or a previous one) -- retry silently instead of re-prompting, - // same as MessagesScreen's room-login flow. - char saved_pw[sizeof(_login_pw)]; - if (the_mesh.getRoomPassword(_target.id.pub_key, saved_pw, sizeof(saved_pw))) - startLoginWithSaved(saved_pw); - else - startLogin(); - } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 1; return true; } + if (c == KEY_DOWN) { _sel = (_sel < 1) ? _sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + if (_sel == 0) { + _local_mode = true; + _tab = 0; _row_sel = _row_scroll = 0; + _phase = COMMAND; + } else { + _task->pickAdminTarget(); // -> Tools > Nodes pick-mode -> startFor() } return true; } @@ -345,18 +486,18 @@ public: if (_phase == LOGIN) { if (_login_waiting) { - if (c == KEY_CANCEL) { _login_waiting = false; _phase = PICKER; } + if (c == KEY_CANCEL) { _login_waiting = false; _phase = CHOOSER; _sel = 0; } return true; } auto r = kb().handleInput(c); if (r == KeyboardWidget::CANCELLED) { - _phase = PICKER; + _phase = CHOOSER; _sel = 0; } else if (r == KeyboardWidget::DONE) { strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1); _login_pw[sizeof(_login_pw) - 1] = '\0'; bool sent = the_mesh.sendRoomLogin(_target, _login_pw); _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); - if (sent) _login_waiting = true; else _phase = PICKER; + if (sent) _login_waiting = true; else { _phase = CHOOSER; _sel = 0; } // else: stay in LOGIN until onRoomLoginResult() fires above. } return true; @@ -370,17 +511,23 @@ public: char edited[sizeof(_cmd_text)]; strncpy(edited, kb().buf, sizeof(edited) - 1); edited[sizeof(edited) - 1] = '\0'; - if (_pending_set_prefix) { + if (_local_mode) { + LocalKind k = _pending_local_kind; + _pending_local_kind = LK_NONE; + commitLocalField(k, edited); + } else if (_pending_set_prefix) { snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _pending_set_prefix, edited); _pending_set_prefix = nullptr; + sendCommand(); } else { strncpy(_cmd_text, edited, sizeof(_cmd_text) - 1); _cmd_text[sizeof(_cmd_text) - 1] = '\0'; + if (_cmd_text[0]) sendCommand(); } - if (_cmd_text[0]) sendCommand(); } else if (r == KeyboardWidget::CANCELLED) { _kb_active = false; _pending_set_prefix = nullptr; + _pending_local_kind = LK_NONE; } return true; } @@ -391,13 +538,18 @@ public: } return true; } - if (c == KEY_CANCEL) { _phase = PICKER; return true; } - if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; } - if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; } - int n = ROWS_PER_TAB[_tab]; + if (c == KEY_CANCEL) { _phase = CHOOSER; _sel = 0; return true; } + int count = tabCount(); + if (keyIsPrev(c)) { _tab = (_tab + count - 1) % count; _row_sel = _row_scroll = 0; return true; } + if (keyIsNext(c)) { _tab = (_tab + 1) % count; _row_sel = _row_scroll = 0; return true; } + int n = rowsInTab(_tab); if (c == KEY_UP) { _row_sel = (_row_sel > 0) ? _row_sel - 1 : n - 1; return true; } if (c == KEY_DOWN) { _row_sel = (_row_sel < n - 1) ? _row_sel + 1 : 0; return true; } - if (c == KEY_ENTER) { activateField(fieldAt(_tab, _row_sel)); return true; } + if (c == KEY_ENTER) { + if (_local_mode) activateLocalField(localFieldAt(_tab, _row_sel)); + else activateField(fieldAt(_tab, _row_sel)); + return true; + } return true; } @@ -409,6 +561,7 @@ public: }; const char* AdminScreen::TAB_LABELS[AdminScreen::ATAB_COUNT] = { "System", "Radio", "Routing", "Actions" }; +const char* AdminScreen::LOCAL_TAB_LABELS[AdminScreen::LOCAL_TAB_COUNT] = { "System", "Actions" }; const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = { { "Name", "get name", "set name" }, @@ -426,10 +579,10 @@ const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = { { "Max hops", "get flood.max", "set flood.max" }, }; const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = { - { "Reboot", "reboot", nullptr }, { "Send advert", "advert", nullptr }, { "Send zero-hop advert", "advert.zerohop", nullptr }, { "Sync clock", "clock sync", nullptr }, + { "Reboot", "reboot", nullptr }, // disruptive + no confirm: keep off the default row { "Custom command...", nullptr, nullptr }, }; @@ -439,3 +592,56 @@ const int AdminScreen::ROWS_PER_TAB[AdminScreen::ATAB_COUNT] = { sizeof(AdminScreen::ROUTING_FIELDS) / sizeof(AdminScreen::AdminField), sizeof(AdminScreen::ACTION_FIELDS) / sizeof(AdminScreen::AdminField), }; + +const AdminScreen::LocalField AdminScreen::LOCAL_SYSTEM_FIELDS[] = { + { "Name", AdminScreen::LK_NAME }, + { "Radio (f,bw,sf,cr)", AdminScreen::LK_RADIO }, + { "TX power", AdminScreen::LK_TXPOWER }, + { "Lat", AdminScreen::LK_LAT }, + { "Lon", AdminScreen::LK_LON }, +}; +const AdminScreen::LocalField AdminScreen::LOCAL_ACTION_FIELDS[] = { + { "Send advert", AdminScreen::LK_ADVERT }, + { "Reboot", AdminScreen::LK_REBOOT }, // disruptive + no confirm: keep off the default row +}; +const int AdminScreen::LOCAL_ROWS_PER_TAB[AdminScreen::LOCAL_TAB_COUNT] = { + sizeof(AdminScreen::LOCAL_SYSTEM_FIELDS) / sizeof(AdminScreen::LocalField), + sizeof(AdminScreen::LOCAL_ACTION_FIELDS) / sizeof(AdminScreen::LocalField), +}; + +const char* const AdminScreen::CLI_COMMANDS[] = { + "reboot", "poweroff", "shutdown", "clkreboot", "clock", "clock sync", + "advert", "advert.zerohop", "start ota", "erase", + "neighbors", "neighbor.remove ", "discover.neighbors", + "ver", "board", + "get radio", "set radio ", "get tx", "set tx ", "tempradio ", + "get freq", "set freq ", "get radio.rxgain", "set radio.rxgain ", + "get name", "set name ", "get lat", "set lat ", "get lon", "set lon ", + "get owner.info", "set owner.info ", "get adc.multiplier", "set adc.multiplier ", + "get public.key", "get role", + "powersaving", "powersaving on", "powersaving off", + "get repeat", "set repeat ", + "get path.hash.mode", "set path.hash.mode ", + "get loop.detect", "set loop.detect ", + "get txdelay", "set txdelay ", "get direct.txdelay", "set direct.txdelay ", + "get rxdelay", "set rxdelay ", "get dutycycle", "set dutycycle ", + "get af", "set af ", "get int.thresh", "set int.thresh ", + "get agc.reset.interval", "set agc.reset.interval ", + "get multi.acks", "set multi.acks ", + "get flood.advert.interval", "set flood.advert.interval ", + "get advert.interval", "set advert.interval ", + "get flood.max", "set flood.max ", + "get flood.max.unscoped", "set flood.max.unscoped ", + "get flood.max.advert", "set flood.max.advert ", + "setperm ", "get acl", "get allow.read.only", "set allow.read.only ", + "region", "region load", "region save", "region list ", + "gps", "gps sync", "gps setloc", "gps advert ", + "sensor list", "sensor get ", "sensor set ", + "get bridge.type", "get bridge.enabled", "set bridge.enabled ", + "get bridge.delay", "set bridge.delay ", "get bridge.source", "set bridge.source ", + "get bridge.baud", "set bridge.baud ", "get bridge.channel", "set bridge.channel ", + "get bridge.secret", "set bridge.secret ", + "get pwrmgt.support", "get pwrmgt.source", "get pwrmgt.bootreason", "get pwrmgt.bootmv", + "password ", "get guest.password", "set guest.password ", +}; +const int AdminScreen::CLI_COMMAND_COUNT = sizeof(AdminScreen::CLI_COMMANDS) / sizeof(AdminScreen::CLI_COMMANDS[0]); diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 12308abd..d140d7c7 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -297,10 +297,21 @@ static int kbUtf8LastCharBytes(const char* buf, int len) { return n; } -static const int KB_PH_MAX = 12; // max placeholders in list -static const int KB_PH_LEN = 9; // max placeholder string length incl. null +static const int KB_PH_MAX = 20; // max placeholders in list (PopupMenu::PM_MAX_ITEMS=24 is the hard ceiling) +static const int KB_PH_LEN = 30; // max placeholder string length incl. null -- sized for the longest + // CLI-command candidate (AdminScreen), not just the short {x} tokens static const int KB_PH_VISIBLE = 3; // items shown at once in overlay +struct KeyboardWidget; +// Optional hook: if set, called right before the placeholder picker opens so +// the caller can repopulate the list contextually (e.g. AdminScreen's CLI +// command autocomplete, filtered by what's already typed). When set, picking +// an entry also replaces the in-progress word (the text since the last +// space) instead of appending it -- true completion, not insertion. Fields +// that don't set this (the common case -- {loc}/{time} etc.) keep the +// original static-list, append-only behaviour untouched. +typedef void (*PlaceholderRefreshFn)(KeyboardWidget& kb, void* ctx); + struct KeyboardWidget { char buf[KB_MAX_LEN + 1]; int len; @@ -311,6 +322,12 @@ struct KeyboardWidget { char _ph_buf[KB_PH_MAX][KB_PH_LEN]; int _ph_count; PopupMenu _ph_menu; + PlaceholderRefreshFn _ph_refresh = nullptr; + void* _ph_refresh_ctx = nullptr; + const char* _ph_title = "Placeholder:"; // popup title -- overridable so e.g. AdminScreen can say "Commands:" + void setPlaceholderRefresh(PlaceholderRefreshFn fn, void* ctx, const char* title = "Placeholder:") { + _ph_refresh = fn; _ph_refresh_ctx = ctx; _ph_title = title; + } // Live setting lookup — set once by UITask::begin(). NULL only in tests/tools // that construct a KeyboardWidget standalone, in which case isT9() defaults @@ -419,6 +436,9 @@ struct KeyboardWidget { t9_cell = -1; t9_cycle = 0; _ph_menu.active = false; + _ph_refresh = nullptr; // opt-in per session -- the owning screen re-sets it if it wants + _ph_refresh_ctx = nullptr; // contextual autocomplete right after this begin() + _ph_title = "Placeholder:"; // default placeholders — always available _ph_count = 0; addPlaceholder("{loc}"); @@ -601,9 +621,16 @@ struct KeyboardWidget { int idx = _ph_menu.selectedIndex(); const char* ph = _ph_buf[idx]; int ph_len = strlen(ph); - if (len + ph_len <= max_len) { - memcpy(buf + len, ph, ph_len); - len += ph_len; + // Contextual (refresh-hook) fields complete the in-progress word -- + // the text since the last space -- instead of appending after it, so + // picking a match doesn't duplicate what's already been typed. + int base_len = len; + if (_ph_refresh) { + while (base_len > 0 && buf[base_len - 1] != ' ') base_len--; + } + if (base_len + ph_len <= max_len) { + memcpy(buf + base_len, ph, ph_len); + len = base_len + ph_len; buf[len] = '\0'; } } @@ -708,7 +735,8 @@ struct KeyboardWidget { if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; } break; case 3: - _ph_menu.begin("Placeholder:", KB_PH_VISIBLE); + if (_ph_refresh) _ph_refresh(*this, _ph_refresh_ctx); // contextual repopulate, if wired up + _ph_menu.begin(_ph_title, KB_PH_VISIBLE); for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]); break; case 4: diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 3e564ba9..216d8dfd 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -25,7 +25,16 @@ class NearbyScreen : public UIScreen { // ── action-menu actions (matched by id, not by row index) ──────────────────── enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, - ACT_ADD, ACT_DELETE, ACT_FAV, ACT_SORT, ACT_SCAN }; + ACT_ADD, ACT_DELETE, ACT_FAV, ACT_ADMIN, ACT_SORT, ACT_SCAN }; + + // Set by UITask::pickAdminTarget() (Tools > Admin's "Remote node..." choice): + // while true, ENTER on an eligible row (a stored repeater/room contact) hands + // the node straight to Admin instead of opening the detail view -- everything + // else (filters, scan, ping, sort, the Hold-Enter menu) behaves identically to + // normal Nodes browsing, so picking a node for Admin looks exactly like using + // this screen for anything else. Mirrors MessagesScreen's + // startPickBotChannel()/startPickBotRoom() pick-mode idiom. + bool _pick_admin_target = false; // ── unified list entry ─────────────────────────────────────────────────────── struct Entry { @@ -541,6 +550,10 @@ class NearbyScreen : public UIScreen { bool is_contact = entryIsContact(e); bool can_add = e && has_key && !is_contact; // a new node we can save bool is_fav = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0; + // Admin needs a real saved contact (repeater/room), not a scan result or a + // name-only live-share row -- same gating as startPickAdminTarget()'s ENTER. + bool is_admin_target = e && stored && e->contact_idx >= 0 + && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM); buildSortLabel(); _menu_action_count = 0; @@ -558,6 +571,7 @@ class NearbyScreen : public UIScreen { if (has_gps && has_key) add("Set as target", ACT_LOCATOR); if (can_add) add("Add contact", ACT_ADD); if (is_contact && has_key) add(is_fav ? "Unfavourite" : "Favourite", ACT_FAV); + if (is_admin_target) add("Admin", ACT_ADMIN); if (is_contact && has_key) add("Delete contact", ACT_DELETE); if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows add(stored ? "Discover scan" : "Rescan", ACT_SCAN); @@ -603,6 +617,13 @@ class NearbyScreen : public UIScreen { break; } case ACT_DELETE: startDeleteConfirm(); break; + case ACT_ADMIN: { + const Entry* e = selected(); + ContactInfo ci; + if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci)) + _task->openAdminFor(ci); + break; + } case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER case ACT_SCAN: enterScan(); break; } @@ -724,11 +745,16 @@ public: _ping_menu.active = false; _confirm.active = false; _pinging = false; + _pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger resetPingLines(); _task->clearPing(); refreshStored(); } + // Entered via UITask::pickAdminTarget() right after setCurrScreen(this) has + // already run onShow()'s reset above -- just arms the pick-mode flag. + void startPickAdminTarget() { _pick_admin_target = true; } + int render(DisplayDriver& display) override { display.setTextSize(1); @@ -908,6 +934,7 @@ public: // ── list view ─────────────────────────────────────────────────────────── if (c == KEY_CANCEL) { + if (_pick_admin_target) { _pick_admin_target = false; _task->gotoAdminScreen(); return true; } if (_source == SRC_SCAN) leaveScan(); else _task->gotoToolsScreen(); return true; @@ -917,6 +944,17 @@ public: if (c == KEY_UP && _count > 0) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return true; } if (c == KEY_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; } if (c == KEY_ENTER) { + if (_pick_admin_target) { + const Entry* e = selected(); + ContactInfo ci; + if (e && e->contact_idx >= 0 && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM) + && the_mesh.getContactByIdx(e->contact_idx, ci)) { + _pick_admin_target = false; + _task->openAdminFor(ci); + } + // else: row isn't an eligible admin target -- ignore, stay on the picker. + return true; + } if (_count == 0) { if (_source == SRC_STORED) enterScan(); return true; } _detail = true; _detail_refresh_ms = millis(); diff --git a/examples/companion_radio/ui-new/TabBar.h b/examples/companion_radio/ui-new/TabBar.h index c5baafe7..284c6c91 100644 --- a/examples/companion_radio/ui-new/TabBar.h +++ b/examples/companion_radio/ui-new/TabBar.h @@ -27,24 +27,41 @@ inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bo display.setColor(DisplayDriver::LIGHT); } -// Truncate `label` to fit within `max_w` pixels of text (no padding), the same -// shrink-and-append-"..." approach as DisplayDriver::drawTextEllipsized (which -// can't be reused directly here: it draws immediately at a fixed x, but a tab -// pill needs the truncated width known *before* drawing, to size/centre the -// pill). Returns the resulting text's pixel width; writes the (possibly -// truncated) text into `out`. +// Truncate `label` to fit within `max_w` pixels of text (no padding). Similar +// intent to DisplayDriver::drawTextEllipsized (which can't be reused directly +// here: it draws immediately at a fixed x, but a tab pill needs the truncated +// width known *before* drawing, to size/centre the pill), but verifies the +// fit by re-measuring the actual "text..." candidate at each step, rather +// than estimating via a separately-measured ellipsis width -- font kerning +// between the last real glyph and the first dot can make a single-pass +// estimate overshoot by a px or two, which was enough to visibly spill the +// dots into whatever sits just past this tab (a neighbour, a reserved icon +// like the context-menu hint). Returns the resulting text's pixel width +// (always <= max_w, unless even a bare "..." doesn't fit); writes the +// (possibly truncated) text into `out`. inline int ellipsize(DisplayDriver& display, const char* label, int max_w, char* out, size_t out_sz) { strncpy(out, label, out_sz - 1); out[out_sz - 1] = '\0'; int w = display.getTextWidth(out); if (w <= max_w) return w; - const int ellipsis_w = display.getTextWidth("..."); int len = (int)strlen(out); - while (len > 0 && display.getTextWidth(out) > max_w - ellipsis_w) out[--len] = '\0'; - // Strip an orphaned UTF-8 lead byte left by the byte-at-a-time trim above. - while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) out[--len] = '\0'; - strcat(out, "..."); + char trial[40]; + while (len > 0) { + // Strip an orphaned UTF-8 lead byte before trying this length. + while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) len--; + if (len == 0) break; + memcpy(trial, out, len); + strcpy(trial + len, "..."); + if (display.getTextWidth(trial) <= max_w) { + strncpy(out, trial, out_sz - 1); + out[out_sz - 1] = '\0'; + return display.getTextWidth(out); + } + len--; + } + strncpy(out, "...", out_sz - 1); + out[out_sz - 1] = '\0'; return display.getTextWidth(out); } @@ -78,6 +95,12 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i if (avail >= min_w) { int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf)); int w = tw + pad * 2; + // ellipsize() targets max_w but can overshoot it by a px or two (the + // ellipsis is re-measured as a whole, so kerning against the last real + // character isn't accounted for) -- clamp so the pill can never spill + // past its allotted space (into the screen edge, or a reserved icon + // on the other side). + if (w > avail) w = avail; drawPill(display, lx - w, w, buf, false); lx = lx - w - gap; } else lfit = false; @@ -89,6 +112,7 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i if (avail >= min_w) { int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf)); int w = tw + pad * 2; + if (w > avail) w = avail; // never spill past rx_limit (e.g. into a context-menu icon) drawPill(display, rx, w, buf, false); rx += w + gap; } else rfit = false; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 1624893d..8c87a6b1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1471,6 +1471,16 @@ void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); } void UITask::gotoBotScreen() { setCurrScreen(bot_screen); } void UITask::gotoAdminScreen() { setCurrScreen(admin_screen); } // AdminScreen::onShow() resets it void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); } + +void UITask::pickAdminTarget() { + setCurrScreen(nearby_screen); // runs NearbyScreen::onShow()'s reset first + ((NearbyScreen*)nearby_screen)->startPickAdminTarget(); +} + +void UITask::openAdminFor(const ContactInfo& ci) { + setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first + ((AdminScreen*)admin_screen)->startFor(ci); +} void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 8c901e62..7aae03e5 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -231,6 +231,8 @@ public: void gotoRingtoneEditor(int slot = 0); void gotoBotScreen(); void gotoAdminScreen(); + void pickAdminTarget(); // open Nodes to choose a remote repeater/room for Admin + void openAdminFor(const ContactInfo& ci); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above) void gotoNearbyScreen(); void gotoDashboardConfig(); void gotoAutoAdvertScreen();