From 61def74383ad636b4feadd773664bc3fa5b78e0a Mon Sep 17 00:00:00 2001 From: Justin John Date: Wed, 14 Oct 2026 16:04:34 +0530 Subject: [PATCH 1/2] M0: finish canvas drags released outside --- crates/engine/src/double_click_tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 11 +++++++++++ crates/ui-egui/src/canvas_drag_tests.rs | 16 ++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index e51cb7da8..e9532f3e4 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -74,6 +74,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() { } } +#[test] +fn editing_text_keeps_other_document_changes_since_open() { + let mut s = Session::new(); + let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}})); + ex(&mut s, "edit.delete", json!({"ids": [deleted]})); + + let bounds = s.doc().unwrap().world_bounds(text).unwrap(); + double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0); + assert_eq!(editing(&s), Some(text)); + crate::textedit::insert(&mut s, "B").unwrap(); + crate::textedit::end(&mut s).unwrap(); + + assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0); + assert!(!s.doc().unwrap().contains(deleted)); + assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B"); +} + #[test] fn a_press_drags_only_past_the_threshold_in_screen_pixels() { for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de0..8fd2e6c46 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,6 +371,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { + if app.canvas.dragging && ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) { + app.canvas.dragging = false; + let pos = hover_pos.unwrap_or(app.canvas.rect.center()); + let ev = pointer_event(app, PointerKind::Up, pos, &mods); + app.canvas.pressure = None; + app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.handle_ui_requests(); + } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { app.session.vector_pointer_left(); diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs index 9bc56fb2a..112ba0e11 100644 --- a/crates/ui-egui/src/canvas_drag_tests.rs +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -59,3 +59,19 @@ fn a_drag_shows_on_the_canvas_while_it_moves() { assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight()), "nothing left to render"); u.button(p + vec2(30.0, 10.0), false); } + +#[test] +fn releasing_a_canvas_drag_outside_finishes_the_interaction() { + let mut s = Session::new(); + let r = + figcraft_engine::params::id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let start = u.app.to_screen(80.0, 70.0); + let outside = egui::pos2(u.app.canvas.rect.max.x + 20.0, start.y); + u.button(start, true); + u.frame(vec![Event::PointerMoved(outside)]); + u.button(outside, false); + + assert!(u.app.session.ds().unwrap().interaction.is_none(), "the document interaction is committed"); +} From 477f019737ee3aa9fcb4df3f6ba63a1d9d4c2659 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:34:23 +0700 Subject: [PATCH 2/2] #280: a drag released off the window ends where the pointer was last seen, not mid-canvas; the test follows #278 --- crates/ui-egui/src/canvas.rs | 35 ++++++++++--------- crates/ui-egui/src/canvas_drag_tests.rs | 46 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 31dbb2d84..348f077db 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,16 +371,12 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { - if app.canvas.dragging && ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) { + // A drag released off the canvas, or off the window, ends where the pointer was last seen. + if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) { app.canvas.dragging = false; - let pos = hover_pos.unwrap_or(app.canvas.rect.center()); - let ev = pointer_event(app, PointerKind::Up, pos, &mods); - app.canvas.pressure = None; - app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); - if let Err(e) = app.session.pointer(&ev) { - app.toast(e.to_string()); + if let Some(at) = hover_pos.or_else(|| app.canvas.last_pointer.map(|p| app.to_screen(p.x, p.y))) { + send(app, PointerKind::Up, at, &mods); } - app.handle_ui_requests(); } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { @@ -493,19 +489,24 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { app.canvas.pressure = force; } if let Some(k) = kind { - let ev = pointer_event(app, k, pos, &mods); - if k == PointerKind::Up { - app.canvas.pressure = None; - } - app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); - if let Err(e) = app.session.pointer(&ev) { - app.toast(e.to_string()); - } - app.handle_ui_requests(); + send(app, k, pos, &mods); } true } +/// Pointer event `kind` at screen point `pos`, to the tools. +fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) { + let ev = pointer_event(app, kind, pos, m); + if kind == PointerKind::Up { + app.canvas.pressure = None; + } + app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.handle_ui_requests(); +} + /// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then /// open the context menu there. pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs index 112ba0e11..9f3b38397 100644 --- a/crates/ui-egui/src/canvas_drag_tests.rs +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -75,3 +75,49 @@ fn releasing_a_canvas_drag_outside_finishes_the_interaction() { assert!(u.app.session.ds().unwrap().interaction.is_none(), "the document interaction is committed"); } + +fn key(u: &mut Ui, key: egui::Key) { + let modifiers = egui::Modifiers::NONE; + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +#[test] +fn a_drag_released_off_the_window_ends_where_the_pointer_was_last_seen_and_later_edits_stay() { + // #278: a drag left open made a later text edit roll back the changes made since. + let mut s = Session::new(); + let id = |v: serde_json::Value| figcraft_engine::params::id_of(&v["id"]).unwrap(); + let kept = id(s.execute("shape.rectangle", &json!({"x": 40, "y": 400, "width": 80, "height": 40})).unwrap()); + let gone = id(s.execute("shape.rectangle", &json!({"x": 160, "y": 400, "width": 80, "height": 40})).unwrap()); + let text = id(s.execute("text.create", &json!({"text": "A", "x": 40, "y": 500})).unwrap()); + s.execute("tool.rectangle", &json!({})).unwrap(); + let mut u = Ui::new(s); + // A rectangle drawn from the middle of the canvas, released off the window, the pointer gone. + let start = u.app.canvas.rect.center(); + u.button(start, true); + for i in 1..=6 { + u.frame(vec![Event::PointerMoved(start + vec2(10.0, 6.0) * i as f32)]); + } + let release = + Event::PointerButton { pos: egui::pos2(-50.0, -50.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + u.frame(vec![release, Event::PointerGone]); + let ds = u.app.session.ds().unwrap(); + assert!(ds.interaction.is_none(), "the drawing is committed"); + let (drawn, zoom) = (ds.selection[0], ds.view.zoom); + let b = ds.doc.world_bounds(drawn).unwrap(); + // Not the 100 x 100 shape of a click, as an end in the middle of the canvas would make. + assert!((b.width() * zoom - 60.0).abs() < 1.5 && (b.height() * zoom - 36.0).abs() < 1.5, "{b:?} at zoom {zoom}"); + // #278's steps: a move in the panel, a delete, then a text edit. + u.app.run("node.set", json!({"ids": [kept], "props": {"x": 650}})); + u.app.run("edit.delete", json!({"ids": [gone]})); + u.app.run("select.set", json!({"ids": [text]})); + key(&mut u, egui::Key::Enter); + u.frame(vec![Event::Text("B".into())]); + key(&mut u, egui::Key::Escape); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.world_bounds(kept).unwrap().x0, 650.0, "the move stays"); + assert!(!doc.contains(gone), "the delete stays"); + assert_eq!(doc.node(text).unwrap().kind.text().unwrap().characters, "B"); +}