Commit Graph

2959 Commits

Author SHA1 Message Date
Jakub
81766650cc docs(features): note OLED Favourites name/badge overlap as follow-up
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:17:03 +02:00
Jakub
0a11d54b61 docs(features): mark trail phase 3 done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:14:39 +02:00
Jakub
0c4e2e3f47 feat(ui): Trail phase 3 — per-point list view
Third view in TrailScreen, reached by LEFT/RIGHT from Summary/Map.
Newest-first, each row shows `HH:MM  +N m` (or `+N.N km` past 1 km).
The first point of each segment shows `start` instead of a delta —
matches the map renderer's segment-aware drawing.

UP/DOWN scrolls within the list; ^/v arrows on the right edge appear
when more rows hide above/below. Time is local (tz_offset_hours
applied via gmtime on (ts + offset)).

Empty trail shows "No trail yet" centred in the content area.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:14:14 +02:00
Jakub
26278e433d docs(features): note trail phase 2 follow-ups (scroll, blink, defaults, segments)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:10:57 +02:00
Jakub
7f0985c63f fix(ui): trail sampling, avg speed, RTC elapsed, segment-aware map
Test feedback revealed four issues:

1. Sampling too sparse — defaults were 60 s interval + 25 m min-delta,
   so a walking pace dropped ~80% of samples. New defaults: 30 s + 5 m.
   Interval options expanded to { 30, 10, 20, 60, 300, 900 } s and
   min-delta to { 5, 10, 25, 100 } m so the user can dial it further
   from Settings (phase 5).

2. "Speed" was current-from-last-pair, misleading next to a Time field
   that grows monotonically. Switch to avgSpeedKmh = total / elapsed,
   labeled "Avg speed".

3. Time advanced only when a new sample landed (it used
   last().ts - first().ts). Now elapsedSeconds takes an optional
   now_ts and uses it whenever the trail is active, so the Time field
   in Summary ticks every render cycle. Sub-1h shown as m:ss for
   visible seconds.

4. Stop → start drew a straight line across the dead time. TrailPoint
   gains a flags byte; addPoint flags the first point and the first
   point after a re-arm as SEG_START. The map renderer skips the line
   from the predecessor for SEG_START points (still draws the start
   pixel). totalDistanceMeters also skips segment boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:10:03 +02:00
Jakub
9d038730df fix(ui): force 1 s home-refresh while trail tracking, matching auto-advert
The status-bar G/A indicators both flip every 2 s (millis() % 4000 <
2000), but with only auto-advert forcing the 1 s refresh, a screen with
G alone repainted every 5 s — the blink looked sparse vs A. Treat any
blinking indicator (auto_adv OR trail active) as needing the 1 s
cadence on OLED.

E-ink builds keep the 30 s home refresh — blink is gated to "always on"
there via Features::BLINK_INDICATORS = false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:24:36 +02:00
Jakub
f50859b4ac fix(ui): scrollable Summary in TrailScreen so OLED hint stops overlapping
OLED (64 px height) couldn't fit 5 summary items plus the bottom hint
row — Time/Speed lines overlapped the [Ent]/[Esc] hint. Convert Summary
to a scrolling list: visible-count derived from the available area
(height - listStart - hint - margin) divided by lineStep, scroll arrows
"^"/"v" on the right edge when more items hide above/below. UP/DOWN
scroll within Summary; LEFT/RIGHT still switch views.

E-ink portrait and OLED both benefit; e-ink landscape fits all 5 items
without ever showing the arrows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:23:06 +02:00
Jakub
2ff27a7178 docs(features): rename Breadcrumb section to Trail, mark phase 2 done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:14:39 +02:00
Jakub
984f348898 feat(ui): Trail phase 2 — auto-fit map view + view switching
LEFT/RIGHT cycles between Summary (existing) and the new Map view.
Pixel-by-pixel polyline auto-fits the bounding box of the points to
the visible area, with longitude scaled by cos(avg_lat) so trails at
higher latitudes don't smear east/west. Picks the limiting axis and
centres the non-limiting dimension, so the trail fills as much screen
as possible without distorting.

