diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 97971c5b..6364409a 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -415,7 +415,7 @@ class NearbyScreen : public UIScreen { void startDeleteConfirm() { const Entry* e = selected(); if (!e || !e->has_key || !entryIsContact(e)) return; - _confirm.begin("Delete contact?", 2); + _confirm.begin("Delete contact?", 2, true); _confirm.addItem("Delete"); _confirm.addItem("Cancel"); _confirm.setSelected(1); @@ -451,7 +451,7 @@ class NearbyScreen : public UIScreen { void rebuildPingMenu() { int keep = _ping_menu.selectedIndex(); // preserve selection across a rebuild - _ping_menu.begin("Ping", 4); + _ping_menu.begin("Ping", 4, true); _ping_menu.addItem("Send"); if (_ping_time_str[0]) _ping_menu.addItem(_ping_time_str); if (_ping_snr_out_str[0]) _ping_menu.addItem(_ping_snr_out_str); @@ -543,7 +543,7 @@ class NearbyScreen : public UIScreen { buildSortLabel(); _menu_action_count = 0; - _menu.begin("Options", 10); + _menu.begin("Options", 10, true); auto add = [&](const char* label, Action a) { _menu.addItem(label); _menu_actions[_menu_action_count++] = a; @@ -611,7 +611,7 @@ class NearbyScreen : public UIScreen { void renderStoredDetail(DisplayDriver& display) { const Entry& e = _entries[_sel]; const int hdr = display.listStart(); // content top (gap below the header separator) - display.drawInvertedHeader(e.name); + display.drawInvertedHeader(e.name, true, ctxMenuOpen()); int step = display.lineStep(); if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; @@ -648,7 +648,7 @@ class NearbyScreen : public UIScreen { char label[32]; if (e.name[0]) { strncpy(label, e.name, 31); label[31] = '\0'; } else { snprintf(label, sizeof(label), "[%s]", typeName(e.type)); } - display.drawInvertedHeader(label); + display.drawInvertedHeader(label, true, ctxMenuOpen()); char b64[48]; pubKeyToBase64(e.pub_key, b64, sizeof(b64)); @@ -679,6 +679,10 @@ class NearbyScreen : public UIScreen { display.print(e.is_known ? "Status: known" : "Status: new"); } + // Any Hold-Enter popup on screen → highlight the ≡ hint so it reads as the + // source of the open menu. + bool ctxMenuOpen() const { return _menu.active || _confirm.active || _ping_menu.active; } + // Draw whichever popup is active over the current view. Returns true if one was. bool renderActivePopup(DisplayDriver& display) { updatePingMenuState(); @@ -719,6 +723,7 @@ class NearbyScreen : public UIScreen { int lx = ax - gap; // next left tab's right edge int rx = ax + w[_filter] + gap; // next right tab's left edge + int rx_limit = display.width() - display.menuHintWidth(); // leave room for the ≡ hint bool lfit = true, rfit = true; for (int k = 1; k <= F_COUNT / 2 && (lfit || rfit); k++) { int li = (_filter - k + F_COUNT) % F_COUNT; @@ -731,12 +736,13 @@ class NearbyScreen : public UIScreen { // Skip the right side when it lands on the same tab as the left (the single // opposite tab on an even count) so it isn't drawn twice. if (rfit && ri != li) { - if (rx + w[ri] <= display.width()) { drawTab(display, rx, w[ri], FILTER_LABELS[ri], false); rx += w[ri] + gap; } + if (rx + w[ri] <= rx_limit) { drawTab(display, rx, w[ri], FILTER_LABELS[ri], false); rx += w[ri] + gap; } else rfit = false; } } display.setColor(DisplayDriver::LIGHT); display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH()); + display.drawContextMenuHint(DisplayDriver::LIGHT, ctxMenuOpen()); // Nodes list has a Hold-Enter menu } public: @@ -825,7 +831,7 @@ public: } else if (_scanning) snprintf(title, sizeof(title), "SCANNING (%d)", _count); else if (_count == 0) snprintf(title, sizeof(title), "SCAN: none"); else snprintf(title, sizeof(title), "SCAN (%d)", _count); - display.drawCenteredHeader(title); + display.drawCenteredHeader(title, true, ctxMenuOpen()); } else { // Stored list: the filter is a visible tab strip (LEFT/RIGHT switches tabs). drawFilterTabs(display); diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index d0e482db..9a86ea97 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -17,14 +17,16 @@ struct PopupMenu { int _cap; // actual visible cap, updated each render() bool active; const char* _title; + bool anchor_tr = false; // opt-in: drop from the top-right ≡ hint instead of centring enum Result { NONE, SELECTED, CANCELLED }; PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), _cap(3), active(false), _title(nullptr) {} - void begin(const char* title, int visible = 3) { + void begin(const char* title, int visible = 3, bool anchor_top_right = false) { _count = 0; _sel = 0; _scroll = 0; _visible = visible; _cap = visible; active = true; _title = title; + anchor_tr = anchor_top_right; } void addItem(const char* item) { @@ -86,8 +88,17 @@ struct PopupMenu { int bh = title_h + vis * item_h + 1; // +1 keeps the last row off the bottom border // Centre on screen (clamped to the margins on small displays). - int bx = (display.width() - bw) / 2; - int by = (display.height() - bh) / 2; + int bx, by; + if (anchor_tr) { + // Drop out of the top-right ≡ hint: right-aligned under the header, so the + // menu reads as emerging from the glyph that spawned it. + bx = display.width() - bw - margin; + by = display.headerH(); + if (by + bh > display.height() - margin) by = display.height() - margin - bh; + } else { + bx = (display.width() - bw) / 2; + by = (display.height() - bh) / 2; + } if (bx < margin) bx = margin; if (by < margin) by = margin; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 7ecf2342..0ae3ba10 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -255,7 +255,7 @@ class QuickMsgScreen : public UIScreen { int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0); if (n == 0) return; _fs_act_n = 0; - _ctx_menu.begin("Options", n); + _ctx_menu.begin("Options", n, true); if (reply_allowed) { _ctx_menu.addItem("Reply"); _fs_act[_fs_act_n++] = FS_REPLY; } if (has_loc) { _ctx_menu.addItem("Navigate"); _fs_act[_fs_act_n++] = FS_NAV; _ctx_menu.addItem("Save waypoint"); _fs_act[_fs_act_n++] = FS_SAVE; } @@ -791,7 +791,7 @@ public: int cw = display.getCharWidth(); if (_phase == MODE_SELECT) { - display.drawCenteredHeader("MESSAGE"); + display.drawCenteredHeader("MESSAGE", true, _ctx_menu.active); const char* opts[] = { "Direct message", "Channels", "Room Servers" }; int badges[3] = { getDMUnreadTotal(), @@ -811,7 +811,7 @@ public: if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == CONTACT_PICK) { - display.drawCenteredHeader(_room_mode ? "SELECT ROOM" : "SELECT CONTACT"); + display.drawCenteredHeader(_room_mode ? "SELECT ROOM" : "SELECT CONTACT", true, _ctx_menu.active); if (_num_contacts == 0) { display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites"); @@ -839,7 +839,7 @@ public: if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == CHANNEL_PICK) { - display.drawCenteredHeader("SELECT CHANNEL"); + display.drawCenteredHeader("SELECT CHANNEL", true, _ctx_menu.active); if (_num_channels == 0) { display.drawTextCentered(display.width()/2, display.height()/2, "No channels"); @@ -895,7 +895,7 @@ public: char title[24]; snprintf(title, sizeof(title), "%.23s", filtered_name); - display.drawCenteredHeader(title); + display.drawCenteredHeader(title, true, _ctx_menu.active); int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); uint32_t now_ts = rtc_clock.getCurrentTime(); @@ -1024,7 +1024,7 @@ public: the_mesh.getChannel(_sel_channel_idx, ch); char title[24]; snprintf(title, sizeof(title), "%.23s", ch.name); - display.drawCenteredHeader(title); + display.drawCenteredHeader(title, true, _ctx_menu.active); int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); uint32_t now_ts = rtc_clock.getCurrentTime(); @@ -1200,7 +1200,7 @@ public: if (c == KEY_CONTEXT_MENU) { // PopupMenu stores the title pointer verbatim — use static strings. static const char* MODE_TITLES[] = { "DM options", "Channel options", "Room options" }; - _ctx_menu.begin(MODE_TITLES[_mode_sel < 3 ? _mode_sel : 0], 1); + _ctx_menu.begin(MODE_TITLES[_mode_sel < 3 ? _mode_sel : 0], 1, true); _ctx_menu.addItem("Mark all read"); return true; } @@ -1314,7 +1314,7 @@ public: snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: %s", s + 1, nm); } } - _ctx_menu.begin("Pick slot", 3); + _ctx_menu.begin("Pick slot", 3, true); for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]); _pin_picker_active = true; } @@ -1355,7 +1355,7 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) { - _ctx_menu.begin("Room options", 1); + _ctx_menu.begin("Room options", 1, true); _ctx_menu.addItem("Login..."); return true; } @@ -1371,7 +1371,7 @@ public: int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key); if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1); else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); - _ctx_menu.begin("Contact options", 3); + _ctx_menu.begin("Contact options", 3, true); _ctx_menu.addItem("Mark as read"); _ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_melody_item); @@ -1471,7 +1471,7 @@ public: { NodePrefs* p2 = _task->getNodePrefs(); bool is_fav = p2 && (p2->ch_fav_bitmask & (1ULL << ch_idx)); snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); } - _ctx_menu.begin("Channel options", 4); + _ctx_menu.begin("Channel options", 4, true); _ctx_menu.addItem("Mark all read"); _ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_melody_item); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index abc8e08f..5aba33e1 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -207,23 +207,52 @@ public: // Inverted title bar: light background, dark ellipsized label, then the // standard separator line. The label is UTF-8 translated by // drawTextEllipsized. Leaves ink colour LIGHT for following content. - void drawInvertedHeader(const char* label) { + // Pixel width the ≡ context-menu hint reserves at the header's right edge. + int menuHintWidth() const { return getCharWidth() + 3; } + + // Small ≡ glyph at the header's top-right, signalling the screen has a + // Hold-Enter context menu. Three stacked bars, drawn in colour c (LIGHT on a + // plain header, DARK on an inverted bar). When active (the menu is open) the + // corner cell is highlighted and the bars knocked out, tying the glyph to the + // popup it spawned. Call after the header body. + void drawContextMenuHint(Color c = LIGHT, bool active = false) { + int gw = getCharWidth() + 1; + int gx = width() - gw - 1; + int th = sepH(); + int gy = (getLineHeight() - (th * 3 + 4)) / 2; + if (gy < 0) gy = 0; + Color bars = c; + if (active) { + setColor(c); + fillRect(gx - 1, 0, gw + 2, headerH() - sepH()); + bars = (c == LIGHT) ? DARK : LIGHT; + } + setColor(bars); + for (int i = 0; i < 3; i++) fillRect(gx, gy + i * (th + 2), gw, th); + setColor(LIGHT); + } + + void drawInvertedHeader(const char* label, bool menu_hint = false, bool menu_open = false) { int hdr = headerH(); setColor(LIGHT); fillRect(0, 0, width(), hdr - 1); + int reserve = menu_hint ? menuHintWidth() : 0; setColor(DARK); - drawTextEllipsized(2, 1, width() - 4, (label && label[0]) ? label : ""); + drawTextEllipsized(2, 1, width() - 4 - reserve, (label && label[0]) ? label : ""); + if (menu_hint) drawContextMenuHint(DARK, menu_open); setColor(LIGHT); fillRect(0, hdr - 1, width(), sepH()); } // Centred screen title + bottom separator line — the standard top bar for // full-screen list/detail views. Leaves ink LIGHT; callers can draw extra - // header content (counters, etc.) afterwards. - void drawCenteredHeader(const char* title) { + // header content (counters, etc.) afterwards. menu_hint adds the ≡ glyph; + // menu_open highlights it while the context menu is on screen. + void drawCenteredHeader(const char* title, bool menu_hint = false, bool menu_open = false) { setColor(LIGHT); drawTextCentered(width() / 2, 0, title); fillRect(0, headerH() - sepH(), width(), sepH()); + if (menu_hint) drawContextMenuHint(LIGHT, menu_open); } // Advance a UTF-8 pointer by one codepoint, returning the decoded value.