1use 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
52const LINE: usize = 32;
58
59pub const BATTERY_SEGMENTS: u8 = 4;
68
69pub 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#[derive(Clone, Copy, Debug)]
93pub struct BatteryIconMetrics {
94 pub body: Size,
96 pub nub: Size,
98 pub bolt_width: u32,
100 pub solo_bolt: Size,
104 pub spacing: u32,
106 pub border: u32,
108 pub pad: u32,
110 pub gap: u32,
112 pub margin: u32,
114}
115
116impl BatteryIconMetrics {
117 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 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 pub const fn zone_width(&self) -> u32 {
145 self.bolt_width + self.spacing + self.body.width + self.nub.width
146 }
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum Controls {
156 OneButton,
159 Dpad,
163}
164
165#[derive(Clone, Copy, Debug)]
181pub struct Layout {
182 pub font: &'static MonoFont<'static>,
183 pub left: i32,
185 pub top: i32,
187 pub row_pitch: i32,
189 pub rows: usize,
191 pub size: Size,
192 pub battery: BatteryIconMetrics,
193 pub overflow: Overflow,
195 pub controls: Controls,
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub enum Overflow {
205 ClipRow,
209 ScrollBar,
213}
214
215impl Layout {
216 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 overflow: Overflow::ClipRow,
229 controls: Controls::OneButton,
232 };
233
234 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 overflow: Overflow::ScrollBar,
246 controls: Controls::OneButton,
247 };
248
249 pub const fn row_top(&self, row: usize) -> i32 {
251 self.top + row as i32 * self.row_pitch
252 }
253
254 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum LinkState {
283 Attached,
285 Connected,
287 Advertising,
289 OffWired,
292 Disabled,
296}
297
298#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum PairingState {
301 LockedOut,
303 Open { pin: Option<u32> },
306 Closed,
308}
309
310#[derive(Clone, Copy, Debug, PartialEq, Eq)]
312pub struct BatteryIndicator {
313 pub level_percent: Option<u8>,
317 pub charge: Option<ChargeClass>,
320}
321
322impl BatteryIndicator {
323 pub const UNKNOWN: Self = Self {
325 level_percent: None,
326 charge: None,
327 };
328
329 const fn shows_bolt(&self) -> bool {
339 matches!(
340 self.charge,
341 Some(ChargeClass::Charging) | Some(ChargeClass::Charged)
342 )
343 }
344}
345
346#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
352pub struct StatsModel {
353 pub tx_frames: u32,
354 pub rx_frames: u32,
356 pub rx_accepted: u32,
360 pub forwarded: u32,
361 pub tx_power_dbm: Option<i8>,
363 pub duty_permille: u16,
366}
367
368impl StatsModel {
369 pub const fn rx_dropped(&self) -> u32 {
371 self.rx_frames.saturating_sub(self.rx_accepted)
372 }
373}
374
375#[derive(Clone, Copy, Debug)]
381pub struct StatusModel<'a> {
382 pub device_name: &'a str,
383 pub battery: BatteryIndicator,
384 pub battery_mv: Option<u16>,
388 pub link: LinkState,
389 pub bonds: u8,
395 pub pairing: PairingState,
396 pub stats: StatsModel,
397 pub clock: Option<ClockModel>,
406 pub settings: SettingsModel,
408 pub identity: Option<IdentityModel<'a>>,
410}
411
412#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
433pub struct IdentityModel<'a> {
434 pub hint: &'a str,
437 pub address: &'a str,
441}
442
443#[derive(Clone, Copy, Debug, PartialEq, Eq)]
449pub struct ClockModel {
450 pub hour: u8,
452 pub minute: u8,
454}
455
456impl ClockModel {
457 fn write(&self, out: &mut String<LINE>) {
461 let _ = write!(out, "time {:02}:{:02}", self.hour, self.minute);
462 }
463}
464
465pub 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 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 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 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 write_clear_question(&mut line, status.bonds);
517 draw_row(target, layout, 1, &line);
518 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 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 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
552pub 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
573pub 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 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 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
665fn 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 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
729fn 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
769fn 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 return 2;
785 };
786
787 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 let mut row = 2;
799 let available = layout.rows.saturating_sub(row);
800 if needed > available {
801 draw_row(target, layout, row, identity.hint);
804 return row + 1;
805 }
806 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
827fn 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 let available = layout.rows.saturating_sub(1);
851 let overflows = count > available;
852 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
890fn 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
899fn 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
920fn 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
962fn draw_header<D>(target: &mut D, layout: &Layout, status: &StatusModel<'_>)
965where
966 D: DrawTarget<Color = BinaryColor>,
967{
968 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
989fn 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
1041fn 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
1082fn 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
1094fn 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 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
1134const 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
1163const fn move_hint(controls: Controls) -> &'static str {
1165 match controls {
1166 Controls::OneButton => "1x: next",
1167 Controls::Dpad => "up/dn: move",
1168 }
1169}
1170
1171const 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
1202fn 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
1232const 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
1249fn 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 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 const TEST_PANEL_BYTES: usize = 200 * 200 / 8;
1298
1299 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 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 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 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 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 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 assert_eq!(battery_segments(200), 4);
1456
1457 assert_eq!(battery_segments(55), 2);
1459
1460 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 #[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 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 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 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 #[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 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 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 (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 #[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 #[test]
1665 fn a_crowded_status_page_drops_the_clock_before_the_battery() {
1666 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 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 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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[test]
1906 fn each_control_set_is_hinted_in_its_own_words() {
1907 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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 let rows = inverted_rows(&layout, &model, &demo_status());
2072 assert!(rows.is_empty(), "inverted {rows:?} on {:?}", layout.size);
2073 }
2074 }
2075
2076 #[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 #[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 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 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 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 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 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 #[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 assert_ne!(rows[0], 0, "{item:?} inverted the header");
2211 }
2212 }
2213 }
2214
2215 #[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 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 #[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 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 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 #[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 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 #[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 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 #[test]
2340 fn overflow_never_leaves_the_highlight_half_drawn() {
2341 let items = MenuItems::all();
2342 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 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 #[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 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 #[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 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 #[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 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 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 #[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}