Pen: Ctrl/Cmd edits the path being drawn in place (#4022) (#4526)

Ctrl/Cmd-pressing an anchor, a shown handle or a segment of the Pen (or
Curvature Pen) path in progress now edits it like Direct Selection does,
and drawing carries on from its last anchor once Ctrl/Cmd is released,
as in Photoshop. A Ctrl/Cmd-press anywhere else still ends the path.

The path in progress is a new Direct Selection candidate, PathRef::Pen,
built from ui.pen; its edits apply to ui.pen directly (no command, no
history step) since the path isn't in the document until it's finished.
The Add/Delete Anchor Point tools handle it the same way.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bazandClaude Opus 5.5 authored and GitHub committed 2026-10-13 12:34:23 -04:00
1 parent 907752daf2
commit 1e916a4e4a
4 files changed
+178 -17

No files matched your search

+14 -1
View File
@@ -90,7 +90,20 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
}
fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) {
let Some((r, _, hit)) = target(app, tool, p) else { return };
let Some((r, mut path, hit)) = target(app, tool, p) else { return };
if r == PathRef::Pen {
// The Pen path being drawn isn't in the document yet: it takes the edit directly.
let edited = match hit {
Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(),
Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(),
Hit::Handle(..) => false,
};
if edited {
app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() };
crate::vector_ui::pen_replace(app, &path);
}
return;
}
apply(app, r, hit);
}
+44 -9
View File
@@ -12,10 +12,14 @@
//! pull out smooth handles, drag a handle to move it alone. The tool selects the anchor it
//! converts, so its handles show; a press on empty canvas or a segment does nothing, and ⌘/Ctrl
//! is Direct Selection, as with the Pen.
//! * While a Pen path is being drawn, ⌘/Ctrl edits it in place (its anchors, handles and
//! segments) and the Pen carries on drawing from its last anchor once ⌘/Ctrl is let go; a
//! ⌘/Ctrl-press anywhere else ends it, as in Photoshop (#4022).
//!
//! A drag previews with `photocraft_vector::edit` and commits once on release through the engine
//! (`path.moveAnchors`, `path.moveHandle`, `path.bendSegment`, `path.convertPoint`): one history
//! step per drag, and the same commands drive it over MCP and the control channel.
//! step per drag, and the same commands drive it over MCP and the control channel. The Pen path
//! being drawn is not in the document yet: its edits apply to it directly, without a command.
use egui::{Color32, Modifiers, Pos2};
use photocraft_doc::vector::Path;
@@ -35,6 +39,8 @@ pub enum PathRef {
Work,
/// A layer's shape path, or its vector mask.
Layer(u64),
/// The Pen path being drawn (`ui.pen`): edited in place, not through the engine.
Pen,
}
impl PathRef {
@@ -43,6 +49,8 @@ impl PathRef {
match self {
PathRef::Work => json!({"name": "work"}),
PathRef::Layer(id) => json!({"name": "layer", "layer": id}),
// Never sent: edits to the Pen path being drawn don't go through the engine.
PathRef::Pen => json!({}),
}
}
}
@@ -143,9 +151,11 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect()
}
/// The paths Direct Selection edits, front first: the active paths, then every visible shape layer.
/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then
/// every visible shape layer.
pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
let mut out = active_paths(app);
let pen = app.ui.pen.as_ref().filter(|p| !p.dragging && !p.knots.is_empty()).map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p)));
let mut out: Vec<_> = pen.into_iter().chain(active_paths(app)).collect();
if let Some(st) = app.session.active() {
for (path, _, l) in st.doc.walk().iter().rev() {
if !l.visible || !(1..=path.len()).all(|n| st.doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) {
@@ -195,8 +205,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
if !(direct(tool, mods) || convert) {
return false;
}
if !pen_idle && tool != Tool::DirectSelection {
// ⌘-click ends the path being drawn, which becomes editable, as in Photoshop.
if !pen_idle && tool != Tool::DirectSelection && !hits_pen(app, [x, y]) {
// ⌘-click off the path being drawn ends it, which becomes editable, as in
// Photoshop. A press on it edits it in place and the Pen carries on (#4022).
crate::vector_ui::pen_commit(app, false);
}
if tool == Tool::FreeformPen {
@@ -218,8 +229,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
true
}
ToolEvent::Up { x, y } => match app.ui.direct_selection.drag.take() {
Some(Drag::Edit { target, edit, start, .. }) => {
release(app, target, &edit, start, [x, y]);
Some(Drag::Edit { target, base, edit, start, .. }) => {
release(app, target, &base, &edit, start, [x, y]);
true
}
Some(Drag::Marquee { start, add, .. }) => {
@@ -231,6 +242,16 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
}
}
/// Does a press at `p` hit the Pen path being drawn: one of its anchors, shown handles or segments?
fn hits_pen(app: &PhotocraftApp, p: [f64; 2]) -> bool {
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
let ds = &app.ui.direct_selection;
candidates(app).into_iter().filter(|(r, _)| *r == PathRef::Pen).any(|(r, path)| {
let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() };
edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).is_some()
})
}
/// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press
/// that hit nothing; the Convert Point tool has nothing else to do, so it keeps the press.
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool {
@@ -306,9 +327,23 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -
}
/// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except
/// Pen + ⌥ on an anchor, which makes it a corner.
fn release(app: &mut PhotocraftApp, target: PathRef, edit: &Edit, start: [f64; 2], end: [f64; 2]) {
/// Pen + ⌥ on an anchor, which makes it a corner. The Pen path being drawn takes the edit directly.
fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, start: [f64; 2], end: [f64; 2]) {
let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.point_zoom()) >= 1.0;
if target == PathRef::Pen {
let edited = match edit {
Edit::Convert(k) if !moved => {
let mut p = base.clone();
edit::convert_point(&mut p, *k, None).ok().map(|_| p)
}
_ if !moved => None,
e => e.preview(base, start, end),
};
if let Some(p) = edited {
crate::vector_ui::pen_replace(app, &p);
}
return;
}
let (id, mut params) = match edit {
Edit::Convert([s, k]) if !moved => ("path.convertPoint", json!({"subpath": s, "knot": k})),
_ if !moved => return,
+69 -5
View File
@@ -118,23 +118,87 @@ fn marquee_shift_and_empty_clicks_select_like_photoshop() {
}
#[test]
fn pen_with_command_edits_paths_and_ends_the_one_being_drawn() {
fn pen_with_command_ends_the_path_being_drawn_when_pressed_off_it() {
let mut app = app(Tool::Pen);
for p in [[30.0, 30.0], [90.0, 30.0]] {
click(&mut app, p, Modifiers::NONE);
}
assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2));
// ⌘-drag (Ctrl-drag off the Mac) on the second anchor: the path is finished and edited.
drag(&mut app, [90.0, 30.0], [90.0, 70.0], Modifiers::COMMAND);
// ⌘-click (Ctrl-click off the Mac) on empty canvas: the path is finished, open.
click(&mut app, [170.0, 170.0], Modifiers::COMMAND);
assert!(app.ui.pen.is_none());
assert_eq!(app.ui.tool, Tool::Pen, "the Pen stays the tool");
let wp = work(&app);
assert_eq!((wp.subpaths[0].closed, wp.subpaths[0].knots[1].anchor), (false, Point::new(90.0, 70.0)));
assert!(!work(&app).subpaths[0].closed);
// ⌘-drag then edits the finished path.
drag(&mut app, [90.0, 30.0], [90.0, 70.0], Modifiers::COMMAND);
assert_eq!(anchor(&app, 1), Point::new(90.0, 70.0));
// Without ⌘ the Pen draws again.
click(&mut app, [150.0, 150.0], Modifiers::NONE);
assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(1));
}
/// #4022: ⌘/Ctrl-dragging an anchor of the path being drawn moves it in place; the path stays
/// unfinished, outside the document and its history, and the Pen carries on from its last anchor.
#[test]
fn pen_with_command_edits_the_path_being_drawn_in_place() {
let mut app = app(Tool::Pen);
for p in [[30.0, 30.0], [90.0, 30.0]] {
click(&mut app, p, Modifiers::NONE);
}
let before = steps(&app);
drag(&mut app, [90.0, 30.0], [90.0, 70.0], Modifiers::COMMAND);
let pen = app.ui.pen.clone().expect("still drawing");
assert_eq!(pen.knots.len(), 2);
assert_eq!(pen.knots[1][0], [90.0, 70.0], "the anchor moved");
assert_eq!(pen.knots[0][0], [30.0, 30.0]);
assert!(app.session.active().unwrap().doc.work_path.is_none(), "not committed");
assert_eq!(steps(&app), before, "no history step until the path is finished");
// Let go of ⌘: the next click adds an anchor after the moved one.
click(&mut app, [150.0, 150.0], Modifiers::NONE);
assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(3));
crate::vector_ui::pen_commit(&mut app, false);
let wp = work(&app);
assert_eq!(wp.subpaths[0].knots.len(), 3);
assert_eq!(wp.subpaths[0].knots[1].anchor, Point::new(90.0, 70.0));
assert_eq!(app.ui.direct_selection.target, None, "the Pen selection is gone with the Pen path");
}
/// ⌘/Ctrl-click selects an anchor of the path being drawn, showing its handles; ⌘-dragging one
/// turns it and the other follows (a smooth point), still without committing.
#[test]
fn pen_with_command_turns_a_handle_of_the_path_being_drawn() {
let mut app = app(Tool::Pen);
click(&mut app, [30.0, 30.0], Modifiers::NONE);
drag(&mut app, [90.0, 30.0], [110.0, 30.0], Modifiers::NONE);
assert_eq!(app.ui.pen.as_ref().unwrap().knots[1], [[90.0, 30.0], [70.0, 30.0], [110.0, 30.0]]);
click(&mut app, [90.0, 30.0], Modifiers::COMMAND);
assert_eq!(app.ui.direct_selection.target, Some(PathRef::Pen));
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 1]]);
drag(&mut app, [110.0, 30.0], [110.0, 50.0], Modifiers::COMMAND);
let k = app.ui.pen.as_ref().expect("still drawing").knots[1];
assert_eq!(k[2], [110.0, 50.0], "the outgoing handle turned");
let (o, i) = ([k[2][0] - k[0][0], k[2][1] - k[0][1]], [k[1][0] - k[0][0], k[1][1] - k[0][1]]);
assert!((o[0] * i[1] - o[1] * i[0]).abs() < 1e-6 && o[0] * i[0] + o[1] * i[1] < 0.0, "the incoming handle stays opposite: {k:?}");
assert!(app.session.active().unwrap().doc.work_path.is_none());
}
/// The Curvature Pen too: ⌘/Ctrl-dragging an anchor of its path in progress moves it, and the
/// curve through the anchors follows.
#[test]
fn curvature_pen_with_command_moves_an_anchor_of_the_path_being_drawn() {
let mut app = app(Tool::CurvaturePen);
for p in [[30.0, 30.0], [90.0, 30.0], [150.0, 60.0]] {
click(&mut app, p, Modifiers::NONE);
}
let before = app.ui.pen.clone().unwrap();
drag(&mut app, [90.0, 30.0], [90.0, 10.0], Modifiers::COMMAND);
let pen = app.ui.pen.clone().expect("still drawing");
assert!(pen.curvature && pen.knots.len() == 3);
assert_eq!(pen.knots[1][0], [90.0, 10.0]);
assert_ne!(pen.knots[1][1], before.knots[1][1], "the handles follow the moved anchor");
assert!(app.session.active().unwrap().doc.work_path.is_none());
}
#[test]
fn pen_with_alt_converts_points() {
let mut app = app(Tool::Pen);
+51 -2
View File
@@ -263,6 +263,13 @@ pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool {
if pen.knots.pop().is_none() {
return false;
}
let n = pen.knots.len();
let ds = &mut app.ui.direct_selection;
if ds.target == Some(crate::direct_select::PathRef::Pen) {
// A ⌘/Ctrl selection on the path being drawn loses the removed anchor.
ds.anchors.retain(|a| a[1] < n);
ds.segment = ds.segment.filter(|s| s[1] + 1 < n);
}
pen.unlinked.retain(|&i| i < pen.knots.len());
if pen.curvature {
pen.moving = None;
@@ -354,9 +361,49 @@ fn fold_close(pen: &PenPath) -> Option<PenPath> {
Some(p)
}
/// The Pen path being drawn as a document path (one open subpath), so ⌘/Ctrl (Direct Selection)
/// can edit it in place while drawing (#4022).
pub(crate) fn pen_path(pen: &PenPath) -> Path {
let pt = |q: [f64; 2]| photocraft_geom::Point::new(q[0], q[1]);
let knots = pen
.knots
.iter()
.enumerate()
.map(|(i, k)| photocraft_doc::Knot {
anchor: pt(k[0]),
in_ctrl: pt(k[1]),
out_ctrl: pt(k[2]),
smooth: !pen.unlinked.contains(&i) && (k[1] != k[0] || k[2] != k[0]),
})
.collect();
Path::new(vec![photocraft_doc::vector::Subpath { closed: false, knots, op: Default::default() }])
}
/// The Pen path being drawn becomes `path`'s first subpath, edited with ⌘/Ctrl (Direct Selection);
/// drawing carries on from its last anchor. A Curvature Pen path keeps following its anchors.
pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) {
let Some(pen) = app.ui.pen.as_mut() else { return };
let knots = path.subpaths.first().map(|s| s.knots.as_slice()).unwrap_or_default();
pen.knots = knots.iter().map(knot_points).collect();
pen.unlinked = knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect();
pen.dragging = false;
pen.adjusting_last = false;
pen.moving = None;
if pen.curvature {
curvature_refresh(pen, false);
}
if pen.knots.is_empty() {
app.ui.pen = None;
}
}
/// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode).
pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) {
let Some(mut pen) = app.ui.pen.take() else { return };
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
// Its anchors are no longer the Pen's: the committed path is another path.
app.ui.direct_selection = Default::default();
}
if pen.curvature {
curvature_refresh(&mut pen, closed);
}
@@ -923,8 +970,10 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
// The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete.
crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers));
}
// Pen path in progress, with handles of the last knot and a rubber band to the pointer.
if let Some(pen) = &app.ui.pen {
// Pen path in progress, with handles of the last knot and a rubber band to the pointer. While
// ⌘/Ctrl drags it, Direct Selection draws its preview instead.
let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. }));
if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited) {
let mut pts = Vec::new();
// A close-drag shows the first segment bending with the handle being pulled out.
let mut knots = pen.knots.clone();