refactor: replace all hardcoded pixel positions with layout helpers

Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.

On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-22 18:05:09 +02:00
parent 18867a85c0
commit 744f263932
10 changed files with 358 additions and 287 deletions

View File

@@ -25,22 +25,27 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT");
display.fillRect(0, 10, display.width(), 1);
int label_y = display.listStart();
int bar_y = label_y + display.lineStep();
int bar_h = display.lineStep();
int tip_y = bar_y + bar_h + display.lineStep();
display.setCursor(2, 14);
display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.setCursor(2, label_y);
display.print("Interval:");
int idx = currentIdx();
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 24, display.width(), 12);
display.fillRect(0, bar_y, display.width(), bar_h);
display.setColor(DisplayDriver::DARK);
display.drawTextCentered(display.width() / 2, 25, OPT_LABELS[idx]);
display.drawTextCentered(display.width() / 2, bar_y + 1, OPT_LABELS[idx]);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(2, 40);
display.setCursor(2, tip_y);
display.print("< > to change");
display.setCursor(2, 51);
display.setCursor(2, tip_y + display.lineStep());
display.print("[Esc] to save");
return 500;
}

View File

@@ -8,9 +8,6 @@ class BotScreen : public UIScreen {
// Items: 0=Enable(DM), 1=Channel, 2=Trigger, 3=Reply DM, 4=Reply Ch
static const int ITEM_COUNT = 5;
static const int ITEM_H = 11;
static const int START_Y = 12;
static const int VAL_X = 70;
int _sel;
bool _dirty;
@@ -58,20 +55,24 @@ public:
return _kb.render(display);
}
int item_h = display.lineStep();
int start_y = display.listStart();
int val_x = display.valCol();
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply DM", "Reply Ch" };
for (int i = 0; i < ITEM_COUNT; i++) {
int y = START_Y + i * ITEM_H;
int y = start_y + i * item_h;
bool sel = (i == _sel);
if (sel) {
display.fillRect(0, y - 1, display.width(), ITEM_H);
display.fillRect(0, y - 1, display.width(), item_h);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(2, y);
display.print(labels[i]);
display.setCursor(VAL_X, y);
display.setCursor(val_x, y);
if (i == 0) {
display.print(_prefs->bot_enabled ? "ON" : "OFF");
@@ -81,19 +82,19 @@ public:
} else {
ChannelDetails ch;
if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name);
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, ch.name);
else
display.print("?");
}
} else if (i == 2) {
const char* tr = _prefs->bot_trigger;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)");
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, tr[0] ? tr : "(none)");
} else if (i == 3) {
const char* rp = _prefs->bot_reply_dm;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)");
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)");
} else {
const char* rp = _prefs->bot_reply_ch;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)");
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)");
}
display.setColor(DisplayDriver::LIGHT);
}

View File

@@ -21,9 +21,6 @@ class DashboardConfigScreen : public UIScreen {
NodePrefs* _prefs;
static const int FIELD_SLOTS = 3;
static const int ITEM_H = 13;
static const int START_Y = 13;
static const int VAL_X = 62;
static const char* OPTION_NAMES[DASH_COUNT];
@@ -44,23 +41,27 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
int item_h = display.lineStep();
int start_y = display.listStart();
int val_x = display.valCol();
display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS");
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
static const char* labels[] = { "Field 1", "Field 2", "Field 3" };
for (int i = 0; i < FIELD_SLOTS; i++) {
int y = START_Y + i * ITEM_H;
int y = start_y + i * item_h;
bool sel = (i == _sel);
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H);
display.fillRect(0, y - 1, display.width(), item_h);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
display.print(labels[i]);
display.setCursor(VAL_X, y);
display.setCursor(val_x, y);
uint8_t f = _prefs->dashboard_fields[i];
display.print(OPTION_NAMES[f < DASH_COUNT ? f : DASH_NONE]);
display.setColor(DisplayDriver::LIGHT);

View File

@@ -82,7 +82,8 @@ struct FullscreenMsgView {
}
}
const int header_h = to_nick[0] ? 20 : 10;
const int cw = display.getCharWidth();
const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2);
const int startY = header_h + 2;
const int visible = (display.height() - startY - lineH) / lineH;
@@ -94,7 +95,7 @@ struct FullscreenMsgView {
char trans_nick[32], to_label[36];
display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick));
snprintf(to_label, sizeof(to_label), "To: %s", trans_nick);
display.drawTextEllipsized(2, 11, display.width() - 4, to_label);
display.drawTextEllipsized(2, lineH + 3, display.width() - 4, to_label);
}
display.setColor(DisplayDriver::LIGHT);
@@ -111,16 +112,16 @@ struct FullscreenMsgView {
}
if (scroll > 0) {
display.setColor(DisplayDriver::DARK);
display.fillRect(display.width() - 6, startY, 6, lineH);
display.fillRect(display.width() - cw, startY, cw, lineH);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(display.width() - 6, startY);
display.setCursor(display.width() - cw, startY);
display.print("^");
}
if (scroll < max_scroll) {
display.setColor(DisplayDriver::DARK);
display.fillRect(display.width() - 6, startY + (visible - 1) * lineH, 6, lineH);
display.fillRect(display.width() - cw, startY + (visible - 1) * lineH, cw, lineH);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(display.width() - 6, startY + (visible - 1) * lineH);
display.setCursor(display.width() - cw, startY + (visible - 1) * lineH);
display.print("v");
}
const int nav_y = display.height() - lineH;
@@ -129,7 +130,7 @@ struct FullscreenMsgView {
display.print("<");
}
if (has_prev) {
display.setCursor(display.width() - 6, nav_y);
display.setCursor(display.width() - cw, nav_y);
display.print(">");
}
return 2000;

View File

