use i_slint_core::accessibility::AccessibleStringProperty; use i_slint_core::item_tree::ItemRc; use i_slint_core::items::AccessibleRole; use i_slint_core::window::WindowInner; use slint::platform::{ Platform, PointerEventButton, WindowAdapter, WindowEvent, software_renderer::{MinimalSoftwareWindow, RepaintBufferType}, }; use slint::{ComponentHandle, ModelRc, VecModel}; use std::{ cell::{Cell, RefCell}, ops::ControlFlow, rc::Rc, time::Duration, }; slint::slint! { import { EmailRow, ListAttachment, MailLabelRow, MailListEntry } from "../ui/models.slint"; export { MailAttachments } from "../ui/models.slint"; import { MailListPane } from "../ui/views/mail-list-pane.slint"; import { MotionSettings } from "../ui/design-system.slint"; export component DesktopListHarness inherits Window { preferred-width: 397px; preferred-height: 260px; in-out property <[EmailRow]> emails: []; in-out property <[MailListEntry]> entries: []; callback selected(int); init => { MotionSettings.enabled = false; } MailListPane { width: parent.width; height: parent.height; emails: root.emails; list_entries: root.entries; surface: #18191a; heading: white; body-text: white; secondary-text: #cccccc; muted-text: #999999; border: #3a3b3c; border-subtle: #303030; accent: #5ca3ff; select_email(id) => { root.selected(id); } } } export component MailListHarness inherits Window { preferred-width: 397px; preferred-height: 480px; in-out property <[EmailRow]> emails: []; in-out property <[MailListEntry]> entries: []; in-out property more: false; in-out property paging: false; in-out property revision: 0; callback group-toggled(); callback page-requested(); init => { MotionSettings.enabled = false; } MailListPane { width: parent.width; height: parent.height; compact: true; emails: root.emails; list_entries: root.entries; can_load_more: root.more; loading_more: root.paging; content_revision: root.revision; surface: #18191a; heading: white; body-text: white; secondary-text: #cccccc; muted-text: #999999; border-subtle: #303030; accent: #5ca3ff; toggle_mail_group(key) => { root.group-toggled(); } load_more => { root.page-requested(); } } } } struct Headless(Rc); impl Platform for Headless { fn create_window_adapter(&self) -> Result, slint::PlatformError> { Ok(self.0.clone()) } } #[test] fn toggling_a_date_group_keeps_infinite_pagination_active() { let window = MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer); slint::platform::set_platform(Box::new(Headless(window.clone()))).unwrap(); let ui = MailListHarness::new().unwrap(); ui.window().set_size(slint::PhysicalSize::new(397, 480)); ui.set_emails(ModelRc::new(VecModel::from(vec![EmailRow { id: 1, sender: "Sender".into(), subject: "Subject".into(), ..Default::default() }]))); ui.set_entries(ModelRc::new(VecModel::from(vec![ MailListEntry { is_header: true, group_key: "this-week".into(), group_kind: "this-week".into(), group_count: 1, expanded: true, show_row: true, ..Default::default() }, MailListEntry { show_row: true, email_index: 0, email: EmailRow { id: 1, sender: "Sender".into(), subject: "Subject".into(), ..Default::default() }, ..Default::default() }, ]))); let toggles = Rc::new(Cell::new(0)); let pages = Rc::new(Cell::new(0)); let count = toggles.clone(); ui.on_group_toggled(move || count.set(count.get() + 1)); let count = pages.clone(); ui.on_page_requested(move || count.set(count.get() + 1)); ui.show().unwrap(); let position = slint::LogicalPosition::new(80.0, 82.0); ui.window().dispatch_event(WindowEvent::PointerPressed { position, button: PointerEventButton::Left, }); ui.window().dispatch_event(WindowEvent::PointerReleased { position, button: PointerEventButton::Left, }); assert_eq!( toggles.get(), 1, "the date group should respond to the click" ); ui.set_more(true); for _ in 0..3 { slint::platform::update_timers_and_animations(); std::thread::sleep(std::time::Duration::from_millis(2)); } assert_eq!( pages.get(), 1, "pagination should still load after a group click" ); // Finish collapsing all loaded mail, then commit multiple pages whose // messages belong to the same collapsed section. No scroll gesture or // change in visible height should be needed to reach the older mail. ui.set_more(false); ui.set_entries(ModelRc::new(VecModel::from(vec![MailListEntry { is_header: true, group_key: "this-week".into(), group_kind: "this-week".into(), group_count: 1, expanded: false, ..Default::default() }]))); let settle = || { let mut pixels = vec![slint::Rgb8Pixel::default(); 397 * 480]; for _ in 0..8 { slint::platform::update_timers_and_animations(); window.draw_if_needed(|renderer| { renderer.render(&mut pixels, 397); }); std::thread::sleep(std::time::Duration::from_millis(2)); } }; settle(); for revision in 1..=3 { ui.set_paging(true); ui.set_emails(ModelRc::new(VecModel::from( (1..=revision + 1) .map(|id| EmailRow { id, ..Default::default() }) .collect::>(), ))); ui.set_revision(revision); ui.set_paging(false); ui.set_more(true); settle(); assert_eq!( pages.get(), revision + 1, "pages hidden in a collapsed section must not stall pagination" ); } ui.set_more(false); ui.set_revision(4); settle(); assert_eq!(pages.get(), 4, "stop when the mailbox is exhausted"); } thread_local! { // The platform clock, advanced by `settle`. static CLOCK: Cell = const { Cell::new(Duration::ZERO) }; } /// A headless desktop that keeps what is copied. struct Desktop(Rc, Rc>); impl Platform for Desktop { fn create_window_adapter(&self) -> Result, slint::PlatformError> { Ok(self.0.clone()) } fn duration_since_start(&self) -> Duration { CLOCK.get() } fn set_clipboard_text(&self, text: &str, _: slint::platform::Clipboard) { *self.1.borrow_mut() = text.to_owned(); } } /// A desktop list showing one row, and what the row copies, opens and asks /// of its files. struct DesktopList { ui: DesktopListHarness, window: Rc, clipboard: Rc>, opened: Rc>, file_commands: Rc>>, } fn desktop_list(email: EmailRow) -> DesktopList { let window = MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer); let clipboard = Rc::new(RefCell::new(String::new())); slint::platform::set_platform(Box::new(Desktop(window.clone(), clipboard.clone()))).unwrap(); let ui = DesktopListHarness::new().unwrap(); ui.window().set_size(slint::PhysicalSize::new(397, 260)); ui.set_emails(ModelRc::new(VecModel::from(vec![email.clone()]))); ui.set_entries(ModelRc::new(VecModel::from(vec![MailListEntry { show_row: true, email_index: 0, email, ..Default::default() }]))); let opened = Rc::new(Cell::new(-1)); let target = opened.clone(); ui.on_selected(move |id| target.set(id)); let file_commands = Rc::new(RefCell::new(Vec::new())); let log = file_commands.clone(); ui.global::() .on_command(move |command, id| { log.borrow_mut().push((command.to_string(), id.to_string())); }); ui.show().unwrap(); settle(&window, Duration::ZERO); DesktopList { ui, window, clipboard, opened, file_commands, } } fn settle(window: &MinimalSoftwareWindow, advance: Duration) { CLOCK.set(CLOCK.get() + advance); let mut pixels = vec![slint::Rgb8Pixel::default(); 397 * 260]; for _ in 0..3 { slint::platform::update_timers_and_animations(); window.draw_if_needed(|renderer| { renderer.render(&mut pixels, 397); }); } } fn label(id: i32, name: &str) -> MailLabelRow { MailLabelRow { id, name: name.into(), color: slint::Color::from_rgb_u8(0x4a, 0x86, 0xe8), applied: true, ..Default::default() } } /// The shown element with this accessible label, of `role` if given. fn labelled(window: &slint::Window, label: &str, role: Option) -> Option { let inner = WindowInner::from_pub(window); ItemRc::new_root(inner.component()).visit_descendants(|item| { if item.is_visible() && role.is_none_or(|role| item.accessible_role() == role) && item .accessible_string_property(AccessibleStringProperty::Label) .as_deref() == Some(label) { ControlFlow::Break(item.clone()) } else { ControlFlow::Continue(()) } }) } /// Left edge, top, width and height in the window. fn bounds(item: &ItemRc) -> (f32, f32, f32, f32) { let geometry = item.geometry(); let origin = item.map_to_window(geometry.origin); (origin.x, origin.y, geometry.width(), geometry.height()) } fn click(window: &slint::Window, x: f32, y: f32) { let position = slint::LogicalPosition::new(x, y); window.dispatch_event(WindowEvent::PointerMoved { position }); window.dispatch_event(WindowEvent::PointerPressed { position, button: PointerEventButton::Left, }); window.dispatch_event(WindowEvent::PointerReleased { position, button: PointerEventButton::Left, }); } #[test] fn verification_code_chip_copies_instead_of_opening_the_message() { let DesktopList { ui, window, clipboard, opened, .. } = desktop_list(EmailRow { id: 7, sender: "GitHub".into(), subject: "Please verify your device".into(), preview: "Verification code: 482913".into(), labels: ModelRc::new(VecModel::from(vec![label(1, "Security"), label(2, "Work")])), verification_code: "482913".into(), verification_code_spelled: "4 8 2 9 1 3".into(), ..Default::default() }); let copy = "Copy verification code 4 8 2 9 1 3"; let copied = "Copied verification code 4 8 2 9 1 3"; let chip = labelled(ui.window(), copy, Some(AccessibleRole::Button)).expect("the code chip"); let (x, y, width, height) = bounds(&chip); // The code leads the detail line; one label still fits beside it. let security = labelled(ui.window(), "Security", None).expect("the first label"); assert!(bounds(&security).0 > x + width, "labels follow the code"); assert!(labelled(ui.window(), "Work", None).is_none()); assert!(labelled(ui.window(), "+1", None).is_some()); click(ui.window(), x + width / 2.0, y + height / 2.0); settle(&window, Duration::ZERO); assert_eq!(*clipboard.borrow(), "482913"); assert_eq!( opened.get(), -1, "copying the code must not open the message" ); assert!(labelled(ui.window(), copied, Some(AccessibleRole::Button)).is_some()); assert!( labelled(ui.window(), copied, Some(AccessibleRole::Text)).is_some(), "screen readers hear about the copy" ); // The confirmation lasts a moment. settle(&window, Duration::from_secs(2)); assert!(labelled(ui.window(), copy, Some(AccessibleRole::Button)).is_some()); assert!(labelled(ui.window(), copied, None).is_none()); // The rest of the row still opens the message. click(ui.window(), x + width / 2.0, y - 24.0); assert_eq!(opened.get(), 7); } #[test] fn labels_past_the_limit_take_no_room() { let DesktopList { ui, .. } = desktop_list(EmailRow { id: 1, sender: "Cloudflare".into(), subject: "Your weekly traffic report".into(), preview: "Requests were up 18% across flectar.com".into(), labels: ModelRc::new(VecModel::from(vec![ label(1, "Reports"), label(2, "Marketing"), label(3, "Travel"), ])), ..Default::default() }); let (marketing_x, _, marketing_width, _) = bounds(&labelled(ui.window(), "Marketing", None).expect("the second label")); let (more_x, ..) = bounds(&labelled(ui.window(), "+1", None).expect("the label count")); assert!(labelled(ui.window(), "Travel", None).is_none()); // The count follows the second chip's end padding and one gap; a hidden // third chip would push it further right. let gap = more_x - (marketing_x + marketing_width); assert!(gap < 10.0, "hidden labels took {gap}px"); } #[test] fn labels_without_room_for_their_names_are_counted() { let DesktopList { ui, window, .. } = desktop_list(EmailRow { id: 1, sender: "Cloudflare".into(), subject: "Your weekly traffic report".into(), preview: "Requests were up 18% across flectar.com".into(), labels: ModelRc::new(VecModel::from(vec![ label(1, "Reports"), label(2, "Marketing"), label(3, "Travel"), ])), ..Default::default() }); // A narrower list keeps one label readable and counts the rest, ui.window().set_size(slint::PhysicalSize::new(320, 260)); settle(&window, Duration::ZERO); let reports = labelled(ui.window(), "Reports", None).expect("the first label"); let width = bounds(&reports).2; assert!(width >= 28.0, "the label was cut to {width}px"); assert!(labelled(ui.window(), "Marketing", None).is_none()); assert!(labelled(ui.window(), "+2", None).is_some()); // and a narrower one only counts them. ui.window().set_size(slint::PhysicalSize::new(260, 260)); settle(&window, Duration::ZERO); assert!(labelled(ui.window(), "Reports", None).is_none()); assert!(labelled(ui.window(), "+3", None).is_some()); } #[test] fn a_preview_too_narrow_for_a_word_is_left_out() { let preview = "Requests were up 18% across flectar.com"; let row = |labels: Vec| EmailRow { id: 1, sender: "Cloudflare".into(), subject: "Your weekly traffic report".into(), preview: preview.into(), labels: ModelRc::new(VecModel::from(labels)), ..Default::default() }; let DesktopList { ui, window, .. } = desktop_list(row(vec![label(1, "VIP")])); assert!(labelled(ui.window(), preview, None).is_some()); // A second label leaves the preview a sliver, which would show only "…". let email = row(vec![label(1, "Quarterly reports"), label(2, "VIP")]); ui.set_emails(ModelRc::new(VecModel::from(vec![email.clone()]))); ui.set_entries(ModelRc::new(VecModel::from(vec![MailListEntry { show_row: true, email_index: 0, email, ..Default::default() }]))); settle(&window, Duration::ZERO); assert!(labelled(ui.window(), "VIP", None).is_some()); assert!(labelled(ui.window(), preview, None).is_none()); } /// A desktop list showing a receipt that carries `attachment`. fn receipt_list( attachment: ListAttachment, attachment_total: i32, labels: Vec, ) -> DesktopList { desktop_list(EmailRow { id: 9, sender: "Stripe".into(), subject: "Your August receipt is ready".into(), preview: "Payment received for your monthly plan".into(), labels: ModelRc::new(VecModel::from(labels)), attachment, attachment_total, ..Default::default() }) } fn receipt(previewable: bool) -> ListAttachment { ListAttachment { id: "101".into(), stem: "Receipt-0818".into(), extension: ".pdf".into(), kind: "pdf".into(), previewable, } } #[test] fn file_chip_previews_instead_of_opening_the_message() { let DesktopList { ui, window, opened, file_commands, .. } = receipt_list(receipt(true), 2, Vec::new()); let chip = labelled( ui.window(), "Preview Receipt-0818.pdf, 1 more file", Some(AccessibleRole::Button), ) .expect("the file chip"); let (x, y, width, height) = bounds(&chip); click(ui.window(), x + width / 2.0, y + height / 2.0); settle(&window, Duration::ZERO); assert_eq!( *file_commands.borrow(), [("preview-listed".to_owned(), "101".to_owned())] ); assert_eq!( opened.get(), -1, "previewing the file must not open the message" ); // The rest of the row still opens the message. click(ui.window(), x + width / 2.0, y - 24.0); assert_eq!(opened.get(), 9); } #[test] fn file_chip_downloads_files_without_a_preview() { let DesktopList { ui, window, opened, file_commands, .. } = receipt_list(receipt(false), 1, Vec::new()); let chip = labelled( ui.window(), "Download Receipt-0818.pdf", Some(AccessibleRole::Button), ) .expect("the file chip"); let (x, y, width, height) = bounds(&chip); click(ui.window(), x + width / 2.0, y + height / 2.0); settle(&window, Duration::ZERO); assert_eq!( *file_commands.borrow(), [("download-listed".to_owned(), "101".to_owned())] ); assert_eq!(opened.get(), -1); } fn two_labels() -> Vec { vec![label(1, "Work"), label(2, "Marketing")] } #[test] fn a_label_shows_beside_a_short_file_name() { let short = ListAttachment { stem: "a".into(), ..receipt(true) }; let DesktopList { ui, .. } = receipt_list(short, 1, two_labels()); let file = labelled(ui.window(), "Preview a.pdf", None).expect("the file chip"); let work = labelled(ui.window(), "Work", None).expect("the first label"); let (file_x, _, file_width, _) = bounds(&file); assert!( bounds(&work).0 > file_x + file_width, "labels follow the file" ); assert!(labelled(ui.window(), "Marketing", None).is_none()); assert!(labelled(ui.window(), "+1", None).is_some()); } #[test] fn labels_beside_a_long_file_name_are_only_counted() { let long = ListAttachment { stem: "Traffic report for every flectar.com zone, week 43".into(), ..receipt(true) }; let DesktopList { ui, .. } = receipt_list(long, 1, two_labels()); // The file keeps its room rather than sharing it with a label cut down // to its icon. let file = labelled( ui.window(), "Preview Traffic report for every flectar.com zone, week 43.pdf", None, ) .expect("the file chip"); assert!(labelled(ui.window(), "Work", None).is_none()); assert!(labelled(ui.window(), "+2", None).is_some()); let width = bounds(&file).2; assert!(width >= 149.0, "the file chip gave up its room: {width}px"); }