Skip to main content

embedded_menu/
lib.rs

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    /// Points the selection indicator at `selected`, without restarting the indicator's animation.
232    ///
233    /// Use this when the selection itself doesn't change, but the items may have been laid out
234    /// anew, e.g. when the menu is rebuilt from an existing state.
235    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    /// Creates a new menu builder with the given title.
297    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    /// Creates a new menu builder with the given title and style.
314    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, // No change
340            InputResult::Interaction(_) => InputState::Idle,
341            InputResult::StateUpdate(state) => state,
342        };
343
344        // The input state drives how the selection indicator is filled in, so a change to it is
345        // visible.
346        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                    // We can't tell whether the item's appearance changed, so assume it did.
372                    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    /// Returns the value of the selected item, without interacting with it.
398    ///
399    /// # Panics
400    ///
401    /// Panics if the menu has no items, as there is no value to return.
402    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                // Bottom border
440                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    /// The height of the selected menu item, or 0 if the menu has no items.
457    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    /// Advances animations by a single frame, and returns whether the menu needs to be redrawn.
468    ///
469    /// Call this once per frame, and only draw the menu if it returns `true`:
470    ///
471    /// ```ignore
472    /// if menu.update(&display) {
473    ///     menu.draw(&mut display)?;
474    ///     display.flush()?;
475    /// }
476    /// ```
477    ///
478    /// Each call reports the changes since the previous one, so ignoring a `true` means losing
479    /// that frame.
480    ///
481    /// The returned value only accounts for state the menu owns. If you change anything else
482    /// that the menu displays - the value of a menu item, or the contents of an item collection
483    /// you own - draw the menu yourself; it has no way to observe those changes.
484    pub fn update(&mut self, display: &impl Dimensions) -> bool {
485        // animations
486        let animation_changed = self
487            .style
488            .indicator
489            .update(self.state.last_input_state, &mut self.state.indicator_state);
490
491        // Ensure selection indicator is always visible by moving the menu list.
492        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            // Indicator is below display top. We only have to
512            // move if indicator bottom is below display bottom.
513            (top_distance + indicator_height + header_height - display_height).max(0)
514        } else {
515            // We need to move up
516            top_distance
517        };
518
519        // Move menu list.
520        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        // An empty list has nothing to scroll, and scaling by its height would divide by zero.
563        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            // Start scrollbar from y=1, so we have a margin on top instead of bottom
580            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    /// The menu is wider and taller than a `MockDisplay`, and freely overdraws.
619    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        // A menu that has never been drawn must be drawn.
670        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        // The arrow rests at its resting position for 3/5 of every period, and only the frames
718        // where it actually moves need to be drawn.
719        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        // Rebuild the menu more often than the arrow's resting phase (3/5 of the period) lasts.
747        // The animation must still reach the moving phase.
748        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                // A rebuilt menu is always redrawn, so those frames say nothing about the arrow.
760                if !rebuilt {
761                    moved += 1;
762                }
763            }
764        }
765
766        assert!(moved > 0, "the arrow did not animate");
767    }
768}