mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
lloyd
Claude Opus 5.5
parent
a7f26522ff
commit
b814fd5241
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user