mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 20:56:50 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19cbe8b12d | ||
|
|
b7cedbd89c | ||
|
|
f5a6eb4eb4 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
//! Canvas context actions for selection and Pen tools.
|
||||
//! Canvas context menus for the selection tools, the Pen and an active Free Transform box.
|
||||
|
||||
use egui::Context;
|
||||
use photocraft_doc::LayerContent;
|
||||
@@ -21,19 +21,61 @@ pub struct CanvasToolMenu {
|
||||
pub transform: bool,
|
||||
}
|
||||
|
||||
/// One menu row: label and command id. `None` is a separator.
|
||||
pub type Row = Option<(&'static str, &'static str)>;
|
||||
|
||||
/// Right-click while transforming: what the box's handles do.
|
||||
pub const TRANSFORM_MENU: &[(&str, &str)] = &[
|
||||
("Free Transform", "edit.freeTransform"),
|
||||
("Scale", "edit.transform.scale"),
|
||||
("Rotate", "edit.transform.rotate"),
|
||||
("Skew", "edit.transform.skew"),
|
||||
("Distort", "edit.transform.distort"),
|
||||
("Perspective", "edit.transform.perspective"),
|
||||
pub const TRANSFORM_MENU: &[Row] = &[
|
||||
Some(("Free Transform", "edit.freeTransform")),
|
||||
Some(("Scale", "edit.transform.scale")),
|
||||
Some(("Rotate", "edit.transform.rotate")),
|
||||
Some(("Skew", "edit.transform.skew")),
|
||||
Some(("Distort", "edit.transform.distort")),
|
||||
Some(("Perspective", "edit.transform.perspective")),
|
||||
];
|
||||
|
||||
/// Photoshop's Pen context menu order. `None` is a separator. Rows whose operation is not
|
||||
/// applicable to the current path or layer stay visible and disabled.
|
||||
pub const PEN_MENU: &[Option<(&str, &str)>] = &[
|
||||
/// Photoshop's selection-tool context menu with an active selection, in its order. Its Generative
|
||||
/// Fill and Delete and Fill Selection rows have no PhotoCraft command and are left out. Rows
|
||||
/// stay visible and grey out exactly like their menu-bar twins.
|
||||
pub const SELECTION_MENU: &[Row] = &[
|
||||
Some(("Deselect", "select.deselect")),
|
||||
Some(("Select Inverse", "select.inverse")),
|
||||
Some(("Feather…", "select.modify.feather")),
|
||||
Some(("Select and Mask…", "select.selectAndMask")),
|
||||
None,
|
||||
Some(("Save Selection…", "select.saveSelection")),
|
||||
Some(("Make Work Path…", "select.toWorkPath")),
|
||||
None,
|
||||
Some(("Layer via Copy", "layer.new.layerViaCopy")),
|
||||
Some(("Layer via Cut", "layer.new.layerViaCut")),
|
||||
Some(("New Layer…", "layer.new.layer")),
|
||||
None,
|
||||
Some(("Free Transform", "edit.freeTransform")),
|
||||
Some(("Transform Selection", "select.transformSelection")),
|
||||
Some(("Distort", "edit.transform.distort")),
|
||||
Some(("Perspective", "edit.transform.perspective")),
|
||||
None,
|
||||
Some(("Fill…", "edit.fill")),
|
||||
Some(("Stroke…", "edit.stroke")),
|
||||
Some(("Content-Aware Fill…", "edit.contentAwareFill")),
|
||||
None,
|
||||
Some(("Last Filter", "filter.lastFilter")),
|
||||
None,
|
||||
Some(("Fade…", "edit.fade")),
|
||||
];
|
||||
|
||||
/// The selection tools' context menu with nothing selected: making a selection.
|
||||
pub const NO_SELECTION_MENU: &[Row] = &[
|
||||
Some(("Select All", "select.all")),
|
||||
Some(("Reselect", "select.reselect")),
|
||||
Some(("Color Range…", "select.colorRange")),
|
||||
None,
|
||||
Some(("Load Selection…", "select.loadSelection")),
|
||||
];
|
||||
|
||||
/// Photoshop's Pen context menu order. Rows whose operation is not applicable to the current
|
||||
/// path or layer stay visible and disabled.
|
||||
pub const PEN_MENU: &[Row] = &[
|
||||
Some(("Create Vector Mask", "layer.vectorMask.fromPath")),
|
||||
Some(("Delete Path", "path.delete")),
|
||||
None,
|
||||
@@ -73,31 +115,27 @@ pub fn applies(tool: Tool) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Command ids are shared with the Select menu. Disabled actions remain visible.
|
||||
pub fn entries(has_selection: bool) -> &'static [(&'static str, &'static str)] {
|
||||
if has_selection {
|
||||
&[
|
||||
("Deselect", "select.deselect"),
|
||||
("Inverse Selection", "select.inverse"),
|
||||
("Feather…", "select.modify.feather"),
|
||||
("Select and Mask…", "select.selectAndMask"),
|
||||
("Transform Selection", "select.transformSelection"),
|
||||
]
|
||||
} else {
|
||||
&[("Reselect", "select.reselect")]
|
||||
}
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
pub fn menu_entries(menu: &CanvasToolMenu) -> &'static [(&'static str, &'static str)] {
|
||||
/// The rows of an open menu.
|
||||
pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] {
|
||||
if menu.transform {
|
||||
TRANSFORM_MENU
|
||||
} else if menu.tool == Tool::Pen {
|
||||
&[]
|
||||
PEN_MENU
|
||||
} else {
|
||||
entries(menu.has_selection)
|
||||
selection_rows(menu.has_selection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `command` one of the open menu's rows, and enabled?
|
||||
pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
|
||||
rows(menu).iter().flatten().any(|(_, id)| *id == command) && entry_enabled(app, menu, command)
|
||||
}
|
||||
|
||||
/// Is `command` the transform box's current mode (checked in the transform menu)?
|
||||
/// Scale and Rotate are Free Transform's mode, so only Free Transform shows it checked.
|
||||
fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
|
||||
@@ -171,24 +209,33 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, pos: [f32; 2]) -> bool {
|
||||
|
||||
pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
|
||||
let Some(menu) = app.ui.canvas_tool_menu.take() else { return };
|
||||
if menu.tool == Tool::Pen && !menu.transform {
|
||||
if !PEN_MENU.iter().flatten().any(|(_, id)| *id == command) || !entry_enabled(app, &menu, command) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = choose_pen(app, ctx, &menu, command) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
if !available(app, &menu, command) {
|
||||
return;
|
||||
}
|
||||
if !menu_entries(&menu).iter().any(|&(_, id)| id == command) || !crate::menus::is_enabled(app, command) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, command, json!({})) {
|
||||
let result = if menu.tool == Tool::Pen && !menu.transform {
|
||||
choose_pen(app, ctx, &menu, command)
|
||||
} else if command == "select.toWorkPath" {
|
||||
// Photoshop's Make Work Path… asks for the tolerance first.
|
||||
spec_dialog(app, command, "Make Work Path", r##"{"tolerance":0.5..10=2}"##, serde_json::Map::new());
|
||||
Ok(())
|
||||
} else {
|
||||
crate::menus::invoke(app, ctx, command, json!({})).map(|_| ())
|
||||
};
|
||||
if let Err(e) = result {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter dialog for `command` following `spec` (registry notation), prefilled with `fields`.
|
||||
fn spec_dialog(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, mut fields: serde_json::Map<String, serde_json::Value>) {
|
||||
fields.insert("__command".into(), json!(command));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
fields.insert("__filter".into(), json!(true));
|
||||
fields.insert("__spec".into(), json!(spec));
|
||||
app.ui.open_dialog(crate::state::DialogKind::Command, fields);
|
||||
}
|
||||
|
||||
fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) {
|
||||
let spec = match command {
|
||||
"edit.defineCustomShape" => r##"{"name":text}"##,
|
||||
@@ -206,10 +253,6 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) {
|
||||
} else {
|
||||
fields.insert("name".into(), json!(name));
|
||||
}
|
||||
fields.insert("__command".into(), json!(command));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
fields.insert("__filter".into(), json!(true));
|
||||
fields.insert("__spec".into(), json!(spec));
|
||||
if matches!(command, "path.fill" | "path.stroke") {
|
||||
let fg = app.session.tools.foreground;
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
@@ -220,7 +263,7 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) {
|
||||
fields.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
app.ui.open_dialog(crate::state::DialogKind::Command, fields);
|
||||
spec_dialog(app, command, label, spec, fields);
|
||||
}
|
||||
|
||||
fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> {
|
||||
@@ -275,23 +318,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
v.widgets.hovered.corner_radius = egui::CornerRadius::same(3);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.set_width(200.0);
|
||||
if menu.tool == Tool::Pen && !menu.transform {
|
||||
for row in PEN_MENU {
|
||||
if let Some((label, command)) = row {
|
||||
let item = egui::Button::selectable(false, tl!(label)).min_size(egui::vec2(200.0, 20.0));
|
||||
if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() {
|
||||
selected = Some(*command);
|
||||
}
|
||||
} else {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for &(label, command) in menu_entries(&menu) {
|
||||
let item = egui::Button::selectable(mode_checked(app, &menu, command), tl!(&label)).min_size(egui::vec2(200.0, 22.0));
|
||||
if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() {
|
||||
selected = Some(command);
|
||||
}
|
||||
for row in rows(&menu) {
|
||||
let Some((label, command)) = *row else {
|
||||
ui.separator();
|
||||
continue;
|
||||
};
|
||||
let item = egui::Button::selectable(mode_checked(app, &menu, command), tl!(label)).min_size(egui::vec2(200.0, 20.0));
|
||||
if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() {
|
||||
selected = Some(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -378,13 +412,57 @@ mod tests {
|
||||
assert!(h.state().ui.canvas_tool_menu.is_none(), "command right-click belongs to layer picker");
|
||||
}
|
||||
|
||||
fn labels(rows: &[Row]) -> Vec<Option<&str>> {
|
||||
rows.iter().map(|row| row.map(|(label, _)| label)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_menus_follow_photoshop_order() {
|
||||
assert_eq!(
|
||||
labels(SELECTION_MENU),
|
||||
vec![
|
||||
Some("Deselect"),
|
||||
Some("Select Inverse"),
|
||||
Some("Feather…"),
|
||||
Some("Select and Mask…"),
|
||||
None,
|
||||
Some("Save Selection…"),
|
||||
Some("Make Work Path…"),
|
||||
None,
|
||||
Some("Layer via Copy"),
|
||||
Some("Layer via Cut"),
|
||||
Some("New Layer…"),
|
||||
None,
|
||||
Some("Free Transform"),
|
||||
Some("Transform Selection"),
|
||||
Some("Distort"),
|
||||
Some("Perspective"),
|
||||
None,
|
||||
Some("Fill…"),
|
||||
Some("Stroke…"),
|
||||
Some("Content-Aware Fill…"),
|
||||
None,
|
||||
Some("Last Filter"),
|
||||
None,
|
||||
Some("Fade…"),
|
||||
]
|
||||
);
|
||||
assert_eq!(labels(NO_SELECTION_MENU), vec![Some("Select All"), Some("Reselect"), Some("Color Range…"), None, Some("Load Selection…")]);
|
||||
// Every row runs a real command: one the menu bar has, or an engine command.
|
||||
let app = app();
|
||||
let items = crate::menus::menu_items(&app);
|
||||
for (label, id) in [SELECTION_MENU, NO_SELECTION_MENU, PEN_MENU, TRANSFORM_MENU].into_iter().flatten().flatten() {
|
||||
assert!(items.iter().any(|i| i.id == *id) || photocraft_engine::commands::find(id).is_some(), "{label}: unknown command {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_menu_routes_actions_through_commands() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::RectMarquee;
|
||||
app.run("select.rect", json!({"x": 1, "y": 1, "width": 8, "height": 8})).unwrap();
|
||||
assert!(open(&mut app, Tool::RectMarquee, [10.0, 10.0]));
|
||||
assert!(entries(true).iter().any(|(_, id)| *id == "select.inverse"));
|
||||
assert_eq!(rows(app.ui.canvas_tool_menu.as_ref().unwrap()), SELECTION_MENU);
|
||||
let before = app.session.journal.len();
|
||||
choose(&mut app, &Context::default(), "select.inverse");
|
||||
assert!(app.ui.canvas_tool_menu.is_none());
|
||||
@@ -394,11 +472,60 @@ mod tests {
|
||||
choose(&mut app, &Context::default(), "select.deselect");
|
||||
assert!(app.session.active().unwrap().doc.selection.is_none());
|
||||
assert!(app.session.journal[before..].iter().any(|(id, _)| id == "select.deselect"));
|
||||
assert_eq!(entries(false), &[("Reselect", "select.reselect")]);
|
||||
// Nothing selected: the menu that makes a selection.
|
||||
assert!(open(&mut app, Tool::RectMarquee, [10.0, 10.0]));
|
||||
assert_eq!(rows(app.ui.canvas_tool_menu.as_ref().unwrap()), NO_SELECTION_MENU);
|
||||
choose(&mut app, &Context::default(), "select.reselect");
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
assert!(app.session.journal.iter().any(|(id, _)| id == "select.reselect"));
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
assert!(open(&mut app, Tool::Lasso, [10.0, 10.0]));
|
||||
choose(&mut app, &Context::default(), "select.all");
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_menu_layer_and_path_actions() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::EllipseMarquee;
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
assert!(open(&mut app, Tool::EllipseMarquee, [10.0, 10.0]));
|
||||
choose(&mut app, &Context::default(), "layer.new.layerViaCopy");
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "Layer via Copy adds a layer");
|
||||
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("layer.new.layerViaCopy"));
|
||||
// Make Work Path… asks for the tolerance, then traces the selection.
|
||||
app.run("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap();
|
||||
assert!(open(&mut app, Tool::EllipseMarquee, [10.0, 10.0]));
|
||||
choose(&mut app, &Context::default(), "select.toWorkPath");
|
||||
let dialog = app.ui.dialogs.last().map(|d| d.id).expect("tolerance dialog");
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none(), "nothing happens before OK");
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_some());
|
||||
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.toWorkPath"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_selection_rows_do_nothing() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::MagicWand;
|
||||
app.run("select.rect", json!({"x": 1, "y": 1, "width": 8, "height": 8})).unwrap();
|
||||
assert!(open(&mut app, Tool::MagicWand, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.clone().unwrap();
|
||||
// No filter has run and nothing can be faded yet.
|
||||
for id in ["filter.lastFilter", "edit.fade"] {
|
||||
assert!(!entry_enabled(&app, &menu, id), "{id} is greyed out");
|
||||
}
|
||||
let before = app.session.journal.len();
|
||||
choose(&mut app, &Context::default(), "edit.fade");
|
||||
assert_eq!(app.session.journal.len(), before);
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
// Rows of the other variant are not part of this menu.
|
||||
assert!(open(&mut app, Tool::MagicWand, [10.0, 10.0]));
|
||||
choose(&mut app, &Context::default(), "select.all");
|
||||
assert_eq!(app.session.journal.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -153,12 +153,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
"ui.context.choose" => {
|
||||
let Some(id) = s("id") else { return err("missing `id`") };
|
||||
let Some(menu) = app.ui.canvas_tool_menu.as_ref() else { return err("no canvas context menu is open") };
|
||||
let listed = if menu.tool == crate::state::Tool::Pen {
|
||||
crate::canvas_tool_menu::PEN_MENU.iter().flatten().any(|(_, command)| *command == id)
|
||||
} else {
|
||||
crate::canvas_tool_menu::menu_entries(menu).iter().any(|(_, command)| *command == id)
|
||||
};
|
||||
if !listed || !crate::canvas_tool_menu::entry_enabled(app, menu, id) {
|
||||
if !crate::canvas_tool_menu::available(app, menu, id) {
|
||||
return err("context action is unavailable");
|
||||
}
|
||||
if let Some(authorize) = app.services.automation_command.as_ref()
|
||||
@@ -613,16 +608,10 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
json!({
|
||||
"pos": menu.pos,
|
||||
"tool": menu.tool,
|
||||
"entries": if menu.tool == crate::state::Tool::Pen {
|
||||
crate::canvas_tool_menu::PEN_MENU.iter().map(|row| match row {
|
||||
Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}),
|
||||
None => json!({"separator": true}),
|
||||
}).collect::<Vec<_>>()
|
||||
} else {
|
||||
crate::canvas_tool_menu::menu_entries(menu).iter().map(|&(label, id)| {
|
||||
json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)})
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
"entries": crate::canvas_tool_menu::rows(menu).iter().map(|row| match row {
|
||||
Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}),
|
||||
None => json!({"separator": true}),
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
"panels": app.ui.panels,
|
||||
|
||||
@@ -1111,11 +1111,12 @@ mod tests {
|
||||
app.run("select.rect", json!({"x": 2, "y": 2, "width": 8, "height": 8})).unwrap();
|
||||
let items = menu_items(&app);
|
||||
let top: Vec<_> = items.iter().filter(|i| i.path.len() == 1 && i.path.first().is_some_and(|p| p == "Select")).collect();
|
||||
// Context actions live somewhere under Select (Feather stays in Select > Modify, as in the
|
||||
// reference menus).
|
||||
// The context menus' Select actions live somewhere under Select (Feather stays in Select >
|
||||
// Modify, as in the reference menus). Make Work Path is a Paths panel action.
|
||||
let select: Vec<_> = items.iter().filter(|i| i.path.first().is_some_and(|p| p == "Select")).collect();
|
||||
for &(label, id) in crate::canvas_tool_menu::entries(true).iter().chain(crate::canvas_tool_menu::entries(false)) {
|
||||
assert!(select.iter().any(|i| i.id == id && i.label == label), "Select menu missing {label} ({id})");
|
||||
let rows = crate::canvas_tool_menu::SELECTION_MENU.iter().chain(crate::canvas_tool_menu::NO_SELECTION_MENU).flatten();
|
||||
for &(label, id) in rows.filter(|(_, id)| id.starts_with("select.") && *id != "select.toWorkPath") {
|
||||
assert!(select.iter().any(|i| i.id == id), "Select menu missing {label} ({id})");
|
||||
}
|
||||
assert!(top.iter().any(|i| i.id == "select.all"));
|
||||
assert!(top.iter().any(|i| i.id == "select.colorRange"));
|
||||
|
||||
@@ -1938,7 +1938,7 @@ mod tests {
|
||||
begin(&mut app, &ctx).unwrap();
|
||||
assert!(crate::canvas_tool_menu::open_transform(&mut app, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.clone().unwrap();
|
||||
let ids: Vec<&str> = crate::canvas_tool_menu::menu_entries(&menu).iter().map(|e| e.1).collect();
|
||||
let ids: Vec<&str> = crate::canvas_tool_menu::rows(&menu).iter().flatten().map(|e| e.1).collect();
|
||||
assert!(ids.contains(&"edit.transform.distort") && ids.contains(&"edit.freeTransform"));
|
||||
crate::canvas_tool_menu::choose(&mut app, &ctx, "edit.transform.distort");
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort);
|
||||
|
||||
@@ -12,7 +12,7 @@ The shared rule is **Command/Ctrl + right-click** on opaque document pixels →
|
||||
| Brush, Pencil, Eraser, Background Eraser | Brush Preset picker (size, hardness, presets) | Alt/Option + right-drag changes size/hardness via `tools.setBrush`; Brush/Eraser can right-drag erase via `tools.rightClickWithPaintingTools=erase` | Verify whether context should expose brush presets, settings, or tool-specific eraser controls. Keep erase preference and modifier priority deterministic. |
|
||||
| Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush Preset picker | Alt/Option + right-drag changes size/hardness | Compare each tool's Photoshop picker and source/retouch options; use existing brush command route. |
|
||||
| Quick Selection | Brush Preset picker | Alt/Option + right-drag changes size/hardness | Check selection-tool brush options and whether mode controls belong in picker. |
|
||||
| Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magnetic Lasso, Magic Wand, Object Selection | No plain secondary action at branch point; this PR adds selection context actions | Command/Ctrl opens layer list | Reuse Select command IDs with exact enablement; compare menu placement and order with a current Photoshop capture. |
|
||||
| Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magnetic Lasso, Magic Wand, Object Selection | Selection context menu: the full Photoshop list with a selection, Select All / Reselect / Color Range / Load Selection without (`canvas_tool_menu.rs`, rows in `docs/context-menu-parity.md`) | Command/Ctrl opens layer list | Confirm the no-selection variant against a dated Photoshop capture. |
|
||||
| Crop, Slice, Slice Select | No plain secondary action | Command/Ctrl opens layer list | Define crop/straighten commit and cancel, aspect/preset, slice selection/edit behavior separately. |
|
||||
| Eyedropper, Ruler, Note, Count | No plain secondary action | Command/Ctrl opens layer list | Check tool-specific sampling, measurement, annotation, and count menus. |
|
||||
| Gradient, Paint Bucket | No plain secondary action | Command/Ctrl opens layer list | Expose gradient preset and fill settings through commands if Photoshop's context UI provides them; avoid painting on right-click. |
|
||||
|
||||
@@ -19,7 +19,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic
|
||||
| Tool(s) in PhotoCraft | Photoshop / desired contextual action | PhotoCraft at branch point | Priority, evidence |
|
||||
|---|---|---|---|
|
||||
| Move | Choose a layer at the point; an auto-select choice also exists in the options bar | Layers | P0; [Adobe layer movement](https://helpx.adobe.com/photoshop/using/moving-stacking-locking-layers.html); exact pop-up layout requires capture |
|
||||
| Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magnetic Lasso, Magic Wand, Quick Selection, Object Selection | Active selection: Deselect, Inverse Selection, Feather, Transform Selection, Select and Mask, Fill/Stroke where applicable **(inferred context placement)** | Quick Selection: Picker; others: no tool menu. **This PR adds an initial selection menu for the other six tools; Quick Selection keeps its picker.** The Select menu exposes these same selection commands (Feather under Select > Modify, as in the reference menus). | P1; capture each selection state |
|
||||
| Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magnetic Lasso, Magic Wand, Quick Selection, Object Selection | Active selection: the 2026 selection menu (see *Selection canvas reference* below); no selection: making a selection | Quick Selection: Picker (a brush tool, as in Photoshop); the other six: the selection menu below (#614, #738) | P1; no-selection variant still needs a dated capture |
|
||||
| Crop | During crop: ratio/preset, overlay, clear/reset, commit/cancel **(inferred context placement)** | No tool menu | P1; pending crop target |
|
||||
| Eyedropper | Sample-size/source choices **(inferred)** | No tool menu | P2; [Adobe illustration uses Eyedropper](https://helpx.adobe.com/photoshop/using/panels-menus.html), but does not publish item text |
|
||||
| Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 |
|
||||
@@ -54,6 +54,22 @@ The user supplied a Photoshop screenshot of a closed path in Path mode. The tabl
|
||||
|
||||
The reference does not establish Photoshop's behavior for right-clicks on a single anchor, a segment, or empty canvas; these still need separate captures. PhotoCraft's disabled rows do not call a command.
|
||||
|
||||
## Selection canvas reference (2026-10-08)
|
||||
|
||||
Right-click with a Marquee, Lasso, Polygonal Lasso, Magic Wand or Object Selection tool. Rows keep the command id, dialog and enablement of their menu-bar twins (`canvas_tool_menu::SELECTION_MENU`); a greyed row never runs.
|
||||
|
||||
| Group | Rows with an active selection (Photoshop 2026 order) | PhotoCraft command |
|
||||
|---|---|---|
|
||||
| Selection | Deselect; Select Inverse; Feather…; Select and Mask… | `select.deselect`, `select.inverse`, `select.modify.feather`, `select.selectAndMask` |
|
||||
| Store | Save Selection…; Make Work Path… | `select.saveSelection`; `select.toWorkPath` after a Tolerance dialog |
|
||||
| Layer | Layer via Copy; Layer via Cut; New Layer… | `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.new.layer` |
|
||||
| Transform | Free Transform; Transform Selection; Distort; Perspective | `edit.freeTransform`, `select.transformSelection`, `edit.transform.distort`, `edit.transform.perspective` |
|
||||
| Fill | Fill…; Stroke…; Content-Aware Fill… | `edit.fill`, `edit.stroke`, `edit.contentAwareFill` |
|
||||
| Filter | Last Filter | `filter.lastFilter` |
|
||||
| Fade | Fade… | `edit.fade` |
|
||||
|
||||
Photoshop's Generative Fill and Delete and Fill Selection rows are left out: PhotoCraft has no such commands. With nothing selected the menu offers Select All, Reselect, Color Range… and Load Selection… (`NO_SELECTION_MENU`). Adobe-adjacent sources document Color Range there; the other rows are **inferred** until a dated capture confirms them.
|
||||
|
||||
## Panel and workspace matrix
|
||||
|
||||
| Surface / target | Desired options and state | PhotoCraft now | Source and confidence |
|
||||
|
||||
Reference in new issue
Block a user