diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e37a258cc0..ac5eacbe6a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2954,9 +2954,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: && !crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { - // Outside the image: the pasteboard colour menu (#2462), else the tool's own. + // Outside the image: the pasteboard colour menu (#2462), else the tool's own. The view + // menu belongs to the selected Zoom or Hand, not to Space or a held zoom key. if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) { crate::canvas_tool_menu::open_pasteboard(app, &ctx, tool, [p.x, p.y]); + } else if crate::canvas_tool_menu::is_view_tool(tool) { + if tool == app.ui.tool { + crate::canvas_tool_menu::open_view(app, &ctx, tool, [p.x, p.y], xf.to_doc(p)); + } } else { crate::canvas_tool_menu::open(app, &ctx, tool, [p.x, p.y]); } @@ -3189,7 +3194,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: draw_transform_controls(app, &painter, &xf); crate::move_hover::draw(app, &painter, &xf, idx, response.hover_pos()); crate::layer_pick_ui::show(app, &ctx); + let shown = app.ui.views.get(idx).cloned(); crate::canvas_tool_menu::show(app, &ctx); + // A view menu row (Zoom In, Fit on Screen…) changed the camera: it wins over this + // frame's copy, which is written back at the end. + if let Some(now) = app.ui.views.get(idx) + && Some(now) != shown.as_ref() + { + view = now.clone(); + ctx.request_repaint(); + } crate::snap_ui::draw(app, &painter, &xf); // The canvas edge isn't outlined while the pixels past it show (Photoshop's crop preview), // nor while the Crop tool hides everything outside its box (Show Cropped Area off). diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index fbafdc3171..ac1d455718 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,5 +1,5 @@ -//! Canvas context menus for the selection tools, the Pen, an active Free Transform box, text being -//! edited with a Type tool and the pasteboard (outside the image). +//! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free +//! Transform box, text being edited with a Type tool and the pasteboard (outside the image). use egui::Context; use photocraft_doc::LayerContent; @@ -24,6 +24,10 @@ pub struct CanvasToolMenu { /// Opened outside the image: the pasteboard colour instead of the tool's actions. #[serde(default)] pub pasteboard: bool, + /// The Zoom and Hand tools' view menu: the document point right-clicked, which Zoom In and + /// Zoom Out keep under the pointer. + #[serde(default)] + pub anchor: Option<[f64; 2]>, /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, @@ -119,6 +123,24 @@ const ENDS_TYPE_EDIT: [&str; 5] = /// The pasteboard menu's Select Custom Color… row. pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor"; +/// The Zoom and Hand tools' context menu: the zoom steps and fixed zooms of the View menu, plus +/// the Hand tool's Fill Screen. +pub const VIEW_MENU: &[Row] = &[ + Some(("Zoom In", "view.zoomIn")), + Some(("Zoom Out", "view.zoomOut")), + None, + Some(("Fit on Screen", "view.fitOnScreen")), + Some(("Fill Screen", FILL_SCREEN)), + None, + Some(("100%", "view.actualPixels")), + Some(("200%", "view.twoHundredPercent")), + Some(("Print Size", "view.printSize")), +]; + +/// The view menu's Fill Screen row. Photoshop has no View menu command for it, so the menu sets +/// the view itself. +pub const FILL_SCREEN: &str = "view.fillScreen"; + /// Photoshop's selection-tool context menu with an active selection, in its order. Its Generative /// Fill row has no PhotoCraft command and is left out. Rows stay visible and grey out exactly like /// their menu-bar twins. @@ -210,6 +232,11 @@ pub fn applies(tool: Tool) -> bool { ) } +/// Tools whose plain canvas right-click offers the view menu. +pub fn is_view_tool(tool: Tool) -> bool { + matches!(tool, Tool::Zoom | Tool::Hand) +} + /// The selection tools' rows, with or without an active selection. pub fn selection_rows(has_selection: bool) -> &'static [Row] { if has_selection { SELECTION_MENU } else { NO_SELECTION_MENU } @@ -221,6 +248,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { PASTEBOARD_MENU } else if menu.transform { TRANSFORM_MENU + } else if menu.anchor.is_some() { + VIEW_MENU } else if menu.text { TYPE_EDIT_MENU } else if menu.tool == Tool::Pen { @@ -283,6 +312,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> path_name: None, transform: true, pasteboard: false, + anchor: None, text: false, }); crate::canvas_popup::mark_open(ctx, menu_id()); @@ -323,8 +353,17 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: } app.ui.brush_picker = None; app.ui.layer_menu = None; - app.ui.canvas_tool_menu = - Some(CanvasToolMenu { pos, tool, has_selection: false, has_path: false, path_name: None, transform: false, pasteboard: true, text: false }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection: false, + has_path: false, + path_name: None, + transform: false, + pasteboard: true, + anchor: None, + text: false, + }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -348,6 +387,9 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) if menu.transform && command == "edit.transform.warp" { return app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none() && t.target.is_none() && t.path.is_none()); } + if menu.anchor.is_some() && command == FILL_SCREEN { + return app.session.active().is_some(); + } if menu.text { return match command { FAUX_BOLD | FAUX_ITALIC => app.ui.text_edit.is_some(), @@ -402,11 +444,60 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some()); let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); - app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, text }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text }); crate::canvas_popup::mark_open(ctx, menu_id()); true } +/// Right-click with the Zoom or Hand tool at screen point `pos`, over document point `doc`: the +/// view menu. +pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: [f64; 2]) -> bool { + if !is_view_tool(tool) || !pos.iter().all(|v| v.is_finite()) || !doc.iter().all(|v| v.is_finite()) { + return false; + } + app.ui.brush_picker = None; + app.ui.layer_menu = None; + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection: false, + has_path: false, + path_name: None, + transform: false, + pasteboard: false, + anchor: Some(doc), + text: false, + }); + crate::canvas_popup::mark_open(ctx, menu_id()); + true +} + +/// A view menu row. Zoom In and Zoom Out step about the right-clicked point, as a Zoom tool click +/// does; the other rows run their View menu command. +fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { + let i = app.session.active_index().ok_or("no document")?; + if command == FILL_SCREEN { + let v = app.ui.views.get_mut(i).ok_or("no document view")?; + v.fit_pending = false; + v.fill_pending = true; + return Ok(()); + } + let before = app.ui.views.get(i).map(|v| (v.zoom, v.center)); + crate::menus::invoke(app, ctx, command, json!({}))?; + if !matches!(command, "view.zoomIn" | "view.zoomOut") { + return Ok(()); + } + let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; + let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; + let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; + let d = [d[0] as f32, d[1] as f32]; + // The view scales about the centre, so the point keeps its screen position when its offset + // from the centre shrinks (or grows) by the zoom ratio, whatever the rotation or flip. + let ratio = zoom0 / v.zoom; + v.center = if center_on_point || !ratio.is_finite() { d } else { [d[0] - (d[0] - c[0]) * ratio, d[1] - (d[1] - c[1]) * ratio] }; + Ok(()) +} + pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { let Some(menu) = app.ui.canvas_tool_menu.take() else { return }; if !available(app, &menu, command) { @@ -414,6 +505,8 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { } let result = if menu.pasteboard { choose_pasteboard(app, &menu, command) + } else if menu.anchor.is_some() { + choose_view(app, ctx, &menu, command) } else if menu.text { choose_type(app, ctx, command) } else if menu.tool == Tool::Pen && !menu.transform { @@ -576,7 +669,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { mod tests { use super::*; use egui::{Modifiers, PointerButton, Pos2, vec2}; - use egui_kittest::Harness; + use egui_kittest::{Harness, kittest::Queryable}; fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -960,6 +1053,138 @@ mod tests { assert_eq!(app.session.journal.len(), before); } + #[test] + fn view_menu_follows_photoshop_order() { + assert_eq!( + labels(VIEW_MENU), + vec![Some("Zoom In"), Some("Zoom Out"), None, Some("Fit on Screen"), Some("Fill Screen"), None, Some("100%"), Some("200%"), Some("Print Size")] + ); + // Every row but Fill Screen (the Hand tool's options-bar button) is a View menu command. + let app = app(); + let items = crate::menus::menu_items(&app); + for (label, id) in VIEW_MENU.iter().flatten().filter(|(_, id)| *id != FILL_SCREEN) { + assert!(items.iter().any(|i| i.id == *id), "{label}: unknown command {id}"); + } + } + + fn zoom_app(w: u32, h: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": w, "height": h})).unwrap(); + app + } + + fn screen_of(app: &PhotocraftApp, doc: [f32; 2]) -> Pos2 { + crate::canvas::ViewXform::active(app).unwrap().to_screen(doc[0], doc[1]) + } + + #[test] + fn zoom_and_hand_right_click_opens_the_view_menu() { + for tool in [Tool::Zoom, Tool::Hand] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + let mut h = harness(app); + let zoom = h.state().ui.views[0].zoom; + let inside = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, inside, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap_or_else(|| panic!("{tool:?}: right-click opens no menu")); + assert!(rows(&menu) == VIEW_MENU, "{tool:?}: the view menu"); + assert_eq!(h.state().ui.views[0].zoom, zoom, "{tool:?}: the right-click alone does not zoom"); + assert!(entry_enabled(h.state(), &menu, FILL_SCREEN) && entry_enabled(h.state(), &menu, "view.printSize")); + // Outside the image the pasteboard colour menu wins, as for the other tools. + h.state_mut().ui.canvas_tool_menu = None; + let outside = h.state().last_canvas_rect.min + vec2(5.0, 5.0); + right_click(&mut h, outside, Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.pasteboard), "{tool:?}: pasteboard menu outside"); + } + } + + #[test] + fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + let mut h = harness(app); + let zoom = h.state().ui.views[0].zoom; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + // Chosen in the menu itself: the canvas frame that draws the menu must keep the new view. + h.get_by_label("Zoom In").click(); + h.run_steps(3); + assert!(h.state().ui.canvas_tool_menu.is_none()); + let after = h.state().ui.views[0].zoom; + assert_eq!(after, crate::zoom_levels::step(zoom, 1, [400, 300]), "one Zoom In step"); + let s = screen_of(h.state(), doc); + assert!((s - p).length() < 1.0, "the right-clicked point stays put: {s:?} vs {p:?}"); + assert_eq!(h.state().session.journal.last().map(|(id, _)| id.as_str()), Some("view.zoomIn")); + // Zoom Out returns to where it was. + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label("Zoom Out").click(); + h.run_steps(3); + assert_eq!(h.state().ui.views[0].zoom, zoom); + assert!((screen_of(h.state(), doc) - p).length() < 1.0); + } + + #[test] + fn view_menu_zoom_in_centres_the_clicked_point_with_the_preference() { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Hand; + app.run("prefs.set", json!({"path": "tools.zoomClickedPointToCenter", "value": true})).unwrap(); + let mut h = harness(app); + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label("Zoom In").click(); + h.run_steps(3); + assert_eq!(h.state().ui.views[0].center, doc); + } + + #[test] + fn view_menu_fits_fills_and_sets_fixed_zooms() { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + let mut h = harness(app); + let p = screen_of(h.state(), [200.0, 150.0]); + let area = crate::rulers::content_rect(h.state(), h.state().last_canvas_rect).size(); + let ppp = h.state().canvas_ppp(); + let pick = |h: &mut Harness<'static, PhotocraftApp>, label: &str| { + right_click(h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + h.state().ui.views[0].zoom + }; + assert_eq!(pick(&mut h, "200%"), 2.0); + // Fill Screen: the image covers the whole canvas (one axis can overflow). + let fill = pick(&mut h, "Fill Screen") / ppp; + assert!(400.0 * fill >= area.x - 1.0 && 300.0 * fill >= area.y - 1.0, "fill {fill} for {area:?}"); + assert!(400.0 * fill <= area.x + 1.0 || 300.0 * fill <= area.y + 1.0, "fill {fill} for {area:?}"); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0]); + // Fit on Screen: back to the whole image (capped at 100%, as on open). + let fit = pick(&mut h, "Fit on Screen") / ppp; + assert!(400.0 * fit <= area.x && 300.0 * fit <= area.y && fit < fill, "fit {fit} fill {fill}"); + } + + #[test] + fn view_menu_needs_finite_points_and_a_view_tool() { + let mut app = app(); + assert!(!open_view(&mut app, &Context::default(), Tool::Brush, [1.0, 1.0], [1.0, 1.0])); + assert!(!open_view(&mut app, &Context::default(), Tool::Zoom, [f32::NAN, 1.0], [1.0, 1.0])); + assert!(!open_view(&mut app, &Context::default(), Tool::Zoom, [1.0, 1.0], [f64::INFINITY, 1.0])); + assert!(app.ui.canvas_tool_menu.is_none()); + assert!(open_view(&mut app, &Context::default(), Tool::Zoom, [1.0, 1.0], [1.0, 1.0])); + let before = app.session.journal.len(); + choose(&mut app, &Context::default(), "select.all"); + assert_eq!(app.session.journal.len(), before, "the view menu cannot run another menu's row"); + // With no document every row is greyed out and choosing one does nothing. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(open_view(&mut app, &Context::default(), Tool::Hand, [1.0, 1.0], [1.0, 1.0])); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + for (_, id) in VIEW_MENU.iter().flatten() { + assert!(!entry_enabled(&app, &menu, id), "{id} is greyed out with no document"); + } + choose(&mut app, &Context::default(), FILL_SCREEN); + choose(&mut app, &Context::default(), "view.zoomIn"); + } + #[test] fn unsupported_tools_and_nonfinite_positions_do_not_open_menu() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 83bdd18813..fde3f0b2cb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -959,6 +959,12 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + if crate::canvas_tool_menu::is_view_tool(app.ui.tool) { + if down { + crate::canvas_tool_menu::open_view(app, ctx, app.ui.tool, screen_point(app, x, y), [x, y]); + } + continue; + } if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { if down { crate::canvas_tool_menu::open(app, ctx, app.ui.tool, screen_point(app, x, y)); @@ -1601,6 +1607,33 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some(), "right-click must not edit selection"); } + #[test] + fn agent_right_click_with_the_zoom_tool_opens_and_runs_the_view_menu() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + let result = call( + &mut app, + &ctx, + "ui.pointer", + json!({"tool": "Zoom", "button": "secondary", "events": [{"kind":"down","x":8,"y":8},{"kind":"up","x":8,"y":8}]}), + ); + assert_eq!(result["ok"], true); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.anchor), Some([8.0, 8.0])); + let inspected = call(&mut app, &ctx, "ui.inspect", json!({})); + let entries = inspected.pointer("/result/canvasToolMenu/entries").and_then(Value::as_array).unwrap(); + let ids: Vec<_> = entries.iter().filter_map(|row| row.get("id").and_then(Value::as_str)).collect(); + assert_eq!( + ids, + ["view.zoomIn", "view.zoomOut", "view.fitOnScreen", "view.fillScreen", "view.actualPixels", "view.twoHundredPercent", "view.printSize"] + ); + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id":"select.all"}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id":"view.twoHundredPercent"}))["ok"], true); + assert_eq!(app.ui.views[0].zoom, 2.0); + assert!(app.ui.canvas_tool_menu.is_none()); + } + #[test] fn agent_can_inspect_and_choose_pen_make_selection_from_full_context_menu() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index be3483bf5d..a26e62234c 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -50,6 +50,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { path_name: None, transform: false, pasteboard: false, + anchor: None, text: false, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); diff --git a/docs/context-menu-inventory.md b/docs/context-menu-inventory.md index 59fb512a63..3d7bc522fb 100644 --- a/docs/context-menu-inventory.md +++ b/docs/context-menu-inventory.md @@ -21,7 +21,7 @@ The shared rule is **Command/Ctrl + right-click** on opaque document pixels → | Type | No plain secondary action | Command/Ctrl opens layer list | Test text edit versus non-edit context, spelling, type options, and selection behavior. | | Pen, Path Selection | No plain secondary action | Command/Ctrl opens layer list | High-priority path-point context contract: add/delete/convert point, close path, make selection, fill/stroke path, path operations when applicable. | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | No plain secondary action | Command/Ctrl opens layer list | Define shape versus path context, point edit, fill/stroke, and transform entries. | -| Hand, Zoom | No plain secondary action | Command/Ctrl opens layer list | Verify Photoshop's zoom/fit/actual-size canvas menu and add corresponding view commands. | +| Hand, Zoom | View menu: Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size (`canvas_tool_menu.rs`, #3242) | Command/Ctrl opens layer list | Confirm the row order against a dated Photoshop capture. | Canvas invariant: secondary click must not accidentally start a paint stroke, commit a crop, or edit the document; every mutating menu action dispatches a command. Escape/outside click closes a popup. Existing `ui.pointer` supports a secondary button; UI tests should exercise both native and control-driven gestures. diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 5f5640b29a..425c91f42f 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -28,7 +28,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | | Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Spelling suggestions for the word under the pointer and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | -| Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | No tool menu | P1; view actions stay out of document history | +| Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | | Slice, Slice Select | Slice properties, duplicate/delete, divide, guides **(inferred context placement)** | No tool menu | P2 | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 929b69b427..b8d38b558a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -63,7 +63,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). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `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`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. 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). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `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 Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); 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`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. 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 `secondary`) 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}`