refactor(ui): shared drawList + header + key-decode helpers

- drawList(): scrollable list skeleton (visible window, keep-sel-in-view,
  scroll-column reserve, per-row callback, indicator). Migrate Tools, Bot,
  Nearby, Waypoints list and the three QuickMsg lists onto it; centralises the
  scroll-clamp/reserve/indicator that each had open-coded (and inconsistently).
- drawCenteredHeader(): centred title + separator, replacing the duplicated
  two-liner across ~10 screens (incl. QuickMsg, which used width()/2 spacing and
  was missed before); unifies the separator at headerH()-sepH().
- keyIsPrev()/keyIsNext() in UIScreen.h for the LEFT||PREV / RIGHT||NEXT idiom;
  applied to 6 screens, behaviour-preserving. Cancel/context-menu stay
  per-screen (they mean different things on different screens).
- rotLabel() dedups the 0/90/180/270 array in Settings.
- Unify selection-row height to lineStep()-1 (Tools/Bot matched the rest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-15 21:23:00 +02:00
parent 98af868e68
commit 9da730e3b6
13 changed files with 89 additions and 123 deletions

View File

@@ -30,8 +30,7 @@ public:
int bar_h = display.lineStep();
int tip_y = bar_y + bar_h + 4;
display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("AUTO-ADVERT");
display.setCursor(2, label_y);
display.print("Interval:");
@@ -56,8 +55,8 @@ public:
_task->gotoToolsScreen();
return true;
}
bool right = (c == KEY_RIGHT || c == KEY_NEXT || c == KEY_ENTER);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = keyIsNext(c) || c == KEY_ENTER;
bool left = keyIsPrev(c);
if (right || left) {
int idx = currentIdx();
idx = right ? (idx + 1) % OPT_COUNT : (idx + OPT_COUNT - 1) % OPT_COUNT;

View File

@@ -66,14 +66,9 @@ public:
return _kb->render(display);
}
int item_h = display.lineStep();
int start_y = display.listStart();
int val_x = display.valCol();
_visible = display.listVisible(item_h);
if (_visible < 1) _visible = 1;
int reserve = scrollIndicatorReserve(display, ITEM_COUNT, _visible);
int val_x = display.valCol();
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
display.drawCenteredHeader("AUTO-REPLY BOT");
// reply counter, right-aligned in the header
uint16_t sent = the_mesh.botReplyCount();
if (sent > 0) {
@@ -83,16 +78,12 @@ public:
display.setCursor(display.width() - cw - 1, 0);
display.print(cbuf);
}
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
static const char* labels[] = { "Enable", "Channel", "Trigger DM", "Reply DM",
"Trigger Ch", "Reply Ch", "Commands",
"Quiet from", "Quiet to" };
for (int vi = 0; vi < _visible && (_scroll + vi) < ITEM_COUNT; vi++) {
int i = _scroll + vi;
int y = start_y + vi * item_h;
bool sel = (i == _sel);
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h, sel);
_visible = drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
display.setCursor(2, y);
display.print(labels[i]);
display.setCursor(val_x, y);
@@ -131,16 +122,15 @@ public:
}
}
display.setColor(DisplayDriver::LIGHT);
}
drawScrollIndicator(display, start_y, _visible * item_h, ITEM_COUNT, _visible, _scroll);
});
return 2000;
}
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
bool enter = (c == KEY_ENTER);
bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU);

View File

@@ -31,8 +31,7 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "COMPASS");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("COMPASS");
const int hdr = display.headerH();
const int cx = display.width() / 2;

View File

@@ -46,8 +46,7 @@ public:
int start_y = display.listStart();
int val_x = display.valCol();
display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("CLOCK FIELDS");
static const char* labels[] = { "Field 1", "Field 2", "Field 3" };
for (int i = 0; i < FIELD_SLOTS; i++) {
@@ -72,8 +71,8 @@ public:
}
if (c == KEY_UP && _sel > 0) { _sel--; return true; }
if (c == KEY_DOWN && _sel < FIELD_SLOTS - 1){ _sel++; return true; }
if (c == KEY_LEFT || c == KEY_PREV) { cycle(_sel, -1); return true; }
if (c == KEY_RIGHT || c == KEY_NEXT) { cycle(_sel, 1); return true; }
if (keyIsPrev(c)) { cycle(_sel, -1); return true; }
if (keyIsNext(c)) { cycle(_sel, 1); return true; }
if (c == KEY_ENTER) { cycle(_sel, 1); return true; }
return false;
}

