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
parent 5caac0b693
commit db8c52ea9a
5 changed files with 178 additions and 165 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"
};

View File

@@ -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");