embedded_menu/
lib.rs

1#![cfg_attr(not(test), no_std)]
2
3pub mod adapters;
4pub mod builder;
5pub mod collection;
6pub mod interaction;
7pub mod items;
8pub mod selection_indicator;
9pub mod theme;
10
11mod margin;
12
13use crate::{
14    builder::MenuBuilder,
15    collection::MenuItemCollection,
16    interaction::{
17        programmed::Programmed, Action, InputAdapter, InputAdapterSource, InputResult, InputState,
18        Interaction, Navigation,
19    },
20    selection_indicator::{
21        style::{line::Line as LineIndicator, IndicatorStyle},
22        AnimatedPosition, Indicator, SelectionIndicatorController, State as IndicatorState,
23        StaticPosition,
24    },
25    theme::Theme,
26};
27use core::marker::PhantomData;
28use embedded_graphics::{
29    draw_target::DrawTarget,
30    geometry::{AnchorPoint, AnchorX, AnchorY},
31    mono_font::{ascii::FONT_6X10, MonoFont, MonoTextStyle},
32    pixelcolor::BinaryColor,
33    prelude::{Dimensions, DrawTargetExt, Point},
34    primitives::{Line, Primitive, PrimitiveStyle, Rectangle},
35    Drawable,
36};
37use embedded_layout::{layout::linear::LinearLayout, prelude::*, view_group::ViewGroup};
38use embedded_text::{
39    style::{HeightMode, TextBoxStyle},
40    TextBox,
41};
42
43pub use embedded_menu_macros::SelectValue;
44
45#[derive(Copy, Clone, Debug)]
46pub enum DisplayScrollbar {
47    Display,
48    Hide,
49    Auto,
50}
51
52#[derive(Copy, Clone, Debug)]
53pub struct MenuStyle<S, IT, P, R, T> {
54    pub(crate) theme: T,
55    pub(crate) scrollbar: DisplayScrollbar,
56    pub(crate) font: &'static MonoFont<'static>,
57    pub(crate) title_font: &'static MonoFont<'static>,
58    pub(crate) input_adapter: IT,
59    pub(crate) indicator: Indicator<P, S>,
60    _marker: PhantomData<R>,
61}
62
63impl<R> Default for MenuStyle<LineIndicator, Programmed, StaticPosition, R, BinaryColor> {
64    fn default() -> Self {
65        Self::new(BinaryColor::On)
66    }
67}
68
69impl<T, R> MenuStyle<LineIndicator, Programmed, StaticPosition, R, T>
70where
71    T: Theme,
72{
73    pub const fn new(theme: T) -> Self {
74        Self {
75            theme,
76            scrollbar: DisplayScrollbar::Auto,
77            font: &FONT_6X10,
78            title_font: &FONT_6X10,
79            input_adapter: Programmed,
80            indicator: Indicator {
81                style: LineIndicator,
82                controller: StaticPosition,
83            },
84            _marker: PhantomData,
85        }
86    }
87}
88
89impl<S, IT, P, R, T> MenuStyle<S, IT, P, R, T>
90where
91    S: IndicatorStyle,
92    IT: InputAdapterSource<R>,
93    P: SelectionIndicatorController,
94    T: Theme,
95{
96    pub const fn with_font(self, font: &'static MonoFont<'static>) -> Self {
97        Self { font, ..self }
98    }
99
100    pub const fn with_title_font(self, title_font: &'static MonoFont<'static>) -> Self {
101        Self { title_font, ..self }
102    }
103
104    pub const fn with_scrollbar_style(self, scrollbar: DisplayScrollbar) -> Self {
105        Self { scrollbar, ..self }
106    }
107
108    pub const fn with_selection_indicator<S2>(
109        self,
110        indicator_style: S2,
111    ) -> MenuStyle<S2, IT, P, R, T>
112    where
113        S2: IndicatorStyle,
114    {
115        MenuStyle {
116            theme: self.theme,
117            scrollbar: self.scrollbar,
118            font: self.font,
119            title_font: self.title_font,
120            input_adapter: self.input_adapter,
121            indicator: Indicator {
122                style: indicator_style,
123                controller: self.indicator.controller,
124            },
125            _marker: PhantomData,
126        }
127    }
128
129    pub const fn with_input_adapter<IT2>(self, input_adapter: IT2) -> MenuStyle<S, IT2, P, R, T>
130    where
131        IT2: InputAdapterSource<R>,
132    {
133        MenuStyle {
134            theme: self.theme,
135            input_adapter,
136            scrollbar: self.scrollbar,
137            font: self.font,
138            title_font: self.title_font,
139            indicator: self.indicator,
140            _marker: PhantomData,
141        }
142    }
143
144    pub const fn with_animated_selection_indicator(
145        self,
146        frames: i32,
147    ) -> MenuStyle<S, IT, AnimatedPosition, R, T> {
148        MenuStyle {
149            theme: self.theme,
150            input_adapter: self.input_adapter,
151            scrollbar: self.scrollbar,
152            font: self.font,
153            title_font: self.title_font,
154            indicator: Indicator {
155                style: self.indicator.style,
156                controller: AnimatedPosition::new(frames),
157            },
158            _marker: PhantomData,
159        }
160    }
161
162    pub fn text_style(&self) -> MonoTextStyle<'static, BinaryColor> {
163        MonoTextStyle::new(self.font, BinaryColor::On)
164    }
165
166    pub fn title_style(&self) -> MonoTextStyle<'static, T::Color> {
167        MonoTextStyle::new(self.title_font, self.theme.text_color())
168    }
169}
170
171pub struct NoItems;
172
173pub struct MenuState<IT, P, S>
174where
175    IT: InputAdapter,
176    P: SelectionIndicatorController,
177    S: IndicatorStyle,
178{
179    selected: usize,
180    list_offset: i32,
181    interaction_state: IT::State,
182    indicator_state: IndicatorState<P, S>,
183    last_input_state: InputState,
184}
185
186impl<IT, P, S> Default for MenuState<IT, P, S>
187where
188    IT: InputAdapter,
189    P: SelectionIndicatorController,
190    S: IndicatorStyle,
191{
192    fn default() -> Self {
193        Self {
194            selected: 0,
195            list_offset: Default::default(),
196            interaction_state: Default::default(),
197            indicator_state: Default::default(),
198            last_input_state: InputState::Idle,
199        }
200    }
201}
202
203impl<IT, P, S> Clone for MenuState<IT, P, S>
204where
205    IT: InputAdapter,
206    P: SelectionIndicatorController,
207    S: IndicatorStyle,
208{
209    fn clone(&self) -> Self {
210        *self
211    }
212}
213
214impl<IT, P, S> Copy for MenuState<IT, P, S>
215where
216    IT: InputAdapter,
217    P: SelectionIndicatorController,
218    S: IndicatorStyle,
219{
220}
221
222impl<IT, P, S> MenuState<IT, P, S>
223where
224    IT: InputAdapter,
225    P: SelectionIndicatorController,
226    S: IndicatorStyle,
227{
228    pub fn reset_interaction(&mut self) {
229        self.interaction_state = Default::default();
230    }
231
232    fn set_selected_item<ITS, R, T>(
233        &mut self,
234        selected: usize,
235        items: &impl MenuItemCollection<R>,
236        style: &MenuStyle<S, ITS, P, R, T>,
237    ) where
238        ITS: InputAdapterSource<R, InputAdapter = IT>,
239        T: Theme,
240    {
241        let selected =
242            Navigation::JumpTo(selected)
243                .calculate_selection(self.selected, items.count(), |i| items.selectable(i));
244        self.selected = selected;
245
246        let selected_offset = items.bounds_of(selected).top_left.y;
247
248        style
249            .indicator
250            .change_selected_item(selected_offset, &mut self.indicator_state);
251    }
252}
253
254pub struct Menu<T, IT, VG, R, P, S, C>
255where
256    T: AsRef<str>,
257    IT: InputAdapterSource<R>,
258    P: SelectionIndicatorController,
259    S: IndicatorStyle,
260    C: Theme,
261{
262    _return_type: PhantomData<R>,
263    title: T,
264    items: VG,
265    style: MenuStyle<S, IT, P, R, C>,
266    state: MenuState<IT::InputAdapter, P, S>,
267}
268
269impl<T, R, S, C> Menu<T, Programmed, NoItems, R, StaticPosition, S, C>
270where
271    T: AsRef<str>,
272    S: IndicatorStyle,
273    C: Theme,
274{
275    /// Creates a new menu builder with the given title.
276    pub fn build(title: T) -> MenuBuilder<T, Programmed, NoItems, R, StaticPosition, S, C>
277    where
278        MenuStyle<S, Programmed, StaticPosition, R, C>: Default,
279    {
280        Self::with_style(title, MenuStyle::default())
281    }
282}
283
284impl<T, IT, R, P, S, C> Menu<T, IT, NoItems, R, P, S, C>
285where
286    T: AsRef<str>,
287    S: IndicatorStyle,
288    IT: InputAdapterSource<R>,
289    P: SelectionIndicatorController,
290    C: Theme,
291{
292    /// Creates a new menu builder with the given title and style.
293    pub fn with_style(
294        title: T,
295        style: MenuStyle<S, IT, P, R, C>,
296    ) -> MenuBuilder<T, IT, NoItems, R, P, S, C> {
297        MenuBuilder::new(title, style)
298    }
299}
300
301impl<T, IT, VG, R, P, S, C> Menu<T, IT, VG, R, P, S, C>
302where
303    T: AsRef<str>,
304    IT: InputAdapterSource<R>,
305    VG: MenuItemCollection<R>,
306    P: SelectionIndicatorController,
307    S: IndicatorStyle,
308    C: Theme,
309{
310    pub fn interact(&mut self, input: <IT::InputAdapter as InputAdapter>::Input) -> Option<R> {
311        let input = self
312            .style
313            .input_adapter
314            .adapter()
315            .handle_input(&mut self.state.interaction_state, input);
316
317        self.state.last_input_state = match input {
318            InputResult::Interaction(_) => InputState::Idle,
319            InputResult::StateUpdate(state) => state,
320        };
321
322        match input {
323            InputResult::Interaction(interaction) => match interaction {
324                Interaction::Navigation(navigation) => {
325                    let count = self.items.count();
326                    let new_selected =
327                        navigation.calculate_selection(self.state.selected, count, |i| {
328                            self.items.selectable(i)
329                        });
330                    if new_selected != self.state.selected {
331                        self.state
332                            .set_selected_item(new_selected, &self.items, &self.style);
333                    }
334                    None
335                }
336                Interaction::Action(Action::Select) => {
337                    let value = self.items.interact_with(self.state.selected);
338                    Some(value)
339                }
340                Interaction::Action(Action::Return(value)) => Some(value),
341            },
342            _ => None,
343        }
344    }
345
346    pub fn state(&self) -> MenuState<IT::InputAdapter, P, S> {
347        self.state
348    }
349}
350
351impl<T, IT, VG, R, P, S, C> Menu<T, IT, VG, R, P, S, C>
352where
353    T: AsRef<str>,
354    R: Copy,
355    IT: InputAdapterSource<R>,
356    VG: MenuItemCollection<R>,
357    C: Theme,
358    P: SelectionIndicatorController,
359    S: IndicatorStyle,
360{
361    pub fn selected_value(&self) -> R {
362        self.items.value_of(self.state.selected)
363    }
364}
365
366impl<T, IT, VG, R, C, P, S> Menu<T, IT, VG, R, P, S, C>
367where
368    T: AsRef<str>,
369    IT: InputAdapterSource<R>,
370    VG: ViewGroup + MenuItemCollection<R>,
371    P: SelectionIndicatorController,
372    S: IndicatorStyle,
373    C: Theme,
374{
375    fn header<'t>(
376        &self,
377        title: &'t str,
378        display_area: Rectangle,
379    ) -> Option<impl View + 't + Drawable<Color = C::Color>>
380    where
381        C: Theme + 't,
382    {
383        if title.is_empty() {
384            return None;
385        }
386
387        let text_style = self.style.title_style();
388        let thin_stroke = PrimitiveStyle::with_stroke(self.style.theme.text_color(), 1);
389        let header = LinearLayout::vertical(
390            Chain::new(TextBox::with_textbox_style(
391                title,
392                display_area,
393                text_style,
394                TextBoxStyle::with_height_mode(HeightMode::FitToText),
395            ))
396            .append(
397                // Bottom border
398                Line::new(
399                    display_area.top_left,
400                    display_area.anchor_point(AnchorPoint::TopRight),
401                )
402                .into_styled(thin_stroke),
403            ),
404        )
405        .arrange();
406
407        Some(header)
408    }
409
410    fn top_offset(&self) -> i32 {
411        self.style.indicator.offset(&self.state.indicator_state) - self.state.list_offset
412    }
413
414    pub fn update(&mut self, display: &impl Dimensions) {
415        // animations
416        self.style
417            .indicator
418            .update(self.state.last_input_state, &mut self.state.indicator_state);
419
420        // Ensure selection indicator is always visible by moving the menu list.
421        let top_distance = self.top_offset();
422
423        let list_offset_change = if top_distance > 0 {
424            let display_area = display.bounding_box();
425            let display_height = display_area.size().height as i32;
426
427            let header_height = if let Some(header) = self.header(self.title.as_ref(), display_area)
428            {
429                header.size().height as i32
430            } else {
431                0
432            };
433
434            let selected_height = MenuItemCollection::bounds_of(&self.items, self.state.selected)
435                .size()
436                .height as i32;
437            let indicator_height = self
438                .style
439                .indicator
440                .item_height(selected_height, &self.state.indicator_state);
441
442            // Indicator is below display top. We only have to
443            // move if indicator bottom is below display bottom.
444            (top_distance + indicator_height + header_height - display_height).max(0)
445        } else {
446            // We need to move up
447            top_distance
448        };
449
450        // Move menu list.
451        self.state.list_offset += list_offset_change;
452    }
453}
454
455impl<T, IT, VG, R, C, P, S> Drawable for Menu<T, IT, VG, R, P, S, C>
456where
457    T: AsRef<str>,
458    IT: InputAdapterSource<R>,
459    VG: ViewGroup + MenuItemCollection<R>,
460    P: SelectionIndicatorController,
461    S: IndicatorStyle,
462    C: Theme,
463{
464    type Color = C::Color;
465    type Output = ();
466
467    fn draw<D>(&self, display: &mut D) -> Result<(), D::Error>
468    where
469        D: DrawTarget<Color = C::Color>,
470    {
471        let display_area = display.bounding_box();
472
473        let header = self.header(self.title.as_ref(), display_area);
474        let content_area = if let Some(header) = header {
475            header.draw(display)?;
476            display_area.resized_height(
477                display_area.size().height - header.size().height,
478                AnchorY::Bottom,
479            )
480        } else {
481            display_area
482        };
483
484        let menu_height = content_area.size().height as i32;
485        let list_height = self.items.bounds().size().height as i32;
486
487        let draw_scrollbar = match self.style.scrollbar {
488            DisplayScrollbar::Display => true,
489            DisplayScrollbar::Hide => false,
490            DisplayScrollbar::Auto => list_height > menu_height,
491        };
492
493        let menu_display_area = if draw_scrollbar {
494            let scrollbar_area = content_area.resized_width(2, AnchorX::Right);
495            let thin_stroke = PrimitiveStyle::with_stroke(self.style.theme.text_color(), 1);
496
497            let scale = |value| value * menu_height / list_height;
498
499            let scrollbar_height = scale(menu_height).max(1);
500            let mut scrollbar_display = display.cropped(&scrollbar_area);
501
502            // Start scrollbar from y=1, so we have a margin on top instead of bottom
503            Line::new(Point::new(0, 1), Point::new(0, scrollbar_height))
504                .into_styled(thin_stroke)
505                .translate(Point::new(1, scale(self.state.list_offset)))
506                .draw(&mut scrollbar_display)?;
507
508            content_area.resized_width(
509                content_area.size().width - scrollbar_area.size().width,
510                AnchorX::Left,
511            )
512        } else {
513            content_area
514        };
515
516        let selected_menuitem_height =
517            MenuItemCollection::bounds_of(&self.items, self.state.selected)
518                .size()
519                .height as i32;
520
521        self.style.indicator.draw(
522            selected_menuitem_height,
523            self.top_offset(),
524            self.state.last_input_state,
525            display.cropped(&menu_display_area),
526            &self.items,
527            &self.style,
528            &self.state,
529        )?;
530
531        Ok(())
532    }
533}