iced-cube 0.0.6

Themeable application components for the iced GUI library, with Lucide icons
Documentation
#![cfg(feature = "status-bar")]

use std::thread;

use futures::executor::block_on;
use futures::{SinkExt, StreamExt};
use iced::{Element, Settings, Size};
use iced_cube::feedback::badge::Variant;
use iced_cube::lucide;
use iced_cube::status_bar::{
    self, Event, Output, Section, State, Update, action, badge, progress, spinner, status_bar, text,
};
use iced_test::simulator::{Simulator, simulator};

#[derive(Debug, Clone)]
enum Message {
    Status(Event<&'static str>),
}

fn view<'a>(state: &'a State<&'static str>) -> Element<'a, Message> {
    status_bar(state).on_event(Message::Status).into()
}

fn sample() -> State<&'static str> {
    State::new().with_items([
        text("branch", "main").icon(lucide!(GitBranch)),
        spinner("sync", "Syncing"),
        progress("build", 0.5)
            .label("Building")
            .section(Section::Centre),
        action("problems", "2 problems")
            .icon(lucide!(TriangleAlert))
            .section(Section::End),
        badge("mode", "Insert", Variant::Secondary).section(Section::End),
    ])
}

#[test]
fn a_producer_thread_feeds_items_through_the_ready_channel() {
    let mut state = State::new();
    let mut events = Box::pin(status_bar::stream::<&'static str>());
    let Some(Event::Ready(sender)) = block_on(events.next()) else {
        panic!("the first event must be Ready");
    };
    let Some(Output::Ready(sender)) = state.update(Event::Ready(sender)) else {
        panic!("Ready hands the sender back");
    };

    let producer = thread::spawn(move || {
        let mut sender = sender;
        block_on(async {
            for line in 1..=20 {
                let cursor = text("cursor", format!("Ln {line}, Col 1")).section(Section::End);
                sender
                    .send(Update::Set(cursor))
                    .await
                    .expect("receiver is alive");
            }
            sender
                .send(Update::Set(spinner("sync", "Syncing")))
                .await
                .expect("receiver is alive");
            sender
                .send(Update::Set(text("sync", "Up to date").icon(lucide!(Check))))
                .await
                .expect("receiver is alive");
        });
    });

    let mut batches = 0;
    while state
        .item(&"sync")
        .is_none_or(|item| item.label != "Up to date")
    {
        let Some(event) = block_on(events.next()) else {
            panic!("the channel closed early");
        };
        assert!(matches!(event, Event::Received(_)));
        batches += 1;
        let _ = state.update(event);
    }
    producer.join().expect("producer finished");
    assert!(batches >= 1);
    assert_eq!(state.items().len(), 2, "updates replace items by id");
    assert!(!state.needs_frames(), "the spinner was replaced");

    let mut ui = simulator(view(&state));
    assert!(ui.find("Ln 20, Col 1").is_ok());
    assert!(ui.find("Up to date").is_ok());
    assert!(ui.find("Ln 19, Col 1").is_err());
}

#[test]
fn renders_every_kind_of_item() {
    let state = sample();
    let mut ui = simulator(view(&state));
    for label in ["main", "Syncing", "Building", "2 problems", "Insert"] {
        assert!(ui.find(label).is_ok(), "{label}");
    }
}

#[test]
fn sections_sit_at_the_start_centre_and_end() {
    let state = sample();
    let mut ui = Simulator::with_size(Settings::default(), Size::new(900.0, 200.0), view(&state));
    let start = ui.find("main").expect("start item").bounds();
    let centre = ui.find("Building").expect("centre item").bounds();
    let end = ui.find("Insert").expect("end item").bounds();
    assert!(start.x < 60.0, "{start:?}");
    assert!(centre.x > 300.0 && centre.x < 600.0, "{centre:?}");
    assert!(end.x > 800.0, "{end:?}");
    assert!((start.y - end.y).abs() < 2.0, "one line: {start:?} {end:?}");
}

#[test]
fn a_narrow_bar_wraps_its_items_onto_more_lines() {
    let state = sample();
    let mut ui = Simulator::with_size(Settings::default(), Size::new(260.0, 200.0), view(&state));
    let first = ui.find("main").expect("first item").bounds();
    let last = ui.find("Insert").expect("last item").bounds();
    assert!(last.y > first.y, "the end section wraps below");
    assert!(last.x + last.width <= 260.0, "nothing runs off the edge");
}

#[test]
fn clicking_an_action_reports_it() {
    let mut state = sample();
    let mut ui = simulator(view(&state));
    ui.click("2 problems").expect("action is rendered");
    let messages: Vec<Message> = ui.into_messages().collect();
    let [Message::Status(event)] = messages.as_slice() else {
        panic!("expected one message, got {messages:?}");
    };
    assert!(matches!(event, Event::Press("problems")));
    assert!(matches!(
        state.update(event.clone()),
        Some(Output::Pressed("problems"))
    ));
}

#[test]
fn a_bar_without_messages_is_inert() {
    let state = sample();
    let element: Element<'_, Message> = status_bar(&state).into();
    let mut ui = simulator(element);
    ui.click("2 problems").expect("action is rendered");
    assert!(ui.into_messages().next().is_none());
}