On nRF52 the buzzer advanced notes by polling millis() >= _note_end_ms in
loop(). A blocking display refresh (e-ink endFrame) starves loop(), so a
note boundary that falls inside a refresh is serviced late — the note
plays long, or the next is skipped. The keypress-time 300 ms render delay
only masked the first note.
Advance notes from a hardware TIMER1 compare interrupt instead, scheduled
for each note's exact duration, so timing is independent of render cadence.
TIMER0 is the SoftDevice's; TIMER1 is free (tone() uses PWM2, nrfx TIMER1
driver is disabled). loop() becomes a no-op on nRF52; the UITask keypress
render-delay workaround is removed.
_disarmNoteTimer() clears the latched NVIC pending IRQ (not just the event)
on stop()/_nrfBegin(), so a note-advance latched just before a stop can't
fire spuriously — which would skip a new melody's first note or blip after
an explicit stop. Event read-backs flush the write buffer per the nRF52
event anomaly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
E-ink panel refreshes block the main loop for 500ms-3.6s (GxEPD2's
display.display() waits on the BUSY pin), and MomentaryButton::check()
only ever samples the live pin level once per loop iteration. A full
press+release that lands entirely inside a refresh is never sampled,
not just delayed - users on WioTracker L1 Eink were losing taps.
Add an opt-in ISR path (BUTTON_USE_INTERRUPTS) that latches each edge
with its own timestamp into a small ring buffer from attachInterrupt(),
independent of what the main loop is doing. check() replays buffered
edges through the existing transition/multi-click logic afterwards, so
click-duration and multi-click-window math still uses the edge's actual
capture time rather than "now".
Scoped to wio-tracker-l1-eink only for now: it's the one board this was
reported against and the only GxEPDDisplay-class variant that currently
builds clean on main. OLED boards refresh in ~20-30ms so polling already
catches every press; analog-button boards (rak4631/rak3401) can't use
attachInterrupt(CHANGE) at all.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content drawn under the title separator touched the line on every standard
screen, because listStart() == headerH() (the row right after the separator).
Graphical screens worked around it with a hand-rolled hdr+2.
Bake a 2px breathing gap into listStart() so every list gains it at once, and
switch the screens that hand-rolled the offset (Compass, Nav, Nearby detail) to
listStart() so the content top is computed the same way everywhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes from a full ui-new audit (OLED + e-ink both build green):
- remove dead DisplayDriver::drawScrollArrows (all lists use drawScrollIndicator)
- SettingsScreen selection bar -> lineStep()-1, matching the drawList screens
- HomeScreen: don't force the 1s blink refresh on the CLOCK page (status icons
are hidden there anyway)
- FullscreenMsgView: clamp KEY_DOWN scroll to a cached _max_scroll instead of
over-incrementing and leaning on the next render to clamp
- DataStore: clamp use_lemon_font (>1 -> 0) on load, like the other enum fields
- move the ~1.5KB wrap scratch (trans/lines) off the render stack into shared
file-scope statics in FullscreenMsgView (single-threaded UI; the fullscreen
view and history list never lay out in the same frame)
Cleanup:
- drop dead members RingtoneEditorScreen::DUR_VALS and HomeScreen::sensors_scroll
- delete redundant manual scroll-clamps in handleInput across the drawList
screens (drawList already reclamps each render); remove the now-unused _visible
from QuickMsgScreen/NearbyScreen/BotScreen and BotScreen::scrollToSel()
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- drawList(): scrollable list skeleton (visible window, keep-sel-in-view,
scroll-column reserve, per-row callback, indicator). Migrate Tools, Bot,
Nearby, Waypoints list and the three QuickMsg lists onto it; centralises the
scroll-clamp/reserve/indicator that each had open-coded (and inconsistently).
- drawCenteredHeader(): centred title + separator, replacing the duplicated
two-liner across ~10 screens (incl. QuickMsg, which used width()/2 spacing and
was missed before); unifies the separator at headerH()-sepH().
- keyIsPrev()/keyIsNext() in UIScreen.h for the LEFT||PREV / RIGHT||NEXT idiom;
applied to 6 screens, behaviour-preserving. Cancel/context-menu stay
per-screen (they mean different things on different screens).
- rotLabel() dedups the 0/90/180/270 array in Settings.
- Unify selection-row height to lineStep()-1 (Tools/Bot matched the rest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull repeated UI idioms into DisplayDriver and remove duplicated logic:
- DisplayDriver: add drawScrollArrows() and drawInvertedHeader(); replaces 11
copy-pasted scroll-arrow blocks across 5 screens and 3 inverted title-bar
blocks (NearbyScreen x2, NavView). Standardises the detail-view separator.
- useImperial(): single source on UITask; NearbyScreen/TrailScreen delegate
instead of each re-reading NodePrefs.units_imperial.
- NearbyScreen: extract saveSelectedWaypoint() (two identical blocks -> one);
drop the redundant local label buffer (WaypointStore truncates).
- UITask::shutdown(): power GPS off through LocationProvider::stop() instead of
poking PIN_GPS_EN directly — centralises enable/reset-pin + active-level logic.
Net -58 lines. Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo
builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UITask::shutdown(): power GPS off using !PIN_GPS_EN_ACTIVE instead of hardcoded
LOW, matching MicroNMEALocationProvider::stop() (still LOW on current active-high
devices, but correct for a future active-low GPS).
- buzzer _noteFreq(): clamp octave to 8 instead of 7 so the parser's accepted range
(4-8) is fully consumed; previously an octave-8 digit was clamped away and could
leak into the stream as the next note's duration.
- Waypoint::readFrom(): check read() return for version/reserved/count header bytes
so a truncated file is rejected instead of using garbage count.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On a tall portrait e-ink panel the clock and lock screens wasted most of
the vertical space on a small inline "HH:MM". Render HH and MM on two
lines in a new size-4 font (built-in GFX scaled 7×, ~42×56 px) so the
digits roughly double in height and fill the narrow width. Wide panels
(OLED, landscape e-ink) keep the classic single-line size-2 layout.
- New shared drawClockTime() helper used by both the Clock home page and
the lock screen; returns the y below the time block so the date and
dashboard rows flow beneath it.
- GxEPDDisplay: size 4 = built-in font × BIG_TEXT_SCALE (7); getCharWidth /
getLineHeight / setTextSize handle it; fontAscender stays 0 (built-in is
top-left origin).
- Centre the big digits on their visible glyph width — the built-in font
advances 6 px per char but the glyph is 5 px wide, so getTextWidth over-
reports by one trailing column and the digits would sit ~half a column
left of centre. AM/PM rendered one size larger (size 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firmware (7-byte header):
Byte 7 = display->screenshotRotation() — GxEPD2/GFX rotation value (0-3).
DisplayDriver::screenshotRotation() defaults to 0 (OLED).
GxEPDDisplay::screenshotRotation() returns display.getRotation(), the
live GxEPD2 value (reflects runtime setDisplayRotation() calls, not just
the compile-time DISPLAY_ROTATION macro).
Python decoder:
- Parse 7-byte header; pass rotation to eink_buffer_to_image().
- Implement all four GxEPD2 drawPixel coordinate transforms:
rot 0: phys_x=lx, phys_y=ly
rot 1: phys_x=vis_w-1-ly, phys_y=lx
rot 2: phys_x=vis_w-1-lx, phys_y=vis_h-1-ly (180° flip)
rot 3: phys_x=ly, phys_y=vis_h-1-lx
- vis_w/vis_h derived from log dimensions + rotation parity (no constants).
- Print rot= in the status line so the user sees which rotation was used.
This fixes the 180° rotated image seen with rotation=2 (portrait inverted)
without hardcoding a flip — correct for any rotation value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Firmware: add screenshotWidth()/screenshotHeight() virtual pair to
DisplayDriver (default: width()/height()). GxEPDDisplay overrides to
return display.width()/display.height() — the GxEPD2-reported values
that use WIDTH_VISIBLE (e.g. 122 for GxEPD2_213_B74), not the full
physical WIDTH stored in DisplayDriver (128). MyMesh uses these
instead of display->width()/height() for the screenshot header bytes.
Result for GxEPD2_213_B74 + DISPLAY_ROTATION=1:
header was 250×128 → now 250×122 (correct visible canvas)
Python decoder (tools/screenshot.py):
- Remove hardcoded EINK_PHYS_WIDTH=128 / EINK_VISIBLE_W=122 constants.
- Derive phys_stride from buffer_size / log_width (works for any panel).
- Fix phys_x formula: was (EINK_PHYS_WIDTH-1-ly = 127-ly),
now (vis_w-1-ly = log_height-1-ly = 121-ly for 213_B74).
Old formula addressed invisible columns 122-127; new formula correctly
maps logical rows 0..121 to visible physical columns 121..0.
- vis_w = log_height (no hardcoded trim; header now carries correct value).
This also works for square panels (e.g. 128×128 where WIDTH=WIDTH_VISIBLE):
header will carry 128×128, decoder produces a correct 128×128 image.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add lib/GxEPD2-patch/src/GxEPD2_BW.h: local patched copy of GxEPD2_BW.h
that exposes getBuffer()/getBufferSize() under ENABLE_SCREENSHOT guard.
Include guard (_GxEPD2_BW_H_) prevents double-inclusion of the installed
library version.
- GxEPDDisplay.h: use patched header via relative include when
ENABLE_SCREENSHOT so the patch takes precedence over the installed lib
(PlatformIO adds library paths before -I build_flags).
- DisplayDriver.h: add virtual getBuffer()/getBufferSize()/getDisplayType()
defaults (nullptr/0/0) under ENABLE_SCREENSHOT.
- SH1106Display.h / SSD1306Display.h / GxEPDDisplay.h: add concrete overrides;
getDisplayType() returns 0 (OLED) or 1 (e-ink).
- MyMesh.cpp/h: replace fragile C-cast with virtual dispatch in
handleScreenshotRequest(); extend 5-byte header to 6 bytes by appending
display_type so the host tool can decode the correct pixel layout.
- tools/screenshot.py: parse 6-byte header; add eink_buffer_to_image()
that decodes the row-major MSB-first GxEPD2 buffer with DISPLAY_ROTATION=1
(phys_x = 127-ly, phys_y = lx); dispatch on display_type.
- variants/wio-tracker-l1-eink/platformio.ini: add
[env:WioTrackerL1Eink_companion_dual_dev] with ENABLE_SCREENSHOT.
- variants/wio-tracker-l1/platformio.ini: unchanged (OLED env already exists).
Builds verified: WioTrackerL1Eink_companion_dual_dev SUCCESS,
WioTrackerL1Eink_companion_radio_ble SUCCESS (unaffected).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Adds the possibility to capture the device screen and save it as a PNG
image
- Wrap the code behind ENABLE_SCREENSHOT build flag, as per instructions
in README
In GxEPDDisplay::print(), the 0xDB fallback block was drawn at
cy - 8*scale, which assumed the Lemon baseline convention
(cy = original_y + 8*scale). For the default GFX font fontAscender
returns 0, so cy = original_y (top of cell) — the block landed
16 px above the character on e-ink (scale=2).
Fix: draw at cy (top of cell) for the non-Lemon path.
Also remove the unused forceNextRefreshFull() virtual from
DisplayDriver — it was added based on a wrong hypothesis (partial
refresh ghosting) and was never wired into endFrame().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Add NodePrefs::joystick_rotation (0-3 steps CW), persisted in DataStore
alongside display_rotation. rotateJoystickKey() now takes a runtime rot
parameter instead of compile-time JOYSTICK_ROTATION macro. UITask::loop()
reads joystick_rotation from NodePrefs each frame. Settings screen exposes
"Joystick" rotation item on any build with UI_HAS_JOYSTICK. JOYSTICK_ROTATION
macro still works as compile-time default for legacy/non-prefs builds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GxEPDDisplay::print() renders '\xDB' as filled rect for Lemon font,
matching OLED Lemon block behavior
- Remove translateUTF8ToBlocks override (base class passthrough sufficient)
- transliterateCodepoint fallback changed '?' → '\xDB' for consistency
- drawTextCentered/Right/LeftAlign now call translateUTF8ToBlocks
- isLemonFont() added to DisplayDriver, SH1106Display, GxEPDDisplay
- isLandscape() guarded by EINK_DISPLAY_MODEL to distinguish OLED vs e-ink
- ind_h trims indicator box height only for Lemon font
- getTextWidth disables wrap before getTextBounds, restores after
- QuickMsgScreen: tighter hist_item padding; 2 messages visible with Lemon
- Hibernate hint fits on one line when width allows, splits otherwise
- BLE PIN page shows toggle hint on landscape e-ink
- Remove unused muted_icon bitmap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GxEPDDisplay: fontAscender only applies to sz=1 with Lemon; sz=2 (NULL font)
was incorrectly getting a 16px cursor offset, causing clock text to render
16px lower than expected and overlap the date line
- KeyboardWidget: compute cell_h from available screen height instead of lh+1;
tighten sep_y to lh so the grid fits within 122px even with Lemon font (lh=20)
- SettingsScreen renderBar: constrain box size to available width so the 5th
buzzer-volume square doesn't overflow past the right edge with Lemon font
- NearbyScreen discover detail: manually truncate b64 public key by charWidth
to guarantee one-line rendering; use dynamic step = (height-hdr)/5 so
Status line doesn't fall off the bottom of the screen
- NearbyScreen contacts detail: merge dist+az into one line and use dynamic
step to fit 5 lines within the display; removes the off-screen Seen: row
- AutoAdvertScreen: replace lineStep() gap before hints with 4px fixed gap
so both hint lines fit within the display with large fonts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Portrait mode (122px wide) matches the OLED's ~21 chars/line with a 1× font.
Landscape mode (250px wide) was using the same small 6×8 font, leaving text
physically tiny and the wide display underutilised.
Size 1 now scales by orientation: 1× (6×8 / Lemon 5×10) in portrait,
2× (12×16 / Lemon 10×20) in landscape. Size 2 stays at 12×16 and size 3
at FreeSans18pt in both orientations — their layout Y-positions are hardcoded
so they cannot be scaled without a full layout refactor.
fontAscender() updated to accept a scale parameter; Lemon ascender becomes
8×scale (8px portrait, 16px landscape) to keep top-of-cell coordinate
semantics consistent with the rest of the UI code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Layout fix: GxEPDDisplay was using proportional GFX fonts (FreeSans9pt/12pt)
for sizes 1 and 2, giving lineHeight=16/20 instead of the 8/16 the UI layout
expects (designed for the OLED's bitmap font). Replaced with the GFX built-in
6×8 bitmap font (size 1) and its 2× scaled variant 12×16 (size 2). Size 3
keeps FreeSans18pt for large headings. fontAscender updated accordingly:
built-in font cursor is top-left (offset=0), Lemon GFX font adds 8px,
only FreeSans18pt still adds 26px.
Lemon font: LemonFont.h already stores the font in GFXfont format, so
GxEPDDisplay can use it directly via display.setFont(&Lemon). Added
_use_lemon flag, setLemonFont() override, and size-1 font selection in
setTextSize()/startFrame(). Adafruit GFX handles UTF-8 decoding for GFX
fonts, so Unicode characters (Cyrillic etc.) render correctly. The FONT
setting in SettingsScreen now works on e-ink the same as on OLED.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Settings > Display > Rotation (0°/90°/180°/270°) that persists to
NodePrefs and is applied on startup. Falls back to compile-time
DISPLAY_ROTATION when the field is missing from an older prefs file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove fixed 128×128 virtual space and scaling; use 1:1 pixel mapping
- Display dimensions derived from panel native size + DISPLAY_ROTATION
- setCursor compensates for GFX font baseline offset (ascender per font size)
- Add getLineHeight() / getCharWidth() overrides for FreeSans fonts
- New envs: landscape (250×122) and portrait (122×250), BLE and dual variants
- Integrate all wio-tracker-l1-improvements features into e-ink build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Lemon font (LemonFont.h, LemonIcons.h) to the standard build
alongside the Adafruit built-in font; ~101 KB added to flash
- SH1106Display: add _use_lemon flag + setLemonFont(bool) to switch
at runtime; print() and getTextWidth() use Lemon path when active;
translateUTF8ToBlocks() passes UTF-8 through unchanged (Lemon supports
full Unicode U+0020-U+04FF) vs transliterating for the default font
- DisplayDriver: add getCharWidth()/getLineHeight() virtuals (defaults 6/8);
SH1106Display returns 5/9 for Lemon, 6/8 for default; add setLemonFont()
no-op virtual; fix orphaned UTF-8 leading byte in drawTextEllipsized
- FullscreenMsgView: replace fixed-char-count word-wrap with pixel-accurate
wrap using display.getTextWidth(); use getLineHeight() for spacing so
Lemon (9 px) and default (8 px) both render correctly
- NodePrefs: add use_lemon_font field (0=default, 1=Lemon)
- SettingsScreen: add "Font" item in Display section (Default / Lemon),
calls UITask::applyFont() on change
- UITask: add applyFont() that calls display->setLemonFont(use_lemon_font);
called at startup (begin()) and when the setting changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use inverted PWM polarity (0x8000) with PCT {2,5,12,25,50} giving
~6-8 dB perceptual steps (-24/-16/-9/-3/0 dB) vs the original
uneven steps where levels 4 and 5 were only 1 dB apart
- Guard against cmp=0 truncation in both _nrfStartPwm and applyVolume:
at level 1 with high notes (freq > ~2.5 kHz) integer math gives
cmp=0 which with inverted polarity means 100% HIGH → complete silence
- Change volume preview note from C5 (523 Hz) to C6 (1047 Hz) to better
match the piezo resonant frequency range and actual notification sounds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ð/Ð (eth), þ/Þ (thorn) for Icelandic, and ţ/Ţ (t with cedilla)
for legacy-encoded Romanian text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>