Hold-Enter context menus were invisible. Add a menu affordance to every screen
that has one:
- A small ≡ glyph in the header's top-right, via new DisplayDriver::
drawContextMenuHint() and an optional menu_hint arg on drawCenteredHeader() /
drawInvertedHeader() (reserves the corner so the title never runs under it).
- The glyph highlights (corner cell filled, bars knocked out) while the menu is
open, tying the hint to the popup it spawned — driven by a menu_open flag the
screens pass from their live popup state.
- The context menu now drops out of that corner: PopupMenu gains an opt-in
top-right anchor (begin(..., anchor_top_right)); it right-aligns under the
header instead of centring, so the menu reads as emerging from the ≡. Default
stays centred, so non-context popups (Tools/Settings/etc.) are untouched.
Enabled on Nodes (list/scan/detail) and Messages (mode select, contact/room and
channel pickers, DM and channel history).
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
renderBatteryIndicator() drew the secondary icons in a fixed sequence, so a busy
bar (many background modes on) shrank the node-name area to a couple of ellipsised
characters. Rework it into a priority-ordered list laid out right->left with a
reserved minimum name width: once an icon won't fit above the reserve, every
lower-priority icon after it is dropped too.
Priority: BT > GPS fix > alarm > mute > auto-advert > trail > live-share > repeater
(battery stays rightmost). Blinking icons still reserve their slot while off, so
the name width doesn't flicker with the blink. Also -39 lines (eight if-blocks
collapse to one table + loop).
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Recent adverts carousel page was a read-only name+age list of recently-heard
nodes — now covered by the Nodes screen (which folds getRecentlyHeard() non-contacts
into its list). Remove it from the UX:
- isPageVisible() hard-hides HomePage::RECENT, so it drops out of the carousel for
everyone (default and custom page orders, existing users included).
- Delete the dead RECENT render block, the nav "Recent adverts" alert and the unused
AdvertPath recent[] member.
- Drop HOME_RECENT from Settings > Home Pages so there's no dead toggle.
HPB_RECENT bit index is left intact, so persisted page_order / home_pages_mask stay
valid (a stored RECENT entry is simply skipped) — no migration, no schema bump.
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn Nearby Nodes into the single node hub and make its navigation legible.
Node management (no phone app needed):
- MyMesh gains addDiscoveredContact() and deleteContactByKey(); CMD_REMOVE_CONTACT
now reuses deleteContactByKey() (one delete path: contact + blob + room password
+ UI cleanup + lazy write).
- Action menu (Hold Enter) gains Add contact (a new scanned node), Favourite /
Unfavourite (pin to the first free dial slot), and Delete contact (confirm first,
defaults to Cancel). Actions are offered contextually per row.
- A pinned contact shows a star in the list row, next to any live-share diamond.
Absorb passively-heard adverts: refreshStored() folds getRecentlyHeard() nodes that
aren't already listed as name+age rows (All filter only — AdvertPath has no type),
so "recently heard" no longer needs its own page.
Legible filter navigation: the filter (LEFT/RIGHT) is now a visible tab strip — the
active filter is a centred inverted pill, neighbours fan out and wrap around
(circular, first<->last). Only whole tabs are drawn so labels never wrap a line.
Rename Tools > "Nearby Nodes" to "Nodes".
Both solo envs build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-polish trio from CODE_REVIEW (biggest "feels finished" gain per line):
- Pill unread badges: new DisplayDriver::drawUnreadBadge()/unreadBadgeWidth()
draw a filled capsule with the count knocked out (corners knocked back for a
rounded look; inverts on a selected row). Replaces the bare right-aligned
digits in MODE_SELECT, the contact/channel pickers and favourites tiles.
No <stdio.h> in the header — fmtBadgeCount formats manually, clamps to 99+.
- Unified DM/CH history headers: DM_HIST and CHANNEL_HIST drew their titles by
hand (drawTextCentered + fillRect at lh+1), a different height/separator than
every other screen. Both now route through drawCenteredHeader().
- Trimmed default home carousel: new NodePrefs::HP_DEFAULT (Clock, Tools,
Shutdown, Favourites, Map; Messages + Settings always visible = 7 pages).
applyDefaults() seeds it instead of HP_ALL. Recent/Radio/BT/Advert/GPS/Sensors
are opt-in via Settings > Home Pages. Existing users keep their saved mask —
factory default only; no migration, no schema bump.
Both solo envs build green (OLED RAM 69.9%/Flash 62.8%; e-ink SUCCESS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full (non-partial) refresh on every screen change (638eea7b) turned out to be
far too aggressive on real e-ink hardware: every navigation black-flashes,
which is worse than the ghosting it was clearing. Remove the whole mechanism —
DisplayDriver::forceFullRefresh() virtual, GxEPDDisplay's _force_full flag and
override, the endFrame() branch, and the setCurrScreen() call. E-ink is back to
interval-only full refreshes (Settings > Full refresh interval).
The favourites "(gone)"-tile prune that shipped in the same commit is kept.
Builds green: WioTrackerL1Eink_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logging in to a room server (first-time prompt or a remembered password) used
to leave the user on the room list, needing a second Enter to open the chat.
onRoomLoginResult() now opens the room history on success via a shared
openDmHistory() helper (extracted from the Enter-on-contact path).
The login result is async, so the auto-enter is gated on the user still being
on that room in the picker: phase CONTACT_PICK, room mode, no context menu /
share / pick-target sub-flow active, and the result pubkey matching the
selected contact. Otherwise it stays put (no yanking the user into a screen
they navigated away from).
Resolves the "auto-enter after login" code-review item. Builds green:
WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared keyboard seeds {loc}/{time} on begin() for message composition, but
a room/repeater login password is not a message — a placeholder token there is
nonsensical and a footgun (picking one inserts literal "{time}" into the
password). Clear them right after opening the keyboard in both room-login paths
(context-menu Login… and the Enter-on-room prompt), matching the same fix
already applied to preset names and waypoint labels.
Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two duplications surfaced by a framework-consistency pass:
- KeyboardWidget applied the a-z shift-uppercase at five draw/commit sites
with an inline `if (caps && ch >= 'a' && ch <= 'z')`. Fold them into one
`kbApplyCaps(ch, caps)` helper.
- The favourites grid drew the empty "+" tile from two branches (the gone-slot
prune and the always-empty slot). Route both through a single `has_contact`
flag so the "+" and the trailing selection-colour reset each live at one site.
No behaviour change. Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
T9 keys now read like a phone keypad — each cell is labelled <digit><group>
(e.g. 2abc), with the digit matching what the multi-tap cycle lands on after
the letters. No separator space: the widest group (".,!?'-") plus the digit
already fills a narrow 128px OLED cell at 3 columns.
Also fills in the v1.22 release notes (T9 keyboard, trail auto-save, Clock
Tools in Tools, Map/Shutdown reorderable, battery-read fix, the UI-audit fix
batch, discover-dedup, favourites self-heal, preset-name placeholder, OLED
frame-skip, e-ink full-refresh) and syncs the solo-feature docs: new Keyboard
settings section, Trail Auto-save row, and Clock Tools reachable from Tools.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The favourites render already continues on !found (gone-slot prune), so the
badge block only runs when found is true. The if(found) guard around the
unread-badge lookup was always true — remove it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two UI quick wins from the 2026-07-05 review:
- E-ink: force a full (non-partial) refresh on the first frame of a new
screen. Inter-screen ghosting was the most visible cheap win — the
N-partials interval alone doesn't catch navigation. New
DisplayDriver::forceFullRefresh() (no-op on OLED), set in setCurrScreen()
and consumed by GxEPDDisplay::endFrame().
- Favourites: clear a stale "(gone)" tile at render time so it reverts to an
empty "+" slot. Happens when prefs outlive the contact list (e.g. a wiped
/contacts3); onContactRemoved only catches a live delete. Pruned slots are
persisted once per pass (self-healing — an emptied slot can't re-fire).
Builds green: WioTrackerL1_companion_solo_dual (OLED),
WioTrackerL1Eink_companion_solo_dual (e-ink).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SH1106 path always pushed the full 1 KB buffer over I2C every endFrame,
even on static screens (clock, home) that don't change between updates. Hash
the GFX buffer (FNV-1a, no external dep — the CRC32 lib is only wired into
e-ink builds) and skip display.display() when it matches the last frame
pushed. _force_redraw forces a flush on the first frame and after
turnOn()/clear() so a post-wake or post-clear frame can't be wrongly skipped.
Mirrors the existing e-ink CRC-skip. Builds green: WioTrackerL1_companion_solo_dual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Losing the whole route on a low-battery auto-shutdown was the worst solo-mode
failure. New NodePrefs::trail_autosave_lowbatt toggle (Tools > Trail >
Settings > Auto-save, default OFF): UITask::shutdown() writes the live trail
to /trail when the toggle is on and _trail.count() > 0. The count guard stops
an empty trail from wiping a previously saved one; it overwrites the same
/trail file the manual Trail > Save uses.
Schema bumped 0xC0DE001A -> 0xC0DE001B: append-only tail field with a load
clamp, so pre-0x1B saves default to off. sizeof(NodePrefs) is unchanged (the
byte fits existing padding after keyboard_type), so the tripwire assert stays
2496.
Builds green: WioTrackerL1_companion_solo_dual (RAM 69.9%, Flash 62.8%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "(N)" origin readout printed the raw wire path_len. That byte packs the
path hash-size mode in its top 2 bits and the hop count in the low 6, so with
path_hash_mode >= 1 (2-byte hashes) it over-reported — e.g. "(66)" for a
2-hop message. Mask & 63 (getPathHashCount) so it shows the real hop count.
Affects the ui-orig variants only (ui-new/ui-tiny ignore the arg).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A discover response often arrives twice — the zero-hop direct copy and a
re-flooded copy relayed by another repeater carry different packet hashes,
so the mesh duplicate filter passes both. The standalone scan appended the
same node twice and app-driven discover forwarded both copies, so one
repeater showed up as two.
Track (tag, responder pubkey) for a short window in isDupDiscoverResp() and
drop the second copy in both the standalone and app-forward paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- locked device: a ringing alarm/timer was invisible — wakeForAlarm() now
holds the lock wake window for the whole ring, the lock screen draws the
alert overlay, and a key-dismissed ring falls back to a 5 s glance
- status bar: A / live-share / trail / repeater icons no longer vanish when
Bluetooth is off (moved outside the isSerialEnabled() gate)
- alert overlay: long text wraps to up to 3 lines inside the box instead of
overflowing the border (new UITask::renderAlertOverlay, shared with lock)
- MyMesh: force NUL on contact.name copied from an app frame (unterminated
name could overrun the AdvertPath strcpy)
- clock tools: ring/timer deadline compares now wrap-safe (signed diff),
matching the trail/loc-share timers
- messages: channel context menu freezes its target channel at open; fav
toggle no longer retargets the menu when the fav-only filter drops the row
All three solo envs build green (WioTrackerL1 OLED/Eink, GAT562-30S).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces the Alarm/Timer/Stopwatch screen (previously reachable only
from the home Clock page) as a System tool, via the existing
UITask::gotoClockTools() and ICON_ALARM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared on-screen keyboard seeds {loc}/{time} placeholders on begin()
for message composition, but they make no sense in a radio-preset name.
Clear them right after opening the keyboard from the "+ Save current..."
flow in both Settings > Radio and Tools > Repeater; the message-slot
editor still keeps them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GPS support was implemented incorrectly for the ThinkNode M3. The reset
pin was being driven when it should be left floating for this unit. The
enable pin also wasn't being picked up by `MicroNMEALocationProvider`
because of a mismatch in constant naming conventions. I did a general
cleanup of the GPS and ThinkNode M3 bring-up code so that constant names
line up and "*_ACTIVE" constants are used consistently vs hardcoding
`HIGH`/`LOW`. After making these changes, serial data immediately starts
streaming in from the NMEA on boot and GPS detection just works.
LED handling was also not quite right for the ThinkNode M3. The LoRa TX
LED was being driven high to turn it on when it should actually be
driven low. I changed the code to use the `LED_STATE_ON` constant and
also added a little code to the shutdown path to properly make sure that
all LEDs are turned off.
Tested and confirmed working on real hardware.
Resolves both #1864 and #2879.
The per-read VBAT_ENABLE gating from 4d46abb2 (energy optimizations)
powered the battery voltage divider HIGH for only 10ms before each ADC
read, then LOW. 10ms is too short for the high-impedance divider node
to settle before the nRF52 SAADC samples it, so readings came out high
and inconsistent (e.g. 4.6-5.0V on a LiPo that maxes ~4.2V).
Hold VBAT_ENABLE HIGH continuously again (as before 4d46abb2) so the
node is always settled at sample time, and drop the toggle + delay(10)
from getBattMilliVolts(). Standby cost is ~2uA on the 2x1M divider,
negligible next to the device's mA-level draw.
The CPU-sleep and LED-off energy optimizations from 4d46abb2 are left
untouched — those are the real savings; only the VBAT gating is reverted.
The on-screen keyboard grid has been a-b-c...z alphabetical order since
it was first introduced, never an actual QWERTY row layout. The
"QWERTY" name only appeared with the T9 option (needed something to
contrast it against) and was inaccurate from the start.
Merges PR #21 (T9 on-screen keyboard option, marczykm) plus a follow-up
fix bumping SCHEMA_SENTINEL to 0xC0DE001A — the PR added a new persisted
field (keyboard_type) without bumping the schema version, which would
have silently misaligned every NodePrefs field after alarm_min for
devices already on the previous schema.
keyboard_type was inserted into the append-only NodePrefs layout without
bumping SCHEMA_SENTINEL, leaving it at 0xC0DE0019 — the same value plain
main already uses without this field. A device already on that schema
would silently misalign every field read after alarm_min once updated
to a build containing this change. Bump to 0xC0DE001A and fix the
migration comment's stale version label (it said 0xC0DE0018, which was
already taken by the MAP home-page migration).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>