Selection tools: Photoshop's full right-click menu (#614, #738) (#831)

Right-click with a Marquee, Lasso, Polygonal Lasso, Magic Wand or Object
Selection tool now lists Photoshop's selection context menu in its order
(Deselect … Fade…), with separators, the menu-bar commands and their
enablement. Without a selection it offers Select All, Reselect, Color
Range… and Load Selection…. Make Work Path… asks for the tolerance first.
All canvas context menus (selection, Pen, Free Transform) now share one
row model, so the control channel lists and chooses them the same way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
BFlat 2026-10-08 09:26:43 +07:00 committed by GitHub
parent e040376e15
commit 772925caf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 219 additions and 86 deletions

View File

@ -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]

View File

@ -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,

View File

@ -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"));

View File

@ -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);

View File

@ -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. |

View File

@ -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 |