View File

@@ -569,8 +569,7 @@ public:
snprintf(title, sizeof(title), "NEARBY %s %s",
FILTER_LABELS[_filter], _sort == SORT_TIME ? "recent" : "dist");
}
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader(title);
if (_count == 0) {
char empty[24];
@@ -588,11 +587,7 @@ public:
display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, hint);
}
} else {
int reserve = scrollIndicatorReserve(display, _count, _visible);
for (int i = 0; i < _visible && (_scroll + i) < _count; i++) {
int idx = _scroll + i;
bool sel = (idx == _sel);
int y = start_y + i * item_h;
_visible = drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
const Entry& e = _entries[idx];
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
@@ -623,9 +618,7 @@ public:
}
display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y);
display.print(right);
}
drawScrollIndicator(display, start_y, _visible * item_h, _count, _visible, _scroll);
});
}
if (renderActivePopup(display)) return 50;
@@ -636,8 +629,7 @@ public:
bool handleInput(char c) override {
// ── navigate-to-node view — any nav key returns to detail ─────────────────
if (_nav) {
if (c == KEY_CANCEL || c == KEY_LEFT || c == KEY_PREV ||
c == KEY_RIGHT || c == KEY_NEXT) _nav = false;
if (c == KEY_CANCEL || keyIsPrev(c) || keyIsNext(c)) _nav = false;
return true;
}
@@ -647,7 +639,7 @@ public:
// LEFT/RIGHT on the Sort row toggles the value in-place and rebuilds the
// label; the popup stays open so the user can keep tapping. Other rows
// swallow L/R. ENTER on Sort just closes (value changes via L/R only).
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
if (keyIsPrev(c) || keyIsNext(c)) {
int i = _menu.selectedIndex();
if (i >= 0 && i < _menu_action_count && _menu_actions[i] == ACT_SORT) {
_sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST;

View File

@@ -822,8 +822,7 @@ public:
_visible = display.listVisible(item_h);
if (_phase == MODE_SELECT) {
display.drawTextCentered(display.width()/2, 0, "MESSAGE");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("MESSAGE");
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
int badges[3] = {
getDMUnreadTotal(),
@@ -848,20 +847,15 @@ public:
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == CONTACT_PICK) {
display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader(_room_mode ? "SELECT ROOM" : "SELECT CONTACT");
if (_num_contacts == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites");
return 5000;
}
int reserve = scrollIndicatorReserve(display, _num_contacts, _visible);
for (int i = 0; i < _visible && (_contact_scroll+i) < _num_contacts; i++) {
int list_idx = _contact_scroll + i;
_visible = drawList(display, _num_contacts, _contact_sel, _contact_scroll, [&](int list_idx, int y, bool sel, int reserve) {
int mesh_idx = _sorted[list_idx];
bool sel = (list_idx == _contact_sel);
int y = start_y + i * item_h;
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
@@ -882,27 +876,20 @@ public:
display.print(badge);
}
}
}
drawScrollIndicator(display, start_y, _visible * item_h, _num_contacts, _visible, _contact_scroll);
});
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
} else if (_phase == CHANNEL_PICK) {
display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("SELECT CHANNEL");
if (_num_channels == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
return 5000;
}
int reserve = scrollIndicatorReserve(display, _num_channels, _visible);
for (int i = 0; i < _visible && (_channel_scroll+i) < _num_channels; i++) {
int list_idx = _channel_scroll + i;
bool sel = (list_idx == _channel_sel);
int y = start_y + i * item_h;
_visible = drawList(display, _num_channels, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
ChannelDetails ch;
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
@@ -919,8 +906,7 @@ public:
display.print(badge);
}
}
}
drawScrollIndicator(display, start_y, _visible * item_h, _num_channels, _visible, _channel_scroll);
});
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
@@ -1244,16 +1230,10 @@ public:
} else {
snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name);
}
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader(title);
int total_msg_items = 1 + _active_msg_count;
int reserve = scrollIndicatorReserve(display, total_msg_items, _visible);
for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) {
int idx = _msg_scroll + i;
bool sel = (idx == _msg_sel);
int y = start_y + i * item_h;
_visible = drawList(display, total_msg_items, _msg_sel, _msg_scroll, [&](int idx, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
if (idx == 0) {
@@ -1265,8 +1245,7 @@ public:
const char* tmpl = p ? p->custom_msgs[slot] : "";
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, tmpl);
}
}
drawScrollIndicator(display, start_y, _visible * item_h, total_msg_items, _visible, _msg_scroll);
});
}
return 2000;
}

