Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 bc02ffe8ed UI tests: scroll Layers rows into view under the fixed-height dock
The dock (#139) gives the Layers panel a fixed height that scrolls, so a
group's triangle or a row can sit below the fold. Tests now scroll the
node into view (accesskit ScrollIntoView) and click its real rect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 02:28:46 -04:00
Brandon ThomasandClaude Opus 5.5 ad3dc029f7 Shortcuts: every displayed shortcut fires, from wherever focus is (#127)
- The menus show the catalogue's Photoshop shortcuts, but only command and
  shell shortcuts were bound: Image Size (Cmd+Alt+I), Canvas Size
  (Cmd+Alt+C), Feather (Shift+F6), Layer > Quick Export / Export As,
  Window > Info (F8) and Actions (Alt+F9) showed a key that did nothing.
  The binding table (new shortcut_dispatch module) now merges the
  catalogue's shortcuts for live items, one owner per key.
- Any focused widget swallowed every shortcut (egui_wants_keyboard_input):
  after clicking into a value field (Layers Opacity/Fill, options bar
  Opacity/Flow/Smoothing), the Curves graph, or Tab, nothing fired until
  the canvas was clicked. Now a focused text field keeps typing and its
  editing keys (select all, clipboard, undo, navigation) while Cmd
  shortcuts and F-keys still fire, as in Photoshop; a focused non-text
  widget keeps only its navigation keys.
- A shortcut whose command is disabled says why on the status bar.
- Layers panel rows carry their layer name for accessibility (and tests).

Tests press every displayed menu / shell shortcut and every tool letter in
a whole-app kittest harness with a layered document (group, type layer,
selection) from four focus places, asserting each dispatches its item or
the item is greyed with a reason; plus real runs of the common ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 02:28:46 -04:00
Brandon ThomasandClaude Opus 5.5 ef86fad78d Layers panel: group disclosure triangles expand/collapse groups (#126)
Group rows get a disclosure triangle; click toggles the group, Alt-click sets
every group. Rows now list a group directly above its contents (the panel
used to reverse a pre-order walk, putting headers below their children) and
closed groups hide their contents (a kind filter still shows them).

The state is Group::expanded (PSD section divider open/closed folder,
.pcraft), toggled by the new layer.setExpanded command as a view change:
no history step, a clean document stays clean. document.inspect reports it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 02:28:46 -04:00
13 changed files with 926 additions and 48 deletions

No files matched your search

+1
View File
@@ -988,6 +988,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::wia_cmds::specs());
v.extend(crate::variables_cmds::specs());
v.extend(crate::plugin_cmds::specs());
v.extend(crate::group_view_cmds::specs());
v
}
+158
View File
@@ -0,0 +1,158 @@
//! Layers panel disclosure state: expanding and collapsing layer groups (#126).
//!
//! A group's open/closed state is document data ([`photocraft_doc::Group::expanded`], the PSD
//! section divider's open/closed folder type, saved in `.pcraft` too) but, as in Photoshop,
//! toggling it is a view change: no history step, and a clean document stays clean.
use std::sync::Arc;
use photocraft_doc::{Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const ID: &str = "layer.setExpanded";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
fn has_group(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.walk().iter().any(|(_, _, l)| l.is_group()) { Ok(()) } else { Err("the document has no layer groups".into()) }
}
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
}
}
fn set_all(layers: &mut [Layer], expanded: bool) -> usize {
let mut n = 0;
for l in layers {
if let LayerContent::Group(g) = &mut l.content {
g.expanded = expanded;
n += 1 + set_all(&mut g.children, expanded);
}
}
n
}
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: open or close a group (no `expanded`: toggle).
/// With `all`, every group in the document takes the new state (Photoshop's ⌥-click on a
/// disclosure triangle); without a layer, `all` toggles from "any group open".
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
let want = opt_bool(p, "expanded")?;
let all = opt_bool(p, "all")?.unwrap_or(false);
let st = s.active().ok_or(EngineError::NoDocument)?;
let layer = match p.get("layer") {
None | Some(Value::Null) if all => None,
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
};
let current = match layer {
Some(id) => match &st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.content {
LayerContent::Group(g) => g.expanded,
_ => return Err(bad(format!("layer {} is not a group", id.0))),
},
None => st.doc.walk().iter().any(|(_, _, l)| matches!(&l.content, LayerContent::Group(g) if g.expanded)),
};
let expanded = want.unwrap_or(!current);
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
// Copy-on-write: history snapshots keep their own state (layers share tiles, so this is cheap).
let doc = Arc::make_mut(&mut st.doc);
let changed = if all {
set_all(&mut doc.layers, expanded)
} else {
let id = layer.ok_or_else(|| bad("no layer"))?;
match doc.layer_mut(id).map(|l| &mut l.content) {
Some(LayerContent::Group(g)) => {
g.expanded = expanded;
1
}
_ => return Err(EngineError::NoLayer(id)),
}
};
Ok(json!({"expanded": expanded, "groups": changed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Expand/Collapse Group",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every group; not an undo step)"##,
enabled: has_group,
run: set_expanded,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
(s, inner, outer)
}
fn expanded(s: &Session, id: u64) -> bool {
matches!(&s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Group(g) if g.expanded)
}
#[test]
fn toggles_one_group_without_history_or_dirtying() {
let (mut s, inner, outer) = session();
let st = s.active_mut().unwrap();
st.saved_revision = st.revision;
let undo_steps = s.active().unwrap().history.entries().len();
assert!(expanded(&s, inner) && expanded(&s, outer));
assert_eq!(s.execute(ID, json!({"layer": inner})).unwrap()["expanded"], false);
assert!(!expanded(&s, inner) && expanded(&s, outer));
assert_eq!(s.active().unwrap().history.entries().len(), undo_steps, "no history step");
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
s.execute(ID, json!({"layer": inner, "expanded": true})).unwrap();
assert!(expanded(&s, inner));
// The inspect JSON reports it.
s.execute(ID, json!({"layer": outer, "expanded": false})).unwrap();
assert_eq!(s.execute("document.inspect", json!({})).unwrap()["layers"][0]["expanded"], false);
}
#[test]
fn all_sets_every_group() {
let (mut s, inner, outer) = session();
s.execute(ID, json!({"layer": outer, "all": true})).unwrap();
assert!(!expanded(&s, inner) && !expanded(&s, outer));
assert_eq!(s.execute(ID, json!({"all": true})).unwrap()["groups"], 2);
assert!(expanded(&s, inner) && expanded(&s, outer));
}
#[test]
fn bad_params_fail_gracefully() {
let (mut s, _, _) = session();
let bg = s.active().unwrap().doc.layers[0].id.0;
for p in [
json!({"layer": bg}),
json!({"layer": 9999}),
json!({"layer": "x"}),
json!({"layer": -1}),
json!({"expanded": "yes", "all": true}),
json!({"all": 3}),
] {
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
}
let mut empty = Session::new();
assert!(empty.execute(ID, json!({})).is_err());
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no groups: disabled");
}
}
+1
View File
@@ -85,6 +85,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
match &l.content {
LayerContent::Group(g) => {
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect());
v["expanded"] = json!(g.expanded);
if let Some(a) = &g.artboard {
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
}
+1
View File
@@ -31,6 +31,7 @@ pub mod filters;
pub mod filters_ext;
mod frame_cmds;
pub mod gallery_cmds;
pub mod group_view_cmds;
pub mod image_cmds;
pub mod inspect;
pub mod layer_menu_cmds;
+71
View File
@@ -0,0 +1,71 @@
//! Layers panel tree: the rows in display order (top of the stack first, each group directly
//! above its contents) and the groups' disclosure triangles (#126).
//!
//! Clicking a triangle opens or closes the group; ⌥-click sets every group in the document to
//! the new state (Photoshop). The state is the group's `expanded` flag, document data saved in
//! `.pcraft` and as the PSD section divider's open/closed folder type, toggled through
//! `layer.setExpanded` (a view change: no history step).
use egui::{Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_doc::{Document, Layer, LayerContent};
use serde_json::{Value, json};
use crate::theme::Tokens;
/// Width the disclosure triangle takes in a group row.
pub const TRIANGLE_W: f32 = 14.0;
/// Rows as the Layers panel lists them: topmost layer first, a group directly above its
/// children, and the contents of closed groups left out (unless `show_all`, used while the
/// panel is filtered by kind so matches inside closed groups still show).
pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> {
fn rec<'a>(layers: &'a [Layer], depth: usize, show_all: bool, out: &mut Vec<(usize, &'a Layer)>) {
for l in layers.iter().rev() {
out.push((depth, l));
if let LayerContent::Group(g) = &l.content
&& (g.expanded || show_all)
{
rec(&g.children, depth + 1, show_all, out);
}
}
}
let mut out = Vec::new();
rec(&doc.layers, 0, show_all, &mut out);
out
}
/// The hit area of a group row's triangle starting at `x`.
pub fn triangle_rect(row: Rect, x: f32) -> Rect {
Rect::from_min_size(pos2(x - 2.0, row.top()), vec2(TRIANGLE_W + 2.0, row.height()))
}
/// Paint and handle a group row's disclosure triangle at `*x` (advancing it). Returns true when
/// it was clicked this frame, so the row doesn't also treat the click as a selection.
pub fn disclosure(ui: &mut egui::Ui, row: Rect, x: &mut f32, l: &Layer, actions: &mut Vec<(String, Value)>) -> bool {
let LayerContent::Group(g) = &l.content else { return false };
let hit = triangle_rect(row, *x);
let resp = ui.interact(hit, ui.id().with(("disclosure", l.id.0)), Sense::click());
let t = Tokens::get(ui.ctx());
let c = pos2(*x + TRIANGLE_W / 2.0 - 1.0, row.center().y);
let s = 3.5;
let pts = if g.expanded {
vec![pos2(c.x - s, c.y - s * 0.6), pos2(c.x + s, c.y - s * 0.6), pos2(c.x, c.y + s * 0.6)]
} else {
vec![pos2(c.x - s * 0.6, c.y - s), pos2(c.x + s * 0.6, c.y), pos2(c.x - s * 0.6, c.y + s)]
};
let color = if resp.hovered() { t.text } else { t.icon };
ui.painter().add(Shape::convex_polygon(pts, color, Stroke::NONE));
*x += TRIANGLE_W;
let clicked = resp.clicked();
if clicked {
let all = ui.input(|i| i.modifiers.alt);
actions.push(("layer.setExpanded".into(), json!({"layer": l.id.0, "expanded": !g.expanded, "all": all})));
}
let (verb, name) = (if g.expanded { "Collapse" } else { "Expand" }, l.name.clone());
let resp = resp.on_hover_text(if g.expanded { "Collapse group (⌥-click: all groups)" } else { "Expand group (⌥-click: all groups)" });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} group {name}")));
clicked
}
#[cfg(test)]
mod tests;
+141
View File
@@ -0,0 +1,141 @@
//! #126: group disclosure triangles in the real Layers panel (whole app, real pointer input).
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::{Document, LayerContent, LayerId};
use serde_json::json;
use super::display_rows;
use crate::PhotocraftApp;
/// Background, then `outer { in-outer, inner { deep } }` with a pixel layer above it all.
fn layered() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layer": inner})).unwrap();
// `layer.new.layer` lands above the active layer, inside `outer`.
s.execute("layer.new.layer", json!({"name": "in-outer"})).unwrap();
s.execute("layer.select", json!({"layer": outer})).unwrap();
s.execute("layer.new.layer", json!({"name": "top"})).unwrap();
s
}
fn harness_with(session: photocraft_engine::Session, ppp: f32) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(session, crate::Services::default())
});
h.run_steps(8);
h
}
fn group(h: &Harness<'_, PhotocraftApp>, name: &str) -> (LayerId, bool) {
let doc = &h.state().session.active().unwrap().doc;
doc.walk()
.into_iter()
.find_map(|(_, _, l)| match &l.content {
LayerContent::Group(g) if l.name == name => Some((l.id, g.expanded)),
_ => None,
})
.unwrap_or_else(|| panic!("no group {name}"))
}
/// The on-screen centre of a group's triangle, scrolled into view in the Layers panel first (the
/// dock gives Layers a fixed height, so rows can sit below its fold). `None` when the row isn't
/// listed (inside a closed group).
fn triangle(h: &mut Harness<'_, PhotocraftApp>, name: &str) -> Option<Pos2> {
let verb = if group(h, name).1 { "Collapse" } else { "Expand" };
let label = format!("{verb} group {name}");
h.query_by_label(&label)?.scroll_to_me();
h.run_steps(4);
h.query_by_label(&label).map(|n| n.rect().center())
}
/// A real click (press and release on separate frames), modifiers held throughout.
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers });
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
#[test]
fn rows_list_groups_above_their_contents_and_hide_closed_ones() {
let s = layered();
let doc: &Document = &s.active().unwrap().doc;
let names = |doc: &Document, all| display_rows(doc, all).iter().map(|(d, l)| format!("{d}:{}", l.name)).collect::<Vec<_>>();
assert_eq!(names(doc, false), ["0:top", "0:outer", "1:in-outer", "1:inner", "2:deep", "0:Background"]);
let mut closed = doc.clone();
let id = closed.walk().into_iter().find(|(_, _, l)| l.name == "inner").unwrap().2.id;
if let Some(LayerContent::Group(g)) = closed.layer_mut(id).map(|l| &mut l.content) {
g.expanded = false;
}
assert_eq!(names(&closed, false), ["0:top", "0:outer", "1:in-outer", "1:inner", "0:Background"]);
assert_eq!(names(&closed, true).len(), 6, "a kind filter still finds layers in closed groups");
}
#[test]
fn clicking_a_triangle_toggles_its_group_at_every_ui_scale() {
for ppp in [1.0, 1.5, 2.0] {
let mut h = harness_with(layered(), ppp);
let active = h.state().session.active().unwrap().active_layer;
let undo = h.state().session.active().unwrap().history.entries().len();
let p = triangle(&mut h, "inner").expect("inner's triangle shows");
click(&mut h, p, Modifiers::NONE);
assert!(!group(&h, "inner").1, "@{ppp}x: click collapses");
assert!(group(&h, "outer").1, "@{ppp}x: only that group");
// The click opened/closed the group; it didn't select it or start a drag-reorder.
assert_eq!(h.state().session.active().unwrap().active_layer, active, "@{ppp}x: selection unchanged");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), undo, "@{ppp}x: not an undo step");
let p = triangle(&mut h, "inner").expect("still shows, now pointing right");
click(&mut h, p, Modifiers::NONE);
assert!(group(&h, "inner").1, "@{ppp}x: click expands again");
// Nested: closing the outer group hides the inner group's row (and so its triangle).
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::NONE);
assert!(!group(&h, "outer").1);
assert!(triangle(&mut h, "inner").is_none(), "@{ppp}x: inner row hidden in a closed group");
}
}
#[test]
fn alt_click_toggles_every_group() {
let mut h = harness_with(layered(), 1.0);
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::ALT);
assert!(!group(&h, "outer").1 && !group(&h, "inner").1, "⌥-click closes all groups");
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::ALT);
assert!(group(&h, "outer").1 && group(&h, "inner").1, "⌥-click opens all groups");
assert!(triangle(&mut h, "inner").is_some());
}
#[test]
fn state_survives_psd_export_and_import_and_imported_groups_toggle() {
let mut h = harness_with(layered(), 2.0);
let p = triangle(&mut h, "inner").unwrap();
click(&mut h, p, Modifiers::NONE);
let doc = (*h.state().session.active().unwrap().doc).clone();
let bytes = photocraft_io::export(&doc, "groups.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("groups.psd", &bytes).unwrap().document;
let mut s = photocraft_engine::Session::new();
s.add_document(back, None);
let mut h = harness_with(s, 2.0);
assert!(group(&h, "outer").1 && !group(&h, "inner").1, "open/closed state round-trips through PSD");
// A PSD-imported group toggles like any other.
let p = triangle(&mut h, "inner").expect("imported group has a triangle");
click(&mut h, p, Modifiers::NONE);
assert!(group(&h, "inner").1);
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::NONE);
assert!(!group(&h, "outer").1);
}
+2
View File
@@ -45,6 +45,7 @@ pub mod icons;
pub mod layer_menu_ui;
pub mod layer_props_ui;
pub mod layer_style;
pub mod layer_tree_ui;
pub mod links;
pub mod liquify_ui;
pub mod menu_catalog;
@@ -65,6 +66,7 @@ pub mod proxy;
pub mod puppet_ui;
pub mod retouch_ui;
pub mod rulers;
pub mod shortcut_dispatch;
pub mod shortcuts;
mod sizing;
pub mod slice_ui;
+1 -1
View File
@@ -60,7 +60,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
];
/// Photoshop's Window › <panel> ids for the panels the shell already has, as `window.toggle.*`.
fn panel_alias(id: &str) -> Option<&'static str> {
pub(crate) fn panel_alias(id: &str) -> Option<&'static str> {
Some(match id.strip_prefix("window.panel.")? {
"layers" => "window.toggle.layers",
"history" => "window.toggle.history",
+10 -6
View File
@@ -1129,7 +1129,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(2.0);
}
let rows = doc.walk();
// Top of the stack first, groups above their contents, closed groups' contents hidden (#126).
let rows = crate::layer_tree_ui::display_rows(&doc, !app.ui.layer_filter.is_empty());
let ctx = ui.ctx().clone();
let footer = 38.0;
let fill = ui.available_height() > footer + 60.0;
@@ -1141,7 +1142,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.auto_shrink([false, !fill])
.show(ui, |ui| {
let filter = app.ui.layer_filter.clone();
for (_, depth, l) in rows.iter().rev() {
for &(depth, l) in &rows {
// Select › Isolate Layers.
if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) {
continue;
@@ -1160,11 +1161,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
}
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
layer_row(app, &ctx, ui, &doc, l, *depth, row, &mut actions);
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
if !l.effects.items.is_empty() {
effect_rows(app, ui, l, *depth);
effect_rows(app, ui, l, depth);
}
crate::smart_ui::filter_rows(app, ui, l, *depth, &mut actions);
crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions);
}
});
// End any layer drag after every row has had a chance to accept the drop.
@@ -1301,6 +1302,8 @@ fn layer_row(
let row_h = if t.pro { 32.0 } else { 46.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
layer_drag_and_drop(ctx, ui, l, rect, &resp, actions);
// Rows are painted: name them for screen readers and UI tests.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &l.name));
let painter = ui.painter_at(rect.expand(1.0));
if t.pro {
if selected {
@@ -1325,6 +1328,7 @@ fn layer_row(
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible})));
}
x += 28.0 + depth as f32 * 14.0;
let toggled = crate::layer_tree_ui::disclosure(ui, rect, &mut x, l, actions);
if l.clipped {
painter.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, "↳", egui::FontId::proportional(13.0), t.text_faint);
x += 12.0;
@@ -1405,7 +1409,7 @@ fn layer_row(
});
if let Some(p) = thumb_load {
actions.push(("select.loadSelection".into(), p));
} else if resp.clicked() && !eye_resp.clicked() {
} else if resp.clicked() && !eye_resp.clicked() && !toggled {
let mode = select_mode(ui.input(|i| i.modifiers));
actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
// Clicking a thumbnail picks what painting targets; adjustment/fill layers target their mask.
+224
View File
@@ -0,0 +1,224 @@
//! Keyboard shortcut table and dispatch (#127): every shortcut the menus display fires.
//!
//! The menus show Photoshop's default shortcuts from [`crate::menu_catalog::CATALOG`], while
//! commands also declare their own (`CommandSpec::shortcut`, [`crate::menus::UI_COMMANDS`]).
//! The binding table merges all three, with Edit › Keyboard Shortcuts overrides on top, so a
//! shortcut printed next to a live menu item always reaches that item. A shortcut whose command
//! is disabled in the current state reports why on the status bar instead of doing nothing
//! silently, as the greyed menu item shows.
use egui::{Key, KeyboardShortcut, Modifiers};
use serde_json::json;
use crate::PhotocraftApp;
use crate::shortcuts::{consume, parse};
/// What a dispatched shortcut did (kept for tests and `ui.inspect`-style debugging).
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
Ran,
Disabled(String),
Failed(String),
}
fn log_id() -> egui::Id {
egui::Id::new("pc-shortcut-log")
}
fn dry_run_id() -> egui::Id {
egui::Id::new("pc-shortcut-dry-run")
}
/// Tests: resolve and check shortcuts without running them (File › Exit, Open, browser links…).
pub fn set_dry_run(ctx: &egui::Context, on: bool) {
ctx.data_mut(|d| d.insert_temp(dry_run_id(), on));
}
/// The shortcuts dispatched since the last call (command id and outcome), oldest first.
pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> {
ctx.data_mut(|d| d.remove_temp::<Vec<(String, Outcome)>>(log_id())).unwrap_or_default()
}
/// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command:
/// command and shell shortcuts, then the menu catalogue's for live items without their own,
/// then Edit › Keyboard Shortcuts assignments to any other menu item.
pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> {
let prefs = app.session.prefs();
let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, prefs.shortcut(id, *sc)));
let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut)));
let own: std::collections::HashSet<&str> = crate::menus::UI_COMMANDS
.iter()
.map(|c| c.0)
.chain(photocraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some() || prefs.shortcuts.contains_key(c.id)).map(|c| c.id))
.collect();
let catalog = crate::menu_catalog::CATALOG
.iter()
.filter(|(_, _, sc, id)| sc.is_some() && !own.contains(id) && crate::menus::is_live(id))
.map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc)));
let overrides = prefs
.shortcuts
.iter()
.filter(|(id, sc)| !sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()))
.map(|(id, sc)| (id.as_str(), Some(sc.as_str())));
let mut all: Vec<(String, KeyboardShortcut)> = Vec::new();
for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides) {
let Some(sc) = sc.and_then(parse) else { continue };
// Plain X and D are the colour keys (handled with the tool keys).
if sc.modifiers == Modifiers::NONE && matches!(sc.logical_key, Key::X | Key::D) {
continue;
}
if !all.iter().any(|(_, b)| *b == sc) {
all.push((id.to_string(), sc));
}
}
all.sort_by_key(|(_, sc)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8));
all
}
/// Where keyboard focus is: nowhere (the canvas), on a widget (a slider, the Curves graph, a
/// button reached with Tab), or in a text field.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
None,
Widget,
Text,
}
impl Focus {
pub fn of(ctx: &egui::Context) -> Focus {
if ctx.text_edit_focused() {
Focus::Text
} else if ctx.egui_wants_keyboard_input() {
Focus::Widget
} else {
Focus::None
}
}
/// May `sc` fire with this focus? A focused widget keeps its navigation keys (arrows, ↩,
/// Space, Delete…); a text field keeps everything but ⌘ shortcuts (minus its own editing
/// ones: select all, clipboard, undo) and the function keys.
pub fn allows(self, sc: &KeyboardShortcut) -> bool {
let k = sc.logical_key;
let function = matches!(
k,
Key::F1
| Key::F2
| Key::F3
| Key::F4
| Key::F5
| Key::F6
| Key::F7
| Key::F8
| Key::F9
| Key::F10
| Key::F11
| Key::F12
| Key::F13
| Key::F14
| Key::F15
);
let navigation = matches!(
k,
Key::ArrowLeft
| Key::ArrowRight
| Key::ArrowUp
| Key::ArrowDown
| Key::Enter
| Key::Space
| Key::Tab
| Key::Escape
| Key::Backspace
| Key::Delete
| Key::Home
| Key::End
| Key::PageUp
| Key::PageDown
);
match self {
Focus::None => true,
Focus::Widget => sc.modifiers.command || !navigation,
Focus::Text => {
let text_edit = !sc.modifiers.alt && matches!(k, Key::A | Key::C | Key::X | Key::V | Key::Z | Key::Y) || navigation;
function || (sc.modifiers.command && !text_edit)
}
}
}
}
/// The command whose shortcut was pressed this frame (its key press consumed), if any.
/// While typing (inline type), clipboard and select-all shortcuts belong to the text.
pub fn pressed_command(app: &PhotocraftApp, ctx: &egui::Context, focus: Focus, editing: bool) -> Option<String> {
// Building the table walks the registry: only do it when a key went down.
if !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) {
return None;
}
for (id, sc) in bindings(app) {
if !focus.allows(&sc) {
continue;
}
if editing && matches!(id.as_str(), "edit.copy" | "edit.cut" | "edit.paste" | "edit.copyMerged" | "select.all" | "edit.pasteSpecial.pasteInPlace") {
continue;
}
if consume(ctx, &sc) {
return Some(id);
}
}
None
}
/// Why `id` can't run now (the engine's reason when it has one).
pub fn disabled_reason(app: &PhotocraftApp, id: &str) -> String {
photocraft_engine::commands::find(id)
.and_then(|c| (c.enabled)(&app.session).err())
.unwrap_or_else(|| if app.session.active().is_none() { "no document open".into() } else { "not available in the current state".into() })
}
fn label(id: &str) -> String {
crate::menu_catalog::CATALOG
.iter()
.find(|c| c.3 == id)
.map(|c| c.1)
.or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1))
.or_else(|| photocraft_engine::commands::find(id).map(|c| c.label))
.unwrap_or(id)
.trim_end_matches('…')
.to_string()
}
/// Run a shortcut's command like its menu item (dialogs included), or say why it can't run.
pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
let outcome = if !crate::menus::is_enabled(app, id) {
let why = disabled_reason(app, id);
app.ui.status = format!("{} is not available: {why}", label(id));
app.ui.status_error = true;
Outcome::Disabled(why)
} else if ctx.data(|d| d.get_temp::<bool>(dry_run_id())).unwrap_or(false) {
Outcome::Ran
} else {
let r = if crate::adjust_dialog::has_dialog(id) {
crate::adjust_dialog::open(app, id);
Ok(serde_json::Value::Null)
} else {
crate::menus::invoke(app, ctx, id, json!({}))
};
match r {
Ok(_) => Outcome::Ran,
Err(e) => {
app.ui.status = e.clone();
app.ui.status_error = true;
Outcome::Failed(e)
}
}
};
ctx.data_mut(|d| {
let log = d.get_temp_mut_or_default::<Vec<(String, Outcome)>>(log_id());
if log.len() >= 64 {
log.remove(0);
}
log.push((id.to_string(), outcome));
});
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,296 @@
//! #127: every shortcut the UI displays (menu items, shell commands, tool tooltips) dispatches its
//! command, or the item is visibly disabled with a reason — through the whole app, with a
//! realistic layered document, from the places focus usually is.
use egui::accesskit::Role;
use egui::{Key, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerContent;
use serde_json::json;
use super::{Focus, Outcome, bindings, set_dry_run, take_log};
use crate::PhotocraftApp;
use crate::shortcuts::parse;
use crate::state::Tool;
/// Background, a filled pixel layer "paint" (active, with a selection), a type layer and a group
/// holding a layer: what a real layout PSD looks like.
fn realistic() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 240, "height": 160})).unwrap();
s.execute("layer.new.layer", json!({"name": "in-group"})).unwrap();
s.execute("layer.groupLayers", json!({"name": "group"})).unwrap();
s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello"})).unwrap();
let paint = s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 60})).unwrap();
s.execute("edit.fill", json!({"color": "#3070c0"})).unwrap();
s.execute("layer.select", json!({"layer": paint})).unwrap();
s
}
fn services() -> crate::Services {
// Saving is available in the app (an exporter is installed); nothing is written here.
crate::Services { export: Some(Box::new(|_, _, _| Err("tests don't write files".into()))), ..Default::default() }
}
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(realistic(), services())
});
h.run_steps(8);
h
}
/// The platform's modifiers for a shortcut's: `Cmd` is ⌘ on the Mac and Ctrl elsewhere, as
/// egui-winit reports them (`command` plus the physical key).
fn platform(m: Modifiers) -> Modifiers {
let mut out = Modifiers { alt: m.alt, shift: m.shift, ctrl: m.ctrl, ..Default::default() };
if m.command {
out.command = true;
if cfg!(target_os = "macos") {
out.mac_cmd = true;
} else {
out.ctrl = true;
}
}
out
}
fn press(h: &mut Harness<'_, PhotocraftApp>, sc: &str) {
let sc = parse(sc).unwrap_or_else(|| panic!("unparsable shortcut {sc}"));
let m = platform(sc.modifiers);
h.event(egui::Event::ModifiersChanged(m));
h.event(egui::Event::Key { key: sc.logical_key, physical_key: None, pressed: true, repeat: false, modifiers: m });
h.event(egui::Event::Key { key: sc.logical_key, physical_key: None, pressed: false, repeat: false, modifiers: m });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn active_name(h: &Harness<'_, PhotocraftApp>) -> String {
let st = h.state().session.active().unwrap();
st.active_layer.and_then(|id| st.doc.layer(id)).map(|l| l.name.clone()).unwrap_or_default()
}
/// Where focus is when the shortcut is pressed.
#[derive(Clone, Copy, Debug)]
enum Place {
/// The pointer over the canvas, nothing focused.
Canvas,
/// Just after clicking the "paint" row in the Layers panel.
LayersRow,
/// Just after clicking into the Layers panel's Opacity field (a text field now).
OpacityField,
/// A non-text widget focused (Tab from nothing focuses the first one, as egui does).
FocusedWidget,
}
fn put_focus(h: &mut Harness<'_, PhotocraftApp>, place: Place) {
match place {
Place::Canvas => {
let c = h.state().last_canvas_rect.center();
h.hover_at(c);
h.run_steps(2);
}
Place::LayersRow => {
h.get_by_role_and_label(Role::Button, "paint").scroll_to_me();
h.run_steps(4);
let row = h.get_by_role_and_label(Role::Button, "paint").rect();
click(h, row.center() + vec2(row.width() / 4.0, 0.0));
assert_eq!(active_name(h), "paint");
}
Place::OpacityField => {
// The Layers panel's Opacity field: the DragValue in the right dock reading 100.
let at = h
.query_all_by_role(Role::SpinButton)
.map(|n| n.rect())
.filter(|r| r.center().x > 1100.0 && r.center().y > 500.0)
.min_by(|a, b| a.center().y.total_cmp(&b.center().y))
.expect("Layers opacity field")
.center();
click(h, at);
assert_eq!(Focus::of(&h.ctx), Focus::Text, "clicking a value field edits it");
}
Place::FocusedWidget => {
h.event(egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(Focus::of(&h.ctx), Focus::Widget, "Tab focused a widget");
}
}
take_log(&h.ctx);
}
/// Is `bound` the same action as the menu item `shown`? (Window › Layers is `window.panel.layers`
/// in the catalogue and `window.toggle.layers` in the shell.)
fn same(shown: &str, bound: &str) -> bool {
shown == bound || crate::menus::panel_alias(shown) == Some(bound) || crate::menus::panel_alias(bound) == Some(shown)
}
/// Every shortcut shown in the menus (and the shell's unlisted ones), with whether it is live.
fn displayed(app: &PhotocraftApp) -> Vec<(String, String, bool)> {
let mut v: Vec<(String, String, bool)> = crate::menus::menu_items(app)
.into_iter()
.filter(|i| i.id != "---")
.filter_map(|i| Some((i.id.clone(), i.shortcut?, crate::menus::is_live(&i.id))))
.collect();
for (id, _, menu, sc) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc
&& menu.is_empty()
{
v.push((id.to_string(), sc.to_string(), true));
}
}
v
}
#[test]
fn every_displayed_shortcut_is_bound_to_its_item() {
let h = harness();
let app = h.state();
let table = bindings(app);
let mut broken = Vec::new();
for (id, sc, live) in displayed(app) {
let parsed = parse(&sc).unwrap_or_else(|| panic!("{id}: unparsable {sc}"));
let owner = table.iter().find(|(_, b)| *b == parsed).map(|(o, _)| o.clone());
match owner {
Some(o) if same(&id, &o) => {}
_ if !live => {
// Not implemented yet: shown greyed (the parity report tracks these).
let item = crate::menus::menu_items(app).into_iter().find(|i| i.id == id);
assert!(item.is_none_or(|i| !i.enabled), "{id} isn't live but shows enabled");
}
o => broken.push(format!("{id} shows {sc} but it is bound to {o:?}")),
}
}
assert!(broken.is_empty(), "{} displayed shortcuts don't reach their item:\n{}", broken.len(), broken.join("\n"));
}
/// Press every displayed shortcut from each place focus usually is: each dispatches its command,
/// or the menu shows it disabled and the press reports why. (Dry run: File › Exit, Open, Help
/// links… resolve and check enablement without running.)
#[test]
fn every_displayed_shortcut_dispatches_from_where_focus_usually_is() {
let mut failures = Vec::new();
for place in [Place::Canvas, Place::LayersRow, Place::FocusedWidget, Place::OpacityField] {
let mut h = harness();
set_dry_run(&h.ctx, true);
put_focus(&mut h, place);
let focus = Focus::of(&h.ctx);
let items = crate::menus::menu_items(h.state());
for (id, sc, live) in displayed(h.state()) {
if !live {
continue;
}
let parsed = parse(&sc).unwrap();
press(&mut h, &sc);
let log = take_log(&h.ctx);
if !focus.allows(&parsed) {
// A focused text field keeps typing / editing keys: nothing may fire.
if !log.is_empty() {
failures.push(format!("{place:?}: {sc} ({id}) fired {log:?} into a focused field"));
}
continue;
}
let enabled = items.iter().find(|i| i.id == id).map_or_else(|| crate::menus::is_enabled(h.state(), &id), |i| i.enabled);
match log.as_slice() {
[(bound, Outcome::Ran)] if same(&id, bound) && enabled => {}
[(bound, Outcome::Disabled(why))] if same(&id, bound) && !enabled && !why.is_empty() => {}
other => failures.push(format!("{place:?}: {sc} ({id}, menu enabled: {enabled}) -> {other:?}")),
}
}
// Tool letters from the tools panel tooltips ("Brush Tool (B)").
if !matches!(focus, Focus::Text) {
for t in Tool::ALL.into_iter().filter(|t| t.key() != '\0') {
h.state_mut().ui.tool = if t.key() == 'H' { Tool::Move } else { Tool::Hand };
press(&mut h, &t.key().to_string());
if h.state().ui.tool.key() != t.key() {
failures.push(format!("{place:?}: tool key {} left {:?}", t.key(), h.state().ui.tool));
}
}
}
}
assert!(failures.is_empty(), "{} shortcut failures:\n{}", failures.len(), failures.join("\n"));
}
/// The common shortcuts really run (not a dry run) in the layered document after clicking around
/// the panels: the user report was "shortcut keys don't do anything".
#[test]
fn common_shortcuts_work_after_clicking_panels() {
for place in [Place::Canvas, Place::LayersRow, Place::FocusedWidget, Place::OpacityField] {
let mut h = harness();
put_focus(&mut h, place);
let count = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.walk().len();
let n = count(&h);
press(&mut h, "Cmd+J");
assert_eq!(count(&h), n + 1, "{place:?}: ⌘J duplicates the layer");
// In a focused text field ⌘Z / ⇧⌘Z undo the typing, as in Photoshop.
let text = matches!(place, Place::OpacityField);
if !text {
press(&mut h, "Cmd+Z");
assert_eq!(count(&h), n, "{place:?}: ⌘Z undoes");
press(&mut h, "Cmd+Shift+Z");
assert_eq!(count(&h), n + 1, "{place:?}: ⇧⌘Z redoes");
press(&mut h, "Cmd+Z");
}
press(&mut h, "Cmd+D");
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "{place:?}: ⌘D deselects");
press(&mut h, "Cmd+Shift+D");
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "{place:?}: ⇧⌘D reselects");
let z = h.state().current_zoom();
press(&mut h, "Cmd+=");
assert!(h.state().current_zoom() > z, "{place:?}: ⌘+ zooms in");
let rulers = h.state().ui.extras.rulers;
press(&mut h, "Cmd+R");
assert_ne!(h.state().ui.extras.rulers, rulers, "{place:?}: ⌘R toggles rulers");
let layers = h.state().ui.panels.layers;
press(&mut h, "F7");
assert_ne!(h.state().ui.panels.layers, layers, "{place:?}: F7 toggles Layers");
press(&mut h, "F7");
// Dialogs: Image Size (⌥⌘I, shown in the menu but unbound before #127) and Levels (⌘L).
for (sc, what) in [("Cmd+Alt+I", "Image Size"), ("Cmd+Alt+C", "Canvas Size"), ("Cmd+L", "Levels"), ("Cmd+M", "Curves")] {
press(&mut h, sc);
assert_eq!(h.state().ui.dialogs.len(), 1, "{place:?}: {sc} opens {what}");
h.state_mut().ui.dialogs.clear();
h.run_steps(2);
}
if !text {
press(&mut h, "B");
assert_eq!(h.state().ui.tool, Tool::Brush, "{place:?}: B picks the Brush");
press(&mut h, "V");
assert_eq!(h.state().ui.tool, Tool::Move, "{place:?}: V picks Move");
// The colour keys from the tools panel tooltips: X swaps, D resets.
let fg = h.state().session.tools.foreground;
press(&mut h, "X");
assert_eq!(h.state().session.tools.background, fg, "{place:?}: X swaps colours");
press(&mut h, "D");
assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0], "{place:?}: D resets colours");
}
press(&mut h, "Cmd+G");
let group = h.state().session.active().unwrap().active_layer.and_then(|id| h.state().session.active().unwrap().doc.layer(id).cloned());
assert!(group.is_some_and(|l| matches!(l.content, LayerContent::Group(_))), "{place:?}: ⌘G groups");
}
}
#[test]
fn a_disabled_shortcut_says_why() {
let mut h = harness();
put_focus(&mut h, Place::Canvas);
// Merge Down on the bottom layer: Photoshop greys it; the key press reports the reason.
let bg = h.state().session.active().unwrap().doc.layers[0].id;
h.state_mut().session.select_layer(bg).unwrap();
h.run_steps(2);
press(&mut h, "Cmd+E");
let log = take_log(&h.ctx);
assert!(matches!(log.as_slice(), [(id, Outcome::Disabled(_) | Outcome::Failed(_))] if id == "layer.mergeLayers"), "{log:?}");
assert!(!h.state().ui.status.is_empty() && h.state().ui.status_error, "the status bar says why");
}
+19 -41
View File
@@ -162,13 +162,25 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if app.distort.liquify.is_some() && !ctx.text_edit_focused() {
crate::liquify_ui::keys(app, ctx);
}
if ctx.egui_wants_keyboard_input() || !app.ui.dialogs.is_empty() || app.discard.is_some() {
// Dialogs and focused sliders keep canvas zoom; a focused text field keeps its keys.
if !ctx.text_edit_focused() {
use crate::shortcut_dispatch::{Focus, dispatch, pressed_command};
let focus = Focus::of(ctx);
// Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a
// focused text field keeps its keys.
let liquify_focus = app.distort.liquify.is_some() && focus != Focus::None;
if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() {
if focus != Focus::Text {
nav_keys(app, ctx);
}
return;
}
if focus == Focus::Text {
// A focused field keeps its typing and editing keys; menu shortcuts (⌘J, ⌘S, F7…) still
// fire, as in Photoshop.
if let Some(id) = pressed_command(app, ctx, focus, false) {
dispatch(app, ctx, &id);
}
return;
}
// Liquify / Puppet Warp / Perspective Warp: ↩ commits, Esc cancels.
if crate::distort_ui::keys(app, ctx) {
return;
@@ -197,44 +209,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
// Inline type editing eats text and navigation keys; ⌘-shortcuts still reach the menus.
let editing = crate::type_tool::handle_keys(app, ctx);
// Registry + UI command shortcuts, most-modifiers first so ⇧⌘Z wins over ⌘Z.
// Edit › Keyboard Shortcuts overrides replace the defaults (and can bind any menu item).
let prefs = app.session.prefs();
let mut all: Vec<(String, KeyboardShortcut)> = crate::menus::UI_COMMANDS
.iter()
.filter_map(|(id, _, _, sc)| Some((id.to_string(), parse(prefs.shortcut(id, *sc)?)?)))
.chain(photocraft_engine::command_specs().iter().filter_map(|c| Some((c.id.to_string(), parse(prefs.shortcut(c.id, c.shortcut)?)?))))
.chain(
prefs
.shortcuts
.iter()
.filter(|(id, sc)| {
!sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str())
})
.filter_map(|(id, sc)| Some((id.clone(), parse(sc)?))),
)
.filter(|(_, sc)| sc.modifiers != Modifiers::NONE || !matches!(sc.logical_key, Key::X | Key::D))
.collect();
all.sort_by_key(|(_, sc)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8));
for (id, sc) in all {
// While typing, clipboard/select-all shortcuts belong to the text, not the pixels.
if editing && matches!(id.as_str(), "edit.copy" | "edit.cut" | "edit.paste" | "edit.copyMerged" | "select.all" | "edit.pasteSpecial.pasteInPlace") {
continue;
}
if consume(ctx, &sc) {
if crate::menus::is_enabled(app, &id) {
let r = if crate::adjust_dialog::has_dialog(&id) {
crate::adjust_dialog::open(app, &id);
Ok(serde_json::Value::Null)
} else {
crate::menus::invoke(app, ctx, &id, json!({}))
};
if let Err(e) = r {
app.ui.status = e;
}
}
return;
}
// Registry, UI and menu-catalogue shortcuts (see [`crate::shortcut_dispatch::bindings`]).
if let Some(id) = pressed_command(app, ctx, focus, editing) {
dispatch(app, ctx, &id);
return;
}
if editing {
return;
+1
View File
@@ -61,6 +61,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
| `layer.select` | `{"layer":id,"mode":"replace\|toggle\|range\|add"}`: ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag |
| `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel |
| `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` |
| `layer.setExpanded` | `{"layer":id?,"expanded":bool?,"all":bool?}`: open/close a group in the Layers panel (no `expanded`: toggle; `all`: every group). A view change saved with the document (PSD open/closed folder), not an undo step; `document.inspect` reports `expanded` on groups |
| `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` |
| `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` |
| `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` |