mirui 0.45.0

A lightweight, no_std ECS-driven UI framework for embedded, desktop, and WebAssembly
Documentation
extern crate alloc;

use crate::anim::{BOUNCY, PlayMode, SMOOTH, Spring, Tween, ease};
use crate::prelude::*;
use crate::ui;
use crate::ui::icons::IconAsset;
use crate::ui::icons::{
    ICON_ARROW_DOWN, ICON_ARROW_LEFT, ICON_ARROW_RIGHT, ICON_ARROW_UP, ICON_CHECK,
    ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_CHEVRON_RIGHT, ICON_CHEVRON_UP, ICON_CIRCLE,
    ICON_CROSS, ICON_HEART, ICON_HOME, ICON_MINUS, ICON_PAUSE, ICON_PLAY, ICON_PLUS, ICON_SQUARE,
    ICON_STAR, ICON_STOP,
};
use crate::ui::theme::{ColorToken, ThemedColor};
use crate::ui::widgets::icon::Icon;
use crate::ui::widgets::{Image, ParagraphStyle, Text, TextAlign};

#[cfg(feature = "std")]
use crate::app::plugins::StdInstantClockPlugin;

mirui_macros::animate!(IconScale, |world, entity, value| {
    if let Some(icon) = world.get_mut::<Icon>(entity) {
        icon.scale = value;
    }
    world.invalidate(entity);
});

fn icons() -> [(IconAsset, ColorToken); 20] {
    [
        (ICON_HOME, ColorToken::Primary),
        (ICON_CHECK, ColorToken::Success),
        (ICON_CROSS, ColorToken::Error),
        (ICON_PLUS, ColorToken::OnSurface),
        (ICON_MINUS, ColorToken::OnSurface),
        (ICON_ARROW_LEFT, ColorToken::OnSurfaceVariant),
        (ICON_ARROW_RIGHT, ColorToken::OnSurfaceVariant),
        (ICON_ARROW_UP, ColorToken::OnSurfaceVariant),
        (ICON_ARROW_DOWN, ColorToken::OnSurfaceVariant),
        (ICON_CHEVRON_LEFT, ColorToken::Primary),
        (ICON_CHEVRON_RIGHT, ColorToken::Primary),
        (ICON_CHEVRON_UP, ColorToken::Primary),
        (ICON_CHEVRON_DOWN, ColorToken::Primary),
        (ICON_STAR, ColorToken::Primary),
        (ICON_HEART, ColorToken::Error),
        (ICON_PLAY, ColorToken::Success),
        (ICON_PAUSE, ColorToken::OnSurface),
        (ICON_STOP, ColorToken::Error),
        (ICON_CIRCLE, ColorToken::OnSurfaceVariant),
        (ICON_SQUARE, ColorToken::OnSurfaceVariant),
    ]
}

fn beat() -> IconScale {
    IconScale(
        Tween::new(
            Fixed::ONE,
            Fixed::from_f32(1.4),
            600,
            ease::ease_in_out_cubic,
            PlayMode::PingPong,
        )
        .into(),
    )
}

fn breathe() -> IconScale {
    IconScale(
        Spring::preset(Fixed::ONE, Fixed::from_f32(1.25), SMOOTH)
            .repeat()
            .into(),
    )
}

fn bounce() -> IconScale {
    IconScale(
        Spring::preset(Fixed::from_f32(0.8), Fixed::from_f32(1.2), BOUNCY)
            .repeat()
            .into(),
    )
}

