From cf3c2c03537fb40146c2ad30b9135270b4156b23 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:01:14 +0200 Subject: [PATCH] fix(ui): revert corner-anchored context menus, centre unread badge, add room logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Context menus (PopupMenu, Nearby/QuickMsg call sites) go back to centring on screen; the header's discoverable-menu glyph stays, but dropping the popup out of its corner looked bad on some screens. - Unread pill badge digit wasn't centred: Adafruit_GFX's classic built-in font (SH1106/SSD1306) always pads a measured string by one trailing advance column regardless of the glyph drawn, so centring on the raw width left 1px more slack on the right than the left. New DisplayDriver:: textWidthTrailingGap() (0 by default) corrects for it on those two backends. - On-device room Logout: mirrors the app's CMD_LOGOUT (drops keep-alive tracking, forgets the saved password) so a room can be deliberately signed out of from the Room options menu, not just re-logged-in. Not build-verified — no PlatformIO toolchain available this session. --- examples/companion_radio/MyMesh.h | 10 ++++ .../companion_radio/ui-new/NearbyScreen.h | 6 +-- examples/companion_radio/ui-new/PopupMenu.h | 17 ++----- .../companion_radio/ui-new/QuickMsgScreen.h | 49 +++++++++++++++---- src/helpers/ui/DisplayDriver.h | 32 ++++++++++-- src/helpers/ui/SH1106Display.h | 4 ++ src/helpers/ui/SSD1306Display.h | 3 ++ 7 files changed, 90 insertions(+), 31 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 284c6c9b..f3c74e18 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -230,6 +230,16 @@ public: return true; } + // On-device UI logout: drops the local keep-alive tracking (mirrors the app's + // CMD_LOGOUT) and forgets the saved password, so the next room open prompts + // for credentials again instead of silently re-using them. No packet is sent + // to the server -- the room ACL has no session state to tear down, this is + // purely local "forget this login" bookkeeping. + void logoutRoom(const uint8_t* pub_key) { + stopConnection(pub_key); + forgetRoomPassword(pub_key); + } + // On-device-saved room/repeater login passwords, persisted to flash (own // small file, independent of /contacts3) so a room that's already been // logged into doesn't need its password retyped after reboot. diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 6364409a..de18490a 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, true); + _confirm.begin("Delete contact?", 2); _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, true); + _ping_menu.begin("Ping", 4); _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, true); + _menu.begin("Options", 10); auto add = [&](const char* label, Action a) { _menu.addItem(label); _menu_actions[_menu_action_count++] = a; diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 9a86ea97..d0e482db 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -17,16 +17,14 @@ 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, bool anchor_top_right = false) { + void begin(const char* title, int visible = 3) { _count = 0; _sel = 0; _scroll = 0; _visible = visible; _cap = visible; active = true; _title = title; - anchor_tr = anchor_top_right; } void addItem(const char* item) { @@ -88,17 +86,8 @@ 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, 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; - } + int bx = (display.width() - bw) / 2; + int 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 0ae3ba10..6fad2185 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, true); + _ctx_menu.begin("Options", n); 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; } @@ -602,6 +602,24 @@ public: memcpy(_room_login_prefix[pos], pub_key, 4); } + // Reverses markRoomLoggedIn() on explicit Logout -- shifts the ring buffer + // closed over the removed slot so isRoomLoggedIn() goes back to false and + // the next room open prompts for a password instead of skipping it. + void forgetRoomLoggedIn(const uint8_t* pub_key) { + for (int i = 0; i < _room_login_count; i++) { + int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE; + if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) { + for (int j = i; j < _room_login_count - 1; j++) { + int from = (_room_login_head + j + 1) % ROOM_LOGIN_TABLE_SIZE; + int to = (_room_login_head + j) % ROOM_LOGIN_TABLE_SIZE; + memcpy(_room_login_prefix[to], _room_login_prefix[from], 4); + } + _room_login_count--; + return; + } + } + } + // Password of the room-login attempt currently in flight -- set right // before sendRoomLogin(), read back in onRoomLoginResult() so a successful // attempt can be persisted (see MyMesh::saveRoomPassword()). @@ -1200,7 +1218,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, true); + _ctx_menu.begin(MODE_TITLES[_mode_sel < 3 ? _mode_sel : 0], 1); _ctx_menu.addItem("Mark all read"); return true; } @@ -1224,10 +1242,18 @@ public: auto res = _ctx_menu.handleInput(c); if (res == PopupMenu::SELECTED && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { - _login_mode = true; - _kb->begin("", 15); // room/repeater password: max 15 chars - _kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password - _phase = KEYBOARD; + int sel = _ctx_menu.selectedIndex(); + if (sel == 0) { + _login_mode = true; + _kb->begin("", 15); // room/repeater password: max 15 chars + _kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password + _phase = KEYBOARD; + } else { + // Logout: only reachable when isRoomLoggedIn() added this item. + the_mesh.logoutRoom(_sel_contact.id.pub_key); + forgetRoomLoggedIn(_sel_contact.id.pub_key); + _task->showAlert("Logged out", 1000); + } } } return true; @@ -1314,7 +1340,7 @@ public: snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: %s", s + 1, nm); } } - _ctx_menu.begin("Pick slot", 3, true); + _ctx_menu.begin("Pick slot", 3); for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]); _pin_picker_active = true; } @@ -1355,8 +1381,11 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) { - _ctx_menu.begin("Room options", 1, true); + ContactInfo ci; + bool logged_in = the_mesh.getContactByIdx(_sorted[_contact_sel], ci) && isRoomLoggedIn(ci.id.pub_key); + _ctx_menu.begin("Room options", logged_in ? 2 : 1); _ctx_menu.addItem("Login..."); + if (logged_in) _ctx_menu.addItem("Logout"); return true; } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { @@ -1371,7 +1400,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, true); + _ctx_menu.begin("Contact options", 3); _ctx_menu.addItem("Mark as read"); _ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_melody_item); @@ -1471,7 +1500,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, true); + _ctx_menu.begin("Channel options", 4); _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 5aba33e1..2176e357 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -172,11 +172,21 @@ public: else if (count >= 10) { buf[0]=(char)('0'+count/10); buf[1]=(char)('0'+count%10); buf[2]=0; } else { buf[0]=(char)('0'+count); buf[1]=0; } } + // Trailing blank column baked into a backend's own advance-based + // getTextWidth() (0 if it's already ink-tight). Adafruit_GFX's classic + // built-in font measures every character as a fixed 6px advance cell (5px + // glyph + 1px inter-character gap) regardless of the glyph actually drawn — + // so getTextWidth() always reports 1px more than the real ink, all of it + // trailing the last character. Centring on the raw width then leaves 1px + // more slack on the right than the left. Overridden by backends that use + // that font. + virtual int textWidthTrailingGap() const { return 0; } // Pixel width the pill from drawUnreadBadge(count) occupies — for reserving // the name column before it. Mirrors the pill's horizontal padding. int unreadBadgeWidth(int count) { char buf[5]; fmtBadgeCount(buf, count); - return getTextWidth(buf) + (sepH() + 1) * 2; + int pad = sepH() + 1; + return getTextWidth(buf) - textWidthTrailingGap() + pad * 2; } // Right-aligned unread-count "pill": a filled capsule ending at right_x, // aligned to a text row of height getLineHeight() at y, with the count @@ -186,7 +196,8 @@ public: // Restores ink to LIGHT. Returns the pill width. int drawUnreadBadge(int right_x, int y, int count, bool sel) { char buf[5]; fmtBadgeCount(buf, count); - int pw = getTextWidth(buf) + (sepH() + 1) * 2; + int pad = sepH() + 1; + int pw = getTextWidth(buf) - textWidthTrailingGap() + pad * 2; int ph = getLineHeight(); int px = right_x - pw; Color body = sel ? DARK : LIGHT; @@ -198,7 +209,7 @@ public: fillRect(px + pw - 1, y, 1, 1); fillRect(px, y + ph - 1, 1, 1); fillRect(px + pw - 1, y + ph - 1, 1, 1); - setCursor(px + sepH() + 1, y); + setCursor(px + pad, y); print(buf); setColor(LIGHT); return pw; @@ -250,7 +261,20 @@ public: // 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); + int reserve = menu_hint ? menuHintWidth() : 0; + char buf[96]; + translateUTF8ToBlocks(buf, (title && title[0]) ? title : "", sizeof(buf)); + int avail = width() - reserve - 4; + if (avail < 0) avail = 0; + if (getTextWidth(buf) <= avail) { + int w = getTextWidth(buf); + setCursor((width() - reserve) / 2 - w / 2, 0); // centred, clear of the ≡ hint + print(buf); + } else { + // Too wide to centre without print() wrapping onto a second line → left-align + // and ellipsize, like a list row. (Long DM / room-server / channel names.) + drawTextEllipsized(2, 0, avail, buf); + } fillRect(0, headerH() - sepH(), width(), sepH()); if (menu_hint) drawContextMenuHint(LIGHT, menu_open); } diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index e8e54f1c..d444370a 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -59,6 +59,10 @@ public: } int getCharWidth() const override { return (_use_lemon ? 5 : 6) * _text_sz; } int getLineHeight() const override { return (_use_lemon ? 9 : 8) * _text_sz; } + // Only the built-in classic font pads every measured string by one trailing + // advance column (see DisplayDriver::textWidthTrailingGap()); the lemon + // font's width comes from its own glyph table (ink-tight, no padding). + int textWidthTrailingGap() const override { return _use_lemon ? 0 : 1; } void setLemonFont(bool enabled) override { _use_lemon = enabled; _vw_dirty = true; } bool isLemonFont() const override { return _use_lemon; } void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override; diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index 0ae4d060..5aa555a4 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -34,6 +34,9 @@ public: int getCharWidth() const override { return 6 * _text_sz; } int getLineHeight() const override { return 8 * _text_sz; } + // Classic Adafruit_GFX built-in font pads every measured string by one + // trailing advance column (see DisplayDriver::textWidthTrailingGap()). + int textWidthTrailingGap() const override { return 1; } bool isOn() override { return _isOn; } void turnOn() override;