mirui 0.42.0

A lightweight, no_std ECS-driven UI framework for embedded, desktop, and WebAssembly
Documentation
use mirui::ecs::{Entity, World};
use mirui::ui;
use mirui::ui::builder::WidgetBuilder;
use mirui::ui::widgets::Text;
use mirui::ui::{IdMap, NicheMap, Parent, ViewRegistry};

ui!(compose MoldCard {
    @@header @@body @@footer
});

ui!(compose NestedCard {
    Column () {
        View (height: 20) {
            @@header
        }
        View (grow: 1.0) {
            @@body
        }
    }
});

#[test]
fn mold_generates_niche_map_via_attach() {
    let mut world = World::new();
    world.insert_resource(IdMap::new());
    let mut reg = ViewRegistry::default();
    reg.insert(ui!(compose MoldCard));
    world.insert_resource(reg);

    let root = WidgetBuilder::new(&mut world).id();

    ui! {
        :(
            parent: root
            world: &mut world
        :)

        MoldCard () {
            @body { Text("body content") {} }
            @footer { Text("footer text") {} }
        }
    };

    let cards: Vec<Entity> = world.query::<MoldCard>().collect();
    assert_eq!(cards.len(), 1, "mold spawns exactly one MoldCard entity");
    let card = cards[0];

    let map = world
        .get::<NicheMap>(card)
        .expect("mold attach registered NicheMap on the entity");
    let header = map.get("header").expect("header slot registered");
    let body = map.get("body").expect("body slot registered");
    let footer = map.get("footer").expect("footer slot registered");

    let texts: Vec<Entity> = world.query::<Text>().collect();
    assert_eq!(texts.len(), 2, "@body + @footer filled two texts");

    for &t in &texts {
        let parent = world.get::<Parent>(t).expect("text has Parent").0;
        let text_val = world.get::<Text>(t).expect("text has Text");
        let bytes = text_val.resolve(&world);
        match &*bytes {
            "body content" => assert_eq!(parent, body),
            "footer text" => assert_eq!(parent, footer),
            other => panic!("unexpected text content {other:?}"),
        }
    }

    let all_with_parent: Vec<Entity> = world.query::<Parent>().collect();
    let unused_header_children: Vec<Entity> = all_with_parent
        .into_iter()
        .filter(|&e| world.get::<Parent>(e).unwrap().0 == header)
        .collect();
    assert!(
        unused_header_children.is_empty(),
        "header niche should be empty since @header was not used"
    );
}

#[test]
fn mold_body_expands_nested_widget_tree() {
    let mut world = World::new();
    world.insert_resource(IdMap::new());
    let mut reg = ViewRegistry::default();
    reg.insert(ui!(compose NestedCard));
    world.insert_resource(reg);

    let root = WidgetBuilder::new(&mut world).id();

    ui! {
        :(
            parent: root
            world: &mut world
        :)

        NestedCard () {
            @header { Text("title") {} }
            @body { Text("content") {} }
        }
    };

    let cards: Vec<Entity> = world.query::<NestedCard>().collect();
    assert_eq!(cards.len(), 1);
    let card = cards[0];

    let map = world
        .get::<NicheMap>(card)
        .expect("attach registered NicheMap");
    let header_slot = map.get("header").expect("header slot registered");
    let body_slot = map.get("body").expect("body slot registered");

    let header_parent = world
        .get::<Parent>(header_slot)
        .expect("header has parent")
        .0;
    let body_parent = world.get::<Parent>(body_slot).expect("body has parent").0;
    assert_ne!(
        header_parent, card,
        "header slot lives under the wrapper View, not the card root"
    );
    assert_ne!(
        body_parent, card,
        "body slot lives under the wrapper View, not the card root"
    );
    assert_ne!(
        header_parent, body_parent,
        "header and body sit inside two different wrapper Views"
    );

    let column_parent = world
        .get::<Parent>(header_parent)
        .expect("wrapper View has parent")
        .0;
    let column_parent_of_body = world
        .get::<Parent>(body_parent)
        .expect("wrapper View has parent")
        .0;
    assert_eq!(
        column_parent, column_parent_of_body,
        "both wrapper Views share the Column"
    );
    assert_eq!(
        world
            .get::<Parent>(column_parent)
            .expect("column has parent")
            .0,
        card,
        "Column is the direct child of the NestedCard root"
    );

    let texts: Vec<Entity> = world.query::<Text>().collect();
    assert_eq!(texts.len(), 2);
    for &t in &texts {
        let parent = world.get::<Parent>(t).unwrap().0;
        let text_val = world.get::<Text>(t).unwrap();
        let bytes = text_val.resolve(&world);
        match &*bytes {
            "title" => assert_eq!(parent, header_slot),
            "content" => assert_eq!(parent, body_slot),
            other => panic!("unexpected text content {other:?}"),
        }
    }
}

