Merge pull request #3729 from storytold/land/3360

G11 #834: bezier drag previews follow the pointer to the release
This commit is contained in:
BFlat
2026-10-23 06:40:24 +07:00
committed by GitHub
5 changed files with 150 additions and 16 deletions
+133 -8
View File
@@ -124,20 +124,27 @@ fn drag_layer_id() -> egui::Id {
fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
}
/// The document point last sent to the tool as a drag during [`Drag::Tool`].
/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press
/// has dragged.
fn tool_drag_doc_id() -> egui::Id {
egui::Id::new("canvas-tool-drag-doc")
}
/// Whether the tool should get another drag event: the pointer moved this frame, or its document
/// position changed without a delta (some Wayland compositors coalesce motion until release, #834).
/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down.
fn tool_press_doc_id() -> egui::Id {
egui::Id::new("canvas-tool-press-doc")
}
/// Whether the tool should get another drag event, and if so records `p` as the last position
/// sent: the pointer moved this frame, or its document position changed without a delta (#834).
/// egui's frame delta is zero when some Wayland compositors coalesce motion until release, and in
/// the frame the pointer comes back into the window after leaving it (`PointerGone` forgot where
/// it was), though it is somewhere new; judged on the delta alone, the preview stayed behind.
fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> bool {
if pointer.delta() != egui::Vec2::ZERO {
return true;
}
let at = xf.to_doc(p);
let id = tool_drag_doc_id();
let prev = ui.data(|d| d.get_temp::<Point>(id));
if prev == Some(at) {
// Before the press has dragged, the last position the tool got is the press itself: a press
// held still is not a drag.
let prev = ui.data(|d| d.get_temp::<Point>(id).or_else(|| d.get_temp::<Point>(tool_press_doc_id())));
if pointer.delta() == egui::Vec2::ZERO && prev == Some(at) {
return false;
}
ui.data_mut(|d| d.insert_temp(id, at));
@@ -998,6 +1005,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
dd.insert_temp(drag_id(), d);
dd.insert_temp(drag_pos_id(), p);
dd.insert_temp(drag_layer_id(), resp.layer_id);
// A new press has not dragged yet, whatever an interrupted one left behind.
dd.remove::<Point>(tool_drag_doc_id());
dd.insert_temp(tool_press_doc_id(), xf.to_doc(p));
});
}
} else if drag.is_none()
@@ -1085,10 +1095,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
} else {
// The last position the tool's drag got: none if the press never dragged.
let last_drag: Option<Point> = ui.data(|dd| dd.get_temp(tool_drag_doc_id()));
ui.data_mut(|dd| {
dd.remove::<Drag>(drag_id());
dd.remove::<Pos2>(drag_pos_id());
dd.remove::<Point>(tool_drag_doc_id());
dd.remove::<Point>(tool_press_doc_id());
});
match d {
Drag::ZoomBox { start } => {
@@ -1118,6 +1131,16 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
if resp.double_clicked() && app.session.tool_id() == "artboard" {
app.session.set_tool_option("suppressFitClick", &json!(true));
}
// A move that came in the release's own frame shapes the preview first: the
// tools commit the last preview, which should be where the pointer let go
// (#834). Only once the press has dragged, so a click's jitter stays a click
// (the Pen's corner anchor, no nudge with Direct Selection).
if let Some(last) = last_drag
&& last != xf.to_doc(p)
{
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
@@ -8236,6 +8259,108 @@ mod tests {
assert!(moved, "a new document position runs even when egui reports no delta");
}
/// #834 review: a press held still is not a drag. Before the press has dragged, the first held
/// frame without a delta at the press's own position sends nothing, so a click stays a click;
/// the first move after it does.
#[test]
fn stationary_press_is_not_a_drag() {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
let v = View::default();
let xf = Xf::new(rect, &v);
let a = pos2(100.0, 100.0);
let b = pos2(150.0, 120.0);
let mut out =
ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| {
ui.data_mut(|d| {
d.remove::<Point>(tool_drag_doc_id());
d.insert_temp(tool_press_doc_id(), xf.to_doc(a));
});
});
out.textures_delta.clear();
let mut held = true;
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
let pointer = ui.input(|i| i.pointer.clone());
assert_eq!(pointer.delta(), egui::Vec2::ZERO);
held = tool_drag_moved(ui, &xf, a, &pointer);
});
out.textures_delta.clear();
assert!(!held, "a press held still at its own position is not sent as a drag");
let mut moved = false;
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| {
let pointer = ui.input(|i| i.pointer.clone());
moved = tool_drag_moved(ui, &xf, b, &pointer);
});
out.textures_delta.clear();
assert!(moved, "the first move after the press is a drag");
}
/// #834: a bezier drag's preview follows every position the pointer is seen at. egui's frame
/// delta is zero in the frame the pointer comes back into the window after leaving it
/// (`PointerGone` forgets where it was), so a drag gated on it kept the preview where the
/// pointer left until the next move; a move in the release's own frame was lost the same way,
/// and the release committed the stale shape. A click's jitter in its release frame stays a
/// click.
#[test]
fn a_bezier_drag_preview_follows_the_pointer_back_into_the_window_and_to_the_release() {
use egui::{Event, Modifiers};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
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 button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
let near = |p: Point, q: Pos2| p.distance(xf.to_doc(q)) < 1e-3;
let first_anchor = |app: &VectorcraftApp| {
let st = app.session.active().unwrap();
st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0]
};
// The Pen pulls a handle out of a new anchor: the pointer leaves the window mid-drag, comes
// back further on and stops there, then moves once more in the frame it lets go.
app.select_tool("pen");
let a = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0);
frame(&mut app, &ctx, vec![Event::PointerMoved(a)]);
frame(&mut app, &ctx, vec![button(a, true)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(40.0, 0.0))]);
assert!(near(first_anchor(&app).h_out, a + vec2(40.0, 0.0)), "{:?}", first_anchor(&app));
frame(&mut app, &ctx, vec![Event::PointerGone]);
frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(80.0, 0.0))]);
assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "back in the window: {:?}", first_anchor(&app));
frame(&mut app, &ctx, vec![]);
assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "still: {:?}", first_anchor(&app));
let end = a + vec2(100.0, 20.0);
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
assert!(near(first_anchor(&app).h_out, end), "released: {:?}", first_anchor(&app));
assert!(app.session.active().unwrap().interaction.is_none(), "committed");
// Direct Selection moving that anchor: the same path through the engine's previews.
app.select_tool("directSelection");
let anchor_at = xf.to_screen(first_anchor(&app).p);
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at)]);
frame(&mut app, &ctx, vec![button(anchor_at, true)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(30.0, 30.0))]);
assert!(near(first_anchor(&app).p, anchor_at + vec2(30.0, 30.0)), "{:?}", first_anchor(&app));
frame(&mut app, &ctx, vec![Event::PointerGone]);
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(60.0, 30.0))]);
assert!(near(first_anchor(&app).p, anchor_at + vec2(60.0, 30.0)), "back in the window: {:?}", first_anchor(&app));
let end = anchor_at + vec2(60.0, 50.0);
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
assert!(near(first_anchor(&app).p, end), "released: {:?}", first_anchor(&app));
// A click whose release frame brings a little jitter is still a click: the Pen places a
// corner anchor (no handle pulled out by the jitter) and the anchor stays where pressed.
app.session.execute("select.none", &json!({})).unwrap();
app.select_tool("pen");
let c = app.canvas_rect.unwrap().center() + vec2(120.0, 80.0);
frame(&mut app, &ctx, vec![Event::PointerMoved(c)]);
frame(&mut app, &ctx, vec![button(c, true), Event::PointerMoved(c + vec2(0.6, 0.2)), button(c + vec2(0.6, 0.2), false)]);
frame(&mut app, &ctx, vec![]);
// (egui hands the press the frame's last pointer position, so it lands within the jitter.)
let a = first_anchor(&app);
assert!(a.p.distance(xf.to_doc(c)) < 1.0 && a.h_out == a.p && a.h_in == a.p, "corner anchor where pressed: {a:?}");
assert!(app.session.active().unwrap().interaction.is_none(), "committed");
}
#[test]
fn frame_moves_keeps_touch_samples_and_pressure_but_leaves_mouse_unchanged() {
let ctx = egui::Context::default();
+7 -2
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -3
View File
File diff suppressed because one or more lines are too long