diff --git a/Cargo.lock b/Cargo.lock index 2d4e3389..d581fdb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1065,6 +1065,7 @@ dependencies = [ "cp-store", "cp-win", "cp-win-sys", + "i-slint-backend-testing", "image", "raw-window-handle", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9b82fad6..1c61cdc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ windows-future = "0.3" slint = { version = "1.18", default-features = false, features = ["std", "compat-1-2", "backend-winit", "renderer-skia", "renderer-skia-opengl", "raw-window-handle-06"] } slint-build = "1.18" +i-slint-backend-testing = "1.18" raw-window-handle = "0.6" objc2 = "0.6" diff --git a/crates/cp-panel/Cargo.toml b/crates/cp-panel/Cargo.toml index fb139a8f..6f1c0c42 100644 --- a/crates/cp-panel/Cargo.toml +++ b/crates/cp-panel/Cargo.toml @@ -29,6 +29,7 @@ cp-mac.workspace = true [dev-dependencies] tempfile.workspace = true image.workspace = true +i-slint-backend-testing.workspace = true [build-dependencies] slint-build.workspace = true diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 897718a0..2264fc4e 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -126,3 +126,7 @@ fn seeded() -> PathBuf { fn where_it_lives() -> Option { here::data_dir().map(|dir| dir.join("history.db")) } + +#[cfg(test)] +#[path = "main_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/main_test.rs b/crates/cp-panel/src/main_test.rs new file mode 100644 index 00000000..ded268a6 --- /dev/null +++ b/crates/cp-panel/src/main_test.rs @@ -0,0 +1,182 @@ +use super::*; +use slint::platform::{PointerEventButton, WindowEvent}; +use slint::{ComponentHandle, LogicalPosition, Model, VecModel}; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +const HEAD: f32 = 23.0; +const GAP: f32 = 6.0; +const SHUT: f32 = 58.0; +const OPEN: f32 = 190.0; +const BIG: f32 = 340.0; + +static PLATFORM: std::sync::Once = std::sync::Once::new(); + +fn card(id: i32) -> Card { + Card { + id, + kind: "text".into(), + title: format!("card {id}").into(), + body: "some words".into(), + face: "words".into(), + opened: "some words\nmore words\nand more".into(), + shut_lines: 1, + open_lines: 3, + shut_px: SHUT, + open_px: if id == 1 { BIG } else { OPEN }, + heads_group: id == 0, + group_said: "Hoy".into(), + can_drag: true, + ..Default::default() + } +} + +struct Bench { + panel: Panel, + cards: Rc>, + pasted: Rc>>, + moved: Rc>>, +} + +fn tick(ms: u64) { + i_slint_backend_testing::mock_elapsed_time(Duration::from_millis(ms)); +} + +fn height_of(cards: &VecModel, open: Option, index: usize) -> f32 { + let card = cards.row_data(index).expect("a row"); + let head = if card.heads_group { HEAD } else { 0.0 }; + head + if open == Some(index) { + card.open_px + } else { + card.shut_px + } +} + +fn span_of(cards: &VecModel, open: Option, index: usize) -> (f32, f32) { + let top = (0..index).map(|i| height_of(cards, open, i)).sum(); + (top, height_of(cards, open, index)) +} + +fn open_one(panel: &Panel) -> Option { + (panel.get_opened() && panel.get_current() >= 0).then(|| panel.get_current() as usize) +} + +fn bench(count: i32) -> Bench { + PLATFORM.call_once(i_slint_backend_testing::init_no_event_loop); + let panel = Panel::new().expect("the panel builds"); + panel + .window() + .set_size(slint::LogicalSize::new(480.0, 620.0)); + let cards = Rc::new(VecModel::from((0..count).map(card).collect::>())); + panel.set_cards(cards.clone().into()); + panel.show().expect("the panel shows"); + let pasted = Rc::new(RefCell::new(Vec::new())); + let seen = pasted.clone(); + panel.on_paste(move |id| seen.borrow_mut().push(id)); + let moved = Rc::new(RefCell::new(Vec::new())); + let noted = moved.clone(); + let weak = panel.as_weak(); + let rows = cards.clone(); + panel.on_moved(move |index| { + noted.borrow_mut().push(index); + let panel = weak.unwrap(); + let (top, span) = span_of(&rows, open_one(&panel), index as usize); + panel.set_scroll_y(crate::model::reveal( + top, + span, + panel.get_scroll_y(), + panel.get_viewport_height(), + )); + }); + tick(600); + Bench { + panel, + cards, + pasted, + moved, + } +} + +fn centre_of(bench: &Bench, index: usize) -> LogicalPosition { + let panel = &bench.panel; + let (top, span) = span_of(&bench.cards, open_one(panel), index); + let head = if bench.cards.row_data(index).expect("a row").heads_group { + HEAD + } else { + 0.0 + }; + let list_top = panel.global::().get_margin() + panel.get_list_top(); + let y = list_top + panel.get_scroll_y() + top + head + (span - head - GAP) / 2.0; + LogicalPosition::new(200.0, y) +} + +fn click(panel: &Panel, at: LogicalPosition, hold: u64) { + let window = panel.window(); + window.dispatch_event(WindowEvent::PointerMoved { position: at }); + window.dispatch_event(WindowEvent::PointerPressed { + position: at, + button: PointerEventButton::Left, + }); + tick(hold); + window.dispatch_event(WindowEvent::PointerReleased { + position: at, + button: PointerEventButton::Left, + }); +} + +#[test] +fn a_double_click_pastes_the_card_under_it_whatever_was_open_and_however_slow() { + the_card_opened_at_the_bottom_ends_up_whole_on_screen(); + let mut failed = Vec::new(); + for gap in [150u64, 250, 350, 400] { + for (open_first, target) in [ + (None, 1usize), + (None, 4), + (None, 7), + (Some(0usize), 3), + (Some(6), 2), + (Some(2), 2), + (Some(1), 2), + (Some(1), 1), + ] { + let bench = bench(12); + if let Some(first) = open_first { + click(&bench.panel, centre_of(&bench, first), 60); + tick(600); + } + bench.moved.borrow_mut().clear(); + let at = centre_of(&bench, target); + let floor = bench.panel.global::().get_margin() + + bench.panel.get_list_top() + + bench.panel.get_viewport_height(); + assert!(at.y < floor, "card {target} is out of sight at {}", at.y); + click(&bench.panel, at, 90); + tick(gap); + click(&bench.panel, at, 90); + tick(600); + let pasted = bench.pasted.borrow().clone(); + let moved = bench.moved.borrow().clone(); + if pasted != [target as i32] || moved.iter().any(|&one| one != target as i32) { + failed.push(format!( + "{gap} ms, open {open_first:?}, card {target}: pasted {pasted:?}, clicked {moved:?}" + )); + } + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +fn the_card_opened_at_the_bottom_ends_up_whole_on_screen() { + let bench = bench(12); + let target = 7; + click(&bench.panel, centre_of(&bench, target), 60); + tick(600); + let (top, span) = span_of(&bench.cards, open_one(&bench.panel), target); + let bottom = top + span + bench.panel.get_scroll_y(); + assert!( + bottom <= bench.panel.get_viewport_height(), + "the open card ends at {bottom}, past {}", + bench.panel.get_viewport_height() + ); +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index 8105b0b5..efc241e8 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -279,7 +279,6 @@ export component CardView inherits Rectangle { in property open; in property shown-here; in property quiet; - callback paste(int); callback copy(int); callback open-asked(int); callback forms(int); @@ -288,7 +287,7 @@ export component CardView inherits Rectangle { callback naming-dropped(); callback pin(int, bool); callback remove(int); - callback chosen(int); + callback chosen(int, length, length); callback stirred(length, length); callback dragged(int); property dragging; @@ -300,8 +299,11 @@ export component CardView inherits Rectangle { background: lit ? Theme.selected : hovering ? Theme.row-hover : Theme.card; animate background { duration: lit || hovering ? 0ms : Theme.let-go; easing: ease-out; } touch := TouchArea { - clicked => { if (!root.dragging) { root.chosen(card.id); } } - double-clicked => { root.paste(card.id); } + clicked => { + if (!root.dragging) { + root.chosen(card.id, self.absolute-position.x + self.mouse-x, self.absolute-position.y + self.mouse-y); + } + } pointer-event(event) => { if (event.kind == PointerEventKind.down) { root.dragging = false; diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 435e900c..5cbba42d 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -53,6 +53,10 @@ export component Panel inherits Window { } in-out property opened: false; in-out property hovered: -1; + property last-chosen: -1; + property last-at; + property last-x; + property last-y; in-out property pointer-hidden: false; in-out property quiet: false; in-out property rest-x: -1px; @@ -335,6 +339,7 @@ export component Panel inherits Window { return accept; } if event.text == Key.DownArrow { + root.last-chosen = -1; if (root.hovered >= 0) { root.current = root.hovered; } @@ -350,6 +355,7 @@ export component Panel inherits Window { return accept; } if event.text == Key.UpArrow { + root.last-chosen = -1; if (root.hovered >= 0) { root.current = root.hovered; } @@ -541,6 +547,7 @@ export component Panel inherits Window { wheel := TouchArea { mouse-cursor: root.pointer-hidden ? MouseCursor.none : MouseCursor.default; scroll-event(event) => { + root.last-chosen = -1; root.pointer-hidden = false; root.quiet = true; root.rest-x = -1px; @@ -623,15 +630,6 @@ export component Panel inherits Window { changed height => { if (self.mine) { root.sheet-span = self.height; - if (self.y + self.height > -list.content-y + list.visible-height) { - list.content-y = min( - 0px, - max( - list.visible-height - self.y - self.height - 8px, - -self.y - ) - ); - } } } property open-here: index == root.current && root.opened; @@ -688,7 +686,18 @@ export component Panel inherits Window { x: 10px; width: parent.width - 20px; height: parent.height - (card.heads-group ? Theme.row-head : 0px) - 6px; - chosen(id) => { + chosen(id, px, py) => { + if (root.last-chosen >= 0 && animation-tick() - root.last-at < Theme.double-click + && (px - root.last-x) * (px - root.last-x) / 1px + (py - root.last-y) * (py - root.last-y) / 1px + < Theme.double-reach * Theme.double-reach / 1px) { + root.paste(root.last-chosen); + root.last-chosen = -1; + return; + } + root.last-chosen = id; + root.last-at = animation-tick(); + root.last-x = px; + root.last-y = py; if (root.sheet-open) { root.sheet-open = false; } else if (index == root.current) { @@ -700,7 +709,6 @@ export component Panel inherits Window { root.moved(index); } layout: root.layout; - paste(id) => { root.paste(id); } copy(id) => { root.copy(id); } forms(id) => { root.current = index; diff --git a/crates/cp-panel/ui/theme.slint b/crates/cp-panel/ui/theme.slint index 5069e2ef..e0cbd104 100644 --- a/crates/cp-panel/ui/theme.slint +++ b/crates/cp-panel/ui/theme.slint @@ -43,6 +43,8 @@ export global Theme { out property width: 480px; out property height: 620px; out property drag-start: 6px; + out property double-click: 500ms; + out property double-reach: 10px; out property appear: 120ms; out property settle: 150ms; out property let-go: 90ms;