Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/cp-panel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/cp-panel/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,7 @@ fn seeded() -> PathBuf {
fn where_it_lives() -> Option<PathBuf> {
here::data_dir().map(|dir| dir.join("history.db"))
}

#[cfg(test)]
#[path = "main_test.rs"]
mod tests;
182 changes: 182 additions & 0 deletions crates/cp-panel/src/main_test.rs
Original file line number Diff line number Diff line change
@@ -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<VecModel<Card>>,
pasted: Rc<RefCell<Vec<i32>>>,
moved: Rc<RefCell<Vec<i32>>>,
}

fn tick(ms: u64) {
i_slint_backend_testing::mock_elapsed_time(Duration::from_millis(ms));
}

fn height_of(cards: &VecModel<Card>, open: Option<usize>, 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<Card>, open: Option<usize>, 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<usize> {
(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::<Vec<_>>()));
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::<Theme>().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::<Theme>().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()
);
}
10 changes: 6 additions & 4 deletions crates/cp-panel/ui/cards.slint
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,6 @@ export component CardView inherits Rectangle {
in property <bool> open;
in property <bool> shown-here;
in property <bool> quiet;
callback paste(int);
callback copy(int);
callback open-asked(int);
callback forms(int);
Expand All @@ -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 <bool> dragging;
Expand All @@ -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;
Expand Down
30 changes: 19 additions & 11 deletions crates/cp-panel/ui/panel.slint
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export component Panel inherits Window {
}
in-out property <bool> opened: false;
in-out property <int> hovered: -1;
property <int> last-chosen: -1;
property <duration> last-at;
property <length> last-x;
property <length> last-y;
in-out property <bool> pointer-hidden: false;
in-out property <bool> quiet: false;
in-out property <length> rest-x: -1px;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <bool> open-here: index == root.current && root.opened;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions crates/cp-panel/ui/theme.slint
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export global Theme {
out property <length> width: 480px;
out property <length> height: 620px;
out property <length> drag-start: 6px;
out property <duration> double-click: 500ms;
out property <length> double-reach: 10px;
out property <duration> appear: 120ms;
out property <duration> settle: 150ms;
out property <duration> let-go: 90ms;
Expand Down
Loading