From db8c52ea9a57c47ca04032e54a816041d290bbcd Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 29 May 2026 00:25:02 +0200 Subject: [PATCH] fix(ui): standardize context menu cycling + PopupMenu height clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopupMenu: - _cap now uses max_by_height as a hard ceiling (never draw outside screen). Previously max(_visible, max_by_height) let callers exceed the screen on OLED 64px. Now: OLED→4 items, landscape e-ink→10, portrait e-ink→22. QuickMsgScreen: - Contact context menu: Notif and Melody cycle with LEFT/RIGHT in-place; ENTER closes without re-cycling. - Channel context menu: Notif, Melody and Fav same pattern. RingtoneEditorScreen: - Migrated from bespoke menu to PopupMenu (same pattern as everywhere else). - Duration (1/4…1/32) and BPM (60…180) are now single rows that cycle with LEFT/RIGHT; separate BPM+/BPM- rows removed. - Fixed _menu_dur_label buffer too small for "Duration: 1/16" (14→16 bytes). NearbyScreen: - Removed redundant "Back" item from context menu (Cancel key navigates back). TrailScreen: - Grid toggle responds to LEFT/RIGHT in addition to ENTER. - Export labels: "Export GPX (live/saved)" → "Export (live/saved)" to fit PM_BW. Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 11 +- examples/companion_radio/ui-new/PopupMenu.h | 7 +- .../companion_radio/ui-new/QuickMsgScreen.h | 99 +++++--- .../ui-new/RingtoneEditorScreen.h | 221 ++++++++---------- examples/companion_radio/ui-new/TrailScreen.h | 5 +- 5 files changed, 178 insertions(+), 165 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 6bb339d5..bdb14e6f 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -516,21 +516,16 @@ public: // ── context menu ───────────────────────────────────────────────────────── if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED) { - if (_ctx_menu.selectedIndex() == 0) - enterDiscoverMode(); - else - _task->gotoToolsScreen(); - } + if (res == PopupMenu::SELECTED) + enterDiscoverMode(); return true; } // ── list view ──────────────────────────────────────────────────────────── if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { - _ctx_menu.begin("Options", 2); + _ctx_menu.begin("Options", 1); _ctx_menu.addItem("Discover nearby"); - _ctx_menu.addItem("Back"); return true; } if (c == KEY_UP && _sel > 0) { diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 05fa6ac8..59cdfd57 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -35,11 +35,12 @@ struct PopupMenu { } int render(DisplayDriver& display) { - // On tall displays (portrait e-ink) show as many items as fit; - // on small displays fall back to the caller-specified _visible cap. + // Hard ceiling: never show more items than physically fit on screen. + // On tall displays (portrait e-ink) this expands beyond _visible; + // on small displays (OLED 64px) it clamps below _visible. int max_by_height = (display.height() - PM_BY - 12) / PM_ITEM_H; if (max_by_height < 1) max_by_height = 1; - _cap = (max_by_height > _visible) ? max_by_height : _visible; + _cap = max_by_height; int vis = (_count < _cap) ? _count : _cap; int bh = 12 + vis * PM_ITEM_H; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index f608e7d8..3feed0a7 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1021,6 +1021,33 @@ public: } else if (_phase == CONTACT_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { + // LEFT/RIGHT cycle Notif/Melody in-place (menu stays open). + if (!_pin_picker_active && _num_contacts > 0) { + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + 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; + } + } + return true; + } + } auto res = _ctx_menu.handleInput(c); if (_pin_picker_active) { // Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot. @@ -1041,17 +1068,10 @@ public: if (res == PopupMenu::SELECTED && _num_contacts > 0) { ContactInfo ci; if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { - if (_ctx_menu.selectedIndex() == 0) { + int sel = _ctx_menu.selectedIndex(); + if (sel == 0) { _task->clearDMUnread(ci.id.pub_key); - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = dmNotifState(ci.id.pub_key); - setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); - _ctx_dirty = true; - } else if (_ctx_menu.selectedIndex() == 2) { - uint8_t slot = dmMelodySlot(ci.id.pub_key); - setDmMelody(ci.id.pub_key, (slot + 1) % 3); - _ctx_dirty = true; - } else if (_ctx_menu.selectedIndex() == 3) { + } else if (sel == 3) { // Pin / Unpin int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key); if (pinned_slot >= 0) { @@ -1061,12 +1081,10 @@ public: snprintf(alert, sizeof(alert), "Unpinned (slot %d)", pinned_slot + 1); _task->showAlert(alert, 800); } else { - // Open slot picker. Each label shows "Slot N: " or "Slot N: empty". for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) { if (_task->isFavouriteSlotEmpty(s)) { snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: empty", s + 1); } else { - // Resolve current occupant name by walking contacts. char nm[16] = "?"; NodePrefs* p = _task->getNodePrefs(); if (p) { @@ -1088,6 +1106,7 @@ public: _pin_picker_active = true; } } + // sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes. } } if (res != PopupMenu::NONE && _ctx_dirty) { @@ -1140,29 +1159,49 @@ 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 (_num_channels > 0) { + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + if (left || right) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + static const char* ML[] = { "global", "M1", "M2" }; + uint8_t ch_idx = _channel_indices[_channel_sel]; + 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 == 3) { + 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_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); + _ctx_dirty = true; + buildChannelList(); + if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0; + } + } + return true; + } + } auto res = _ctx_menu.handleInput(c); if (res == PopupMenu::SELECTED && _num_channels > 0) { uint8_t ch_idx = _channel_indices[_channel_sel]; - if (_ctx_menu.selectedIndex() == 0) { + int sel = _ctx_menu.selectedIndex(); + if (sel == 0) { _ch_unread[ch_idx] = 0; - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = chNotifState(ch_idx); - setChNotifState(ch_idx, (nstate + 1) % 3); - _ctx_dirty = true; - } else if (_ctx_menu.selectedIndex() == 2) { - uint8_t slot = chNotifMelody(ch_idx); - setChNotifMelody(ch_idx, (slot + 1) % 3); - _ctx_dirty = true; - } else { - // Toggle favourite - NodePrefs* p2 = _task->getNodePrefs(); - if (p2) { - p2->ch_fav_bitmask ^= (1ULL << ch_idx); - _ctx_dirty = true; - buildChannelList(); // refilter if ch_fav_only is active - if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0; - } } + // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. } if (res != PopupMenu::NONE && _ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 9767d30c..e6b2bdab 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -6,8 +6,10 @@ class RingtoneEditorScreen : public UIScreen { UITask* _task; NodePrefs* _prefs; - static const int MAX_NOTES = 32; - static const int MENU_VISIBLE = 5; + static const int MAX_NOTES = 32; + + // Menu item indices + enum MenuIdx { MI_PLAY=0, MI_SWITCH, MI_DURATION, MI_BPM, MI_INSERT, MI_DELETE, MI_SAVE, MI_DISCARD, MI_COUNT }; int _visible_notes = 7; // updated in render(); used by clampScroll() @@ -22,16 +24,12 @@ class RingtoneEditorScreen : public UIScreen { int _slot; // 0=melody1, 1=melody2 int _cursor; int _scroll; - bool _menu_open; - int _menu_sel; - int _menu_scroll; + PopupMenu _menu; char _play_buf[220]; // persistent RTTTL buffer — library holds a pointer into it - - enum MenuOpt { - M_PLAY_STOP, M_SWITCH, M_DURATION, M_BPM_UP, M_BPM_DOWN, - M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT - }; - static const char* MENU_LABELS[M_COUNT]; + char _menu_play_label[8]; + char _menu_slot_label[16]; + char _menu_dur_label[16]; + char _menu_bpm_label[10]; static uint8_t notePitch(uint8_t b) { return b & 0x07; } static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } @@ -68,11 +66,26 @@ public: uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0; _len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0; if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes)); - _cursor = 0; - _scroll = 0; - _menu_open = false; - _menu_sel = 0; - _menu_scroll = 0; + _cursor = 0; + _scroll = 0; + _menu.active = false; + } + + void openMenu() { + snprintf(_menu_play_label, sizeof(_menu_play_label), "%s", _task->isMelodyPlaying() ? "Stop" : "Play"); + snprintf(_menu_slot_label, sizeof(_menu_slot_label), "Melody %d", (_slot == 0) ? 2 : 1); + uint8_t di = (_cursor < _len) ? noteDurIdx(_notes[_cursor]) : 0; + snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); + snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); + _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.addItem("Insert"); + _menu.addItem("Delete"); + _menu.addItem("Save & Exit"); + _menu.addItem("Discard"); } int render(DisplayDriver& display) override { @@ -144,34 +157,7 @@ public: display.setCursor(0, bottom_y); display.print("ENT:oct MENU:opts"); - if (_menu_open) { - const int menu_ih = display.lineStep(); - const int mw = 100, mh = MENU_VISIBLE * menu_ih + 4; - int mx = (display.width() - mw) / 2; - int my = (display.height() - mh) / 2; - display.setColor(DisplayDriver::DARK); - display.fillRect(mx, my, mw, mh); - display.setColor(DisplayDriver::LIGHT); - display.drawRect(mx, my, mw, mh); - for (int i = 0; i < MENU_VISIBLE; i++) { - int item = _menu_scroll + i; - if (item >= M_COUNT) break; - int iy = my + 2 + i * menu_ih; - bool sel = (item == _menu_sel); - display.drawSelectionRow(mx + 1, iy - 1, mw - 2, menu_ih, sel); - display.setCursor(mx + 4, iy); - if (item == M_PLAY_STOP) - display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); - else if (item == M_SWITCH) - display.print(_slot == 0 ? "Edit Melody 2" : "Edit Melody 1"); - else - display.print(MENU_LABELS[item]); - display.setColor(DisplayDriver::LIGHT); - } - int cw = display.getCharWidth(); - if (_menu_scroll > 0) { display.setCursor(mx + mw - cw - 1, my + 2); display.print("^"); } - if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - cw - 1, my + mh - menu_ih); display.print("v"); } - } + if (_menu.active) _menu.render(display); return 200; } @@ -185,90 +171,84 @@ public: bool menu_key = (c == KEY_CONTEXT_MENU); bool cancel = (c == KEY_CANCEL); - if (_menu_open) { - if (cancel) { _menu_open = false; return true; } - if (up && _menu_sel > 0) { - _menu_sel--; - if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel; + 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; } - if (down && _menu_sel < M_COUNT - 1) { - _menu_sel++; - if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1; - return true; - } - if (!enter) return true; - - _menu_open = false; - switch ((MenuOpt)_menu_sel) { - case M_PLAY_STOP: - if (_task->isMelodyPlaying()) { + auto res = _menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + switch ((MenuIdx)_menu.selectedIndex()) { + case MI_PLAY: + if (_task->isMelodyPlaying()) { _task->stopMelody(); } + else if (_len > 0) { buildRTTTL(); _task->playMelody(_play_buf); } + break; + case MI_SWITCH: _task->stopMelody(); - } else if (_len > 0) { - buildRTTTL(); - _task->playMelody(_play_buf); - } - break; - case M_SWITCH: - _task->stopMelody(); - this->enter(1 - _slot); - break; - case M_DURATION: - if (_cursor < _len) { - uint8_t p = notePitch(_notes[_cursor]); - uint8_t o = noteOctave(_notes[_cursor]); - uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03; - _notes[_cursor] = packNote(p, o, di); - } - break; - case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break; - case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break; - case M_INSERT: - if (_len < MAX_NOTES) { - int ins = (_cursor < _len) ? _cursor + 1 : _cursor; - for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; - _notes[ins] = packNote(1, 5, 1); - _len++; - _cursor = ins; - clampScroll(); - } - break; - case M_DELETE: - if (_len > 0 && _cursor < _len) { - for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; - _len--; - if (_cursor >= _len && _len > 0) _cursor = _len - 1; - else if (_len == 0) _cursor = 0; - clampScroll(); - } - break; - case M_SAVE: - if (_prefs) { - if (_slot == 1) { - _prefs->ringtone2_bpm_idx = _bpm_idx; - _prefs->ringtone2_len = _len; - memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); - } else { - _prefs->ringtone_bpm_idx = _bpm_idx; - _prefs->ringtone_len = _len; - memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + this->enter(1 - _slot); + break; + case MI_DURATION: break; // already handled by LEFT/RIGHT + case MI_BPM: break; // already handled by LEFT/RIGHT + case MI_INSERT: + if (_len < MAX_NOTES) { + int ins = (_cursor < _len) ? _cursor + 1 : _cursor; + for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; + _notes[ins] = packNote(1, 5, 1); + _len++; + _cursor = ins; + clampScroll(); } - the_mesh.savePrefs(); - } - _task->stopMelody(); - _task->gotoToolsScreen(); - break; - case M_DISCARD: - _task->stopMelody(); - _task->gotoToolsScreen(); - break; - default: break; + break; + case MI_DELETE: + if (_len > 0 && _cursor < _len) { + for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; + _len--; + if (_cursor >= _len && _len > 0) _cursor = _len - 1; + else if (_len == 0) _cursor = 0; + clampScroll(); + } + break; + case MI_SAVE: + if (_prefs) { + if (_slot == 1) { + _prefs->ringtone2_bpm_idx = _bpm_idx; + _prefs->ringtone2_len = _len; + memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); + } else { + _prefs->ringtone_bpm_idx = _bpm_idx; + _prefs->ringtone_len = _len; + memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + } + the_mesh.savePrefs(); + } + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + case MI_DISCARD: + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + default: break; + } } return true; } if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; } - if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } + if (menu_key) { openMenu(); return true; } if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } if (right) { @@ -316,6 +296,3 @@ const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; -const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { - "Play/Stop", "Switch Melody", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" -}; diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 3d7f6df2..61ede5e9 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -80,6 +80,7 @@ public: int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1; if (_act_map[idx] == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (_act_map[idx] == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } + if (_act_map[idx] == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; } } return true; // swallow on action rows } @@ -229,10 +230,10 @@ private: _act_map[_act_count++] = ACT_LOAD; _action_menu.addItem("Load trail"); } if (!_store->empty()) { - _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX (live)"); + _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export (live)"); } if (saved) { - _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export GPX (saved)"); + _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export (saved)"); } if (!_store->empty()) { _act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail");