mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 20:47:15 -06:00
refactor(ui-egui): move canvas tests to canvas/tests.rs
This commit is contained in:
@@ -1611,583 +1611,7 @@ fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::Shape
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's
|
||||
/// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old
|
||||
/// fixed handle-to-cursor mapping showed NW-SE there.
|
||||
#[test]
|
||||
fn resize_cursor_tracks_rotation() {
|
||||
let mut s = Session::with_new();
|
||||
// A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape
|
||||
// rotation cleanly moves the cursor to the next of the four resize directions.
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
// Unrotated: the NW handle (0) drags the NW-SE diagonal.
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical);
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal);
|
||||
// 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 45.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally");
|
||||
// 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 90.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal");
|
||||
}
|
||||
|
||||
/// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The
|
||||
/// corner's direction is the diagonal, not the line to the box's centre, so a wide
|
||||
/// rectangle's NW corner still drags along the NW-SE diagonal.
|
||||
#[test]
|
||||
fn a_wide_shape_keeps_its_corner_cursors_diagonal() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW");
|
||||
// The edges keep their own cursors.
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally");
|
||||
}
|
||||
|
||||
/// #745: a placeholder that inherits its box from the layout still shows a resize cursor.
|
||||
/// Its handles are drawn from the inherited box, so the cursor follows that one.
|
||||
#[test]
|
||||
fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() {
|
||||
let s = Session::with_new();
|
||||
let st = s.doc().unwrap();
|
||||
let ph = st
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.xfrm.is_none() && sh.ph.is_some())
|
||||
.expect("the new slide has a placeholder without a box of its own")
|
||||
.id;
|
||||
assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// The slide point (in slide points) under screen position `p`.
|
||||
fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 {
|
||||
let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
|
||||
pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s)
|
||||
}
|
||||
|
||||
/// Test helper: inject a platform-command-modified line wheel event and run thirty harness
|
||||
/// steps.
|
||||
fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, lines),
|
||||
phase: TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
modifiers: Modifiers::COMMAND,
|
||||
};
|
||||
h.event(wheel);
|
||||
// egui spreads a wheel notch over several frames.
|
||||
h.run_steps(30);
|
||||
}
|
||||
|
||||
/// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel
|
||||
/// grid (#184).
|
||||
fn assert_near(a: Pos2, b: Pos2, what: &str) {
|
||||
assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}");
|
||||
}
|
||||
|
||||
/// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer.
|
||||
#[test]
|
||||
fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
let r = h.state().slide_rect.unwrap();
|
||||
let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7);
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
let under = slide_point(&h, p);
|
||||
|
||||
h.event(Event::Zoom(2.0));
|
||||
h.run_steps(2);
|
||||
assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3);
|
||||
assert_near(under, slide_point(&h, p), "pinch in");
|
||||
|
||||
ctrl_wheel(&mut h, 1.0);
|
||||
assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in");
|
||||
assert_near(under, slide_point(&h, p), "wheel in");
|
||||
|
||||
ctrl_wheel(&mut h, -1.0);
|
||||
ctrl_wheel(&mut h, -1.0);
|
||||
assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out");
|
||||
assert_near(under, slide_point(&h, p), "wheel out");
|
||||
|
||||
h.event(Event::Zoom(0.8));
|
||||
h.run_steps(2);
|
||||
assert_near(under, slide_point(&h, p), "pinch out");
|
||||
|
||||
// Zoomed out below the window size, the slide is centred again.
|
||||
h.event(Event::Zoom(0.25));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().canvas_scale < fit);
|
||||
let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap());
|
||||
assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}");
|
||||
}
|
||||
|
||||
/// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is.
|
||||
#[test]
|
||||
fn zoom_commands_keep_the_middle_of_the_view() {
|
||||
let mut h = harness();
|
||||
let c = h.state().canvas_rect.unwrap().center();
|
||||
let mid = slide_point(&h, c);
|
||||
for _ in 0..4 {
|
||||
h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_near(mid, slide_point(&h, c), "zoom in");
|
||||
}
|
||||
h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_near(mid, slide_point(&h, c), "zoom out");
|
||||
}
|
||||
|
||||
/// Drags with the primary button from `a` to `b`; `during` runs after every move.
|
||||
fn drag_with(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2, mut during: impl FnMut(&SlideApp)) {
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=8 {
|
||||
h.event(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
during(h.state());
|
||||
}
|
||||
h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// #1157: a slide zoomed past the window has a scroll bar on each axis that overflows, and
|
||||
/// dragging its thumb pans the slide, not a marquee on the canvas.
|
||||
#[test]
|
||||
fn scroll_bars_pan_a_zoomed_slide() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
h.state_mut().ui.zoom = Some(fit * 3.0);
|
||||
h.run_steps(3);
|
||||
let canvas = h.state().canvas_rect.unwrap();
|
||||
let before = h.state().slide_rect.unwrap();
|
||||
let no_marquee =
|
||||
|app: &SlideApp| assert!(app.session.tool.marquee.is_none() && !app.session.tool.dragging(), "the bar drag reached the slide");
|
||||
|
||||
// Zoomed about the middle, so each thumb sits in the middle of its track.
|
||||
let bottom = pos2(canvas.center().x, canvas.max.y - 4.0);
|
||||
drag_with(&mut h, bottom, bottom + vec2(150.0, 0.0), no_marquee);
|
||||
let after = h.state().slide_rect.unwrap();
|
||||
assert!(before.min.x - after.min.x > 150.0, "the horizontal thumb pans right: {before:?} -> {after:?}");
|
||||
assert!((before.min.y - after.min.y).abs() < 1.0, "only across: {before:?} -> {after:?}");
|
||||
|
||||
let right = pos2(canvas.max.x - 4.0, canvas.center().y);
|
||||
drag_with(&mut h, right, right + vec2(0.0, 100.0), no_marquee);
|
||||
let down = h.state().slide_rect.unwrap();
|
||||
assert!(after.min.y - down.min.y > 100.0, "the vertical thumb pans down: {after:?} -> {down:?}");
|
||||
assert!((after.min.x - down.min.x).abs() < 1.0, "only down: {after:?} -> {down:?}");
|
||||
|
||||
// A click on the track beside the thumb pages back toward it.
|
||||
let left = pos2(canvas.min.x + 20.0, canvas.max.y - 4.0);
|
||||
h.hover_at(left);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let paged = h.state().slide_rect.unwrap();
|
||||
assert!(paged.min.x - down.min.x > 100.0, "a track click pages left: {down:?} -> {paged:?}");
|
||||
assert!(h.state().session.tool.marquee.is_none());
|
||||
|
||||
// The ends of the tracks reach the slide's far edges.
|
||||
drag_with(&mut h, right, right + vec2(0.0, 2000.0), |_| {});
|
||||
let end = h.state().slide_rect.unwrap();
|
||||
assert!((end.max.y - (canvas.max.y - 28.0)).abs() < 2.0, "the bottom of the slide comes into view: {end:?} in {canvas:?}");
|
||||
}
|
||||
|
||||
/// A press on the thumb that ends just past its end is a nudge, not a page: the track click
|
||||
/// is decided where the button went down, not where it came up.
|
||||
#[test]
|
||||
fn a_short_press_on_the_thumb_does_not_page() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
h.state_mut().ui.zoom = Some(fit * 3.0);
|
||||
h.run_steps(3);
|
||||
let (canvas, before) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap());
|
||||
let overflow = vec2((before.width() + 56.0 - canvas.width()).max(0.0), (before.height() + 56.0 - canvas.height()).max(0.0));
|
||||
let offset = canvas.min + vec2(28.0, 28.0) - before.min;
|
||||
let (_, thumb, _) = scroll_bar(canvas, 0.0, overflow, offset, 0).unwrap();
|
||||
let (a, b) = (pos2(thumb.max.x - 3.0, canvas.max.y - 4.0), pos2(thumb.max.x + 3.0, canvas.max.y - 4.0));
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(b));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let after = h.state().slide_rect.unwrap();
|
||||
let moved = before.min.x - after.min.x;
|
||||
assert!((0.0..40.0).contains(&moved), "a 6 pt nudge of the thumb, not a page: {before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
/// Only the primary button drags a thumb: a right or middle drag after a left one leaves the
|
||||
/// slide where it is.
|
||||
#[test]
|
||||
fn only_the_primary_button_drags_a_thumb() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
h.state_mut().ui.zoom = Some(fit * 3.0);
|
||||
h.run_steps(3);
|
||||
let canvas = h.state().canvas_rect.unwrap();
|
||||
let bottom = pos2(canvas.center().x, canvas.max.y - 4.0);
|
||||
drag_with(&mut h, bottom, bottom + vec2(40.0, 0.0), |_| {});
|
||||
let before = h.state().slide_rect.unwrap();
|
||||
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
|
||||
let a = pos2(canvas.center().x + 40.0, canvas.max.y - 4.0);
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
h.event(Event::PointerMoved(a + vec2(30.0 * i as f32, 0.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(Event::PointerButton { pos: a + vec2(120.0, 0.0), button, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let after = h.state().slide_rect.unwrap();
|
||||
assert!((before.min - after.min).length() < 1.0, "{button:?} drag moved the slide: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide that fits has no scroll bars: a drag along the canvas edge is a marquee.
|
||||
#[test]
|
||||
fn a_fitted_slide_has_no_scroll_bars() {
|
||||
let mut h = harness();
|
||||
let canvas = h.state().canvas_rect.unwrap();
|
||||
let a = pos2(canvas.center().x, canvas.max.y - 4.0);
|
||||
let mut marquee = false;
|
||||
drag_with(&mut h, a, a + vec2(150.0, 0.0), |app| marquee |= app.session.tool.marquee.is_some());
|
||||
assert!(marquee, "the drag selects on the canvas");
|
||||
}
|
||||
|
||||
fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let data = json!([["Hello world", "b"], ["c", ""]]);
|
||||
let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(s, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
harness.run_steps(2);
|
||||
(harness, id)
|
||||
}
|
||||
|
||||
/// Screen position of slide point (x, y).
|
||||
fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 {
|
||||
Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y))
|
||||
}
|
||||
|
||||
/// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click).
|
||||
fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
let t = harness.ctx.input(|i| i.time) + 0.05;
|
||||
harness.input_mut().time = Some(t);
|
||||
harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// The first empty placeholder and the paragraph after Enter keep a list marker and indent.
|
||||
#[test]
|
||||
fn empty_bullet_preview_follows_click_type_enter_and_toggle() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh = st
|
||||
.shapes()
|
||||
.iter()
|
||||
.find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj))
|
||||
.unwrap();
|
||||
let id = sh.id;
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let p = screen(h.state(), x.x + 40.0, x.y + 20.0);
|
||||
click(&mut h, p);
|
||||
h.run_steps(3);
|
||||
let caret = |h: &Harness<'_, SlideApp>| {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().expect("click starts typing");
|
||||
assert_eq!(ts.shape, id);
|
||||
deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0
|
||||
};
|
||||
let first = caret(&h);
|
||||
h.event(Event::Text("First point".into()));
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let second = caret(&h);
|
||||
assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent");
|
||||
assert!(second.y > first.y);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(l.empty_bullets.len(), 1);
|
||||
assert_eq!(l.empty_bullets[0].para, 1);
|
||||
let renders = h.state().textures.canvas_renders;
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture");
|
||||
h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.event(Event::Text("Second point".into()));
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().unwrap();
|
||||
assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point");
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty());
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("format.bullets", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty());
|
||||
}
|
||||
|
||||
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
|
||||
#[test]
|
||||
fn double_click_in_a_table_cell_shows_the_caret_there() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
// The empty cell (1, 1) spans x 300..500, y 140..180.
|
||||
let p = screen(harness.state(), 400.0, 160.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
click(&mut harness, p);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().expect("editing the cell");
|
||||
assert_eq!((ts.shape, ts.cell), (id, Some((1, 1))));
|
||||
assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range");
|
||||
let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret");
|
||||
for q in [top, bottom] {
|
||||
assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)");
|
||||
}
|
||||
// A click in another cell moves the caret there.
|
||||
let p = screen(harness.state(), 110.0, 120.0);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(1);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0)));
|
||||
let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap();
|
||||
assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)");
|
||||
}
|
||||
|
||||
/// Table borders show a resize cursor and drag to resize columns.
|
||||
#[test]
|
||||
fn table_borders_resize_with_the_pointer() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
let p = screen(harness.state(), 300.0, 120.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
harness.step();
|
||||
assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn);
|
||||
let q = screen(harness.state(), 340.0, 120.0);
|
||||
harness.drag_at(p);
|
||||
harness.step();
|
||||
for k in 1..=4 {
|
||||
harness.hover_at(p + (q - p) * k as f32 / 4.0);
|
||||
harness.step();
|
||||
}
|
||||
harness.drop_at(q);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") };
|
||||
assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols);
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) {
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking
|
||||
/// a point offers Open Path, then Close Path once the outline is open.
|
||||
#[test]
|
||||
fn edit_points_menu_sets_segment_types_and_opens_the_path() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// The middle of the right edge: the segment ending at point 2.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Curved Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"]));
|
||||
// The bottom-right corner: Open Path breaks the outline there.
|
||||
let p = screen(h.state(), 500.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5));
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4));
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Add Point at the pointer, Delete Segment on
|
||||
/// a closed outline, and Open Path at the segment's start point.
|
||||
#[test]
|
||||
fn edit_points_segment_menu_adds_points_and_deletes_segments() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// A quarter of the way along the top edge: the new point goes there.
|
||||
let p = screen(h.state(), 275.0, 150.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Add Point").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0], json!([[200.0, 150.0], [275.0, 150.0], [500.0, 150.0], [500.0, 350.0], [200.0, 350.0]]));
|
||||
// The right edge: Open Path opens the outline at its top end.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0][0], json!([500.0, 150.0]));
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(6));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
// Delete Segment removes the right edge: the outline now runs from its bottom end to its top end.
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Delete Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0], json!([[500.0, 350.0], [200.0, 350.0], [200.0, 150.0], [275.0, 150.0], [500.0, 150.0]]));
|
||||
assert_eq!(q(&mut h)["segments"][0][0], json!(null));
|
||||
// On the open outline Delete Segment is grayed out; Close Path joins the ends again.
|
||||
let p = screen(h.state(), 350.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
assert!(h.get_by_label("Delete Segment").accesskit_node().is_disabled());
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0][0], json!("straight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_follows_spacing_and_rejects_unusable_values() {
|
||||
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
|
||||
assert!((step - 36.0).abs() < 1e-3, "{step}");
|
||||
assert!(super::grid_line_step(6.0, 1.0).is_some());
|
||||
assert!(super::grid_line_step(0.0, 1.0).is_none());
|
||||
assert!(super::grid_line_step(f64::NAN, 1.0).is_none());
|
||||
assert!(super::grid_line_step(1_000.0, 1.0).is_none());
|
||||
assert!(super::grid_line_step(6.0, 0.0).is_none());
|
||||
assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point");
|
||||
}
|
||||
|
||||
/// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the
|
||||
/// user can see the tool is waiting for a click.
|
||||
#[test]
|
||||
fn format_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
// Hover over the canvas so the pointer function runs and sets the cursor.
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
// Arming the format painter overrides the cursor to Copy (#830).
|
||||
h.state_mut().session.painter = Some((Default::default(), false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor");
|
||||
}
|
||||
|
||||
/// #830: the Animation Painter arms the same cursor.
|
||||
#[test]
|
||||
fn animation_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
h.state_mut().session.anim_painter = Some((vec![], false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ruler_tests.rs"]
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's
|
||||
/// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old
|
||||
/// fixed handle-to-cursor mapping showed NW-SE there.
|
||||
#[test]
|
||||
fn resize_cursor_tracks_rotation() {
|
||||
let mut s = Session::with_new();
|
||||
// A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape
|
||||
// rotation cleanly moves the cursor to the next of the four resize directions.
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"].as_u64().unwrap()
|
||||
as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
// Unrotated: the NW handle (0) drags the NW-SE diagonal.
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical);
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal);
|
||||
// 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 45.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally");
|
||||
// 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 90.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal");
|
||||
}
|
||||
|
||||
/// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The
|
||||
/// corner's direction is the diagonal, not the line to the box's centre, so a wide
|
||||
/// rectangle's NW corner still drags along the NW-SE diagonal.
|
||||
#[test]
|
||||
fn a_wide_shape_keeps_its_corner_cursors_diagonal() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"].as_u64().unwrap()
|
||||
as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW");
|
||||
// The edges keep their own cursors.
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally");
|
||||
}
|
||||
|
||||
/// #745: a placeholder that inherits its box from the layout still shows a resize cursor.
|
||||
/// Its handles are drawn from the inherited box, so the cursor follows that one.
|
||||
#[test]
|
||||
fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() {
|
||||
let s = Session::with_new();
|
||||
let st = s.doc().unwrap();
|
||||
let ph = st
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.xfrm.is_none() && sh.ph.is_some())
|
||||
.expect("the new slide has a placeholder without a box of its own")
|
||||
.id;
|
||||
assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// The slide point (in slide points) under screen position `p`.
|
||||
fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 {
|
||||
let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
|
||||
pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s)
|
||||
}
|
||||
|
||||
/// Test helper: inject a platform-command-modified line wheel event and run thirty harness
|
||||
/// steps.
|
||||
fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, lines),
|
||||
phase: TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
modifiers: Modifiers::COMMAND,
|
||||
};
|
||||
h.event(wheel);
|
||||
// egui spreads a wheel notch over several frames.
|
||||
h.run_steps(30);
|
||||
}
|
||||
|
||||
/// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel
|
||||
/// grid (#184).
|
||||
fn assert_near(a: Pos2, b: Pos2, what: &str) {
|
||||
assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}");
|
||||
}
|
||||
|
||||
/// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer.
|
||||
#[test]
|
||||
fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
let r = h.state().slide_rect.unwrap();
|
||||
let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7);
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
let under = slide_point(&h, p);
|
||||
|
||||
h.event(Event::Zoom(2.0));
|
||||
h.run_steps(2);
|
||||
assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3);
|
||||
assert_near(under, slide_point(&h, p), "pinch in");
|
||||
|
||||
ctrl_wheel(&mut h, 1.0);
|
||||
assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in");
|
||||
assert_near(under, slide_point(&h, p), "wheel in");
|
||||
|
||||
ctrl_wheel(&mut h, -1.0);
|
||||
ctrl_wheel(&mut h, -1.0);
|
||||
assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out");
|
||||
assert_near(under, slide_point(&h, p), "wheel out");
|
||||
|
||||
h.event(Event::Zoom(0.8));
|
||||
h.run_steps(2);
|
||||
assert_near(under, slide_point(&h, p), "pinch out");
|
||||
|
||||
// Zoomed out below the window size, the slide is centred again.
|
||||
h.event(Event::Zoom(0.25));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().canvas_scale < fit);
|
||||
let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap());
|
||||
assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}");
|
||||
}
|
||||
|
||||
/// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is.
|
||||
#[test]
|
||||
fn zoom_commands_keep_the_middle_of_the_view() {
|
||||
let mut h = harness();
|
||||
let c = h.state().canvas_rect.unwrap().center();
|
||||
let mid = slide_point(&h, c);
|
||||
for _ in 0..4 {
|
||||
h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_near(mid, slide_point(&h, c), "zoom in");
|
||||
}
|
||||
h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_near(mid, slide_point(&h, c), "zoom out");
|
||||
}
|
||||
|
||||
/// Drags with the primary button from `a` to `b`; `during` runs after every move.
|
||||
fn drag_with(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2, mut during: impl FnMut(&SlideApp)) {
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=8 {
|
||||
h.event(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
during(h.state());
|
||||
}
|
||||
h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// #1157: a slide zoomed past the window has a scroll bar on each axis that overflows, and
|
||||
/// dragging its thumb pans the slide, not a marquee on the canvas.
|
||||
#[test]
|
||||
fn scroll_bars_pan_a_zoomed_slide() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
h.state_mut().ui.zoom = Some(fit * 3.0);
|
||||
h.run_steps(3);
|
||||
let canvas = h.state().canvas_rect.unwrap();
|
||||
let before = h.state().slide_rect.unwrap();
|
||||
let no_marquee = |app: &SlideApp| assert!(app.session.tool.marquee.is_none() && !app.session.tool.dragging(), "the bar drag reached the slide");
|
||||
|
||||
// Zoomed about the middle, so each thumb sits in the middle of its track.
|
||||
let bottom = pos2(canvas.center().x, canvas.max.y - 4.0);
|
||||
drag_with(&mut h, bottom, bottom + vec2(150.0, 0.0), no_marquee);
|
||||
let after = h.state().slide_rect.unwrap();
|
||||
assert!(before.min.x - after.min.x > 150.0, "the horizontal thumb pans right: {before:?} -> {after:?}");
|
||||
assert!((before.min.y - after.min.y).abs() < 1.0, "only across: {before:?} -> {after:?}");
|
||||
|
||||
let right = pos2(canvas.max.x - 4.0, canvas.center().y);
|
||||
drag_with(&mut h, right, right + vec2(0.0, 100.0), no_marquee);
|
||||
let down = h.state().slide_rect.unwrap();
|
||||
assert!(after.min.y - down.min.y > 100.0, "the vertical thumb pans down: {after:?} -> {down:?}");
|
||||
assert!((after.min.x - down.min.x).abs() < 1.0, "only down: {after:?} -> {down:?}");
|
||||
|
||||
// A click on the track beside the thumb pages back toward it.
|
||||
let left = pos2(canvas.min.x + 20.0, canvas.max.y - 4.0);
|
||||
h.hover_at(left);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let paged = h.state().slide_rect.unwrap();
|
||||
assert!(paged.min.x - down.min.x > 100.0, "a track click pages left: {down:?} -> {paged:?}");
|
||||
assert!(h.state().session.tool.marquee.is_none());
|
||||
|
||||
// The ends of the tracks reach the slide's far edges.
|
||||
drag_with(&mut h, right, right + vec2(0.0, 2000.0), |_| {});
|
||||
let end = h.state().slide_rect.unwrap();
|
||||
assert!((end.max.y - (canvas.max.y - 28.0)).abs() < 2.0, "the bottom of the slide comes into view: {end:?} in {canvas:?}");
|
||||
}
|
||||
|
||||
/// A press on the thumb that ends just past its end is a nudge, not a page: the track click
|
||||
/// is decided where the button went down, not where it came up.
|
||||
#[test]
|
||||
fn a_short_press_on_the_thumb_does_not_page() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
h.state_mut().ui.zoom = Some(fit * 3.0);
|
||||
h.run_steps(3);
|
||||
let (canvas, before) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap());
|
||||
let overflow = vec2((before.width() + 56.0 - canvas.width()).max(0.0), (before.height() + 56.0 - canvas.height()).max(0.0));
|
||||
let offset = canvas.min + vec2(28.0, 28.0) - before.min;
|
||||
let (_, thumb, _) = scroll_bar(canvas, 0.0, overflow, offset, 0).unwrap();
|
||||
let (a, b) = (pos2(thumb.max.x - 3.0, canvas.max.y - 4.0), pos2(thumb.max.x + 3.0, canvas.max.y - 4.0));
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(b));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let after = h.state().slide_rect.unwrap();
|
||||
let moved = before.min.x - after.min.x;
|
||||
assert!((0.0..40.0).contains(&moved), "a 6 pt nudge of the thumb, not a page: {before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
/// Only the primary button drags a thumb: a right or middle drag after a left one leaves the
|
||||
/// slide where it is.
|
||||
#[test]
|
||||
fn only_the_primary_button_drags_a_thumb() {
|
||||
let mut h = harness();
|
||||
let fit = h.state().canvas_scale;
|
||||
h.state_mut().ui.zoom = Some(fit * 3.0);
|
||||
h.run_steps(3);
|
||||
let canvas = h.state().canvas_rect.unwrap();
|
||||
let bottom = pos2(canvas.center().x, canvas.max.y - 4.0);
|
||||
drag_with(&mut h, bottom, bottom + vec2(40.0, 0.0), |_| {});
|
||||
let before = h.state().slide_rect.unwrap();
|
||||
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
|
||||
let a = pos2(canvas.center().x + 40.0, canvas.max.y - 4.0);
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
h.event(Event::PointerMoved(a + vec2(30.0 * i as f32, 0.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(Event::PointerButton { pos: a + vec2(120.0, 0.0), button, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let after = h.state().slide_rect.unwrap();
|
||||
assert!((before.min - after.min).length() < 1.0, "{button:?} drag moved the slide: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide that fits has no scroll bars: a drag along the canvas edge is a marquee.
|
||||
#[test]
|
||||
fn a_fitted_slide_has_no_scroll_bars() {
|
||||
let mut h = harness();
|
||||
let canvas = h.state().canvas_rect.unwrap();
|
||||
let a = pos2(canvas.center().x, canvas.max.y - 4.0);
|
||||
let mut marquee = false;
|
||||
drag_with(&mut h, a, a + vec2(150.0, 0.0), |app| marquee |= app.session.tool.marquee.is_some());
|
||||
assert!(marquee, "the drag selects on the canvas");
|
||||
}
|
||||
|
||||
fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let data = json!([["Hello world", "b"], ["c", ""]]);
|
||||
let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(s, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
harness.run_steps(2);
|
||||
(harness, id)
|
||||
}
|
||||
|
||||
/// Screen position of slide point (x, y).
|
||||
fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 {
|
||||
Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y))
|
||||
}
|
||||
|
||||
/// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click).
|
||||
fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
let t = harness.ctx.input(|i| i.time) + 0.05;
|
||||
harness.input_mut().time = Some(t);
|
||||
harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// The first empty placeholder and the paragraph after Enter keep a list marker and indent.
|
||||
#[test]
|
||||
fn empty_bullet_preview_follows_click_type_enter_and_toggle() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh =
|
||||
st.shapes().iter().find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap();
|
||||
let id = sh.id;
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let p = screen(h.state(), x.x + 40.0, x.y + 20.0);
|
||||
click(&mut h, p);
|
||||
h.run_steps(3);
|
||||
let caret = |h: &Harness<'_, SlideApp>| {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().expect("click starts typing");
|
||||
assert_eq!(ts.shape, id);
|
||||
deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0
|
||||
};
|
||||
let first = caret(&h);
|
||||
h.event(Event::Text("First point".into()));
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let second = caret(&h);
|
||||
assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent");
|
||||
assert!(second.y > first.y);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(l.empty_bullets.len(), 1);
|
||||
assert_eq!(l.empty_bullets[0].para, 1);
|
||||
let renders = h.state().textures.canvas_renders;
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture");
|
||||
h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.event(Event::Text("Second point".into()));
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().unwrap();
|
||||
assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point");
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty());
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("format.bullets", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty());
|
||||
}
|
||||
|
||||
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
|
||||
#[test]
|
||||
fn double_click_in_a_table_cell_shows_the_caret_there() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
// The empty cell (1, 1) spans x 300..500, y 140..180.
|
||||
let p = screen(harness.state(), 400.0, 160.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
click(&mut harness, p);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().expect("editing the cell");
|
||||
assert_eq!((ts.shape, ts.cell), (id, Some((1, 1))));
|
||||
assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range");
|
||||
let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret");
|
||||
for q in [top, bottom] {
|
||||
assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)");
|
||||
}
|
||||
// A click in another cell moves the caret there.
|
||||
let p = screen(harness.state(), 110.0, 120.0);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(1);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0)));
|
||||
let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap();
|
||||
assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)");
|
||||
}
|
||||
|
||||
/// Table borders show a resize cursor and drag to resize columns.
|
||||
#[test]
|
||||
fn table_borders_resize_with_the_pointer() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
let p = screen(harness.state(), 300.0, 120.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
harness.step();
|
||||
assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn);
|
||||
let q = screen(harness.state(), 340.0, 120.0);
|
||||
harness.drag_at(p);
|
||||
harness.step();
|
||||
for k in 1..=4 {
|
||||
harness.hover_at(p + (q - p) * k as f32 / 4.0);
|
||||
harness.step();
|
||||
}
|
||||
harness.drop_at(q);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") };
|
||||
assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols);
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) {
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking
|
||||
/// a point offers Open Path, then Close Path once the outline is open.
|
||||
#[test]
|
||||
fn edit_points_menu_sets_segment_types_and_opens_the_path() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// The middle of the right edge: the segment ending at point 2.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Curved Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"]));
|
||||
// The bottom-right corner: Open Path breaks the outline there.
|
||||
let p = screen(h.state(), 500.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5));
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4));
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Add Point at the pointer, Delete Segment on
|
||||
/// a closed outline, and Open Path at the segment's start point.
|
||||
#[test]
|
||||
fn edit_points_segment_menu_adds_points_and_deletes_segments() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// A quarter of the way along the top edge: the new point goes there.
|
||||
let p = screen(h.state(), 275.0, 150.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Add Point").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0], json!([[200.0, 150.0], [275.0, 150.0], [500.0, 150.0], [500.0, 350.0], [200.0, 350.0]]));
|
||||
// The right edge: Open Path opens the outline at its top end.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0][0], json!([500.0, 150.0]));
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(6));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
// Delete Segment removes the right edge: the outline now runs from its bottom end to its top end.
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Delete Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0], json!([[500.0, 350.0], [200.0, 350.0], [200.0, 150.0], [275.0, 150.0], [500.0, 150.0]]));
|
||||
assert_eq!(q(&mut h)["segments"][0][0], json!(null));
|
||||
// On the open outline Delete Segment is grayed out; Close Path joins the ends again.
|
||||
let p = screen(h.state(), 350.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
assert!(h.get_by_label("Delete Segment").accesskit_node().is_disabled());
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0][0], json!("straight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_follows_spacing_and_rejects_unusable_values() {
|
||||
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
|
||||
assert!((step - 36.0).abs() < 1e-3, "{step}");
|
||||
assert!(super::grid_line_step(6.0, 1.0).is_some());
|
||||
assert!(super::grid_line_step(0.0, 1.0).is_none());
|
||||
assert!(super::grid_line_step(f64::NAN, 1.0).is_none());
|
||||
assert!(super::grid_line_step(1_000.0, 1.0).is_none());
|
||||
assert!(super::grid_line_step(6.0, 0.0).is_none());
|
||||
assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point");
|
||||
}
|
||||
|
||||
/// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the
|
||||
/// user can see the tool is waiting for a click.
|
||||
#[test]
|
||||
fn format_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
// Hover over the canvas so the pointer function runs and sets the cursor.
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
// Arming the format painter overrides the cursor to Copy (#830).
|
||||
h.state_mut().session.painter = Some((Default::default(), false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor");
|
||||
}
|
||||
|
||||
/// #830: the Animation Painter arms the same cursor.
|
||||
#[test]
|
||||
fn animation_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
h.state_mut().session.anim_painter = Some((vec![], false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor");
|
||||
}
|
||||
Reference in New Issue
Block a user