The UI system
The UI is a no_std, allocation-free system for a 240×320-pixel display. It uses four buttons and immediate-mode drawing.
Screen model
Each screen is one Screen enum variant. The variant owns its state by value.
A screens! table defines each variant and its Caps. The table generates the enum, normal input and drawing dispatch, and capability metadata. A small manual prepare match delegates only the four reader-backed screens that need a one-shot operation before drawing.
Caps declares cross-cutting behavior. It covers base content, overlays, timers, holds, reader access, idle return, rain, catalog remapping, and the render key.
Screen enum provides static dispatch without heap allocation.A normal screen implements these operations:
handlereads oneGestureand returns aTransition.drawwrites the current frame.
Four reader-backed screens also implement prepare for a required one-shot reader operation. The manual dispatch is partial because other screens do not need this operation.
The Ctx input context contains mutable application state. The Render context contains read-only state and borrowed rendering resources.
Navigation
The screen stack is a heapless::Vec<Screen, 10>. Home is always the first item.
apply is the only function that changes the stack.| Transition | Stack operation |
|---|---|
None | Keep the stack. |
Push(screen) | Add a top screen. |
Pop | Remove the top screen, except Home. |
Replace(screen) | Replace the top screen. |
Root(screen) | Keep Home and add one screen. |
Home | Remove all screens above Home. |
A stack change cancels all incomplete holds. This rule prevents a hold from completing on a new screen.
Each screen owns its ordinary Back policy in its typed handle method. Back can pop a screen, leave an editor, cancel domain work, or move to a sibling view. Back-hold and button chords are handled above screen dispatch.
Detour flow
The Detour command is available during route navigation. It requires a routing graph and a matched route position.
Up and Down move the rejoin point in 100 m steps. The minimum rejoin distance is 600 m.
A successful plan shows distance and climb differences. Commit stores the splice as the active route.
The splice keeps completed route geometry, adds the detour, and continues from the rejoin point. It then rebuilds route-derived data.
Input
The device has Up, Down, Select, and Back buttons.
The recognizer emits these gestures:
| Gesture | Source |
|---|---|
Step(n) | Up or Down step |
Press | Select release within 200 ms |
Hold | Select held for 500 ms |
Back | Back release within 200 ms |
BackHold | Back held for 500 ms |
A release after 200 ms and before 500 ms emits no gesture. Long holds emit at the threshold, not on release.
BackHold is the global escape. The app answers it above the screen stack, so it never reaches a screen: it closes any open drawer and goes to the main menu, from every screen. With a main menu already on the stack it returns to that one instead of opening a second, so repeated holds cannot grow the stack.
Three states refuse it, because the rider must finish them first: a blocking card (the pairing passkey, a map transfer, the terminal update card), the recovered-ride card, and a shutdown that the rider has already confirmed. A two-button squeeze is refused in the same three states.
Chords
Two buttons pressed within 100 ms of each other are one chord, not two gestures. The recognizer reports the chord above the screen stack and emits nothing for the two buttons: no step, no tap, no long press, and no release. The chord stays latched until both buttons are up.
| Chord | Meaning |
|---|---|
| Up + Select | Open or close the universal quick drawer |
| Down + Back | Open or close the current screen's contextual drawer |
| Up + Down | Reserved |
| Select + Back | Reserved |
A reserved chord is recognized and swallowed. It does nothing. This keeps a squeeze of two buttons from becoming two unrelated actions.
Because a chord can start with a direction button, the first step of Up or Down waits for the 100 ms window. A release inside the window steps immediately, so a tap does not feel slower. Automatic repeat measures its delay from the press edge, so a held button keeps its usual cadence.
Drawers
A drawer is a sheet that the device draws over the current screen. There are two, and only one of them can be open: the second chord replaces the sheet instead of adding one.
The universal quick drawer comes down from the top and holds the device-wide controls: brightness, the Bluetooth radio, the central settings, and power. Brightness and power open a nested page. Back closes the sheet and returns the rider to the screen below it.
A platform whose panel has no controllable light does not show the brightness control. The sheet has the remaining three controls.
Nothing lands on top of a drawer. A card that arrives while a sheet is open takes the sheet with it, so dismissing the card returns the rider to the screen they were on.
The contextual drawer comes up from the bottom and holds the current screen's secondary actions. A screen does not build a drawer: it declares a static table of rows, and one generic drawer supplies the cursor, the transitions and the drawing. A screen that declares no table gets no sheet, and the chord does nothing on it — an empty drawer is never shown.
The four riding views (Map, Statistics, Climb, and the paused page) offer the same four actions in the same order: Up ahead, Detour, POIs, and Routes. A row that cannot act right now is drawn recessed and does nothing — the Detour row without a route, without map routing data, or off the route. A row that can act replaces the sheet with its screen, so one Back returns the rider to the riding view they squeezed from.
The Map adds a fifth row, Map display, because it is the only one of the four with something to apply it to. That row replaces the sheet with a shorter sheet holding three switches: the clock, the scale bar and the contour layer. The new sheet arrives with no animation, because a sheet that is already on the screen does not come in again. Back closes it onto the Map, like every other row's destination.
A row can also hold a value in place of a screen. Such a row slides the sheet to a nested editor: Up and Down change the staged choice, Select writes it and returns to the row table, and Back discards it. The editor keeps a mark on the choice that is already in effect. The sheet becomes as high as the editor needs and goes back to its table height. The Up-ahead view declares two such rows, Filter and Sources; the three weather views declare one, Interval — the time between the weather requests the device makes on its own. The create-route card declares one, Bike type — the routing profile the device plans with. These rows are the only place those controls are set.
The bike-type row shows what a value row does when its choices come from the loaded map. The choices are the map's own routing-profile names. A map built with a custom profile offers that profile with no change to the device software. A map with only one profile, or no map at all, offers no choice. The row is then drawn recessed and does nothing. The row is on the create-route card because that card is where the choice is used: the next press asks for a plan. The route overview shows the profile a route was planned with. It does not let the rider change it, because a change would make the page say something untrue about the route it shows.
A row can also be a switch. Such a row shows its own state and flips it in place: the sheet stays open and only that row changes. The three rows of the map display sheet are switches. What they change is not visible until the sheet closes, because the screen below a sheet is held still — so the rider sets all three in one visit and the flips themselves cost no map render. The map is drawn once when the shorter display sheet replaces the taller sheet, and once more, with all three answers, when the sheet goes away.
A row can also simply act. The weather views declare one such row, Refresh now. It asks for fresh weather and closes the sheet, because the answer and the cue for it are content of the screen below, which a sheet holds still. The row is drawn recessed while a request is already on its way, or while one waits for the phone to come back into range: a second request would change nothing.
The drawer is the only home for a setting that belongs to one screen. A control that moves into a drawer is removed from the central settings tree in the same change. A check in the build fails if a drawer and a settings screen write the same stored setting.
The screen under a drawer is frozen. A drawer states its own facts as its render key — the page, the selected control, the staged value and the value in effect — and that key replaces the facts of the screens below. So a moving map under a drawer causes no repaint, and the timed content of the screen below stops with it.
Whether the screen below is dimmed is a property of that screen. A map view is not dimmed: its second drawing is a map render, hundreds of milliseconds on the device, and the map reads well under the sheet at full colour. Menus, lists and settings pages are dimmed through a colour table, because drawing them again costs almost nothing and the recess helps the sheet read as being in front.
A screen that is not dimmed is also frozen on the panel. While a sheet grows over such a screen, the device draws the sheet alone and leaves the rows below it exactly as they are, so the open costs the sheet and no more. This needs no extra frame buffer: the panel keeps the last frame, and the sheet writes over it. A screen that is dimmed is drawn again on every one of those frames, because the dim is that drawing — but it is a menu, so the drawing is cheap.
Two cases draw the screen below again whatever it is, and both are cases where the sheet stops purely covering. Every frame of a page slide does, because the two pages travel through the narrow margin either side of the sheet, where the screen below shows; when the two pages differ in height, the same drawing puts back the rows the shrinking sheet gives up. And the frame that closes the drawer does, once.
The sheet slides in from its edge over about 440 milliseconds, in steps timed to what the panel can complete. A step that does not move the sheet is not drawn. Closing is immediate, on every screen: the sheet goes, the screen below is drawn once, and the device sends only the rows that changed.
A drawer is refused while a blocking card is on the screen: the pairing passkey, a map transfer, and the terminal update card.
Hold to confirm
A destructive or irreversible action can require Hold. The screen must also declare hold_fill in its capabilities.
The input plane supplies progress from 0.0 through 1.0. The selected guarded row draws this progress.
Delete actions exist on specific detail or confirmation rows. A hold elsewhere does not delete data.
Deleting things — the hold-to-delete footer
The delete footer is a guarded row. The action runs only after a complete hold on that row.
POI browser
The main POI menu contains water, campsite, lodging, resupply, pharmacy, and bicycle-shop categories.
A category query returns the nearest 16 matching POIs. The list stores one application-owned snapshot.
The snapshot does not change while the list is open. The bearing arrow uses the latest fix and heading.
The detail screen reads the POI schedule once through prepare. It calculates today's open state from the current local clock.
Settings
Settings screens use two focus levels. The row cursor selects a setting. Edit focus changes the selected value.
The application marks settings dirty when a value changes. SettingsMachine waits until the user leaves the Settings subtree before it requests a write. The host writes the snapshot through SettingsStore and reports the result.
The weather alert cooldown is not a setting. It is device state, and it has its own record with its own lifecycle. A firing alert writes only that record, and it writes it immediately: an open settings screen does not hold it back, and a change to a setting does not touch it.
The settings blob is independent of the SD card. The current UI languages are English, German, French, and Spanish.
A firmware update does not erase the stored settings. The blob format is append-only: a new setting is added at the end, and it carries the version that first wrote it. Stored fields never move. The device reads a stored blob at the blob's own version, and the fields added after that version take their defaults. Two cases reset the settings, and both are deliberate: a blob older than the oldest version whose exact bytes are committed as a reference, and a downgrade, where the stored blob is newer than the firmware and its layout is unknown.
The build generates a complete translation table from four TOML catalogs. The build fails if a catalog has missing or extra keys.
Retention
Routes have individual retention values. Synced rides use one global retention setting.
The retention sweep requires a trusted clock from this boot. GPS or the companion can establish this clock.
The device has no clock — so deletion waits for a trusted one
The sweep does not delete the active route. It does not delete unsynced rides.
An unknown route-use time starts a new retention period. It does not cause immediate deletion.
Runtime boundaries
Input logic and drawing receive different data views.
Ctx. Drawing uses read-only Render data and borrowed render resources.The screen table also declares whether a screen needs the map reader. A map screen needs it each frame.
The POI list and detail screens need the reader only until their one-shot data is ready. Other chrome screens do not build a reader.
Repaint policy
The application renders on demand. Dirty separates base-frame changes from transient overlay changes.
maprequests a base-frame render.overlayrequests a transient overlay render.regioncan limit a base-frame update to one rectangle.
A static screen with no new input, data, or timer event does not render.
The render key
Each screen row declares a render-key kind — the name of the facts its drawing reads. The frame builds the key from that declaration: it reads the named facts out of the current state and returns their exact values.
Each kind names what its screen draws. The Map names the camera, the fix, the pan mode, the route-relative chrome, the low-battery cue, and, on the rain map alone, the selected rain frame. The riding grid names the ride readouts and the live sensor values of the fields the rider pinned. The Climb view names the climb and the cursor on it. The Up-ahead timeline names the progress its rows measure from. Home names the battery level, the connected indicator, and the screensaver backdrop. A screen whose content moves only on input declares no facts of its own.
One frame builds the visible screens' key before its work and again after it. A changed key requests a base-frame render. The rule this keeps is per screen, not per screen class: a heart-rate reading repaints the grid that shows it and not the map beside it.
Five kinds of change cannot move a key, and each asks for its render directly. A host feeds some data between two frames, so the change is already in both keys. A screen keeps its own selection and scroll position, so each recognized gesture requests a render. The card scheduler answers for the cards it owns. A planner landing rewrites the screen stack. Some resident data — the catalogs, the derived route data — no row names. Over-redraw is safe. Under-redraw is a defect.
The high-priority input plane recognizes gestures and draws hold feedback. The map plane handles screen logic and expensive map rendering.
The overlay presenter reads the clean base frame, adds the overlay, and presents the result. It leaves the base frame unchanged.
Screens the companion link pushes
The companion can open modal cards for pairing, route updates, trip updates, warnings, and weather alerts.
The card scheduler assigns a fixed priority to each card type. A new card does not replace a hold in progress.
The passkey card
The passkey card shows the six-digit pairing code. The rider cannot dismiss it before pairing ends.
The Sensors screen
The Sensors settings screen shows heart-rate, power, and cadence sensor slots. It also opens the sensor scan list.
Riding data
Climbs
The route processor supplies climb segments and profiles. The Climb screen reads the active segment and its resident profile.
The riding-view cycle contains Map and Statistics. It also contains Climb when a climb is active and Climb mode is on.
Waypoints
The route file supplies route-ordered waypoints. Navigator tracks the next waypoint from matched route progress.
The map shows waypoint markers and an approach chip. Statistics shows waypoint progress and configured values.
Up ahead
The Up-ahead view merges route waypoints with map POIs near the route.
The corridor query sorts results by distance along the route. It excludes POIs behind the snapshot anchor.
The merge walks both sorted inputs. It does not allocate or copy list rows.
A category filter changes the corridor snapshot key. A source scope selects waypoints, map POIs, or both. The rider sets both from the view's own contextual drawer, which replaced an in-view mode the Select hold used to open. The filter is a selection that starts again at "Everything" each time the view opens; the source scope is stored.
A cursor the rider set counts only against the list they set it in. While the list shows something else, the cursor is the first row still ahead; it comes back if the rider sets the controls back. Without this a rider who scrolls and then filters lands on the last match instead of the nearest one.
Replacing active route geometry clears the corridor snapshot. The open view keeps its frozen progress anchor and requests new rows from the replacement route. A card that covers the view does not by itself clear the snapshot.
Configured Next: category fields use cached per-category corridor results. A visible Up-ahead screen has priority over these background requests.
Main rider flow
Home opens the main menu. A route selection opens its overview. Start uses Root(Map) to create a clean ride stack.
During a ride, Back cycles through riding views. Press pauses. A Down plus Back squeeze raises the ride context sheet. Back-hold opens the main menu from any of them.
Map Inspect uses Select-hold to enter. Back exits Inspect before it changes riding views. Inside Inspect a Select tap walks the mode ring: route movement, free movement, then zoom.
Idle return removes abandoned chrome. It returns to Home when idle and to Map during an active ride.
Visual vocabulary
Screens use shared primitives for titles, lists, rows, bands, tiles, text, and status indicators.
The chrome, rows, list, and tiles modules contain composable drawing parts. The band, spinner, and pager modules each own one shared mechanism. The fmt module owns shared quantity formatting.
ActionRows owns card-row selection, wrapping, Back dismissal, Press or Hold activation, guard state, and row drawing. A card screen maps CardEvent to typed domain work and owns its body layout. Cards compose the existing chrome helpers. There is no universal card-body or frame abstraction.
draw_rows is for selected, actionable lists. A read-only timeline can compose list_frame and scrollbar without inventing a selection. Weather Hourly uses this composition.
Overlays and screen-specific drawing layers stay local. The Climb grade renderer is different from the shared elevation band.
Palette constants use RGB565. The framebuffer converts them to the device's 64-color RGB222 gamut.
Source map
- Screen table, capabilities, contexts, and transitions:
screen/mod.rs - Gesture recognition:
input.rs - Input and overlay plane:
input_plane.rs - Repaint state and UI runtime:
dirty.rs,render_key.rs,ui_runtime.rs - Shared screen primitives:
screen/vocab/ - Settings and translations:
settings.rs,i18n/,i18n.rs - POI and Up-ahead views:
poi_list.rs,poi_detail.rs,up_ahead.rs - Retention policy:
retention.rs
See system architecture for the host loop. See rendering pipeline for pixel generation.