1#![cfg_attr(not(test), no_std)]
2#![deny(clippy::missing_const_for_fn)]
3pub mod adapters;
4pub mod builder;
5pub mod collection;
6pub mod interaction;
7pub mod items;
8pub mod margin;
9pub mod selection_indicator;
10pub mod theme;
11
12use crate::{
13 builder::MenuBuilder,
14 collection::MenuItemCollection,
15 interaction::{
16 programmed::Programmed, Action, InputAdapter, InputAdapterSource, InputResult, InputState,
17 Interaction, Navigation,
18 },
19 selection_indicator::{
20 style::{line::Line as LineIndicator, IndicatorStyle},
21 AnimatedPosition, Indicator, SelectionIndicatorController, State as IndicatorState,
22 StaticPosition,
23 },
24 theme::Theme,
25};
26use core::marker::PhantomData;
27use embedded_graphics::{
28 draw_target::DrawTarget,
29 geometry::{AnchorPoint, AnchorX, AnchorY},
30 mono_font::{ascii::FONT_6X10, MonoFont, MonoTextStyle},
31 pixelcolor::BinaryColor,
32 prelude::{Dimensions, DrawTargetExt, Point},
33 primitives::{Line, Primitive, PrimitiveStyle, Rectangle},
34 Drawable,
35};
36use embedded_layout::{layout::linear::LinearLayout, prelude::*, view_group::ViewGroup};
37use embedded_text::{
38 style::{HeightMode, TextBoxStyle},
39 TextBox,
40};
41
42pub use embedded_menu_macros::SelectValue;
43
44#[derive(Copy, Clone, Debug)]
45pub enum DisplayScrollbar {
46 Display,
47 Hide,
48 Auto,
49}
50
51#[derive(Copy, Clone, Debug)]
52pub struct MenuStyle<S, IT, P, R, T> {
53 pub(crate) theme: T,
54 pub(crate) scrollbar: DisplayScrollbar,
55 pub(crate) font: &'static MonoFont<'static>,
56 pub(crate) title_font: &'static MonoFont<'static>,
57 pub(crate) input_adapter: IT,
58 pub(crate) indicator: Indicator<P, S>,
59 _marker: PhantomData<R>,
60}
61
62impl<R> Default for MenuStyle<LineIndicator, Programmed, StaticPosition, R, BinaryColor> {
63 fn default() -> Self {
64 Self::new(BinaryColor::On)
65 }
66}
67
68impl<T, R> MenuStyle<LineIndicator, Programmed, StaticPosition, R, T>
69where
70 T: Theme,
71{
72 pub const fn new(theme: T) -> Self {
73 Self {
74 theme,
75 scrollbar: DisplayScrollbar::Auto,
76 font: &FONT_6X10,
77 title_font: &FONT_6X10,
78 input_adapter: Programmed,
79 indicator: Indicator {
80 style: LineIndicator,
81 controller: StaticPosition,
82 },
83 _marker: PhantomData,
84 }
85 }
86}
87
88impl<S, IT, P, R, T> MenuStyle<S, IT, P, R, T>
89where
90 S: IndicatorStyle,
91 IT: InputAdapterSource<R>,
92 P: SelectionIndicatorController,
93 T: Theme,
94{
95 pub const fn with_font(self, font: &'static MonoFont<'static>) -> Self {
96 Self { font, ..self }
97 }
98
99 pub const fn with_title_font(self, title_font: &'static MonoFont<'static>) -> Self {
100 Self { title_font, ..self }
101 }
102
103 pub const fn with_scrollbar_style(self, scrollbar: DisplayScrollbar) -> Self {
104 Self { scrollbar, ..self }
105 }
106
107 pub const fn with_selection_indicator<S2>(
108 self,
109 indicator_style: S2,
110 ) -> MenuStyle<S2, IT, P, R, T>
111 where
112 S2: IndicatorStyle,
113 {
114 MenuStyle {
115 theme: self.theme,
116 scrollbar: self.scrollbar,
117 font: self.font,
118 title_font: self.title_font,
119 input_adapter: self.input_adapter,
120 indicator: Indicator {
121 style: indicator_style,
122 controller: self.indicator.controller,
123 },
124 _marker: PhantomData,
125 }
126 }
127
128 pub const fn with_input_adapter<IT2>(self, input_adapter: IT2) -> MenuStyle<S, IT2, P, R, T>
129 where
130 IT2: InputAdapterSource<R>,
131 {
132 MenuStyle {
133 theme: self.theme,
134 input_adapter,
135 scrollbar: self.scrollbar,
136 font: self.font,
137 title_font: self.title_font,
138 indicator: self.indicator,
139 _marker: PhantomData,
140 }
141 }
142
143 pub const fn with_animated_selection_indicator(
144 self,
145 frames: i32,
146 ) -> MenuStyle<S, IT, AnimatedPosition, R, T> {
147 MenuStyle {
148 theme: self.theme,
149 input_adapter: self.input_adapter,
150 scrollbar: self.scrollbar,
151 font: self.font,
152 title_font: self.title_font,
153 indicator: Indicator {
154 style: self.indicator.style,
155 controller: AnimatedPosition::new(frames),
156 },
157 _marker: PhantomData,
158 }
159 }
160
161 pub const fn text_style(&self) -> MonoTextStyle<'static, BinaryColor> {
162 MonoTextStyle::new(self.font, BinaryColor::On)
163 }
164
165 pub fn title_style(&self) -> MonoTextStyle<'static, T::Color> {
166 MonoTextStyle::new(self.title_font, self.theme.text_color())
167 }
168}
169
170pub struct NoItems;
171
172pub struct MenuState<IT, P, S>
173where
174 IT: InputAdapter,
175 P: SelectionIndicatorController,
176 S: IndicatorStyle,
177{
178 selected: usize,
179 list_offset: i32,
180 interaction_state: IT::State,
181 indicator_state: IndicatorState<P, S>,
182 last_input_state: InputState,
183}
184
185impl<IT, P, S> Default for MenuState<IT, P, S>
186where
187 IT: InputAdapter,
188 P: SelectionIndicatorController,
189 S: IndicatorStyle,
190{
191 fn default() -> Self {
192 Self {
193 selected: 0,
194 list_offset: Default::default(),
195 interaction_state: Default::default(),
196 indicator_state: Default::default(),
197 last_input_state: InputState::Idle,
198 }
199 }
200}
201
202impl<IT, P, S> Clone for MenuState<IT, P, S>
203where
204 IT: InputAdapter,
205 P: SelectionIndicatorController,
206 S: IndicatorStyle,
207{
208 fn clone(&self) -> Self {
209 *self
210 }
211}
212
213impl<IT, P, S> Copy for MenuState<IT, P, S>
214where
215 IT: InputAdapter,
216 P: SelectionIndicatorController,
217 S: IndicatorStyle,
218{
219}
220
221impl<IT, P, S> MenuState<IT, P, S>
222where
223 IT: InputAdapter,
224 P: SelectionIndicatorController,
225 S: IndicatorStyle,
226{
227 pub fn reset_interaction(&mut self) {
228 self.interaction_state = Default::default();
229 }
230
231 fn relayout<ITS, R, T>(
236 &mut self,
237 selected: usize,
238 items: &impl MenuItemCollection<R>,
239 style: &MenuStyle<S, ITS, P, R, T>,
240 ) where
241 ITS: InputAdapterSource<R, InputAdapter = IT>,
242 T: Theme,
243 {
244 let selected =
245 Navigation::JumpTo(selected)
246 .calculate_selection(self.selected, items.count(), |i| items.selectable(i));
247 self.selected = selected;
248
249 let selected_offset = if items.count() == 0 {
250 0
251 } else {
252 items.bounds_of(selected).top_left.y
253 };
254
255 style
256 .indicator
257 .update_target(selected_offset, &mut self.indicator_state);
258 }
259
260 fn set_selected_item<ITS, R, T>(
261 &mut self,
262 selected: usize,
263 items: &impl MenuItemCollection<R>,
264 style: &MenuStyle<S, ITS, P, R, T>,
265 ) where
266 ITS: InputAdapterSource<R, InputAdapter = IT>,
267 T: Theme,
268 {
269 self.relayout(selected, items, style);
270 style.indicator.on_target_changed(&mut self.indicator_state);
271 }
272}
273
274pub struct Menu<T, IT, VG, R, P, S, C>
275where
276 T: AsRef<str>,
277 IT: InputAdapterSource<R>,
278 P: SelectionIndicatorController,
279 S: IndicatorStyle,
280 C: Theme,
281{
282 _return_type: PhantomData<R>,
283 title: T,
284 items: VG,
285 style: MenuStyle<S, IT, P, R, C>,
286 state: MenuState<IT::InputAdapter, P, S>,
287 dirty: bool,
288}
289
290impl<T, R, S, C> Menu<T, Programmed, NoItems, R, StaticPosition, S, C>
291where
292 T: AsRef<str>,
293 S: IndicatorStyle,
294 C: Theme,
295{
296 pub fn build(title: T) -> MenuBuilder<T, Programmed, NoItems, R, StaticPosition, S, C>
298 where
299 MenuStyle<S, Programmed, StaticPosition, R, C>: Default,
300 {
301 Self::with_style(title, MenuStyle::default())
302 }
303}
304
305impl<T, IT, R, P, S, C> Menu<T, IT, NoItems, R, P, S, C>
306where
307 T: AsRef<str>,
308 S: IndicatorStyle,
309 IT: InputAdapterSource<R>,
310 P: SelectionIndicatorController,
311 C: Theme,
312{
313 pub const fn with_style(
315 title: T,
316 style: MenuStyle<S, IT, P, R, C>,
317 ) -> MenuBuilder<T, IT, NoItems, R, P, S, C> {
318 MenuBuilder::new(title, style)
319 }
320}
321
322impl<T, IT, VG, R, P, S, C> Menu<T, IT, VG, R, P, S, C>
323where
324 T: AsRef<str>,
325 IT: InputAdapterSource<R>,
326 VG: MenuItemCollection<R>,
327 P: SelectionIndicatorController,
328 S: IndicatorStyle,
329 C: Theme,
330{
331 pub fn interact(&mut self, input: <IT::InputAdapter as InputAdapter>::Input) -> Option<R> {
332 let input = self
333 .style
334 .input_adapter
335 .adapter()
336 .handle_input(&mut self.state.interaction_state, input);
337
338 let last_input_state = match input {
339 InputResult::None => self.state.last_input_state, InputResult::Interaction(_) => InputState::Idle,
341 InputResult::StateUpdate(state) => state,
342 };
343
344 if last_input_state != self.state.last_input_state {
347 self.dirty = true;
348 }
349 self.state.last_input_state = last_input_state;
350
351 match input {
352 InputResult::Interaction(interaction) => match interaction {
353 Interaction::Navigation(navigation) => {
354 let count = self.items.count();
355 let new_selected =
356 navigation.calculate_selection(self.state.selected, count, |i| {
357 self.items.selectable(i)
358 });
359 if new_selected != self.state.selected {
360 self.state
361 .set_selected_item(new_selected, &self.items, &self.style);
362 self.dirty = true;
363 }
364 None
365 }
366 Interaction::Action(Action::Select) => {
367 if self.items.count() == 0 {
368 return None;
369 }
370
371 self.dirty = true;
373 let value = self.items.interact_with(self.state.selected);
374 Some(value)
375 }
376 Interaction::Action(Action::Return(value)) => Some(value),
377 },
378 _ => None,
379 }
380 }
381
382 pub const fn state(&self) -> MenuState<IT::InputAdapter, P, S> {
383 self.state
384 }
385}
386
387impl<T, IT, VG, R, P, S, C> Menu<T, IT, VG, R, P, S, C>
388where
389 T: AsRef<str>,
390 R: Copy,
391 IT: InputAdapterSource<R>,
392 VG: MenuItemCollection<R>,
393 C: Theme,
394 P: SelectionIndicatorController,
395 S: IndicatorStyle,
396{
397 pub fn selected_value(&self) -> R {
403 assert!(self.items.count() > 0, "the menu has no items");
404 self.items.value_of(self.state.selected)
405 }
406}
407
408impl<T, IT, VG, R, C, P, S> Menu<T, IT, VG, R, P, S, C>
409where
410 T: AsRef<str>,
411 IT: InputAdapterSource<R>,
412 VG: ViewGroup + MenuItemCollection<R>,
413 P: SelectionIndicatorController,
414 S: IndicatorStyle,
415 C: Theme,
416{
417 fn header<'t>(
418 &self,
419 title: &'t str,
420 display_area: Rectangle,
421 ) -> Option<impl View + 't + Drawable<Color = C::Color>>
422 where
423 C: Theme + 't,
424 {
425 if title.is_empty() {
426 return None;
427 }
428
429 let text_style = self.style.title_style();
430 let thin_stroke = PrimitiveStyle::with_stroke(self.style.theme.text_color(), 1);
431 let header = LinearLayout::vertical(
432 Chain::new(TextBox::with_textbox_style(
433 title,
434 display_area,
435 text_style,
436 TextBoxStyle::with_height_mode(HeightMode::FitToText),
437 ))
438 .append(
439 Line::new(
441 display_area.top_left,
442 display_area.anchor_point(AnchorPoint::TopRight),
443 )
444 .into_styled(thin_stroke),
445 ),
446 )
447 .arrange();
448
449 Some(header)
450 }
451
452 fn top_offset(&self) -> i32 {
453 self.style.indicator.offset(&self.state.indicator_state) - self.state.list_offset
454 }
455
456 fn selected_item_height(&self) -> i32 {
458 if self.items.count() == 0 {
459 0
460 } else {
461 MenuItemCollection::bounds_of(&self.items, self.state.selected)
462 .size()
463 .height as i32
464 }
465 }
466
467 pub fn update(&mut self, display: &impl Dimensions) -> bool {
485 let animation_changed = self
487 .style
488 .indicator
489 .update(self.state.last_input_state, &mut self.state.indicator_state);
490
491 let top_distance = self.top_offset();
493
494 let list_offset_change = if top_distance > 0 {
495 let display_area = display.bounding_box();
496 let display_height = display_area.size().height as i32;
497
498 let header_height = if let Some(header) = self.header(self.title.as_ref(), display_area)
499 {
500 header.size().height as i32
501 } else {
502 0
503 };
504
505 let selected_height = self.selected_item_height();
506 let indicator_height = self
507 .style
508 .indicator
509 .item_height(selected_height, &self.state.indicator_state);
510
511 (top_distance + indicator_height + header_height - display_height).max(0)
514 } else {
515 top_distance
517 };
518
519 self.state.list_offset += list_offset_change;
521
522 if animation_changed || list_offset_change != 0 {
523 self.dirty = true;
524 }
525
526 core::mem::take(&mut self.dirty)
527 }
528}
529
530impl<T, IT, VG, R, C, P, S> Drawable for Menu<T, IT, VG, R, P, S, C>
531where
532 T: AsRef<str>,
533 IT: InputAdapterSource<R>,
534 VG: ViewGroup + MenuItemCollection<R>,
535 P: SelectionIndicatorController,
536 S: IndicatorStyle,
537 C: Theme,
538{
539 type Color = C::Color;
540 type Output = ();
541
542 fn draw<D>(&self, display: &mut D) -> Result<(), D::Error>
543 where
544 D: DrawTarget<Color = C::Color>,
545 {
546 let display_area = display.bounding_box();
547
548 let header = self.header(self.title.as_ref(), display_area);
549 let content_area = if let Some(header) = header {
550 header.draw(display)?;
551 display_area.resized_height(
552 display_area.size().height - header.size().height,
553 AnchorY::Bottom,
554 )
555 } else {
556 display_area
557 };
558
559 let menu_height = content_area.size().height as i32;
560 let list_height = self.items.bounds().size().height as i32;
561
562 let draw_scrollbar = list_height > 0
564 && match self.style.scrollbar {
565 DisplayScrollbar::Display => true,
566 DisplayScrollbar::Hide => false,
567 DisplayScrollbar::Auto => list_height > menu_height,
568 };
569
570 let menu_display_area = if draw_scrollbar {
571 let scrollbar_area = content_area.resized_width(2, AnchorX::Right);
572 let thin_stroke = PrimitiveStyle::with_stroke(self.style.theme.text_color(), 1);
573
574 let scale = |value| value * menu_height / list_height;
575
576 let scrollbar_height = scale(menu_height).max(1);
577 let mut scrollbar_display = display.cropped(&scrollbar_area);
578
579 Line::new(Point::new(0, 1), Point::new(0, scrollbar_height))
581 .into_styled(thin_stroke)
582 .translate(Point::new(1, scale(self.state.list_offset)))
583 .draw(&mut scrollbar_display)?;
584
585 content_area.resized_width(
586 content_area.size().width - scrollbar_area.size().width,
587 AnchorX::Left,
588 )
589 } else {
590 content_area
591 };
592
593 self.style.indicator.draw(
594 self.selected_item_height(),
595 self.top_offset(),
596 self.state.last_input_state,
597 display.cropped(&menu_display_area),
598 &self.items,
599 &self.style,
600 &self.state,
601 )?;
602
603 Ok(())
604 }
605}
606
607#[cfg(test)]
608mod test {
609 use embedded_graphics::{mock_display::MockDisplay, pixelcolor::BinaryColor, Drawable};
610
611 use crate::{
612 interaction::{Action, Interaction, Navigation},
613 items::MenuItem,
614 selection_indicator::style::AnimatedTriangle,
615 DisplayScrollbar, Menu, MenuStyle,
616 };
617
618 fn test_display() -> MockDisplay<BinaryColor> {
620 let mut display = MockDisplay::new();
621 display.set_allow_overdraw(true);
622 display.set_allow_out_of_bounds_drawing(true);
623 display
624 }
625
626 #[test]
627 fn a_menu_without_items_can_be_used() {
628 let mut items: [MenuItem<&str, (), &str, true>; 0] = [];
629 let mut menu = Menu::with_style(
630 "Title",
631 MenuStyle::new(BinaryColor::On).with_scrollbar_style(DisplayScrollbar::Display),
632 )
633 .add_menu_items(&mut items)
634 .build();
635
636 let mut display = test_display();
637 menu.update(&display);
638 menu.draw(&mut display).unwrap();
639
640 assert_eq!(
641 menu.interact(Interaction::Navigation(Navigation::Next)),
642 None
643 );
644 assert_eq!(menu.interact(Interaction::Action(Action::Select)), None);
645 }
646
647 #[test]
648 fn a_value_wider_than_the_display_can_be_drawn() {
649 let mut menu = Menu::with_style("Title", MenuStyle::new(BinaryColor::On))
650 .add_item("Title", "a marker far wider than the display", |_| ())
651 .build();
652
653 let mut display = test_display();
654 menu.update(&display);
655 menu.draw(&mut display).unwrap();
656 }
657
658 #[test]
659 fn animated_position_settles_and_input_wakes_it_up() {
660 let style = MenuStyle::new(BinaryColor::On).with_animated_selection_indicator(10);
661 let mut menu = Menu::with_style("Title", style)
662 .add_item("Item 1", "", |_| ())
663 .add_item("Item 2", "", |_| ())
664 .add_item("Item 3", "", |_| ())
665 .build();
666
667 let mut display = test_display();
668
669 assert!(menu.update(&display));
671 menu.draw(&mut display).unwrap();
672 assert!(!menu.update(&display));
673
674 menu.interact(Interaction::Navigation(Navigation::Next));
675
676 let mut frames = 0;
677 while menu.update(&display) {
678 menu.draw(&mut display).unwrap();
679 frames += 1;
680 assert!(frames < 100, "the indicator animation did not settle");
681 }
682 assert!(frames > 1, "the indicator did not animate");
683 }
684
685 #[test]
686 fn selecting_an_item_requires_a_redraw() {
687 let style = MenuStyle::new(BinaryColor::On);
688 let mut menu = Menu::with_style("Title", style)
689 .add_item("Item 1", false, |_| ())
690 .add_item("Item 2", false, |_| ())
691 .build();
692
693 let mut display = test_display();
694 assert!(menu.update(&display));
695 menu.draw(&mut display).unwrap();
696 assert!(!menu.update(&display));
697
698 menu.interact(Interaction::Action(Action::Select));
699 assert!(menu.update(&display));
700 }
701
702 #[test]
703 fn animated_triangle_only_redraws_while_the_arrow_moves() {
704 let period = 10;
705 let style =
706 MenuStyle::new(BinaryColor::On).with_selection_indicator(AnimatedTriangle::new(period));
707 let mut menu = Menu::with_style("Title", style)
708 .add_item("Item 1", "", |_| ())
709 .add_item("Item 2", "", |_| ())
710 .build();
711
712 let mut display = test_display();
713 while menu.update(&display) {
714 menu.draw(&mut display).unwrap();
715 }
716
717 let redraws = (0..period)
720 .filter(|_| {
721 let redraw = menu.update(&display);
722 if redraw {
723 menu.draw(&mut display).unwrap();
724 }
725 redraw
726 })
727 .count();
728
729 assert_eq!(redraws, 4);
730 }
731
732 #[test]
733 fn rebuilding_a_menu_does_not_restart_the_indicator_animation() {
734 let period = 10;
735 let new_menu = || {
736 let style = MenuStyle::new(BinaryColor::On)
737 .with_selection_indicator(AnimatedTriangle::new(period));
738 Menu::with_style("Title", style)
739 .add_item("Item 1", "", |_| ())
740 .add_item("Item 2", "", |_| ())
741 };
742
743 let mut menu = new_menu().build();
744 let mut display = test_display();
745
746 let rebuild_period = 3 * period / 5 - 1;
749
750 let mut moved = 0;
751 for frame in 0..3 * period {
752 let rebuilt = frame % rebuild_period == 0;
753 if rebuilt {
754 menu = new_menu().build_with_state(menu.state());
755 }
756
757 if menu.update(&display) {
758 menu.draw(&mut display).unwrap();
759 if !rebuilt {
761 moved += 1;
762 }
763 }
764 }
765
766 assert!(moved > 0, "the arrow did not animate");
767 }
768}