Markers:
- Start point: a 5×5 plus sign
- Current/last point: a 5×5 diagonal cross

Edge cases:
- Empty trail → "No trail yet" centred
- Single point or all samples colocated → single current marker at centre
- Degenerate area_w/h (very small screen) → no draw

Lines are Bresenham via 1×1 fillRect — a few-tens-of-ms render which is
fine for an on-demand screen. A drawPixel override on the two drivers
would speed this up, deferred until the cost becomes visible.

Bottom hint shows `<>` for view nav, "N/2" current view, and the
Enter-toggle label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:13:52 +02:00
Jakub
ae783edc47 refactor(ui): rename "Breadcrumb" to "Trail" across the feature
User-facing label change requested for the GPS trail viewer. Carries
through internally for consistency:
- Files: Breadcrumb.h → Trail.h, BreadcrumbScreen.h → TrailScreen.h
- Types: BreadcrumbPoint/Store/Screen → Trail*
- NodePrefs fields: breadcrumb_*_idx → trail_*_idx (same byte layout, no
  sentinel bump needed)
- UITask: _breadcrumb, breadcrumb_screen, gotoBreadcrumbScreen,
  _next_breadcrumb_sample_ms → trail-prefixed equivalents
- Tools menu label "Breadcrumb" → "Trail", screen title "TRAIL"

Status bar indicator stays "G" — semantics ("GPS logging active") match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:10:40 +02:00
Jakub
adef2fceb9 feat(ui): GPS breadcrumb phase 1 — storage, sampling, Summary view
Adds Tools › Breadcrumb. Enter on the screen toggles tracking; while
active, UITask::loop samples GPS at the cadence chosen in settings
(default 1 min) and drops the point into a 256-entry RAM ring guarded
by a min-distance gate (default 25 m). A "G" indicator joins "A" in
the status bar with the same blink convention.

New storage:
- examples/companion_radio/Breadcrumb.h — BreadcrumbStore class
- 256 × 12 B = 3 KB RAM (survives auto-off, lost on reboot — explicit
  flash-slot save comes in phase 4)
- Haversine min-delta gate, total distance, elapsed seconds, current
  km/h, bounding-box helper

New screen:
- examples/companion_radio/ui-new/BreadcrumbScreen.h — Summary view
  with Status, Points, Dist (m or km), Time (h:mm), Speed; Enter
  toggles tracking, Esc returns to Tools

Schema bump 0xC0DE0002 → 0xC0DE0003 for two new NodePrefs bytes:
breadcrumb_interval_idx (1min default) and breadcrumb_min_delta_idx
(25m default). Older saves leave both at zero, which matches the new
defaults. DataStore::loadPrefsInt/savePrefs read/write the pair.

Logging on/off is a runtime flag inside BreadcrumbStore — not a
preference — so reboot returns to the off state cleanly; the user
will be able to persist a snapshot to a slot in phase 4.

