fix(ui): standardize context menu cycling + PopupMenu height clamping

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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-29 00:25:02 +02:00
co-authored by Claude Sonnet 4.6
parent 5caac0b693
commit db8c52ea9a
5 changed files with 178 additions and 165 deletions
@@ -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: <name>" 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;