diff --git a/examples/companion_radio/GeoUtils.h b/examples/companion_radio/GeoUtils.h index 32264d03..fdb4e90c 100644 --- a/examples/companion_radio/GeoUtils.h +++ b/examples/companion_radio/GeoUtils.h @@ -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); } } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index fcb557db..18396ddd 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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]; } diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index 4f269482..d117e261 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index fb7e7baa..d720488e 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h index d3329d6f..76450032 100644 --- a/examples/companion_radio/ui-new/ClockToolsScreen.h +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/GpioScreen.h b/examples/companion_radio/ui-new/GpioScreen.h index cba504bb..b100fe4e 100644 --- a/examples/companion_radio/ui-new/GpioScreen.h +++ b/examples/companion_radio/ui-new/GpioScreen.h @@ -10,6 +10,7 @@ // Included by UITask.cpp. #if defined(PIN_GPIO1) +#include "InfoKit.h" #include #include #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 diff --git a/examples/companion_radio/ui-new/InfoKit.h b/examples/companion_radio/ui-new/InfoKit.h index fe788833..9cd90e02 100644 --- a/examples/companion_radio/ui-new/InfoKit.h +++ b/examples/companion_radio/ui-new/InfoKit.h @@ -6,6 +6,7 @@ // fixed 128x64, so a larger-font e-ink layout gets them scaled for free. #include +#include #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. diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index 702f8ef5..5f70d9e8 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -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; } diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index e83fa609..e0886315 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -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; } diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 1cd93781..6cdca723 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index ec2c7732..5f138e9a 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -1,4 +1,5 @@ #pragma once +#include "InfoKit.h" #include #include #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); } diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 4b5e13e8..235de97f 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -16,6 +16,7 @@ // can't land outside what's legal for the operator's region). // Included by UITask.cpp. +#include "InfoKit.h" #include #include #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); }); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index b53563b7..eadf876c 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -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 diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 665fdaf1..1a445cc7 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -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) {