1use 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
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)]
155pub struct Layout {
156 pub font: &'static MonoFont<'static>,
157 pub left: i32,
159 pub top: i32,
161 pub row_pitch: i32,
163 pub rows: usize,
165 pub size: Size,
166 pub battery: BatteryIconMetrics,
167}
168
169impl Layout {
170 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 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 pub const fn row_top(&self, row: usize) -> i32 {
195 self.top + row as i32 * self.row_pitch
196 }
197
198 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum LinkState {
213 Attached,
215 Connected,
217 Advertising,
219 OffWired,
222}
223
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226pub enum PairingState {
227 LockedOut,
229 Open { pin: Option<u32> },
232 Closed,
234}
235
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub struct BatteryIndicator {
239 pub level_percent: Option<u8>,
243 pub charge: Option<ChargeClass>,
246}
247
248impl BatteryIndicator {
249 pub const UNKNOWN: Self = Self {
251 level_percent: None,
252 charge: None,
253 };
254
255 const fn shows_bolt(&self) -> bool {
265 matches!(
266 self.charge,
267 Some(ChargeClass::Charging) | Some(ChargeClass::Charged)
268 )
269 }
270}
271
272#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
278pub struct StatsModel {
279 pub tx_frames: u32,
280 pub rx_frames: u32,
282 pub rx_accepted: u32,
286 pub forwarded: u32,
287 pub tx_power_dbm: Option<i8>,
289 pub duty_permille: u16,
292}
293
294impl StatsModel {
295 pub const fn rx_dropped(&self) -> u32 {
297 self.rx_frames.saturating_sub(self.rx_accepted)
298 }
299}
300
301#[derive(Clone, Copy, Debug)]
307pub struct StatusModel<'a> {
308 pub device_name: &'a str,
309 pub battery: BatteryIndicator,
310 pub battery_mv: Option<u16>,
314 pub link: LinkState,
315 pub bonds: u8,
321 pub pairing: PairingState,
322 pub stats: StatsModel,
323 pub clock: Option<ClockModel>,
332}
333
334#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340pub struct ClockModel {
341 pub hour: u8,
343 pub minute: u8,
345}
346
347impl ClockModel {
348 fn write(&self, out: &mut String<LINE>) {
352 let _ = write!(out, "time {:02}:{:02}", self.hour, self.minute);
353 }
354}
355
356pub 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 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
418pub 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
439pub 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 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 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
531fn 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 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
595fn 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
635fn draw_header<D>(target: &mut D, layout: &Layout, status: &StatusModel<'_>)
638where
639 D: DrawTarget<Color = BinaryColor>,
640{
641 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
662fn 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
714fn 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 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
754const 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
766const 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
787const 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
803fn 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 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 const TEST_PANEL_BYTES: usize = 200 * 200 / 8;
852
853 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 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 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 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 assert_eq!(battery_segments(200), 4);
973
974 assert_eq!(battery_segments(55), 2);
976
977 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 #[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 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 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 #[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 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 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 (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 #[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 #[test]
1173 fn a_crowded_status_page_drops_the_clock_before_the_battery() {
1174 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 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 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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[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}