@@ -8,10 +8,7 @@
class NearbyScreen : public UIScreen {
UITask* _task;
static const int VISIBLE = 4;
static const int ITEM_H = 12;
static const int START_Y = 12;
static const int DIST_COL = 86;
int _visible = 4; // updated each render; used by handleInput for scroll clamping
static const int FILTER_COUNT = 6;
static const char* FILTER_LABELS[FILTER_COUNT];
@@ -54,10 +51,7 @@ class NearbyScreen : public UIScreen {
int _dsel;
bool _ddetail;
static const int D_BOX_H = 19;
static const int D_ITEM_H = 21;
static const int D_VISIBLE = 2;
static const int D_START_Y = 11;
int _d_visible = 2; // updated each render; used by handleInputDiscover
// ── helpers ──────────────────────────────────────────────────────────────────
static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) {
@@ -193,6 +187,14 @@ class NearbyScreen : public UIScreen {
}
int renderDiscover(DisplayDriver& display) {
int lh = display.getLineHeight();
int hdr = display.headerH();
int d_box_h = 2 * lh + 3; // two text rows + padding
int d_item_h = d_box_h + 2;
int d_start_y = hdr;
_d_visible = display.listVisible(d_item_h);
if (_d_visible < 1) _d_visible = 1;
if (_ddetail) {
// ── full-screen detail for selected node ──────────────────────────────
const DiscoverResult& r = _dresults[_dsel];
@@ -207,29 +209,29 @@ class NearbyScreen : public UIScreen {
char filtered[32];
display.translateUTF8ToBlocks(filtered, label, sizeof(filtered));
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 0, display.width(), 10);
display.fillRect(0, 0, display.width(), hdr - 1);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(2, 1, display.width() - 4, filtered);
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, hdr - 1, display.width(), 1);
// public key as base64, truncated with ... by drawTextEllipsized
// public key as base64
char b64[48];
pubKeyToBase64(r.pub_key, b64, sizeof(b64));
display.drawTextEllipsized(2, 12, display.width() - 4, b64);
display.drawTextEllipsized(2, hdr, display.width() - 4, b64);
int step = lh + 1;
char buf[32];
snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi);
display.setCursor(2, 21); display.print(buf);
display.setCursor(2, hdr + step); display.print(buf);
snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4));
display.setCursor(2, 30); display.print(buf);
display.setCursor(2, hdr + step * 2); display.print(buf);
snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4));
display.setCursor(2, 39); display.print(buf);
display.setCursor(2, hdr + step * 3); display.print(buf);
display.setCursor(2, 48);
display.setCursor(2, hdr + step * 4);
display.print(r.is_known ? "Status: known" : "Status: new");
return 5000;
@@ -250,21 +252,22 @@ class NearbyScreen : public UIScreen {
else
snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, hdr - 1, display.width(), 1);
if (_dresult_count == 0) {
display.drawTextCentered(display.width() / 2, 32,
display.drawTextCentered(display.width() / 2, display.height() / 2,
_discovering ? "Waiting for replies..." : "No nodes found");
} else {
if (_dsel >= _dresult_count) _dsel = _dresult_count - 1;
if (_dscroll > _dresult_count - D_VISIBLE)
_dscroll = _dresult_count > D_VISIBLE ? _dresult_count - D_VISIBLE : 0;
if (_dscroll > _dresult_count - _d_visible)
_dscroll = _dresult_count > _d_visible ? _dresult_count - _d_visible : 0;
if (_dscroll < 0) _dscroll = 0;
for (int i = 0; i < D_VISIBLE && (_dscroll + i) < _dresult_count; i++) {
for (int i = 0; i < _d_visible && (_dscroll + i) < _dresult_count; i++) {
int idx = _dscroll + i;
bool sel = (idx == _dsel);
const DiscoverResult& r = _dresults[idx];
int y = D_START_Y + i * D_ITEM_H;
int y = d_start_y + i * d_item_h;
const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Rpt" :
(r.type == ADV_TYPE_SENSOR) ? "Snsr" :
@@ -272,11 +275,11 @@ class NearbyScreen : public UIScreen {
display.setColor(DisplayDriver::LIGHT);
if (sel) {
display.fillRect(0, y, display.width(), D_BOX_H); // fully inverted when selected
display.fillRect(0, y, display.width(), d_box_h);
display.setColor(DisplayDriver::DARK);
} else {
display.drawRect(0, y, display.width(), D_BOX_H);
display.fillRect(1, y + 1, display.width() - 2, 8);
display.drawRect(0, y, display.width(), d_box_h);
display.fillRect(1, y + 1, display.width() - 2, lh);
display.setColor(DisplayDriver::DARK);
}
@@ -300,14 +303,15 @@ class NearbyScreen : public UIScreen {
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
char sig[24];
snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d", (int)r.rssi, (int)(r.snr_x4 / 4));
display.drawTextEllipsized(3, y + 10, display.width() - 6, sig);
display.drawTextEllipsized(3, y + lh + 2, display.width() - 6, sig);
}
display.setColor(DisplayDriver::LIGHT);
int cw = display.getCharWidth();
if (_dscroll > 0)
{ display.setCursor(display.width() - 6, D_START_Y + 1); display.print("^"); }
if (_dscroll + D_VISIBLE < _dresult_count)
{ display.setCursor(display.width() - 6, D_START_Y + D_VISIBLE * D_ITEM_H - 10); display.print("v"); }
{ display.setCursor(display.width() - cw, d_start_y); display.print("^"); }
if (_dscroll + _d_visible < _dresult_count)
{ display.setCursor(display.width() - cw, d_start_y + (_d_visible - 1) * d_item_h); display.print("v"); }
}
return _discovering ? 200 : 2000;
@@ -342,7 +346,7 @@ class NearbyScreen : public UIScreen {
}
if (c == KEY_DOWN && _dsel < _dresult_count - 1) {
_dsel++;
if (_dsel >= _dscroll + D_VISIBLE) _dscroll = _dsel - D_VISIBLE + 1;
if (_dsel >= _dscroll + _d_visible) _dscroll = _dsel - _d_visible + 1;
return true;
}
return true;
@@ -390,9 +394,12 @@ public:
// ── detail view ──────────────────────────────────────────────────────────
if (_detail && _sel < _count) {
const Entry& e = _entries[_sel];
int lh = display.getLineHeight();
int hdr = display.headerH();
int step = lh + 1;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 0, display.width(), 10);
display.fillRect(0, 0, display.width(), hdr - 1);
display.setColor(DisplayDriver::DARK);
char filtered[32];
display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered));
@@ -401,55 +408,60 @@ public:
char buf[32];
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
display.setCursor(2, 11); display.print(buf);
display.setCursor(2, hdr); display.print(buf);
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
display.setCursor(2, 20); display.print(buf);
display.setCursor(2, hdr + step); display.print(buf);
if (e.dist_km >= 0.0f) {
char dist[12];
fmtDist(dist, sizeof(dist), e.dist_km);
int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6);
snprintf(buf, sizeof(buf), "Dist: %s", dist);
display.setCursor(2, 29); display.print(buf);
display.setCursor(2, hdr + step * 2); display.print(buf);
snprintf(buf, sizeof(buf), "Az: %dd (%s)", az, bearingCardinal(az));
display.setCursor(2, 38); display.print(buf);
display.setCursor(2, hdr + step * 3); display.print(buf);
} else {
display.setCursor(2, 29); display.print("Dist: no own GPS");
display.setCursor(2, 38); display.print("Az: unknown");
display.setCursor(2, hdr + step * 2); display.print("Dist: no own GPS");
display.setCursor(2, hdr + step * 3); display.print("Az: unknown");
}
snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type));
display.setCursor(2, 47); display.print(buf);
display.setCursor(2, hdr + step * 4); display.print(buf);
char age[16];
fmtAge(age, sizeof(age), e.lastmod);
snprintf(buf, sizeof(buf), "Seen: %s", age);
display.setCursor(2, 56); display.print(buf);
display.setCursor(2, hdr + step * 5); display.print(buf);
return 2000;
}
// ── list view ────────────────────────────────────────────────────────────
int item_h = display.lineStep();
int start_y = display.listStart();
int dist_col = display.width() - display.getCharWidth() * 7;
_visible = display.listVisible(item_h);
display.setColor(DisplayDriver::LIGHT);
char title[22];
snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
if (_count == 0) {
display.drawTextCentered(display.width() / 2, 28, "No contacts found");
display.drawTextCentered(display.width() / 2, 40, "[Enter]=Discover");
display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No contacts found");
display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, "[Enter]=Discover");
} else {
for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) {
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;
int y = start_y + i * item_h;
const Entry& e = _entries[idx];
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H - 1);
display.fillRect(0, y - 1, display.width(), item_h - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -457,21 +469,22 @@ public:
char filt[32];
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
display.drawTextEllipsized(2, y, DIST_COL - 4, filt);
display.drawTextEllipsized(2, y, dist_col - 4, filt);
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
char dist[10];
if (e.dist_km >= 0.0f) fmtDist(dist, sizeof(dist), e.dist_km);
else strncpy(dist, "?GPS", sizeof(dist));
display.setCursor(DIST_COL, y);
display.setCursor(dist_col, y);
display.print(dist);
}
display.setColor(DisplayDriver::LIGHT);
int cw = display.getCharWidth();
if (_scroll > 0)
{ display.setCursor(display.width() - 6, START_Y); display.print("^"); }
if (_scroll + VISIBLE < _count)
{ display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); }
{ display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_scroll + _visible < _count)
{ display.setCursor(display.width() - cw, start_y + (_visible - 1) * item_h); display.print("v"); }
}
if (_ctx_menu.active) {
@@ -519,7 +532,7 @@ public:
}
if (c == KEY_DOWN && _sel < _count - 1) {
_sel++;
if (_sel >= _scroll + VISIBLE) _scroll = _sel - VISIBLE + 1;
if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1;
return true;
}
if (c == KEY_ENTER && _count == 0) { enterDiscoverMode(); return true; }

