diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b9726898d..746c7dd6b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3139,7 +3139,8 @@ pub(crate) fn margin_drag(edge: MarginEdge, at: f32, s: &wordcraft_doc::section: /// A draggable margin edge on a ruler: `to_pt` turns the pointer into points from the page's /// left (or top) edge, and `mirrored` is an even page of a mirrored layout ([`margin_drag`]). A -/// whole drag is one undo step, through `layout.margins`. +/// whole drag is one undo step, through `layout.margins`. The view stays put: following the caret +/// mid-drag would move the page under the pointer and the margin with it. fn margin_handle( app: &mut WordApp, ui: &Ui, @@ -3160,7 +3161,7 @@ fn margin_handle( if !r.drag_started() { app.session.join_next_undo(); } - let _ = app.run("layout.margins", json!({ stored.param(): v })); + let _ = app.run_keep_view("layout.margins", json!({ stored.param(): v })); } } @@ -3979,6 +3980,58 @@ mod tests { assert_eq!(margin_drag(MarginEdge::Top, 50.0, §, true), (MarginEdge::Top, dragged_margin(MarginEdge::Top, 50.0, §))); } + /// Dragging a margin edge on the vertical ruler leaves the view where it is. Each step of the + /// drag runs `layout.margins`, which brought the caret back into view: with the caret off + /// screen (the bottom margin is far from the text at the top) the page slid under the pointer + /// mid-drag, and the margin ran far past it (a drag of about 1 cm set it about 6 cm larger). + #[test] + fn dragging_the_bottom_margin_on_the_ruler_keeps_the_view_and_follows_the_pointer() { + let text = vec!["A line of text."; 20].join("\n"); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&text)), Default::default()); + a.run("view.ruler", json!({"value": true})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(6); + let body = h.state_mut().session.layout().pages[0].body; + let area = h.state().canvas.canvas_rect.expect("the canvas is shown"); + // Scroll the first page's bottom margin to the middle of the window: the caret, at the + // top of the page, is off screen. + let bottom = screen(&mut h, 0.0, body.bottom()).y; + h.state_mut().canvas.scroll_request = Some(vec2(0.0, bottom - area.center().y)); + h.run_steps(4); + assert!(screen(&mut h, 0.0, body.y).y < area.min.y, "the caret's line is above the view"); + let offset = h.state().canvas.scroll_offset; + let sect = wordcraft_engine::cmd::page::sect(&h.state().session); + let depth = h.state().session.undo_depth(); + + // The bottom margin's edge on the vertical ruler, dragged up 36 points in a few moves. + let x = area.min.x - RULER / 2.0; + let start = pos2(x, screen(&mut h, 0.0, body.bottom()).y); + let end = pos2(x, screen(&mut h, 0.0, body.bottom() - 36.0).y); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=5 { + h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 5.0))); + h.step(); + assert_eq!(h.state().canvas.scroll_offset, offset, "the view moved during the drag (step {k})"); + } + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + assert_eq!(h.state().canvas.scroll_offset, offset, "the view moved after the drag"); + let after = wordcraft_engine::cmd::page::sect(&h.state().session); + assert!((after.margin_bottom - (sect.margin_bottom + 36.0)).abs() <= 4.5, "{} → {}", sect.margin_bottom, after.margin_bottom); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step for the drag"); + } + /// Show revisions in balloons: a document whose only revision is a deletion gets the markup /// area for its balloon; the other modes have nothing to show there, and neither does the view /// while the Comments pane is open.