From b8e5b8e133d4976d8f5cb6e4712bd55b6e650d81 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 16:58:51 +0700 Subject: [PATCH] fix(pen): show the bezier handles while drawing The Pen showed only its anchors while a path was drawn; handles appeared only in vector edit mode afterwards. Now: - The point being dragged shows its in and out handles, and the point before it its out handle (the handles shaping the segments at the pen), drawn like vector edit mode's (one shared canvas helper). - Moving on to the next point, the rubber band is the segment a click would make: it leaves the last anchor along its out handle instead of going straight. - A press no longer leaves the previous straight rubber band on screen over the segment being dragged. Overlay gains pen_handles and rubber_handle (shown in ui.inspect). Tests: pen_overlay_tests (handles during and after a drag, none and a straight band for corner points, cleared on finish); canvas_fixes_tests (handle lines and dots painted mid-drag, curved band after release). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/lib.rs | 2 + crates/engine/src/pen_overlay_tests.rs | 65 ++++++++++++++++++++++++ crates/engine/src/tools.rs | 23 ++++++++- crates/ui-egui/src/canvas.rs | 34 ++++++++++--- crates/ui-egui/src/canvas_fixes_tests.rs | 36 ++++++++++++- 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/pen_overlay_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..e6641a8d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1517,6 +1517,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_overlay_tests; +#[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod playground_tests; diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs new file mode 100644 index 00000000..7e0064d2 --- /dev/null +++ b/crates/engine/src/pen_overlay_tests.rs @@ -0,0 +1,65 @@ +//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments +//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); +} + +/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle +/// (to - p). +fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) { + at(s, PointerKind::Down, x, y); + at(s, PointerKind::Move, to_x, to_y); + at(s, PointerKind::Up, to_x, to_y); +} + +#[test] +fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point"); + // The second point, mid-drag. + at(&mut s, PointerKind::Down, 200.0, 200.0); + assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band"); + at(&mut s, PointerKind::Move, 200.0, 240.0); + assert_eq!( + s.tool.overlay.pen_handles, + vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]], + "the one before's out handle, then the dragged point's in and out" + ); + at(&mut s, PointerKind::Up, 200.0, 240.0); + // Moving on: the band leaves along the last out handle. + at(&mut s, PointerKind::Hover, 300.0, 200.0); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0])); +} + +#[test] +fn corner_points_have_no_handles_and_a_straight_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + for (x, y) in [(100.0, 100.0), (200.0, 100.0)] { + at(&mut s, PointerKind::Down, x, y); + at(&mut s, PointerKind::Up, x, y); + } + at(&mut s, PointerKind::Hover, 250.0, 200.0); + assert!(s.tool.overlay.pen_handles.is_empty()); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, None, "straight"); +} + +#[test] +fn finishing_the_path_clears_its_handles_and_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + drag_point(&mut s, 200.0, 200.0, 200.0, 240.0); + at(&mut s, PointerKind::Hover, 300.0, 200.0); + s.pen_finish().unwrap(); + let ov = &s.tool.overlay; + assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..3358bad3 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -266,6 +266,12 @@ pub struct Overlay { /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. pub pen: Vec<[f64; 2]>, pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, /// Frame/section the pointer is drawing or dropping into (highlighted). pub drop_target: Option, /// Auto layout insertion indicator (x0, y0, x1, y1). @@ -397,6 +403,14 @@ pub(crate) enum Gesture { }, } +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + /// The pen tool's in-progress path (page coordinates). #[derive(Clone, Debug, Default)] pub struct PenPath { @@ -1026,8 +1040,9 @@ impl Session { let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); - if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } } self.tool.overlay.guides = guides; @@ -1931,6 +1946,9 @@ impl Session { fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; let (doc, page) = { let ds = self.ds()?; @@ -1985,6 +2003,7 @@ impl Session { fn pen_update(&mut self) -> Result<()> { let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); if pen.points.len() < 2 { if let Ok(ds) = self.ds_mut() { ds.revision += 1; @@ -2067,7 +2086,9 @@ impl Session { pub fn pen_finish(&mut self) -> Result<()> { let pen = self.tool.pen.take(); self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; self.tool.gesture = Gesture::None; if pen.as_ref().is_some_and(|p| p.id.is_some()) { self.commit_interaction()?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de..beec9aa3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -973,12 +973,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if app.session.tool.tool == ToolId::Pencil { painter.add(Shape::line(pts, stroke(t.text, 2.0))); } else { + for [x0, y0, x1, y1] in &ov.pen_handles { + bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE); + } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0)); + let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); + // The segment a click would make: along the last anchor's out handle, if it has one. + match ov.rubber_handle { + Some([hx, hy]) => { + let h = app.to_screen(hx, hy); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + [a, h, b, b], + false, + Color32::TRANSPARENT, + stroke(ACCENT, 1.0), + )); + } + None => { + painter.line_segment([a, b], stroke(ACCENT, 1.0)); + } + } } } } @@ -1111,6 +1129,14 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig } } +/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of +/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). +fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { + painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.circle_filled(e, r, fill); + painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); +} + /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and /// for the `active` layer the closed area under the paint bucket and the pen's rubber band. fn vector_overlay( @@ -1151,13 +1177,9 @@ fn vector_overlay( return; } for h in &o.handles { - let (v, e) = (sp(&h.vertex), sp(&h.handle)); - painter.line_segment([v, e], stroke(ACCENT, 1.0)); let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); - let r = if hovered { 4.5 } else { 3.5 }; let on = o.selected_handles.contains(&(h.seg, h.start)); - painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE }); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e55..5a36b712 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -1,5 +1,6 @@ //! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off -//! by default), and the render worker counts a job as in flight until it is done. +//! by default), the render worker counts a job as in flight until it is done, and the Pen shows +//! the handles of the path it draws. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -91,3 +92,36 @@ fn a_job_queued_behind_a_finished_one_stays_in_flight() { // Not in flight only once the second is done: its image is then waiting. assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there"); } + +#[test] +fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() { + use egui::{Event, PointerButton}; + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y); + let press = |p: Pos2, pressed: bool| { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] + }; + let accent = egui::epaint::ColorMode::Solid(crate::theme::ACCENT); + // A handle: the line from its anchor and the dot at its end. + let handle = |shapes: &[Shape], a: Pos2, e: Pos2| { + let near = |p: Pos2, q: Pos2| p.distance(q) < 0.5; + let line = shapes.iter().any(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if near(*p, a) && near(*q, e))); + let dot = shapes.iter().any(|s| matches!(s, Shape::Circle(c) if near(c.center, e) && c.fill == egui::Color32::WHITE)); + line && dot + }; + let a = at(&u, 100.0, 100.0); + u.frame(press(a, true)); + for k in 1..=4 { + u.frame(vec![Event::PointerMoved(at(&u, 100.0 + 10.0 * f64::from(k), 100.0))]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the out handle while dragging"); + assert!(handle(&shapes, a, at(&u, 60.0, 100.0)), "and the in handle opposite"); + u.frame(press(at(&u, 140.0, 100.0), false)); + // After release, towards the next point: the band curves along the out handle. + let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at(&u, 200.0, 200.0))]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the handles stay"); + let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent)); + assert!(band, "a curve leaving along the out handle"); +}