From 4efb01332e5a1abb88c57e94760eef9392d04583 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:42:53 +0200 Subject: [PATCH] Canvas: every click places a point while drawing, however fast (fixes #815) (#835) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 32 +++++++++ crates/ui-egui/tests/rapid_clicks.rs | 98 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 crates/ui-egui/tests/rapid_clicks.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e83c703..251ed5b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1231,6 +1231,38 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { hatch_dialog = true; } + // While a command asks for a point, every click places one, however fast the drafting (#815). + // egui drops a click whose button comes up a few pixels from where it went down (a drag to it), + // and eframe hands a slow frame's clicks over together, which egui reports as one. + let point_prompt = app.session.current_prompt().is_some_and(|p| cadcraft_engine::pointmod::accepts(&p)) + && app.session.pending_window.is_none() + && !app.session.realtime_zoom_active(); + let pressed_primary = pressed_primary || (point_prompt && resp.drag_stopped_by(egui::PointerButton::Primary)); + if point_prompt && inside && pressed_primary && !middle_down && app.cmd.options.is_none() && app.cmd.buffer.is_empty() { + let releases: Vec = ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, .. } => Some(*pos), + _ => None, + }) + .collect() + }); + // The last one is placed below, at the cursor, like any click. + if let Some((_, earlier)) = releases.split_last() { + for pos in earlier.iter().filter(|p| rect.contains(**p)) { + let w = effective_point(app, xf.to_world(*pos), &xf, ui.ctx()); + let result = match app.canvas.snap { + Some(hit) => app.session.input_snapped(hit), + None => app.session.input(Input::Point(w)), + }; + if let Err(e) = result { + app.session.echo(e.to_string()); + } + } + } + } + let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p)); let dyn_frame = crate::cmdline::dyn_frame(app); if let Some(w) = raw_world { diff --git a/crates/ui-egui/tests/rapid_clicks.rs b/crates/ui-egui/tests/rapid_clicks.rs new file mode 100644 index 0000000..1a75063 --- /dev/null +++ b/crates/ui-egui/tests/rapid_clicks.rs @@ -0,0 +1,98 @@ +//! Fast drafting: every click on the canvas places a point, however fast it comes (#815). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Drawing, EntityKind, Space}; +use cadcraft_engine::geom::Vec2; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, CadApp> { + let mut session = Session::empty(); + session.open_drawing(Drawing::new_imperial(), "Rapid clicks", None); + session.execute("view.set", &json!({"center":[50,50],"height":100})).unwrap(); + session.settings.osmode = 0; + session.settings.snapmode = false; + session.settings.orthomode = false; + let mut app = CadApp::new(session, Services::default()); + app.canvas.zoom_pending = false; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn screen(h: &Harness<'_, CadApp>, p: Vec2) -> Pos2 { + h.state().canvas.xf.unwrap().to_screen(p) +} + +fn button(h: &mut Harness<'_, CadApp>, pos: Pos2, pressed: bool) { + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn start_line_at(h: &mut Harness<'_, CadApp>, first: Vec2) { + h.state_mut().start("line"); + h.run_steps(2); + assert_eq!(h.state().session.running.as_ref().unwrap().id, "line"); + let pos = screen(h, first); + button(h, pos, true); + h.run_steps(1); + button(h, pos, false); + h.run_steps(2); +} + +fn lines(h: &Harness<'_, CadApp>) -> Vec<(Vec2, Vec2)> { + let st = h.state().session.state().unwrap(); + st.doc + .space(&Space::Model) + .unwrap() + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }) + .collect() +} + +#[test] +fn two_clicks_within_one_frame_place_two_points() { + let mut h = harness(); + start_line_at(&mut h, Vec2::new(10.0, 10.0)); + let (a, b) = (screen(&h, Vec2::new(40.0, 10.0)), screen(&h, Vec2::new(40.0, 40.0))); + // A slow frame: both clicks arrive before the next one is drawn, and eframe hands them over + // together. (`Harness::event` would run a frame per event, so they go into the input directly.) + for pos in [a, b] { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + } + h.run_steps(2); + let got = lines(&h); + assert_eq!(got.len(), 2, "{got:?}"); + assert!(got[0].1.near(Vec2::new(40.0, 10.0), 1e-4), "{got:?}"); + assert!(got[1].1.near(Vec2::new(40.0, 40.0), 1e-4), "{got:?}"); +} + +#[test] +fn a_click_made_while_the_mouse_moves_still_places_the_point() { + let mut h = harness(); + start_line_at(&mut h, Vec2::new(10.0, 10.0)); + let down = screen(&h, Vec2::new(40.0, 10.0)); + button(&mut h, down, true); + h.run_steps(1); + // The hand is already on its way to the next point when the button comes up. + let up = down + vec2(12.0, 0.0); + button(&mut h, up, false); + h.run_steps(2); + let got = lines(&h); + assert_eq!(got.len(), 1, "{got:?}"); + assert!(got[0].1.near(h.state().canvas.xf.unwrap().to_world(up), 1e-4), "{got:?}"); +}