diff --git a/docs/solo_features/message_screen/message_screen.md b/docs/solo_features/message_screen/message_screen.md index 90cbab3c..c228e08c 100644 --- a/docs/solo_features/message_screen/message_screen.md +++ b/docs/solo_features/message_screen/message_screen.md @@ -102,6 +102,8 @@ Selecting the row opens a read-only list of the resolved hops — each shown as **Hold Enter** on a contact entry opens a context menu: +> Rows that show a value (`Notif:`, `Melody:`, `Fav:`) are changed in place — **LEFT/RIGHT** steps the value and **Enter** advances it, with the menu staying open. Only **Back** closes the menu. The same rule holds in every context menu on the device. + | OLED | E-Ink | | :-----------------------: | :-----------------------: | | ![](./ctx_contact_oled.png) | ![](./ctx_contact_eink.png) | @@ -109,9 +111,9 @@ Selecting the row opens a read-only list of the resolved hops — each shown as | Item | Action | | ---------------------------- | ------------------------------------------------------------------------------ | | Mark as read | Clears unread counter for this contact | -| Notif: Default / OFF / ON | Per-contact notification override — **LEFT/RIGHT** to cycle | -| Melody: Global / M1 / M2 | Per-contact melody override — **LEFT/RIGHT** to cycle | -| Fav: ON / OFF | Mark this contact as a favourite — **LEFT/RIGHT** to toggle | +| Notif: Default / OFF / ON | Per-contact notification override — **LEFT/RIGHT** or **Enter** to cycle | +| Melody: Global / M1 / M2 | Per-contact melody override — **LEFT/RIGHT** or **Enter** to cycle | +| Fav: ON / OFF | Mark this contact as a favourite — **LEFT/RIGHT** or **Enter** to toggle | | Pin to dial / Unpin (slot N) | Pin this contact to a Favourites Dial slot; if already pinned shows which slot | **Fav** and **Pin to dial** are separate. **Fav** is the starred flag shared with the companion app: it marks the row with a ★, sorts it above the rest of the list (unless **Settings › Contacts › Favs top** is off), and drives the `DMs = Fav` list filter. **Pin to dial** puts the contact on the [Favourites Dial](../favourites_dial/favourites_dial.md) page and changes nothing about the list. @@ -124,7 +126,7 @@ In the **Rooms** list the context menu instead offers: | ------------- | ---------------------------------------------------------------------------- | | Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) | | Logout | Only shown once logged in. Forgets the saved password so the next open prompts for one again | -| Fav: ON / OFF | Mark this room as a favourite — **LEFT/RIGHT** to toggle; drives the **Rooms = Fav** list filter | +| Fav: ON / OFF | Mark this room as a favourite — **LEFT/RIGHT** or **Enter** to toggle; drives the **Rooms = Fav** list filter | | Pin to dial / Unpin (slot N) | Pin this room to a [Favourites Dial](../favourites_dial/favourites_dial.md) slot | --- @@ -140,9 +142,9 @@ In the **Rooms** list the context menu instead offers: | Item | Action | | ------------------------- | --------------------------------------------------------------------- | | Mark all read | Clears all unread for this channel | -| Notif: Default / OFF / ON | Per-channel notification override — **LEFT/RIGHT** to cycle | -| Melody: Global / M1 / M2 | Per-channel melody override — **LEFT/RIGHT** to cycle | -| Fav: ON / OFF | Add or remove this channel from favourites — **LEFT/RIGHT** to toggle | +| Notif: Default / OFF / ON | Per-channel notification override — **LEFT/RIGHT** or **Enter** to cycle | +| Melody: Global / M1 / M2 | Per-channel melody override — **LEFT/RIGHT** or **Enter** to cycle | +| Fav: ON / OFF | Add or remove this channel from favourites — **LEFT/RIGHT** or **Enter** to toggle | | Pin to dial / Unpin (slot N) | Pin this channel to a [Favourites Dial](../favourites_dial/favourites_dial.md) slot | | Edit | Opens the Add/Edit form below, pre-filled with the channel's name | | Delete | Removes the channel immediately (no confirm prompt) | diff --git a/docs/solo_features/settings_screen/settings_screen.md b/docs/solo_features/settings_screen/settings_screen.md index 04b38936..2ac96cb2 100644 --- a/docs/solo_features/settings_screen/settings_screen.md +++ b/docs/solo_features/settings_screen/settings_screen.md @@ -8,7 +8,7 @@ | :-----------------------: | :-----------------------: | | ![](./overview_oled.png) | ![](./overview_eink.png) | -All settings are saved to flash and restored on next boot. Settings are organised into collapsible sections. Press **Enter** on a section header to expand or collapse it — all sections start collapsed for faster navigation. Press **LEFT/RIGHT** to change a value, or **Enter** for toggle items. +All settings are saved to flash and restored on next boot. Settings are organised into collapsible sections. Press **Enter** on a section header to expand or collapse it — all sections start collapsed for faster navigation. Press **LEFT/RIGHT** to change a value. **Enter** advances any row whose options wrap around — toggles, melodies, and option lists like Auto-off. Rows that ramp between fixed ends (Brightness, Volume, TX Pwr, Timezone, SF / BW / CR) are LEFT/RIGHT only, since there is nothing to wrap to. Press **Cancel/Back** to save and return to the home screen. @@ -19,7 +19,7 @@ Press **Cancel/Back** to save and return to the home screen. | Setting | Options | Notes | | ------------------------------------ | -------------------------------- | ----------------------------------------------------------------------------------------------------- | | Brightness | 1–5 | LEFT/RIGHT; preview applies immediately | -| Auto-off | 5 s / 15 s / 30 s / 60 s / never | LEFT/RIGHT | +| Auto-off | 5 s / 15 s / 30 s / 60 s / never | LEFT/RIGHT, or **Enter** to advance | | Auto-lock | ON / OFF | Locks device when display turns off | | Battery | icon / % / V | Display mode for the top-bar battery indicator | | Clock seconds | show / hide | Hiding reduces OLED refresh from 1 s to 60 s | diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index e4d7e175..3620166b 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -41,10 +41,10 @@ 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**) | -| Fav: ON / OFF | selected node is a saved contact — the same starred flag the Messages lists use, shared with the companion app | +| Fav: ON / OFF | selected node is a saved contact — **LEFT/RIGHT** or **Enter** toggles it in place, as in the Messages menus; the same starred flag those lists use, shared with the companion app | | Pin to dial / Unpin (slot N) | selected node is a saved contact — puts it on the [Favourites Dial](../favourites_dial/favourites_dial.md), taking the first free slot | | 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 | +| Sort: Dist/Recent | browsing stored nodes — **LEFT/RIGHT** or **Enter** flips distance ↔ last-heard in place | | Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) | Filtering stays on the list itself (**LEFT/RIGHT** cycles the type), so there is no separate Filter action in the menu. **Sort** is adjusted in place: highlight the **Sort** row and tap **LEFT/RIGHT** to flip the list (and its right-hand column) between **distance** and **last-heard** without closing the menu — the same in-popup pattern as Trail's settings. The row appears only while browsing stored nodes (live-scan rows carry signal, not distance). Filter and sort are independent and **persist** across re-entry to the screen. @@ -350,8 +350,8 @@ Each melody supports up to 32 notes: | ------------ | ----------- | -------------------------------------- | | Play / Stop | Enter | Preview the melody | | Melody 1 / 2 | Enter | Switch to the other slot | -| Duration | LEFT/RIGHT | Cycle duration for selected note | -| BPM | LEFT/RIGHT | Cycle tempo | +| Duration | LEFT/RIGHT or Enter | Cycle duration for selected note | +| BPM | LEFT/RIGHT or Enter | Step tempo (stops at each end) | | Insert | Enter | Insert a new note after the cursor | | Delete | Enter | Delete the note at cursor | | Save & Exit | Enter | Persist the melody and return to Tools | diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index 4bbc164c..c597f9d3 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -50,7 +50,7 @@ public: } bool handleInput(char c) override { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index e07fe763..f73bd921 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -267,7 +267,7 @@ public: bool up = (c == KEY_UP); bool down = (c == KEY_DOWN); bool enter = (c == KEY_ENTER); - bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); + bool cancel = (c == KEY_CANCEL); if (_kb_row >= 0) { auto res = _kb->handleInput(c); diff --git a/examples/companion_radio/ui-new/CompassScreen.h b/examples/companion_radio/ui-new/CompassScreen.h index c37ea150..bd2f86ee 100644 --- a/examples/companion_radio/ui-new/CompassScreen.h +++ b/examples/companion_radio/ui-new/CompassScreen.h @@ -120,7 +120,7 @@ public: } bool handleInput(char c) override { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoToolsScreen(); return true; } + if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } return true; } }; diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index e2f307ae..8081fce3 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -64,7 +64,7 @@ public: } bool handleInput(char c) override { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); _task->gotoHomeScreen(); return true; diff --git a/examples/companion_radio/ui-new/GpioScreen.h b/examples/companion_radio/ui-new/GpioScreen.h index c4320538..cba504bb 100644 --- a/examples/companion_radio/ui-new/GpioScreen.h +++ b/examples/companion_radio/ui-new/GpioScreen.h @@ -136,7 +136,7 @@ public: } bool handleInput(char c) override { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoToolsScreen(); return true; } + if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _item_count - 1; return true; } if (c == KEY_DOWN) { _sel = (_sel < _item_count - 1) ? _sel + 1 : 0; return true; } if (!_prefs) return false; diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index e69e499c..49a81626 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -164,7 +164,7 @@ public: } bool handleInput(char c) override { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index 06824d4a..db354a1b 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -301,10 +301,10 @@ public: if (c == KEY_UP) { _pick_sel = (_pick_sel > 0) ? _pick_sel - 1 : _target_n - 1; return true; } if (c == KEY_DOWN) { _pick_sel = (_pick_sel < _target_n - 1) ? _pick_sel + 1 : 0; return true; } if (c == KEY_ENTER) { applyTarget(_targets[_pick_sel]); _picking = false; return true; } - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _picking = false; return true; } + if (c == KEY_CANCEL) { _picking = false; return true; } return true; } - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); // engine re-seeded per edit _task->gotoToolsScreen(); return true; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 62a21ff5..77d70965 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -722,6 +722,68 @@ class MessagesScreen : public UIScreen { } } + // Advance one of the contact menu's value rows. dir is +1 for RIGHT and for + // Enter (which PopupMenu reports as VALUE_NEXT on a value row), -1 for LEFT. + void cycleContactCtxValue(int sel, int dir) { + static const char* NOTIF_LABELS[] = { "Default", "OFF", "ON" }; + static const char* ML[] = { "Global", "M1", "M2" }; + ContactInfo ci; + if (_num_contacts <= 0 || !the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) return; + if (sel == 1) { + uint8_t v = dmNotifState(ci.id.pub_key); + v = (dir > 0) ? (v + 1) % 3 : (v + 2) % 3; + setDmNotifState(ci.id.pub_key, v); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]); + _ctx_dirty = true; + } else if (sel == 2) { + uint8_t v = dmMelodySlot(ci.id.pub_key); + v = (dir > 0) ? (v + 1) % 3 : (v + 2) % 3; + setDmMelody(ci.id.pub_key, v); + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]); + _ctx_dirty = true; + } else if (sel == _ctx_fav_idx) { + toggleContactFav(ci); + } + } + + // Same, for the room menu -- its only value row is Fav. + void cycleRoomCtxValue(int sel) { + ContactInfo ci; + if (sel != _ctx_fav_idx || _num_contacts <= 0) return; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) toggleContactFav(ci); + } + + // Same, for the channel menu. The list rebuild is deferred to menu close: with + // the fav-only filter on, un-favouriting removes this channel from the list, + // and rebuilding under the open menu would shift _channel_sel onto another one. + void cycleChannelCtxValue(int sel, int dir) { + static const char* NOTIF_LABELS[] = { "Default", "OFF", "ON" }; + static const char* ML[] = { "Global", "M1", "M2" }; + if (_num_channels <= 0) return; + uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open -- see declaration + if (sel == 1) { + uint8_t v = chNotifState(ch_idx); + v = (dir > 0) ? (v + 1) % 3 : (v + 2) % 3; + setChNotifState(ch_idx, v); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]); + _ctx_dirty = true; + } else if (sel == 2) { + uint8_t v = chNotifMelody(ch_idx); + v = (dir > 0) ? (v + 1) % 3 : (v + 2) % 3; + setChNotifMelody(ch_idx, v); + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]); + _ctx_dirty = true; + } else if (sel == _ctx_fav_idx) { + NodePrefs* p2 = _task->getNodePrefs(); + if (p2) { + p2->ch_fav_bitmask ^= (1ULL << ch_idx); + bool is_fav = (p2->ch_fav_bitmask & (1ULL << ch_idx)); + snprintf(_ctx_fav_item, sizeof(_ctx_fav_item), is_fav ? "Fav: ON" : "Fav: OFF"); + _ctx_dirty = true; + } + } + } + bool chIsFav(uint8_t ch_idx) const { NodePrefs* p = _task->getNodePrefs(); return p && (p->ch_fav_bitmask & (1ULL << ch_idx)) != 0; @@ -1721,9 +1783,9 @@ public: // Channel Add/Edit form consumes all input while active. if (_ch_view.active()) return _ch_view.handleInput(c); - // Navigate view: any back key returns to the message it was opened from. + // Navigate view: Back or Enter returns to the message it was opened from. if (_nav_active) { - if (c == KEY_CANCEL || c == KEY_ENTER || c == KEY_CONTEXT_MENU) _nav_active = false; + if (c == KEY_CANCEL || c == KEY_ENTER) _nav_active = false; return true; } if (_phase == MODE_SELECT) { @@ -1787,14 +1849,17 @@ public: return true; } if (_room_mode) { - // LEFT/RIGHT toggle Fav in-place (menu stays open), as in the other menus. - if ((keyIsPrev(c) || keyIsNext(c)) && _num_contacts > 0 && - _ctx_menu.selectedIndex() == _ctx_fav_idx) { - ContactInfo ci; - if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) toggleContactFav(ci); + // LEFT/RIGHT -- and Enter, via VALUE_NEXT -- toggle Fav in place; the + // menu stays open and only Back closes it. + if (keyIsPrev(c) || keyIsNext(c)) { + cycleRoomCtxValue(_ctx_menu.selectedIndex()); return true; } auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::VALUE_NEXT) { + cycleRoomCtxValue(_ctx_menu.selectedIndex()); + return true; // still open -- the list rebuild below waits for close + } if (res == PopupMenu::SELECTED && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { int sel = _ctx_menu.selectedIndex(); @@ -1812,7 +1877,7 @@ public: forgetRoomLoggedIn(_sel_contact.id.pub_key); _task->showAlert("Logged out", 1000); } - // Fav row: already toggled by LEFT/RIGHT, ENTER just closes. + // Fav is a value row -- Enter never selects it (see cycleRoomCtxValue). } } if (res != PopupMenu::NONE && _phase == CONTACT_PICK) { @@ -1822,36 +1887,17 @@ public: } return true; } - // LEFT/RIGHT cycle Notif/Melody in-place (menu stays open). - if (_num_contacts > 0) { - bool left = keyIsPrev(c); - bool right = keyIsNext(c); - if (left || right) { - static const char* NOTIF_LABELS[] = { "Default", "OFF", "ON" }; - static const char* ML[] = { "Global", "M1", "M2" }; - ContactInfo ci; - if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { - int sel = _ctx_menu.selectedIndex(); - if (sel == 1) { - uint8_t v = dmNotifState(ci.id.pub_key); - v = right ? (v + 1) % 3 : (v + 2) % 3; - setDmNotifState(ci.id.pub_key, v); - snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]); - _ctx_dirty = true; - } else if (sel == 2) { - uint8_t v = dmMelodySlot(ci.id.pub_key); - v = right ? (v + 1) % 3 : (v + 2) % 3; - setDmMelody(ci.id.pub_key, v); - snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]); - _ctx_dirty = true; - } else if (sel == _ctx_fav_idx) { - toggleContactFav(ci); - } - } - return true; - } + // LEFT/RIGHT -- and Enter, via VALUE_NEXT -- cycle Notif/Melody/Fav in + // place; the menu stays open and only Back closes it. + if (keyIsPrev(c) || keyIsNext(c)) { + cycleContactCtxValue(_ctx_menu.selectedIndex(), keyIsNext(c) ? 1 : -1); + return true; } auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::VALUE_NEXT) { + cycleContactCtxValue(_ctx_menu.selectedIndex(), 1); + return true; // still open -- the save/rebuild below waits for close + } if (res == PopupMenu::SELECTED && _num_contacts > 0) { ContactInfo ci; if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { @@ -1863,7 +1909,8 @@ public: } else if (sel == 4) { pinContactAction(ci); } - // sel 1 (Notif), 2 (Melody), 3 (Fav): already cycled via LEFT/RIGHT; ENTER just closes. + // sel 1 (Notif), 2 (Melody), 3 (Fav) are value rows -- Enter never + // selects them (see cycleContactCtxValue). } } if (res != PopupMenu::NONE) { @@ -1935,7 +1982,7 @@ public: _ctx_menu.addItem("Login..."); if (logged_in) _ctx_menu.addItem("Logout"); _ctx_fav_idx = logged_in ? 2 : 1; - _ctx_menu.addItem(_ctx_fav_item); + _ctx_menu.addValueItem(_ctx_fav_item); _ctx_pin_idx = _ctx_fav_idx + 1; _ctx_menu.addItem(_ctx_pin_item); return true; @@ -1956,10 +2003,10 @@ public: (ci.flags & 0x01) ? "Fav: ON" : "Fav: OFF"); _ctx_menu.begin("Contact options", 3); _ctx_menu.addItem("Mark as read"); - _ctx_menu.addItem(_ctx_notif_item); - _ctx_menu.addItem(_ctx_melody_item); + _ctx_menu.addValueItem(_ctx_notif_item); + _ctx_menu.addValueItem(_ctx_melody_item); _ctx_fav_idx = 3; - _ctx_menu.addItem(_ctx_fav_item); + _ctx_menu.addValueItem(_ctx_fav_item); _ctx_menu.addItem(_ctx_pin_item); _ctx_dirty = false; return true; @@ -1968,42 +2015,11 @@ public: } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { - // LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open). - if (!_pin_picker_active && _num_channels > 0) { - bool left = keyIsPrev(c); - bool right = keyIsNext(c); - if (left || right) { - static const char* NOTIF_LABELS[] = { "Default", "OFF", "ON" }; - static const char* ML[] = { "Global", "M1", "M2" }; - uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration - int sel = _ctx_menu.selectedIndex(); - if (sel == 1) { - uint8_t v = chNotifState(ch_idx); - v = right ? (v + 1) % 3 : (v + 2) % 3; - setChNotifState(ch_idx, v); - snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]); - _ctx_dirty = true; - } else if (sel == 2) { - uint8_t v = chNotifMelody(ch_idx); - v = right ? (v + 1) % 3 : (v + 2) % 3; - setChNotifMelody(ch_idx, v); - snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]); - _ctx_dirty = true; - } else if (sel == _ctx_fav_idx) { - NodePrefs* p2 = _task->getNodePrefs(); - if (p2) { - p2->ch_fav_bitmask ^= (1ULL << ch_idx); - bool is_fav = (p2->ch_fav_bitmask & (1ULL << ch_idx)); - snprintf(_ctx_fav_item, sizeof(_ctx_fav_item), is_fav ? "Fav: ON" : "Fav: OFF"); - _ctx_dirty = true; - // List rebuild is deferred to menu close: with the fav-only - // filter on, un-favouriting this channel removes it from the - // list, and rebuilding under the open menu would shift - // _channel_sel onto a different channel mid-interaction. - } - } - return true; - } + // LEFT/RIGHT -- and Enter, via VALUE_NEXT below -- cycle Notif/Melody/Fav + // in place; the menu stays open and only Back closes it. + if (!_pin_picker_active && (keyIsPrev(c) || keyIsNext(c))) { + cycleChannelCtxValue(_ctx_menu.selectedIndex(), keyIsNext(c) ? 1 : -1); + return true; } auto res = _ctx_menu.handleInput(c); if (_pin_picker_active) { @@ -2018,6 +2034,10 @@ public: } return true; } + if (res == PopupMenu::VALUE_NEXT) { + cycleChannelCtxValue(_ctx_menu.selectedIndex(), 1); + return true; // still open -- the save/rebuild below waits for close + } if (res == PopupMenu::SELECTED && _num_channels > 0) { uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration int sel = _ctx_menu.selectedIndex(); @@ -2050,7 +2070,8 @@ public: the_mesh.setChannelLocal(ch_idx, ch); _task->showAlert("Channel deleted", 1000); } - // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. + // sel 1/2/3 are value rows -- Enter never selects them + // (see cycleChannelCtxValue). } if (res != PopupMenu::NONE) { _task->savePrefsIfDirty(_ctx_dirty); @@ -2111,10 +2132,10 @@ public: else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); } _ctx_menu.begin("Channel options", 6); _ctx_menu.addItem("Mark all read"); - _ctx_menu.addItem(_ctx_notif_item); - _ctx_menu.addItem(_ctx_melody_item); + _ctx_menu.addValueItem(_ctx_notif_item); + _ctx_menu.addValueItem(_ctx_melody_item); _ctx_fav_idx = 3; - _ctx_menu.addItem(_ctx_fav_item); + _ctx_menu.addValueItem(_ctx_fav_item); _ctx_menu.addItem(_ctx_pin_item); _ctx_menu.addItem("Edit"); _ctx_menu.addItem("Delete"); diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 7d1852cf..ab312853 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -567,6 +567,7 @@ class NearbyScreen : public UIScreen { return e && ((e->contact_idx >= 0) || (_source == SRC_SCAN && e->is_known)); } + char _fav_label[12]; // "Fav: ON" / "Fav: OFF" -- rewritten in place by L/R char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer void openActionMenu() { @@ -589,6 +590,10 @@ class NearbyScreen : public UIScreen { _menu.addItem(label); _menu_actions[_menu_action_count++] = a; }; + auto addValue = [&](const char* label, Action a) { + _menu.addValueItem(label); + _menu_actions[_menu_action_count++] = a; + }; if (has_gps) add("Navigate", ACT_NAV); if (has_key) add("Ping", ACT_PING); @@ -597,7 +602,10 @@ class NearbyScreen : public UIScreen { // by pubkey prefix, so a name-only live-scan/channel row can't offer this. 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(e->fav ? "Fav: ON" : "Fav: OFF", ACT_FAV); + if (is_contact && has_key) { + snprintf(_fav_label, sizeof(_fav_label), e->fav ? "Fav: ON" : "Fav: OFF"); + addValue(_fav_label, ACT_FAV); + } if (is_contact && has_key) { if (is_pinned) snprintf(_pin_label, sizeof(_pin_label), "Unpin (slot %d)", _task->findFavouriteSlot(e->pub_key) + 1); @@ -606,10 +614,35 @@ class NearbyScreen : public UIScreen { } 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 + if (stored) addValue(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows add(stored ? "Discover scan" : "Rescan", ACT_SCAN); } + // Flip the selected contact's favourite flag and retitle the open menu row. + // The list re-sorts underneath (favourites first), but refreshKeepingSelection() + // re-finds this node, so the popup stays anchored to it. + void toggleFavSelected() { + const Entry* e = selected(); + if (!e || !e->has_key) return; + bool now_fav = !e->fav; + if (!the_mesh.setContactFavourite(e->pub_key, now_fav)) return; + snprintf(_fav_label, sizeof(_fav_label), now_fav ? "Fav: ON" : "Fav: OFF"); + refreshKeepingSelection(); + } + + // Advance the value on the menu's value rows (Sort, Fav). Both are two-state, + // so LEFT and RIGHT do the same thing here and Enter joins them. + void cycleMenuValue(int i) { + if (i < 0 || i >= _menu_action_count) return; + if (_menu_actions[i] == ACT_SORT) { + _sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST; + buildSortLabel(); + refresh(); + } else if (_menu_actions[i] == ACT_FAV) { + toggleFavSelected(); + } + } + void runAction(Action a) { switch (a) { case ACT_NAV: { @@ -644,11 +677,7 @@ class NearbyScreen : public UIScreen { } break; } - case ACT_FAV: { - const Entry* e = selected(); - if (e && e->has_key && the_mesh.setContactFavourite(e->pub_key, !e->fav)) refreshKeepingSelection(); - break; - } + case ACT_FAV: break; // value rows -- see cycleMenuValue() case ACT_PIN: { const Entry* e = selected(); if (e && e->has_key) togglePinToDial(e->pub_key); @@ -662,7 +691,7 @@ class NearbyScreen : public UIScreen { _task->openAdminFor(ci, false); // direct from Nodes -- Cancel should return here, not to a pick-list break; } - case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER + case ACT_SORT: break; // value rows -- see cycleMenuValue() case ACT_SCAN: enterScan(); break; } } @@ -951,23 +980,19 @@ public: } if (_ping_menu.active) { handlePingMenuInput(c); return true; } if (_menu.active) { - // LEFT/RIGHT on the Sort row toggles the value in-place and rebuilds the - // label; the popup stays open so the user can keep tapping. Other rows - // swallow L/R. ENTER on Sort just closes (value changes via L/R only). + // LEFT/RIGHT -- and Enter, which PopupMenu reports as VALUE_NEXT on a + // value row -- cycle Sort and Fav in place; the popup stays open so the + // user can keep tapping, and only Back closes it. Other rows swallow L/R. if (keyIsPrev(c) || keyIsNext(c)) { - int i = _menu.selectedIndex(); - if (i >= 0 && i < _menu_action_count && _menu_actions[i] == ACT_SORT) { - _sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST; - buildSortLabel(); - refresh(); - } + cycleMenuValue(_menu.selectedIndex()); return true; } auto res = _menu.handleInput(c); - if (res == PopupMenu::SELECTED) { + if (res == PopupMenu::VALUE_NEXT) { + cycleMenuValue(_menu.selectedIndex()); + } else if (res == PopupMenu::SELECTED) { int i = _menu.selectedIndex(); - if (i >= 0 && i < _menu_action_count && _menu_actions[i] != ACT_SORT) - runAction(_menu_actions[i]); + if (i >= 0 && i < _menu_action_count) runAction(_menu_actions[i]); } return true; } diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 801e41df..42ddc0cc 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -16,22 +16,39 @@ struct PopupMenu { int _cap; // actual visible cap, recomputed each render() bool active; const char* _title; + // Rows added via addValueItem(): they carry a value the caller cycles rather + // than an action to run, so Enter advances the value and leaves the menu open + // (see handleInput). One bit per row; PM_MAX_ITEMS fits in a uint32_t. + uint32_t _value_mask; - enum Result { NONE, SELECTED, CANCELLED }; + // VALUE_NEXT: Enter landed on a value row -- caller advances that row's value + // (same as its RIGHT step) and the menu stays open. + enum Result { NONE, SELECTED, CANCELLED, VALUE_NEXT }; - PopupMenu() : _count(0), _sel(0), _scroll(0), _cap(3), active(false), _title(nullptr) {} + PopupMenu() : _count(0), _sel(0), _scroll(0), _cap(3), active(false), _title(nullptr), + _value_mask(0) {} // `visible` is only a seed for the first frame: render() recomputes _cap from // the live display height, so it does not cap or pad the item list. void begin(const char* title, int visible = 3) { _count = 0; _sel = 0; _scroll = 0; _cap = visible; active = true; _title = title; + _value_mask = 0; } void addItem(const char* item) { if (_count < PM_MAX_ITEMS) _items[_count++] = item; } + // A row whose label shows a value ("Notif: ON", "Sort: Dist"). LEFT/RIGHT are + // the caller's to handle as always; this only makes Enter behave like RIGHT + // instead of picking the row and closing. + void addValueItem(const char* item) { + int i = _count; + addItem(item); + if (_count > i) _value_mask |= (1u << i); + } + int render(DisplayDriver& display) { // Everything is derived from the live font metrics so the box fits its // content on every display — including landscape e-ink, where the font (and @@ -134,8 +151,13 @@ struct PopupMenu { // Selection only moves here; render() keeps it scrolled into view. if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return NONE; } if (c == KEY_DOWN) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return NONE; } - if (c == KEY_ENTER) { active = false; return SELECTED; } - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { active = false; return CANCELLED; } + if (c == KEY_ENTER) { + if (_value_mask & (1u << _sel)) return VALUE_NEXT; // value row -- stays open + active = false; return SELECTED; + } + // Only Back closes a popup. Hold-Enter opens menus and cycles value rows; + // it is deliberately not a second way to go back. + if (c == KEY_CANCEL) { active = false; return CANCELLED; } return NONE; } diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 7b9c7cd5..bffcda4b 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -242,7 +242,7 @@ public: return true; } - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 37f71043..f2215934 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -81,8 +81,8 @@ public: _menu.begin("Options", 5); _menu.addItem(_menu_play_label); _menu.addItem(_menu_slot_label); - _menu.addItem(_menu_dur_label); - _menu.addItem(_menu_bpm_label); + _menu.addValueItem(_menu_dur_label); + _menu.addValueItem(_menu_bpm_label); _menu.addItem("Insert"); _menu.addItem("Delete"); _menu.addItem("Save & Exit"); @@ -163,6 +163,21 @@ public: return 200; } + void cycleMenuValue(int sel, int dir) { + if (sel == MI_DURATION && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + di = (dir > 0) ? (di + 1) & 0x03 : (di + 3) & 0x03; + _notes[_cursor] = packNote(p, o, di); + snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); + } else if (sel == MI_BPM) { + if (dir > 0) { if (_bpm_idx < 4) _bpm_idx++; } + else { if (_bpm_idx > 0) _bpm_idx--; } + snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); + } + } + bool handleInput(char c) override { bool up = (c == KEY_UP); bool down = (c == KEY_DOWN); @@ -173,24 +188,11 @@ public: bool cancel = (c == KEY_CANCEL); if (_menu.active) { - // LEFT/RIGHT cycle Duration and BPM in-place, menu stays open. - if (left || right) { - int sel = _menu.selectedIndex(); - if (sel == MI_DURATION && _cursor < _len) { - uint8_t p = notePitch(_notes[_cursor]); - uint8_t o = noteOctave(_notes[_cursor]); - uint8_t di = noteDurIdx(_notes[_cursor]); - di = right ? (di + 1) & 0x03 : (di + 3) & 0x03; - _notes[_cursor] = packNote(p, o, di); - snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); - } else if (sel == MI_BPM) { - if (right && _bpm_idx < 4) _bpm_idx++; - else if (left && _bpm_idx > 0) _bpm_idx--; - snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); - } - return true; - } + // LEFT/RIGHT -- and Enter, via VALUE_NEXT -- cycle Duration and BPM in + // place; the menu stays open and only Back closes it. + if (left || right) { cycleMenuValue(_menu.selectedIndex(), right ? 1 : -1); return true; } auto res = _menu.handleInput(c); + if (res == PopupMenu::VALUE_NEXT) { cycleMenuValue(_menu.selectedIndex(), 1); return true; } if (res == PopupMenu::SELECTED) { switch ((MenuIdx)_menu.selectedIndex()) { case MI_PLAY: @@ -201,8 +203,8 @@ public: _task->stopMelody(); this->selectSlot(1 - _slot); break; - case MI_DURATION: break; // already handled by LEFT/RIGHT - case MI_BPM: break; // already handled by LEFT/RIGHT + case MI_DURATION: break; // value rows -- see cycleMenuValue() + case MI_BPM: break; case MI_INSERT: if (_len < MAX_NOTES) { int ins = (_cursor < _len) ? _cursor + 1 : _cursor; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 77a4a597..5dedeb30 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -959,9 +959,9 @@ public: #if AUTO_OFF_MILLIS > 0 if (_selected == AUTO_OFF && p) { int idx = autoOffIndex(); - if (right) idx = (idx + 1) % AUTO_OFF_COUNT; - if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; - if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } + if (right || enter) idx = (idx + 1) % AUTO_OFF_COUNT; + else if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; + if (left || right || enter) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } } #endif if (_selected == AUTO_LOCK && p && (left || right || enter)) { @@ -975,15 +975,15 @@ public: } if (_selected == LOW_BAT && p) { int idx = lowBatIndex(); - if (right) idx = (idx + 1) % LOW_BAT_COUNT; - if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; - if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } + if (right || enter) idx = (idx + 1) % LOW_BAT_COUNT; + else if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; + if (left || right || enter) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } } #if ENV_INCLUDE_GPS == 1 - if (_selected == GPS_DUTY_CYCLE && p && (left || right)) { + if (_selected == GPS_DUTY_CYCLE && p && (left || right || enter)) { int idx = gpsDutyIndex(); - if (right) idx = (idx + 1) % GPS_DUTY_COUNT; - if (left) idx = (idx + GPS_DUTY_COUNT - 1) % GPS_DUTY_COUNT; + if (right || enter) idx = (idx + 1) % GPS_DUTY_COUNT; + else if (left) idx = (idx + GPS_DUTY_COUNT - 1) % GPS_DUTY_COUNT; p->gps_interval = GPS_DUTY_OPTS[idx]; _task->applyGpsInterval(); _dirty = true; @@ -1049,9 +1049,9 @@ public: } if (_selected == BATT_DISPLAY && p) { int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; - if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; - if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; - if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } + if (right || enter) idx = (idx + 1) % BATT_DISPLAY_COUNT; + else if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; + if (left || right || enter) { p->batt_display_mode = idx; _dirty = true; return true; } } #if FEAT_CLOCK_SECONDS_SETTING if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index b79e8bbd..45281f76 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -108,7 +108,7 @@ public: } bool handleInput(char c) override { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } switch (_acc.handleInput(c)) { case AccordionList::ACTIVATED: { const AccordionList::Row& r = _acc.selected(); diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 44029ebc..3e139df6 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -161,6 +161,11 @@ public: return true; // swallow elsewhere } auto res = _action_menu.handleInput(c); + if (res == PopupMenu::VALUE_NEXT) { + int idx = _action_menu.selectedIndex(); + if (idx >= 0 && idx < _act_count) cycleSetting((ActionId)_act_map[idx], 1); + return true; + } if (res == PopupMenu::SELECTED) { // GPS-off confirmation popup: rows aren't ActionIds, route by level. if (_menu_level == ML_CONFIRM_GPS) { @@ -177,13 +182,13 @@ public: switch (act) { case ACT_FILE: buildFileMenu(); return true; // descend into submenu case ACT_SETTINGS: buildSettingsMenu(); return true; - // Settings rows: Enter advances/toggles the value and keeps focus. + // Value rows -- Enter reaches them as VALUE_NEXT above, never here. case ACT_MIN_DIST: case ACT_UNITS: case ACT_GRID: case ACT_MARK_AVG: case ACT_AUTOSAVE: - case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; + case ACT_AUTOPAUSE: return true; case ACT_SHARE_NOW: shareMyLocationNow(); break; case ACT_TOGGLE: // Starting a trail with GPS switched off logs nothing and just @@ -346,6 +351,14 @@ private: _action_menu.addItem(label); } + // A settings row: its label carries a value, so Enter advances it and leaves + // the popup open (PopupMenu reports VALUE_NEXT) instead of picking the row. + void pushSetting(ActionId id, const char* label) { + if (_act_count >= (int)sizeof(_act_map)) return; + _act_map[_act_count++] = (uint8_t)id; + _action_menu.addValueItem(label); + } + bool fileMenuHasItems() const { return !_store->empty() || savedTrailExists(); } // Hold-Enter entry point — always opens the short main menu. @@ -398,12 +411,12 @@ private: _menu_level = ML_SETTINGS; _act_count = 0; _action_menu.begin("Settings", 4); - pushAction(ACT_MIN_DIST, _act_min_dist_label); - pushAction(ACT_AUTOPAUSE, _act_autopause_label); - pushAction(ACT_MARK_AVG, _act_mark_avg_label); - pushAction(ACT_AUTOSAVE, _act_autosave_label); - if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label); - if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label); + pushSetting(ACT_MIN_DIST, _act_min_dist_label); + pushSetting(ACT_AUTOPAUSE, _act_autopause_label); + pushSetting(ACT_MARK_AVG, _act_mark_avg_label); + pushSetting(ACT_AUTOSAVE, _act_autosave_label); + if (_view == V_SUMMARY) pushSetting(ACT_UNITS, _act_units_label); + if (_view == V_MAP) pushSetting(ACT_GRID, _act_grid_label); } // Cycle a settings value. Returns true if `act` was a settings row. @@ -432,14 +445,6 @@ private: bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); } bool useImperial() const { return _task && _task->useImperial(); } - // After Enter on a settings row, the popup auto-closes per PopupMenu's - // semantics. Re-open the Settings submenu with focus restored to that row so - // the user can continue cycling. - void reopenSettingsAt(int sel) { - buildSettingsMenu(); - _action_menu.setSelected(sel); - } - void cycleMinDelta(NodePrefs* p, int dir) { uint8_t idx = p->trail_min_delta_idx; if (idx >= TrailStore::MIN_DELTA_COUNT) idx = 0; diff --git a/release-notes.md b/release-notes.md index 5302137b..29940fab 100644 --- a/release-notes.md +++ b/release-notes.md @@ -5,7 +5,7 @@ - **Long names and labels scroll into view when selected instead of staying stuck behind an ellipsis.** Any selected row that would otherwise truncate — contacts, channels, messages, settings values, and more — now pauses, scrolls to reveal the full text, then swings back to the start and repeats, for as long as it stays selected; unselected rows keep the plain static ellipsis. E-ink gets its own slower cadence to suit the panel. - **Messages sent from the phone app now show up in the device's own chat history**, not just on the phone — open a DM, room, or channel on the device and a message you just sent from the app is right there, with the same delivery-confirmation markers (pending/delivered for DMs, pending/relayed for channels) a message composed on the device itself gets. - **The Favourites Dial takes channels and room servers, not just contacts.** Pinning an empty tile now opens the Messages screen's own Direct/Channels/Rooms browse — the same list you already use to open a conversation — instead of a picker built on the dial, and the tile opens the right thing: a DM, a channel history, or a room (running its login handshake first, which the dial used to skip). Channel tiles are marked with a leading `#` and carry their own unread badge. You can also pin straight from the Messages context menus, which every kind now has, and the dial itself gained **Unpin** / **Replace** under Hold Enter on a filled tile. -- **Favourites now behave the same on every list.** A favourite is marked with a ★ on its row and sorted above everything else — in Messages (contacts, rooms, channels) and in Tools › Nodes alike. New **Settings › Contacts › Favs top** turns the sorting off if you'd rather keep the natural order; it's on by default. On Nodes, the old `Favourite` menu row actually pinned to the dial despite its name — it's now split into a real `Fav: ON/OFF` and a separate `Pin to dial`. +- **Favourites now behave the same on every list.** A favourite is marked with a ★ on its row and sorted above everything else — in Messages (contacts, rooms, channels) and in Tools › Nodes alike, and the Locator target picker marks them too. New **Settings › Contacts › Favs top** turns the sorting off if you'd rather keep the natural order; it's on by default. On Nodes, the old `Favourite` menu row actually pinned to the dial despite its name — it's now split into a real `Fav: ON/OFF` and a separate `Pin to dial`. - **Contacts and rooms can be marked as favourites from the device.** `Fav: ON/OFF` joins the contact and room context menus, alongside the one channels already had. It's the same starred flag the phone app shows, so it syncs both ways — and it's what the Settings › Contacts › `DMs`/`Rooms` = `Fav` list filters read, which previously could only be satisfied by starring someone in the app. - **"Path" / "Relayed by" — see which repeaters actually carried a message.** Hold Enter on a message (list or fullscreen) and, when the data is available, the Options menu gains a new row: **Path (N hops)** on a received message lists the repeaters it actually travelled through, in order; **Relayed by (N)** on your own channel post lists every distinct repeater heard echoing it back into the mesh. Each hop resolves to a contact's name where known, or a short hex tag otherwise. @@ -13,6 +13,10 @@ - **A room server could end up pinned to the Favourites Dial and then never be removed.** The pin picker's "recent conversations" tier didn't filter by contact type, and room posts share the DM history, so a room could appear there; opening it from the dial then skipped the login handshake, and Unpin only existed in the chat contact list, which rooms never appear in. Rooms are now pinned deliberately (and open correctly), and any tile can be unpinned from the dial itself. - **Bluetooth connect/disconnect/reconnect cycles (e.g. a phone drifting in and out of range) could make the device nearly unresponsive for a few seconds at a time**, including briefly after turning BLE off. A failed BLE notify was retried on a fixed short interval, repeatedly hitting a blocking wait deep in the BLE stack while the link stayed marginal. Failed sends now back off exponentially (capped at 2s) instead of hammering the same blocking call. +- **Enter on a `Notif:` / `Melody:` / `Fav:` / `Sort:` row in a context menu now changes the value instead of closing the menu.** Those rows show a value you step through with LEFT/RIGHT, but Enter treated them as ordinary menu picks and dismissed the popup, so changing two of them meant reopening the menu in between. Enter now advances the value and the menu stays open — only Back closes it — across the Messages contact/room/channel menus, Tools › Nodes, and the Ringtone editor, matching what Trail › Settings already did. The Nodes `Fav` row was the worst of it: LEFT/RIGHT did nothing there at all, so the only way to toggle a favourite was an Enter that closed the menu on every flip. +- **Settings rows Auto-off, Low battery, GPS pwr and Battery ignored Enter**, even though their options wrap around exactly like the melody, keyboard and clock rows next to them, where Enter has always stepped to the next value. They now accept Enter too. Rows that ramp between fixed ends instead of wrapping — Brightness, Volume, TX Pwr, Timezone, SF/BW/CR — stay LEFT/RIGHT-only, since there is nothing to wrap to. +- **The Settings › Contacts filter rows read `DM`, `all` and `fav` in three different casings** from each other and from the rest of the screen. Now `DMs`, `All` and `Fav`. +- **Hold Enter no longer doubles as a second Back key.** On Tools, Locator, Live Share, Repeater, Remote Bot, Auto-Advert, GPIO, Compass, the Dashboard config and the Messages navigate view it quietly meant "go back", while on other screens the same gesture opens a context menu — so the same long press did two unrelated things depending on where you were. It now only ever opens a menu (or does nothing where there is none), and Back is the single way back. This also applies inside an open popup, which Hold Enter used to dismiss. ---