View File

@@ -61,13 +61,8 @@ class QuickMsgScreen : public UIScreen {
int _dm_hist_sel, _dm_hist_scroll;
FullscreenMsgView _dm_fs;
static const int VISIBLE = 4;
static const int ITEM_H = 12;
static const int START_Y = 12;
static const int HIST_VISIBLE = 2; // 2-line boxed history entries
static const int HIST_ITEM_H = 21; // box(19) + gap(2)
static const int HIST_BOX_H = 19;
static const int HIST_START_Y = 11;
int _visible = 4; // updated in render(); used by handleInput() for scroll clamping
int _hist_visible = 2; // updated in render(); for history list scroll clamping
void expandMsg(const char* tmpl, char* out, int out_len) const {
double lat = 0, lon = 0;
@@ -128,17 +123,6 @@ class QuickMsgScreen : public UIScreen {
}
}
void renderScrollHints(DisplayDriver& display, int scroll, int count) {
display.setColor(DisplayDriver::LIGHT);
if (scroll > 0) {
display.setCursor(display.width() - 6, START_Y);
display.print("^");
}
if (scroll + VISIBLE < count) {
display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H);
display.print("v");
}
}
static void fmtMsgAge(char* buf, int n, uint32_t timestamp) {
uint32_t now = rtc_clock.getCurrentTime();
@@ -476,9 +460,15 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
int lh = display.getLineHeight();
int item_h = display.lineStep();
int start_y = display.listStart();
int cw = display.getCharWidth();
_visible = display.listVisible(item_h);
if (_phase == MODE_SELECT) {
display.drawTextCentered(display.width()/2, 0, "MESSAGE");
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
int badges[3] = {
getDMUnreadTotal(),
@@ -486,18 +476,18 @@ public:
_task->getRoomUnreadCount()
};
for (int i = 0; i < 3; i++) {
int y = START_Y + i * ITEM_H;
int y = start_y + i * item_h;
bool sel = (i == _mode_sel);
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H - 1);
display.fillRect(0, y - 1, display.width(), item_h - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
display.print(sel ? ">" : " ");
display.setCursor(8, y);
display.setCursor(cw + 2, y);
display.print(opts[i]);
if (badges[i] > 0) {
char badge[5];
@@ -511,22 +501,22 @@ public:
} else if (_phase == CONTACT_PICK) {
display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT");
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
if (_num_contacts == 0) {
display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites");
display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites");
return 5000;
}
for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) {
for (int i = 0; i < _visible && (_contact_scroll+i) < _num_contacts; i++) {
int list_idx = _contact_scroll + i;
int mesh_idx = _sorted[list_idx];
bool sel = (list_idx == _contact_sel);
int y = START_Y + i * ITEM_H;
int y = start_y + i * item_h;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H - 1);
display.fillRect(0, y - 1, display.width(), item_h - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -545,7 +535,7 @@ public:
snprintf(badge, sizeof(badge), "%d", (int)dm_unread);
bw = display.getTextWidth(badge) + 2;
}
display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered);
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw - 1, filtered);
if (dm_unread > 0) {
display.setCursor(display.width() - bw + 1, y);
display.print(badge);
@@ -553,28 +543,29 @@ public:
}
}
display.setColor(DisplayDriver::LIGHT);
renderScrollHints(display, _contact_scroll, _num_contacts);
if (_contact_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_contact_scroll + _visible < _num_contacts) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); }
// 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, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
if (_num_channels == 0) {
display.drawTextCentered(display.width()/2, 32, "No channels");
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
return 5000;
}
for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) {
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;
int y = start_y + i * item_h;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H - 1);
display.fillRect(0, y - 1, display.width(), item_h - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -590,7 +581,7 @@ public:
snprintf(badge, sizeof(badge), "%d", (int)unread);
bw = display.getTextWidth(badge) + 2;
}
display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name);
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4 - bw, ch.name);
if (unread > 0) {
display.setCursor(display.width() - bw, y);
display.print(badge);
@@ -598,7 +589,8 @@ public:
}
}
display.setColor(DisplayDriver::LIGHT);
renderScrollHints(display, _channel_scroll, _num_channels);
if (_channel_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_channel_scroll + _visible < _num_channels) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); }
// Context menu overlay
if (_ctx_menu.active) _ctx_menu.render(display);
@@ -623,18 +615,25 @@ public:
return 500;
}
int hist_box_h = 2 * lh + 3;
int hist_item_h = hist_box_h + 2;
int hist_start_y = display.headerH();
_hist_visible = (display.height() - hist_start_y) / hist_item_h;
if (_hist_visible < 1) _hist_visible = 1;
int cby = hist_start_y + _hist_visible * hist_item_h + 2;
char title[24];
display.setColor(DisplayDriver::LIGHT);
snprintf(title, sizeof(title), "%.23s", filtered_name);
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, 9, display.width(), 1);
display.fillRect(0, lh + 1, display.width(), 1);
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) {
for (int i = 0; i < _hist_visible && (_dm_hist_scroll + i) < dm_count; i++) {
int item = _dm_hist_scroll + i;
bool sel = (item == _dm_hist_sel);
int y = HIST_START_Y + i * HIST_ITEM_H;
int y = hist_start_y + i * hist_item_h;
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item);
if (ring_pos < 0) continue;
@@ -647,44 +646,44 @@ public:
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y, display.width(), HIST_BOX_H);
display.fillRect(0, y, display.width(), hist_box_h);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); }
display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(e.text));
display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(e.text));
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, y, display.width(), HIST_BOX_H);
display.fillRect(1, y + 1, display.width() - 2, 8);
display.drawRect(0, y, display.width(), hist_box_h);
display.fillRect(1, y + 1, display.width() - 2, lh);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); }
display.setColor(DisplayDriver::LIGHT);
display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(e.text));
display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(e.text));
}
}
if (dm_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, 32, "No messages yet");
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
}
display.setColor(DisplayDriver::LIGHT);
if (_dm_hist_scroll > 0) {
display.setCursor(display.width() - 6, HIST_START_Y + 1);
display.setCursor(display.width() - cw, hist_start_y + 1);
display.print("^");
}
if (_dm_hist_scroll + HIST_VISIBLE < dm_count) {
display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10);
if (_dm_hist_scroll + _hist_visible < dm_count) {
display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh);
display.print("v");
}
bool compose_sel = (_dm_hist_sel == -1);
const char* ctxt = "[+ send]";
int ctw = display.getTextWidth(ctxt);
int cbx = 1, cby = 55;
int cbx = 1;
if (compose_sel) {
display.fillRect(cbx, cby - 1, ctw + 4, 9);
display.fillRect(cbx, cby - 1, ctw + 4, lh + 1);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(cbx + 2, cby);
@@ -718,19 +717,26 @@ public:
return 2000;
}
int hist_box_h = 2 * lh + 3;
int hist_item_h = hist_box_h + 2;
int hist_start_y = display.headerH();
_hist_visible = (display.height() - hist_start_y) / hist_item_h;
if (_hist_visible < 1) _hist_visible = 1;
int cby = hist_start_y + _hist_visible * hist_item_h + 2;
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
char title[24];
snprintf(title, sizeof(title), "%.23s", ch.name);
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, 9, display.width(), 1);
display.fillRect(0, lh + 1, display.width(), 1);
int ch_hist_count = histCountForChannel(_sel_channel_idx);
for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) {
for (int i = 0; i < _hist_visible && (_hist_scroll + i) < ch_hist_count; i++) {
int item = _hist_scroll + i;
bool sel = (item == _hist_sel);
int y = HIST_START_Y + i * HIST_ITEM_H;
int y = hist_start_y + i * hist_item_h;
int ring_pos = histEntryForChannel(_sel_channel_idx, item);
if (ring_pos < 0) continue;
@@ -754,36 +760,36 @@ public:
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y, display.width(), HIST_BOX_H);
display.fillRect(0, y, display.width(), hist_box_h);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); }
display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(msg_part));
display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(msg_part));
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, y, display.width(), HIST_BOX_H);
display.fillRect(1, y + 1, display.width() - 2, 8);
display.drawRect(0, y, display.width(), hist_box_h);
display.fillRect(1, y + 1, display.width() - 2, lh);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender);
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); }
display.setColor(DisplayDriver::LIGHT);
display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(msg_part));
display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(msg_part));
}
}
if (ch_hist_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width()/2, 32, "No messages yet");
display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet");
}
// scroll hints
display.setColor(DisplayDriver::LIGHT);
if (_hist_scroll > 0) {
display.setCursor(display.width() - 6, HIST_START_Y + 1);
display.setCursor(display.width() - cw, hist_start_y + 1);
display.print("^");
}
if (_hist_scroll + HIST_VISIBLE < ch_hist_count) {
display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10);
if (_hist_scroll + _hist_visible < ch_hist_count) {
display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh);
display.print("v");
}
@@ -791,14 +797,14 @@ public:
bool compose_sel = (_hist_sel == -1);
const char* ctxt = "[+ send]";
int ctw = display.getTextWidth(ctxt);
int cbx = 1, cby = 55;
int cbx = 1;
if (compose_sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cbx - 1, cby - 1, ctw + 4, 10);
display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cbx - 1, cby - 1, ctw + 4, 10);
display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2);
}
display.setCursor(cbx + 1, cby);
display.print(ctxt);
@@ -826,17 +832,17 @@ public:
snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name);
}
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
int total_msg_items = 1 + _active_msg_count;
for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) {
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;
int y = start_y + i * item_h;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H - 1);
display.fillRect(0, y - 1, display.width(), item_h - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -845,17 +851,18 @@ public:
display.print(sel ? ">" : " ");
if (idx == 0) {
display.setCursor(8, y);
display.setCursor(cw + 2, y);
display.print("Custom message...");
} else {
NodePrefs* p = _task->getNodePrefs();
int slot = _active_msgs[idx - 1];
const char* tmpl = p ? p->custom_msgs[slot] : "";
display.drawTextEllipsized(8, y, display.width() - 10, tmpl);
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4, tmpl);
}
}
display.setColor(DisplayDriver::LIGHT);
renderScrollHints(display, _msg_scroll, total_msg_items);
if (_msg_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_msg_scroll + _visible < total_msg_items) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); }
}
return 2000;
}
@@ -911,7 +918,7 @@ public:
}
if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) {
_contact_sel++;
if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1;
if (_contact_sel >= _contact_scroll + _visible) _contact_scroll = _contact_sel - _visible + 1;
return true;
}
if (c == KEY_ENTER && _num_contacts > 0) {
@@ -972,7 +979,7 @@ public:
}
if (c == KEY_DOWN && _channel_sel < _num_channels - 1) {
_channel_sel++;
if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1;
if (_channel_sel >= _channel_scroll + _visible) _channel_scroll = _channel_sel - _visible + 1;
return true;
}
if (c == KEY_ENTER && _num_channels > 0) {
@@ -1055,8 +1062,8 @@ public:
if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; }
else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) {
_dm_hist_sel++;
if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE)
_dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1;
if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible)
_dm_hist_scroll = _dm_hist_sel - _hist_visible + 1;
}
return true;
}
@@ -1129,7 +1136,7 @@ public:
if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; }
else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) {
_hist_sel++;
if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1;
if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1;
}
updateChannelUnread();
return true;
@@ -1183,7 +1190,7 @@ public:
}
if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) {
_msg_sel++;
if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1;
if (_msg_sel >= _msg_scroll + _visible) _msg_scroll = _msg_sel - _visible + 1;
return true;
}
if (c == KEY_ENTER) {

View File

@@ -6,12 +6,11 @@ class RingtoneEditorScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
static const int MAX_NOTES = 32;
static const int VISIBLE_NOTES = 7;
static const int CELL_W = 18;
static const int NOTES_Y = 12;
static const int CELL_H = 14;
static const int MENU_VISIBLE = 5;
static const int MAX_NOTES = 32;
static const int CELL_W = 18;
static const int MENU_VISIBLE = 5;
int _visible_notes = 7; // updated in render(); used by clampScroll()
static const uint16_t BPM_OPTS[5];
static const uint8_t DUR_VALS[4];
@@ -43,8 +42,8 @@ class RingtoneEditorScreen : public UIScreen {
}
void clampScroll() {
if (_cursor < _scroll) _scroll = _cursor;
if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1;
if (_cursor < _scroll) _scroll = _cursor;
if (_cursor >= _scroll + _visible_notes) _scroll = _cursor - _visible_notes + 1;
if (_scroll < 0) _scroll = 0;
}
@@ -93,13 +92,18 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
const int lh = display.getLineHeight();
const int notes_y = display.listStart();
const int cell_h = lh + 6;
_visible_notes = display.width() / CELL_W;
char hdr[32];
snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
display.setCursor(0, 0);
display.print(hdr);
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
for (int i = 0; i < VISIBLE_NOTES; i++) {
for (int i = 0; i < _visible_notes; i++) {
int ni = _scroll + i;
int cx = i * CELL_W;
bool sel = (ni == _cursor);
@@ -116,50 +120,52 @@ public:
}
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.fillRect(cx, notes_y, CELL_W - 1, cell_h);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.drawRect(cx, notes_y, CELL_W - 1, cell_h);
}
display.setCursor(cx + 3, NOTES_Y + 3);
display.setCursor(cx + 3, notes_y + 3);
display.print(label);
display.setColor(DisplayDriver::LIGHT);
} else if (ni == _len && _len < MAX_NOTES) {
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.fillRect(cx, notes_y, CELL_W - 1, cell_h);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(cx + 6, NOTES_Y + 3);
display.setCursor(cx + 6, notes_y + 3);
display.print("+");
display.setColor(DisplayDriver::LIGHT);
}
}
int info_y = NOTES_Y + CELL_H + 2;
const int info_y = notes_y + cell_h + 2;
const int bottom_y = display.height() - display.lineStep();
if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); }
if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); }
if (_scroll + _visible_notes <= _len) { display.setCursor(display.width() - display.getCharWidth(), info_y); display.print(">"); }
if (_cursor < _len) {
char info[24];
snprintf(info, sizeof(info), "oct:%d dur:%s",
noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]);
display.setCursor(10, info_y);
display.setCursor(display.getCharWidth() + 2, info_y);
display.print(info);
} else if (_cursor == _len) {
display.setCursor(10, info_y);
display.setCursor(display.getCharWidth() + 2, info_y);
display.print("U/D to add note");
}
display.setCursor(0, 54);
display.setCursor(0, bottom_y);
display.print("ENT:oct MENU:opts");
if (_menu_open) {
static const int mw = 100, mh = MENU_VISIBLE * 10 + 4;
int mx = (128 - mw) / 2;
int my = (64 - mh) / 2;
const int menu_ih = display.lineStep();
const int mw = 100, mh = MENU_VISIBLE * menu_ih + 4;
int mx = (display.width() - mw) / 2;
int my = (display.height() - mh) / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(mx, my, mw, mh);
display.setColor(DisplayDriver::LIGHT);
@@ -167,10 +173,10 @@ public:
for (int i = 0; i < MENU_VISIBLE; i++) {
int item = _menu_scroll + i;
if (item >= M_COUNT) break;
int iy = my + 2 + i * 10;
int iy = my + 2 + i * menu_ih;
bool sel = (item == _menu_sel);
if (sel) {
display.fillRect(mx + 1, iy - 1, mw - 2, 10);
display.fillRect(mx + 1, iy - 1, mw - 2, menu_ih);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(mx + 4, iy);
@@ -182,8 +188,9 @@ public:
display.print(MENU_LABELS[item]);
display.setColor(DisplayDriver::LIGHT);
}
if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); }
if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); }
int cw = display.getCharWidth();
if (_menu_scroll > 0) { display.setCursor(mx + mw - cw - 1, my + 2); display.print("^"); }
if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - cw - 1, my + mh - menu_ih); display.print("v"); }
}
return 200;