#[compose]
pub fn build_widgets() {
    let table = icons();
    ui! {
        Column (
            id: "icon_gallery_shell",
            grow: 1.0,
            padding: Padding::all(12),
            row_gap: 8,
            bg_color: ColorToken::Surface
        ) {
            Text (
                "VECTOR ICONS",
                height: 24,
                font_size: 18,
                text_color: ColorToken::OnSurface,
                paragraph: ParagraphStyle::label().with_align(TextAlign::Start)
            )
            Text (
                "STATIC PATHS · LIVE THEME · RETAINED MOTION",
                height: 18,
                font_size: 9,
                text_color: ColorToken::OnSurfaceVariant,
                paragraph: ParagraphStyle::label().with_align(TextAlign::Start)
            )
            Row (
                id: "icon_gallery_grid",
                grow: 1.0,
                wrap: FlexWrap::Wrap,
                justify: JustifyContent::Center,
                align: AlignItems::Center,
                row_gap: 6,
                column_gap: 6
            ) {
                walk table.iter() with cell {
                    Image (
                        src: cell.0,
                        color: ThemedColor::Token(cell.1),
                        viewbox: Fixed::from_int(24),
                        scale: Fixed::from_ratio(7, 6),
                        width: 44,
                        height: 44,
                        grow: 0.0,
                        bg_color: ColorToken::SurfaceVariant,
                        border_radius: 10
                    )
                }
            }
            Text (
                "MOTION PRESETS",
                height: 18,
                font_size: 9,
                text_color: ColorToken::OnSurfaceVariant,
                paragraph: ParagraphStyle::label().with_align(TextAlign::Start)
            )
            Row (
                id: "icon_gallery_motion",
                height: 54,
                justify: JustifyContent::SpaceEvenly,
                align: AlignItems::Center,
                column_gap: 4
            ) {
                Icon (
                    path: ICON_HEART,
                    color: ThemedColor::Token(ColorToken::Error),
                    size: Dimension::Px(Fixed::from_int(34)),
                    grow: 1.0,
                    height: 52
                ) [
                    beat(),
                ]
                Icon (
                    path: ICON_CIRCLE,
                    color: ThemedColor::Token(ColorToken::Primary),
                    size: Dimension::Px(Fixed::from_int(34)),
                    grow: 1.0,
                    height: 52
                ) [
                    breathe(),
                ]
                Icon (
                    path: ICON_STAR,
                    color: ThemedColor::Token(ColorToken::Success),
                    size: Dimension::Px(Fixed::from_int(34)),
                    grow: 1.0,
                    height: 52
                ) [
                    bounce(),
                ]
                Icon (
                    path: ICON_PLAY,
                    color: ThemedColor::Token(ColorToken::Primary),
                    size: Dimension::Px(Fixed::from_int(34)),
                    grow: 1.0,
                    height: 52
                ) [
                    beat(),
                ]
                Icon (
                    path: ICON_PLUS,
                    color: ThemedColor::Token(ColorToken::OnSurface),
                    size: Dimension::Px(Fixed::from_int(34)),
                    grow: 1.0,
                    height: 52
                ) [
                    bounce(),
                ]
            }
        }
    };
}

#[cfg(feature = "std")]
pub fn setup_app<B, F>(app: &mut App<B, F>, parent: Entity)
where
    B: Surface,
    F: RendererFactory<B>,
{
    app.add_system(IconScale::system());
    app.add_plugin(StdInstantClockPlugin);
    app.compose(parent, build_widgets);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::Viewport;
    use crate::ui::Children;
    use crate::ui::render_system::update_layout;
    use crate::ui::{ComputedRect, IdMap, UiScope};

    #[test]
    fn build_widgets_smoke() {
        let mut world = World::new();
        world.insert_resource(IdMap::new());
        let parent = WidgetBuilder::new(&mut world).id();
        let mut cx = UiScope::new(&mut world, parent);
        build_widgets(&mut cx);
        assert!(
            world
                .get::<Children>(parent)
                .is_some_and(|c| !c.0.is_empty()),
        );
    }

    #[test]
    fn icons_table_has_twenty_entries() {
        assert_eq!(icons().len(), 20);
    }

    #[test]
    fn icon_grid_stays_inside_phone_shell() {
        let mut world = World::new();
        world.insert_resource(IdMap::new());
        let parent = WidgetBuilder::new(&mut world).id();
        let mut cx = UiScope::new(&mut world, parent);
        build_widgets(&mut cx);
        drop(cx);

        update_layout(&mut world, parent, &Viewport::new(320, 568, Fixed::ONE));
        let rect = |id| {
            world
                .get::<ComputedRect>(world.find_by_id(id).unwrap())
                .unwrap()
                .0
        };
        let shell = rect("icon_gallery_shell");
        for id in ["icon_gallery_grid", "icon_gallery_motion"] {
            let child = rect(id);
            assert!(child.x >= shell.x);
            assert!(child.x + child.w <= shell.x + shell.w);
        }
    }
}