iced-cube 0.0.2

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

use std::thread;

use futures::executor::block_on;
use futures::{SinkExt, StreamExt};
use iced::keyboard::key::Named;
use iced::{Element, widget};
use iced_cube::lucide;
use iced_cube::navigation::command::{self, Event, Output, State, command, group, item, results};
use iced_test::simulator::{Simulator, simulator};

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

fn search_id() -> widget::Id {
    widget::Id::new("command-search")
}

fn sample() -> State<&'static str> {
    State::new([
        group(
            "Suggestions",
            [
                item("calendar", "Calendar").icon(lucide!(Calendar)),
                item("emoji", "Search emoji").icon(lucide!(Image)),
                item("calculator", "Calculator").disabled(true),
            ],
        ),
        group(
            "Settings",
            [
                item("profile", "Profile").shortcut("Ctrl+P"),
                item("billing", "Billing").shortcut("Ctrl+B"),
            ],
        ),
    ])
}

fn view<'a>(state: &'a State<&'static str>) -> Element<'a, Message> {
    command(state)
        .id(search_id())
        .on_event(Message::Command)
        .into()
}

fn events(ui: Simulator<'_, Message>) -> Vec<Event<&'static str>> {
    ui.into_messages()
        .map(|Message::Command(event)| event)
        .collect()
}

fn run(state: &mut State<&'static str>, events: Vec<Event<&'static str>>) -> Vec<&'static str> {
    events
        .into_iter()
        .filter_map(|event| match state.update(event) {
            Some(Output::Activated(id)) => Some(id),
            _ => None,
        })
        .collect()
}

#[test]
fn renders_groups_icons_and_shortcuts() {
    let state = sample();
    let mut ui = simulator(view(&state));
    for text in ["Suggestions", "Settings", "Calendar", "Billing", "Ctrl+B"] {
        assert!(ui.find(text).is_ok(), "{text}");
    }
}

#[test]
fn typing_filters_the_list() {
    let mut state = sample();
    let mut ui = simulator(view(&state));
    ui.click(search_id()).expect("search field is rendered");
    let _ = ui.typewrite("b");

    let events = events(ui);
    assert!(
        matches!(events.as_slice(), [Event::Focus(true), Event::Input(query)] if query == "b"),
        "{events:?}"
    );
    let outputs: Vec<_> = events.into_iter().map(|e| state.update(e)).collect();
    assert!(matches!(outputs.as_slice(), [None, Some(Output::Search(query))] if query == "b"));

    let mut ui = simulator(view(&state));
    assert!(ui.find("Billing").is_ok());
    assert!(ui.find("Calendar").is_err());
    assert!(ui.find("Suggestions").is_err());
}

#[test]
fn arrows_and_enter_run_the_highlighted_item() {
    let mut state = sample();
    let mut ui = simulator(view(&state));
    ui.click(search_id()).expect("search field is rendered");
    let _ = ui.tap_key(Named::ArrowDown);
    let _ = ui.tap_key(Named::ArrowDown);
    let _ = ui.tap_key(Named::Enter);

    let events = events(ui);
    assert!(
        matches!(
            events.as_slice(),
            [
                Event::Focus(true),
                Event::Next,
                Event::Next,
                Event::ActivateHighlighted
            ]
        ),
        "{events:?}"
    );
    // The disabled calculator is skipped.
    assert_eq!(run(&mut state, events), ["profile"]);
}

#[test]
fn escape_clears_the_query() {
    let mut state = sample();
    let _ = state.update(Event::Input("bill".into()));

    let mut ui = simulator(view(&state));
    ui.click(search_id()).expect("search field is rendered");
    let _ = ui.tap_key(Named::Escape);

    let events = events(ui);
    assert!(
        matches!(events.as_slice(), [Event::Focus(true), Event::Close]),
        "{events:?}"
    );
    for event in events {
        let _ = state.update(event);
    }
    assert_eq!(state.query(), "");
    assert_eq!(state.len(), 5);
}

#[test]
fn clicking_an_item_runs_it() {
    let mut state = sample();
    let mut ui = simulator(view(&state));
    ui.click("Billing").expect("item is rendered");
    let events = events(ui);
    assert_eq!(run(&mut state, events), ["billing"]);
}

