#![cfg(feature = "virtual-list")]
use std::thread;
use std::time::{Duration, Instant};
use futures::executor::block_on;
use futures::{SinkExt, StreamExt};
use iced::keyboard::{Key, Modifiers, key::Named};
use iced::widget::{container, text};
use iced::{Element, Point, Size, keyboard, mouse};
use iced_cube::data::virtual_list::{self, Event, Mode, Output, RowId, State, virtual_list};
use iced_cube::overlay::context_menu;
use iced_cube::overlay::dropdown_menu::item;
use iced_test::simulator::Simulator;
#[derive(Debug, Clone)]
enum Message {
List(Event<u32>),
Menu(context_menu::Event<u8, RowId>),
}
fn numbers(count: u32) -> State<u32> {
State::new(0..count)
}
fn view(state: &State<u32>, height: f32) -> Element<'_, Message> {
virtual_list(state, |_, n: &u32| text(format!("Row {n}")).into())
.height(height)
.on_event(Message::List)
.into()
}
fn sized(element: Element<'_, Message>) -> Simulator<'_, Message> {
Simulator::with_size(iced::Settings::default(), Size::new(400.0, 400.0), element)
}
fn events(ui: Simulator<'_, Message>) -> Vec<Event<u32>> {
ui.into_messages()
.filter_map(|message| match message {
Message::List(event) => Some(event),
Message::Menu(_) => None,
})
.collect()
}
fn with_menu<'a>(
state: &'a State<u32>,
menu: &'a context_menu::State<u8, RowId>,
) -> Element<'a, Message> {
virtual_list(state, |_, n: &u32| text(format!("Row {n}")).into())
.height(300)
.context_menu(menu, Message::Menu)
.on_event(Message::List)
.into()
}
#[test]
fn a_right_click_opens_the_context_menu_on_that_row() {
let state = numbers(20).with_limit(18);
let menu = context_menu::State::new([item(1, "Open")]);
let mut ui = sized(with_menu(&state, &menu));
let row = ui.find("Row 4").expect("row").bounds();
ui.point_at(row.center());
let _ = ui.simulate([
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)),
]);
let opened: Vec<RowId> = ui
.into_messages()
.filter_map(|message| match message {
Message::Menu(context_menu::Event::Open(id, _)) => Some(id),
_ => None,
})
.collect();
assert_eq!(opened, [4], "keyed by row id, not index");
assert_eq!(state.index(4), Some(2));
}
#[test]
fn shift_f10_opens_the_menu_on_the_highlighted_row() {
let mut state = numbers(10);
let _ = state.update(Event::Press(3, Modifiers::empty()));
let menu = context_menu::State::new([item(1, "Open")]);
let mut ui = sized(with_menu(&state, &menu));
ui.click("Row 3").expect("row");
let _ = ui.simulate([press_key(Key::Named(Named::F10), Modifiers::SHIFT)]);
let opened = ui.into_messages().any(|message| {
matches!(
message,
Message::Menu(context_menu::Event::OpenFromKeyboard(3))
)
});
assert!(opened);
}
fn press_key(key: Key, modifiers: Modifiers) -> iced::Event {
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers,
text: None,
repeat: false,
})
}
fn wheel(ui: &mut Simulator<'_, Message>, at: Point, y: f32) {
ui.point_at(at);
let _ = ui.simulate([iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0.0, y },
})]);
}
#[test]
fn a_hundred_thousand_rows_only_build_the_rows_in_view() {
let started = Instant::now();
let state = numbers(100_000);
let mut ui = sized(view(&state, 300.0));
assert!(ui.find("Row 0").is_ok());
assert!(ui.find("Row 50").is_err(), "beyond the view");
wheel(&mut ui, Point::new(100.0, 100.0), -32.0 * 50_000.0);
assert!(ui.find("Row 50000").is_ok(), "scrolled half way");
assert!(ui.find("Row 0").is_err());
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"building and scrolling took {elapsed:?}"
);
}
#[test]
fn rows_are_equally_tall() {
let state = numbers(5);
let mut ui = sized(view(&state, 300.0));
let first = ui.find("Row 0").expect("first row").bounds();
let second = ui.find("Row 1").expect("second row").bounds();
assert!((second.y - first.y - virtual_list::ROW_HEIGHT).abs() < 0.01);
}
#[test]
fn clicking_a_row_focuses_the_list_and_presses_it() {
let state = numbers(10);
let mut ui = sized(view(&state, 300.0));
ui.click("Row 3").expect("row is rendered");
let emitted = events(ui);
assert!(
emitted
.iter()
.any(|event| matches!(event, Event::Focus(true))),
"{emitted:?}"
);
assert!(
emitted
.iter()
.any(|event| matches!(event, Event::Press(3, modifiers) if modifiers.is_empty())),
"{emitted:?}"
);
}
#[test]
fn modifier_clicks_select_a_range() {
let mut state = numbers(10).with_mode(Mode::Multiple);
let emitted = {
let mut ui = sized(view(&state, 300.0));
ui.click("Row 2").expect("row");
let _ = ui.simulate([iced::Event::Keyboard(keyboard::Event::ModifiersChanged(
Modifiers::SHIFT,
))]);
ui.click("Row 5").expect("row");
events(ui)
};
let outputs: Vec<Output<u32>> = emitted
.into_iter()
.filter_map(|event| state.update(event))
.collect();
assert!(
matches!(outputs.last(), Some(Output::Selected(indices)) if indices == &[2, 3, 4, 5]),
"{outputs:?}"
);
}
#[test]
fn a_double_click_activates() {
let state = numbers(10);
let mut ui = sized(view(&state, 300.0));
ui.click("Row 4").expect("row");
ui.click("Row 4").expect("row");
let emitted = events(ui);
assert!(
emitted
.iter()
.any(|event| matches!(event, Event::Activate(4))),
"{emitted:?}"
);
}
#[test]
fn keys_are_ignored_until_the_list_has_focus() {
let state = numbers(10);
let mut ui = sized(view(&state, 300.0));
let _ = ui.simulate([press_key(Key::Named(Named::ArrowDown), Modifiers::empty())]);
assert!(!events(ui).iter().any(|event| matches!(event, Event::Next)));
let mut ui = sized(view(&state, 300.0));
ui.click("Row 0").expect("row");
let _ = ui.simulate([
press_key(Key::Named(Named::ArrowDown), Modifiers::empty()),
press_key(Key::Named(Named::PageDown), Modifiers::empty()),
press_key(Key::Named(Named::End), Modifiers::empty()),
]);
let emitted = events(ui);
for expected in ["Next", "NextPage", "Last"] {
assert!(
emitted.iter().any(|event| format!("{event:?}") == expected),
"{expected} in {emitted:?}"
);
}
}
#[test]
fn the_list_reports_how_many_rows_fit() {
let state = numbers(100);
let mut ui = sized(view(&state, 200.0));
let _ = ui.simulate([iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(100.0, 100.0),
})]);
let pages: Vec<usize> = events(ui)
.into_iter()
.filter_map(|event| match event {
Event::Page(rows) => Some(rows),
_ => None,
})
.collect();
assert_eq!(pages, [6]);
}
#[test]
fn the_highlight_scrolls_into_view() {
let mut state = numbers(1000);
let _ = state.update(Event::Page(5));
let _ = state.update(Event::First);
for _ in 0..100 {
let _ = state.update(Event::NextPage);
}
assert_eq!(state.highlighted(), Some(500));
{
let mut ui = sized(view(&state, 200.0));
assert!(ui.find("Row 500").is_ok());
assert!(ui.find("Row 0").is_err());
}
state.scroll_to(900);
let mut ui = sized(view(&state, 200.0));
assert!(
ui.find("Row 900").is_ok(),
"scroll_to reveals without the highlight"
);
}
#[test]
fn a_following_list_shows_its_end() {
let state = numbers(500).with_follow(true);
let mut ui = sized(view(&state, 200.0));
assert!(ui.find("Row 499").is_ok());
assert!(ui.find("Row 0").is_err());
}
#[test]
fn scrolling_away_from_the_end_stops_following() {
let state = numbers(500).with_follow(true);
let mut ui = sized(view(&state, 200.0));
wheel(&mut ui, Point::new(100.0, 100.0), 120.0);
let emitted = events(ui);
assert!(
emitted
.iter()
.any(|event| matches!(event, Event::Follow(false))),
"{emitted:?}"
);
}
#[test]
fn scrolling_back_to_the_end_follows_again() {
let mut state = numbers(500).with_follow(true);
let _ = state.update(Event::Follow(false));
let mut ui = sized(view(&state, 200.0));
assert!(ui.find("Row 0").is_ok(), "not following, so at the top");
wheel(&mut ui, Point::new(100.0, 100.0), -100_000.0);
let emitted = events(ui);
assert!(
emitted
.iter()
.any(|event| matches!(event, Event::Follow(true))),
"{emitted:?}"
);
}
#[test]
fn a_list_that_does_not_follow_never_reports_it() {
let state = numbers(500);
let mut ui = sized(view(&state, 200.0));
wheel(&mut ui, Point::new(100.0, 100.0), -100_000.0);
wheel(&mut ui, Point::new(100.0, 100.0), 100.0);
assert!(
!events(ui)
.iter()
.any(|event| matches!(event, Event::Follow(_)))
);
}
#[test]
fn a_list_without_messages_is_disabled() {
let state = numbers(5);
let element: Element<'_, Message> =
virtual_list(&state, |_, n: &u32| text(format!("Row {n}")).into()).into();
let mut ui = sized(element);
ui.click("Row 1").expect("row is rendered");
assert_eq!(ui.into_messages().count(), 0);
}
#[test]
fn rows_fit_a_narrow_container() {
let state = numbers(3);
let element: Element<'_, Message> = container(
virtual_list(&state, |_, n: &u32| text(format!("Row {n}")).into()).on_event(Message::List),
)
.width(160)
.into();
let mut ui = Simulator::with_size(iced::Settings::default(), Size::new(160.0, 200.0), element);
let row = ui.find("Row 2").expect("row").bounds();
assert!(row.x + row.width <= 160.0);
}
#[test]
fn a_producer_thread_sends_items_through_the_channel() {
let mut events = Box::pin(virtual_list::stream::<u32>());
let Some(Event::Ready(sender)) = block_on(events.next()) else {
panic!("first event must be Ready");
};
let mut state = State::default().with_follow(true);
let producer = thread::spawn(move || {
let mut sender = sender;
block_on(async {
for n in 0..1000 {
sender.send(n).await.expect("channel open");
}
});
});
let mut received = 0;
while received < 1000 {
let Some(event) = block_on(events.next()) else {
break;
};
if let Event::Received(batch) = &event {
assert!(batch.len() <= virtual_list::BATCH_SIZE);
received += batch.len();
}
let _ = state.update(event);
}
producer.join().expect("producer finished");
assert_eq!(state.len(), 1000);
assert_eq!(state.get(999), Some(&999));
assert!(state.is_following());
let mut ui = sized(view(&state, 200.0));
assert!(ui.find("Row 999").is_ok(), "follows the newest item");
}