umsh_ux_display_tracker/
screen.rs

1//! Frame rendering shared by every display tracker.
2//!
3//! One renderer draws every board in the class. A board contributes its
4//! panel driver, its frame buffer, and a [`Layout`] describing the
5//! geometry it can offer; what actually appears on the glass — which
6//! rows exist, what they say, where the battery sits — is decided here,
7//! so a T-Echo and a Heltec V3 disagree about pixels and about nothing
8//! else.
9//!
10//! Every frame carries a header: the device name on the left and a
11//! battery indicator on the right. That includes the message frames
12//! ([`render_message`]) shown while pairing starts or the board shuts
13//! down — a panel that blanks its status to say "Clearing bonds..." is a
14//! panel the user has to wait on to learn anything.
15//!
16//! # Coordinates and color
17//!
18//! The renderer draws `BinaryColor::On` as foreground and clears with
19//! `Off`, which is what all three panels in this class already mean by
20//! those values: the e-paper's frame buffer maps `On` to ink, both OLEDs
21//! map it to a lit pixel. Boards hand over a `DrawTarget` in natural
22//! screen coordinates and keep rotation, packing, and flushing to
23//! themselves.
24//!
25//! # Layout model
26//!
27//! A frame is a stack of `rows` text lines of one font. Row 0 is the
28//! header, row 1 is the menu cursor or the confirmation question, and the
29//! rows after that belong to the page. Gesture hints sit at the bottom of
30//! the panel and are dropped, last one first, when the page needs the
31//! room — which is how a five-row OLED and a seven-row e-paper run the
32//! same code without either one wasting a line.
33//!
34//! Errors are swallowed throughout: a panel that fails mid-frame leaves a
35//! stale image, which is a display problem and never a protocol one.
36
37use core::fmt::Write as _;
38
39use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_10X20};
40use embedded_graphics::mono_font::{MonoFont, MonoTextStyle, MonoTextStyleBuilder};
41use embedded_graphics::pixelcolor::BinaryColor;
42use embedded_graphics::prelude::*;
43use embedded_graphics::primitives::{
44    PrimitiveStyle, PrimitiveStyleBuilder, Rectangle, StrokeAlignment, Triangle,
45};
46use embedded_graphics::text::{Baseline, Text};
47use heapless::String;
48
49use crate::menu::{EntryKind, MenuItem, Page, ToggleId, UiEffect, UiModel, UiNotice};
50use umsh_ux_tracker::battery::ChargeClass;
51
52/// Scratch buffer for a composed line. No panel in the class shows more
53/// than 21 characters — the 200 px e-paper manages only 19, since its
54/// font is proportionally much larger than the OLEDs' — so this is slack
55/// rather than a constraint. Rows are clipped to the panel on the way
56/// out regardless.
57const LINE: usize = 32;
58
59// ─── Board geometry ──────────────────────────────────────────────────────────
60
61/// How many fill segments the battery body is divided into.
62///
63/// Four is a deliberate coarseness. The e-paper diffs frames to decide
64/// how small a partial refresh it can get away with, so an indicator that
65/// moved on every sample would keep re-inking the panel; quantizing to
66/// quarters means the icon changes four times across a discharge.
67pub const BATTERY_SEGMENTS: u8 = 4;
68
69/// Number of lit segments for a *known* charge level, from 0 to
70/// [`BATTERY_SEGMENTS`].
71///
72/// The bands center each bar count on the level it depicts: two of four
73/// bars covers 37–63 %, so a half-full pack draws half a body. The two
74/// end bands are deliberately narrower than the middle ones — full and
75/// empty are absolute claims, and a body should not look full at 80 %
76/// nor empty at 20 %.
77pub const fn battery_segments(level_percent: u8) -> u8 {
78    match level_percent {
79        0..=14 => 0,
80        15..=36 => 1,
81        37..=62 => 2,
82        63..=84 => 3,
83        _ => BATTERY_SEGMENTS,
84    }
85}
86
87/// Battery indicator geometry, in pixels.
88///
89/// The bolt slot is reserved whether or not a bolt is drawn, so plugging
90/// in a charger never moves the battery body. On a partial-refresh panel
91/// that keeps the changed region down to the bolt itself.
92#[derive(Clone, Copy, Debug)]
93pub struct BatteryIconMetrics {
94    /// Outline of the battery body, border included.
95    pub body: Size,
96    /// Terminal nub, drawn flush against the body's right edge.
97    pub nub: Size,
98    /// Width reserved to the left of the body for the charging bolt.
99    pub bolt_width: u32,
100    /// Size of the bolt drawn when it stands in for the whole indicator,
101    /// which is bigger than [`Self::bolt_width`] because it is then the
102    /// only thing in the zone rather than an adornment beside a body.
103    pub solo_bolt: Size,
104    /// Gap between the bolt slot and the body.
105    pub spacing: u32,
106    /// Body outline stroke width, drawn inside [`Self::body`].
107    pub border: u32,
108    /// Clearance between the outline and the fill segments.
109    pub pad: u32,
110    /// Gap between adjacent fill segments.
111    pub gap: u32,
112    /// Clearance between the indicator and the right edge of the panel.
113    pub margin: u32,
114}
115
116impl BatteryIconMetrics {
117    /// Sized against `FONT_6X10`'s ten-pixel row on a 128×64 panel.
118    pub const OLED: Self = Self {
119        body: Size::new(16, 9),
120        nub: Size::new(2, 3),
121        bolt_width: 5,
122        solo_bolt: Size::new(7, 9),
123        spacing: 2,
124        border: 1,
125        pad: 1,
126        gap: 1,
127        margin: 1,
128    };
129
130    /// Sized against `FONT_10X20`'s twenty-pixel row on a 200×200 panel.
131    pub const EPD: Self = Self {
132        body: Size::new(32, 17),
133        nub: Size::new(4, 7),
134        bolt_width: 10,
135        solo_bolt: Size::new(13, 17),
136        spacing: 3,
137        border: 2,
138        pad: 2,
139        gap: 2,
140        margin: 3,
141    };
142
143    /// Total width the indicator occupies, bolt slot included.
144    pub const fn zone_width(&self) -> u32 {
145        self.bolt_width + self.spacing + self.body.width + self.nub.width
146    }
147}
148
149/// What the board gives the user to drive the menu with.
150///
151/// The hints at the bottom of every page name gestures the hardware
152/// actually has, so the renderer has to know which set it is looking at.
153/// Nothing else about a frame changes.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum Controls {
156    /// One button carrying the whole vocabulary: click advances,
157    /// double-click selects, a released hold goes back one entry.
158    OneButton,
159    /// A four-way pad with a center press, beside a button the case
160    /// labels Back. Up and down move, the center selects, and Back
161    /// leaves the screen — no gesture means two things.
162    Dpad,
163}
164
165/// A board's panel and controls.
166///
167/// Everything the renderer needs to place a row of text and the battery
168/// indicator, plus which gestures to name in the hints. A board picks one
169/// of the constants — or writes its own if its panel is neither of the
170/// two shapes in the class today — and overrides the fields its hardware
171/// disagrees about:
172///
173/// ```
174/// # use umsh_ux_display_tracker::screen::{Controls, Layout};
175/// const LAYOUT: Layout = Layout {
176///     controls: Controls::Dpad,
177///     ..Layout::OLED_128X64
178/// };
179/// ```
180#[derive(Clone, Copy, Debug)]
181pub struct Layout {
182    pub font: &'static MonoFont<'static>,
183    /// Left margin for row text.
184    pub left: i32,
185    /// Top of row 0's glyph band.
186    pub top: i32,
187    /// Distance between the tops of consecutive rows.
188    pub row_pitch: i32,
189    /// How many rows fit on the panel.
190    pub rows: usize,
191    pub size: Size,
192    pub battery: BatteryIconMetrics,
193    /// How this panel says a list continues past what it can draw.
194    pub overflow: Overflow,
195    /// What the user drives it with.
196    pub controls: Controls,
197}
198
199/// How a board shows that a list has more entries than fit.
200///
201/// One per board, used on every list. Two overflow idioms in one product
202/// teach the user to read neither.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub enum Overflow {
205    /// Cut the row past the last complete one off half-way, so a partial
206    /// row hangs over the bottom. Costs nothing horizontally, which is
207    /// what recommends it on a panel already short of characters.
208    ClipRow,
209    /// A track down the right edge with a thumb sized to the visible
210    /// fraction. Takes a column from every row to say how much list there
211    /// is and how far through it you are.
212    ScrollBar,
213}
214
215impl Layout {
216    /// 128×64 OLED: five rows of `FONT_6X10`. Shared by the Wio Tracker
217    /// L1's SH1106 and the Heltec V3's SSD1306.
218    pub const OLED_128X64: Self = Self {
219        font: &FONT_6X10,
220        left: 0,
221        top: 3,
222        row_pitch: 12,
223        rows: 5,
224        size: Size::new(128, 64),
225        battery: BatteryIconMetrics::OLED,
226        // 21 characters to a row already; a bar would take one of them
227        // from every row on the screen.
228        overflow: Overflow::ClipRow,
229        // The narrower assumption: a board with more controls says so,
230        // and one with fewer than a single button has no menu at all.
231        controls: Controls::OneButton,
232    };
233
234    /// 200×200 e-paper: seven rows of `FONT_10X20`. The T-Echo's SSD1681.
235    pub const EPD_200X200: Self = Self {
236        font: &FONT_10X20,
237        left: 5,
238        top: 8,
239        row_pitch: 27,
240        rows: 7,
241        size: Size::new(200, 200),
242        battery: BatteryIconMetrics::EPD,
243        // 200 px across can spare the column, and a bistable panel is
244        // read at leisure, which is when extent is worth knowing.
245        overflow: Overflow::ScrollBar,
246        controls: Controls::OneButton,
247    };
248
249    /// Top of `row`'s glyph band.
250    pub const fn row_top(&self, row: usize) -> i32 {
251        self.top + row as i32 * self.row_pitch
252    }
253
254    /// The full-width band `row` occupies.
255    ///
256    /// A highlight fills this rather than the glyph cells, so it reads as
257    /// a solid bar rather than as emphasized text.
258    pub fn row_rect(&self, row: usize) -> Rectangle {
259        let top = self.row_top(row);
260        let height = self.row_pitch.max(self.font.character_size.height as i32);
261        let bottom = (top + height).min(self.size.height as i32);
262        Rectangle::new(
263            Point::new(0, top),
264            Size::new(self.size.width, (bottom - top).max(0) as u32),
265        )
266    }
267
268    /// The rectangle the battery indicator owns, right-aligned on row 0.
269    pub fn battery_zone(&self) -> Rectangle {
270        let width = self.battery.zone_width();
271        let height = self.battery.body.height;
272        let x = self.size.width as i32 - self.battery.margin as i32 - width as i32;
273        let y = self.row_top(0) + (self.font.character_size.height as i32 - height as i32) / 2;
274        Rectangle::new(Point::new(x, y), Size::new(width, height))
275    }
276}
277
278// ─── What the frame says ─────────────────────────────────────────────────────
279
280/// How the board's local link is currently reachable.
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum LinkState {
283    /// A companion is connected and has a live session.
284    Attached,
285    /// A companion is connected but has not attached a session.
286    Connected,
287    /// Nothing is connected; the board is discoverable.
288    Advertising,
289    /// Advertising is suppressed, typically because a wired host owns the
290    /// device.
291    OffWired,
292    /// The operator turned Bluetooth off. Distinct from `OffWired`: this
293    /// device is unreachable because it was told to be, not because a
294    /// cable outranks the radio.
295    Disabled,
296}
297
298/// Whether a companion can pair right now, and with what secret.
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum PairingState {
301    /// Too many failed attempts; pairing cannot be opened.
302    LockedOut,
303    /// A pairing window is open. The panel is the only place the PIN is
304    /// ever shown, which is why an open window holds the display awake.
305    Open { pin: Option<u32> },
306    /// No pairing window is open.
307    Closed,
308}
309
310/// Charge level and charging state, as far as the board can tell.
311#[derive(Clone, Copy, Debug, PartialEq, Eq)]
312pub struct BatteryIndicator {
313    /// `None` when there is no level to show — before the estimator has
314    /// had a resting sample, or while charging on a board whose charger
315    /// reports no completion. Nothing is drawn in its place.
316    pub level_percent: Option<u8>,
317    /// `None` on a board whose charger reports nothing to the MCU, which
318    /// is different from knowing the pack is discharging.
319    pub charge: Option<ChargeClass>,
320}
321
322impl BatteryIndicator {
323    /// Nothing known yet: no body, no bolt.
324    pub const UNKNOWN: Self = Self {
325        level_percent: None,
326        charge: None,
327    };
328
329    /// Whether the indicator should carry a charging bolt.
330    ///
331    /// `Charged` draws one too, which is a deliberate degradation. The
332    /// full vocabulary is a bolt for "charging" and a plug for "charging
333    /// complete"; no board in this class can tell the two apart — only
334    /// the T-1000E reads a real charge-status line, and it has no panel
335    /// — so a board that sees external power flies the bolt for as long
336    /// as it is plugged in rather than asserting a completion it never
337    /// learns. Add the plug when a display board can substantiate it.
338    const fn shows_bolt(&self) -> bool {
339        matches!(
340            self.charge,
341            Some(ChargeClass::Charging) | Some(ChargeClass::Charged)
342        )
343    }
344}
345
346/// Radio activity, for the stats page.
347///
348/// Counts are cumulative since boot. They saturate rather than wrap: a
349/// counter that rolled over would make a long-quiet node look busy, and
350/// pinning at the maximum is at least monotone.
351#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
352pub struct StatsModel {
353    pub tx_frames: u32,
354    /// Every frame the radio handed up, whoever it was for.
355    pub rx_frames: u32,
356    /// Receptions that produced an event — addressed to this node, or
357    /// forwarded. The shortfall against [`Self::rx_frames`] is other
358    /// people's traffic and undecodable noise, shown as "drop".
359    pub rx_accepted: u32,
360    pub forwarded: u32,
361    /// Configured transmit power; `None` until the radio is configured.
362    pub tx_power_dbm: Option<i8>,
363    /// Duty-cycle usage in tenths of a percent. Trackers normally sit
364    /// well under one percent, so whole percent would read zero forever.
365    pub duty_permille: u16,
366}
367
368impl StatsModel {
369    /// Receptions that went nowhere.
370    pub const fn rx_dropped(&self) -> u32 {
371        self.rx_frames.saturating_sub(self.rx_accepted)
372    }
373}
374
375/// Everything drawn that is not menu state.
376///
377/// The firmware assembles this immediately before rendering — the device
378/// name in particular comes from an async read, which is why it arrives
379/// as a borrowed string rather than being fetched here.
380#[derive(Clone, Copy, Debug)]
381pub struct StatusModel<'a> {
382    pub device_name: &'a str,
383    pub battery: BatteryIndicator,
384    /// Pack voltage for the status page's diagnostic row. The header icon
385    /// is the glanceable reading; this is the one to quote in a bug
386    /// report.
387    pub battery_mv: Option<u16>,
388    pub link: LinkState,
389    /// How many companions are bonded. Shown only on the clear-bonds
390    /// confirmation, where it says what is about to be destroyed; on the
391    /// status page it was a number nobody was deciding anything with.
392    /// Running out of slots surfaces as a pairing failure, which is the
393    /// moment the capacity matters.
394    pub bonds: u8,
395    pub pairing: PairingState,
396    pub stats: StatsModel,
397    /// The local time to show in the header, or `None` when the device
398    /// does not know what time it is.
399    ///
400    /// `None` draws nothing at all — not a placeholder, not dashes, not a
401    /// zeroed clock. A device that does not know the time **must not**
402    /// indicate one, and enforcing that here rather than in each panel is
403    /// what keeps it true: there is no way to render a clock without a
404    /// reading to render.
405    pub clock: Option<ClockModel>,
406    /// The values behind the toggle entries.
407    pub settings: SettingsModel,
408    /// This device's own address, for the Status and Identity screens.
409    pub identity: Option<IdentityModel<'a>>,
410}
411
412/// What the toggle entries currently read.
413///
414/// Every field is an `Option` for the same reason [`StatusModel::clock`]
415/// is: a board that cannot say which way a setting is set draws no state
416/// rather than a plausible guess. `None` is also what a board without the
417/// subsystem reports, and such a board does not enable the entry anyway.
418#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
419pub struct SettingsModel {
420    pub bluetooth: Option<bool>,
421    pub gnss: Option<bool>,
422    pub share_location: Option<bool>,
423    pub forwarding: Option<bool>,
424}
425
426/// This device's own address, rendered by the firmware.
427///
428/// Both forms arrive pre-formatted because base58 lives in `umsh-core`,
429/// which this crate does not depend on — and because the hint's
430/// star-truncated rendering is canonical elsewhere and must not be
431/// reinvented here.
432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
433pub struct IdentityModel<'a> {
434    /// The four-character node hint, `*` and all. What a person compares
435    /// by eye.
436    pub hint: &'a str,
437    /// The complete 44-character Base58 address. What a machine reads —
438    /// and what Identity falls back to on a panel with no room for a
439    /// scannable symbol, which today is every panel in the class.
440    pub address: &'a str,
441}
442
443/// A local wall-clock reading for the header.
444///
445/// Hours and minutes only. A seconds field would commit every panel to
446/// redrawing once a second, which an e-paper cannot do and a
447/// battery-powered OLED should not.
448#[derive(Clone, Copy, Debug, PartialEq, Eq)]
449pub struct ClockModel {
450    /// Local hour, 0–23.
451    pub hour: u8,
452    /// Local minute, 0–59.
453    pub minute: u8,
454}
455
456impl ClockModel {
457    /// Render the status-page row, labeled to match the battery row
458    /// beside it — a bare `14:30` on a line of its own reads as a
459    /// measurement without a name.
460    fn write(&self, out: &mut String<LINE>) {
461        let _ = write!(out, "time {:02}:{:02}", self.hour, self.minute);
462    }
463}
464
465// ─── Entry points ────────────────────────────────────────────────────────────
466
467/// Draw the menu or the confirmation page.
468pub fn render_frame<D>(target: &mut D, layout: &Layout, model: &UiModel, status: &StatusModel<'_>)
469where
470    D: DrawTarget<Color = BinaryColor>,
471{
472    let _ = target.clear(BinaryColor::Off);
473    draw_header(target, layout, status);
474
475    let mut line: String<LINE> = String::new();
476    let content_end = match model.page() {
477        // The top level is three pages the user walks between, each of
478        // them the whole panel: the highlight names what is being read
479        // and the rows below are the reading. Everything below the top is
480        // only meaningful beside its neighbors, so the list it belongs to
481        // is the screen and a Select is what opens one of its entries.
482        Page::Menu(MenuItem::Status) => {
483            draw_row_inverted(target, layout, 1, menu_label(MenuItem::Status));
484            draw_status_page(target, layout, model, status, &mut line)
485        }
486        Page::Menu(MenuItem::Identity) => {
487            draw_row_inverted(target, layout, 1, menu_label(MenuItem::Identity));
488            draw_identity_page(target, layout, status)
489        }
490        // Settings is a doorway and has nothing of its own to say. A page
491        // that listed what was behind it read as the list one Select
492        // away rather than as the way to it — rows of labels and states,
493        // under a bar that looked like a highlight on the first of them.
494        // So: its name, centered, and the hint saying what opens it.
495        Page::Menu(MenuItem::Settings) => {
496            draw_row_centered(target, layout, 1, menu_label(MenuItem::Settings));
497            2
498        }
499        Page::Detail(MenuItem::Stats) => {
500            draw_row_inverted(target, layout, 1, menu_label(MenuItem::Stats));
501            draw_stats_page(target, layout, status, &mut line)
502        }
503        // No other entry opens a page yet. Naming it is still better than
504        // a blank panel, and better than a list the Select just left.
505        Page::Detail(item) => {
506            draw_row_inverted(target, layout, 1, menu_label(item));
507            2
508        }
509        Page::Menu(item) => draw_level_list(target, layout, model, status, item),
510        Page::Confirm {
511            confirm_selected, ..
512        } => {
513            // The question names the object and its size, which is the
514            // only place the bond count changes a decision — and is why
515            // the status page no longer spends a row carrying it around.
516            write_clear_question(&mut line, status.bonds);
517            draw_row(target, layout, 1, &line);
518            // The two choices carry the same inversion the menu uses, so
519            // "which one is under the cursor" is one question everywhere.
520            draw_row_selectable(target, layout, 2, "Cancel", !confirm_selected);
521            draw_row_selectable(target, layout, 3, "CLEAR", confirm_selected);
522            4
523        }
524    };
525
526    // The second hint names what Select would actually do here, so it is
527    // built rather than picked from a table of literals.
528    let mut menu_hints = [move_hint(layout.controls), ""];
529    let hints: &[&str] = match model.page() {
530        Page::Menu(item) => match select_hint(layout.controls, item) {
531            Some(hint) => {
532                menu_hints[1] = hint;
533                &menu_hints[..]
534            }
535            None => &menu_hints[..1],
536        },
537        // A reading page has one question left, so it gets one hint —
538        // and it names the gesture that is quickest rather than the only
539        // one that works, since every press dismisses it.
540        Page::Detail(_) => match layout.controls {
541            Controls::OneButton => &["2x: back"],
542            Controls::Dpad => &["OK: back"],
543        },
544        Page::Confirm { .. } => match layout.controls {
545            Controls::OneButton => &["1x/hold: toggle", "2x: confirm"],
546            Controls::Dpad => &["up/dn: pick", "OK: confirm"],
547        },
548    };
549    draw_hints(target, layout, content_end, hints);
550}
551
552/// Draw a short centered message — a pairing window opening, a wipe
553/// running, an alert, a farewell.
554///
555/// The header stays, so the battery is readable even while the board is
556/// busy saying something else.
557pub fn render_message<D>(
558    target: &mut D,
559    layout: &Layout,
560    status: &StatusModel<'_>,
561    title: &str,
562    detail: &str,
563) where
564    D: DrawTarget<Color = BinaryColor>,
565{
566    let _ = target.clear(BinaryColor::Off);
567    draw_header(target, layout, status);
568    let title_row = layout.rows / 2;
569    draw_row_centered(target, layout, title_row, title);
570    draw_row_centered(target, layout, title_row + 1, detail);
571}
572
573/// Draw the battery indicator with its zone's top-left corner at
574/// `top_left`.
575///
576/// Public because it is the one piece of this module a richer, more
577/// graphical UI would want to keep and reuse verbatim.
578pub fn draw_battery_icon<D>(
579    target: &mut D,
580    top_left: Point,
581    metrics: &BatteryIconMetrics,
582    indicator: &BatteryIndicator,
583) where
584    D: DrawTarget<Color = BinaryColor>,
585{
586    let outline = PrimitiveStyleBuilder::new()
587        .stroke_color(BinaryColor::On)
588        .stroke_width(metrics.border)
589        .stroke_alignment(StrokeAlignment::Inside)
590        .build();
591    let solid = PrimitiveStyle::with_fill(BinaryColor::On);
592
593    // Two independent facts, drawn independently: whether there is a
594    // level to show, and whether the pack is charging. Either, both, or
595    // neither.
596    //
597    // The bolt takes its reserved column beside a body when there is one
598    // to sit beside, and the whole zone when there is not. It keeps the
599    // zone's right edge either way, so the indicator stays anchored to
600    // the same corner whatever it is currently drawing.
601    if indicator.shows_bolt() {
602        if indicator.level_percent.is_some() {
603            draw_bolt(
604                target,
605                top_left,
606                Size::new(metrics.bolt_width, metrics.body.height),
607                solid,
608            );
609        } else {
610            let bolt = metrics.solo_bolt;
611            let at = Point::new(
612                top_left.x + metrics.zone_width().saturating_sub(bolt.width) as i32,
613                top_left.y + (metrics.body.height.saturating_sub(bolt.height) / 2) as i32,
614            );
615            draw_bolt(target, at, bolt, solid);
616        }
617    }
618
619    // No level, no body. An empty body means a pack down to its last
620    // sixth; a level the device has not established is drawn as nothing
621    // at all.
622    let Some(level) = indicator.level_percent else {
623        return;
624    };
625
626    let body_left = top_left.x + (metrics.bolt_width + metrics.spacing) as i32;
627    let _ = Rectangle::new(Point::new(body_left, top_left.y), metrics.body)
628        .into_styled(outline)
629        .draw(target);
630
631    let nub_y = top_left.y + (metrics.body.height as i32 - metrics.nub.height as i32) / 2;
632    let _ = Rectangle::new(
633        Point::new(body_left + metrics.body.width as i32, nub_y),
634        metrics.nub,
635    )
636    .into_styled(solid)
637    .draw(target);
638
639    let inset = metrics.border + metrics.pad;
640    let inner = Size::new(
641        metrics.body.width.saturating_sub(2 * inset),
642        metrics.body.height.saturating_sub(2 * inset),
643    );
644    let lit = u32::from(battery_segments(level));
645    let count = u32::from(BATTERY_SEGMENTS);
646    let seg_width = inner
647        .width
648        .saturating_sub(metrics.gap * (count - 1))
649        .checked_div(count)
650        .unwrap_or(0);
651    if seg_width == 0 || inner.height == 0 {
652        return;
653    }
654    for index in 0..lit {
655        let x = body_left + inset as i32 + (index * (seg_width + metrics.gap)) as i32;
656        let _ = Rectangle::new(
657            Point::new(x, top_left.y + inset as i32),
658            Size::new(seg_width, inner.height),
659        )
660        .into_styled(solid)
661        .draw(target);
662    }
663}
664
665// ─── Pages ───────────────────────────────────────────────────────────────────
666
667/// The status page's content rows, packed upward from row 2.
668///
669/// Only state that departs from nominal earns a row. A closed pairing
670/// window and plain advertising are what every tracker does when nothing
671/// is happening, and a line that appears on almost every frame trains the
672/// user to stop reading it — so neither is drawn, and what is left on a
673/// resting device is a single battery line. That is also what gives the
674/// five-row panels enough room to show their gesture hints on the page
675/// users actually sit on.
676///
677/// The order is falling importance, so the line a short panel runs out of
678/// room for is always the one it can most afford to lose: the battery row
679/// duplicates a header icon that is already on screen.
680fn draw_status_page<D>(
681    target: &mut D,
682    layout: &Layout,
683    model: &UiModel,
684    status: &StatusModel<'_>,
685    line: &mut String<LINE>,
686) -> usize
687where
688    D: DrawTarget<Color = BinaryColor>,
689{
690    let mut row = 2;
691
692    if let Some(notice) = model.notice() {
693        draw_row(target, layout, row, notice_label(notice));
694        row += 1;
695    }
696
697    line.clear();
698    if write_pairing(line, status.pairing) {
699        draw_row(target, layout, row, line);
700        row += 1;
701    }
702
703    if let Some(label) = link_label(status.link) {
704        draw_row(target, layout, row, label);
705        row += 1;
706    }
707
708    line.clear();
709    write_battery(line, status);
710    draw_row(target, layout, row, line);
711    row += 1;
712
713    // Last, so that on a panel whose rows have run out the clock is what
714    // falls off rather than the battery: how much charge is left is a
715    // fact somebody is deciding something with, and what time it is is
716    // not. Absent entirely when the device does not know the time —
717    // there is no placeholder row, because a row that says the time is
718    // unknown is still an indication about the time.
719    if let Some(clock) = status.clock {
720        line.clear();
721        clock.write(line);
722        draw_row(target, layout, row, line);
723        row += 1;
724    }
725
726    row
727}
728
729/// Radio activity: what the node has actually done on the air.
730///
731/// Enough to tell a working node from a deaf one without reaching for a
732/// capture — a node whose `rx` never moves is not hearing anybody, and one
733/// whose `tx` never moves is not being heard.
734fn draw_stats_page<D>(
735    target: &mut D,
736    layout: &Layout,
737    status: &StatusModel<'_>,
738    line: &mut String<LINE>,
739) -> usize
740where
741    D: DrawTarget<Color = BinaryColor>,
742{
743    let stats = status.stats;
744
745    line.clear();
746    let _ = write!(line, "tx {}  rx {}", stats.tx_frames, stats.rx_frames);
747    draw_row(target, layout, 2, line);
748
749    line.clear();
750    let _ = write!(line, "fwd {}  drop {}", stats.forwarded, stats.rx_dropped());
751    draw_row(target, layout, 3, line);
752
753    line.clear();
754    match stats.tx_power_dbm {
755        Some(dbm) => {
756            let _ = write!(line, "{dbm} dBm  ");
757        }
758        None => {
759            let _ = write!(line, "-- dBm  ");
760        }
761    }
762    let (whole, tenth) = (stats.duty_permille / 10, stats.duty_permille % 10);
763    let _ = write!(line, "duty {whole}.{tenth}%");
764    draw_row(target, layout, 4, line);
765
766    5
767}
768
769/// Draw this device's address.
770///
771/// The QR code the spec wants here needs a symbol a camera can resolve —
772/// about 110 px square for a `umsh:n:` URI — which neither panel in the
773/// class can offer below the header. So both fall back to the address as
774/// text, wrapped across the rows below, with the four-character hint
775/// above it as the part a person can compare by eye where there is room
776/// for both.
777fn draw_identity_page<D>(target: &mut D, layout: &Layout, status: &StatusModel<'_>) -> usize
778where
779    D: DrawTarget<Color = BinaryColor>,
780{
781    let Some(identity) = status.identity else {
782        // No identity yet is a real state on a freshly flashed board, and
783        // an empty screen says it better than a row of placeholder.
784        return 2;
785    };
786
787    // The address is 44 characters and no panel in the class shows more
788    // than 21, so it wraps. Splitting on the character grid rather than
789    // at a fixed width keeps every chunk the same length, which is what
790    // makes a transcription check possible at all.
791    let room = layout.size.width.saturating_sub(layout.left.max(0) as u32);
792    let per_row = clip(layout, identity.address, room).chars().count().max(1);
793    let needed = identity.address.chars().count().div_ceil(per_row);
794
795    // The address goes on whole or not at all. An address cut off at the
796    // bottom of the panel is worse than none, because it looks like a
797    // complete one — and this screen exists to be transcribed from.
798    let mut row = 2;
799    let available = layout.rows.saturating_sub(row);
800    if needed > available {
801        // Not even alone. Show what a person can compare by eye and leave
802        // the machine-readable form to the phone.
803        draw_row(target, layout, row, identity.hint);
804        return row + 1;
805    }
806    // The hint is what yields the row when both will not fit: the spec
807    // offers it as a convenience, and the header still names the device.
808    if needed < available {
809        draw_row(target, layout, row, identity.hint);
810        row += 1;
811    }
812
813    let mut rest = identity.address;
814    while !rest.is_empty() && row < layout.rows {
815        let end = rest
816            .char_indices()
817            .nth(per_row)
818            .map_or(rest.len(), |(at, _)| at);
819        let (chunk, remainder) = rest.split_at(end);
820        draw_row(target, layout, row, chunk);
821        rest = remainder;
822        row += 1;
823    }
824    row
825}
826
827/// Draw one settings level as a list, with the highlight on `selected`.
828///
829/// The window always contains the highlighted entry drawn complete: a
830/// Select against a row the user can only half read is a guess.
831fn draw_level_list<D>(
832    target: &mut D,
833    layout: &Layout,
834    model: &UiModel,
835    status: &StatusModel<'_>,
836    selected: MenuItem,
837) -> usize
838where
839    D: DrawTarget<Color = BinaryColor>,
840{
841    let level = selected.level();
842    let items = model.items();
843    let count = items.entries(level).count();
844    let index = items
845        .entries(level)
846        .position(|item| item == selected)
847        .unwrap_or(0);
848
849    // Rows 1.. belong to the list; row 0 is the header.
850    let available = layout.rows.saturating_sub(1);
851    let overflows = count > available;
852    // With a clipped row the last slot shows a partial entry, so one
853    // fewer entry is drawn complete. A scroll bar costs width, not rows.
854    let visible = match (overflows, layout.overflow) {
855        (true, Overflow::ClipRow) => available.saturating_sub(1).max(1),
856        _ => available,
857    };
858
859    let start = if index < visible {
860        0
861    } else {
862        (index + 1 - visible).min(count.saturating_sub(visible))
863    };
864
865    let mut line: String<LINE> = String::new();
866    for (offset, item) in items.entries(level).skip(start).take(visible).enumerate() {
867        line.clear();
868        write_entry(&mut line, item, &status.settings);
869        draw_row_selectable(target, layout, 1 + offset, &line, item == selected);
870    }
871
872    if !overflows {
873        return 1 + count;
874    }
875
876    match layout.overflow {
877        Overflow::ClipRow => {
878            let after = start + visible;
879            if let Some(item) = items.entries(level).nth(after) {
880                line.clear();
881                write_entry(&mut line, item, &status.settings);
882                draw_clipped_row(target, layout, 1 + visible, &line);
883            }
884        }
885        Overflow::ScrollBar => draw_scroll_bar(target, layout, count, start, visible),
886    }
887    layout.rows
888}
889
890/// An entry's name and, for a toggle, the state it is in.
891fn write_entry(line: &mut String<LINE>, item: MenuItem, settings: &SettingsModel) {
892    let _ = write!(line, "{}", menu_label(item));
893    let state = toggle_label(item, settings);
894    if !state.is_empty() {
895        let _ = write!(line, "  {state}");
896    }
897}
898
899/// Draw a row cut off half-way by the bottom of the panel.
900///
901/// The clip is explicit rather than left to the panel: a partially
902/// off-target row is a bug everywhere else, and the test panel rightly
903/// asserts on one.
904fn draw_clipped_row<D>(target: &mut D, layout: &Layout, row: usize, text: &str)
905where
906    D: DrawTarget<Color = BinaryColor>,
907{
908    let band = layout.row_rect(row);
909    let half = Rectangle::new(
910        band.top_left,
911        Size::new(band.size.width, band.size.height / 2),
912    );
913    if half.size.height == 0 {
914        return;
915    }
916    let mut clipped = target.clipped(&half);
917    draw_row(&mut clipped, layout, row, text);
918}
919
920/// Draw the scroll bar: a track down the right edge with a thumb sized to
921/// the visible fraction and placed at the current position.
922fn draw_scroll_bar<D>(target: &mut D, layout: &Layout, count: usize, start: usize, visible: usize)
923where
924    D: DrawTarget<Color = BinaryColor>,
925{
926    if count == 0 {
927        return;
928    }
929    let width = layout.battery.border.max(2);
930    let x = layout.size.width as i32 - width as i32;
931    let top = layout.row_top(1);
932    let bottom = layout.size.height as i32;
933    let height = (bottom - top).max(0) as u32;
934    if height == 0 {
935        return;
936    }
937
938    let track = Rectangle::new(Point::new(x, top), Size::new(width, height));
939    let _ = track
940        .into_styled(
941            PrimitiveStyleBuilder::new()
942                .stroke_color(BinaryColor::On)
943                .stroke_width(1)
944                .stroke_alignment(StrokeAlignment::Inside)
945                .build(),
946        )
947        .draw(target);
948
949    let thumb_height = ((height as usize * visible.min(count)) / count).max(2) as u32;
950    let span = height.saturating_sub(thumb_height);
951    let scrollable = count.saturating_sub(visible).max(1);
952    let offset = (span as usize * start.min(scrollable)) / scrollable;
953    let thumb = Rectangle::new(
954        Point::new(x, top + offset as i32),
955        Size::new(width, thumb_height.min(height)),
956    );
957    let _ = thumb
958        .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
959        .draw(target);
960}
961
962// ─── Drawing helpers ─────────────────────────────────────────────────────────
963
964fn draw_header<D>(target: &mut D, layout: &Layout, status: &StatusModel<'_>)
965where
966    D: DrawTarget<Color = BinaryColor>,
967{
968    // The battery owns its corner: the name is cut to the room left over
969    // rather than being allowed to run under the indicator and off the
970    // panel. Blanking the zone afterwards keeps that true no matter what
971    // else the header grows.
972    //
973    // The clock is deliberately *not* here. It fits, but only by taking
974    // the room from the device name, and on the 200 px e-paper's
975    // twenty-pixel font that cut the name from fourteen characters to
976    // seven — which across a fleet of `umsh-`-prefixed radios is the
977    // difference between identifying one and guessing. The clock lives on
978    // the status page instead, where a row costs nothing that was being
979    // read.
980    let zone = layout.battery_zone();
981    let room = (zone.top_left.x - layout.left).max(0) as u32;
982    draw_row(target, layout, 0, clip(layout, status.device_name, room));
983    let _ = zone
984        .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
985        .draw(target);
986    draw_battery_icon(target, zone.top_left, &layout.battery, &status.battery);
987}
988
989/// Longest prefix of `text` that fits in `width` pixels.
990fn clip<'a>(layout: &Layout, text: &'a str, width: u32) -> &'a str {
991    let advance = layout.font.character_size.width + layout.font.character_spacing;
992    if advance == 0 {
993        return text;
994    }
995    let fits = (width / advance) as usize;
996    match text.char_indices().nth(fits) {
997        Some((end, _)) => &text[..end],
998        None => text,
999    }
1000}
1001
1002fn draw_row<D>(target: &mut D, layout: &Layout, row: usize, text: &str)
1003where
1004    D: DrawTarget<Color = BinaryColor>,
1005{
1006    if row >= layout.rows || text.is_empty() {
1007        return;
1008    }
1009    let room = layout.size.width.saturating_sub(layout.left.max(0) as u32);
1010    let text = clip(layout, text, room);
1011    draw_text(
1012        target,
1013        layout,
1014        Point::new(layout.left, layout.row_top(row)),
1015        text,
1016    );
1017}
1018
1019fn draw_row_centered<D>(target: &mut D, layout: &Layout, row: usize, text: &str)
1020where
1021    D: DrawTarget<Color = BinaryColor>,
1022{
1023    if row >= layout.rows || text.is_empty() {
1024        return;
1025    }
1026    let advance = layout.font.character_size.width + layout.font.character_spacing;
1027    let text = clip(layout, text, layout.size.width);
1028    let width = advance.saturating_mul(text.chars().count() as u32);
1029    let x = (layout.size.width.saturating_sub(width) / 2) as i32;
1030    draw_text(target, layout, Point::new(x, layout.row_top(row)), text);
1031}
1032
1033fn draw_text<D>(target: &mut D, layout: &Layout, at: Point, text: &str)
1034where
1035    D: DrawTarget<Color = BinaryColor>,
1036{
1037    let style = MonoTextStyle::new(layout.font, BinaryColor::On);
1038    let _ = Text::with_baseline(text, at, style, Baseline::Top).draw(target);
1039}
1040
1041/// Draw `row` inverted: the panel's foreground and background swap across
1042/// the whole width of the row, including the space either side of the
1043/// label.
1044///
1045/// Inversion survives everything these panels do badly. It needs no color,
1046/// no second font, and no glyph column stolen from a row that is already
1047/// narrow, and it is legible on a monochrome OLED at a glance and on a
1048/// bistable panel with no backlight. Filling the band and *then* drawing
1049/// the glyphs on their own inverted background is what makes it one solid
1050/// bar rather than a row of boxed letters.
1051fn draw_row_inverted<D>(target: &mut D, layout: &Layout, row: usize, text: &str)
1052where
1053    D: DrawTarget<Color = BinaryColor>,
1054{
1055    if row >= layout.rows {
1056        return;
1057    }
1058    let band = layout.row_rect(row);
1059    let _ = band
1060        .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
1061        .draw(target);
1062
1063    let room = layout.size.width.saturating_sub(layout.left.max(0) as u32);
1064    let text = clip(layout, text, room);
1065    if text.is_empty() {
1066        return;
1067    }
1068    let style = MonoTextStyleBuilder::new()
1069        .font(layout.font)
1070        .text_color(BinaryColor::Off)
1071        .background_color(BinaryColor::On)
1072        .build();
1073    let _ = Text::with_baseline(
1074        text,
1075        Point::new(layout.left, layout.row_top(row)),
1076        style,
1077        Baseline::Top,
1078    )
1079    .draw(target);
1080}
1081
1082/// Draw a row highlighted or plain, so callers stop repeating the choice.
1083fn draw_row_selectable<D>(target: &mut D, layout: &Layout, row: usize, text: &str, selected: bool)
1084where
1085    D: DrawTarget<Color = BinaryColor>,
1086{
1087    if selected {
1088        draw_row_inverted(target, layout, row, text);
1089    } else {
1090        draw_row(target, layout, row, text);
1091    }
1092}
1093
1094/// Park the gesture hints against the bottom of the panel, dropping them
1095/// from the front when the page has left fewer rows than there are hints.
1096///
1097/// This is what lets one renderer serve both panel shapes: the five-row
1098/// OLED silently loses both hints on the crowded status page and keeps
1099/// the last one on the confirmation page, while the seven-row e-paper has
1100/// room for both everywhere.
1101fn draw_hints<D>(target: &mut D, layout: &Layout, content_end: usize, hints: &[&str])
1102where
1103    D: DrawTarget<Color = BinaryColor>,
1104{
1105    let shown = hints.len().min(layout.rows.saturating_sub(content_end));
1106    let first_row = layout.rows - shown;
1107    for (offset, hint) in hints[hints.len() - shown..].iter().enumerate() {
1108        draw_row(target, layout, first_row + offset, hint);
1109    }
1110}
1111
1112fn draw_bolt<D>(target: &mut D, top_left: Point, size: Size, style: PrimitiveStyle<BinaryColor>)
1113where
1114    D: DrawTarget<Color = BinaryColor>,
1115{
1116    let (x, y) = (top_left.x, top_left.y);
1117    let (w, h) = (size.width as i32, size.height as i32);
1118    // Two overlapping wedges: the upper one falls left, the lower one
1119    // rises right, and the rows they share join them into one stroke.
1120    let upper = Triangle::new(
1121        Point::new(x + w * 2 / 3, y),
1122        Point::new(x, y + h * 3 / 5),
1123        Point::new(x + w * 2 / 3, y + h * 3 / 5),
1124    );
1125    let lower = Triangle::new(
1126        Point::new(x + w / 3, y + h - 1),
1127        Point::new(x + w - 1, y + h * 2 / 5),
1128        Point::new(x + w / 3, y + h * 2 / 5),
1129    );
1130    let _ = upper.into_styled(style).draw(target);
1131    let _ = lower.into_styled(style).draw(target);
1132}
1133
1134// ─── Strings ─────────────────────────────────────────────────────────────────
1135
1136/// The entry's name, with no cursor decoration — the highlight is the
1137/// inversion, not a prefix.
1138///
1139/// Back reads as the way out of the level it sits in rather than naming
1140/// the destination, because that is the word the user is looking for.
1141const fn menu_label(item: MenuItem) -> &'static str {
1142    match item {
1143        MenuItem::Status => "Status",
1144        MenuItem::Identity => "Identity",
1145        MenuItem::Settings => "Settings",
1146        MenuItem::SettingsBack
1147        | MenuItem::BluetoothBack
1148        | MenuItem::GnssBack
1149        | MenuItem::RadioBack => "Back",
1150        MenuItem::Bluetooth => "Bluetooth",
1151        MenuItem::Gnss => "GNSS",
1152        MenuItem::Radio => "Radio",
1153        MenuItem::BluetoothToggle => "Bluetooth",
1154        MenuItem::StartPairing => "Start pairing",
1155        MenuItem::ClearBonds => "Clear bonds",
1156        MenuItem::GnssToggle => "GNSS",
1157        MenuItem::ShareLocation => "Share location",
1158        MenuItem::Forwarding => "Forwarding",
1159        MenuItem::Stats => "Statistics",
1160    }
1161}
1162
1163/// How this board says "move to the next entry".
1164const fn move_hint(controls: Controls) -> &'static str {
1165    match controls {
1166        Controls::OneButton => "1x: next",
1167        Controls::Dpad => "up/dn: move",
1168    }
1169}
1170
1171/// What a Select would do from this entry, or `None` where it would do
1172/// nothing and the hint would be a lie.
1173///
1174/// The verb comes from the entry and the gesture from the hardware, so
1175/// the two tables below say the same things in each board's own words.
1176/// A reading entry is the one whose verb depends on where it sits: at the
1177/// top level it is already the whole screen and Select has nothing left
1178/// to do, while below the top it is a row that Select opens.
1179const fn select_hint(controls: Controls, item: MenuItem) -> Option<&'static str> {
1180    let reading_opens = matches!(item.kind(), EntryKind::Reading(_)) && !item.reads_in_place();
1181    match (controls, item.kind()) {
1182        (Controls::OneButton, _) if reading_opens => Some("2x: open"),
1183        (Controls::Dpad, _) if reading_opens => Some("OK: open"),
1184        (_, EntryKind::Reading(None)) => None,
1185        (Controls::OneButton, kind) => Some(match kind {
1186            EntryKind::Reading(Some(UiEffect::CheckIn)) => "2x: check in",
1187            EntryKind::Submenu(_) => "2x: open",
1188            EntryKind::Back => "2x: back",
1189            EntryKind::Toggle(_) => "2x: toggle",
1190            _ => "2x: select",
1191        }),
1192        (Controls::Dpad, kind) => Some(match kind {
1193            EntryKind::Reading(Some(UiEffect::CheckIn)) => "OK: check in",
1194            EntryKind::Submenu(_) => "OK: open",
1195            EntryKind::Back => "OK: back",
1196            EntryKind::Toggle(_) => "OK: toggle",
1197            _ => "OK: select",
1198        }),
1199    }
1200}
1201
1202/// The state a toggle entry reports, drawn after its name.
1203///
1204/// A board that cannot say draws nothing rather than guessing, which is
1205/// why this returns an empty string rather than "off".
1206fn toggle_label(item: MenuItem, settings: &SettingsModel) -> &'static str {
1207    let value = match item.kind() {
1208        EntryKind::Toggle(ToggleId::Bluetooth) => settings.bluetooth,
1209        EntryKind::Toggle(ToggleId::Gnss) => settings.gnss,
1210        EntryKind::Toggle(ToggleId::ShareLocation) => settings.share_location,
1211        EntryKind::Toggle(ToggleId::Forwarding) => settings.forwarding,
1212        _ => return "",
1213    };
1214    match value {
1215        Some(true) => "on",
1216        Some(false) => "off",
1217        None => "",
1218    }
1219}
1220
1221const fn notice_label(notice: UiNotice) -> &'static str {
1222    match notice {
1223        UiNotice::CheckInRequested => "checking in...",
1224        UiNotice::PairingStarted => "pairing started",
1225        UiNotice::PairingUnavailable => "pair unavailable",
1226        UiNotice::BondsCleared => "bonds cleared",
1227        UiNotice::ClearFailed => "CLEAR FAILED",
1228        UiNotice::ToggleUnavailable => "not available",
1229    }
1230}
1231
1232/// The link line, or `None` when there is nothing worth a row.
1233///
1234/// Advertising is what a tracker does whenever nobody is talking to it, so
1235/// announcing it says only that the device is behaving normally. What
1236/// earns a row is a host actually being on the other end, or advertising
1237/// being suppressed — the case where a user looking for the device on a
1238/// phone would otherwise be left wondering.
1239const fn link_label(link: LinkState) -> Option<&'static str> {
1240    match link {
1241        LinkState::Attached => Some("host attached"),
1242        LinkState::Connected => Some("host connected"),
1243        LinkState::OffWired => Some("off (wired)"),
1244        LinkState::Disabled => Some("off"),
1245        LinkState::Advertising => None,
1246    }
1247}
1248
1249/// Write the pairing line, reporting whether it wrote anything.
1250///
1251/// A closed window is the resting state of every tracker; saying so costs
1252/// a row to report that nothing is happening.
1253fn write_pairing(line: &mut String<LINE>, pairing: PairingState) -> bool {
1254    let _ = match pairing {
1255        PairingState::LockedOut => write!(line, "PAIR LOCKED"),
1256        PairingState::Open { pin: Some(pin) } => write!(line, "PIN {pin:06}"),
1257        PairingState::Open { pin: None } => write!(line, "pairing (no PIN)"),
1258        PairingState::Closed => return false,
1259    };
1260    true
1261}
1262
1263fn write_clear_question(line: &mut String<LINE>, bonds: u8) {
1264    let _ = match bonds {
1265        0 => write!(line, "No bonds to clear"),
1266        1 => write!(line, "Clear 1 bond?"),
1267        n => write!(line, "Clear {n} bonds?"),
1268    };
1269}
1270
1271fn write_battery(line: &mut String<LINE>, status: &StatusModel<'_>) {
1272    let Some(mv) = status.battery_mv else {
1273        let _ = write!(line, "batt --");
1274        return;
1275    };
1276    let _ = write!(line, "batt {mv} mV");
1277    match status.battery.level_percent {
1278        Some(level) => {
1279            let _ = write!(line, " {level}%");
1280        }
1281        // Charging with no level is a known state, not a stalled reading,
1282        // so the row says which one it is rather than trailing off.
1283        None if status.battery.shows_bolt() => {
1284            let _ = write!(line, " chg");
1285        }
1286        None => {}
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293    use crate::menu::{Level, MenuItems, UiInput};
1294
1295    /// Widest panel in the class, bit-packed: 200 × 200 costs 5 kB, which
1296    /// a test can keep several of without thinking about it.
1297    const TEST_PANEL_BYTES: usize = 200 * 200 / 8;
1298
1299    /// A plain bitmap `DrawTarget` so the tests can ask what actually
1300    /// landed on the glass rather than trusting the call sequence.
1301    struct TestPanel {
1302        size: Size,
1303        pixels: [u8; TEST_PANEL_BYTES],
1304    }
1305
1306    impl TestPanel {
1307        fn new(size: Size) -> Self {
1308            assert!((size.width * size.height) as usize <= TEST_PANEL_BYTES * 8);
1309            Self {
1310                size,
1311                pixels: [0; TEST_PANEL_BYTES],
1312            }
1313        }
1314
1315        fn lit(&self, x: u32, y: u32) -> bool {
1316            let bit = y * self.size.width + x;
1317            self.pixels[(bit / 8) as usize] & (1 << (bit % 8)) != 0
1318        }
1319
1320        /// How many pixels are lit inside `area`.
1321        fn lit_in(&self, area: Rectangle) -> usize {
1322            let mut count = 0;
1323            for y in area.top_left.y..area.top_left.y + area.size.height as i32 {
1324                for x in area.top_left.x..area.top_left.x + area.size.width as i32 {
1325                    if x >= 0
1326                        && y >= 0
1327                        && (x as u32) < self.size.width
1328                        && (y as u32) < self.size.height
1329                        && self.lit(x as u32, y as u32)
1330                    {
1331                        count += 1;
1332                    }
1333                }
1334            }
1335            count
1336        }
1337    }
1338
1339    impl OriginDimensions for TestPanel {
1340        fn size(&self) -> Size {
1341            self.size
1342        }
1343    }
1344
1345    impl DrawTarget for TestPanel {
1346        type Color = BinaryColor;
1347        type Error = core::convert::Infallible;
1348
1349        fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
1350        where
1351            I: IntoIterator<Item = Pixel<BinaryColor>>,
1352        {
1353            for Pixel(Point { x, y }, color) in pixels {
1354                // Anything drawn off-panel is a layout bug, not something
1355                // to silently absorb the way a real driver would.
1356                assert!(
1357                    x >= 0
1358                        && y >= 0
1359                        && (x as u32) < self.size.width
1360                        && (y as u32) < self.size.height,
1361                    "drew outside the panel at ({x}, {y}) on {:?}",
1362                    self.size
1363                );
1364                let bit = y as u32 * self.size.width + x as u32;
1365                let mask = 1 << (bit % 8);
1366                if color.is_on() {
1367                    self.pixels[(bit / 8) as usize] |= mask;
1368                } else {
1369                    self.pixels[(bit / 8) as usize] &= !mask;
1370                }
1371            }
1372            Ok(())
1373        }
1374    }
1375
1376    fn demo_status() -> StatusModel<'static> {
1377        StatusModel {
1378            device_name: "umsh-tracker",
1379            battery: BatteryIndicator {
1380                level_percent: Some(75),
1381                charge: Some(ChargeClass::Discharging),
1382            },
1383            battery_mv: Some(3_950),
1384            link: LinkState::Advertising,
1385            bonds: 1,
1386            pairing: PairingState::Closed,
1387            stats: StatsModel {
1388                tx_frames: 12,
1389                rx_frames: 340,
1390                rx_accepted: 300,
1391                forwarded: 7,
1392                tx_power_dbm: Some(22),
1393                duty_permille: 4,
1394            },
1395            // The device does not know what time it is, which is the
1396            // state every panel must render as no clock at all.
1397            clock: None,
1398            settings: SettingsModel {
1399                bluetooth: Some(true),
1400                gnss: Some(false),
1401                share_location: Some(false),
1402                forwarding: Some(true),
1403            },
1404            identity: Some(IdentityModel {
1405                hint: "7bQ*",
1406                address: "1BvYtT4nCJmqvKGpZbW8XdRfLhNs2eQaUxAyDzMr6HkP",
1407            }),
1408        }
1409    }
1410
1411    fn layouts() -> [Layout; 2] {
1412        [Layout::OLED_128X64, Layout::EPD_200X200]
1413    }
1414
1415    /// Walk to `item` within the level it lives in.
1416    fn walk_to(model: &mut UiModel, item: MenuItem) {
1417        for _ in 0..MenuItem::ALL.len() + 1 {
1418            if model.page() == Page::Menu(item) {
1419                return;
1420            }
1421            model.apply(UiInput::Forward);
1422        }
1423        panic!("never reached {item:?}");
1424    }
1425
1426    /// Descend from home into `item`'s level and highlight it.
1427    fn navigate_to(model: &mut UiModel, item: MenuItem) {
1428        let level = item.level();
1429        if level != Level::Top {
1430            walk_to(model, MenuItem::Settings);
1431            model.apply(UiInput::Select);
1432            if let Some(opener) = level.opened_by() {
1433                if opener.level() != Level::Top {
1434                    walk_to(model, opener);
1435                    model.apply(UiInput::Select);
1436                }
1437            }
1438        }
1439        walk_to(model, item);
1440    }
1441
1442    #[test]
1443    fn segments_center_each_bar_count_on_the_level_it_depicts() {
1444        assert_eq!(battery_segments(0), 0);
1445        assert_eq!(battery_segments(14), 0);
1446        assert_eq!(battery_segments(15), 1);
1447        assert_eq!(battery_segments(36), 1);
1448        assert_eq!(battery_segments(37), 2);
1449        assert_eq!(battery_segments(62), 2);
1450        assert_eq!(battery_segments(63), 3);
1451        assert_eq!(battery_segments(84), 3);
1452        assert_eq!(battery_segments(85), 4);
1453        assert_eq!(battery_segments(100), 4);
1454        // Clamped rather than wrapped, so a bad sample cannot overdraw.
1455        assert_eq!(battery_segments(200), 4);
1456
1457        // Just over half draws half a body, not three quarters of one.
1458        assert_eq!(battery_segments(55), 2);
1459
1460        // Never falls as the level rises, and never overdraws.
1461        let mut previous = 0;
1462        for level in 0..=255u8 {
1463            let bars = battery_segments(level);
1464            assert!(bars >= previous, "{level} % lost a bar");
1465            assert!(bars <= BATTERY_SEGMENTS, "{level} % overdrew");
1466            previous = bars;
1467        }
1468    }
1469
1470    /// The requirement that started this: a battery reading on every
1471    /// frame the user can be looking at, on every board.
1472    #[test]
1473    fn every_frame_kind_carries_the_battery_indicator() {
1474        for layout in layouts() {
1475            let zone = layout.battery_zone();
1476            let mut model = UiModel::new(MenuItems::all());
1477
1478            let mut panel = TestPanel::new(layout.size);
1479            render_frame(&mut panel, &layout, &model, &demo_status());
1480            assert!(panel.lit_in(zone) > 0, "menu frame lost the battery");
1481
1482            let mut panel = TestPanel::new(layout.size);
1483            render_message(
1484                &mut panel,
1485                &layout,
1486                &demo_status(),
1487                "Clearing",
1488                "bonds + PIN...",
1489            );
1490            assert!(panel.lit_in(zone) > 0, "message frame lost the battery");
1491
1492            // Walk to the destructive item and open its confirmation.
1493            navigate_to(&mut model, MenuItem::ClearBonds);
1494            model.apply(UiInput::Select);
1495            assert!(matches!(model.page(), Page::Confirm { .. }));
1496            let mut panel = TestPanel::new(layout.size);
1497            render_frame(&mut panel, &layout, &model, &demo_status());
1498            assert!(panel.lit_in(zone) > 0, "confirm frame lost the battery");
1499
1500            // And the reading page a submenu entry opens.
1501            let mut model = UiModel::new(MenuItems::all());
1502            navigate_to(&mut model, MenuItem::Stats);
1503            model.apply(UiInput::Select);
1504            assert!(matches!(model.page(), Page::Detail(_)));
1505            let mut panel = TestPanel::new(layout.size);
1506            render_frame(&mut panel, &layout, &model, &demo_status());
1507            assert!(panel.lit_in(zone) > 0, "detail frame lost the battery");
1508        }
1509    }
1510
1511    /// Whether the panel renders `text` as one of its rows.
1512    ///
1513    /// Draws the row alone on a reference panel and checks every lit
1514    /// pixel of it is also lit on `panel` — the closest a bitmap target
1515    /// gets to reading text back off the glass.
1516    fn shows_row(panel: &TestPanel, layout: &Layout, text: &str) -> bool {
1517        (1..layout.rows).any(|row| {
1518            let mut reference = TestPanel::new(layout.size);
1519            draw_row(&mut reference, layout, row, text);
1520            let top = layout.row_top(row);
1521            let bottom = top + layout.font.character_size.height as i32;
1522            let mut any = false;
1523            for y in top..bottom {
1524                for x in 0..layout.size.width {
1525                    if reference.lit(x, y as u32) {
1526                        any = true;
1527                        if !panel.lit(x, y as u32) {
1528                            return false;
1529                        }
1530                    }
1531                }
1532            }
1533            any
1534        })
1535    }
1536
1537    /// The requirement this whole feature is conditioned on: a device
1538    /// that does not know what time it is shows **nothing** about the
1539    /// time — not a placeholder, not zeros, not dashes, and not a row
1540    /// saying it does not know.
1541    #[test]
1542    fn an_unknown_time_draws_no_clock_at_all() {
1543        for layout in layouts() {
1544            let mut status = demo_status();
1545            status.pairing = PairingState::Closed;
1546
1547            let mut known = TestPanel::new(layout.size);
1548            status.clock = Some(ClockModel {
1549                hour: 23,
1550                minute: 5,
1551            });
1552            render_frame(
1553                &mut known,
1554                &layout,
1555                &UiModel::new(MenuItems::all()),
1556                &status,
1557            );
1558            assert!(
1559                shows_row(&known, &layout, "time 23:05"),
1560                "the reference case drew no clock, so the negative proves nothing"
1561            );
1562
1563            let mut unknown = TestPanel::new(layout.size);
1564            status.clock = None;
1565            render_frame(
1566                &mut unknown,
1567                &layout,
1568                &UiModel::new(MenuItems::all()),
1569                &status,
1570            );
1571            assert!(
1572                !shows_row(&unknown, &layout, "time 23:05"),
1573                "a device that does not know the time indicated one"
1574            );
1575            // The row is absent rather than blanked, so nothing about the
1576            // time is left on the panel at all.
1577            for label in ["time --:--", "time 00:00", "time"] {
1578                assert!(
1579                    !shows_row(&unknown, &layout, label),
1580                    "an unset clock rendered {label:?}"
1581                );
1582            }
1583        }
1584    }
1585
1586    #[test]
1587    fn a_known_time_draws_a_clock_row_on_the_status_page() {
1588        for layout in layouts() {
1589            let mut status = demo_status();
1590            // A quiet device, so the status page has room for every row
1591            // it wants; the crowding behavior has its own test.
1592            status.pairing = PairingState::Closed;
1593
1594            for (clock, expected) in [
1595                (
1596                    ClockModel {
1597                        hour: 23,
1598                        minute: 5,
1599                    },
1600                    "time 23:05",
1601                ),
1602                // Midnight is a real reading, not an absent one: 00:00
1603                // must draw, or the minute a day it is midnight would
1604                // look like a device that has forgotten the time.
1605                (ClockModel { hour: 0, minute: 0 }, "time 00:00"),
1606            ] {
1607                status.clock = Some(clock);
1608                let mut panel = TestPanel::new(layout.size);
1609                render_frame(
1610                    &mut panel,
1611                    &layout,
1612                    &UiModel::new(MenuItems::all()),
1613                    &status,
1614                );
1615                assert!(
1616                    shows_row(&panel, &layout, expected),
1617                    "the status page lost {expected:?}"
1618                );
1619            }
1620        }
1621    }
1622
1623    /// The clock lives in the body, so it takes nothing from the header —
1624    /// a long device name reads exactly as far as it did before there was
1625    /// a clock at all.
1626    #[test]
1627    fn the_clock_costs_the_device_name_nothing() {
1628        for layout in layouts() {
1629            let header = Rectangle::new(
1630                Point::new(0, layout.row_top(0)),
1631                Size::new(layout.size.width, layout.font.character_size.height),
1632            );
1633            let mut status = demo_status();
1634            status.device_name = "a-very-long-device-name-indeed";
1635
1636            let mut without = TestPanel::new(layout.size);
1637            status.clock = None;
1638            render_frame(
1639                &mut without,
1640                &layout,
1641                &UiModel::new(MenuItems::all()),
1642                &status,
1643            );
1644
1645            let mut with = TestPanel::new(layout.size);
1646            status.clock = Some(ClockModel {
1647                hour: 14,
1648                minute: 30,
1649            });
1650            render_frame(&mut with, &layout, &UiModel::new(MenuItems::all()), &status);
1651
1652            assert!(without.lit_in(header) > 0, "the name drew nothing");
1653            assert_eq!(
1654                with.lit_in(header),
1655                without.lit_in(header),
1656                "the clock moved the header"
1657            );
1658        }
1659    }
1660
1661    /// On a panel that has run out of rows the clock is what falls off,
1662    /// never the battery: how much charge is left is a fact somebody is
1663    /// deciding something with, and what time it is is not.
1664    #[test]
1665    fn a_crowded_status_page_drops_the_clock_before_the_battery() {
1666        // The five-row OLED with every optional row asking for space.
1667        let layout = Layout::OLED_128X64;
1668        let mut status = demo_status();
1669        status.pairing = PairingState::Open { pin: Some(123_456) };
1670        status.link = LinkState::Attached;
1671        status.clock = Some(ClockModel {
1672            hour: 14,
1673            minute: 30,
1674        });
1675
1676        let mut panel = TestPanel::new(layout.size);
1677        render_frame(
1678            &mut panel,
1679            &layout,
1680            &UiModel::new(MenuItems::all()),
1681            &status,
1682        );
1683        assert!(
1684            shows_row(&panel, &layout, "batt 3950 mV 75%"),
1685            "the battery row was displaced"
1686        );
1687        assert!(
1688            !shows_row(&panel, &layout, "time 14:30"),
1689            "the clock survived a page with no room for it"
1690        );
1691    }
1692
1693    #[test]
1694    fn fill_grows_with_the_level_and_an_unknown_level_draws_nothing() {
1695        for layout in layouts() {
1696            let zone = layout.battery_zone();
1697            let mut previous = 0;
1698            // One level from each of the five bands, lowest first.
1699            for level in [5, 25, 50, 75, 100] {
1700                let mut panel = TestPanel::new(layout.size);
1701                let mut status = demo_status();
1702                status.battery.level_percent = Some(level);
1703                render_frame(
1704                    &mut panel,
1705                    &layout,
1706                    &UiModel::new(MenuItems::all()),
1707                    &status,
1708                );
1709                let lit = panel.lit_in(zone);
1710                assert!(lit > previous, "level {level} did not add fill");
1711                previous = lit;
1712            }
1713
1714            // An empty body means a flat pack, and only that. "No
1715            // reading" is said by drawing no indicator at all.
1716            let mut flat = TestPanel::new(layout.size);
1717            let mut status = demo_status();
1718            status.battery.level_percent = Some(0);
1719            render_frame(&mut flat, &layout, &UiModel::new(MenuItems::all()), &status);
1720            assert!(flat.lit_in(zone) > 0, "a flat pack drew no body at all");
1721
1722            let mut unknown = TestPanel::new(layout.size);
1723            let mut status = demo_status();
1724            status.battery = BatteryIndicator::UNKNOWN;
1725            render_frame(
1726                &mut unknown,
1727                &layout,
1728                &UiModel::new(MenuItems::all()),
1729                &status,
1730            );
1731            assert_eq!(
1732                unknown.lit_in(zone),
1733                0,
1734                "an unknown level drew something in the zone"
1735            );
1736        }
1737    }
1738
1739    /// The bolt has its own reserved column, so a charger going in must
1740    /// not shift the body — on the e-paper that is the difference between
1741    /// re-inking a bolt and re-inking the whole header.
1742    #[test]
1743    fn charging_adds_a_bolt_without_moving_the_body() {
1744        for layout in layouts() {
1745            let zone = layout.battery_zone();
1746            let body = Rectangle::new(
1747                Point::new(
1748                    zone.top_left.x + (layout.battery.bolt_width + layout.battery.spacing) as i32,
1749                    zone.top_left.y,
1750                ),
1751                Size::new(
1752                    layout.battery.body.width + layout.battery.nub.width,
1753                    layout.battery.body.height,
1754                ),
1755            );
1756            let bolt = Rectangle::new(
1757                zone.top_left,
1758                Size::new(layout.battery.bolt_width, zone.size.height),
1759            );
1760
1761            let mut idle = TestPanel::new(layout.size);
1762            let mut status = demo_status();
1763            status.battery.charge = Some(ChargeClass::Discharging);
1764            render_frame(&mut idle, &layout, &UiModel::new(MenuItems::all()), &status);
1765
1766            let mut charging = TestPanel::new(layout.size);
1767            let mut status = demo_status();
1768            status.battery.charge = Some(ChargeClass::Charging);
1769            render_frame(
1770                &mut charging,
1771                &layout,
1772                &UiModel::new(MenuItems::all()),
1773                &status,
1774            );
1775
1776            assert_eq!(
1777                idle.lit_in(bolt),
1778                0,
1779                "a discharging pack drew something in the bolt slot"
1780            );
1781            assert!(charging.lit_in(bolt) > 0, "charging drew no bolt");
1782            assert_eq!(
1783                idle.lit_in(body),
1784                charging.lit_in(body),
1785                "the body moved when the charger went in"
1786            );
1787        }
1788    }
1789
1790    /// The indicator must stay inside the rectangle the layout reserved
1791    /// for it — that rectangle is what the header blanks before drawing,
1792    /// and anything spilling out of it lands on top of the device name.
1793    #[test]
1794    fn the_indicator_stays_inside_its_zone() {
1795        for layout in layouts() {
1796            let zone = layout.battery_zone();
1797            for charge in [
1798                None,
1799                Some(ChargeClass::Discharging),
1800                Some(ChargeClass::Charging),
1801                Some(ChargeClass::Charged),
1802            ] {
1803                for level in [None, Some(0), Some(1), Some(50), Some(100)] {
1804                    let mut panel = TestPanel::new(layout.size);
1805                    draw_battery_icon(
1806                        &mut panel,
1807                        zone.top_left,
1808                        &layout.battery,
1809                        &BatteryIndicator {
1810                            level_percent: level,
1811                            charge,
1812                        },
1813                    );
1814                    let whole = Rectangle::new(Point::zero(), layout.size);
1815                    assert_eq!(
1816                        panel.lit_in(whole),
1817                        panel.lit_in(zone),
1818                        "{charge:?}/{level:?} drew outside the reserved zone on {:?}",
1819                        layout.size
1820                    );
1821                }
1822            }
1823        }
1824    }
1825
1826    /// A board with no charger telemetry says nothing rather than
1827    /// claiming the pack is discharging.
1828    #[test]
1829    fn unknown_charge_state_draws_no_bolt() {
1830        let layout = Layout::OLED_128X64;
1831        let zone = layout.battery_zone();
1832        let bolt = Rectangle::new(
1833            zone.top_left,
1834            Size::new(layout.battery.bolt_width, zone.size.height),
1835        );
1836        let mut panel = TestPanel::new(layout.size);
1837        let mut status = demo_status();
1838        status.battery.charge = None;
1839        render_frame(
1840            &mut panel,
1841            &layout,
1842            &UiModel::new(MenuItems::all()),
1843            &status,
1844        );
1845        assert_eq!(panel.lit_in(bolt), 0);
1846    }
1847
1848    /// A name long enough to run under the indicator must lose, not
1849    /// smear into it.
1850    #[test]
1851    fn an_overlong_device_name_never_reaches_the_indicator() {
1852        for layout in layouts() {
1853            let zone = layout.battery_zone();
1854            let mut panel = TestPanel::new(layout.size);
1855            let mut status = demo_status();
1856            status.device_name = "a-very-long-device-name-that-runs-off-the-panel";
1857            status.battery = BatteryIndicator::UNKNOWN;
1858            render_frame(
1859                &mut panel,
1860                &layout,
1861                &UiModel::new(MenuItems::all()),
1862                &status,
1863            );
1864
1865            // Whatever is in the zone is the empty body and nothing else.
1866            let mut bare = TestPanel::new(layout.size);
1867            let mut short = status;
1868            short.device_name = "x";
1869            render_frame(&mut bare, &layout, &UiModel::new(MenuItems::all()), &short);
1870            assert_eq!(panel.lit_in(zone), bare.lit_in(zone));
1871        }
1872    }
1873
1874    /// A resting device says one thing — its battery — and the rows that
1875    /// frees are exactly what the five-row panel needed for its gesture
1876    /// hints. This is the payoff for dropping the nominal-state rows.
1877    #[test]
1878    fn a_nominal_status_page_leaves_room_for_the_hints() {
1879        let model = UiModel::new(MenuItems::all());
1880        let status = demo_status();
1881        assert_eq!(status.pairing, PairingState::Closed);
1882        assert_eq!(status.link, LinkState::Advertising);
1883
1884        let oled = Layout::OLED_128X64;
1885        let mut panel = TestPanel::new(oled.size);
1886        render_frame(&mut panel, &oled, &model, &status);
1887        // Row 2 is the battery line; rows 3 and 4 are the two hints.
1888        for row in 2..oled.rows {
1889            assert!(panel.lit_in(row_area(&oled, row)) > 0, "row {row} is blank");
1890        }
1891
1892        let epd = Layout::EPD_200X200;
1893        let mut panel = TestPanel::new(epd.size);
1894        render_frame(&mut panel, &epd, &model, &status);
1895        // Hints bottom-align, so the taller panel leaves the gap in the
1896        // middle rather than trailing empty rows under the text.
1897        assert!(panel.lit_in(row_area(&epd, 2)) > 0);
1898        assert!(panel.lit_in(row_area(&epd, 5)) > 0);
1899        assert!(panel.lit_in(row_area(&epd, 6)) > 0);
1900    }
1901
1902    /// A hint that names a gesture the board does not have is worse than
1903    /// no hint: every string has to be in the vocabulary of the hardware
1904    /// it is drawn on, and has to fit the narrowest panel in the class.
1905    #[test]
1906    fn each_control_set_is_hinted_in_its_own_words() {
1907        // 128 px of FONT_6X10.
1908        let budget = (Layout::OLED_128X64.size.width / 6) as usize;
1909        for controls in [Controls::OneButton, Controls::Dpad] {
1910            let clicks = controls == Controls::OneButton;
1911            let mut hints: heapless::Vec<&str, 24> = heapless::Vec::new();
1912            hints.push(move_hint(controls)).unwrap();
1913            for item in MenuItem::ALL {
1914                // Which entries answer a Select is a property of the
1915                // entry, so the two vocabularies must agree about it.
1916                assert_eq!(
1917                    select_hint(controls, item).is_some(),
1918                    select_hint(Controls::OneButton, item).is_some(),
1919                    "{controls:?} disagrees about {item:?}"
1920                );
1921                if let Some(hint) = select_hint(controls, item) {
1922                    let _ = hints.push(hint);
1923                }
1924            }
1925            for hint in hints {
1926                assert_eq!(
1927                    hint.contains("1x") || hint.contains("2x"),
1928                    clicks,
1929                    "{controls:?} hint {hint:?} counts clicks"
1930                );
1931                assert!(hint.len() <= budget, "{hint:?} does not fit a 128px row");
1932            }
1933        }
1934    }
1935
1936    /// The rows a resting device is not spending: neither a closed
1937    /// pairing window nor plain advertising may put anything on screen.
1938    #[test]
1939    fn nominal_state_costs_no_rows() {
1940        for layout in layouts() {
1941            let model = UiModel::new(MenuItems::all());
1942            let mut nominal = demo_status();
1943            nominal.pairing = PairingState::Closed;
1944            nominal.link = LinkState::Advertising;
1945            let mut quiet = TestPanel::new(layout.size);
1946            render_frame(&mut quiet, &layout, &model, &nominal);
1947
1948            for (label, busy) in [
1949                ("pairing", PairingState::Open { pin: Some(123_456) }),
1950                ("lockout", PairingState::LockedOut),
1951            ] {
1952                let mut status = nominal;
1953                status.pairing = busy;
1954                let mut panel = TestPanel::new(layout.size);
1955                render_frame(&mut panel, &layout, &model, &status);
1956                assert!(
1957                    panel.lit_in(row_area(&layout, 2)) != quiet.lit_in(row_area(&layout, 2)),
1958                    "{label} did not claim a row on {:?}",
1959                    layout.size
1960                );
1961            }
1962
1963            for link in [
1964                LinkState::Attached,
1965                LinkState::Connected,
1966                LinkState::OffWired,
1967            ] {
1968                let mut status = nominal;
1969                status.link = link;
1970                let mut panel = TestPanel::new(layout.size);
1971                render_frame(&mut panel, &layout, &model, &status);
1972                assert!(
1973                    panel.lit_in(row_area(&layout, 2)) != quiet.lit_in(row_area(&layout, 2)),
1974                    "{link:?} did not claim a row on {:?}",
1975                    layout.size
1976                );
1977            }
1978        }
1979    }
1980
1981    /// The bond count moved to where it changes a decision.
1982    #[test]
1983    fn the_confirmation_names_how_many_bonds_it_would_destroy() {
1984        let layout = Layout::EPD_200X200;
1985        let mut counts = [0usize; 3];
1986        for (index, bonds) in [0u8, 1, 4].iter().enumerate() {
1987            let mut model = UiModel::new(MenuItems::all());
1988            navigate_to(&mut model, MenuItem::ClearBonds);
1989            model.apply(UiInput::Select);
1990            assert!(matches!(model.page(), Page::Confirm { .. }));
1991
1992            let mut status = demo_status();
1993            status.bonds = *bonds;
1994            let mut panel = TestPanel::new(layout.size);
1995            render_frame(&mut panel, &layout, &model, &status);
1996            counts[index] = panel.lit_in(row_area(&layout, 1));
1997        }
1998        assert_ne!(counts[0], counts[1]);
1999        assert_ne!(counts[1], counts[2]);
2000    }
2001
2002    /// The stats page renders three populated rows on every layout — a
2003    /// deaf node has to be distinguishable from a busy one at a glance.
2004    #[test]
2005    fn the_stats_page_shows_its_counters() {
2006        for layout in layouts() {
2007            let mut model = UiModel::new(MenuItems::all());
2008            navigate_to(&mut model, MenuItem::Stats);
2009            // Below the top level, reading takes a Select.
2010            model.apply(UiInput::Select);
2011            assert_eq!(model.page(), Page::Detail(MenuItem::Stats));
2012
2013            let mut panel = TestPanel::new(layout.size);
2014            render_frame(&mut panel, &layout, &model, &demo_status());
2015            for row in 2..=4 {
2016                assert!(
2017                    panel.lit_in(row_area(&layout, row)) > 0,
2018                    "stats row {row} is blank on {:?}",
2019                    layout.size
2020                );
2021            }
2022        }
2023    }
2024
2025    /// Walking onto Statistics shows the Radio list with Statistics
2026    /// highlighted — not the statistics. Reading in place is the top
2027    /// level's exception, and this is the entry that used to break it.
2028    #[test]
2029    fn a_reading_entry_below_the_top_is_drawn_as_a_row() {
2030        for layout in layouts() {
2031            let mut model = UiModel::new(MenuItems::all());
2032            navigate_to(&mut model, MenuItem::Stats);
2033
2034            let mut panel = TestPanel::new(layout.size);
2035            render_frame(&mut panel, &layout, &model, &demo_status());
2036            assert!(
2037                shows_row(&panel, &layout, "Forwarding  on"),
2038                "the Radio list is not on screen at {:?}",
2039                layout.size
2040            );
2041            // The counters belong to the page a Select away.
2042            assert!(
2043                !shows_row(&panel, &layout, "tx 12  rx 340"),
2044                "the statistics leaked onto the list at {:?}",
2045                layout.size
2046            );
2047        }
2048    }
2049
2050    /// Every top-level entry takes the whole panel, Settings included:
2051    /// what it says is what the switches under it are set to.
2052    #[test]
2053    fn the_top_level_settings_page_says_only_its_name() {
2054        for layout in layouts() {
2055            let mut model = UiModel::new(MenuItems::all());
2056            walk_to(&mut model, MenuItem::Settings);
2057
2058            let mut panel = TestPanel::new(layout.size);
2059            render_frame(&mut panel, &layout, &model, &demo_status());
2060            // Nothing from the level behind it: rows of labels and states
2061            // here read as that list rather than as the way into it.
2062            for switch in ["Bluetooth  on", "GNSS  on", "Forwarding  on"] {
2063                assert!(
2064                    !shows_row(&panel, &layout, switch),
2065                    "{switch:?} leaked onto the doorway at {:?}",
2066                    layout.size
2067                );
2068            }
2069            // And no inverted bar, which is what made it look like a list
2070            // with its first row highlighted.
2071            let rows = inverted_rows(&layout, &model, &demo_status());
2072            assert!(rows.is_empty(), "inverted {rows:?} on {:?}", layout.size);
2073        }
2074    }
2075
2076    /// Selecting a non-status item frees rows 3 and 4 on the OLED, which
2077    /// is exactly where its two hints belong.
2078    #[test]
2079    fn a_sparse_page_gets_its_hints_back() {
2080        let mut model = UiModel::new(MenuItems::all());
2081        model.apply(UiInput::Forward);
2082        let layout = Layout::OLED_128X64;
2083        let mut panel = TestPanel::new(layout.size);
2084        render_frame(&mut panel, &layout, &model, &demo_status());
2085        assert!(panel.lit_in(row_area(&layout, 3)) > 0);
2086        assert!(panel.lit_in(row_area(&layout, 4)) > 0);
2087    }
2088
2089    fn row_area(layout: &Layout, row: usize) -> Rectangle {
2090        Rectangle::new(
2091            Point::new(0, layout.row_top(row)),
2092            Size::new(layout.size.width, layout.font.character_size.height),
2093        )
2094    }
2095
2096    /// Every page on every layout stays inside the panel — `TestPanel`
2097    /// asserts on any pixel that does not.
2098    #[test]
2099    fn no_page_draws_outside_the_panel() {
2100        for layout in layouts() {
2101            for pairing in [
2102                PairingState::LockedOut,
2103                PairingState::Open { pin: Some(123_456) },
2104                PairingState::Open { pin: None },
2105                PairingState::Closed,
2106            ] {
2107                for link in [
2108                    LinkState::Attached,
2109                    LinkState::Connected,
2110                    LinkState::Advertising,
2111                    LinkState::OffWired,
2112                ] {
2113                    let mut status = demo_status();
2114                    status.pairing = pairing;
2115                    status.link = link;
2116
2117                    // Every entry of every level, plus the confirmation
2118                    // each destructive one opens.
2119                    for item in MenuItem::ALL {
2120                        let mut model = UiModel::new(MenuItems::all());
2121                        navigate_to(&mut model, item);
2122                        let mut panel = TestPanel::new(layout.size);
2123                        render_frame(&mut panel, &layout, &model, &status);
2124
2125                        if item.requires_confirmation() {
2126                            model.apply(UiInput::Select);
2127                            // Both sides of the confirmation, since the
2128                            // highlight moves between them.
2129                            for _ in 0..2 {
2130                                assert!(matches!(model.page(), Page::Confirm { .. }));
2131                                let mut panel = TestPanel::new(layout.size);
2132                                render_frame(&mut panel, &layout, &model, &status);
2133                                model.apply(UiInput::Forward);
2134                            }
2135                        }
2136
2137                        // ...and the page every reading entry below the
2138                        // top level opens.
2139                        if !item.reads_in_place() && matches!(item.kind(), EntryKind::Reading(_)) {
2140                            model.apply(UiInput::Select);
2141                            assert!(matches!(model.page(), Page::Detail(_)));
2142                            let mut panel = TestPanel::new(layout.size);
2143                            render_frame(&mut panel, &layout, &model, &status);
2144                        }
2145                    }
2146
2147                    let mut panel = TestPanel::new(layout.size);
2148                    render_message(
2149                        &mut panel,
2150                        &layout,
2151                        &status,
2152                        "Locate alert",
2153                        "Press to stop",
2154                    );
2155                }
2156            }
2157        }
2158    }
2159
2160    /// What fraction of a row band is lit, in percent. An inverted row is
2161    /// nearly solid; an ordinary one is a scattering of glyph pixels.
2162    fn row_fill(panel: &TestPanel, layout: &Layout, row: usize) -> usize {
2163        let band = layout.row_rect(row);
2164        let area = (band.size.width * band.size.height) as usize;
2165        if area == 0 {
2166            return 0;
2167        }
2168        panel.lit_in(band) * 100 / area
2169    }
2170
2171    /// Which rows read as inverted, on a panel drawn from `model`.
2172    fn inverted_rows(
2173        layout: &Layout,
2174        model: &UiModel,
2175        status: &StatusModel<'_>,
2176    ) -> heapless::Vec<usize, 8> {
2177        let mut panel = TestPanel::new(layout.size);
2178        render_frame(&mut panel, layout, model, status);
2179        (0..layout.rows)
2180            .filter(|&row| row_fill(&panel, layout, row) > 50)
2181            .collect()
2182    }
2183
2184    /// The highlight is a solid bar across the whole row, not emphasized
2185    /// text — that is what makes it legible on a bistable panel with no
2186    /// backlight, and it is the contract Select is drawn against.
2187    ///
2188    /// Settings is the one frame without one. It is a doorway whose whole
2189    /// content is its own name, and a bar there had nothing under it to
2190    /// mark: it read as a list with its first row highlighted, which is
2191    /// exactly what the page is not.
2192    #[test]
2193    fn the_highlight_is_a_solid_bar_and_there_is_exactly_one() {
2194        for layout in layouts() {
2195            for item in MenuItem::ALL {
2196                let mut model = UiModel::new(MenuItems::all());
2197                navigate_to(&mut model, item);
2198                let rows = inverted_rows(&layout, &model, &demo_status());
2199                if item == MenuItem::Settings {
2200                    assert!(rows.is_empty(), "the doorway inverted {rows:?}");
2201                    continue;
2202                }
2203                assert_eq!(
2204                    rows.len(),
2205                    1,
2206                    "{item:?} inverted {rows:?} on {:?}",
2207                    layout.size
2208                );
2209                // Never the header, which is not a list entry.
2210                assert_ne!(rows[0], 0, "{item:?} inverted the header");
2211            }
2212        }
2213    }
2214
2215    /// The confirmation's two choices use the same inversion, and it
2216    /// follows the cursor rather than sitting on the destructive one.
2217    #[test]
2218    fn the_confirmation_inverts_whichever_choice_the_cursor_is_on() {
2219        for layout in layouts() {
2220            let mut model = UiModel::new(MenuItems::all());
2221            navigate_to(&mut model, MenuItem::ClearBonds);
2222            model.apply(UiInput::Select);
2223
2224            // Opens on Cancel, per the spec's default.
2225            let cancel = inverted_rows(&layout, &model, &demo_status());
2226            model.apply(UiInput::Forward);
2227            let clear = inverted_rows(&layout, &model, &demo_status());
2228
2229            assert_eq!(cancel.len(), 1, "confirmation lost its highlight");
2230            assert_eq!(clear.len(), 1, "confirmation lost its highlight");
2231            assert_ne!(cancel, clear, "the highlight did not move to CLEAR");
2232        }
2233    }
2234
2235    /// The address is what the screen is transcribed from, so every
2236    /// character of it reaches the panel. The 128×64 has exactly enough
2237    /// rows for the 44 characters and none to spare, so the hint yields
2238    /// its row there and keeps it on the 200×200.
2239    #[test]
2240    fn the_identity_page_shows_the_whole_address_or_none_of_it() {
2241        for layout in layouts() {
2242            let status = demo_status();
2243            let identity = status.identity.expect("fixture has an identity");
2244            let mut model = UiModel::new(MenuItems::all());
2245            navigate_to(&mut model, MenuItem::Identity);
2246            let mut panel = TestPanel::new(layout.size);
2247            render_frame(&mut panel, &layout, &model, &status);
2248
2249            // Every chunk the wrap produces, at the panel's own width.
2250            let room = layout.size.width.saturating_sub(layout.left.max(0) as u32);
2251            let per_row = clip(&layout, identity.address, room).chars().count();
2252            let mut rest = identity.address;
2253            while !rest.is_empty() {
2254                let end = rest
2255                    .char_indices()
2256                    .nth(per_row)
2257                    .map_or(rest.len(), |(at, _)| at);
2258                let (chunk, remainder) = rest.split_at(end);
2259                assert!(
2260                    shows_row(&panel, &layout, chunk),
2261                    "{:?} lost {chunk:?} off the address",
2262                    layout.size
2263                );
2264                rest = remainder;
2265            }
2266        }
2267
2268        // And a panel with room for both keeps the hint.
2269        let layout = Layout::EPD_200X200;
2270        let status = demo_status();
2271        let mut model = UiModel::new(MenuItems::all());
2272        navigate_to(&mut model, MenuItem::Identity);
2273        let mut panel = TestPanel::new(layout.size);
2274        render_frame(&mut panel, &layout, &model, &status);
2275        assert!(shows_row(&panel, &layout, status.identity.unwrap().hint));
2276    }
2277
2278    /// A settings level is a list: every entry it holds is on the panel at
2279    /// once, not one at a time behind a gesture.
2280    #[test]
2281    fn a_settings_level_draws_all_of_its_entries() {
2282        for layout in layouts() {
2283            let items = MenuItems::all();
2284            for level in [Level::Settings, Level::Bluetooth, Level::Gnss] {
2285                let selected = items.first_after_back(level);
2286                let mut model = UiModel::new(items);
2287                navigate_to(&mut model, selected);
2288                let mut panel = TestPanel::new(layout.size);
2289                render_frame(&mut panel, &layout, &model, &demo_status());
2290
2291                // The highlighted entry is drawn inverted, so its glyphs
2292                // are unlit and there is nothing for `shows_row` to find;
2293                // the bar it draws instead is what the highlight tests
2294                // check.
2295                for entry in items.entries(level).filter(|&e| e != selected) {
2296                    assert!(
2297                        shows_row(&panel, &layout, menu_label(entry)),
2298                        "{level:?} did not draw {entry:?} on {:?}",
2299                        layout.size
2300                    );
2301                }
2302            }
2303        }
2304    }
2305
2306    /// A toggle carries its state beside its name, because the state is
2307    /// the whole reason to walk to it. A board that cannot report one
2308    /// draws no state rather than guessing "off".
2309    #[test]
2310    fn a_toggle_reports_its_state_and_an_unknown_one_reports_nothing() {
2311        let mut settings = SettingsModel {
2312            bluetooth: Some(true),
2313            gnss: Some(false),
2314            share_location: None,
2315            forwarding: Some(true),
2316        };
2317        let mut line: String<LINE> = String::new();
2318
2319        write_entry(&mut line, MenuItem::BluetoothToggle, &settings);
2320        assert_eq!(line.as_str(), "Bluetooth  on");
2321
2322        line.clear();
2323        write_entry(&mut line, MenuItem::GnssToggle, &settings);
2324        assert_eq!(line.as_str(), "GNSS  off");
2325
2326        line.clear();
2327        write_entry(&mut line, MenuItem::ShareLocation, &settings);
2328        assert_eq!(line.as_str(), "Share location");
2329
2330        // And a non-toggle never grows one.
2331        line.clear();
2332        settings.forwarding = Some(false);
2333        write_entry(&mut line, MenuItem::StartPairing, &settings);
2334        assert_eq!(line.as_str(), "Start pairing");
2335    }
2336
2337    /// Both overflow idioms keep the highlighted entry drawn complete: a
2338    /// Select against a row the user can only half read is a guess.
2339    #[test]
2340    fn overflow_never_leaves_the_highlight_half_drawn() {
2341        let items = MenuItems::all();
2342        // Neither shipping panel overflows a level today, so the window
2343        // arithmetic is exercised against panels short enough that they
2344        // must — three content rows against a four-entry level.
2345        for style in [Overflow::ClipRow, Overflow::ScrollBar] {
2346            for base in layouts() {
2347                let layout = Layout {
2348                    rows: 4,
2349                    overflow: style,
2350                    ..base
2351                };
2352                for entry in items.entries(Level::Bluetooth) {
2353                    let mut model = UiModel::new(items);
2354                    navigate_to(&mut model, entry);
2355                    let mut panel = TestPanel::new(layout.size);
2356                    render_frame(&mut panel, &layout, &model, &demo_status());
2357
2358                    // The bar is the highlight, and it is drawn across the
2359                    // whole row band. A band only half filled is a row
2360                    // the clip cut in two, which the highlight may never
2361                    // land on.
2362                    let bars: heapless::Vec<usize, 8> = (1..layout.rows)
2363                        .map(|row| row_fill(&panel, &layout, row))
2364                        .filter(|&fill| fill > 50)
2365                        .collect();
2366                    assert_eq!(
2367                        bars.len(),
2368                        1,
2369                        "{entry:?} under {style:?} inverted {bars:?} on {:?}",
2370                        layout.size
2371                    );
2372                    assert!(
2373                        bars[0] > 80,
2374                        "{entry:?} was drawn half a row under {style:?} on {:?}",
2375                        layout.size
2376                    );
2377                }
2378            }
2379        }
2380    }
2381
2382    /// A clipped row hangs over the bottom; a scroll bar takes a column
2383    /// from every row instead. A board uses one, and the other must leave
2384    /// no trace.
2385    #[test]
2386    fn each_overflow_style_marks_the_list_its_own_way() {
2387        let items = MenuItems::all();
2388        let base = Layout::EPD_200X200;
2389        let mut lit = [0usize; 2];
2390        for (index, style) in [Overflow::ClipRow, Overflow::ScrollBar]
2391            .into_iter()
2392            .enumerate()
2393        {
2394            let layout = Layout {
2395                rows: 4,
2396                overflow: style,
2397                ..base
2398            };
2399            let mut model = UiModel::new(items);
2400            navigate_to(&mut model, items.first_after_back(Level::Bluetooth));
2401            let mut panel = TestPanel::new(layout.size);
2402            render_frame(&mut panel, &layout, &model, &demo_status());
2403
2404            // The right-hand column the bar would own, below the header.
2405            let bar = Rectangle::new(
2406                Point::new(layout.size.width as i32 - 2, layout.row_top(1)),
2407                Size::new(2, layout.size.height - layout.row_top(1) as u32),
2408            );
2409            lit[index] = panel.lit_in(bar);
2410        }
2411        assert!(
2412            lit[1] > lit[0],
2413            "the scroll bar did not claim the edge column"
2414        );
2415    }
2416
2417    /// A notice takes the top content row and pushes the rest down rather
2418    /// than replacing them: with the nominal rows gone there is room for
2419    /// both, and a PIN the user is mid-way through typing must not
2420    /// vanish because an unrelated action reported back.
2421    #[test]
2422    fn a_notice_takes_the_top_row_without_displacing_the_pin() {
2423        let layout = Layout::OLED_128X64;
2424        let mut model = UiModel::new(MenuItems::all());
2425        let mut status = demo_status();
2426        status.pairing = PairingState::Open { pin: Some(123_456) };
2427
2428        let mut with_pin = TestPanel::new(layout.size);
2429        render_frame(&mut with_pin, &layout, &model, &status);
2430        let pin_row = with_pin.lit_in(row_area(&layout, 2));
2431        assert!(pin_row > 0);
2432
2433        model.set_notice(UiNotice::BondsCleared);
2434        let mut with_notice = TestPanel::new(layout.size);
2435        render_frame(&mut with_notice, &layout, &model, &status);
2436
2437        // The notice is now row 2 and the PIN has moved to row 3.
2438        assert_ne!(with_notice.lit_in(row_area(&layout, 2)), pin_row);
2439        assert_eq!(with_notice.lit_in(row_area(&layout, 3)), pin_row);
2440    }
2441
2442    /// Charging with no level draws a bolt and nothing else: there is no
2443    /// level, so there is no body — the two are drawn independently.
2444    #[test]
2445    fn charging_without_a_level_replaces_the_body_with_a_bolt() {
2446        for layout in layouts() {
2447            let zone = layout.battery_zone();
2448            let mut charging = TestPanel::new(layout.size);
2449            let mut status = demo_status();
2450            status.battery = BatteryIndicator {
2451                level_percent: None,
2452                charge: Some(ChargeClass::Charging),
2453            };
2454            render_frame(
2455                &mut charging,
2456                &layout,
2457                &UiModel::new(MenuItems::all()),
2458                &status,
2459            );
2460
2461            // Something is in the zone, and it is not the outline: an
2462            // unknown-level pack that is *not* charging draws the body,
2463            // and the two must not look alike.
2464            assert!(charging.lit_in(zone) > 0, "charging drew nothing at all");
2465
2466            let mut unknown = TestPanel::new(layout.size);
2467            let mut status = demo_status();
2468            status.battery = BatteryIndicator::UNKNOWN;
2469            render_frame(
2470                &mut unknown,
2471                &layout,
2472                &UiModel::new(MenuItems::all()),
2473                &status,
2474            );
2475            assert_ne!(
2476                charging.lit_in(zone),
2477                unknown.lit_in(zone),
2478                "charging and no-reading drew the same picture on {:?}",
2479                layout.size
2480            );
2481
2482            // The bolt keeps the zone's right edge, so the indicator does
2483            // not shift sideways when a charger goes in. Its rightmost
2484            // column must be lit and everything left of the bolt clear —
2485            // which also proves no body outline survived.
2486            let solo = layout.battery.solo_bolt;
2487            let right_edge = Rectangle::new(
2488                Point::new(
2489                    zone.top_left.x + zone.size.width as i32 - 1,
2490                    zone.top_left.y,
2491                ),
2492                Size::new(1, zone.size.height),
2493            );
2494            assert!(
2495                charging.lit_in(right_edge) > 0,
2496                "the solo bolt is not right-aligned on {:?}",
2497                layout.size
2498            );
2499
2500            let left_of_bolt = Rectangle::new(
2501                zone.top_left,
2502                Size::new(zone.size.width - solo.width, zone.size.height),
2503            );
2504            assert_eq!(
2505                charging.lit_in(left_of_bolt),
2506                0,
2507                "something remained left of the solo bolt on {:?}",
2508                layout.size
2509            );
2510        }
2511    }
2512
2513    /// A board that *can* see charge completion still supplies a level,
2514    /// and keeps its body plus a bolt beside it.
2515    #[test]
2516    fn a_known_level_keeps_its_body_even_while_charging() {
2517        let layout = Layout::OLED_128X64;
2518        let zone = layout.battery_zone();
2519
2520        let mut charging = TestPanel::new(layout.size);
2521        let mut status = demo_status();
2522        status.battery = BatteryIndicator {
2523            level_percent: Some(100),
2524            charge: Some(ChargeClass::Charged),
2525        };
2526        render_frame(
2527            &mut charging,
2528            &layout,
2529            &UiModel::new(MenuItems::all()),
2530            &status,
2531        );
2532
2533        let mut solo = TestPanel::new(layout.size);
2534        let mut status = demo_status();
2535        status.battery = BatteryIndicator {
2536            level_percent: None,
2537            charge: Some(ChargeClass::Charged),
2538        };
2539        render_frame(&mut solo, &layout, &UiModel::new(MenuItems::all()), &status);
2540
2541        assert!(charging.lit_in(zone) > solo.lit_in(zone));
2542    }
2543}