Direct Selection: arrow keys nudge the selected anchors (fixes #4486) (#4500)

This commit is contained in:
Marco Arnulfo 2026-10-13 18:11:24 +02:00 committed by GitHub
parent 33728243fb
commit 2ca12e17b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 69 additions and 1 deletions

View File

@ -7,6 +7,7 @@
//! canvas to marquee-select anchors, click empty canvas to deselect.
//! * Drag selected anchors to move them, a direction handle to turn it (a smooth point keeps its
//! handles collinear; ⌥ breaks them apart), or a segment to bend it (straight segments move).
//! The arrow keys nudge the selected anchors 1 px (⇧ 10 px), one history step per press.
//! * Convert Point (the tool, or Pen + ⌥): click an anchor to make it a corner, drag from it to
//! 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
@ -346,6 +347,37 @@ fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) {
}
}
/// Arrow keys with Direct Selection and selected anchors (#4486), as in Photoshop: nudge them
/// 1 px (⇧ 10 px) through `path.moveAnchors`, one history step per press. Not while a drag is
/// in progress. Returns true when a key was used.
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
let ds = &app.ui.direct_selection;
let Some(target) = ds.target.filter(|_| app.ui.tool == Tool::DirectSelection && !ds.anchors.is_empty() && ds.drag.is_none()) else {
return false;
};
let anchors = ds.anchors.clone();
use egui::Key;
let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (key, ux, uy) in keys {
// ⇧ first: `consume_key` ignores an extra ⇧.
for mods in [Modifiers::SHIFT, Modifiers::NONE] {
if ctx.input_mut(|i| i.consume_key(mods, key)) {
let k = if mods.shift { 10.0 } else { 1.0 };
let mut params = json!({"anchors": anchors, "move": [ux * k, uy * k]});
if let (Some(p), Some(t)) = (params.as_object_mut(), target.params().as_object()) {
p.extend(t.clone());
}
if let Err(e) = app.run("path.moveAnchors", params) {
app.ui.status = e;
app.ui.status_error = true;
}
return true;
}
}
}
false
}
/// Paths with their anchors (selected ones filled), the shown handles, the drag's preview in
/// place of its path, and the marquee. `paths` false (View › Show › Target Path off) leaves only
/// the marquee.

View File

@ -225,3 +225,39 @@ fn the_convert_point_tool_shows_the_path_overlay() {
assert!(!super::shows(&app, Modifiers::NONE));
assert!(super::shows(&app, Modifiers::COMMAND));
}
/// One key press through the canvas arrow keys, as egui delivers it.
fn press(app: &mut PhotocraftApp, key: egui::Key, mods: Modifiers) -> bool {
let ctx = egui::Context::default();
ctx.input_mut(|i| {
i.modifiers = mods;
i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods });
});
crate::move_mods::arrow_keys(app, &ctx)
}
/// #4486: the arrow keys nudge the selected anchors 1 px (⇧ 10 px), one history step per press,
/// as in Photoshop. With no anchor selected they leave the path alone.
#[test]
fn arrow_keys_nudge_the_selected_anchors() {
let mut app = app(Tool::DirectSelection);
with_path(&mut app);
// The path stays targeted after a click on empty canvas, but no anchor is selected.
click(&mut app, [40.0, 120.0], Modifiers::NONE);
click(&mut app, [80.0, 80.0], Modifiers::NONE);
assert_eq!(app.ui.direct_selection.target, Some(PathRef::Work));
let n = steps(&app);
assert!(!press(&mut app, egui::Key::ArrowRight, Modifiers::NONE), "no anchor selected: not Direct Selection's key");
assert_eq!(steps(&app), n);
click(&mut app, [40.0, 120.0], Modifiers::NONE);
click(&mut app, [120.0, 120.0], Modifiers::SHIFT);
let (before, n) = (work(&app), steps(&app));
assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE));
assert_eq!((anchor(&app, 2), anchor(&app, 3)), (Point::new(121.0, 120.0), Point::new(41.0, 120.0)));
assert!(press(&mut app, egui::Key::ArrowDown, Modifiers::SHIFT));
assert_eq!((anchor(&app, 2), anchor(&app, 3)), (Point::new(121.0, 130.0), Point::new(41.0, 130.0)), "⇧ nudges 10 px");
assert_eq!(anchor(&app, 0), Point::new(40.0, 40.0), "unselected anchors stay");
assert_eq!(steps(&app), n + 2, "one history step per press");
assert!(app.session.undo() && app.session.undo());
assert_eq!(work(&app), before);
}

View File

@ -175,7 +175,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
use egui::{Key, Modifiers};
let cmd = ctx.input(|i| i.modifiers.command) && crate::hold_keys::cmd_nudges(app);
if app.ui.tool != Tool::Move && app.ui.transform.is_none() && !cmd {
return selection_arrow_keys(app, ctx) || brush_arrow_keys(app, ctx);
return selection_arrow_keys(app, ctx) || crate::direct_select::arrow_keys(app, ctx) || brush_arrow_keys(app, ctx);
}
// `consume_key` never ignores an extra ⌘, so it is part of every pattern while it moves.
let base = if cmd { Modifiers::COMMAND } else { Modifiers::NONE };