Zoom and Hand tools: right-click the canvas for the view menu (#3242) (#3255)

Right-clicking the image with the Zoom or Hand tool opens Zoom In, Zoom Out,
Fit on Screen, Fill Screen, 100%, 200% and Print Size. Zoom In/Out step about
the right-clicked point (or centre it with Zoom Clicked Point to Center); the
other rows run their View menu command. Outside the image the pasteboard menu
still wins. The canvas keeps the view a menu row sets instead of writing its
frame copy back over it. Agents get the same menu through ui.pointer and
ui.context.choose.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
LloydNicholson
2026-10-11 12:50:01 -04:00
committed by GitHub
co-authored by lloyd Claude Opus 5.5
parent a7f26522ff
commit b814fd5241
7 changed files with 283 additions and 10 deletions
+15 -1
View File
@@ -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).
+231 -6
View File
@@ -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();
+33
View File
@@ -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());
@@ -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"));
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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}`