Phase 2 (map view), 3 (point list), 4 (slot save/load, GPX export over
USB), and 5 (settings UI) follow incrementally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:22:12 +02:00
Jakub
ccf45f0c0c docs(features): Favourites dial complete (all 3 phases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:08:32 +02:00
Jakub
e1cbd959b2 feat(ui): Favourites phase 3 — in-place pin picker on empty tile
Enter on an empty Favourites slot opens a PopupMenu listing candidate
contacts. Sourced first from upstream-favourited chat contacts (those
flagged with c.flags & 0x01), then from recent DM contacts (resolved
from the 4-byte _dm_hist prefix to a full 6-byte pub_key via the contact
list), deduped against the favourites list. Capped at 12 entries.

Selection writes the chosen 6-byte prefix into the target slot; if the
contact is already pinned elsewhere, the previous slot is vacated first
(one contact per dial, per the agreed pin-only semantics). savePrefs
commits to flash and a brief alert confirms.

Plumbing:
- New QuickMsgScreen::getRecentDMContacts() resolves recent DM senders
  to 6-byte pub_key prefixes, deduped, newest first
- UITask::getRecentDMContacts() wraps it so HomeScreen doesn't reach
  into the QuickMsg screen directly
- HomeScreen gains its own PopupMenu + per-entry pub_key / label buffers
  (12 × 6 B keys + 12 × 22 B labels), no shared state with QuickMsg's
  Contact-options menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:08:12 +02:00
Jakub
c5faaf0afe docs(features): Favourites phase 2 complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:00:52 +02:00
Jakub
221885373b fix(ui): Cancel from Favourites-opened DM returns home, not to contact list
QuickMsgScreen::enterDM (used by openContactDM from the Favourites dial)
now sets _dm_direct_entry so KEY_CANCEL in DM_HIST jumps back to the
home screen instead of falling through CONTACT_PICK → MODE_SELECT. The
flag clears on every reset() and after the cancel returns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:58:45 +02:00
Jakub
16d6da33ea fix(ui): include ContactInfo.h in UITask.h
openContactDM(const ContactInfo&) declared in UITask.h needed the type
visible; previously ContactInfo only reached UITask.cpp transitively
through MyMesh.h and broke the eink build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:54:31 +02:00
Jakub
fd37b84d71 feat(ui): open DM from filled Favourites tile
Enter on a populated slot of the Favourites grid now jumps straight into
that contact's DM history — resolves the pinned 6-byte pub_key prefix
against the contact list, then routes through new
UITask::openContactDM(ci) which resets QuickMsgScreen and calls
enterDM() to land directly in DM_HIST.

Empty slots still show the "pin from contact options" hint until phase 3
wires the in-grid mini-picker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:52:56 +02:00
Jakub
bee8e7c3dd feat(ui): Favourites phase 2 — pin from Contact options
Adds a fourth item to the QuickMsg Contact options popup that toggles
between "Pin to dial" (when the contact isn't pinned) and
"Unpin (slot N)" (when it is). Pinning opens a six-slot picker submenu
labeled "Slot N: <name>" or "Slot N: empty"; the chosen slot stores the
6-byte pub_key prefix in NodePrefs::favourite_contacts and savePrefs
commits to flash with a brief confirmation alert.

New UITask helpers backing the flow:
- findFavouriteSlot(pub_key) — returns 0..5 if pinned, -1 otherwise
- isFavouriteSlotEmpty(slot)
- setFavouriteSlot(slot, pub_key) / clearFavouriteSlot(slot)

PopupMenu reuse: a single _ctx_menu instance hosts both the Contact
options menu and the slot picker, gated by _pin_picker_active so input
routes correctly. Reset on screen reset() and on any non-NONE menu
result.

Slot labels live in a member buffer (6×22 B) because PopupMenu stores
the string pointers verbatim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:30:16 +02:00
Jakub
aedb7f6f5d fix(ui): Favourites migration even when page_order is full
ensurePageOrderInit only inserted FAVOURITES when len < PAGE_ORDER_LEN.
Older saves with all 11 slots filled (default order with GPS + SENSORS)
hit the full-array branch and were left without FAVOURITES in the
explicit order, so the page was shown via the missing-page fallback at
end of nav but movePageInOrder couldn't reorder it.

Now drop the last entry (typically SHUTDOWN, which the same fallback
already re-appends at end of nav) to make room. End result: FAVOURITES
sits after CLOCK, reorderable; nav layout matches the new default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:24:13 +02:00
Jakub
66122e782d fix(ui): Favourites page polish + upgrade migration
Render issues:
- Drop the "FAVOURITES" title — it overlapped the node-name/battery top
  bar; the page-dots indicator already identifies the page
- Use the locally-computed content_y (matches other home pages) so tiles
  sit below the dots row instead of starting at headerH() + sepH()
- Add 2 px top/bottom margin inside the grid so cells don't hug the dots
  or the panel edge

Upgrade migration (visibility + position):
- Newly-loaded prefs with an older sentinel OR a non-default mask had
  HP_FAVOURITES off and could not be enabled — DataStore::loadPrefsInt
  now ORs HP_FAVOURITES into home_pages_mask whenever the sentinel
  doesn't match the current schema
- Existing page_order_set users were stuck because FAVOURITES wasn't in
  page_order, so movePageInOrder bailed — ensurePageOrderInit now
  detects the missing FAVOURITES entry and inserts it right after CLOCK,
  shifting the tail; if the array is already full the page still
  appears via the buildVisibleOrder fallback

Bug fix in ensurePageOrderInit verification loop: iterated to HPB_COUNT
(12) over a PAGE_ORDER_LEN (11) array → one OOB read per call. Now
bounded to PAGE_ORDER_LEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:17:48 +02:00
Jakub
63c5d99d7d docs(features): mark Favourites phase 1 done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:00:43 +02:00
Jakub
21c02c7114 feat(ui): Favourites home page (phase 1: storage + read-only grid)
New home page rendering 6 pinned-contact slots. Grid transposes to
orientation: landscape → 3×2, portrait → 2×3. Joystick claims UP/DOWN
and inner LEFT/RIGHT for grid nav; edge LEFT/RIGHT fall through to
page navigation. Selected tile inverts via drawSelectionRow, filled
tile shows contact name + unread badge, empty tile shows "+".

Storage:
- NodePrefs::favourite_contacts[6][6] (6-byte pub_key prefix per slot,
  all-zero = empty; collision probability with a real key is 2^-48)
- HomePageBit gains HPB_FAVOURITES = 11 (HPB_COUNT = 12), with a new
  HP_FAVOURITES mask bit so the page is toggleable in Settings
- New PAGE_ORDER_LEN constant decouples the persisted array length (11)
  from the page-type count; loops over page_order now use it, value-range
  checks still use HPB_COUNT
- homePageBit returns 0 for HPB_SETTINGS / HPB_QUICK_MSG explicitly
  rather than by bit-index range, so HPB_FAVOURITES (bit 11) is correctly
  treated as toggleable
- Default order inserts FAVOURITES after CLOCK; SHUTDOWN drops to the
  fallback "missing pages" append (still reachable, lands at end)

Schema sentinel bumped to 0xC0DE0002. DataStore::loadPrefsInt and
savePrefs read/write the new field. Older saves leave the field
zero-initialised (all slots empty), which is the natural starting state.

Phase 2 (Pin from Contact options) and Phase 3 (Pin from empty-slot
mini picker) wire interactions; this commit handles render + nav only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:00:27 +02:00
Jakub
3e4e85e1ff feat(ui): show read-count in mark-all-read confirmation alert
Sample the unread total for the active mode (DM/Channels/Rooms) before
clearing, then alert "N marked read". Matches the original FEATURES.md
design note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:46:54 +02:00
Jakub
31cfbf4997 feat(ui): mark-all-read context menu on MESSAGE mode-select
Hold Enter (context menu) on the DM / Channels / Rooms picker opens a
single-item "Mark all read" menu that clears every unread counter of
the highlighted type. Per-mode title is a static-const string because
PopupMenu stores the title pointer verbatim.

New UITask::clearAllDMUnread() resets the whole _dm_unread_table;
clearAllChannelUnread() and clearRoomUnread() already existed.

Also document the GPS breadcrumb storage decision in FEATURES.md:
RAM-only ring with explicit per-slot saves, no background flash writes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:43:31 +02:00
Jakub
a4b4362b1b refactor(prefs): centralise home-page bit numbering in NodePrefs::HomePageBit
pageBit/bitToPage in HomeScreen and homePageBitIndex in SettingsScreen
each spelled out the bit assignment with magic numbers 0..10. Lift the
numbering into a NodePrefs::HomePageBit enum (HPB_CLOCK … HPB_QUICK_MSG,
HPB_COUNT) and route every reference through it: mapping functions, the
HP_* bitmasks, the homePageLabel table sizing, loop bounds over
page_order, the bounds check on page_order[0], the "always-visible"
discriminator for SETTINGS/QUICK_MSG.

Per-class enums (HomeScreen::HomePage, SettingsScreen::SettingItem)
stay — they index different concerns (navigation page vs. settings
row). The mapping functions still exist, but now reference a single
source of truth for the bit values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:26:56 +02:00
Jakub
900f2d59e9 refactor(ui): tag SettingsScreen ifdefs with semantic FEAT_* macros
Extends Features.h with preprocessor flags (FEAT_BRIGHTNESS_SETTING,
FEAT_CLOCK_SECONDS_SETTING, FEAT_DISPLAY_ROTATION_SETTING,
FEAT_JOYSTICK_ROTATION_SETTING, FEAT_FULL_REFRESH_SETTING) since
constexpr can't gate enum members or class fields. SettingsScreen.h
now uses #if FEAT_X instead of #if defined(EINK_DISPLAY_MODEL) — grep
on the feature name finds the toggle and every use site, and the
condition names *what* the gate controls.

Driver headers (DisplayDriver.h, GxEPDDisplay.h) keep raw
EINK_DISPLAY_MODEL — they're below the companion_radio layer and the
ifdef there gates on the build's panel type, not a UI feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:20:42 +02:00
Jakub
7c81ea0f52 refactor(ui): centralise EINK_DISPLAY_MODEL toggles in Features.h
Five sites in UITask.cpp + MyMesh.cpp branched on EINK_DISPLAY_MODEL
purely for runtime-shape decisions (blink rate, default pref values,
refresh intervals). Replace with a constexpr Features namespace:
IS_EINK, BLINK_INDICATORS, CLOCK_HIDE_SECONDS_DEFAULT, HOME_REFRESH_MS,
LOCKSCREEN_REFRESH_MS. Compiler dead-branch-eliminates so cost is
identical to the preprocessor branch, but the code stays reachable to
tooling and the policy lives in one file.

SettingsScreen and driver headers still need real #ifdef — they
condition enum members and class field layouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v1.12
2026-05-24 20:41:25 +02:00
Jakub
e16ecbd690 style(ui): sprintf → snprintf with sizeof in UI render paths
Mechanical sweep across UITask.cpp and SettingsScreen.h. All current
format strings already fit their buffers, but a future format change
would silently overflow with sprintf. snprintf with sizeof makes the
buffer bound explicit and traceable to the array declaration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:35:49 +02:00
Jakub
559fe86f20 style(ui): use NSDMI for SH1106Display::_use_lemon default
Match the rest of the file's defaults; constructor body no longer
re-initialises a field that the in-class initializer already set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:33:59 +02:00
Jakub
d981f93e6e refactor(ui): drawSelectionRow helper for the 5-line invert/restore pattern
Eight screens repeated the same if/else block to invert the row
background for selected items. Add DisplayDriver::drawSelectionRow that
sets LIGHT, optionally fills the rect, and leaves the colour as DARK
when sel (so the caller's text renders inverted). 61 lines removed
across SettingsScreen, KeyboardWidget (cells + special row), ToolsScreen,
DashboardConfigScreen, BotScreen, RingtoneEditorScreen (note slots +
menu), NearbyScreen, QuickMsgScreen (4 list views).

Card-style rows (QuickMsg hist, NearbyScreen discover, RingtoneEditor
notes display) keep their original drawRect/partial-fill outline for
unselected — they don't match the simple-invert pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:33:02 +02:00
Jakub
52a7081a13 refactor(ui): dedupe UTF-8 codepoint decoder
Three copies of the same routine: DisplayDriver::decodeCodepoint (added
in the FullscreenMsgView wrap fix) plus per-driver duplicates in
SH1106Display and GxEPDDisplay. Consolidate on DisplayDriver and have
translateUTF8Static also route through it instead of its own inline
decoder. Net: 63 lines removed, single source of truth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:25:35 +02:00
Jakub
74764aed2b perf(ui): O(n) FullscreenMsgView wrap via getCodepointWidth virtual
The wrap loop used to memcpy a growing substring and call getTextWidth
on it for every appended codepoint — O(n²) UTF-8 decoding with Lemon
font, ~5-20 ms on a full e-ink redraw of a long message.

Add DisplayDriver::getCodepointWidth(cp) (default: build a UTF-8 buffer
and forward to getTextWidth) plus DisplayDriver::decodeCodepoint() as
the shared UTF-8 walker. Override getCodepointWidth in GxEPDDisplay
(direct lemonXAdvance for Lemon size 1) and SH1106Display (lemonXAdvance
for Lemon, fixed 6*sz for built-in font). FullscreenMsgView now walks
codepoints once and sums widths incrementally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:22:16 +02:00
Jakub
87bdc5cb9f feat(prefs): tail schema sentinel for layout-change detection
Old saves (without sentinel) load unchanged — rd() lambda zero-inits
anything not on disk. New saves append a 4-byte sentinel that encodes
the schema revision; the low byte is bumped on every layout change in
loadPrefsInt/savePrefs. Mismatch on load logs a warning and continues
with defaults; the next savePrefs writes the current sentinel so the
file self-heals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:18:50 +02:00
Jakub
f82d23b200 perf(ui): replace transliterateCodepoint switch with sorted-table binary search
Old 80+ case switch generated a jump table around ~600-1200 B of flash;
new layout is two parallel static-const arrays (uint16 codepoint + char
ascii, 144 entries each = 432 B) and a 5-line bsearch loop. Same input
range, same output, faster lookup (~log2(144) ≈ 7 compares vs jump-table
indirection + bounds check).

Adds a static_assert that the two arrays stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:14:01 +02:00
Jakub
71e1cb7a23 perf(ui): drop redundant getTextWidth and contact re-fetch in QuickMsg
- DM/channel history rows precomputed age_w (= getTextWidth(age) + 3) but
  then called getTextWidth(age) again to place the cursor. Reuse age_w.
- Contact-by-message-count sort fetched each contact twice: once when
  building _sorted, once when filling counts[]. Fold counts[] into the
  build loop so each contact is fetched once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:07:46 +02:00
Jakub
6410a346f2 refactor(eink): centralise scale() helper, hoist out of Lemon hot loop
Per-char recomputation of (width() >= height() ? 2 : 1) in drawLemonChar
and lemonXAdvance is now passed in by the caller. print() and
getTextWidth() compute scale() once and feed it to every glyph. All
other callers in the file route through the helper for consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:01:46 +02:00
Jakub
bf724ce692 perf(bot): lower body once and strstr instead of O(n*m) per-offset tolower
For a 140-byte channel msg with a 64-byte trigger, the old loop did
~8960 tolower() calls per inbound message; now it's 140 tolower calls
plus one strstr.

perf(buzzer): skip nRF52 STOPPED wait when PWM was not running

TASKS_STOP on a disabled PWM never fires EVENTS_STOPPED, so the wait
always timed out at 2 ms. Gating on _pwm_on removes the wait on the
first note of a melody and after pauses, where there's nothing to wait
for.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:59:21 +02:00
Jakub
e2d59b9a3d fix(prefs): use magic sentinel to validate page_order
Previously page_order_set status was inferred from page_order[0] being
in 1..11, so a single junk byte in that range falsely triggered
custom-order mode. Add explicit page_order_set magic (0xA5) written
alongside the array; pre-magic saves are migrated once on load when
their first byte still passes the legacy range check.

Also fix(ui): keep "@[nick] " prefix out of placeholder expansion in
QuickMsgScreen reply path, so a nick containing a token like {loc}
isn't substituted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:53:05 +02:00
Jakub
10b2629b36 fix(eink): gate Lemon print path on _text_sz == 1
setTextSize(2/3) switches GFX to non-Lemon fonts (built-in scaled or
FreeSans18pt). The Lemon path bypassed GFX entirely and rendered glyphs
at size 1 scale, shrinking splash version and clock when Lemon font
was active.

Refresh CODE_REVIEW.md: remove resolved issues (signed-int timing vars,
abs() no-op, XBM CRC, loadPrefs nesting, lock-screen LPP thrash, reply
prefix raw UTF-8), add new findings (upstream-merge surface, virtual
width()/height() per glyph, page_order sentinel), list enhancement
ideas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:45:19 +02:00
Jakub
7dcb763d83 fix(eink): align unknown-glyph fallback box to top of cell
The placeholder rectangle for codepoints outside U+0020-U+04FF was drawn
1 scale unit too low (y-7*sc instead of y-8*sc relative to the GFX
baseline), causing it to sit below full-height glyphs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:55:50 +02:00
Jakub
9c03e9a2db fix(eink): render Lemon font UTF-8 correctly by bypassing GFX write()
Adafruit GFX write(uint8_t) treats each byte as a glyph index and cannot
decode multi-byte UTF-8 sequences, so Cyrillic and other non-ASCII chars
were rendered as garbage even with the Lemon font selected.

Add decodeUtf8 + drawLemonChar + lemonXAdvance to GxEPDDisplay, mirroring
the existing SH1106Display implementation. Override print() for Lemon mode
to decode UTF-8 codepoints and draw each glyph directly; override
getTextWidth() to sum per-codepoint advances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:39:41 +02:00
Jakub
5fde79fae7 fix(ui): translate keyboard preview buffer through UTF-8 handler
KeyboardWidget printed the text preview directly via display.print()
without calling translateUTF8ToBlocks first. When a reply prefix
contained a UTF-8 sender name (e.g. Cyrillic), raw multi-byte sequences
reached the display driver on non-Lemon renders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:28:10 +02:00
Jakub
c375c4b217 fix(eink): pass UTF-8 through unchanged when Lemon font is active
drawTextCentered/Left/Right/Ellipsized all call translateUTF8ToBlocks
before passing text to print(), which converted Cyrillic (and other
Unicode) to block characters even when the Lemon font was selected.

Override translateUTF8ToBlocks in GxEPDDisplay to skip conversion when
_use_lemon is true — the Lemon/GFX font handles UTF-8 natively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:47:20 +02:00
Jakub
1cfe7c168a Update README with case model links
Added links to E-ink and Oled case models.
2026-05-24 11:04:51 +02:00
Jakub
a6bf3ee08f feat(eink): full refresh interval setting in Settings › Display
Add "Full rfsh" setting (e-ink only) with options off/5/10/20/30
partial refreshes between full refreshes. Prevents ghosting on panels
that accumulate artifacts after many partial updates.

GxEPDDisplay counts partial refreshes and calls display.display(false)
every N frames when the interval is set. Persisted in DataStore and
applied at startup via applyFullRefreshInterval().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:22:03 +02:00
Jakub
8012278483 fix(ui): remove space in hibernate hint formatting 2026-05-24 00:56:43 +02:00
Jakub
3a08eedfec fix(ci): fix double-v in version for v* style tags
setup-build-environment was using v${TAG#*-v} which prepended an extra
"v" when the tag already starts with "v" (e.g. v1.11 -> vv1.11).
Now uses the tag name directly for v* tags; strips name-v prefix only
for compound tags like room-server-v1.2.3.

Remove redundant Extract Version steps from the wio firmware workflow
since setup-build-environment already handles this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v1.11
2026-05-24 00:50:10 +02:00
Jakub
34fcab6bd5 feat(ui): show MeshCore version large and plus version in splash bar
Add MESHCORE_VERSION build flag ("1.15") to companion base configs.
Splash screen now shows MESHCORE_VERSION in the large text and the
plus firmware version (stripped of commit hash) in the Plus bar,
e.g. "1.15" large + "Plus v1.11 for Wio" below.

Change CI tag pattern to v* and simplify version extraction so tag
v1.11 maps directly to FIRMWARE_VERSION=v1.11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:35:07 +02:00
Jakub
62cae314f7 ci: change tag pattern to plus_* and fix version extraction
Trigger workflow on tags matching plus_* instead of wio-tracker-v*.
Extract version as v<x.y> from plus_<x.y> tag format.
Also update firmware filenames in README to match renamed env names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:18:31 +02:00
Jakub
137b0234d5 ci: rename dual envs and fix tag-triggered builds for OLED and e-ink
Rename WioTrackerL1_companion_radio_dual_settings → WioTrackerL1_companion_dual
and WioTrackerL1Eink_companion_radio_dual → WioTrackerL1Eink_companion_dual.

Fix CI workflow: remove hardcoded branch refs so both build jobs check out
the tagged commit; add version extraction to each build job (previously
GIT_TAG_VERSION was only set in the release job, leaving FIRMWARE_VERSION
empty during builds); update build commands to use new env names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:13:34 +02:00