mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 23:24:07 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc02ffe8ed | ||
|
|
ad3dc029f7 | ||
|
|
ef86fad78d |
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}` |
|
||||
|
||||
Reference in new issue
Block a user