From 8a3ca13d4467b0a26f980502bf72501abecd83b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Wed, 30 Sep 2026 17:44:37 +0000 Subject: [PATCH] fix: honor checked mail selection --- tests/mail_deletion_interactions.rs | 202 ++++++++++++++++++++++++++++ ui/views/mail-view.slint | 12 +- 2 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 tests/mail_deletion_interactions.rs diff --git a/tests/mail_deletion_interactions.rs b/tests/mail_deletion_interactions.rs new file mode 100644 index 00000000..863867ed --- /dev/null +++ b/tests/mail_deletion_interactions.rs @@ -0,0 +1,202 @@ +//! Delete actions in the reader must honor the checked mail selection. +use slint::platform::{ + Platform, PointerEventButton, WindowAdapter, WindowEvent, + software_renderer::{MinimalSoftwareWindow, RepaintBufferType}, +}; +use slint::{ComponentHandle, Rgb8Pixel}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + time::Duration, +}; + +slint::slint! { + import { Palette } from "std-widgets.slint"; + import { AppTheme } from "../ui/design-system.slint"; + export { MotionSettings } from "../ui/design-system.slint"; + import { MailView } from "../ui/views/mail-view.slint"; + + export component MailDeletionHarness inherits Window { + preferred-width: 1200px; + preferred-height: 600px; + in property compact: false; + in property dark: false; + in property layout-mode: "default"; + in property checked: true; + in property trash-view: false; + in-out property page: "message"; + callback single-action(string); + callback bulk-action(string); + + init => { Palette.color-scheme = ColorScheme.light; } + changed dark => { Palette.color-scheme = root.dark ? ColorScheme.dark : ColorScheme.light; } + MailView { + width: parent.width; + height: parent.height; + available-width: parent.width; + compact: root.compact; + layout-mode: root.layout-mode; + compact-page <=> root.page; + mail_sidebar_collapsed: true; + show-avatars: false; + selection-count: root.checked ? 2 : 0; + has_selected: true; + selected_sender: "Third sender"; + selected_subject: "Preview outside the checked selection"; + trash_view: root.trash-view; + emails: [ + { id: 1, sender: "First sender", subject: "First checked message", checked: root.checked }, + { id: 2, sender: "Second sender", subject: "Second checked message", checked: root.checked }, + { id: 3, sender: "Third sender", subject: "Preview outside the checked selection", selected: true }, + ]; + list_entries: [ + { show_row: true, email_index: 0, email: { id: 1, sender: "First sender", subject: "First checked message", checked: root.checked } }, + { show_row: true, email_index: 1, email: { id: 2, sender: "Second sender", subject: "Second checked message", checked: root.checked } }, + { show_row: true, email_index: 2, email: { id: 3, sender: "Third sender", subject: "Preview outside the checked selection", selected: true } }, + ]; + accent: AppTheme.accent; + accent-soft: AppTheme.accent-soft; + avatar-bg: AppTheme.avatar-bg; + avatar-selected-bg: AppTheme.avatar-selected-bg; + body-text: AppTheme.body; + border: AppTheme.border; + border-subtle: AppTheme.border-subtle; + control-bg: AppTheme.control-bg; + faint-text: AppTheme.faint; + heading: AppTheme.heading; + muted-control-bg: AppTheme.muted-control-bg; + muted-text: AppTheme.muted; + secondary-text: AppTheme.secondary; + selected-row: AppTheme.selected-row; + surface: AppTheme.surface; + surface-raised: AppTheme.surface-raised; + title-color: AppTheme.title; + message_action(action) => { root.single-action(action); } + bulk_message_action(action) => { root.bulk-action(action); } + } + } +} + +struct Headless { + window: Rc, + clock: Rc>, +} + +impl Platform for Headless { + fn create_window_adapter(&self) -> Result, slint::PlatformError> { + Ok(self.window.clone()) + } + + fn duration_since_start(&self) -> Duration { + self.clock.get() + } +} + +#[test] +fn reader_deletion_uses_checked_messages_in_every_layout() { + let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer); + let clock = Rc::new(Cell::new(Duration::ZERO)); + slint::platform::set_platform(Box::new(Headless { + window: window.clone(), + clock: clock.clone(), + })) + .unwrap(); + + for (name, compact, layout, width) in [ + ("desktop", false, "default", 1200), + ("minimal", false, "minimal", 620), + ("compact", true, "default", 390), + ] { + for dark in [false, true] { + let ui = MailDeletionHarness::new().unwrap(); + ui.window().set_size(slint::PhysicalSize::new(width, 600)); + ui.global::().set_enabled(false); + ui.set_compact(compact); + ui.set_layout_mode(layout.into()); + ui.set_page("message".into()); + ui.set_dark(dark); + let actions = Rc::new(RefCell::new(Vec::new())); + let recorded = actions.clone(); + ui.on_single_action(move |action| { + recorded.borrow_mut().push(("single", action.to_string())); + }); + let recorded = actions.clone(); + ui.on_bulk_action(move |action| { + recorded.borrow_mut().push(("bulk", action.to_string())); + }); + ui.show().unwrap(); + + let render = || { + let mut pixels = vec![Rgb8Pixel::default(); width as usize * 600]; + for _ in 0..3 { + clock.set(clock.get() + Duration::from_secs(1)); + slint::platform::update_timers_and_animations(); + ui.window().request_redraw(); + window.draw_if_needed(|renderer| { + renderer.render(&mut pixels, width as usize); + }); + } + pixels + }; + render(); + // Enter the reader after Minimal's layout initialization opens the list. + ui.set_page("message".into()); + let pixels = render(); + if let Ok(dir) = std::env::var("FLECTAR_MAIL_TEST_SCREENSHOTS") { + let bytes: Vec = pixels.iter().flat_map(|p| [p.r, p.g, p.b]).collect(); + image::save_buffer( + format!("{dir}/{name}-{}.png", if dark { "dark" } else { "light" }), + &bytes, + width, + 600, + image::ColorType::Rgb8, + ) + .unwrap(); + } + let delete = || { + // Trash is followed by the label and overflow controls. + let position = slint::LogicalPosition::new(width as f32 - 130.0, 32.0); + ui.window().dispatch_event(WindowEvent::PointerPressed { + position, + button: PointerEventButton::Left, + }); + ui.window().dispatch_event(WindowEvent::PointerReleased { + position, + button: PointerEventButton::Left, + }); + }; + + delete(); + assert_eq!( + actions.borrow_mut().drain(..).collect::>(), + [("bulk", "trash".into())], + "{name}: reader trash must act on the checked messages, even when another message is open" + ); + ui.set_trash_view(true); + render(); + delete(); + assert_eq!( + actions.borrow_mut().drain(..).collect::>(), + [("bulk", "delete_permanently".into())], + "{name}: permanent deletion must use the bulk confirmation path" + ); + ui.set_checked(false); + render(); + delete(); + assert_eq!( + actions.borrow_mut().drain(..).collect::>(), + [("single", "delete_permanently".into())], + "{name}: clearing the checked selection restores the reader's single-message action" + ); + ui.set_trash_view(false); + render(); + delete(); + assert_eq!( + actions.borrow_mut().drain(..).collect::>(), + [("single", "trash".into())], + "{name}: trash without a checked selection acts on the open message" + ); + ui.hide().unwrap(); + } + } +} diff --git a/ui/views/mail-view.slint b/ui/views/mail-view.slint index 99a62c1e..dd5ea0d6 100644 --- a/ui/views/mail-view.slint +++ b/ui/views/mail-view.slint @@ -154,6 +154,14 @@ export component MailView inherits Rectangle { callback select-email-text(); callback load-remote-images-for-message(); + function dispatch-message-action(action: string) { + if root.selection-count > 0 && (action == "trash" || action == "delete_permanently") { + root.bulk_message_action(action); + } else { + root.message_action(action); + } + } + changed layout-mode => { if (root.layout-mode == "minimal") { root.compact-page = "list"; @@ -336,7 +344,7 @@ export component MailView inherits Rectangle { surface: root.surface; surface-raised: root.surface-raised; title-color: root.title-color; - message_action(action) => { root.message_action(action); } + message_action(action) => { root.dispatch-message-action(action); } toggle-mail-label(id, applied) => { root.toggle_mail_label(id, applied); } save-mail-label(id, name, color, global) => { root.save_mail_label(id, -1, name, color, global, -1); } search-mail-labels(query) => { root.search_mail_labels(query); } @@ -558,7 +566,7 @@ export component MailView inherits Rectangle { surface: root.surface; surface-raised: root.surface-raised; title-color: root.title-color; - message_action(action) => { root.message_action(action); } + message_action(action) => { root.dispatch-message-action(action); } toggle-mail-label(id, applied) => { root.toggle_mail_label(id, applied); } save-mail-label(id, name, color, global) => { root.save_mail_label(id, -1, name, color, global, -1); } search-mail-labels(query) => { root.search_mail_labels(query); }