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};
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::{MenuItem, Page, 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 centre 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/// A board's screen geometry.
150///
151/// Everything the renderer needs to place a row of text and the battery
152/// indicator. A board picks one of the constants — or writes its own if
153/// its panel is neither of the two shapes in the class today.
154#[derive(Clone, Copy, Debug)]
155pub struct Layout {
156    pub font: &'static MonoFont<'static>,
157    /// Left margin for row text.
158    pub left: i32,
159    /// Top of row 0's glyph band.
160    pub top: i32,
161    /// Distance between the tops of consecutive rows.
162    pub row_pitch: i32,
163    /// How many rows fit on the panel.
164    pub rows: usize,
165    pub size: Size,
166    pub battery: BatteryIconMetrics,
167}
168
169impl Layout {
170    /// 128×64 OLED: five rows of `FONT_6X10`. Shared by the Wio Tracker
171    /// L1's SH1106 and the Heltec V3's SSD1306.
172    pub const OLED_128X64: Self = Self {
173        font: &FONT_6X10,
174        left: 0,
175        top: 3,
176        row_pitch: 12,
177        rows: 5,
178        size: Size::new(128, 64),
179        battery: BatteryIconMetrics::OLED,
180    };
181
182    /// 200×200 e-paper: seven rows of `FONT_10X20`. The T-Echo's SSD1681.
183    pub const EPD_200X200: Self = Self {
184        font: &FONT_10X20,
185        left: 5,
186        top: 8,
187        row_pitch: 27,
188        rows: 7,
189        size: Size::new(200, 200),
190        battery: BatteryIconMetrics::EPD,
191    };
192
193    /// Top of `row`'s glyph band.
194    pub const fn row_top(&self, row: usize) -> i32 {
195        self.top + row as i32 * self.row_pitch
196    }
197
198    /// The rectangle the battery indicator owns, right-aligned on row 0.
199    pub fn battery_zone(&self) -> Rectangle {
200        let width = self.battery.zone_width();
201        let height = self.battery.body.height;
202        let x = self.size.width as i32 - self.battery.margin as i32 - width as i32;
203        let y = self.row_top(0) + (self.font.character_size.height as i32 - height as i32) / 2;
204        Rectangle::new(Point::new(x, y), Size::new(width, height))
205    }
206}
207
208// ─── What the frame says ─────────────────────────────────────────────────────
209
210/// How the board's local link is currently reachable.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum LinkState {
213    /// A companion is connected and has a live session.
214    Attached,
215    /// A companion is connected but has not attached a session.
216    Connected,
217    /// Nothing is connected; the board is discoverable.
218    Advertising,
219    /// Advertising is suppressed, typically because a wired host owns the
220    /// device.
221    OffWired,
222}
223
224/// Whether a companion can pair right now, and with what secret.
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226pub enum PairingState {
227    /// Too many failed attempts; pairing cannot be opened.
228    LockedOut,
229    /// A pairing window is open. The panel is the only place the PIN is
230    /// ever shown, which is why an open window holds the display awake.
231    Open { pin: Option<u32> },
232    /// No pairing window is open.
233    Closed,
234}
235
236/// Charge level and charging state, as far as the board can tell.
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub struct BatteryIndicator {
239    /// `None` when there is no level to show — before the estimator has
240    /// had a resting sample, or while charging on a board whose charger
241    /// reports no completion. Nothing is drawn in its place.
242    pub level_percent: Option<u8>,
243    /// `None` on a board whose charger reports nothing to the MCU, which
244    /// is different from knowing the pack is discharging.
245    pub charge: Option<ChargeClass>,
246}
247
248impl BatteryIndicator {
249    /// Nothing known yet: no body, no bolt.
250    pub const UNKNOWN: Self = Self {
251        level_percent: None,
252        charge: None,
253    };
254
255    /// Whether the indicator should carry a charging bolt.
256    ///
257    /// `Charged` draws one too, which is a deliberate degradation. The
258    /// full vocabulary is a bolt for "charging" and a plug for "charging
259    /// complete"; no board in this class can tell the two apart — only
260    /// the T-1000E reads a real charge-status line, and it has no panel
261    /// — so a board that sees external power flies the bolt for as long
262    /// as it is plugged in rather than asserting a completion it never
263    /// learns. Add the plug when a display board can substantiate it.
264    const fn shows_bolt(&self) -> bool {
265        matches!(
266            self.charge,
267            Some(ChargeClass::Charging) | Some(ChargeClass::Charged)
268        )
269    }
270}
271
272/// Radio activity, for the stats page.
273///
274/// Counts are cumulative since boot. They saturate rather than wrap: a
275/// counter that rolled over would make a long-quiet node look busy, and
276/// pinning at the maximum is at least monotone.
277#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
278pub struct StatsModel {
279    pub tx_frames: u32,
280    /// Every frame the radio handed up, whoever it was for.
281    pub rx_frames: u32,
282    /// Receptions that produced an event — addressed to this node, or
283    /// forwarded. The shortfall against [`Self::rx_frames`] is other
284    /// people's traffic and undecodable noise, shown as "drop".
285    pub rx_accepted: u32,
286    pub forwarded: u32,
287    /// Configured transmit power; `None` until the radio is configured.
288    pub tx_power_dbm: Option<i8>,
289    /// Duty-cycle usage in tenths of a percent. Trackers normally sit
290    /// well under one percent, so whole percent would read zero forever.
291    pub duty_permille: u16,
292}
293
294impl StatsModel {
295    /// Receptions that went nowhere.
296    pub const fn rx_dropped(&self) -> u32 {
297        self.rx_frames.saturating_sub(self.rx_accepted)
298    }
299}
300
301/// Everything drawn that is not menu state.
302///
303/// The firmware assembles this immediately before rendering — the device
304/// name in particular comes from an async read, which is why it arrives
305/// as a borrowed string rather than being fetched here.
306#[derive(Clone, Copy, Debug)]
307pub struct StatusModel<'a> {
308    pub device_name: &'a str,
309    pub battery: BatteryIndicator,
310    /// Pack voltage for the status page's diagnostic row. The header icon
311    /// is the glanceable reading; this is the one to quote in a bug
312    /// report.
313    pub battery_mv: Option<u16>,
314    pub link: LinkState,
315    /// How many companions are bonded. Shown only on the clear-bonds
316    /// confirmation, where it says what is about to be destroyed; on the
317    /// status page it was a number nobody was deciding anything with.
318    /// Running out of slots surfaces as a pairing failure, which is the
319    /// moment the capacity matters.
320    pub bonds: u8,
321    pub pairing: PairingState,
322    pub stats: StatsModel,
323    /// The local time to show in the header, or `None` when the device
324    /// does not know what time it is.
325    ///
326    /// `None` draws nothing at all — not a placeholder, not dashes, not a
327    /// zeroed clock. A device that does not know the time **must not**
328    /// indicate one, and enforcing that here rather than in each panel is
329    /// what keeps it true: there is no way to render a clock without a
330    /// reading to render.
331    pub clock: Option<ClockModel>,
332}
333
334/// A local wall-clock reading for the header.
335///
336/// Hours and minutes only. A seconds field would commit every panel to
337/// redrawing once a second, which an e-paper cannot do and a
338/// battery-powered OLED should not.
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340pub struct ClockModel {
341    /// Local hour, 0–23.
342    pub hour: u8,
343    /// Local minute, 0–59.
344    pub minute: u8,
345}
346
347impl ClockModel {
348    /// Render the status-page row, labelled to match the battery row
349    /// beside it — a bare `14:30` on a line of its own reads as a
350    /// measurement without a name.
351    fn write(&self, out: &mut String<LINE>) {
352        let _ = write!(out, "time {:02}:{:02}", self.hour, self.minute);
353    }
354}
355
356// ─── Entry points ────────────────────────────────────────────────────────────
357
358/// Draw the menu or the confirmation page.
359pub fn render_frame<D>(target: &mut D, layout: &Layout, model: &UiModel, status: &StatusModel<'_>)
360where
361    D: DrawTarget<Color = BinaryColor>,
362{
363    let _ = target.clear(BinaryColor::Off);
364    draw_header(target, layout, status);
365
366    let mut line: String<LINE> = String::new();
367    let content_end = match model.page() {
368        Page::Menu(item) => {
369            draw_row(target, layout, 1, menu_label(item));
370            match item {
371                MenuItem::Status => draw_status_page(target, layout, model, status, &mut line),
372                MenuItem::Stats => draw_stats_page(target, layout, status, &mut line),
373                other => {
374                    draw_row(target, layout, 2, action_label(other));
375                    3
376                }
377            }
378        }
379        Page::Confirm {
380            confirm_selected, ..
381        } => {
382            // The question names the object and its size, which is the
383            // only place the bond count changes a decision — and is why
384            // the status page no longer spends a row carrying it around.
385            write_clear_question(&mut line, status.bonds);
386            draw_row(target, layout, 1, &line);
387            draw_row(
388                target,
389                layout,
390                2,
391                if confirm_selected {
392                    "  Cancel"
393                } else {
394                    "> Cancel"
395                },
396            );
397            draw_row(
398                target,
399                layout,
400                3,
401                if confirm_selected {
402                    "> CLEAR"
403                } else {
404                    "  CLEAR"
405                },
406            );
407            4
408        }
409    };
410
411    let hints: &[&str] = match model.page() {
412        Page::Menu(_) => &["1x: next", "hold: back"],
413        Page::Confirm { .. } => &["1x/hold: toggle", "2x: confirm"],
414    };
415    draw_hints(target, layout, content_end, hints);
416}
417
418/// Draw a short centered message — a pairing window opening, a wipe
419/// running, an alert, a farewell.
420///
421/// The header stays, so the battery is readable even while the board is
422/// busy saying something else.
423pub fn render_message<D>(
424    target: &mut D,
425    layout: &Layout,
426    status: &StatusModel<'_>,
427    title: &str,
428    detail: &str,
429) where
430    D: DrawTarget<Color = BinaryColor>,
431{
432    let _ = target.clear(BinaryColor::Off);
433    draw_header(target, layout, status);
434    let title_row = layout.rows / 2;
435    draw_row_centered(target, layout, title_row, title);
436    draw_row_centered(target, layout, title_row + 1, detail);
437}
438
439/// Draw the battery indicator with its zone's top-left corner at
440/// `top_left`.
441///
442/// Public because it is the one piece of this module a richer, more
443/// graphical UI would want to keep and reuse verbatim.
444pub fn draw_battery_icon<D>(
445    target: &mut D,
446    top_left: Point,
447    metrics: &BatteryIconMetrics,
448    indicator: &BatteryIndicator,
449) where
450    D: DrawTarget<Color = BinaryColor>,
451{
452    let outline = PrimitiveStyleBuilder::new()
453        .stroke_color(BinaryColor::On)
454        .stroke_width(metrics.border)
455        .stroke_alignment(StrokeAlignment::Inside)
456        .build();
457    let solid = PrimitiveStyle::with_fill(BinaryColor::On);
458
459    // Two independent facts, drawn independently: whether there is a
460    // level to show, and whether the pack is charging. Either, both, or
461    // neither.
462    //
463    // The bolt takes its reserved column beside a body when there is one
464    // to sit beside, and the whole zone when there is not. It keeps the
465    // zone's right edge either way, so the indicator stays anchored to
466    // the same corner whatever it is currently drawing.
467    if indicator.shows_bolt() {
468        if indicator.level_percent.is_some() {
469            draw_bolt(
470                target,
471                top_left,
472                Size::new(metrics.bolt_width, metrics.body.height),
473                solid,
474            );
475        } else {
476            let bolt = metrics.solo_bolt;
477            let at = Point::new(
478                top_left.x + metrics.zone_width().saturating_sub(bolt.width) as i32,
479                top_left.y + (metrics.body.height.saturating_sub(bolt.height) / 2) as i32,
480            );
481            draw_bolt(target, at, bolt, solid);
482        }
483    }
484
485    // No level, no body. An empty body means a pack down to its last
486    // sixth; a level the device has not established is drawn as nothing
487    // at all.
488    let Some(level) = indicator.level_percent else {
489        return;
490    };
491
492    let body_left = top_left.x + (metrics.bolt_width + metrics.spacing) as i32;
493    let _ = Rectangle::new(Point::new(body_left, top_left.y), metrics.body)
494        .into_styled(outline)
495        .draw(target);
496
497    let nub_y = top_left.y + (metrics.body.height as i32 - metrics.nub.height as i32) / 2;
498    let _ = Rectangle::new(
499        Point::new(body_left + metrics.body.width as i32, nub_y),
500        metrics.nub,
501    )
502    .into_styled(solid)
503    .draw(target);
504
505    let inset = metrics.border + metrics.pad;
506    let inner = Size::new(
507        metrics.body.width.saturating_sub(2 * inset),
508        metrics.body.height.saturating_sub(2 * inset),
509    );
510    let lit = u32::from(battery_segments(level));
511    let count = u32::from(BATTERY_SEGMENTS);
512    let seg_width = inner
513        .width
514        .saturating_sub(metrics.gap * (count - 1))
515        .checked_div(count)
516        .unwrap_or(0);
517    if seg_width == 0 || inner.height == 0 {
518        return;
519    }
520    for index in 0..lit {
521        let x = body_left + inset as i32 + (index * (seg_width + metrics.gap)) as i32;
522        let _ = Rectangle::new(
523            Point::new(x, top_left.y + inset as i32),
524            Size::new(seg_width, inner.height),
525        )
526        .into_styled(solid)
527        .draw(target);
528    }
529}
530
531// ─── Pages ───────────────────────────────────────────────────────────────────
532
533/// The status page's content rows, packed upward from row 2.
534///
535/// Only state that departs from nominal earns a row. A closed pairing
536/// window and plain advertising are what every tracker does when nothing
537/// is happening, and a line that appears on almost every frame trains the
538/// user to stop reading it — so neither is drawn, and what is left on a
539/// resting device is a single battery line. That is also what gives the
540/// five-row panels enough room to show their gesture hints on the page
541/// users actually sit on.
542///
543/// The order is falling importance, so the line a short panel runs out of
544/// room for is always the one it can most afford to lose: the battery row
545/// duplicates a header icon that is already on screen.
546fn draw_status_page<D>(
547    target: &mut D,
548    layout: &Layout,
549    model: &UiModel,
550    status: &StatusModel<'_>,
551    line: &mut String<LINE>,
552) -> usize
553where
554    D: DrawTarget<Color = BinaryColor>,
555{
556    let mut row = 2;
557
558    if let Some(notice) = model.notice() {
559        draw_row(target, layout, row, notice_label(notice));
560        row += 1;
561    }
562
563    line.clear();
564    if write_pairing(line, status.pairing) {
565        draw_row(target, layout, row, line);
566        row += 1;
567    }
568
569    if let Some(label) = link_label(status.link) {
570        draw_row(target, layout, row, label);
571        row += 1;
572    }
573
574    line.clear();
575    write_battery(line, status);
576    draw_row(target, layout, row, line);
577    row += 1;
578
579    // Last, so that on a panel whose rows have run out the clock is what
580    // falls off rather than the battery: how much charge is left is a
581    // fact somebody is deciding something with, and what time it is is
582    // not. Absent entirely when the device does not know the time —
583    // there is no placeholder row, because a row that says the time is
584    // unknown is still an indication about the time.
585    if let Some(clock) = status.clock {
586        line.clear();
587        clock.write(line);
588        draw_row(target, layout, row, line);
589        row += 1;
590    }
591
592    row
593}
594
595/// Radio activity: what the node has actually done on the air.
596///
597/// Enough to tell a working node from a deaf one without reaching for a
598/// capture — a node whose `rx` never moves is not hearing anybody, and one
599/// whose `tx` never moves is not being heard.
600fn draw_stats_page<D>(
601    target: &mut D,
602    layout: &Layout,
603    status: &StatusModel<'_>,
604    line: &mut String<LINE>,
605) -> usize
606where
607    D: DrawTarget<Color = BinaryColor>,
608{
609    let stats = status.stats;
610
611    line.clear();
612    let _ = write!(line, "tx {}  rx {}", stats.tx_frames, stats.rx_frames);
613    draw_row(target, layout, 2, line);
614
615    line.clear();
616    let _ = write!(line, "fwd {}  drop {}", stats.forwarded, stats.rx_dropped());
617    draw_row(target, layout, 3, line);
618
619    line.clear();
620    match stats.tx_power_dbm {
621        Some(dbm) => {
622            let _ = write!(line, "{dbm} dBm  ");
623        }
624        None => {
625            let _ = write!(line, "-- dBm  ");
626        }
627    }
628    let (whole, tenth) = (stats.duty_permille / 10, stats.duty_permille % 10);
629    let _ = write!(line, "duty {whole}.{tenth}%");
630    draw_row(target, layout, 4, line);
631
632    5
633}
634
635// ─── Drawing helpers ─────────────────────────────────────────────────────────
636
637fn draw_header<D>(target: &mut D, layout: &Layout, status: &StatusModel<'_>)
638where
639    D: DrawTarget<Color = BinaryColor>,
640{
641    // The battery owns its corner: the name is cut to the room left over
642    // rather than being allowed to run under the indicator and off the
643    // panel. Blanking the zone afterwards keeps that true no matter what
644    // else the header grows.
645    //
646    // The clock is deliberately *not* here. It fits, but only by taking
647    // the room from the device name, and on the 200 px e-paper's
648    // twenty-pixel font that cut the name from fourteen characters to
649    // seven — which across a fleet of `umsh-`-prefixed radios is the
650    // difference between identifying one and guessing. The clock lives on
651    // the status page instead, where a row costs nothing that was being
652    // read.
653    let zone = layout.battery_zone();
654    let room = (zone.top_left.x - layout.left).max(0) as u32;
655    draw_row(target, layout, 0, clip(layout, status.device_name, room));
656    let _ = zone
657        .into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
658        .draw(target);
659    draw_battery_icon(target, zone.top_left, &layout.battery, &status.battery);
660}
661
662/// Longest prefix of `text` that fits in `width` pixels.
663fn clip<'a>(layout: &Layout, text: &'a str, width: u32) -> &'a str {
664    let advance = layout.font.character_size.width + layout.font.character_spacing;
665    if advance == 0 {
666        return text;
667    }
668    let fits = (width / advance) as usize;
669    match text.char_indices().nth(fits) {
670        Some((end, _)) => &text[..end],
671        None => text,
672    }
673}
674
675fn draw_row<D>(target: &mut D, layout: &Layout, row: usize, text: &str)
676where
677    D: DrawTarget<Color = BinaryColor>,
678{
679    if row >= layout.rows || text.is_empty() {
680        return;
681    }
682    let room = layout.size.width.saturating_sub(layout.left.max(0) as u32);
683    let text = clip(layout, text, room);
684    draw_text(
685        target,
686        layout,
687        Point::new(layout.left, layout.row_top(row)),
688        text,
689    );
690}
691
692fn draw_row_centered<D>(target: &mut D, layout: &Layout, row: usize, text: &str)
693where
694    D: DrawTarget<Color = BinaryColor>,
695{
696    if row >= layout.rows || text.is_empty() {
697        return;
698    }
699    let advance = layout.font.character_size.width + layout.font.character_spacing;
700    let text = clip(layout, text, layout.size.width);
701    let width = advance.saturating_mul(text.chars().count() as u32);
702    let x = (layout.size.width.saturating_sub(width) / 2) as i32;
703    draw_text(target, layout, Point::new(x, layout.row_top(row)), text);
704}
705
706fn draw_text<D>(target: &mut D, layout: &Layout, at: Point, text: &str)
707where
708    D: DrawTarget<Color = BinaryColor>,
709{
710    let style = MonoTextStyle::new(layout.font, BinaryColor::On);
711    let _ = Text::with_baseline(text, at, style, Baseline::Top).draw(target);
712}
713
714/// Park the gesture hints against the bottom of the panel, dropping them
715/// from the front when the page has left fewer rows than there are hints.
716///
717/// This is what lets one renderer serve both panel shapes: the five-row
718/// OLED silently loses both hints on the crowded status page and keeps
719/// the last one on the confirmation page, while the seven-row e-paper has
720/// room for both everywhere.
721fn draw_hints<D>(target: &mut D, layout: &Layout, content_end: usize, hints: &[&str])
722where
723    D: DrawTarget<Color = BinaryColor>,
724{
725    let shown = hints.len().min(layout.rows.saturating_sub(content_end));
726    let first_row = layout.rows - shown;
727    for (offset, hint) in hints[hints.len() - shown..].iter().enumerate() {
728        draw_row(target, layout, first_row + offset, hint);
729    }
730}
731
732fn draw_bolt<D>(target: &mut D, top_left: Point, size: Size, style: PrimitiveStyle<BinaryColor>)
733where
734    D: DrawTarget<Color = BinaryColor>,
735{
736    let (x, y) = (top_left.x, top_left.y);
737    let (w, h) = (size.width as i32, size.height as i32);
738    // Two overlapping wedges: the upper one falls left, the lower one
739    // rises right, and the rows they share join them into one stroke.
740    let upper = Triangle::new(
741        Point::new(x + w * 2 / 3, y),
742        Point::new(x, y + h * 3 / 5),
743        Point::new(x + w * 2 / 3, y + h * 3 / 5),
744    );
745    let lower = Triangle::new(
746        Point::new(x + w / 3, y + h - 1),
747        Point::new(x + w - 1, y + h * 2 / 5),
748        Point::new(x + w / 3, y + h * 2 / 5),
749    );
750    let _ = upper.into_styled(style).draw(target);
751    let _ = lower.into_styled(style).draw(target);
752}
753
754// ─── Strings ─────────────────────────────────────────────────────────────────
755
756const fn menu_label(item: MenuItem) -> &'static str {
757    match item {
758        MenuItem::Status => "> Status",
759        MenuItem::Stats => "> Stats",
760        MenuItem::CheckIn => "> Check in",
761        MenuItem::StartPairing => "> Start pairing",
762        MenuItem::ClearBonds => "> Clear bonds",
763    }
764}
765
766/// What a double-click would do from this item. `Status` and `Stats` are
767/// pages, not actions, and never reach here.
768const fn action_label(item: MenuItem) -> &'static str {
769    match item {
770        MenuItem::CheckIn => "2x: check in",
771        MenuItem::StartPairing => "2x: start",
772        MenuItem::ClearBonds => "2x: continue",
773        MenuItem::Status | MenuItem::Stats => "",
774    }
775}
776
777const fn notice_label(notice: UiNotice) -> &'static str {
778    match notice {
779        UiNotice::CheckInRequested => "checking in...",
780        UiNotice::PairingStarted => "pairing started",
781        UiNotice::PairingUnavailable => "pair unavailable",
782        UiNotice::BondsCleared => "bonds cleared",
783        UiNotice::ClearFailed => "CLEAR FAILED",
784    }
785}
786
787/// The link line, or `None` when there is nothing worth a row.
788///
789/// Advertising is what a tracker does whenever nobody is talking to it, so
790/// announcing it says only that the device is behaving normally. What
791/// earns a row is a host actually being on the other end, or advertising
792/// being suppressed — the case where a user looking for the device on a
793/// phone would otherwise be left wondering.
794const fn link_label(link: LinkState) -> Option<&'static str> {
795    match link {
796        LinkState::Attached => Some("host attached"),
797        LinkState::Connected => Some("host connected"),
798        LinkState::OffWired => Some("off (wired)"),
799        LinkState::Advertising => None,
800    }
801}
802
803/// Write the pairing line, reporting whether it wrote anything.
804///
805/// A closed window is the resting state of every tracker; saying so costs
806/// a row to report that nothing is happening.
807fn write_pairing(line: &mut String<LINE>, pairing: PairingState) -> bool {
808    let _ = match pairing {
809        PairingState::LockedOut => write!(line, "PAIR LOCKED"),
810        PairingState::Open { pin: Some(pin) } => write!(line, "PIN {pin:06}"),
811        PairingState::Open { pin: None } => write!(line, "pairing (no PIN)"),
812        PairingState::Closed => return false,
813    };
814    true
815}
816
817fn write_clear_question(line: &mut String<LINE>, bonds: u8) {
818    let _ = match bonds {
819        0 => write!(line, "No bonds to clear"),
820        1 => write!(line, "Clear 1 bond?"),
821        n => write!(line, "Clear {n} bonds?"),
822    };
823}
824
825fn write_battery(line: &mut String<LINE>, status: &StatusModel<'_>) {
826    let Some(mv) = status.battery_mv else {
827        let _ = write!(line, "batt --");
828        return;
829    };
830    let _ = write!(line, "batt {mv} mV");
831    match status.battery.level_percent {
832        Some(level) => {
833            let _ = write!(line, " {level}%");
834        }
835        // Charging with no level is a known state, not a stalled reading,
836        // so the row says which one it is rather than trailing off.
837        None if status.battery.shows_bolt() => {
838            let _ = write!(line, " chg");
839        }
840        None => {}
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::menu::{MenuItems, UiInput};
848
849    /// Widest panel in the class, bit-packed: 200 × 200 costs 5 kB, which
850    /// a test can keep several of without thinking about it.
851    const TEST_PANEL_BYTES: usize = 200 * 200 / 8;
852
853    /// A plain bitmap `DrawTarget` so the tests can ask what actually
854    /// landed on the glass rather than trusting the call sequence.
855    struct TestPanel {
856        size: Size,
857        pixels: [u8; TEST_PANEL_BYTES],
858    }
859
860    impl TestPanel {
861        fn new(size: Size) -> Self {
862            assert!((size.width * size.height) as usize <= TEST_PANEL_BYTES * 8);
863            Self {
864                size,
865                pixels: [0; TEST_PANEL_BYTES],
866            }
867        }
868
869        fn lit(&self, x: u32, y: u32) -> bool {
870            let bit = y * self.size.width + x;
871            self.pixels[(bit / 8) as usize] & (1 << (bit % 8)) != 0
872        }
873
874        /// How many pixels are lit inside `area`.
875        fn lit_in(&self, area: Rectangle) -> usize {
876            let mut count = 0;
877            for y in area.top_left.y..area.top_left.y + area.size.height as i32 {
878                for x in area.top_left.x..area.top_left.x + area.size.width as i32 {
879                    if x >= 0
880                        && y >= 0
881                        && (x as u32) < self.size.width
882                        && (y as u32) < self.size.height
883                        && self.lit(x as u32, y as u32)
884                    {
885                        count += 1;
886                    }
887                }
888            }
889            count
890        }
891    }
892
893    impl OriginDimensions for TestPanel {
894        fn size(&self) -> Size {
895            self.size
896        }
897    }
898
899    impl DrawTarget for TestPanel {
900        type Color = BinaryColor;
901        type Error = core::convert::Infallible;
902
903        fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
904        where
905            I: IntoIterator<Item = Pixel<BinaryColor>>,
906        {
907            for Pixel(Point { x, y }, color) in pixels {
908                // Anything drawn off-panel is a layout bug, not something
909                // to silently absorb the way a real driver would.
910                assert!(
911                    x >= 0
912                        && y >= 0
913                        && (x as u32) < self.size.width
914                        && (y as u32) < self.size.height,
915                    "drew outside the panel at ({x}, {y}) on {:?}",
916                    self.size
917                );
918                let bit = y as u32 * self.size.width + x as u32;
919                let mask = 1 << (bit % 8);
920                if color.is_on() {
921                    self.pixels[(bit / 8) as usize] |= mask;
922                } else {
923                    self.pixels[(bit / 8) as usize] &= !mask;
924                }
925            }
926            Ok(())
927        }
928    }
929
930    fn demo_status() -> StatusModel<'static> {
931        StatusModel {
932            device_name: "umsh-tracker",
933            battery: BatteryIndicator {
934                level_percent: Some(75),
935                charge: Some(ChargeClass::Discharging),
936            },
937            battery_mv: Some(3_950),
938            link: LinkState::Advertising,
939            bonds: 1,
940            pairing: PairingState::Closed,
941            stats: StatsModel {
942                tx_frames: 12,
943                rx_frames: 340,
944                rx_accepted: 300,
945                forwarded: 7,
946                tx_power_dbm: Some(22),
947                duty_permille: 4,
948            },
949            // The device does not know what time it is, which is the
950            // state every panel must render as no clock at all.
951            clock: None,
952        }
953    }
954
955    fn layouts() -> [Layout; 2] {
956        [Layout::OLED_128X64, Layout::EPD_200X200]
957    }
958
959    #[test]
960    fn segments_centre_each_bar_count_on_the_level_it_depicts() {
961        assert_eq!(battery_segments(0), 0);
962        assert_eq!(battery_segments(14), 0);
963        assert_eq!(battery_segments(15), 1);
964        assert_eq!(battery_segments(36), 1);
965        assert_eq!(battery_segments(37), 2);
966        assert_eq!(battery_segments(62), 2);
967        assert_eq!(battery_segments(63), 3);
968        assert_eq!(battery_segments(84), 3);
969        assert_eq!(battery_segments(85), 4);
970        assert_eq!(battery_segments(100), 4);
971        // Clamped rather than wrapped, so a bad sample cannot overdraw.
972        assert_eq!(battery_segments(200), 4);
973
974        // Just over half draws half a body, not three quarters of one.
975        assert_eq!(battery_segments(55), 2);
976
977        // Never falls as the level rises, and never overdraws.
978        let mut previous = 0;
979        for level in 0..=255u8 {
980            let bars = battery_segments(level);
981            assert!(bars >= previous, "{level} % lost a bar");
982            assert!(bars <= BATTERY_SEGMENTS, "{level} % overdrew");
983            previous = bars;
984        }
985    }
986
987    /// The requirement that started this: a battery reading on every
988    /// frame the user can be looking at, on every board.
989    #[test]
990    fn every_frame_kind_carries_the_battery_indicator() {
991        for layout in layouts() {
992            let zone = layout.battery_zone();
993            let mut model = UiModel::new(MenuItems::all());
994
995            let mut panel = TestPanel::new(layout.size);
996            render_frame(&mut panel, &layout, &model, &demo_status());
997            assert!(panel.lit_in(zone) > 0, "menu frame lost the battery");
998
999            let mut panel = TestPanel::new(layout.size);
1000            render_message(
1001                &mut panel,
1002                &layout,
1003                &demo_status(),
1004                "Clearing",
1005                "bonds + PIN...",
1006            );
1007            assert!(panel.lit_in(zone) > 0, "message frame lost the battery");
1008
1009            // Walk to the destructive item and open its confirmation.
1010            model.apply(UiInput::Backward);
1011            model.apply(UiInput::Select);
1012            assert!(matches!(model.page(), Page::Confirm { .. }));
1013            let mut panel = TestPanel::new(layout.size);
1014            render_frame(&mut panel, &layout, &model, &demo_status());
1015            assert!(panel.lit_in(zone) > 0, "confirm frame lost the battery");
1016        }
1017    }
1018
1019    /// Whether the panel renders `text` as one of its rows.
1020    ///
1021    /// Draws the row alone on a reference panel and checks every lit
1022    /// pixel of it is also lit on `panel` — the closest a bitmap target
1023    /// gets to reading text back off the glass.
1024    fn shows_row(panel: &TestPanel, layout: &Layout, text: &str) -> bool {
1025        (1..layout.rows).any(|row| {
1026            let mut reference = TestPanel::new(layout.size);
1027            draw_row(&mut reference, layout, row, text);
1028            let top = layout.row_top(row);
1029            let bottom = top + layout.font.character_size.height as i32;
1030            let mut any = false;
1031            for y in top..bottom {
1032                for x in 0..layout.size.width {
1033                    if reference.lit(x, y as u32) {
1034                        any = true;
1035                        if !panel.lit(x, y as u32) {
1036                            return false;
1037                        }
1038                    }
1039                }
1040            }
1041            any
1042        })
1043    }
1044
1045    /// The requirement this whole feature is conditioned on: a device
1046    /// that does not know what time it is shows **nothing** about the
1047    /// time — not a placeholder, not zeros, not dashes, and not a row
1048    /// saying it does not know.
1049    #[test]
1050    fn an_unknown_time_draws_no_clock_at_all() {
1051        for layout in layouts() {
1052            let mut status = demo_status();
1053            status.pairing = PairingState::Closed;
1054
1055            let mut known = TestPanel::new(layout.size);
1056            status.clock = Some(ClockModel {
1057                hour: 23,
1058                minute: 5,
1059            });
1060            render_frame(
1061                &mut known,
1062                &layout,
1063                &UiModel::new(MenuItems::all()),
1064                &status,
1065            );
1066            assert!(
1067                shows_row(&known, &layout, "time 23:05"),
1068                "the reference case drew no clock, so the negative proves nothing"
1069            );
1070
1071            let mut unknown = TestPanel::new(layout.size);
1072            status.clock = None;
1073            render_frame(
1074                &mut unknown,
1075                &layout,
1076                &UiModel::new(MenuItems::all()),
1077                &status,
1078            );
1079            assert!(
1080                !shows_row(&unknown, &layout, "time 23:05"),
1081                "a device that does not know the time indicated one"
1082            );
1083            // The row is absent rather than blanked, so nothing about the
1084            // time is left on the panel at all.
1085            for label in ["time --:--", "time 00:00", "time"] {
1086                assert!(
1087                    !shows_row(&unknown, &layout, label),
1088                    "an unset clock rendered {label:?}"
1089                );
1090            }
1091        }
1092    }
1093
1094    #[test]
1095    fn a_known_time_draws_a_clock_row_on_the_status_page() {
1096        for layout in layouts() {
1097            let mut status = demo_status();
1098            // A quiet device, so the status page has room for every row
1099            // it wants; the crowding behavior has its own test.
1100            status.pairing = PairingState::Closed;
1101
1102            for (clock, expected) in [
1103                (
1104                    ClockModel {
1105                        hour: 23,
1106                        minute: 5,
1107                    },
1108                    "time 23:05",
1109                ),
1110                // Midnight is a real reading, not an absent one: 00:00
1111                // must draw, or the minute a day it is midnight would
1112                // look like a device that has forgotten the time.
1113                (ClockModel { hour: 0, minute: 0 }, "time 00:00"),
1114            ] {
1115                status.clock = Some(clock);
1116                let mut panel = TestPanel::new(layout.size);
1117                render_frame(
1118                    &mut panel,
1119                    &layout,
1120                    &UiModel::new(MenuItems::all()),
1121                    &status,
1122                );
1123                assert!(
1124                    shows_row(&panel, &layout, expected),
1125                    "the status page lost {expected:?}"
1126                );
1127            }
1128        }
1129    }
1130
1131    /// The clock lives in the body, so it takes nothing from the header —
1132    /// a long device name reads exactly as far as it did before there was
1133    /// a clock at all.
1134    #[test]
1135    fn the_clock_costs_the_device_name_nothing() {
1136        for layout in layouts() {
1137            let header = Rectangle::new(
1138                Point::new(0, layout.row_top(0)),
1139                Size::new(layout.size.width, layout.font.character_size.height),
1140            );
1141            let mut status = demo_status();
1142            status.device_name = "a-very-long-device-name-indeed";
1143
1144            let mut without = TestPanel::new(layout.size);
1145            status.clock = None;
1146            render_frame(
1147                &mut without,
1148                &layout,
1149                &UiModel::new(MenuItems::all()),
1150                &status,
1151            );
1152
1153            let mut with = TestPanel::new(layout.size);
1154            status.clock = Some(ClockModel {
1155                hour: 14,
1156                minute: 30,
1157            });
1158            render_frame(&mut with, &layout, &UiModel::new(MenuItems::all()), &status);
1159
1160            assert!(without.lit_in(header) > 0, "the name drew nothing");
1161            assert_eq!(
1162                with.lit_in(header),
1163                without.lit_in(header),
1164                "the clock moved the header"
1165            );
1166        }
1167    }
1168
1169    /// On a panel that has run out of rows the clock is what falls off,
1170    /// never the battery: how much charge is left is a fact somebody is
1171    /// deciding something with, and what time it is is not.
1172    #[test]
1173    fn a_crowded_status_page_drops_the_clock_before_the_battery() {
1174        // The five-row OLED with every optional row asking for space.
1175        let layout = Layout::OLED_128X64;
1176        let mut status = demo_status();
1177        status.pairing = PairingState::Open { pin: Some(123_456) };
1178        status.link = LinkState::Attached;
1179        status.clock = Some(ClockModel {
1180            hour: 14,
1181            minute: 30,
1182        });
1183
1184        let mut panel = TestPanel::new(layout.size);
1185        render_frame(
1186            &mut panel,
1187            &layout,
1188            &UiModel::new(MenuItems::all()),
1189            &status,
1190        );
1191        assert!(
1192            shows_row(&panel, &layout, "batt 3950 mV 75%"),
1193            "the battery row was displaced"
1194        );
1195        assert!(
1196            !shows_row(&panel, &layout, "time 14:30"),
1197            "the clock survived a page with no room for it"
1198        );
1199    }
1200
1201    #[test]
1202    fn fill_grows_with_the_level_and_an_unknown_level_draws_nothing() {
1203        for layout in layouts() {
1204            let zone = layout.battery_zone();
1205            let mut previous = 0;
1206            // One level from each of the five bands, lowest first.
1207            for level in [5, 25, 50, 75, 100] {
1208                let mut panel = TestPanel::new(layout.size);
1209                let mut status = demo_status();
1210                status.battery.level_percent = Some(level);
1211                render_frame(
1212                    &mut panel,
1213                    &layout,
1214                    &UiModel::new(MenuItems::all()),
1215                    &status,
1216                );
1217                let lit = panel.lit_in(zone);
1218                assert!(lit > previous, "level {level} did not add fill");
1219                previous = lit;
1220            }
1221
1222            // An empty body means a flat pack, and only that. "No
1223            // reading" is said by drawing no indicator at all.
1224            let mut flat = TestPanel::new(layout.size);
1225            let mut status = demo_status();
1226            status.battery.level_percent = Some(0);
1227            render_frame(&mut flat, &layout, &UiModel::new(MenuItems::all()), &status);
1228            assert!(flat.lit_in(zone) > 0, "a flat pack drew no body at all");
1229
1230            let mut unknown = TestPanel::new(layout.size);
1231            let mut status = demo_status();
1232            status.battery = BatteryIndicator::UNKNOWN;
1233            render_frame(
1234                &mut unknown,
1235                &layout,
1236                &UiModel::new(MenuItems::all()),
1237                &status,
1238            );
1239            assert_eq!(
1240                unknown.lit_in(zone),
1241                0,
1242                "an unknown level drew something in the zone"
1243            );
1244        }
1245    }
1246
1247    /// The bolt has its own reserved column, so a charger going in must
1248    /// not shift the body — on the e-paper that is the difference between
1249    /// re-inking a bolt and re-inking the whole header.
1250    #[test]
1251    fn charging_adds_a_bolt_without_moving_the_body() {
1252        for layout in layouts() {
1253            let zone = layout.battery_zone();
1254            let body = Rectangle::new(
1255                Point::new(
1256                    zone.top_left.x + (layout.battery.bolt_width + layout.battery.spacing) as i32,
1257                    zone.top_left.y,
1258                ),
1259                Size::new(
1260                    layout.battery.body.width + layout.battery.nub.width,
1261                    layout.battery.body.height,
1262                ),
1263            );
1264            let bolt = Rectangle::new(
1265                zone.top_left,
1266                Size::new(layout.battery.bolt_width, zone.size.height),
1267            );
1268
1269            let mut idle = TestPanel::new(layout.size);
1270            let mut status = demo_status();
1271            status.battery.charge = Some(ChargeClass::Discharging);
1272            render_frame(&mut idle, &layout, &UiModel::new(MenuItems::all()), &status);
1273
1274            let mut charging = TestPanel::new(layout.size);
1275            let mut status = demo_status();
1276            status.battery.charge = Some(ChargeClass::Charging);
1277            render_frame(
1278                &mut charging,
1279                &layout,
1280                &UiModel::new(MenuItems::all()),
1281                &status,
1282            );
1283
1284            assert_eq!(
1285                idle.lit_in(bolt),
1286                0,
1287                "a discharging pack drew something in the bolt slot"
1288            );
1289            assert!(charging.lit_in(bolt) > 0, "charging drew no bolt");
1290            assert_eq!(
1291                idle.lit_in(body),
1292                charging.lit_in(body),
1293                "the body moved when the charger went in"
1294            );
1295        }
1296    }
1297
1298    /// The indicator must stay inside the rectangle the layout reserved
1299    /// for it — that rectangle is what the header blanks before drawing,
1300    /// and anything spilling out of it lands on top of the device name.
1301    #[test]
1302    fn the_indicator_stays_inside_its_zone() {
1303        for layout in layouts() {
1304            let zone = layout.battery_zone();
1305            for charge in [
1306                None,
1307                Some(ChargeClass::Discharging),
1308                Some(ChargeClass::Charging),
1309                Some(ChargeClass::Charged),
1310            ] {
1311                for level in [None, Some(0), Some(1), Some(50), Some(100)] {
1312                    let mut panel = TestPanel::new(layout.size);
1313                    draw_battery_icon(
1314                        &mut panel,
1315                        zone.top_left,
1316                        &layout.battery,
1317                        &BatteryIndicator {
1318                            level_percent: level,
1319                            charge,
1320                        },
1321                    );
1322                    let whole = Rectangle::new(Point::zero(), layout.size);
1323                    assert_eq!(
1324                        panel.lit_in(whole),
1325                        panel.lit_in(zone),
1326                        "{charge:?}/{level:?} drew outside the reserved zone on {:?}",
1327                        layout.size
1328                    );
1329                }
1330            }
1331        }
1332    }
1333
1334    /// A board with no charger telemetry says nothing rather than
1335    /// claiming the pack is discharging.
1336    #[test]
1337    fn unknown_charge_state_draws_no_bolt() {
1338        let layout = Layout::OLED_128X64;
1339        let zone = layout.battery_zone();
1340        let bolt = Rectangle::new(
1341            zone.top_left,
1342            Size::new(layout.battery.bolt_width, zone.size.height),
1343        );
1344        let mut panel = TestPanel::new(layout.size);
1345        let mut status = demo_status();
1346        status.battery.charge = None;
1347        render_frame(
1348            &mut panel,
1349            &layout,
1350            &UiModel::new(MenuItems::all()),
1351            &status,
1352        );
1353        assert_eq!(panel.lit_in(bolt), 0);
1354    }
1355
1356    /// A name long enough to run under the indicator must lose, not
1357    /// smear into it.
1358    #[test]
1359    fn an_overlong_device_name_never_reaches_the_indicator() {
1360        for layout in layouts() {
1361            let zone = layout.battery_zone();
1362            let mut panel = TestPanel::new(layout.size);
1363            let mut status = demo_status();
1364            status.device_name = "a-very-long-device-name-that-runs-off-the-panel";
1365            status.battery = BatteryIndicator::UNKNOWN;
1366            render_frame(
1367                &mut panel,
1368                &layout,
1369                &UiModel::new(MenuItems::all()),
1370                &status,
1371            );
1372
1373            // Whatever is in the zone is the empty body and nothing else.
1374            let mut bare = TestPanel::new(layout.size);
1375            let mut short = status;
1376            short.device_name = "x";
1377            render_frame(&mut bare, &layout, &UiModel::new(MenuItems::all()), &short);
1378            assert_eq!(panel.lit_in(zone), bare.lit_in(zone));
1379        }
1380    }
1381
1382    /// A resting device says one thing — its battery — and the rows that
1383    /// frees are exactly what the five-row panel needed for its gesture
1384    /// hints. This is the payoff for dropping the nominal-state rows.
1385    #[test]
1386    fn a_nominal_status_page_leaves_room_for_the_hints() {
1387        let model = UiModel::new(MenuItems::all());
1388        let status = demo_status();
1389        assert_eq!(status.pairing, PairingState::Closed);
1390        assert_eq!(status.link, LinkState::Advertising);
1391
1392        let oled = Layout::OLED_128X64;
1393        let mut panel = TestPanel::new(oled.size);
1394        render_frame(&mut panel, &oled, &model, &status);
1395        // Row 2 is the battery line; rows 3 and 4 are the two hints.
1396        for row in 2..oled.rows {
1397            assert!(panel.lit_in(row_area(&oled, row)) > 0, "row {row} is blank");
1398        }
1399
1400        let epd = Layout::EPD_200X200;
1401        let mut panel = TestPanel::new(epd.size);
1402        render_frame(&mut panel, &epd, &model, &status);
1403        // Hints bottom-align, so the taller panel leaves the gap in the
1404        // middle rather than trailing empty rows under the text.
1405        assert!(panel.lit_in(row_area(&epd, 2)) > 0);
1406        assert!(panel.lit_in(row_area(&epd, 5)) > 0);
1407        assert!(panel.lit_in(row_area(&epd, 6)) > 0);
1408    }
1409
1410    /// The rows a resting device is not spending: neither a closed
1411    /// pairing window nor plain advertising may put anything on screen.
1412    #[test]
1413    fn nominal_state_costs_no_rows() {
1414        for layout in layouts() {
1415            let model = UiModel::new(MenuItems::all());
1416            let mut nominal = demo_status();
1417            nominal.pairing = PairingState::Closed;
1418            nominal.link = LinkState::Advertising;
1419            let mut quiet = TestPanel::new(layout.size);
1420            render_frame(&mut quiet, &layout, &model, &nominal);
1421
1422            for (label, busy) in [
1423                ("pairing", PairingState::Open { pin: Some(123_456) }),
1424                ("lockout", PairingState::LockedOut),
1425            ] {
1426                let mut status = nominal;
1427                status.pairing = busy;
1428                let mut panel = TestPanel::new(layout.size);
1429                render_frame(&mut panel, &layout, &model, &status);
1430                assert!(
1431                    panel.lit_in(row_area(&layout, 2)) != quiet.lit_in(row_area(&layout, 2)),
1432                    "{label} did not claim a row on {:?}",
1433                    layout.size
1434                );
1435            }
1436
1437            for link in [
1438                LinkState::Attached,
1439                LinkState::Connected,
1440                LinkState::OffWired,
1441            ] {
1442                let mut status = nominal;
1443                status.link = link;
1444                let mut panel = TestPanel::new(layout.size);
1445                render_frame(&mut panel, &layout, &model, &status);
1446                assert!(
1447                    panel.lit_in(row_area(&layout, 2)) != quiet.lit_in(row_area(&layout, 2)),
1448                    "{link:?} did not claim a row on {:?}",
1449                    layout.size
1450                );
1451            }
1452        }
1453    }
1454
1455    /// The bond count moved to where it changes a decision.
1456    #[test]
1457    fn the_confirmation_names_how_many_bonds_it_would_destroy() {
1458        let layout = Layout::EPD_200X200;
1459        let mut counts = [0usize; 3];
1460        for (index, bonds) in [0u8, 1, 4].iter().enumerate() {
1461            let mut model = UiModel::new(MenuItems::all());
1462            model.apply(UiInput::Backward);
1463            model.apply(UiInput::Select);
1464            assert!(matches!(model.page(), Page::Confirm { .. }));
1465
1466            let mut status = demo_status();
1467            status.bonds = *bonds;
1468            let mut panel = TestPanel::new(layout.size);
1469            render_frame(&mut panel, &layout, &model, &status);
1470            counts[index] = panel.lit_in(row_area(&layout, 1));
1471        }
1472        assert_ne!(counts[0], counts[1]);
1473        assert_ne!(counts[1], counts[2]);
1474    }
1475
1476    /// The stats page renders three populated rows on every layout — a
1477    /// deaf node has to be distinguishable from a busy one at a glance.
1478    #[test]
1479    fn the_stats_page_shows_its_counters() {
1480        for layout in layouts() {
1481            let mut model = UiModel::new(MenuItems::all());
1482            model.apply(UiInput::Forward);
1483            assert_eq!(model.page(), Page::Menu(MenuItem::Stats));
1484
1485            let mut panel = TestPanel::new(layout.size);
1486            render_frame(&mut panel, &layout, &model, &demo_status());
1487            for row in 2..=4 {
1488                assert!(
1489                    panel.lit_in(row_area(&layout, row)) > 0,
1490                    "stats row {row} is blank on {:?}",
1491                    layout.size
1492                );
1493            }
1494        }
1495    }
1496
1497    /// Selecting a non-status item frees rows 3 and 4 on the OLED, which
1498    /// is exactly where its two hints belong.
1499    #[test]
1500    fn a_sparse_page_gets_its_hints_back() {
1501        let mut model = UiModel::new(MenuItems::all());
1502        model.apply(UiInput::Forward);
1503        let layout = Layout::OLED_128X64;
1504        let mut panel = TestPanel::new(layout.size);
1505        render_frame(&mut panel, &layout, &model, &demo_status());
1506        assert!(panel.lit_in(row_area(&layout, 3)) > 0);
1507        assert!(panel.lit_in(row_area(&layout, 4)) > 0);
1508    }
1509
1510    fn row_area(layout: &Layout, row: usize) -> Rectangle {
1511        Rectangle::new(
1512            Point::new(0, layout.row_top(row)),
1513            Size::new(layout.size.width, layout.font.character_size.height),
1514        )
1515    }
1516
1517    /// Every page on every layout stays inside the panel — `TestPanel`
1518    /// asserts on any pixel that does not.
1519    #[test]
1520    fn no_page_draws_outside_the_panel() {
1521        for layout in layouts() {
1522            for pairing in [
1523                PairingState::LockedOut,
1524                PairingState::Open { pin: Some(123_456) },
1525                PairingState::Open { pin: None },
1526                PairingState::Closed,
1527            ] {
1528                for link in [
1529                    LinkState::Attached,
1530                    LinkState::Connected,
1531                    LinkState::Advertising,
1532                    LinkState::OffWired,
1533                ] {
1534                    let mut status = demo_status();
1535                    status.pairing = pairing;
1536                    status.link = link;
1537
1538                    let mut model = UiModel::new(MenuItems::all());
1539                    for _ in 0..MenuItem::ALL.len() {
1540                        let mut panel = TestPanel::new(layout.size);
1541                        render_frame(&mut panel, &layout, &model, &status);
1542                        model.apply(UiInput::Forward);
1543                    }
1544
1545                    let mut panel = TestPanel::new(layout.size);
1546                    render_message(
1547                        &mut panel,
1548                        &layout,
1549                        &status,
1550                        "Locate alert",
1551                        "Press to stop",
1552                    );
1553                }
1554            }
1555        }
1556    }
1557
1558    /// A notice takes the top content row and pushes the rest down rather
1559    /// than replacing them: with the nominal rows gone there is room for
1560    /// both, and a PIN the user is mid-way through typing must not
1561    /// vanish because an unrelated action reported back.
1562    #[test]
1563    fn a_notice_takes_the_top_row_without_displacing_the_pin() {
1564        let layout = Layout::OLED_128X64;
1565        let mut model = UiModel::new(MenuItems::all());
1566        let mut status = demo_status();
1567        status.pairing = PairingState::Open { pin: Some(123_456) };
1568
1569        let mut with_pin = TestPanel::new(layout.size);
1570        render_frame(&mut with_pin, &layout, &model, &status);
1571        let pin_row = with_pin.lit_in(row_area(&layout, 2));
1572        assert!(pin_row > 0);
1573
1574        model.set_notice(UiNotice::BondsCleared);
1575        let mut with_notice = TestPanel::new(layout.size);
1576        render_frame(&mut with_notice, &layout, &model, &status);
1577
1578        // The notice is now row 2 and the PIN has moved to row 3.
1579        assert_ne!(with_notice.lit_in(row_area(&layout, 2)), pin_row);
1580        assert_eq!(with_notice.lit_in(row_area(&layout, 3)), pin_row);
1581    }
1582
1583    /// Charging with no level draws a bolt and nothing else: there is no
1584    /// level, so there is no body — the two are drawn independently.
1585    #[test]
1586    fn charging_without_a_level_replaces_the_body_with_a_bolt() {
1587        for layout in layouts() {
1588            let zone = layout.battery_zone();
1589            let mut charging = TestPanel::new(layout.size);
1590            let mut status = demo_status();
1591            status.battery = BatteryIndicator {
1592                level_percent: None,
1593                charge: Some(ChargeClass::Charging),
1594            };
1595            render_frame(
1596                &mut charging,
1597                &layout,
1598                &UiModel::new(MenuItems::all()),
1599                &status,
1600            );
1601
1602            // Something is in the zone, and it is not the outline: an
1603            // unknown-level pack that is *not* charging draws the body,
1604            // and the two must not look alike.
1605            assert!(charging.lit_in(zone) > 0, "charging drew nothing at all");
1606
1607            let mut unknown = TestPanel::new(layout.size);
1608            let mut status = demo_status();
1609            status.battery = BatteryIndicator::UNKNOWN;
1610            render_frame(
1611                &mut unknown,
1612                &layout,
1613                &UiModel::new(MenuItems::all()),
1614                &status,
1615            );
1616            assert_ne!(
1617                charging.lit_in(zone),
1618                unknown.lit_in(zone),
1619                "charging and no-reading drew the same picture on {:?}",
1620                layout.size
1621            );
1622
1623            // The bolt keeps the zone's right edge, so the indicator does
1624            // not shift sideways when a charger goes in. Its rightmost
1625            // column must be lit and everything left of the bolt clear —
1626            // which also proves no body outline survived.
1627            let solo = layout.battery.solo_bolt;
1628            let right_edge = Rectangle::new(
1629                Point::new(
1630                    zone.top_left.x + zone.size.width as i32 - 1,
1631                    zone.top_left.y,
1632                ),
1633                Size::new(1, zone.size.height),
1634            );
1635            assert!(
1636                charging.lit_in(right_edge) > 0,
1637                "the solo bolt is not right-aligned on {:?}",
1638                layout.size
1639            );
1640
1641            let left_of_bolt = Rectangle::new(
1642                zone.top_left,
1643                Size::new(zone.size.width - solo.width, zone.size.height),
1644            );
1645            assert_eq!(
1646                charging.lit_in(left_of_bolt),
1647                0,
1648                "something remained left of the solo bolt on {:?}",
1649                layout.size
1650            );
1651        }
1652    }
1653
1654    /// A board that *can* see charge completion still supplies a level,
1655    /// and keeps its body plus a bolt beside it.
1656    #[test]
1657    fn a_known_level_keeps_its_body_even_while_charging() {
1658        let layout = Layout::OLED_128X64;
1659        let zone = layout.battery_zone();
1660
1661        let mut charging = TestPanel::new(layout.size);
1662        let mut status = demo_status();
1663        status.battery = BatteryIndicator {
1664            level_percent: Some(100),
1665            charge: Some(ChargeClass::Charged),
1666        };
1667        render_frame(
1668            &mut charging,
1669            &layout,
1670            &UiModel::new(MenuItems::all()),
1671            &status,
1672        );
1673
1674        let mut solo = TestPanel::new(layout.size);
1675        let mut status = demo_status();
1676        status.battery = BatteryIndicator {
1677            level_percent: None,
1678            charge: Some(ChargeClass::Charged),
1679        };
1680        render_frame(&mut solo, &layout, &UiModel::new(MenuItems::all()), &status);
1681
1682        assert!(charging.lit_in(zone) > solo.lit_in(zone));
1683    }
1684}