feat(l1): one list row for settings and tool screens: values flush right, switches for on/off

InfoKit gains valueRow (value at the right edge, cut and scrolling when
selected), switchRow and listRow (exact ON/OFF becomes a switch). Settings,
Live Share, Locator, Repeater, Bot, Auto-Advert, alarm, GPIO and popup value
rows use them; units after a space, "Off" in title case, Trail summary as
blocks with a big distance and a points meter, "+ Send" as a soft pill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 08:29:44 +02:00
co-authored by Claude Opus 5.5
parent 4d8724dcc5
commit 4ba171d6d8
14 changed files with 245 additions and 274 deletions
+10 -8
View File
@@ -46,18 +46,20 @@ static inline const char* bearingCardinal(int deg) {
}
// Human distance string. Metric: "850m" / "2.3km" / "140km". Imperial:
// "850ft" / "2.3mi" / "140mi" (feet below ~1000 ft, then miles).
static inline void fmtDist(char* buf, int n, float km, bool imperial) {
// "850ft" / "2.3mi" / "140mi" (feet below ~1000 ft, then miles). `spaced`
// puts a space before the unit ("850 m"), for value rows with room for it.
static inline void fmtDist(char* buf, int n, float km, bool imperial, bool spaced = false) {
const char* sp = spaced ? " " : "";
if (imperial) {
float ft = km * 3280.84f;
if (ft < 1000.0f) { snprintf(buf, n, "%dft", (int)(ft + 0.5f)); return; }
if (ft < 1000.0f) { snprintf(buf, n, "%d%sft", (int)(ft + 0.5f), sp); return; }
float mi = km * 0.621371f;
if (mi < 100.0f) snprintf(buf, n, "%.1fmi", mi);
else snprintf(buf, n, "%dmi", (int)(mi + 0.5f));
if (mi < 100.0f) snprintf(buf, n, "%.1f%smi", mi, sp);
else snprintf(buf, n, "%d%smi", (int)(mi + 0.5f), sp);
} else {
if (km < 1.0f) snprintf(buf, n, "%dm", (int)(km * 1000 + 0.5f));
else if (km < 100.0f) snprintf(buf, n, "%.1fkm", km);
else snprintf(buf, n, "%dkm", (int)(km + 0.5f));
if (km < 1.0f) snprintf(buf, n, "%d%sm", (int)(km * 1000 + 0.5f), sp);
else if (km < 100.0f) snprintf(buf, n, "%.1f%skm", km, sp);
else snprintf(buf, n, "%d%skm", (int)(km + 0.5f), sp);
}
}
+1 -1
View File
@@ -310,7 +310,7 @@ struct NodePrefs { // persisted to file
}
}
static const char* alarmRepeatLabel(uint8_t idx) {
static const char* L[ALARM_REPEAT_COUNT] = { "OFF", "Daily", "Weekdays", "Weekends" };
static const char* L[ALARM_REPEAT_COUNT] = { "Once", "Daily", "Weekdays", "Weekends" }; // 0: no repeat (as the L2 shows it)
return L[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
@@ -1,4 +1,5 @@
#pragma once
#include "InfoKit.h"
// Configures periodic automatic 0-hop advert with GPS position.
// Included by UITask.cpp after DashboardConfigScreen.h.
@@ -28,14 +29,10 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("AUTO-ADVERT");
const int valx = display.width() / 2 + 6;
int sel = 0, scroll = 0;
drawList(display, 1, sel, scroll, [&](int, int y, bool is_sel, int reserve) {
drawRowSelection(display, y, is_sel, reserve);
display.setCursor(4, y);
display.print("Interval");
display.setCursor(valx, y);
display.print(OPT_LABELS[currentIdx()]);
info::valueRow(display, y, "Interval", OPT_LABELS[currentIdx()], is_sel, reserve);
});
return 5000;
}
@@ -60,4 +57,4 @@ public:
};
const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 30, 60, 120, 300, 600, 1800, 3600 };
const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "OFF", "30s", "1min", "2min", "5min", "10min", "30min", "1h" };
const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "Off", "30 s", "1 min", "2 min", "5 min", "10 min", "30 min", "1 h" };
+11 -23
View File
@@ -2,6 +2,7 @@
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after KeyboardWidget.h is defined.
#include "InfoKit.h"
#include "TabBar.h"
#include "../ui-core/BotConfig.h"
@@ -89,8 +90,6 @@ public:
return _kb->render(display);
}
int val_x = display.valCol();
// Reply counter, right-aligned in the header — reserve its width from the
// tab bar before drawing so a right-side tab can't run under it.
uint16_t sent = the_mesh.botReplyCount();
@@ -112,44 +111,33 @@ public:
drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = tabRow(_tab, i);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(r.label);
display.setCursor(val_x, y);
int cap;
const char* txt = botcfg::text(_prefs, r.kind, cap);
const uint8_t* fl = botcfg::flag(_prefs, r.kind);
char name[32];
const char* shown = "";
if (fl) {
display.print(r.kind == botcfg::DM_SCOPE ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF"));
shown = r.kind == botcfg::DM_SCOPE ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF");
} else if (txt) {
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,
botcfg::shownText(txt, botcfg::isTrigger(r.kind)), sel);
if (sel && mqr > 0) mq_delay = mqr;
shown = botcfg::shownText(txt, botcfg::isTrigger(r.kind));
} else if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
bool any = r.kind == botcfg::CHANNEL ? _num_channels > 0 : _num_rooms > 0;
bool ok = r.kind == botcfg::CHANNEL ? botcfg::channelName(_prefs, name, sizeof(name))
: botcfg::roomName(_prefs, name, sizeof(name));
if (!any) display.print("(none)");
else if (!ok) display.print("?");
else {
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, name, sel);
if (sel && mqr > 0) mq_delay = mqr;
}
shown = !any ? "(none)" : !ok ? "?" : name;
} else if (botcfg::isHour(r.kind)) {
char hb[10];
if (botcfg::quietOff(_prefs)) strcpy(hb, "OFF");
if (botcfg::quietOff(_prefs)) strcpy(hb, "Off");
else snprintf(hb, sizeof(hb), "%02d:00", botcfg::hour(_prefs, r.kind));
// Bracket the value while the stepper sub-mode is open on this row,
// as a visual cue that UP/DOWN now steps it instead of moving rows.
if (_stepper_row == i) {
char bracketed[14];
snprintf(bracketed, sizeof(bracketed), "[%s]", hb);
display.print(bracketed);
} else {
display.print(hb);
}
if (_stepper_row == i) snprintf(name, sizeof(name), "[%s]", hb);
else snprintf(name, sizeof(name), "%s", hb);
shown = name;
}
int mqr = info::listRow(display, y, r.label, shown, sel, reserve);
if (mqr > 0) mq_delay = mqr;
display.setColor(DisplayDriver::LIGHT);
});
return 2000;
@@ -28,6 +28,7 @@
// (and immediately on any key); the millis() timing underneath is unaffected.
// Included by UITask.cpp after the other screen fragments.
#include "InfoKit.h"
#include "../NodePrefs.h"
#include "icons.h" // drawList / drawRowSelection
@@ -166,19 +167,18 @@ class ClockToolsScreen : public UIScreen {
if (!_prefs) return 60000;
const char* rows[3] = { "Time", "Repeat", "Armed" };
if (_sel > 2) _sel = 2;
const int valx = d.width() / 2 + 6;
int mq_delay = 0;
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(d, y, sel, reserve);
d.setCursor(4, y); d.print(rows[i]);
if (i == 0) {
drawAlarmTime(d, y, valx, sel && _alarm_editing);
if (i == 0) { // the time, flush right like every value (digits edited in place)
d.setCursor(2, y); d.print(rows[i]);
drawAlarmTime(d, y, d.width() - reserve - 2 - d.getTextWidth("00:00"), sel && _alarm_editing);
} else if (i == 1) {
int mqr = d.drawTextEllipsized(valx, y, d.width() - valx - reserve,
NodePrefs::alarmRepeatLabel(NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask)), sel);
if (sel && mqr > 0) mq_delay = mqr;
int mqr = info::valueRow(d, y, rows[i],
NodePrefs::alarmRepeatLabel(NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask)), sel, reserve);
if (mqr > 0) mq_delay = mqr;
} else {
d.drawTextEllipsized(valx, y, d.width() - valx - reserve, _prefs->alarm_on ? "ON" : "OFF");
info::switchRow(d, y, rows[i], _prefs->alarm_on, sel, reserve);
}
});
if (_alarm_editing) return 50;
+6 -8
View File
@@ -10,6 +10,7 @@
// Included by UITask.cpp.
#if defined(PIN_GPIO1)
#include "InfoKit.h"
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include "icons.h"
@@ -65,11 +66,11 @@ class GpioScreen : public UIScreen {
case 3: strncpy(buf, "Output", n); break;
case 4: {
int mv = 0;
if (_task->botGetGPIOAnalog(pin + 1, mv)) snprintf(buf, n, "%dmV", mv);
if (_task->botGetGPIOAnalog(pin + 1, mv)) snprintf(buf, n, "%d mV", mv);
else strncpy(buf, "Analog", n);
break;
}
default: strncpy(buf, "OFF", n); break;
default: strncpy(buf, "Off", n); break;
}
buf[n - 1] = '\0';
}
@@ -116,20 +117,17 @@ public:
drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
const Row& it = _items[row];
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
char val[16];
if (it.kind == ROW_MODE) {
char lbl[8];
snprintf(lbl, sizeof(lbl), "GPIO%d", it.pin + 1);
display.print(lbl);
modeRowValue(it.pin, val, sizeof(val));
uint8_t m = gpioModeOf(it.pin);
if (m == 1 || m == 4) any_live = true; // Input / Analog need a live-refreshed reading
} else {
display.print(" State"); // indented: reads as GPIOn's sub-row
strncpy(val, gpioModeOf(it.pin) == 3 ? "ON" : "OFF", sizeof(val));
info::valueRow(display, y, lbl, val, sel, reserve);
} else { // indented: reads as GPIOn's sub-row
info::switchRow(display, y, " State", gpioModeOf(it.pin) == 3, sel, reserve);
}
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
display.setColor(DisplayDriver::LIGHT);
});
return any_live ? 500 : 2000; // faster refresh only while a row needs a live reading
+43 -3
View File
@@ -6,6 +6,7 @@
// fixed 128x64, so a larger-font e-ink layout gets them scaled for free.
#include <helpers/ui/DisplayDriver.h>
#include <string.h>
#include "icons.h"
namespace info {
@@ -124,19 +125,58 @@ inline int bigH(DisplayDriver& d) { return 2 * d.getLineHeight(); }
// An on/off switch, right edge at x_right, centred on a text line at y: a
// filled pill with the knob right when on, an outline with the knob left off.
// `inv` draws it in a selected row's colours (dark on the light bar) and
// leaves the ink dark, as the row found it.
inline int switchW(DisplayDriver& d) { return 13 * miniIconScale(d); }
inline void toggle(DisplayDriver& d, int x_right, int y, bool on) {
inline void toggle(DisplayDriver& d, int x_right, int y, bool on, bool inv = false) {
const DisplayDriver::Color ink = inv ? DisplayDriver::DARK : DisplayDriver::LIGHT;
const DisplayDriver::Color paper = inv ? DisplayDriver::LIGHT : DisplayDriver::DARK;
const int s = miniIconScale(d), w = switchW(d), h = 7 * s;
const int x = x_right - w, ty = y + (d.getLineHeight() - h) / 2;
d.setColor(ink);
if (on) {
d.fillSoftRect(x, ty, w, h);
d.setColor(DisplayDriver::DARK);
d.setColor(paper);
d.fillRect(x + w - 5 * s, ty + s, 3 * s, h - 2 * s);
d.setColor(DisplayDriver::LIGHT);
} else {
d.drawSoftRect(x, ty, w, h);
d.fillRect(x + 2 * s, ty + 2 * s, 3 * s, h - 4 * s);
}
d.setColor(ink);
}
// ── List rows (call after drawRowSelection(); text is already in the row's ink)
// A setting or field: label at x0, value flush right. A value too long for the
// room beside the label is cut, and scrolls while the row is selected.
// Returns that marquee's next-step delay (0: nothing moving).
inline int valueRow(DisplayDriver& d, int y, const char* label, const char* value, bool sel,
int reserve, int x0 = 2) {
d.setCursor(x0, y);
d.print(label);
if (!value || !*value) return 0;
const int right = d.width() - reserve - 2;
const int lx = x0 + (*label ? d.getTextWidth(label) + d.getCharWidth() : 0);
if (lx + (int)d.getTextWidth(value) <= right) { d.drawTextRightAlign(right, y, value); return 0; }
const int r = d.drawTextEllipsized(lx, y, right - lx, value, sel);
return sel && r > 0 ? r : 0;
}
// A row from a screen's value formatter: exactly "ON" / "OFF" is a yes/no
// setting and gets the switch; anything else (an option that can be "Off"
// among numbers included) is a value. Returns valueRow()'s marquee delay.
inline void switchRow(DisplayDriver& d, int y, const char* label, bool on, bool sel, int reserve, int x0 = 2);
inline int listRow(DisplayDriver& d, int y, const char* label, const char* value, bool sel,
int reserve, int x0 = 2) {
if (value && (!strcmp(value, "ON") || !strcmp(value, "OFF"))) {
switchRow(d, y, label, value[1] == 'N', sel, reserve, x0);
return 0;
}
return valueRow(d, y, label, value, sel, reserve, x0);
}
// An on/off row: the label, then a switch at the right edge.
inline void switchRow(DisplayDriver& d, int y, const char* label, bool on, bool sel, int reserve, int x0) {
d.setCursor(x0, y);
d.print(label);
toggle(d, d.width() - reserve - 2, y, on, sel);
}
// The last N samples of one reading, oldest first, for a history line.
@@ -8,6 +8,7 @@
// one-shot "Share my pos" lives on the Map screen.
// Included by UITask.cpp after AutoAdvertScreen.h.
#include "InfoKit.h"
#include "../NodePrefs.h"
#include "icons.h" // drawList (shared scrolling-list helper)
@@ -65,7 +66,7 @@ public:
snprintf(buf, n, "%s", (_prefs && _prefs->loc_share_enabled) ? "ON" : "OFF");
break;
case K_DURATION:
snprintf(buf, n, "%uh", (unsigned)(NodePrefs::locShareDurationMins(_prefs ? _prefs->loc_share_duration_idx : 0) / 60));
snprintf(buf, n, "%u h", (unsigned)(NodePrefs::locShareDurationMins(_prefs ? _prefs->loc_share_duration_idx : 0) / 60));
break;
case K_TARGET:
currentTargetName(buf, n);
@@ -79,18 +80,18 @@ public:
break;
}
case K_MOVE:
snprintf(buf, n, "%um", (unsigned)NodePrefs::locShareMoveMeters(_prefs ? _prefs->loc_share_move_idx : 1));
snprintf(buf, n, "%u m", (unsigned)NodePrefs::locShareMoveMeters(_prefs ? _prefs->loc_share_move_idx : 1));
break;
case K_GAP: {
uint16_t s = NodePrefs::locShareIntervalSecs(_prefs ? _prefs->loc_share_interval_idx : 1);
if (s < 60) snprintf(buf, n, "%us", (unsigned)s);
else snprintf(buf, n, "%um", (unsigned)(s / 60));
if (s < 60) snprintf(buf, n, "%u s", (unsigned)s);
else snprintf(buf, n, "%u min", (unsigned)(s / 60));
break;
}
case K_HB: {
uint16_t s = NodePrefs::locShareHeartbeatSecs(_prefs ? _prefs->loc_share_heartbeat_idx : 0);
if (s == 0) snprintf(buf, n, "OFF");
else snprintf(buf, n, "%um", (unsigned)(s / 60));
if (s == 0) snprintf(buf, n, "Off");
else snprintf(buf, n, "%u min", (unsigned)(s / 60));
break;
}
default: buf[0] = '\0';
@@ -102,19 +103,14 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("LIVE SHARE");
const int valx = display.width() / 2 + 6;
int mq_delay = 0;
drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i);
drawRowSelection(display, y, sel, reserve);
display.setCursor(4, y);
display.print(r.label);
char val[24];
valueLabel(r.kind, val, sizeof(val));
if (val[0]) {
int mqr = display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val, sel);
if (sel && mqr > 0) mq_delay = mqr;
}
int mqr = info::listRow(display, y, r.label, val, sel, reserve);
if (mqr > 0) mq_delay = mqr;
});
return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500;
}
@@ -15,6 +15,7 @@
// (UITask::setTargetNow()), bypassing this picker entirely.
// Included by UITask.cpp after LiveShareScreen.h.
#include "InfoKit.h"
#include "../NodePrefs.h"
#include "../Waypoint.h"
#include "../LiveTrack.h"
@@ -92,7 +93,7 @@ public:
break;
case K_RADIUS: {
uint16_t r = NodePrefs::locatorRadiusMeters(_prefs ? _prefs->locator_radius_idx : 1);
geo::fmtDist(buf, n, r / 1000.0f, _task && _task->useImperial());
geo::fmtDist(buf, n, r / 1000.0f, _task && _task->useImperial(), true);
break;
}
case K_MODE:
@@ -113,19 +114,14 @@ public:
const int rc = visibleRows();
if (_sel >= rc) _sel = rc - 1; // beeper row may have just been hidden
const int valx = display.width() / 2 + 6;
int mq_delay = 0;
drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i);
drawRowSelection(display, y, sel, reserve);
display.setCursor(4, y);
display.print(r.label);
char val[24];
valueLabel(r.kind, val, sizeof(val));
if (val[0]) {
int mqr = display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val, sel);
if (sel && mqr > 0) mq_delay = mqr;
}
int mqr = info::listRow(display, y, r.label, val, sel, reserve);
if (mqr > 0) mq_delay = mqr;
});
return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500;
}
@@ -554,25 +554,25 @@ class MessagesScreen : public UIScreen {
return { outgoing ? (full_avail - w) : 0, w };
}
// Bottom-right "[+ send]" compose button, shared by both history lists:
// Bottom-right "+ Send" compose button, shared by both history lists:
// bordered when idle, inverted (filled) when selected. Right-aligned to
// match the outgoing ("Me") bubbles anchored on that side, so composing
// sits under your own messages rather than the other side's. Box is exactly
// lh tall (no added border padding) — the text has no descenders ("[+
// send]"), so padding sized against the font's full ascent+descent cell
// lh tall (no added border padding) — the text has no descenders ("+
// Send"), so padding sized against the font's full ascent+descent cell
// read as a lopsided 1px-top/3px-bottom gap instead of an even border;
// tight framing avoids that and gives the history list back the 2px. Always
// reset to LIGHT first so it stays visible regardless of the ink the
// message loop left.
static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) {
const char* ctxt = "[+ send]";
const char* ctxt = "+ Send";
int ctw = d.getTextWidth(ctxt);
int bw = ctw + 4;
int bw = ctw + 6;
int bx = d.width() - bw;
d.setColor(DisplayDriver::LIGHT);
if (sel) { d.fillSoftRect(bx, cby, bw, lh); d.setColor(DisplayDriver::DARK); }
else d.drawSoftRect(bx, cby, bw, lh);
d.setCursor(bx + 2, cby);
d.setCursor(bx + 3, cby);
d.print(ctxt);
d.setColor(DisplayDriver::LIGHT);
}
@@ -2535,5 +2535,5 @@ public:
}
};
const char* const MessagesScreen::NOTIF_LABELS[3] = { "Default", "OFF", "ON" };
const char* const MessagesScreen::NOTIF_LABELS[3] = { "Default", "Off", "On" }; // three states: text, not a switch
const char* const MessagesScreen::MELODY_LABELS[3] = { "Global", "M1", "M2" };
+25 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include "InfoKit.h"
#include <helpers/ui/DisplayDriver.h>
#include <Arduino.h>
#include "icons.h" // scalable scroll indicator (track + thumb), matches the rest of the UI
@@ -88,6 +89,21 @@ struct PopupMenu {
setSelected(1);
}
// A value row reads "Label: value": split it so the value can stand at the
// right edge (a switch for ON / OFF), like a settings row.
bool splitValue(int i, char* lab, int n, const char*& val) const {
if (!(_value_mask & (1u << i))) return false;
const char* c = strstr(_items[i], ": ");
if (!c || c - _items[i] >= n) return false;
memcpy(lab, _items[i], c - _items[i]);
lab[c - _items[i]] = '\0';
val = c + 2;
return true;
}
static int valueWidth(DisplayDriver& d, const char* v) {
return (!strcmp(v, "ON") || !strcmp(v, "OFF")) ? info::switchW(d) : d.getTextWidth(v);
}
int render(DisplayDriver& display) {
// 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
@@ -130,7 +146,10 @@ struct PopupMenu {
// 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]);
char lab[32]; const char* val;
int w = splitValue(i, lab, sizeof(lab), val)
? display.getTextWidth(lab) + 2 * cw + valueWidth(display, val)
: display.getTextWidth(_items[i]);
if (w > content_w) content_w = w;
}
int box_w = _has_checkboxes ? (checkboxWidth(display) + pad) : 0;
@@ -171,7 +190,11 @@ struct PopupMenu {
}
// Return value not needed here: this popup already redraws every 50ms
// (below), faster than any marquee step, so the animation is already smooth.
display.drawTextEllipsized(bx + pad, py, text_w, _items[idx], idx == _sel);
char lab[32]; const char* val;
if (splitValue(idx, lab, sizeof(lab), val)) // "Label: value" -> label left, value / switch right
info::listRow(display, py, lab, val, idx == _sel, display.width() - (bx + pad + text_w) - 2, bx + pad);
else
display.drawTextEllipsized(bx + pad, py, text_w, _items[idx], idx == _sel);
if (_has_checkboxes) drawCheckbox(display, bx + bw - arrow_w - pad - checkboxWidth(display), py, isChecked(idx));
display.setColor(DisplayDriver::LIGHT);
}
@@ -16,6 +16,7 @@
// can't land outside what's legal for the operator's region).
// Included by UITask.cpp.
#include "InfoKit.h"
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include "icons.h"
@@ -113,12 +114,12 @@ class RepeaterScreen : public UIScreen {
case IT_RPRESET: strncpy(buf, _picker.currentName(p, rptTarget(p)), n); break;
case IT_RFREQ: snprintf(buf, n, "%.3f", p->repeater_freq); break;
case IT_RSF: snprintf(buf, n, "%d", (int)p->repeater_sf); break;
case IT_RBW: snprintf(buf, n, "%.1f", p->repeater_bw); break;
case IT_RBW: snprintf(buf, n, "%.1f kHz", p->repeater_bw); break;
case IT_RCR: snprintf(buf, n, "%d", (int)p->repeater_cr); break;
case IT_SKIP: strncpy(buf, p->repeat_skip_adverts ? "ON" : "OFF", n); break;
case IT_HOPS: rptctl::fmtHops(buf, n, p->repeat_max_hops, "OFF"); break;
case IT_YIELD: rptctl::fmtYield(buf, n, p->repeat_delay_boost, "OFF"); break;
case IT_SNR: rptctl::fmtSnr(buf, n, p->repeat_min_snr, "OFF"); break;
case IT_HOPS: rptctl::fmtHops(buf, n, p->repeat_max_hops); break; // "Off": a value, not a switch
case IT_YIELD: rptctl::fmtYield(buf, n, p->repeat_delay_boost); break;
case IT_SNR: rptctl::fmtSnr(buf, n, p->repeat_min_snr); break;
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
case IT_SCOPE: strncpy(buf, p->repeat_scope_only ? "ON" : "OFF", n); break;
case IT_SCOPE_EXTRA: {
@@ -151,18 +152,18 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("REPEATER");
// Config only — live forwarding stats live on Tools › Diagnostics.
// Config only — live forwarding stats live on Status › Mesh.
drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
int item = _items[row];
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _editor.active()) {
_editor.render(display, display.valCol(), y);
if (item == IT_RFREQ && sel && _editor.active()) { // the digit editor where the value stands
display.setCursor(2, y);
display.print(itemLabel(item));
_editor.render(display, display.width() - reserve - 2 - display.getTextWidth("000.000"), y);
} else {
char val[16];
char val[24];
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
info::listRow(display, y, itemLabel(item), val, sel, reserve);
}
display.setColor(DisplayDriver::LIGHT);
});
+64 -118
View File
@@ -8,6 +8,7 @@
#include "RadioPresetPicker.h"
#include "AccordionList.h"
#include "PopupMenu.h" // scope list management's per-row action menu
#include "InfoKit.h" // value / switch rows
class SettingsScreen : public UIScreen {
UITask* _task;
@@ -107,13 +108,16 @@ class SettingsScreen : public UIScreen {
return L[r & 3];
}
// Level boxes ending at the row's right edge, like every other value.
void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) {
const int gap = 2;
const int avail = display.width() - x - _reserve;
const int right = display.width() - _reserve - 2;
const int avail = right - x;
const int raw = (avail - (max_val - 1) * gap) / max_val;
const int cap = display.getLineHeight() - 2;
const int box_h = raw < cap ? (raw < 2 ? 2 : raw) : cap;
const int box_w = box_h;
x = right - (max_val * box_w + (max_val - 1) * gap);
for (int i = 0; i < max_val; i++) {
int bx = x + i * (box_w + gap);
display.drawRect(bx, y, box_w, box_h);
@@ -219,22 +223,20 @@ class SettingsScreen : public UIScreen {
int renderSchema(DisplayDriver& display, const settings::Setting& st, NodePrefs* p, int y, bool sel) {
const char* label = settings::shortLabel(st);
display.print(label);
if (!p) return 0;
if (!p) { display.setCursor(2, y); display.print(label); return 0; }
if (st.offset == offsetof(NodePrefs, display_brightness) || st.offset == offsetof(NodePrefs, buzzer_volume)) {
display.setCursor(2, y);
display.print(label);
renderBar(display, valCol(display), y, settings::get(*p, st) + 1, st.count);
return 0;
}
char v[24];
settings::text(*p, st, v, sizeof(v), "ON", "OFF");
int right = display.width() - _reserve;
int x = valCol(display), w = display.getTextWidth(v);
if (x + w > right) {
int min_x = 2 + display.getTextWidth(label) + display.getCharWidth();
x = right - w > min_x ? right - w : min_x;
if (!st.option) { // a switch
info::switchRow(display, y, label, settings::get(*p, st) != 0, sel, _reserve);
return 0;
}
int r = display.drawTextEllipsized(x, y, right - x, v, sel);
return sel && r > 0 ? r : 0;
char v[24];
settings::text(*p, st, v, sizeof(v));
return info::valueRow(display, y, label, v, sel, _reserve);
}
// (Re)load the section sizes into the accordion (folds all, resets the cursor).
@@ -445,173 +447,117 @@ class SettingsScreen : public UIScreen {
// to keep a marquee animation going (see DisplayDriver::drawTextEllipsized).
int renderItem(DisplayDriver& display, int item, int y, bool sel) {
NodePrefs* p = _task->getNodePrefs();
int mq_delay = 0;
int mq = 0;
char buf[24];
auto val = [&](const char* label, const char* v) { mq = info::valueRow(display, y, label, v, sel, _reserve); };
auto sw = [&](const char* label, bool on) { info::switchRow(display, y, label, on, sel, _reserve); };
drawRowSelection(display, y, sel, _reserve);
display.setCursor(2, y);
if (item >= SCHEMA_ITEM) {
mq_delay = renderSchema(display, settings::ALL[item - SCHEMA_ITEM], p, y, sel);
mq = renderSchema(display, settings::ALL[item - SCHEMA_ITEM], p, y, sel);
} else if (item == BUZZER) {
display.print("Buzzer");
display.setCursor(valCol(display), y);
#ifdef PIN_BUZZER
{ static const char* labels[] = { "ON", "OFF", "Auto" };
int m = _task->getBuzzerMode();
display.print(labels[m < 3 ? m : 0]); }
static const char* labels[] = { "On", "Off", "Auto" };
int m = _task->getBuzzerMode();
val("Buzzer", labels[m < 3 ? m : 0]);
#else
display.print("N/A");
val("Buzzer", "N/A");
#endif
} else if (isHomePage(item)) {
if (p) ensurePageOrderInit(p);
int pos = homePagePosition(item, p);
if (pos > 0) {
char pb[5]; snprintf(pb, sizeof(pb), "%2d ", pos);
display.print(pb);
}
display.print(homePageLabel(item));
display.setCursor(display.width() - 6 * display.getCharWidth() - _reserve, y);
if (!homePageToggleable(item))
display.print("always");
else
display.print(homePageVisible(item, p) ? "ON" : "OFF");
char label[24];
if (pos > 0) snprintf(label, sizeof(label), "%2d %s", pos, homePageLabel(item));
else snprintf(label, sizeof(label), "%s", homePageLabel(item));
if (!homePageToggleable(item)) val(label, "always");
else sw(label, homePageVisible(item, p));
} else if (item == TX_POWER) {
display.print("TX Pwr");
char buf[8];
snprintf(buf, sizeof(buf),"%ddBm", p ? p->tx_power_dbm : 0);
display.setCursor(valCol(display), y);
display.print(buf);
snprintf(buf, sizeof(buf), "%d dBm", p ? p->tx_power_dbm : 0);
val("TX Pwr", buf);
} else if (item == RADIO_PRESET) {
display.print("Preset");
const char* name = p ? _picker.currentName(p, radioTarget(p)) : "Custom";
int xc = valCol(display);
int r = display.drawTextEllipsized(xc, y, display.width() - xc - _reserve, name, sel);
if (sel && r > 0) mq_delay = r;
val("Preset", p ? _picker.currentName(p, radioTarget(p)) : "Custom");
} else if (item == CUSTOM_FREQ) {
display.print("Freq");
int xc = valCol(display);
if (sel && _editor.active()) {
_editor.render(display, xc, y);
if (sel && _editor.active()) { // the digit editor where the value stands
display.print("Freq");
_editor.render(display, display.width() - _reserve - 2 - display.getTextWidth("000.000"), y);
} else {
char buf[10];
snprintf(buf, sizeof(buf), "%.3f", p ? p->freq : 0.0f);
display.setCursor(xc, y);
display.print(buf);
val("Freq", buf);
}
} else if (item == CUSTOM_SF) {
display.print("SF");
char buf[6];
snprintf(buf, sizeof(buf), "%d", p ? (int)p->sf : 0);
display.setCursor(valCol(display), y);
display.print(buf);
val("SF", buf);
} else if (item == CUSTOM_BW) {
display.print("BW");
char buf[10];
snprintf(buf, sizeof(buf), "%.1f", p ? p->bw : 0.0f);
display.setCursor(valCol(display), y);
display.print(buf);
snprintf(buf, sizeof(buf), "%.1f kHz", p ? p->bw : 0.0f);
val("BW", buf);
} else if (item == CUSTOM_CR) {
display.print("CR");
char buf[6];
snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0);
display.setCursor(valCol(display), y);
display.print(buf);
val("CR", buf);
#if FEAT_RX_POWERSAVE
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(valCol(display), y);
// Forced off (and locked) while the repeater is on — it must hear all traffic.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->rx_powersave) ? "ON" : "OFF");
if (p && p->client_repeat) val("Pwr save", "--");
else sw("Pwr save", p && p->rx_powersave);
#endif
} else if (item == TX_APC) {
display.print("Auto pwr");
display.setCursor(valCol(display), y);
// Suppressed (and locked) while repeating — a repeater holds full TX power.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->tx_apc) ? "ON" : "OFF");
if (p && p->client_repeat) val("Auto pwr", "--");
else sw("Auto pwr", p && p->tx_apc);
} else if (item == SCOPE_NAME) {
display.print("Scope");
int vx = valCol(display);
const ScopeList& sl = the_mesh.scopeList();
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
sl.name(sl.default_idx), sel);
if (sel && r > 0) mq_delay = r;
val("Scope", sl.name(sl.default_idx));
} else if (item == LOCK_PIN) {
display.print("Lock PIN");
display.setCursor(valCol(display), y);
display.print(_task->passwordLockEnabled() ? "ON" : "OFF");
sw("Lock PIN", _task->passwordLockEnabled());
} else if (item == DEVICE_NAME) {
display.print("Name");
int vx = valCol(display);
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, the_mesh.getNodeName(), sel);
if (sel && r > 0) mq_delay = r;
val("Name", the_mesh.getNodeName());
} else if (item == REBOOT) {
display.print("Reboot"); // action row: Enter reboots this device
} else if (item == KEYBOARD_TYPE) {
display.print("Type");
display.setCursor(valCol(display), y);
display.print((p && p->keyboard_type) ? "T9" : "ABC");
val("Type", (p && p->keyboard_type) ? "T9" : "ABC");
} else if (item == KEYBOARD_MAIN_ALPHABET) {
display.print("Main");
display.setCursor(valCol(display), y);
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_main_alphabet : 0));
val("Main", NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_main_alphabet : 0));
} else if (item == KEYBOARD_ALPHABET) {
display.print("Additional");
display.setCursor(valCol(display), y);
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0));
val("Additional", NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0));
#if defined(CARDKB_I2C)
} else if (item == KEYBOARD_CARDKB_COMPACT) {
display.print("Ext. KB");
display.setCursor(valCol(display), y);
display.print((p && p->keyboard_cardkb_compact) ? "Compact" : "Full");
val("Ext. KB", (p && p->keyboard_cardkb_compact) ? "Compact" : "Full");
#endif
#if FEAT_DISPLAY_ROTATION_SETTING
} else if (item == ROTATION) {
display.print("Rotation");
display.setCursor(valCol(display), y);
display.print(rotLabel(p ? p->display_rotation : 0));
val("Rotation", 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);
display.print(rotLabel(p ? p->joystick_rotation : 0));
val("Joystick", rotLabel(p ? p->joystick_rotation : 0));
#endif
#if FEAT_FULL_REFRESH_SETTING
} else if (item == EINK_FULL_REFRESH) {
display.print("Full rfsh");
display.setCursor(valCol(display), y);
{ uint8_t idx = p ? p->eink_full_refresh_every : 0;
if (idx >= EINK_FULL_REFRESH_COUNT) idx = 0;
display.print(EINK_FULL_REFRESH_LABELS[idx]); }
uint8_t idx = p ? p->eink_full_refresh_every : 0;
if (idx >= EINK_FULL_REFRESH_COUNT) idx = 0;
val("Full rfsh", EINK_FULL_REFRESH_LABELS[idx]);
#endif
} else if (item == DM_FILTER) {
display.print("DMs");
display.setCursor(valCol(display), y);
display.print((p && p->dm_show_all) ? "All" : "Fav");
val("DMs", (p && p->dm_show_all) ? "All" : "Fav");
} else if (item == CH_FILTER) {
display.print("Channels");
display.setCursor(valCol(display), y);
display.print((p && p->ch_fav_only) ? "Fav" : "All");
val("Channels", (p && p->ch_fav_only) ? "Fav" : "All");
} else if (item == ROOM_FILTER) {
display.print("Rooms");
display.setCursor(valCol(display), y);
display.print((p && p->room_fav_only) ? "Fav" : "All");
val("Rooms", (p && p->room_fav_only) ? "Fav" : "All");
} else if (item == PRUNE_NOW) {
display.print("Prune now"); // action row: Enter counts + confirms + removes
} else if (isMsgSlot(item)) {
// A message template reads from the left, cut at the edge (scrolls when selected).
int slot = msgSlotIndex(item);
char label[5];
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)";
int xm = 8 + display.getCharWidth() * 4;
int r = display.drawTextEllipsized(xm, y, display.width() - xm - _reserve, tmpl, sel);
if (sel && r > 0) mq_delay = r;
int r = display.drawTextEllipsized(xm, y, display.width() - xm - _reserve - 2, tmpl, sel);
if (sel && r > 0) mq = r;
}
return mq_delay;
return mq;
}
// Keyboard state for editing message slots
@@ -656,7 +602,7 @@ class SettingsScreen : public UIScreen {
} else {
display.print(sl.name((uint8_t)idx));
if ((uint8_t)idx == sl.default_idx)
display.drawTextRightAlign(display.width() - reserve - 2, y, "[default]");
display.drawTextRightAlign(display.width() - reserve - 2, y, "default");
}
display.setColor(DisplayDriver::LIGHT);
});
@@ -1138,5 +1084,5 @@ public:
};
#if FEAT_FULL_REFRESH_SETTING
const char* SettingsScreen::EINK_FULL_REFRESH_LABELS[5] = { "OFF", "5", "10", "20", "30" };
const char* SettingsScreen::EINK_FULL_REFRESH_LABELS[5] = { "Off", "5", "10", "20", "30" };
#endif
+42 -58
View File
@@ -5,6 +5,7 @@
// accidentally stops tracking.
// Included by UITask.cpp after Trail store + ToolsScreen.
#include "InfoKit.h"
#include "../Trail.h"
#include "../GeoUtils.h"
#include "GfxUtils.h"
@@ -92,7 +93,6 @@ class TrailScreen : public UIScreen {
char _act_autosave_label[24];
char _act_toggle_label[20];
static const int SUMMARY_ITEM_COUNT = 5;
public:
TrailScreen(UITask* task, TrailStore* store)
@@ -505,67 +505,51 @@ private:
snprintf(buf, n, "Pace: %u:%02u %s", pm, ps, unit);
}
void summaryItem(int i, char* buf, size_t n) const {
switch (i) {
case 0: {
const char* st;
if (!_store->isActive()) st = "stopped";
else if (_store->isPaused()) st = "paused";
else if (_store->empty()) st = "waiting fix";
else st = "tracking";
snprintf(buf, n, "Status: %s", st);
break;
}
case 1:
snprintf(buf, n, "Points: %d / %d", _store->count(), TrailStore::CAPACITY);
break;
case 2: {
uint32_t d = _store->totalDistanceMeters();
char ds[12];
geo::fmtDist(ds, sizeof(ds), d / 1000.0f, useImperial());
snprintf(buf, n, "Dist: %s", ds);
break;
}
case 3: {
uint32_t es = _store->elapsedSeconds();
// Unit-suffixed so "1h 05m" can't be misread as "1m 05s".
if (es < 3600) snprintf(buf, n, "Time: %lum %02lus",
(unsigned long)(es / 60), (unsigned long)(es % 60));
else snprintf(buf, n, "Time: %luh %02lum",
(unsigned long)(es / 3600), (unsigned long)((es % 3600) / 60));
break;
}
case 4:
formatAvgPaceOrSpeed(buf, n, _task->getNodePrefs());
break;
default:
buf[0] = '\0';
// The summary: the distance large with the time and state beside it, the
// points stored as a meter, then the average speed (or pace).
void summaryBlocks(DisplayDriver& d, info::Flow& f, int rsv) {
const int lh = d.getLineHeight(), right = d.width() - rsv - 2;
char a[28], b[28];
if (f.place(info::bigH(d) + 3)) {
geo::fmtDist(a, sizeof(a), _store->totalDistanceMeters() / 1000.0f, useImperial());
char* u = a; // "2.4km" -> "2.4" + "km"
while (*u && (isdigit((unsigned char)*u) || *u == '.')) u++;
snprintf(b, sizeof(b), "%s", u);
*u = 0;
info::big(d, 1, f.at, a, b);
const uint32_t es = _store->elapsedSeconds();
// Unit-suffixed so "1h 05m" can't be misread as "1m 05s".
if (es < 3600) snprintf(a, sizeof(a), "%lum %02lus", (unsigned long)(es / 60), (unsigned long)(es % 60));
else snprintf(a, sizeof(a), "%luh %02lum", (unsigned long)(es / 3600), (unsigned long)((es % 3600) / 60));
d.drawTextRightAlign(right, f.at, a);
const char* st = !_store->isActive() ? "stopped" : _store->isPaused() ? "paused"
: _store->empty() ? "waiting fix" : "tracking";
d.drawTextRightAlign(right, f.at + lh + 1, st);
}
if (f.place(d.lineStep())) {
snprintf(a, sizeof(a), "%d", _store->count());
info::row(d, f.at, "Points", a, rsv);
const int x0 = 1 + d.getTextWidth("Points") + 6, x1 = right - d.getTextWidth("000") - 6;
if (x1 - x0 > 10) info::meter(d, x0, f.at, x1 - x0, lh - 1, (float)_store->count() / TrailStore::CAPACITY);
}
if (f.place(d.lineStep())) {
formatAvgPaceOrSpeed(a, sizeof(a), _task->getNodePrefs());
char* c = strstr(a, ": "); // "Avg: 5 km/h" -> label + value
if (c) { *c = 0; info::row(d, f.at, a, c + 2, rsv); }
else info::row(d, f.at, a, nullptr, rsv);
}
}
void renderSummary(DisplayDriver& display) {
const int y0 = display.listStart();
const int step = display.lineStep();
const int avail = display.height() - y0 - 2;
int visible = avail / step;
if (visible < 1) visible = 1;
if (visible > SUMMARY_ITEM_COUNT) visible = SUMMARY_ITEM_COUNT;
int max_scroll = SUMMARY_ITEM_COUNT - visible;
if (_summary_scroll > max_scroll) _summary_scroll = max_scroll;
if (_summary_scroll < 0) _summary_scroll = 0;
_summary_max_scroll = max_scroll;
for (int i = 0; i < visible; i++) {
int idx = _summary_scroll + i;
if (idx >= SUMMARY_ITEM_COUNT) break;
char buf[28];
summaryItem(idx, buf, sizeof(buf));
display.setCursor(2, y0 + i * step);
display.print(buf);
}
drawScrollIndicator(display, y0, visible * step, SUMMARY_ITEM_COUNT, visible, _summary_scroll);
const int top = display.listStart(), bottom = display.height();
info::Flow m = info::Flow::measure();
summaryBlocks(display, m, 0);
const long view = bottom - top;
const int rsv = m.total_px > view ? scrollIndicatorColWidth(display) : 0;
info::Flow f(top, bottom, _summary_scroll);
summaryBlocks(display, f, rsv);
_summary_max_scroll = f.more ? _summary_scroll + 1 : _summary_scroll;
if (rsv) drawScrollIndicatorPx(display, top, view, f.total_px, view, f.skipped_px);
}
void renderList(DisplayDriver& display) {