From 477f019737ee3aa9fcb4df3f6ba63a1d9d4c2659 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:34:23 +0700 Subject: [PATCH] #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 31dbb2d8..348f077d 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 112ba0e1..9f3b3839 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"); +}