faststep 0.1.0

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

use super::{FsTheme, I18n, Localized, TouchEvent, TouchPhase};

/// One tab definition used by [`TabBar`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TabSpec<'a, K> {
    /// Stable tab identifier.
    pub key: K,
    /// Small icon glyph for the tab.
    pub icon: &'a str,
    /// Localized tab title.
    pub title: Localized<'a>,
}

/// Low-level tab bar renderer and interaction helper.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TabBar<'a, K, const N: usize> {
    tabs: [TabSpec<'a, K>; N],
    active_index: usize,
}

impl<'a, K: Copy, const N: usize> TabBar<'a, K, N> {
    /// Creates a tab bar from specs and an active index.
    pub const fn new(tabs: [TabSpec<'a, K>; N], active_index: usize) -> Self {
        Self { tabs, active_index }
    }

    /// Selects a tab by index.
    pub fn select(&mut self, active_index: usize) {
        self.active_index = active_index.min(N.saturating_sub(1));
    }

    /// Returns the active tab identifier.
    pub fn active(&self) -> K {
        self.tabs[self.active_index.min(N.saturating_sub(1))].key
    }

    /// Returns the content frame above the tab bar.
    pub fn content_frame(&self, bounds: Rectangle, theme: &FsTheme) -> Rectangle {
        Rectangle::new(
            bounds.top_left,
            Size::new(
                bounds.size.width,
                bounds.size.height.saturating_sub(theme.tab_bar_height),
            ),
        )
    }

    /// Routes one touch event to the tab bar.
    pub fn handle_touch(
        &mut self,
        touch: TouchEvent,
        bounds: Rectangle,
        theme: &FsTheme,
    ) -> Option<K> {
        if !matches!(touch.phase, TouchPhase::End) {
            return None;
        }

        let index = self.hit_test(touch, bounds, theme)?;
        if index == self.active_index {
            return None;
        }
        self.active_index = index;
        Some(self.tabs[index].key)
    }

    /// Draws the tab bar.
    pub fn draw_bar<D>(&self, display: &mut D, bounds: Rectangle, theme: &FsTheme, i18n: &I18n<'a>)
    where
        D: DrawTarget<Color = Rgb565>,
    {
        let bar = tab_bar_frame(bounds, theme);
        bar.into_styled(PrimitiveStyle::with_fill(theme.surface))
            .draw(display)
            .ok();
        Line::new(
            bar.top_left,
            bar.top_left + Point::new(bar.size.width as i32, 0),
        )
        .into_styled(PrimitiveStyle::with_stroke(theme.surface_alt, 2))
        .draw(display)
        .ok();

        for index in 0..N {
            self.draw_tab(display, index, bar, theme, i18n);
        }
    }

    fn hit_test(&self, touch: TouchEvent, bounds: Rectangle, theme: &FsTheme) -> Option<usize> {
        if !touch.within(tab_bar_frame(bounds, theme)) {
            return None;
        }

        let width = bounds.size.width as i32 / N as i32;
        let relative_x = touch.point.x - bounds.top_left.x;
        let index = (relative_x / width.max(1)) as usize;
        Some(index.min(N.saturating_sub(1)))
    }

    fn draw_tab<D>(
        &self,
        display: &mut D,
        index: usize,
        bar: Rectangle,
        theme: &FsTheme,
        i18n: &I18n<'a>,
    ) where
        D: DrawTarget<Color = Rgb565>,
    {
        let tab = tab_frame::<N>(bar, index);
        let spec = self.tabs[index];
        let selected = index == self.active_index;
        let accent = if selected {
            theme.accent
        } else {
            theme.surface
        };
        let title_color = if selected {
            theme.text_primary
        } else {
            theme.text_secondary
        };

        if selected {
            Rectangle::new(tab.top_left, Size::new(tab.size.width, 4))
                .into_styled(PrimitiveStyle::with_fill(accent))
                .draw(display)
                .ok();
        }

        let icon_style = MonoTextStyleBuilder::new()
            .font(&FONT_7X14)
            .text_color(title_color)
            .build();
        let title_style = MonoTextStyleBuilder::new()
            .font(&FONT_6X10)
            .text_color(title_color)
            .build();
        let text_style = TextStyleBuilder::new()
            .alignment(Alignment::Center)
            .baseline(Baseline::Middle)
            .build();

        Text::with_text_style(
            spec.icon,
            tab.center() + Point::new(0, -10),
            icon_style,
            text_style,
        )
        .draw(display)
        .ok();
        Text::with_text_style(
            i18n.text(spec.title),
            tab.center() + Point::new(0, 14),
            title_style,
            text_style,
        )
        .draw(display)
        .ok();

        let outline = PrimitiveStyleBuilder::new()
            .stroke_color(theme.surface_alt)
            .stroke_width(1)
            .build();
        Rectangle::new(tab.top_left, Size::new(tab.size.width, tab.size.height))
            .into_styled(outline)
            .draw(display)
            .ok();
    }
}

fn tab_bar_frame(bounds: Rectangle, theme: &FsTheme) -> Rectangle {
    let top =
        bounds.top_left + Point::new(0, bounds.size.height as i32 - theme.tab_bar_height as i32);
    Rectangle::new(top, Size::new(bounds.size.width, theme.tab_bar_height))
}

fn tab_frame<const N: usize>(bar: Rectangle, index: usize) -> Rectangle {
    let tab_width = (bar.size.width / N as u32).max(1);
    let x = bar.top_left.x + (index as i32 * tab_width as i32);
    let width = if index + 1 == N {
        bar.size.width - (tab_width * index as u32)
    } else {
        tab_width
    };
    Rectangle::new(
        Point::new(x, bar.top_left.y),
        Size::new(width, bar.size.height),
    )
}