View File

@@ -115,6 +115,12 @@ class SettingsScreen : public UIScreen {
// values never render under the indicator when the list scrolls.
int valCol(DisplayDriver& display) const { return display.valCol() - _reserve; }
// Shared 0/90/180/270 labels for display + joystick rotation.
static const char* rotLabel(uint8_t r) {
static const char* const L[] = { "0 deg", "90 deg", "180 deg", "270 deg" };
return L[r & 3];
}
void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) {
const int gap = 2;
const int avail = display.width() - x - _reserve;
@@ -540,17 +546,13 @@ class SettingsScreen : public UIScreen {
} else if (item == ROTATION) {
display.print("Rotation");
display.setCursor(valCol(display), y);
{ static const char* ROT_LABELS[] = { "0 deg", "90 deg", "180 deg", "270 deg" };
uint8_t r = p ? (p->display_rotation & 3) : 0;
display.print(ROT_LABELS[r]); }
display.print(rotLabel(p ? p->display_rotation : 0));
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
} else if (item == JOY_ROTATION) {
display.print("Joystick");
display.setCursor(valCol(display), y);
{ static const char* ROT_LABELS[] = { "0 deg", "90 deg", "180 deg", "270 deg" };
uint8_t r = p ? (p->joystick_rotation & 3) : 0;
display.print(ROT_LABELS[r]); }
display.print(rotLabel(p ? p->joystick_rotation : 0));
#endif
#if FEAT_FULL_REFRESH_SETTING
} else if (item == EINK_FULL_REFRESH) {
@@ -620,9 +622,7 @@ public:
_visible = display.listVisible(item_h);
_reserve = scrollIndicatorReserve(display, _vis_count, _visible);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "SETTINGS");
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
display.drawCenteredHeader("SETTINGS");
for (int i = 0; i < _visible && (_scroll + i) < _vis_count; i++) {
renderItem(display, _vis[_scroll + i], start_y + i * item_h);
@@ -676,8 +676,8 @@ public:
return true;
}
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = keyIsNext(c);
bool left = keyIsPrev(c);
bool enter = (c == KEY_ENTER);
if (enter && isSection(_selected)) {

View File

@@ -16,28 +16,13 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "TOOLS");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("TOOLS");
int item_h = display.lineStep();
int start_y = display.listStart();
int vis = display.listVisible(item_h);
if (vis < 1) vis = 1;
// Keep the selection in view (short OLED panel can't show all 6 items).
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
int reserve = scrollIndicatorReserve(display, ITEM_COUNT, vis);
for (int i = 0; i < vis && (_scroll + i) < ITEM_COUNT; i++) {
int idx = _scroll + i;
int y = start_y + i * item_h;
bool sel = (idx == _sel);
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h, sel);
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
display.setCursor(2, y);
display.print(ITEMS[idx]);
}
drawScrollIndicator(display, start_y, vis * item_h, ITEM_COUNT, vis, _scroll);
});
return 500;
}

View File

@@ -76,8 +76,7 @@ public:
: "TRAIL";
char title[20];
snprintf(title, sizeof(title), "%s %d/%d", base, (int)_view + 1, (int)V_COUNT);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader(title);
if (_view == V_MAP) renderMap(display);
else if (_view == V_LIST) renderList(display);

View File