View File

@@ -57,13 +57,9 @@ class SettingsScreen : public UIScreen {
Count
};
static const int VISIBLE_ITEMS = 4;
static const int ITEM_H = 11;
static const int START_Y = 12;
static const int VAL_X = 80;
int _selected;
int _scroll;
int _visible; // items fitting on screen; updated each render, used by handleInput
bool _dirty;
static const uint16_t AUTO_OFF_OPTS[5];
@@ -230,7 +226,7 @@ class SettingsScreen : public UIScreen {
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), ITEM_H);
display.fillRect(0, y - 1, display.width(), display.lineStep());
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -242,10 +238,10 @@ class SettingsScreen : public UIScreen {
if (item == BRIGHTNESS) {
display.print("Bright");
renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5);
renderBar(display, display.valCol(), y, (p ? p->display_brightness : 2) + 1, 5);
} else if (item == BUZZER) {
display.print("Buzzer");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
#ifdef PIN_BUZZER
{ static const char* labels[] = { "ON", "OFF", "Auto" };
int m = _task->getBuzzerMode();
@@ -256,45 +252,45 @@ class SettingsScreen : public UIScreen {
} else if (item == BUZZER_VOLUME) {
display.print("BzrVol");
#ifdef PIN_BUZZER
renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5);
renderBar(display, display.valCol(), y, _task->getBuzzerVolume() + 1, 5);
#else
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print("N/A");
#endif
} else if (item == DM_MELODY) {
display.print("DM sound");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
{ static const char* L[] = { "built-in", "M1", "M2" };
uint8_t v = p ? p->notif_melody_dm : 0;
display.print(L[v < 3 ? v : 0]); }
} else if (item == CH_MELODY) {
display.print("Ch sound");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
{ static const char* L[] = { "built-in", "M1", "M2" };
uint8_t v = p ? p->notif_melody_ch : 0;
display.print(L[v < 3 ? v : 0]); }
} else if (isHomePage(item)) {
display.print(homePageLabel(item));
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print(homePageVisible(item, p) ? "ON" : "OFF");
} else if (item == TX_POWER) {
display.print("TX Pwr");
char buf[8];
sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0);
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print(buf);
} else if (item == AUTO_OFF) {
display.print("AutoOff");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print(AUTO_OFF_LABELS[autoOffIndex()]);
} else if (item == AUTO_LOCK) {
display.print("AutoLock");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print((p && p->auto_lock) ? "ON" : "OFF");
#if ENV_INCLUDE_GPS == 1
} else if (item == GPS_INTERVAL) {
display.print("GPS upd");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]);
#endif
} else if (item == TIMEZONE) {
@@ -303,46 +299,46 @@ class SettingsScreen : public UIScreen {
int8_t tz = p ? p->tz_offset_hours : 0;
if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz);
else sprintf(buf, "UTC%d", (int)tz);
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print(buf);
} else if (item == LOW_BAT) {
display.print("LowBat");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print(LOW_BAT_LABELS[lowBatIndex()]);
} else if (item == BATT_DISPLAY) {
display.print("BattDisp");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
uint8_t mode = p ? p->batt_display_mode : 0;
display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]);
#if !defined(EINK_DISPLAY_MODEL)
} else if (item == CLOCK_SECONDS) {
display.print("Seconds");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print((p && p->clock_hide_seconds) ? "OFF" : "ON");
#endif
} else if (item == CLOCK_FORMAT) {
display.print("Format");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print((p && p->clock_12h) ? "12h" : "24h");
} else if (item == FONT) {
display.print("Font");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print((p && p->use_lemon_font) ? "Lemon" : "Default");
#if defined(EINK_DISPLAY_MODEL)
} else if (item == ROTATION) {
display.print("Rotation");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
{ static const char* ROT_LABELS[] = { "0deg", "90deg", "180deg", "270deg" };
uint8_t r = p ? (p->display_rotation & 3) : 0;
display.print(ROT_LABELS[r]); }
#endif
} else if (item == DM_FILTER) {
display.print("DM");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print((p && p->dm_show_all) ? "all" : "fav");
} else if (item == ROOM_FILTER) {
display.print("Rooms");
display.setCursor(VAL_X, y);
display.setCursor(display.valCol(), y);
display.print((p && p->room_fav_only) ? "fav" : "all");
} else if (isMsgSlot(item)) {
int slot = msgSlotIndex(item);
@@ -350,7 +346,8 @@ class SettingsScreen : public UIScreen {
snprintf(label, sizeof(label), "Q%d:", slot + 1);
display.print(label);
const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)";
display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl);
int xm = 8 + display.getCharWidth() * 4;
display.drawTextEllipsized(xm, y, display.width() - xm, tmpl);
}
}
@@ -360,7 +357,7 @@ class SettingsScreen : public UIScreen {
public:
SettingsScreen(UITask* task)
: _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {}
: _task(task), _selected(BRIGHTNESS), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {}
void markClean() { _dirty = false; }
@@ -372,23 +369,27 @@ public:
return _kb.render(display);
}
int item_h = display.lineStep();
int start_y = display.listStart();
_visible = display.listVisible(item_h);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "SETTINGS");
display.fillRect(0, 10, display.width(), 1);
display.fillRect(0, display.headerH() - 2, display.width(), 1);
for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) {
renderItem(display, _scroll + i, START_Y + i * ITEM_H);
for (int i = 0; i < _visible && (_scroll + i) < SettingItem::Count; i++) {
renderItem(display, _scroll + i, start_y + i * item_h);
}
// scroll indicators
if (_scroll > 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(display.width() - 6, START_Y);
display.setCursor(display.width() - display.getCharWidth(), start_y);
display.print("^");
}
if (_scroll + VISIBLE_ITEMS < SettingItem::Count) {
if (_scroll + _visible < SettingItem::Count) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H);
display.setCursor(display.width() - display.getCharWidth(), start_y + (_visible - 1) * item_h);
display.print("v");
}
@@ -426,7 +427,7 @@ public:
int next = nextSelectable(_selected);
if (next != _selected) {
_selected = next;
if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1;
if (_selected >= _scroll + _visible) _scroll = _selected - _visible + 1;
}
return true;
}