#[test]
fn disabled_items_do_not_run() {
    let mut state = sample();
    let mut ui = simulator(view(&state));
    ui.click("Calculator").expect("item is rendered");
    let events = events(ui);
    assert!(run(&mut state, events).is_empty());
}

#[test]
fn focusing_and_leaving_the_field_toggle_the_highlight() {
    let mut state = sample();
    let mut ui = simulator(view(&state));
    ui.click(search_id()).expect("search field is rendered");
    ui.point_at(iced::Point::new(900.0, 700.0));
    let _ = ui.simulate(iced_test::simulator::click());
    let events = events(ui);
    assert!(
        matches!(events.as_slice(), [Event::Focus(true), Event::Focus(false)]),
        "{events:?}"
    );

    assert!(!state.shows_highlight());
    let _ = state.update(Event::Focus(true));
    assert!(state.shows_highlight());
    let _ = state.update(Event::Focus(false));
    assert!(!state.shows_highlight());
}

#[test]
fn a_capped_list_shrinks_to_a_few_results() {
    use iced::widget::{column, text};

    let mut state = sample();
    let _ = state.update(Event::Input("bill".into()));
    let element: Element<'_, Message> = column![
        command(&state).max_height(400.0).on_event(Message::Command),
        text("Below"),
    ]
    .into();
    let mut ui = simulator(element);
    let billing = ui.find("Billing").expect("result is rendered").bounds();
    let below = ui.find("Below").expect("text under the list").bounds();
    assert!(
        below.y < billing.y + billing.height + 40.0,
        "the list ends just after its one result: {billing:?} {below:?}"
    );
}

#[test]
fn a_capped_list_stops_at_its_maximum() {
    use iced::widget::{column, text};

    let state = sample();
    let element: Element<'_, Message> = column![
        command(&state).max_height(120.0).on_event(Message::Command),
        text("Below"),
    ]
    .into();
    let mut ui = simulator(element);
    let below = ui.find("Below").expect("text under the list").bounds();
    assert!(below.y <= 120.5, "{below:?}");
}

#[test]
fn keys_pass_through_without_focus() {
    let state = sample();
    let mut ui = simulator(view(&state));
    let _ = ui.tap_key(Named::ArrowDown);
    let _ = ui.tap_key(Named::Enter);
    assert!(events(ui).is_empty());
}

#[test]
fn empty_and_loading_messages() {
    let mut state = sample();
    let _ = state.update(Event::Input("zzz".into()));

    let element: Element<'_, Message> = command(&state)
        .empty("Nothing here.")
        .on_event(Message::Command)
        .into();
    let mut ui = simulator(element);
    assert!(ui.find("Nothing here.").is_ok());

    let element: Element<'_, Message> = command(&state)
        .loading(true)
        .on_event(Message::Command)
        .into();
    let mut ui = simulator(element);
    assert!(ui.find(command::SEARCHING).is_ok());
}

#[test]
fn producer_thread_streams_results_and_stale_ones_are_dropped() {
    let mut events = Box::pin(command::stream::<&'static str>());
    let Some(Event::Ready(sender)) = block_on(events.next()) else {
        panic!("the first event must be Ready");
    };

    let mut state = sample();
    let _ = state.update(Event::Input("rep".into()));

    let producer = thread::spawn(move || {
        let mut sender = sender;
        block_on(async {
            sender
                .send(results("re", "Files", [item("stale", "readme.md")]))
                .await
                .expect("receiver is alive");
            for name in ["report.pdf", "reports.csv", "repo.zip"] {
                sender
                    .send(results("rep", "Files", [item(name, name)]))
                    .await
                    .expect("receiver is alive");
            }
        });
    });
    producer.join().expect("producer finished");

    let mut received = 0;
    while received < 4 {
        let Some(event) = block_on(events.next()) else {
            break;
        };
        let Event::Received(batch) = &event else {
            panic!("expected a batch, got {event:?}");
        };
        assert!(!batch.is_empty() && batch.len() <= command::BATCH_SIZE);
        received += batch.len();
        let _ = state.update(event);
    }

    let files: Vec<_> = state
        .results()
        .filter(|(group, _)| *group == "Files")
        .map(|(_, item)| item.id)
        .collect();
    assert_eq!(files, ["report.pdf", "reports.csv", "repo.zip"]);
}