mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 18:22:06 +01:00
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
6.4 KiB
Rust
159 lines
6.4 KiB
Rust
//! 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");
|
|
}
|
|
}
|