fix(ui): revert corner-anchored context menus, centre unread badge, add room logout

- 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.
This commit is contained in:
Jakub
2026-07-10 16:01:14 +02:00
parent a1ee67ae94
commit cf3c2c0353
7 changed files with 90 additions and 31 deletions

View File

@@ -230,6 +230,16 @@ public:
return true; 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 // On-device-saved room/repeater login passwords, persisted to flash (own
// small file, independent of /contacts3) so a room that's already been // small file, independent of /contacts3) so a room that's already been
// logged into doesn't need its password retyped after reboot. // logged into doesn't need its password retyped after reboot.

View File

@@ -415,7 +415,7 @@ class NearbyScreen : public UIScreen {
void startDeleteConfirm() { void startDeleteConfirm() {
const Entry* e = selected(); const Entry* e = selected();
if (!e || !e->has_key || !entryIsContact(e)) return; if (!e || !e->has_key || !entryIsContact(e)) return;
_confirm.begin("Delete contact?", 2, true); _confirm.begin("Delete contact?", 2);
_confirm.addItem("Delete"); _confirm.addItem("Delete");
_confirm.addItem("Cancel"); _confirm.addItem("Cancel");
_confirm.setSelected(1); _confirm.setSelected(1);
@@ -451,7 +451,7 @@ class NearbyScreen : public UIScreen {
void rebuildPingMenu() { void rebuildPingMenu() {
int keep = _ping_menu.selectedIndex(); // preserve selection across a rebuild 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"); _ping_menu.addItem("Send");
if (_ping_time_str[0]) _ping_menu.addItem(_ping_time_str); if (_ping_time_str[0]) _ping_menu.addItem(_ping_time_str);
if (_ping_snr_out_str[0]) _ping_menu.addItem(_ping_snr_out_str); if (_ping_snr_out_str[0]) _ping_menu.addItem(_ping_snr_out_str);
@@ -543,7 +543,7 @@ class NearbyScreen : public UIScreen {
buildSortLabel(); buildSortLabel();
_menu_action_count = 0; _menu_action_count = 0;
_menu.begin("Options", 10, true); _menu.begin("Options", 10);
auto add = [&](const char* label, Action a) { auto add = [&](const char* label, Action a) {
_menu.addItem(label); _menu.addItem(label);
_menu_actions[_menu_action_count++] = a; _menu_actions[_menu_action_count++] = a;

View File

@@ -17,16 +17,14 @@ struct PopupMenu {
int _cap; // actual visible cap, updated each render() int _cap; // actual visible cap, updated each render()
bool active; bool active;
const char* _title; const char* _title;
bool anchor_tr = false; // opt-in: drop from the top-right ≡ hint instead of centring
enum Result { NONE, SELECTED, CANCELLED }; enum Result { NONE, SELECTED, CANCELLED };
PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), _cap(3), active(false), _title(nullptr) {} 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; _count = 0; _sel = 0; _scroll = 0;
_visible = visible; _cap = visible; active = true; _title = title; _visible = visible; _cap = visible; active = true; _title = title;
anchor_tr = anchor_top_right;
} }
void addItem(const char* item) { 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 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). // Centre on screen (clamped to the margins on small displays).
int bx, by; int bx = (display.width() - bw) / 2;
if (anchor_tr) { int by = (display.height() - bh) / 2;
// 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 (bx < margin) bx = margin;
if (by < margin) by = margin; if (by < margin) by = margin;

View File

@@ -255,7 +255,7 @@ class QuickMsgScreen : public UIScreen {
int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0); int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0);
if (n == 0) return; if (n == 0) return;
_fs_act_n = 0; _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 (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; 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; } _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); 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 // Password of the room-login attempt currently in flight -- set right
// before sendRoomLogin(), read back in onRoomLoginResult() so a successful // before sendRoomLogin(), read back in onRoomLoginResult() so a successful
// attempt can be persisted (see MyMesh::saveRoomPassword()). // attempt can be persisted (see MyMesh::saveRoomPassword()).
@@ -1200,7 +1218,7 @@ public:
if (c == KEY_CONTEXT_MENU) { if (c == KEY_CONTEXT_MENU) {
// PopupMenu stores the title pointer verbatim — use static strings. // PopupMenu stores the title pointer verbatim — use static strings.
static const char* MODE_TITLES[] = { "DM options", "Channel options", "Room options" }; 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"); _ctx_menu.addItem("Mark all read");
return true; return true;
} }
@@ -1224,10 +1242,18 @@ public:
auto res = _ctx_menu.handleInput(c); auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_contacts > 0) { if (res == PopupMenu::SELECTED && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
_login_mode = true; int sel = _ctx_menu.selectedIndex();
_kb->begin("", 15); // room/repeater password: max 15 chars if (sel == 0) {
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password _login_mode = true;
_phase = KEYBOARD; _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; return true;
@@ -1314,7 +1340,7 @@ public:
snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: %s", s + 1, nm); 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]); for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]);
_pin_picker_active = true; _pin_picker_active = true;
} }
@@ -1355,8 +1381,11 @@ public:
return true; return true;
} }
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) { 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..."); _ctx_menu.addItem("Login...");
if (logged_in) _ctx_menu.addItem("Logout");
return true; return true;
} }
if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) {
@@ -1371,7 +1400,7 @@ public:
int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key); 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); 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"); 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("Mark as read");
_ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item); _ctx_menu.addItem(_ctx_melody_item);
@@ -1471,7 +1500,7 @@ public:
{ NodePrefs* p2 = _task->getNodePrefs(); { NodePrefs* p2 = _task->getNodePrefs();
bool is_fav = p2 && (p2->ch_fav_bitmask & (1ULL << ch_idx)); 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"); } 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("Mark all read");
_ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_notif_item);
_ctx_menu.addItem(_ctx_melody_item); _ctx_menu.addItem(_ctx_melody_item);

View File

@@ -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 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; } 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 // Pixel width the pill from drawUnreadBadge(count) occupies — for reserving
// the name column before it. Mirrors the pill's horizontal padding. // the name column before it. Mirrors the pill's horizontal padding.
int unreadBadgeWidth(int count) { int unreadBadgeWidth(int count) {
char buf[5]; fmtBadgeCount(buf, 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, // 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 // 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. // Restores ink to LIGHT. Returns the pill width.
int drawUnreadBadge(int right_x, int y, int count, bool sel) { int drawUnreadBadge(int right_x, int y, int count, bool sel) {
char buf[5]; fmtBadgeCount(buf, count); 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 ph = getLineHeight();
int px = right_x - pw; int px = right_x - pw;
Color body = sel ? DARK : LIGHT; Color body = sel ? DARK : LIGHT;
@@ -198,7 +209,7 @@ public:
fillRect(px + pw - 1, y, 1, 1); fillRect(px + pw - 1, y, 1, 1);
fillRect(px, y + ph - 1, 1, 1); fillRect(px, y + ph - 1, 1, 1);
fillRect(px + pw - 1, 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); print(buf);
setColor(LIGHT); setColor(LIGHT);
return pw; return pw;
@@ -250,7 +261,20 @@ public:
// menu_open highlights it while the context menu is on screen. // menu_open highlights it while the context menu is on screen.
void drawCenteredHeader(const char* title, bool menu_hint = false, bool menu_open = false) { void drawCenteredHeader(const char* title, bool menu_hint = false, bool menu_open = false) {
setColor(LIGHT); 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()); fillRect(0, headerH() - sepH(), width(), sepH());
if (menu_hint) drawContextMenuHint(LIGHT, menu_open); if (menu_hint) drawContextMenuHint(LIGHT, menu_open);
} }

View File

@@ -59,6 +59,10 @@ public:
} }
int getCharWidth() const override { return (_use_lemon ? 5 : 6) * _text_sz; } int getCharWidth() const override { return (_use_lemon ? 5 : 6) * _text_sz; }
int getLineHeight() const override { return (_use_lemon ? 9 : 8) * _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; } void setLemonFont(bool enabled) override { _use_lemon = enabled; _vw_dirty = true; }
bool isLemonFont() const override { return _use_lemon; } bool isLemonFont() const override { return _use_lemon; }
void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override; void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override;

View File

@@ -34,6 +34,9 @@ public:
int getCharWidth() const override { return 6 * _text_sz; } int getCharWidth() const override { return 6 * _text_sz; }
int getLineHeight() const override { return 8 * _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; } bool isOn() override { return _isOn; }
void turnOn() override; void turnOn() override;