@@ -127,8 +127,7 @@ class WaypointsView {
void renderAddForm(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "ADD WAYPOINT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader("ADD WAYPOINT");
const int top = display.listStart();
const int step = display.lineStep();
for (int i = 0; i < 4; i++) {
@@ -150,35 +149,25 @@ class WaypointsView {
char title[24];
snprintf(title, sizeof(title), "WAYPOINTS %d/%d",
_task->waypoints().count(), WaypointStore::CAPACITY);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.drawCenteredHeader(title);
int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords"
const int top = display.listStart();
const int step = display.lineStep();
int vis = display.listVisible();
if (vis < 1) vis = 1;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
int reserve = scrollIndicatorReserve(display, total, vis);
for (int i = 0; i < vis && (_scroll + i) < total; i++) {
int row = _scroll + i;
int y = top + i * step;
bool sel = (row == _sel);
drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel);
if (row == n) { // the synthetic "Add" row
display.setCursor(2, y); display.print("+ Add by coords");
display.setColor(DisplayDriver::LIGHT);
continue;
return;
}
int32_t tlat, tlon; const char* label;
if (!rowTarget(row, tlat, tlon, label)) continue;
if (!rowTarget(row, tlat, tlon, label)) return;
char dist[12] = ""; int bw = 0;
if (have) {
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, tlat, tlon), useImperial());
@@ -189,8 +178,7 @@ class WaypointsView {
display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, nm);
if (dist[0]) { display.setCursor(display.width() - bw + 1 - reserve, y); display.print(dist); }
display.setColor(DisplayDriver::LIGHT);
}
drawScrollIndicator(display, top, vis * step, total, vis, _scroll);
});
}
void renderWpNav(DisplayDriver& display) {
@@ -322,8 +310,7 @@ public:
// Navigation view — any nav key returns to the list.
if (_mode == NAV) {
if (c == KEY_CANCEL || c == KEY_LEFT || c == KEY_PREV ||
c == KEY_RIGHT || c == KEY_NEXT) { _mode = LIST; }
if (c == KEY_CANCEL || keyIsPrev(c) || keyIsNext(c)) { _mode = LIST; }
return true;
}

View File

@@ -276,6 +276,28 @@ inline void drawScrollIndicator(DisplayDriver& d, int top_y, int track_h,
miniIconDrawHalo(d, x, top_y + track_h - th, ICON_SCROLL_DOWN);
}
// Scrollable item-list skeleton shared by the list screens. Computes the visible
// window from the font metrics, keeps `sel` in view, reserves the scroll-column,
// draws each visible row through `row`, then the indicator. The callback
// `row(idx, y, sel, reserve)` draws one item — including its own selection bar —
// using `reserve` to keep right-aligned content clear of the indicator. Returns
// the visible row count (callers cache it for input handling).
template <class RenderRow>
inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow row) {
const int item_h = d.lineStep();
const int start_y = d.listStart();
int visible = d.listVisible(item_h);
if (visible < 1) visible = 1;
if (sel < scroll) scroll = sel;
if (sel >= scroll + visible) scroll = sel - visible + 1;
if (scroll < 0) scroll = 0;
const int reserve = scrollIndicatorReserve(d, total, visible);
for (int i = 0; i < visible && (scroll + i) < total; i++)
row(scroll + i, start_y + i * item_h, scroll + i == sel, reserve);
drawScrollIndicator(d, start_y, visible * item_h, total, visible, scroll);
return visible;
}
// ── Big ASCII-art icons (skeleton, not yet used) ─────────────────────────────
// Same authoring idea as the mini-icons but for full page glyphs up to 32 px
// wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…)

View File

@@ -185,6 +185,15 @@ public:
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) {
setColor(LIGHT);
drawTextCentered(width() / 2, 0, title);
fillRect(0, headerH() - sepH(), width(), sepH());
}
// Advance a UTF-8 pointer by one codepoint, returning the decoded value.
// Invalid sequences return 0xFFFD and consume trailing continuation bytes.
static uint32_t decodeCodepoint(const uint8_t*& p) {

View File

@@ -14,6 +14,12 @@
#define KEY_PREV 0xF2
#define KEY_CONTEXT_MENU 0xF3
// "Previous"/"Next" navigation keys: the directional key and its rotary-encoder
// twin. Decrement / increment a value, or step a selection. (Cancel and the
// context-menu key stay screen-specific — they mean different things per screen.)
static inline bool keyIsPrev(char c) { return c == KEY_LEFT || c == KEY_PREV; }
static inline bool keyIsNext(char c) { return c == KEY_RIGHT || c == KEY_NEXT; }
#ifndef JOYSTICK_ROTATION
#define JOYSTICK_ROTATION 0
#endif