mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
907752daf2
commit
1e916a4e4a
4 files changed
+178
-17
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user