Cmd held is the Move tool with the painting tools (Cmd+Alt duplicates) (#2248)
Photoshop switches to the Move tool while Cmd (Ctrl off the Mac) is held with every tool but the Hand, Rotate View, Zoom, Crop, Slice, Path Selection, shape, Pen and Type tools. #896 did this for the selection tools per press; this does it for the rest (Brush, Eraser, Gradient, Clone Stamp, Eyedropper, ...). hold_keys gains Temporary::Move (a modifier, not a rebindable key); the canvas sets PhotocraftApp::tool_override for the frame and the tool state machine, the Move tool's selection check (move_ui::moves_selected_pixels) and the tool modules that gate on the tool read active_tool(). The held-tool latch now lasts through the release frame, otherwise the Move's Up went to the selected tool. So Cmd-drag with the Brush moves the layer, Cmd+Alt-drag duplicates it first (move_mods), Cmd-drag with a selection floats the selected pixels, and Cmd-click picks the layer under the pointer. Off the Mac, Cmd+Alt is Ctrl+Alt, which the brush tools keep for the on-canvas resize (brush_resize, also Alt+right-drag there): with a brush tool Ctrl+Alt-drag resizes, with the other tools (Gradient, Eyedropper...) it duplicates. Tests pin both layouts on any host. docs/control-protocol.md describes `command` on ui.pointer events. Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
935c94b5d3
commit
b37bff987c
|
|
@ -227,7 +227,7 @@ fn set_ruler(app: &mut PhotocraftApp, r: Ruler) {
|
|||
|
||||
/// Pointer input for the Ruler, Count and Note tools. Returns true when consumed.
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||
let tool = app.ui.tool;
|
||||
let tool = app.active_tool();
|
||||
if !matches!(tool, Tool::Ruler | Tool::Count | Tool::Note) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2490,6 +2490,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
None if middle => Tool::Hand,
|
||||
None => app.ui.tool,
|
||||
};
|
||||
// ⌘ held: this frame's gestures go to the Move tool (`PhotocraftApp::active_tool`). The Hand
|
||||
// and Zoom never reach `tool_event`, so only the Move needs the state machine to know.
|
||||
app.tool_override = (temporary == Some(crate::hold_keys::Temporary::Move)).then_some(Tool::Move);
|
||||
// Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool).
|
||||
let zoom_out = |alt: bool| match temporary {
|
||||
Some(crate::hold_keys::Temporary::ZoomOut) => true,
|
||||
|
|
@ -2996,6 +2999,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift);
|
||||
}
|
||||
}
|
||||
app.tool_override = None;
|
||||
// Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there.
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let before = view.center;
|
||||
|
|
@ -3513,7 +3517,7 @@ pub(crate) fn pipette_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon {
|
|||
/// painting and sampling.
|
||||
fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
app.alt_sampling = alt_samples(app.ui.tool, mods);
|
||||
app.alt_sampling = alt_samples(app.active_tool(), mods);
|
||||
}
|
||||
if !app.alt_sampling {
|
||||
return false;
|
||||
|
|
@ -3566,7 +3570,7 @@ pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option
|
|||
/// on. It does not feed the live stroke, so the canvas can push a whole frame's recovered samples
|
||||
/// and update the live stroke once (see `canvas_view`).
|
||||
fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui::Modifiers) {
|
||||
let tool = app.ui.tool;
|
||||
let tool = app.active_tool();
|
||||
if tool.is_type() && app.drag.is_none() {
|
||||
crate::type_tool::pointer_move(app, x, y);
|
||||
}
|
||||
|
|
@ -3672,7 +3676,14 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
|||
if crate::magnetic_lasso_ui::pointer(app, ev, mods) {
|
||||
return;
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
// ⌘ held is the Move tool (`hold_keys::cmd_moves`). The canvas resolves the held key before
|
||||
// the event (`tool_override`); automation and tests send the modifier with the event.
|
||||
let tool = match app.active_tool() {
|
||||
t if app.tool_override.is_none() && mods.command && crate::hold_keys::cmd_moves(t) && app.ui.transform.is_none() && app.ui.text_edit.is_none() => {
|
||||
Tool::Move
|
||||
}
|
||||
t => t,
|
||||
};
|
||||
if tool == Tool::Eyedropper {
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ pub struct LiveGradient {
|
|||
/// Painting into a layer mask, an alpha channel or the Quick Mask stays classic (pixels), except
|
||||
/// that a selected gradient fill layer is always edited live (selecting it targets its mask).
|
||||
pub fn live_mode(app: &PhotocraftApp) -> bool {
|
||||
if app.ui.tool != Tool::Gradient || app.ui.tool_options.gradient_classic {
|
||||
if app.active_tool() != Tool::Gradient || app.ui.tool_options.gradient_classic {
|
||||
return false;
|
||||
}
|
||||
match crate::canvas::paint_target(app).as_str() {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@
|
|||
//! - ⌘⌥Space: Zoom Out while held.
|
||||
//! - Space while a marquee, lasso or shape is being dragged repositions it; releasing Space
|
||||
//! goes back to sizing it (the Crop tool does the same for its frame, `crop_ui`).
|
||||
//! - ⌘ (Ctrl off the Mac): the Move tool while held, with the painting, retouching, eraser,
|
||||
//! gradient, eyedropper and other non-selection tools ([`cmd_moves`]); so ⌘-drag with the
|
||||
//! Brush moves the layer, ⌘⌥-drag duplicates it first (`move_mods`), and a ⌘-click picks the
|
||||
//! layer under the pointer, as with the Move tool itself. The selection tools do their own ⌘
|
||||
//! handling in `canvas` (⌘ inside the selection cuts the selected pixels, outside it moves the
|
||||
//! layer, #896), and the Hand, Zoom, Crop, Slice, Path Selection, shape, Pen and Type tools
|
||||
//! keep ⌘ for themselves.
|
||||
//!
|
||||
//! The current tool is never changed, so releasing the key gives the previous tool back. A
|
||||
//! temporary tool that started a drag lasts until the button is released, as in Photoshop.
|
||||
|
|
@ -25,6 +32,8 @@ pub enum Temporary {
|
|||
Hand,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
/// ⌘ held with a tool that [`cmd_moves`]: a modifier, not a rebindable key.
|
||||
Move,
|
||||
}
|
||||
|
||||
impl Temporary {
|
||||
|
|
@ -33,6 +42,7 @@ impl Temporary {
|
|||
Temporary::Hand => "tools.temporary.hand",
|
||||
Temporary::ZoomIn => "tools.temporary.zoomIn",
|
||||
Temporary::ZoomOut => "tools.temporary.zoomOut",
|
||||
Temporary::Move => "tools.temporary.move",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,10 +50,45 @@ impl Temporary {
|
|||
match self {
|
||||
Temporary::Hand => Tool::Hand,
|
||||
Temporary::ZoomIn | Temporary::ZoomOut => Tool::Zoom,
|
||||
Temporary::Move => Tool::Move,
|
||||
}
|
||||
}
|
||||
|
||||
const ALL: [Temporary; 3] = [Temporary::Hand, Temporary::ZoomIn, Temporary::ZoomOut];
|
||||
/// The key-bound temporary tools (`prefs::TEMPORARY_TOOLS`).
|
||||
const BOUND: [Temporary; 3] = [Temporary::Hand, Temporary::ZoomIn, Temporary::ZoomOut];
|
||||
}
|
||||
|
||||
/// Tools on which holding ⌘ (Ctrl off the Mac) is the Move tool. Photoshop excepts the Hand,
|
||||
/// Rotate View, Zoom, Slice, Path Selection, shape, Pen and Type tools (⌘ means something else to
|
||||
/// each) and the Crop tool's frame; a Free Transform in progress keeps ⌘ for its distort handles. The
|
||||
/// selection tools are left out too: for them ⌘ is handled per press in `canvas::tool_event`
|
||||
/// (`selection_drag_kind`, `command_moves_layer`), which needs the selection tool to stay in
|
||||
/// effect.
|
||||
pub fn cmd_moves(t: Tool) -> bool {
|
||||
!t.is_type()
|
||||
&& !matches!(
|
||||
t,
|
||||
Tool::Move
|
||||
| Tool::Hand
|
||||
| Tool::RotateView
|
||||
| Tool::Zoom
|
||||
| Tool::Crop
|
||||
| Tool::Slice
|
||||
| Tool::SliceSelect
|
||||
| Tool::PathSelection
|
||||
| Tool::Pen
|
||||
| Tool::RectMarquee
|
||||
| Tool::EllipseMarquee
|
||||
| Tool::Lasso
|
||||
| Tool::PolygonLasso
|
||||
| Tool::MagicWand
|
||||
)
|
||||
&& !crate::vector_ui::is_shape_tool(t)
|
||||
}
|
||||
|
||||
/// Is ⌘ alone (no other temporary key) making the Move tool now?
|
||||
fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool {
|
||||
i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none()
|
||||
}
|
||||
|
||||
/// Is `id` a held temporary tool rather than a command?
|
||||
|
|
@ -80,8 +125,9 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
|
|||
m.alt as u8 + m.shift as u8 + (m.command || m.mac_cmd) as u8 + (m.ctrl && !m.command) as u8
|
||||
};
|
||||
let mut best: Option<(u8, Temporary)> = None;
|
||||
let mut cmd_move = false;
|
||||
ctx.input(|i| {
|
||||
for t in Temporary::ALL {
|
||||
for t in Temporary::BOUND {
|
||||
if let Some(sc) = binding(app, t)
|
||||
&& held(i, &sc)
|
||||
&& best.is_none_or(|(n, _)| count(&sc) > n)
|
||||
|
|
@ -89,8 +135,10 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
|
|||
best = Some((count(&sc), t));
|
||||
}
|
||||
}
|
||||
cmd_move = cmd_move_held(app, i);
|
||||
});
|
||||
best.map(|(_, t)| t)
|
||||
// A bound key (⌘Space is Zoom In) wins over ⌘ alone.
|
||||
best.map(|(_, t)| t).or(cmd_move.then_some(Temporary::Move))
|
||||
}
|
||||
|
||||
/// Is the reposition key (the Hand key, any modifiers) down?
|
||||
|
|
@ -109,11 +157,12 @@ fn latch_id() -> egui::Id {
|
|||
|
||||
/// The temporary tool for this canvas frame: the held one, except while a selection is being
|
||||
/// drawn (the key repositions it instead); a temporary tool that was in effect when the button
|
||||
/// went down stays until the button comes up.
|
||||
/// went down stays until the button comes up, release included (the Move tool's release is
|
||||
/// what commits the move).
|
||||
pub fn for_frame(app: &PhotocraftApp, ctx: &egui::Context, drawing: bool) -> Option<Temporary> {
|
||||
let down = ctx.input(|i| i.pointer.primary_down());
|
||||
let (down, released) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.primary_released()));
|
||||
let latched = ctx.data(|d| d.get_temp::<Option<Temporary>>(latch_id())).flatten();
|
||||
let t = if down && latched.is_some() {
|
||||
let t = if (down || released) && latched.is_some() {
|
||||
latched
|
||||
} else if drawing {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -347,3 +347,202 @@ fn release(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers
|
|||
let p = screen(h, x, y);
|
||||
button(h, p, false, m);
|
||||
}
|
||||
|
||||
/// A red square on a new layer for the ⌘ (Move) tests; Auto-Select, snapping and smart guides
|
||||
/// off so the drag offsets are exact.
|
||||
fn with_layer(h: &mut Harness<'static, PhotocraftApp>) -> photocraft_doc::LayerId {
|
||||
let app = h.state_mut();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session
|
||||
.edit("paint", |doc, a| {
|
||||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(100, 100, 160, 160), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
app.ui.tool_options.move_auto_select = false;
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
h.run_steps(2);
|
||||
id
|
||||
}
|
||||
|
||||
fn bounds(h: &Harness<'static, PhotocraftApp>, id: photocraft_doc::LayerId) -> Rect {
|
||||
h.state().session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
/// A drag with `m` held throughout (the modifiers go down before the press, as on a keyboard).
|
||||
fn drag_with(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, x: f32, y: f32, x2: f32, y2: f32) {
|
||||
drag_raw(h, platform(m), x, y, x2, y2);
|
||||
}
|
||||
|
||||
/// [`drag_with`] with the modifiers exactly as given, for one platform's layout on any host.
|
||||
fn drag_raw(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, x: f32, y: f32, x2: f32, y2: f32) {
|
||||
h.event(Event::ModifiersChanged(m));
|
||||
h.run_steps(1);
|
||||
let p = screen(h, x, y);
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m });
|
||||
h.run_steps(1);
|
||||
move_to(h, x + 8.0, y + 6.0);
|
||||
move_to(h, x2, y2);
|
||||
let q = screen(h, x2, y2);
|
||||
h.event(Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m });
|
||||
h.run_steps(1);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// ⌘⌥ as a Mac sends it, and Ctrl+Alt as Windows and Linux send it (`command` is Ctrl there).
|
||||
const MAC_CMD_ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: false, mac_cmd: true, command: true };
|
||||
const CTRL_ALT: Modifiers = Modifiers { alt: true, ctrl: true, shift: false, mac_cmd: false, command: true };
|
||||
|
||||
/// ⌘ with the Brush is the Move tool: a ⌘-drag moves the layer and paints nothing; the Brush is
|
||||
/// back when ⌘ comes up.
|
||||
#[test]
|
||||
fn cmd_drag_with_the_brush_is_the_move_tool() {
|
||||
let mut h = harness(Tool::Brush);
|
||||
let id = with_layer(&mut h);
|
||||
h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND)));
|
||||
h.run_steps(1);
|
||||
assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Move));
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
drag_with(&mut h, Modifiers::COMMAND, 130.0, 130.0, 170.0, 150.0);
|
||||
assert_eq!(held_tool(h.state(), &h.ctx), None);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(bounds(&h, id), Rect::new(140, 120, 200, 180), "the layer moved by (40, 20)");
|
||||
assert_eq!(st.history.undo_label(), Some("Move"));
|
||||
assert_eq!(st.history.past_len(), steps + 1, "one step: no brush stroke");
|
||||
assert_eq!(h.state().ui.tool, Tool::Brush, "the Brush is back");
|
||||
}
|
||||
|
||||
/// ⌘ released mid-drag: the Move tool lasts until the button comes up (Photoshop), and the
|
||||
/// Eraser's `Up` never fires.
|
||||
#[test]
|
||||
fn cmd_released_mid_drag_keeps_moving_until_the_button_comes_up() {
|
||||
let mut h = harness(Tool::Eraser);
|
||||
let id = with_layer(&mut h);
|
||||
h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND)));
|
||||
h.run_steps(1);
|
||||
let p = screen(&h, 130.0, 130.0);
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
button(&mut h, p, true, Modifiers::COMMAND);
|
||||
move_to(&mut h, 150.0, 140.0);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
move_to(&mut h, 170.0, 150.0);
|
||||
release(&mut h, 170.0, 150.0, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bounds(&h, id), Rect::new(140, 120, 200, 180));
|
||||
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Move"), "nothing erased");
|
||||
}
|
||||
|
||||
/// ⌘⌥-drag duplicates the layer first and moves the copy, as the Move tool's ⌥-drag does. Off
|
||||
/// the Mac the same keys are Ctrl+Alt, which the Brush keeps for its on-canvas resize
|
||||
/// (`brush_resize`, Alt+right-drag there too), so Ctrl+Alt duplicates with the tools that have no
|
||||
/// brush to resize, such as the Gradient.
|
||||
#[test]
|
||||
fn cmd_alt_drag_duplicates_the_layer_then_moves_the_copy() {
|
||||
for (tool, mods) in [(Tool::Brush, MAC_CMD_ALT), (Tool::Gradient, MAC_CMD_ALT), (Tool::Gradient, CTRL_ALT)] {
|
||||
let mut h = harness(tool);
|
||||
let orig = with_layer(&mut h);
|
||||
let n0 = h.state().session.active().unwrap().doc.layers.len();
|
||||
let h0 = h.state().session.active().unwrap().history.past_len();
|
||||
drag_raw(&mut h, mods, 130.0, 130.0, 170.0, 150.0);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 1, "one copy ({tool:?}, {mods:?})");
|
||||
let copy = st.active_layer.unwrap();
|
||||
assert_ne!(copy, orig);
|
||||
assert_eq!(bounds(&h, orig), Rect::new(100, 100, 160, 160), "the original stays");
|
||||
assert_eq!(bounds(&h, copy), Rect::new(140, 120, 200, 180), "the copy moved");
|
||||
assert_eq!(st.history.past_len(), h0 + 1, "one undo step");
|
||||
assert_eq!(st.history.undo_label(), Some(crate::move_mods::DUPLICATE_MOVE_LABEL));
|
||||
assert_eq!(h.state().ui.tool, tool);
|
||||
}
|
||||
}
|
||||
|
||||
/// Off the Mac, Ctrl+Alt-drag with the Brush resizes the brush, as before ⌘ became the Move
|
||||
/// tool: no copy, no move, the layer untouched.
|
||||
#[test]
|
||||
fn ctrl_alt_drag_with_the_brush_resizes_it_off_the_mac() {
|
||||
let mut h = harness(Tool::Brush);
|
||||
let orig = with_layer(&mut h);
|
||||
let n0 = h.state().session.active().unwrap().doc.layers.len();
|
||||
let size0 = h.state().session.tools.brush.size;
|
||||
drag_raw(&mut h, CTRL_ALT, 130.0, 130.0, 170.0, 150.0);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0, "no copy");
|
||||
assert_eq!(st.active_layer, Some(orig));
|
||||
assert_eq!(bounds(&h, orig), Rect::new(100, 100, 160, 160), "the layer stays");
|
||||
assert_ne!(st.history.undo_label(), Some("Move"));
|
||||
assert_ne!(h.state().session.tools.brush.size, size0, "the brush was resized");
|
||||
}
|
||||
|
||||
/// With a selection, ⌘-drag with the Brush moves the selected pixels (a floating piece), as the
|
||||
/// Move tool does, not the whole layer.
|
||||
#[test]
|
||||
fn cmd_drag_with_the_brush_inside_the_selection_floats_the_selected_pixels() {
|
||||
let mut h = harness(Tool::Brush);
|
||||
let id = with_layer(&mut h);
|
||||
h.state_mut().run("select.rect", json!({"x": 110, "y": 110, "width": 40, "height": 40})).unwrap();
|
||||
h.run_steps(1);
|
||||
let n0 = h.state().session.active().unwrap().doc.layers.len();
|
||||
drag_with(&mut h, Modifiers::COMMAND, 130.0, 130.0, 170.0, 150.0);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let f = photocraft_engine::float_cmds::floating(st).expect("a floating piece");
|
||||
assert_eq!((f.layer, f.offset), (id, (40, 20)));
|
||||
assert_eq!(st.doc.layers.len(), n0, "no layer was added");
|
||||
assert_eq!(bounds(&h, id), Rect::new(100, 100, 160, 160), "the layer itself stayed");
|
||||
}
|
||||
|
||||
/// ⌘-click with the Brush picks the layer under the pointer, as a ⌘-click with the Move tool does
|
||||
/// (Auto-Select off).
|
||||
#[test]
|
||||
fn cmd_click_with_the_brush_picks_the_layer_under_the_pointer() {
|
||||
let mut h = harness(Tool::Brush);
|
||||
let red = with_layer(&mut h);
|
||||
let bg = h.state().session.active().unwrap().doc.layers.first().unwrap().id;
|
||||
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
h.run_steps(1);
|
||||
h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND)));
|
||||
h.run_steps(1);
|
||||
click(&mut h, 130.0, 130.0, Modifiers::COMMAND);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.active_layer, Some(red));
|
||||
assert_eq!(rgba(&h, 130, 130), [1.0, 0.0, 0.0, 1.0], "nothing painted");
|
||||
}
|
||||
|
||||
/// Tools that keep ⌘ for themselves, and the selection tools (their ⌘ is handled per press in
|
||||
/// `canvas`, #896), never become the Move tool this way.
|
||||
#[test]
|
||||
fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() {
|
||||
for tool in [
|
||||
Tool::Hand,
|
||||
Tool::RotateView,
|
||||
Tool::Zoom,
|
||||
Tool::Pen,
|
||||
Tool::Rectangle,
|
||||
Tool::Type,
|
||||
Tool::VerticalType,
|
||||
Tool::Crop,
|
||||
Tool::PathSelection,
|
||||
Tool::Move,
|
||||
Tool::RectMarquee,
|
||||
Tool::EllipseMarquee,
|
||||
Tool::Lasso,
|
||||
Tool::PolygonLasso,
|
||||
Tool::MagicWand,
|
||||
] {
|
||||
assert!(!cmd_moves(tool), "{tool:?}");
|
||||
let mut h = harness(tool);
|
||||
h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND)));
|
||||
h.run_steps(1);
|
||||
assert_eq!(held_tool(h.state(), &h.ctx), None, "{tool:?}");
|
||||
}
|
||||
for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::CloneStamp, Tool::Eyedropper, Tool::MagneticLasso, Tool::QuickSelection] {
|
||||
assert!(cmd_moves(tool), "{tool:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -371,6 +371,9 @@ pub struct PhotocraftApp {
|
|||
monitors: monitor_status::State,
|
||||
checker: Option<egui::TextureHandle>,
|
||||
drag: Option<canvas::Drag>,
|
||||
/// The tool pointer events go to this frame when it isn't the selected one: the Move tool
|
||||
/// while ⌘ is held (`hold_keys::cmd_moves`). Set by the canvas for its gestures, never saved.
|
||||
pub(crate) tool_override: Option<state::Tool>,
|
||||
/// Brush/Eraser stroke being drawn, rendered by the engine (see `canvas::LiveStroke`).
|
||||
live_stroke: Option<canvas::LiveStroke>,
|
||||
/// Footprint trail of a retouching drag (see `stroke_trail`).
|
||||
|
|
@ -568,6 +571,7 @@ impl PhotocraftApp {
|
|||
monitors: Default::default(),
|
||||
checker: None,
|
||||
drag: None,
|
||||
tool_override: None,
|
||||
live_stroke: None,
|
||||
trail: None,
|
||||
move_preview: None,
|
||||
|
|
@ -832,6 +836,12 @@ impl PhotocraftApp {
|
|||
|
||||
/// Keep one view per document, in tab order: a view and its windows stay with their document
|
||||
/// when tabs move (`document.move`) or close.
|
||||
/// The tool pointer events go to: a held temporary tool when there is one (⌘ is the Move
|
||||
/// tool, `hold_keys::cmd_moves`), else the selected tool.
|
||||
pub fn active_tool(&self) -> state::Tool {
|
||||
self.tool_override.unwrap_or(self.ui.tool)
|
||||
}
|
||||
|
||||
pub fn sync_views(&mut self) {
|
||||
type_transform::cancel_stale(self);
|
||||
crate::lasso_ui::cancel_stale(self);
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ fn trace(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], guide: &[[f64; 2
|
|||
|
||||
/// Pointer events while the Magnetic Lasso is the tool (it takes them all).
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
|
||||
if app.ui.tool != Tool::MagneticLasso {
|
||||
// ⌘ held makes it the Move tool (`hold_keys::cmd_moves`): nothing to take then.
|
||||
if app.active_tool() != Tool::MagneticLasso {
|
||||
return false;
|
||||
}
|
||||
match ev {
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ fn fold_history(app: &mut PhotocraftApp, from: usize) {
|
|||
/// Rewrites a Move-tool pointer event for the held modifiers: ⇧ locks it to an axis, and the
|
||||
/// first real movement of an ⌥-drag duplicates the layers being moved. Other tools pass through.
|
||||
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
|
||||
if app.ui.tool != Tool::Move || app.ui.transform.is_some() {
|
||||
if app.active_tool() != Tool::Move || app.ui.transform.is_some() {
|
||||
app.move_mods = MoveDrag::default();
|
||||
return ev;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ use crate::state::Tool;
|
|||
/// float (type, shape, Smart Object, group, position-locked), a hidden layer or several selected
|
||||
/// layers still move whole, as without one.
|
||||
pub(crate) fn moves_selected_pixels(app: &PhotocraftApp) -> bool {
|
||||
moves_selected_pixels_with(app, app.ui.tool)
|
||||
// The Move tool, or ⌘ held with a painting tool (`hold_keys::cmd_moves`).
|
||||
moves_selected_pixels_with(app, app.active_tool())
|
||||
}
|
||||
|
||||
/// [`moves_selected_pixels`] with `tool` in effect (the canvas cursor asks before the press).
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ pub fn set_source(app: &mut PhotocraftApp, x: f64, y: f64) {
|
|||
/// Where the clone source is sampled from, shown only while a Clone Stamp or Healing Brush stroke
|
||||
/// is painted, as in Photoshop: setting the source (⌥-click) leaves nothing on the canvas (#668).
|
||||
pub fn source_marker_point(app: &PhotocraftApp) -> Option<[f64; 2]> {
|
||||
if !matches!(app.ui.tool, Tool::CloneStamp | Tool::Healing) {
|
||||
if !matches!(app.active_tool(), Tool::CloneStamp | Tool::Healing) {
|
||||
return None;
|
||||
}
|
||||
let at = app.drag.as_ref().filter(|d| matches!(d.tool, Tool::CloneStamp | Tool::Healing)).and_then(|d| d.points.last().map(|p| [p[0], p[1]]))?;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
|||
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).
|
||||
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.
|
||||
- Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape.
|
||||
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
|
||||
|
|
@ -169,7 +169,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca
|
|||
| `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key |
|
||||
| `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.<id>` or `menus.colors.<id>` reset removes the override and returns `null`, including when it is already absent |
|
||||
| `edit.preferences.<section>` | the section's values; in the app (no params) it opens the Preferences dialog on that section |
|
||||
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Cmd+Alt+Space); they list with `"hold": true`. In the app it opens Keyboard Shortcuts and Menus, whose Import Photoshop Shortcuts… button loads a Photoshop `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same; rows are matched by menu label, tool keys are not imported). On a desktop with Photoshop installed the newest install's live set is imported once, at the first launch that has no shortcut overrides yet (`dialogs.photoshopShortcuts` in the preferences records it) |
|
||||
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Cmd+Alt+Space); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. In the app it opens Keyboard Shortcuts and Menus, whose Import Photoshop Shortcuts… button loads a Photoshop `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same; rows are matched by menu label, tool keys are not imported). On a desktop with Photoshop installed the newest install's live set is imported once, at the first launch that has no shortcut overrides yet (`dialogs.photoshopShortcuts` in the preferences records it) |
|
||||
| `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` |
|
||||
| `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue