From 1600696aeac9b2e67aff6b5cba2b3f9d7926dd30 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 02:39:08 -0800 Subject: [PATCH] Canvas: a drag's release reads Backtick as it was at the button-up A press or release of Backtick, or a window focus loss, that came after the mouse button went up in the same frame changed whether the drag committed a pattern-only transform. The release now takes Backtick's state at the button-up from the frame's ordered input events. --- crates/ui-egui/src/canvas/drag.rs | 40 ++++++++++++++++++++ crates/ui-egui/src/canvas/input.rs | 50 +++++++----------------- crates/ui-egui/src/canvas/tests.rs | 61 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 3f3c156ed..1980661b8 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -89,6 +89,46 @@ pub(super) fn tool_drag_mods_changed(ui: &Ui, m: Mods, backquote: bool) -> bool dragged && last.is_some_and(|l| l != held) } +/// Whether Backtick is held at the end of this frame, and whether it was at the end of the frame +/// before (`None` before the first). The canvas records it once a frame. +pub(super) fn backquote_seen(ui: &Ui) -> (bool, Option) { + let id = egui::Id::unique("canvas-backquote-seen"); + let now = ui.input(|i| i.key_down(egui::Key::Backtick)); + let before = ui.data_mut(|d| { + let before = d.get_temp::(id); + d.insert_temp(id, now); + before + }); + (now, before) +} + +/// Whether Backtick was held when `button` went up in this frame, with `before` its state at the end +/// of the frame before ([`backquote_seen`]). Its state as the frame began is the opposite of its +/// first press or release in the frame; when the window lost the focus first (which lets go of every +/// key without a release), it is `before`; with neither, it is its state now. The frame's presses, +/// releases and focus losses before the button-up then apply in the order they came. Without a +/// button-up in this frame, its state now. +pub(super) fn backquote_at_release(ui: &Ui, button: egui::PointerButton, before: Option) -> bool { + ui.input(|i| { + let now = i.key_down(egui::Key::Backtick); + // A press or release of Backtick (`Some(pressed)`), or a focus loss (`None`). + let step = |e: &egui::Event| match e { + egui::Event::Key { key: egui::Key::Backtick, pressed, repeat: false, .. } => Some(Some(*pressed)), + egui::Event::WindowFocused(false) => Some(None), + _ => None, + }; + let Some(up) = i.events.iter().position(|e| matches!(e, egui::Event::PointerButton { button: b, pressed: false, .. } if *b == button)) else { + return now; + }; + let start = match i.events.iter().find_map(step) { + Some(Some(down)) => !down, + Some(None) => before.unwrap_or(now), + None => now, + }; + i.events[..up].iter().filter_map(step).fold(start, |_, s| s.unwrap_or(false)) + }) +} + /// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch /// input, else the pressure in the host's feed while a pen is the pointer that moved last /// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`] diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 3775edeb8..e0bd9f029 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -1,7 +1,8 @@ use super::artboards::{artboard_name_at, begin_artboard_name_edit, name_press_id, select_named_artboard}; use super::drag::{ - FrameMove, HoverContext, apply_requests, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, drop_press, mods, move_pose, on_canvas, - pen_pose, pen_pressure, tool_drag_doc_id, tool_drag_mods_changed, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, + FrameMove, HoverContext, apply_requests, backquote_at_release, backquote_seen, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, + drop_press, mods, move_pose, on_canvas, pen_pose, pen_pressure, tool_drag_doc_id, tool_drag_mods_changed, tool_drag_moved, tool_frame_moves, + tool_holds, tool_press_doc_id, }; use super::view::{ HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, @@ -19,7 +20,8 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo // double-taps) where it was seen last (#491). let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); let view = app.view_info(); - let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view }; + let (_, backquote_before) = backquote_seen(ui); + let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before }; touch_gestures(app, ui, rect, view); // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the // tool's press is dropped, as a right-click presses nothing. @@ -186,6 +188,8 @@ struct Pointing<'a> { xf: Xf, v: View, view: vectorcraft_engine::ViewInfo, + /// Whether Backtick was held at the end of the frame before ([`backquote_seen`]). + backquote_before: Option, } /// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. Returns the pointer, the @@ -433,6 +437,8 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po dd.remove::(tool_drag_doc_id()); dd.remove::(tool_press_doc_id()); }); + // Backtick as it was at the button-up: a key pressed or released later in the frame does not count. + let backquote = backquote_at_release(ui, egui::PointerButton::Primary, f.backquote_before); match d { Drag::ZoomBox { start } => { let start = zoom_marquee_start(ui, start, before, p, space); @@ -472,7 +478,6 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po let (moves, last_pose) = tool_frame_moves(ui, app, true); let pen = move_pose(app, &xf, last_pose); if let Some(last) = last_drag { - let backquote = ui.input(|i| i.key_down(egui::Key::Backtick)); dispatch_frame_moves(app, ui, f, backquote, moves); if last != xf.to_doc(p) { let ev = PointerEvent { @@ -490,7 +495,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), - backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + backquote, pressure: pen_pressure(ui, app, false), pen, }; @@ -501,30 +506,8 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po egui::DragAndDrop::clear_payload(ui.ctx()); let (pressure, pen) = (pen_pressure(ui, app, false), pen_pose(app, &xf)); let pos = xf.to_doc(p); - dispatch( - app, - &PointerEvent { - kind: PointerKind::Drag, - pos, - mods: mods(m, space), - backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), - pressure, - pen, - }, - view, - ); - dispatch( - app, - &PointerEvent { - kind: PointerKind::Up, - pos, - mods: mods(m, space), - backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), - pressure, - pen, - }, - view, - ); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), backquote, pressure, pen }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), backquote, pressure, pen }, view); } Drag::Art => { // Released over a panel: the move goes back. (The app ends it before the panels @@ -533,14 +516,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po let _ = app.session.cancel_interaction(); dispatch( app, - &PointerEvent { - kind: PointerKind::Up, - pos: Point::ZERO, - mods: Mods::default(), - backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), - pressure: 1.0, - pen: None, - }, + &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote, pressure: 1.0, pen: None }, view, ); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 0c9462818..201faa327 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3051,6 +3051,67 @@ fn egui_backtick_drag_uses_key_state_at_release() { ); } +/// The release takes Backtick as it was at the button-up, when the key goes up or down later in the +/// same frame. +#[test] +fn backtick_changed_after_the_button_up_in_its_frame_does_not_change_the_release() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let tile = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("object.pattern.make", json!({"ids": [tile], "name": "Dots", "width": 20, "height": 20})).unwrap(); + app.run("object.pattern.done", json!({})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(Point::new(125.0, 125.0)); + let to = xf.to_screen(Point::new(145.0, 125.0)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |pressed| Event::Key { key: egui::Key::Backtick, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let node = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let pattern_x = |node: &vectorcraft_doc::Node| { + let vectorcraft_color::Paint::Pattern { xf, .. } = node.appearance.fill_paint() else { panic!("pattern fill expected") }; + xf.as_coeffs()[4] + }; + let before = node(&app); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![key(true), Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false), key(false)]); + let pattern_only = node(&app); + assert!(app.session.active().unwrap().interaction.is_none(), "committed"); + assert_eq!(pattern_only.geometric_bounds(), before.geometric_bounds(), "Backtick held at the button-up keeps geometry still"); + assert!((pattern_x(&pattern_only) - pattern_x(&before) - 20.0).abs() < 0.001, "Backtick held at the button-up moves the pattern"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(node(&app), before); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false), key(true)]); + let ordinary = node(&app); + frame(&mut app, &ctx, vec![key(false)]); + assert!( + (ordinary.geometric_bounds().unwrap().x0 - before.geometric_bounds().unwrap().x0 - 20.0).abs() < 0.001, + "Backtick pressed after the button-up commits an ordinary move" + ); + assert!((pattern_x(&ordinary) - pattern_x(&before)).abs() < 0.001, "the ordinary move keeps the pattern matrix"); + // A focus loss after the button-up lets go of Backtick with no release event. + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(node(&app), before); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![key(true), Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false), Event::WindowFocused(false)]); + let unfocused = node(&app); + assert_eq!(unfocused.geometric_bounds(), before.geometric_bounds(), "a focus loss after the button-up keeps geometry still"); + assert!((pattern_x(&unfocused) - pattern_x(&before) - 20.0).abs() < 0.001, "a focus loss after the button-up moves the pattern"); +} + #[test] fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() { let mut app = VectorcraftApp::new(Session::new(), Default::default());