2026-05-14 08:06:07 +02:00
|
|
|
#pragma once
|
|
|
|
|
#include <helpers/ui/DisplayDriver.h>
|
|
|
|
|
#include <Arduino.h>
|
2026-06-18 18:00:06 +02:00
|
|
|
#include "icons.h" // scalable scroll indicator (track + thumb), matches the rest of the UI
|
2026-05-14 08:06:07 +02:00
|
|
|
|
|
|
|
|
// Generic scrollable popup menu overlay.
|
|
|
|
|
// Caller owns item string lifetimes — addItem() stores const char* pointers.
|
|
|
|
|
// Navigation wraps around: up from first item goes to last, and vice versa.
|
|
|
|
|
struct PopupMenu {
|
feat(repeater): politeness controls, dedicated radio profile, and diagnostics
Settings > Radio gains a repeater toggle, 16 community-suggested radio
presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor),
4 persisted user preset slots, and the packet pool bumped 16->32 so
queued retransmits stop starving incoming-packet allocation while
relaying.
Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR),
all flood-only and off by default, plus overhear suppression that
cancels a queued retransmit if a peer relays the same packet first.
Adaptive Power Control is suppressed while relaying (pins TX power to
the ceiling) and duty-cycle RX is forced off, since a repeater needs
to hear and relay at consistent power.
A dedicated Tools > Repeater screen consolidates the toggle, the
politeness knobs, and live forwarding stats, plus an optional "Custom"
radio profile — a dedicated frequency/SF/BW/CR for relaying, separate
from the companion's own network, band-matched to the companion
frequency by default and used everywhere a radio change can happen
(boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings
radio edits) so the device never silently falls back to the wrong
params mid-relay.
Diagnostics gains the actually-forwarded packet count, real heap-free
via mallinfo(), a reset-counters popup, and hardened loop-detect
bounds. Also: a frequency-floor fix and a float-equality preset-match
fix in the repeater profile logic, deduped BW-table/valCol/profile-
seeding helpers shared between Settings and the Repeater screen, and
a build.sh fix tolerating control characters in `pio project config`'s
JSON dump.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 09:07:00 +02:00
|
|
|
static const int PM_MAX_ITEMS = 24;
|
2026-05-14 08:06:07 +02:00
|
|
|
|
|
|
|
|
const char* _items[PM_MAX_ITEMS];
|
|
|
|
|
int _count;
|
|
|
|
|
int _sel;
|
|
|
|
|
int _scroll;
|
2026-05-28 23:20:15 +02:00
|
|
|
int _visible; // caller hint (min visible items)
|
|
|
|
|
int _cap; // actual visible cap, updated each render()
|
2026-05-14 08:06:07 +02:00
|
|
|
bool active;
|
|
|
|
|
const char* _title;
|
|
|
|
|
|
|
|
|
|
enum Result { NONE, SELECTED, CANCELLED };
|
|
|
|
|
|
2026-05-28 23:20:15 +02:00
|
|
|
PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), _cap(3), active(false), _title(nullptr) {}
|
2026-05-14 08:06:07 +02:00
|
|
|
|
2026-07-10 16:01:14 +02:00
|
|
|
void begin(const char* title, int visible = 3) {
|
2026-05-14 08:06:07 +02:00
|
|
|
_count = 0; _sel = 0; _scroll = 0;
|
2026-05-28 23:20:15 +02:00
|
|
|
_visible = visible; _cap = visible; active = true; _title = title;
|
2026-05-14 08:06:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void addItem(const char* item) {
|
|
|
|
|
if (_count < PM_MAX_ITEMS) _items[_count++] = item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int render(DisplayDriver& display) {
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
// Everything is derived from the live font metrics so the box fits its
|
|
|
|
|
// content on every display — including landscape e-ink, where the font (and
|
|
|
|
|
// line height) is ~2x the OLED. Fixed pixel constants used to clip the text
|
|
|
|
|
// and leave the box the wrong size there.
|
|
|
|
|
const int lh = display.getLineHeight();
|
|
|
|
|
const int cw = display.getCharWidth();
|
|
|
|
|
const int sh = display.sepH(); // separator thickness (2px landscape e-ink, else 1)
|
|
|
|
|
const int item_h = lh + 2; // row pitch
|
|
|
|
|
const int pad = 4; // inner left/right padding
|
|
|
|
|
const int margin = 4; // gap to the screen edge
|
2026-06-18 15:33:18 +02:00
|
|
|
// Compact menu: rows tile straight under the separator (no header gap here —
|
|
|
|
|
// unlike full screens) so the selection bar fills its cell edge to edge.
|
|
|
|
|
const int title_h = lh + 2 + sh; // title text + scaled separator
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
|
|
|
|
|
// Hard ceiling: never show more rows than physically fit on screen.
|
|
|
|
|
int avail_h = display.height() - margin * 2 - title_h - 2;
|
|
|
|
|
int max_by_height = avail_h / item_h;
|
2026-05-28 23:20:15 +02:00
|
|
|
if (max_by_height < 1) max_by_height = 1;
|
2026-05-29 00:25:02 +02:00
|
|
|
_cap = max_by_height;
|
2026-05-28 23:20:15 +02:00
|
|
|
int vis = (_count < _cap) ? _count : _cap;
|
2026-06-15 10:00:48 +02:00
|
|
|
// render() is the single source of truth for _scroll: using the cap just
|
|
|
|
|
// computed from the live display height, keep the selection inside the
|
|
|
|
|
// visible window. handleInput() therefore never has to touch _scroll (and
|
|
|
|
|
// can't act on a stale _cap from before the first render).
|
|
|
|
|
if (_sel < _scroll) _scroll = _sel;
|
|
|
|
|
else if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
|
|
|
|
|
int max_scroll = _count - vis;
|
|
|
|
|
if (max_scroll < 0) max_scroll = 0;
|
|
|
|
|
if (_scroll > max_scroll) _scroll = max_scroll;
|
|
|
|
|
if (_scroll < 0) _scroll = 0;
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
|
|
|
|
|
bool can_scroll = (_count > vis);
|
2026-06-18 18:00:06 +02:00
|
|
|
// Right gutter for the scroll indicator column, plus a couple px of
|
|
|
|
|
// clearance from the box border (full-screen lists sit flush against the
|
|
|
|
|
// screen edge instead, so they don't need this extra margin).
|
|
|
|
|
int arrow_w = can_scroll ? (scrollIndicatorColWidth(display) + 2) : 0;
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
|
|
|
|
|
// Box width: widest of the title / all items, plus padding and the arrow
|
|
|
|
|
// gutter; clamped to the screen with a sane minimum.
|
|
|
|
|
int content_w = _title ? display.getTextWidth(_title) : 0;
|
|
|
|
|
for (int i = 0; i < _count; i++) {
|
|
|
|
|
int w = display.getTextWidth(_items[i]);
|
|
|
|
|
if (w > content_w) content_w = w;
|
|
|
|
|
}
|
|
|
|
|
int bw = content_w + pad * 2 + arrow_w;
|
|
|
|
|
int max_bw = display.width() - margin * 2;
|
|
|
|
|
if (bw > max_bw) bw = max_bw;
|
|
|
|
|
int min_bw = cw * 6 + pad * 2;
|
|
|
|
|
if (bw < min_bw) bw = min_bw;
|
|
|
|
|
if (bw > max_bw) bw = max_bw;
|
|
|
|
|
|
2026-06-18 15:33:18 +02:00
|
|
|
int bh = title_h + vis * item_h + 1; // +1 keeps the last row off the bottom border
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
|
|
|
|
|
// Centre on screen (clamped to the margins on small displays).
|
2026-07-10 16:01:14 +02:00
|
|
|
int bx = (display.width() - bw) / 2;
|
|
|
|
|
int by = (display.height() - bh) / 2;
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
if (bx < margin) bx = margin;
|
|
|
|
|
if (by < margin) by = margin;
|
2026-05-14 08:06:07 +02:00
|
|
|
|
|
|
|
|
display.setColor(DisplayDriver::DARK);
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
display.fillRect(bx, by, bw, bh);
|
2026-05-14 08:06:07 +02:00
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
display.drawRect(bx, by, bw, bh);
|
|
|
|
|
if (_title) display.drawTextEllipsized(bx + pad, by + 1, bw - pad * 2, _title);
|
|
|
|
|
display.fillRect(bx, by + lh + 2, bw, sh); // separator just under the title; gap follows
|
2026-05-14 08:06:07 +02:00
|
|
|
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
int list_y = by + title_h;
|
|
|
|
|
int text_w = bw - pad * 2 - arrow_w;
|
|
|
|
|
if (text_w < cw) text_w = cw;
|
2026-05-14 08:06:07 +02:00
|
|
|
for (int i = 0; i < vis && (_scroll + i) < _count; i++) {
|
|
|
|
|
int idx = _scroll + i;
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
int py = list_y + i * item_h + 1;
|
2026-05-14 08:06:07 +02:00
|
|
|
if (idx == _sel) {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-06-18 18:06:59 +02:00
|
|
|
// Stops short of the scroll-indicator gutter, like every other list's
|
|
|
|
|
// selection bar (e.g. drawList's row width - reserve) — otherwise the
|
|
|
|
|
// bar paints over the indicator's column instead of framing it.
|
|
|
|
|
display.fillRect(bx + 1, py - 1, bw - 2 - arrow_w, item_h);
|
2026-05-14 08:06:07 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
} else {
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
}
|
fix(ui): self-sizing popup menu, scaled for landscape e-ink
The popup menu used fixed pixel constants (box width, item height, text
positions) sized for the OLED 128x64. On landscape e-ink the font is ~2x, so
rows overlapped, text ran past the box edge, and the box never adapted to its
contents.
Rebuild render() from live font metrics: box width fits the widest title/item
(clamped to the screen, centred), row height and title bar derive from the line
height, items and title are ellipsized so nothing overflows, and the scroll
arrows live in a reserved gutter. The title separator uses the scaled sepH()
with a 2px gap before the first row so content no longer touches the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:35:40 +02:00
|
|
|
display.drawTextEllipsized(bx + pad, py, text_w, _items[idx]);
|
|
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:00:06 +02:00
|
|
|
// Same proportional track + thumb indicator the rest of the UI uses,
|
|
|
|
|
// anchored to the box's own right edge (a couple px clear of the border)
|
|
|
|
|
// instead of the screen edge since this box floats centred on screen.
|
|
|
|
|
drawScrollIndicator(display, bx + bw - 2, list_y, vis * item_h, _count, vis, _scroll);
|
2026-05-14 08:06:07 +02:00
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
return 50;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Result handleInput(char c) {
|
|
|
|
|
if (_count == 0) { active = false; return CANCELLED; }
|
2026-06-15 10:00:48 +02:00
|
|
|
// Selection only moves here; render() keeps it scrolled into view.
|
|
|
|
|
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return NONE; }
|
|
|
|
|
if (c == KEY_DOWN) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return NONE; }
|
2026-05-14 08:06:07 +02:00
|
|
|
if (c == KEY_ENTER) { active = false; return SELECTED; }
|
|
|
|
|
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { active = false; return CANCELLED; }
|
|
|
|
|
return NONE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int selectedIndex() const { return _sel; }
|
2026-06-14 13:37:03 +02:00
|
|
|
int count() const { return _count; }
|
|
|
|
|
void setSelected(int i) { if (i >= 0 && i < _count) _sel = i; }
|
2026-05-14 08:06:07 +02:00
|
|
|
};
|