Screens and the UI Bridge
The UI layer answers one question: how do a dozen concurrent components safely share one 128x160 screen? Safi's answer is a hard rule, one bridge task, and a facade.
The rule and the bridge
Only display_driver calls LVGL. Everything else publishes events.
Enforcement is structural: no other component even includes an LVGL header. The bridge (display_task.c) subscribes to twenty-three event types (playback, volume, seek, WiFi, NFC, battery, OTA progress, stream metadata, SD mount state, time sync, shutdown, input) and translates each into calls on the screen facade (display_ui.h).
Why so strict: LVGL is not thread-safe, and the alternative (each component grabbing a lock and poking widgets) fails in two reliable ways. First, lock misuse under load: any missed lock is heap corruption with a random corpse. Second, priority coupling: the audio task rendering a "now playing" label would hold the UI lock at priority 10, and every render hiccup would become an audio hiccup. Routing through events means the audio task's UI cost is one queue post, constant and tiny; rendering happens at UI priority on the UI core.
Inside display_driver, the few facade functions callable from other contexts take the port's LVGL lock with a bounded 200 ms timeout and drop on failure: a jammed renderer costs a missed status update, never a deadlocked caller. Timeouts-and-drop rather than waiting forever, because for status display, stale-then-corrected beats stuck.
The screen catalog
ui_screens.c builds each screen once (lazily) and switches between them; screens are state, not popups:
| Screen | Trigger | Content notes |
|---|---|---|
| Splash | boot | Mark, version, and the boot report lines in red |
| Idle clock | nothing playing | Time (--:-- until SNTP) in 12- or 24-hour form, the AM/PM suffix drawn in a Latin font the Arabic clock font lacks, ticking on each minute rollover; date, IP address, battery |
| Now Playing | EVENT_AUDIO_PLAY_START | Surah name in shaped Arabic (22 pt), reciter, progress bar, elapsed/total, stream title via ICY |
| Browse | encoder press | Reciter then surah lists from the SD index |
| WiFi status | connect/disconnect events | State plus IP |
| OTA progress | EVENT_OTA_PROGRESS | Bar plus "do not power off" |
| Error | EVENT_SYSTEM_ERROR, battery empty | Short message |
Two overlays float above whatever screen is active, on LVGL's top layer with auto-dismiss timers: the volume arc (2 s) and the NFC toast (2 s). Overlays are deliberately not screens: volume feedback must not navigate away from Browse.
The status bar (WiFi, battery and charging, volume) persists across screens and updates from events alone.
Backlight policy
display_task also owns the backlight timers: full brightness on any activity event, dim after 30 s, off after 5 min. It is the same task on purpose: activity is defined as "any event a human caused," and the bridge sees every one of them anyway.
The screenshot endpoint
GET /api/debug/screenshot returns the live screen as a BMP. It exists because the display was developed and verified headless: the panel was not wired during most of firmware bring-up, so the UI needed a way to be seen without glass.
Implementation (ui_screenshot.c): under the LVGL lock, lv_snapshot_take() renders the active screen into a buffer; the handler streams it as a bottom-up 24-bit BMP over chunked HTTP, converting RGB565 to RGB888 row by row (BMP because the format is a 54-byte header plus raw pixels, encodable with zero libraries and readable by everything).
It earns its permanent place three ways: UI regression checks in CI-adjacent scripts (fetch, compare), remote support ("send me a screenshot" for a device in another city), and documentation figures. It costs one small buffer during capture and is auth-and-rate-limited like any debug endpoint.
What died in the rewrite
The LVGL migration deleted the entire hand-rolled predecessor: the custom framebuffer renderer, a bitmap text engine with no shaping, and per-component draw calls. None of it was portable to the "one owner" model, and the Arabic chapter explains why its text output was unshippable anyway. The commit that removed it is the most satisfying diff in the repository's history.