mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
595 lines
20 KiB
Rust
595 lines
20 KiB
Rust
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 <bool> more: false;
|
|
in-out property <bool> paging: false;
|
|
in-out property <int> 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<MinimalSoftwareWindow>);
|
|
|
|
impl Platform for Headless {
|
|
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, 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::<Vec<_>>(),
|
|
)));
|
|
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<Duration> = const { Cell::new(Duration::ZERO) };
|
|
}
|
|
|
|
/// A headless desktop that keeps what is copied.
|
|
struct Desktop(Rc<MinimalSoftwareWindow>, Rc<RefCell<String>>);
|
|
|
|
impl Platform for Desktop {
|
|
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, 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<MinimalSoftwareWindow>,
|
|
clipboard: Rc<RefCell<String>>,
|
|
opened: Rc<Cell<i32>>,
|
|
file_commands: Rc<RefCell<Vec<(String, String)>>>,
|
|
}
|
|
|
|
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::<MailAttachments>()
|
|
.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<AccessibleRole>) -> Option<ItemRc> {
|
|
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<MailLabelRow>| 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<MailLabelRow>,
|
|
) -> 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<MailLabelRow> {
|
|
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");
|
|
}
|