Merge #277: fix(pen): show the bezier handles while drawing (#256) (@Nael-Nathanael)

This commit is contained in:
bflatastic
2026-10-15 15:09:44 +07:00
5 changed files with 152 additions and 8 deletions
+2
View File
@@ -1527,6 +1527,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;
+65
View File
@@ -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());
}
+22 -1
View File
@@ -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<NodeId>,
/// 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 {
@@ -1057,8 +1071,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;
@@ -1974,6 +1989,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()?;
@@ -2028,6 +2046,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;
@@ -2110,7 +2129,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()?;
+28 -6
View File
@@ -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);
+35 -1
View File
@@ -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");
}