ui!(compose FallbackCard {
    @@header {
        Text ("Default header") {}
    }
    @@body {
        Text ("Default body") {}
    }
});

#[test]
fn mold_unfilled_slot_keeps_fallback() {
    let mut world = World::new();
    world.insert_resource(IdMap::new());
    let mut reg = ViewRegistry::default();
    reg.insert(ui!(compose FallbackCard));
    world.insert_resource(reg);

    let root = WidgetBuilder::new(&mut world).id();

    ui! {
        :(
            parent: root
            world: &mut world
        :)

        FallbackCard () {
        }
    };

    let cards: Vec<Entity> = world.query::<FallbackCard>().collect();
    assert_eq!(cards.len(), 1);
    let card = cards[0];
    let map = world.get::<NicheMap>(card).unwrap();
    let header = map.get("header").unwrap();
    let body = map.get("body").unwrap();

    let texts: Vec<Entity> = world.query::<Text>().collect();
    assert_eq!(texts.len(), 2, "both slots keep their fallback text");
    for &t in &texts {
        let parent = world.get::<Parent>(t).unwrap().0;
        let bytes = world.get::<Text>(t).unwrap().resolve(&world);
        match &*bytes {
            "Default header" => assert_eq!(parent, header),
            "Default body" => assert_eq!(parent, body),
            other => panic!("unexpected fallback text {other:?}"),
        }
    }
}

#[test]
fn mold_filled_slot_despawns_fallback() {
    let mut world = World::new();
    world.insert_resource(IdMap::new());
    let mut reg = ViewRegistry::default();
    reg.insert(ui!(compose FallbackCard));
    world.insert_resource(reg);

    let root = WidgetBuilder::new(&mut world).id();

    ui! {
        :(
            parent: root
            world: &mut world
        :)

        FallbackCard () {
            @body { Text ("Custom body") {} }
        }
    };

    let cards: Vec<Entity> = world.query::<FallbackCard>().collect();
    assert_eq!(cards.len(), 1);
    let card = cards[0];
    let map = world.get::<NicheMap>(card).unwrap();
    let header = map.get("header").unwrap();
    let body = map.get("body").unwrap();

    let texts: Vec<Entity> = world.query::<Text>().collect();
    let contents: Vec<String> = texts
        .iter()
        .map(|&t| (t, world.get::<Text>(t).unwrap().resolve(&world).to_string()))
        .map(|(_, s)| s)
        .collect();

    assert!(
        contents.iter().any(|s| s == "Default header"),
        "header fallback kept (slot not filled). got {contents:?}"
    );
    assert!(
        contents.iter().any(|s| s == "Custom body"),
        "body replaced with caller's text. got {contents:?}"
    );
    assert!(
        !contents.iter().any(|s| s == "Default body"),
        "old fallback body must be despawned. got {contents:?}"
    );

    for &t in &texts {
        let parent = world.get::<Parent>(t).unwrap().0;
        let bytes = world.get::<Text>(t).unwrap().resolve(&world);
        match &*bytes {
            "Default header" => assert_eq!(parent, header),
            "Custom body" => assert_eq!(parent, body),
            other => panic!("unexpected text {other:?}"),
        }
    }
}

use mirui::types::Fixed;
use mirui::ui::Style;

ui!(compose SizedCard(pad: Fixed, radius: Fixed) {
    View (
        border_radius: radius,
        padding: mirui::ui::layout::Padding::all(pad)
    ) {
        @@body
    }
});

#[test]
fn mold_param_values_flow_into_body_attrs() {
    let mut world = World::new();
    world.insert_resource(IdMap::new());
    let mut reg = ViewRegistry::default();
    reg.insert(ui!(compose SizedCard));
    world.insert_resource(reg);

    let root = WidgetBuilder::new(&mut world).id();

    ui! {
        :(
            parent: root
            world: &mut world
        :)

        SizedCard (
            pad: Fixed::from_int(8),
            radius: Fixed::from_int(12)
        ) {
            @body { Text ("bordered") {} }
        }
    };

    let cards: Vec<Entity> = world.query::<SizedCard>().collect();
    assert_eq!(cards.len(), 1);
    let card = cards[0];

    let map = world.get::<NicheMap>(card).unwrap();
    let body = map.get("body").expect("body slot registered");

    let wrapper = world.get::<Parent>(body).expect("body has parent").0;
    let style = world
        .get::<Style>(wrapper)
        .expect("wrapper View has a Style");
    assert_eq!(
        style.border_radius,
        Fixed::from_int(12),
        "wrapper border_radius should be the radius param, got {:?}",
        style.border_radius
    );
}