diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d190d110b9..e2079c3005 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -890,7 +890,7 @@ fn ui_set_drives_the_layers_panel_artboard_filter() { assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard); let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}})); assert_eq!(bad["ok"], false, "{bad}"); - assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}"); + assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject, artboard or group"), "{bad}"); assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing"); } diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index a56c349833..1c439872a3 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -3,8 +3,8 @@ //! picks one effect kind (#10880), Mode picks one blend mode, Attribute one layer attribute or //! its opposite (#10881), Color one layer colour label (#5898), Smart Object toggles linked, //! missing and embedded smart objects, Artboard lists only the artboards that hold the selected -//! layers. The criteria live in `UiState` (`layer_filter*`), so the control channel can read and -//! set them. +//! layers, Group every group with its contents (#10883). The criteria live in `UiState` +//! (`layer_filter*`), so the control channel can read and set them. use std::collections::HashSet; @@ -28,6 +28,7 @@ pub enum FilterBy { Color, SmartObject, Artboard, + Group, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -46,7 +47,8 @@ pub struct Filter { /// The linked files the Smart Object filter's Missing toggle lists /// ([`photocraft_engine::Session::missing_links`]). pub missing: HashSet, - /// The rows the Artboard filter lists ([`board_rows`]); empty lists every row. + /// The rows the Artboard filter ([`board_rows`]) or the Group filter ([`group::listed`]) + /// lists; empty lists every row under Artboard and none under Group. pub board_rows: HashSet, /// The empty layers the Attribute filter's Empty and Not Empty use /// ([`attribute::empty_layers`]); `None` reads each layer's pixels. @@ -83,6 +85,9 @@ impl Filter { if f.by == FilterBy::Artboard { f.board_rows = board_rows(doc, selection); } + if f.by == FilterBy::Group { + f.board_rows = group::listed(doc); + } if f.by == FilterBy::Attribute { f.empty = attribute::empty_layers(app, f.attribute); } @@ -99,7 +104,7 @@ impl Filter { } /// Whether the panel is filtered. Like Photoshop, Effect, Mode, Attribute and Color always - /// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first); Kind, Name and + /// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first), and so does Group; Kind, Name and /// Smart Object only once a toggle is on or some text is typed; Artboard only while some layers /// are selected and each of them is in an artboard. pub fn active(&self) -> bool { @@ -109,9 +114,16 @@ impl Filter { FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true, FilterBy::SmartObject => !self.smart.is_empty(), FilterBy::Artboard => !self.board_rows.is_empty(), + FilterBy::Group => true, } } + /// Whether the panel lists the contents of closed groups: while it is filtered, except by + /// Group, which lists whole groups and keeps each one open or closed as it is (#10883). + pub fn show_all(&self) -> bool { + self.active() && self.by != FilterBy::Group + } + /// Whether `l` is listed under this filter. pub fn shows(&self, l: &Layer) -> bool { if !self.active() { @@ -133,6 +145,7 @@ impl Filter { }) } FilterBy::Artboard => self.board_rows.contains(&l.id), + FilterBy::Group => self.board_rows.contains(&l.id), } } } @@ -180,12 +193,12 @@ pub const SMART: [&str; 3] = ["linked", "missing", "embedded"]; /// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}}`: /// `current` with the given fields replaced, or an error naming the bad field (nothing changes -/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or -/// `artboard`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of +/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`, +/// `artboard` or `group`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of /// the layer blend modes such as `Multiply` (not Pass Through, which the Mode list doesn't offer, /// #10878); `attribute` an [`attribute::Attribute`] id; `color` a label colour id (`none`, `red`, /// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its -/// own: the selected layers pick the artboards. +/// own: the selected layers pick the artboards; nor has Group. pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) { @@ -194,7 +207,7 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result {} + // Group has no controls either: it lists every group ([`group::listed`]). + FilterBy::Group => {} } }); selection::follow(app, ui.ctx()); } pub mod effect; +mod group; pub mod selection; #[cfg(test)] @@ -369,3 +386,6 @@ mod tests; #[cfg(test)] mod kind_mode_tests; + +#[cfg(test)] +mod group_tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group.rs b/crates/ui-egui/src/panels/layer_filter_ui/group.rs new file mode 100644 index 0000000000..8e0e5ed571 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group.rs @@ -0,0 +1,22 @@ +//! The Layers panel's Group filter (#10883): every group with its whole subtree; layers in no +//! group are hidden. An artboard row is not a group here, but the groups inside it are. + +use std::collections::HashSet; + +use photocraft_doc::{Document, Layer, LayerContent, LayerId}; + +/// The ids of the layers the Group filter lists. The tree is walked once, with a stack rather +/// than recursion, so a deep document can't overflow it. +pub fn listed(doc: &Document) -> HashSet { + let mut out = HashSet::new(); + let mut pending: Vec<(&Layer, bool)> = doc.layers.iter().map(|l| (l, false)).collect(); + while let Some((l, inside)) = pending.pop() { + let artboard = matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some()); + let inside = inside || (l.is_group() && !artboard); + if inside { + out.insert(l.id); + } + pending.extend(l.children().unwrap_or_default().iter().map(|c| (c, inside))); + } + out +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs new file mode 100644 index 0000000000..c2f9ebb727 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs @@ -0,0 +1,144 @@ +//! #10883: the Layers panel's Group filter. + +use photocraft_engine::Session; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +fn new_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +/// The app with `s` open and the Layers panel filtered by Group. +fn app(s: Session) -> PhotocraftApp { + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter_by = FilterBy::Group; + app +} + +fn id(s: &Session, name: &str) -> u64 { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0).unwrap_or_else(|| panic!("no layer {name}")) +} + +fn select(s: &mut Session, name: &str) { + let layer = id(s, name); + s.execute("layer.select", json!({"layer": layer})).unwrap(); +} + +/// A new layer above the one selected (inside its group, if it is in one). +fn add(s: &mut Session, name: &str) { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); +} + +/// Groups the layer `name`. +fn group(s: &mut Session, name: &str, group_name: &str) { + select(s, name); + s.execute("layer.groupLayers", json!({"name": group_name})).unwrap(); +} + +fn set_expanded(s: &mut Session, name: &str, expanded: bool) { + let layer = id(s, name); + s.execute("layer.setExpanded", json!({"layer": layer, "expanded": expanded})).unwrap(); +} + +/// The rows the panel draws, top to bottom, two spaces per level: the rows and the filter the +/// Layers panel uses for the frame. +fn listed(app: &PhotocraftApp) -> Vec { + let doc = &app.session.active().unwrap().doc; + let f = Filter::of_app(app); + crate::layer_tree_ui::panel_rows(app, doc).into_iter().filter(|(_, l)| f.shows(l)).map(|(depth, l)| format!("{}{}", " ".repeat(depth), l.name)).collect() +} + +/// The selected layers' names. +fn selected(app: &PhotocraftApp) -> Vec { + let st = app.session.active().unwrap(); + st.selected_layers().iter().filter_map(|id| st.doc.layer(*id)).map(|l| l.name.clone()).collect() +} + +/// Top Pixel · Outer { Inner { Deep } } · Background. +fn nested() -> Session { + let mut s = new_session(); + add(&mut s, "Top Pixel"); + add(&mut s, "Deep"); + group(&mut s, "Deep", "Inner"); + group(&mut s, "Inner", "Outer"); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + s +} + +#[test] +fn lists_nested_groups_with_their_contents_and_hides_loose_layers() { + let app = app(nested()); + assert!(Filter::of_app(&app).active(), "Group filters at once"); + assert_eq!(listed(&app), ["Outer", " Inner", " Deep"]); +} + +#[test] +fn collapsed_groups_stay_collapsed() { + let mut app = app(nested()); + set_expanded(&mut app.session, "Inner", false); + assert_eq!(listed(&app), ["Outer", " Inner"], "a collapsed nested group hides its children"); + set_expanded(&mut app.session, "Outer", false); + assert_eq!(listed(&app), ["Outer"], "a collapsed group hides everything inside"); + set_expanded(&mut app.session, "Outer", true); + assert_eq!(listed(&app), ["Outer", " Inner"], "each group keeps its own state"); +} + +#[test] +fn an_artboard_is_not_listed_but_its_groups_are() { + let mut s = new_session(); + add(&mut s, "G child"); + group(&mut s, "G child", "Plain G"); + select(&mut s, "Background"); + add(&mut s, "A child"); + s.execute("layer.new.artboardFromLayers", json!({"name": "Artboard 1"})).unwrap(); + select(&mut s, "A child"); + add(&mut s, "GA child"); + group(&mut s, "GA child", "G in AB"); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + let mut app = app(s); + assert_eq!(listed(&app), [" G in AB", " GA child", "Plain G", " G child"]); + set_expanded(&mut app.session, "Artboard 1", false); + assert_eq!(listed(&app), ["Plain G", " G child"], "a collapsed artboard hides the groups inside it"); +} + +#[test] +fn the_list_does_not_depend_on_the_selection() { + let mut app = app(nested()); + select(&mut app.session, "Deep"); + let with_deep = listed(&app); + app.session.execute("select.deselectLayers", json!({})).unwrap(); + assert_eq!(listed(&app), with_deep, "same list with nothing selected"); +} + +#[test] +fn applying_it_keeps_a_selected_layer_in_a_group_and_deselects_a_loose_one() { + let mut app = app(nested()); + select(&mut app.session, "Deep"); + super::selection::deselect_hidden(&mut app); + assert_eq!(selected(&app), ["Deep"], "a listed layer stays selected"); + select(&mut app.session, "Top Pixel"); + super::selection::deselect_hidden(&mut app); + assert!(selected(&app).is_empty(), "a layer in no group is hidden, so it is deselected"); +} + +#[test] +fn group_is_set_by_name_through_the_control_channel() { + let ui = UiState::default(); + let f = super::patched(&ui, &json!({"by": "group"})).unwrap(); + assert_eq!(f.by, FilterBy::Group); + let err = super::patched(&ui, &json!({"by": "nope"})).unwrap_err(); + assert!(err.contains("group"), "the error lists group: {err}"); +} + +#[test] +fn lists_empty_groups_and_nothing_without_groups() { + let mut app = app(new_session()); + assert!(listed(&app).is_empty(), "no group: nothing is listed"); + app.session.execute("layer.new.group", json!({"name": "Empty Group"})).unwrap(); + assert_eq!(listed(&app), ["Empty Group"], "an empty group is listed as a group row"); +} diff --git a/crates/ui-egui/src/panels/layer_tree_ui.rs b/crates/ui-egui/src/panels/layer_tree_ui.rs index 0bd071c520..1d7586f4c6 100644 --- a/crates/ui-egui/src/panels/layer_tree_ui.rs +++ b/crates/ui-egui/src/panels/layer_tree_ui.rs @@ -40,7 +40,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> { /// Mask session's working text, which is no layer to the user: Photoshop shows only the mask /// overlay while you type (#3036). pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> { - let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).active()); + let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).show_all()); if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) { rows.retain(|(_, l)| l.id.0 != working); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6cf6bf3956..8abcbfe764 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -55,7 +55,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root -- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`, `artboard` or `group` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Group (#10883) has no field of its own either and filters at once: it lists every group that is not an artboard with everything inside it (the groups inside an artboard too), and hides the layers that are in no group; each group stays open or closed as it is. Fields left out keep their value; a bad one is an error and changes nothing. - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error.