View File

@@ -65,27 +65,36 @@ public:
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
const int lh = display.getLineHeight();
const int step = display.lineStep();
// meshcore logo
display.setColor(DisplayDriver::LIGHT);
int logoWidth = 128;
display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
int logo_y = 3;
display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13);
// version info
// version info at sz2
int ver_y = logo_y + 13 + 2;
display.setTextSize(2);
display.drawTextCentered(display.width()/2, 22, _version_info);
display.drawTextCentered(display.width()/2, ver_y, _version_info);
// build date at sz1 — sz2 lh is always 16
int date_y = ver_y + 16 + 2;
display.setTextSize(1);
display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE);
display.drawTextCentered(display.width()/2, date_y, FIRMWARE_BUILD_DATE);
#ifdef FIRMWARE_PLUS_BUILD
display.fillRect(0, 53, display.width(), 10);
int plus_y = date_y + step;
display.fillRect(0, plus_y - 1, display.width(), lh + 2);
display.setColor(DisplayDriver::DARK);
char plus_label[24];
if (_plus_ver[0])
snprintf(plus_label, sizeof(plus_label), "Plus %s for Wio", _plus_ver);
else
snprintf(plus_label, sizeof(plus_label), "Plus for Wio");
display.drawTextCentered(display.width()/2, 54, plus_label);
display.drawTextCentered(display.width()/2, plus_y, plus_label);
display.setColor(DisplayDriver::LIGHT);
#endif
@@ -338,9 +347,14 @@ public:
int render(DisplayDriver& display) override {
char tmp[80];
display.setTextSize(1);
const int lh = display.getLineHeight(); // line height at sz1
const int step = display.lineStep(); // lh + 2
const int dots_y = lh + 4; // page-dot row: just below header
const int content_y = dots_y + step; // first content row
// node name + battery — hidden on CLOCK page (full screen used for dashboard)
if (_page != CLOCK) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
@@ -360,13 +374,12 @@ public:
if (i == _page) curr_vis = vis_count;
vis_count++;
}
int y = 14;
int x = display.width() / 2 - 5 * (vis_count - 1);
int vi = 0;
for (int i = 0; i < (int)Count; i++) {
if (!isPageVisible(i)) continue;
if (vi == curr_vis) display.fillRect(x-1, y-1, 3, 3);
else display.fillRect(x, y, 1, 1);
if (vi == curr_vis) display.fillRect(x-1, dots_y-1, 3, 3);
else display.fillRect(x, dots_y, 1, 1);
x += 10; vi++;
}
}
@@ -376,9 +389,10 @@ public:
if (unix_ts < 1000000000UL) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 25, "! No time sync");
display.drawTextCentered(display.width() / 2, 40, "Enable GPS or");
display.drawTextCentered(display.width() / 2, 51, "connect app");
int mid_y = display.height() / 2 - step;
display.drawTextCentered(display.width() / 2, mid_y, "! No time sync");
display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or");
display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app");
} else {
int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
unix_ts += (int32_t)tz * 3600;
@@ -407,12 +421,14 @@ public:
sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year);
display.drawTextCentered(display.width() / 2, 19, buf);
display.fillRect(0, 28, display.width(), 1);
int sep_y = 19 + lh + 1;
int dash0 = sep_y + 3;
display.fillRect(0, sep_y, display.width(), 1);
// dashboard data fields
if (_node_prefs) {
refresh_sensors();
static const int FIELD_Y[] = { 31, 41, 51 };
const int FIELD_Y[3] = { dash0, dash0 + step, dash0 + step * 2 };
for (int fi = 0; fi < 3; fi++) {
uint8_t field = _node_prefs->dashboard_fields[fi];
if (field == DASH_NONE) continue;
@@ -491,8 +507,8 @@ public:
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(DisplayDriver::LIGHT);
int y = 20;
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
int y = content_y;
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += step) {
auto a = &recent[i];
if (a->name[0] == 0) continue; // empty slot
int secs = _rtc->getCurrentTime() - a->recv_timestamp;
@@ -515,44 +531,46 @@ public:
}
} else if (_page == HomePage::RADIO) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
// freq / sf
display.setCursor(0, 20);
display.setCursor(0, content_y);
sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
display.print(tmp);
display.setCursor(0, 31);
display.setCursor(0, content_y + step);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
display.print(tmp);
// tx power, noise floor
display.setCursor(0, 42);
// tx power, noise floor
display.setCursor(0, content_y + step * 2);
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
display.print(tmp);
display.setCursor(0, 53);
display.setCursor(0, content_y + step * 3);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
display.print(tmp);
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::LIGHT);
display.drawXbm((display.width() - 32) / 2, 14,
int icon_y = content_y;
display.drawXbm((display.width() - 32) / 2, icon_y,
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
32, 32);
display.setTextSize(1);
int pin_y = icon_y + 32 + 3;
int hint_y = pin_y + step;
if (_task->isSerialEnabled() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) {
char pin_buf[16];
snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 49, pin_buf);
display.drawTextCentered(display.width() / 2, pin_y, pin_buf);
}
display.drawTextCentered(display.width() / 2, 57, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, hint_y, "toggle: " PRESS_LABEL);
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::LIGHT);
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
display.drawXbm((display.width() - 32) / 2, content_y, advert_icon, 32, 32);
display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
int y = content_y;
bool gps_state = _task->getGPSState();
#ifdef PIN_GPS_SWITCH
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
@@ -566,30 +584,30 @@ public:
#endif
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
y = y + 12;
y += step;
display.drawTextLeftAlign(0, y, "Can't access GPS");
} else {
strcpy(buf, nmea->isValid()?"fix":"no fix");
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
y += step;
display.drawTextLeftAlign(0, y, "sat");
sprintf(buf, "%d", nmea->satellitesCount());
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
y += step;
display.drawTextLeftAlign(0, y, "pos");
sprintf(buf, "%.4f %.4f",
sprintf(buf, "%.4f %.4f",
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
y += step;
display.drawTextLeftAlign(0, y, "alt");
sprintf(buf, "%.2f", nmea->getAltitude()/1000.);
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
y += step;
}
#endif
#if UI_SENSORS_PAGE == 1
} else if (_page == HomePage::SENSORS) {
int y = 18;
int y = content_y;
refresh_sensors();
uint8_t avail_types[16];
@@ -655,7 +673,7 @@ public:
display.print(name);
display.setCursor(display.width() - display.getTextWidth(buf) - 1, y);
display.print(buf);
y += 12;
y += step;
}
if (need_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % avail_count;
else sensors_scroll_offset = 0;
@@ -663,32 +681,32 @@ public:
} else if (_page == HomePage::SETTINGS) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 22, "Settings");
display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open");
display.drawTextCentered(display.width() / 2, content_y, "Settings");
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
} else if (_page == HomePage::TOOLS) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 22, "Tools");
display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open");
display.drawTextCentered(display.width() / 2, content_y, "Tools");
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
} else if (_page == HomePage::QUICK_MSG) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 22, "Messages");
display.drawTextCentered(display.width() / 2, content_y, "Messages");
int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
if (total_unread > 0) {
char badge[20];
snprintf(badge, sizeof(badge), "%d unread", total_unread);
display.drawTextCentered(display.width() / 2, 35, badge);
display.drawTextCentered(display.width() / 2, content_y + step, badge);
}
display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open");
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
if (_shutdown_init) {
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
display.drawTextCentered(display.width() / 2, content_y + step, "hibernating...");
} else {
display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
display.drawXbm((display.width() - 32) / 2, content_y, power_icon, 32, 32);
display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "hibernate:" PRESS_LABEL);
}
}
bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0;
@@ -1337,9 +1355,11 @@ void UITask::loop() {
// Lock screen: clock + unlock hint popup
uint32_t unix_ts = rtc_clock.getCurrentTime();
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(1);
const int lk_lh = _display->getLineHeight();
const int lk_step = _display->lineStep();
if (unix_ts < 1000000000UL) {
_display->setTextSize(1);
_display->drawTextCentered(_display->width() / 2, 20, "No time sync");
_display->drawTextCentered(_display->width() / 2, _display->height() / 2 - lk_step, "No time sync");
} else {
int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
unix_ts += (int32_t)tz * 3600;
@@ -1347,18 +1367,21 @@ void UITask::loop() {
struct tm* ti = gmtime(&t);
char buf[12];
_display->setTextSize(2);
const int lh2 = _display->getLineHeight(); // sz2 line height
const int clk_y = 2;
if (_node_prefs && _node_prefs->clock_12h) {
int h = ti->tm_hour % 12; if (h == 0) h = 12;
sprintf(buf, "%d:%02d %s", h, ti->tm_min, ti->tm_hour < 12 ? "AM" : "PM");
} else {
sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min);
}
_display->drawTextCentered(_display->width() / 2, 8, buf);
_display->drawTextCentered(_display->width() / 2, clk_y, buf);
_display->setTextSize(1);
static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"};
sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]);
_display->drawTextCentered(_display->width() / 2, 26, buf);
int date_y = clk_y + lh2 + 2;
_display->drawTextCentered(_display->width() / 2, date_y, buf);
// Two sensor values side by side (dashboard_fields[0] and [1])
if (_node_prefs) {
@@ -1375,17 +1398,17 @@ void UITask::loop() {
formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr);
formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr);
if (v0[0] || v1[0]) {
_display->setTextSize(1);
int sv_y = date_y + lk_step;
_display->setColor(DisplayDriver::LIGHT);
if (v0[0] && v1[0]) {
_display->setCursor(0, 37);
_display->setCursor(0, sv_y);
_display->print(v0);
int vw = _display->getTextWidth(v1);
_display->setCursor(_display->width() - vw, 37);
_display->setCursor(_display->width() - vw, sv_y);
_display->print(v1);
} else {
const char* sv = v0[0] ? v0 : v1;
_display->drawTextCentered(_display->width() / 2, 37, sv);
_display->drawTextCentered(_display->width() / 2, sv_y, sv);
}
}
}
@@ -1395,11 +1418,11 @@ void UITask::loop() {
const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" :
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
int p = 3;
int hy = _display->height() - 13;
int hy = _display->height() - lk_lh - p * 2;
int hw = _display->getTextWidth(hint);
int hx = (_display->width() - hw) / 2;
_display->setColor(DisplayDriver::LIGHT);
_display->fillRect(hx - p, hy - p, hw + p*2, 8 + p*2);
_display->fillRect(hx - p, hy - p, hw + p*2, lk_lh + p*2);
_display->setColor(DisplayDriver::DARK);
_display->setCursor(hx, hy);
_display->print(hint);
@@ -1457,8 +1480,10 @@ void UITask::loop() {
_display->startFrame();
_display->setTextSize(1);
_display->setColor(DisplayDriver::LIGHT);
_display->drawTextCentered(_display->width() / 2, 24, "Low Battery");
_display->drawTextCentered(_display->width() / 2, 36, "Shutting down");
int mid = _display->height() / 2;
int step = _display->lineStep();
_display->drawTextCentered(_display->width() / 2, mid - step, "Low Battery");
_display->drawTextCentered(_display->width() / 2, mid, "Shutting down");
_display->endFrame();
delay(2000);
}

View File

@@ -31,6 +31,16 @@ public:
virtual int getCharWidth() const { return 6; } // typical character advance width (px)
virtual int getLineHeight() const { return 8; } // pixel rows per text line
virtual void setLemonFont(bool) { } // no-op; overridden by displays that support Lemon
// Layout helpers — derived from font metrics and screen size.
// Use these instead of hardcoded pixel values so layouts adapt to any display.
int lineStep() const { return getLineHeight() + 2; } // row pitch: text + gap
int headerH() const { return getLineHeight() + 3; } // title bar height
int listStart() const { return headerH(); } // y where list items begin
int listVisible(int itemH) const { return (height() - listStart()) / itemH; }
int listVisible() const { return listVisible(lineStep()); }
// x where a right-side value column starts (leaves ~8 chars for the value)
int valCol() const { return width() - getCharWidth() * 8; }
virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise)
int w = getTextWidth(str);
setCursor(mid_x - w/2, y);