faststep 0.1.0

UIKit-inspired embedded UI framework built on embedded-graphics
Documentation
use embedded_graphics::{
    mono_font::{
        MonoTextStyleBuilder,
        ascii::{FONT_7X14, FONT_9X18_BOLD},
    },
    pixelcolor::Rgb565,
    prelude::*,
    primitives::{Line, PrimitiveStyle, PrimitiveStyleBuilder, Rectangle},
    text::{Alignment, Baseline, Text, TextStyleBuilder},
};

use crate::{
    Button, ButtonTouchResponse, ButtonTouchState, FsTheme, I18n, Layer, Localized, StackLayers,
    StackNav, TouchEvent,
};

use super::stack_header::{
    HeaderTitle, back_button, back_title_center, draw_title, header_titles, show_back_button,
};

const HEADER_HEIGHT: u32 = 64;

/// Action emitted by navigation chrome.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavHeaderAction {
    /// Navigate back one level.
    Back,
}

/// Navigation chrome plus stack body geometry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NavView<'a, K> {
    /// Full navigation frame.
    pub frame: Rectangle,
    /// Header frame.
    pub header: Rectangle,
    /// Body frame below the header.
    pub body: Rectangle,
    /// Presentation layers for the body.
    pub layers: StackLayers<K>,
    back_button: Option<Button<'a, NavHeaderAction>>,
    back_title: Option<Localized<'a>>,
    active_title: HeaderTitle<'a>,
    secondary_title: Option<HeaderTitle<'a>>,
}

impl<K: Copy, const N: usize> StackNav<K, N> {
    /// Builds a navigation view for a stack frame and title callback.
    pub fn nav_view<'a, F>(&self, frame: Rectangle, title_for: F) -> NavView<'a, K>
    where
        F: Fn(K) -> Localized<'a>,
    {
        let header_height = HEADER_HEIGHT.min(frame.size.height.saturating_sub(1));
        let header = Rectangle::new(frame.top_left, Size::new(frame.size.width, header_height));
        let body = Rectangle::new(
            frame.top_left + Point::new(0, header_height as i32),
            Size::new(
                frame.size.width,
                frame.size.height.saturating_sub(header_height),
            ),
        );
        let back_button = show_back_button(self).then_some(back_button(header));
        let (back_title, active_title, secondary_title) =
            header_titles(self, header, &title_for, back_button.as_ref());
        NavView {
            frame,
            header,
            body,
            layers: self.layers(body),
            back_button,
            back_title,
            active_title,
            secondary_title,
        }
    }
}

impl<'a> NavView<'a, ()> {
    /// Creates a non-navigating root navigation view.
    pub fn root(frame: Rectangle, title: Localized<'a>) -> Self {
        let header_height = HEADER_HEIGHT.min(frame.size.height.saturating_sub(1));
        let header = Rectangle::new(frame.top_left, Size::new(frame.size.width, header_height));
        let body = Rectangle::new(
            frame.top_left + Point::new(0, header_height as i32),
            Size::new(
                frame.size.width,
                frame.size.height.saturating_sub(header_height),
            ),
        );
        Self {
            frame,
            header,
            body,
            layers: StackLayers {
                base: Layer::new((), body, Point::zero()),
                overlay: None,
            },
            back_button: None,
            back_title: None,
            active_title: HeaderTitle {
                title,
                center: header.center(),
            },
            secondary_title: None,
        }
    }
}

impl<'a, K> NavView<'a, K> {
    /// Draws navigation chrome without preserving touch state.
    pub fn draw_static_chrome<D>(&self, display: &mut D, theme: &FsTheme, i18n: &I18n<'a>)
    where
        D: DrawTarget<Color = Rgb565>,
    {
        let touch = ButtonTouchState::new();
        self.draw_chrome(display, theme, i18n, &touch);
    }

    /// Draws navigation chrome using explicit header touch state.
    pub fn draw_chrome<D>(
        &self,
        display: &mut D,
        theme: &FsTheme,
        i18n: &I18n<'a>,
        touch: &ButtonTouchState<NavHeaderAction>,
    ) where
        D: DrawTarget<Color = Rgb565>,
    {
        let shell = PrimitiveStyleBuilder::new()
            .fill_color(theme.surface_alt)
            .stroke_color(theme.surface)
            .stroke_width(2)
            .build();
        self.frame.into_styled(shell).draw(display).ok();

        self.header
            .into_styled(PrimitiveStyle::with_fill(theme.surface))
            .draw(display)
            .ok();
        Line::new(
            self.header.top_left + Point::new(0, self.header.size.height as i32),
            self.header.bottom_right().unwrap_or(self.header.top_left),
        )
        .into_styled(PrimitiveStyle::with_stroke(theme.surface_alt, 2))
        .draw(display)
        .ok();

        let title_style = MonoTextStyleBuilder::new()
            .font(&FONT_9X18_BOLD)
            .text_color(theme.text_primary)
            .build();
        let back_style = MonoTextStyleBuilder::new()
            .font(&FONT_7X14)
            .text_color(theme.text_primary)
            .build();
        let text_style = TextStyleBuilder::new()
            .alignment(Alignment::Center)
            .baseline(Baseline::Middle)
            .build();
        let settled_back_button = self.back_title.is_some() && self.secondary_title.is_none();

        if settled_back_button {
            let button = self
                .back_button
                .as_ref()
                .expect("settled stacked headers include a back button");
            button.draw_state(display, theme, i18n, touch.is_highlighted(button));
            let arrow = MonoTextStyleBuilder::new()
                .font(&FONT_7X14)
                .text_color(theme.text_primary)
                .build();
            Text::with_text_style(
                "<",
                button.frame.top_left + Point::new(18, (button.frame.size.height / 2) as i32),
                arrow,
                TextStyleBuilder::new()
                    .alignment(Alignment::Center)
                    .baseline(Baseline::Middle)
                    .build(),
            )
            .draw(display)
            .ok();
        }

        let mut header = display.clipped(&self.header);
        if settled_back_button {
            let title = self
                .back_title
                .expect("settled stacked headers include a back title");
            Text::with_text_style(
                i18n.text(title),
                back_title_center(self.back_button.as_ref()),
                back_style,
                text_style,
            )
            .draw(&mut header)
            .ok();
        }
        draw_title(
            &mut header,
            i18n.text(self.active_title.title),
            self.active_title.center,
            title_style,
            text_style,
        );
        if let Some(title) = self.secondary_title {
            draw_title(
                &mut header,
                i18n.text(title.title),
                title.center,
                title_style,
                text_style,
            );
        }
    }

    /// Routes one touch event to the navigation header.
    pub fn handle_touch(
        &self,
        touch_state: &mut ButtonTouchState<NavHeaderAction>,
        touch: TouchEvent,
    ) -> ButtonTouchResponse<NavHeaderAction> {
        if let Some(button) = self.back_button.as_ref() {
            touch_state.handle_touch(touch, core::slice::from_ref(button))
        } else {
            touch_state.clear();
            ButtonTouchResponse {
                action: None,
                captured: false,
                redraw: false,
            }
        }
    }
}