From 74ce42807ecfb0be135281331a09f915fe921dfd Mon Sep 17 00:00:00 2001 From: nurana23799 Date: Tue, 3 Nov 2026 20:03:17 +0400 Subject: [PATCH 1/3] Layers filter: Group filter (#10883) --- crates/ui-egui/src/panels/layer_filter_ui.rs | 36 +++++- .../src/panels/layer_filter_ui/group.rs | 25 ++++ .../src/panels/layer_filter_ui/group_tests.rs | 118 ++++++++++++++++++ crates/ui-egui/src/panels/layer_tree_ui.rs | 2 +- crates/ui-egui/src/panels/layers.rs | 3 +- docs/control-protocol.md | 3 +- 6 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/group.rs create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index fd035f3284..eaa086d29a 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -3,6 +3,8 @@ //! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel //! can read and set them. +use std::collections::HashSet; + use photocraft_color::BlendMode; use photocraft_doc::{Layer, LayerContent}; use serde::{Deserialize, Serialize}; @@ -18,6 +20,7 @@ pub enum FilterBy { Kind, Name, Mode, + Group, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -42,6 +45,7 @@ impl Filter { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), FilterBy::Mode => true, + FilterBy::Group => true, } } @@ -54,8 +58,26 @@ impl Filter { FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), FilterBy::Mode => l.blend == self.mode, + FilterBy::Group => true, } } + + pub fn show_all(&self) -> bool { + self.active() && self.by != FilterBy::Group + } + + /// The layers the Group filter lists, worked out once per frame (empty for other filters). + pub fn group_ids(&self, doc: &photocraft_doc::Document) -> HashSet { + if self.by == FilterBy::Group { group::listed(doc) } else { HashSet::new() } + } + + /// Like [`Filter::shows`], with the Group filter's precomputed ids from [`Filter::group_ids`]. + pub fn shows_in(&self, groups: &HashSet, l: &Layer) -> bool { + if self.by == FilterBy::Group { + return groups.contains(&l.id.0); + } + self.shows(l) + } } /// The kinds the Kind filter toggles. @@ -72,7 +94,7 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result &'static str { pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode")), (FilterBy::Group, tl!("Group"))]; widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); match app.ui.layer_filter_by { @@ -153,9 +175,19 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let width = ui.available_width().max(96.0); widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width); } + FilterBy::Group => {} } }); } +mod group; + #[cfg(test)] mod tests; +mod group; + +#[cfg(test)] +mod group_tests; + +#[cfg(test)] +mod tests; \ No newline at end of file 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..8af26b6ad5 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group.rs @@ -0,0 +1,25 @@ +//! 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}; + +/// The ids of the layers the Group filter lists. +pub fn listed(doc: &Document) -> HashSet { + fn rec(layers: &[Layer], inside: bool, out: &mut HashSet) { + for l in layers { + 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.0); + } + if let Some(children) = l.children() { + rec(children, inside, out); + } + } + } + let mut out = HashSet::new(); + rec(&doc.layers, false, &mut out); + out +} \ No newline at end of file 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..b37b534c68 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs @@ -0,0 +1,118 @@ +//! #10883: the Layers panel's Group filter. + +use photocraft_doc::Document; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::state::UiState; + +fn group_filter() -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Group, ..UiState::default() }) +} + +fn new_session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +fn id(doc: &Document, name: &str) -> u64 { + 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 photocraft_engine::Session, name: &str) { + let layer = id(&s.active().unwrap().doc, 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 photocraft_engine::Session, name: &str) { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); +} + +/// Groups the layer `name`. +fn group(s: &mut photocraft_engine::Session, name: &str, group_name: &str) { + select(s, name); + s.execute("layer.groupLayers", json!({"name": group_name})).unwrap(); +} + +fn set_expanded(s: &mut photocraft_engine::Session, name: &str, expanded: bool) { + let layer = id(&s.active().unwrap().doc, name); + s.execute("layer.setExpanded", json!({"layer": layer, "expanded": expanded})).unwrap(); +} + +/// The rows the panel draws under the Group filter, top to bottom, two spaces per level. +fn listed(doc: &Document) -> Vec { + let f = group_filter(); + let ids = f.group_ids(doc); + crate::layer_tree_ui::display_rows(doc, f.show_all()) + .into_iter() + .filter(|(_, l)| f.shows_in(&ids, l)) + .map(|(depth, l)| format!("{}{}", " ".repeat(depth), l.name)) + .collect() +} + +/// Top Pixel · Outer { Inner { Deep } } · Background. +fn nested() -> photocraft_engine::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 s = nested(); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer", " Inner", " Deep"]); +} + +#[test] +fn collapsed_groups_stay_collapsed() { + let mut s = nested(); + set_expanded(&mut s, "Inner", false); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer", " Inner"], "a collapsed nested group hides its children"); + set_expanded(&mut s, "Outer", false); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer"], "a collapsed group hides everything inside"); + set_expanded(&mut s, "Outer", true); + assert_eq!(listed(&s.active().unwrap().doc), ["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(); + assert_eq!(listed(&s.active().unwrap().doc), [" G in AB", " GA child", "Plain G", " G child"]); + set_expanded(&mut s, "Artboard 1", false); + assert_eq!(listed(&s.active().unwrap().doc), ["Plain G", " G child"], "a collapsed artboard hides the groups inside it"); +} + +#[test] +fn the_selection_does_not_change_the_list_or_the_selection() { + let mut s = nested(); + select(&mut s, "Deep"); + let doc_selected = listed(&s.active().unwrap().doc); + let before = s.active().unwrap().active_layer; + select(&mut s, "Top Pixel"); + assert_eq!(listed(&s.active().unwrap().doc), doc_selected, "same list whatever is selected"); + select(&mut s, "Deep"); + assert_eq!(s.active().unwrap().active_layer, before, "listing never selects or deselects"); +} + +#[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); + assert!(super::patched(&ui, &json!({"by": "nope"})).is_err()); +} \ No newline at end of file diff --git a/crates/ui-egui/src/panels/layer_tree_ui.rs b/crates/ui-egui/src/panels/layer_tree_ui.rs index 858fe432fc..32d401b3f1 100644 --- a/crates/ui-egui/src/panels/layer_tree_ui.rs +++ b/crates/ui-egui/src/panels/layer_tree_ui.rs @@ -39,7 +39,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/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 176ed271e1..da98160cc8 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -271,6 +271,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.request_repaint(); } let filter = crate::layer_filter_ui::Filter::of(&app.ui); + let group_ids = filter.group_ids(&doc); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last @@ -281,7 +282,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { continue; } - if !filter.shows(l) { + if !filter.shows_in(&group_ids, l) { continue; } let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7c5f4f2c24..5117c7f870 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,8 @@ 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?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (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; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. 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?, mode?}`, any subset. `by` is `kind`, `name`, `mode` 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; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. +Group lists every group that is not an artboard with everything inside it, and hides layers that are in no group; it has no further controls and filters at once, and each group keeps its own open or closed state. 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. From 3dbf843df08a81b9bc23bc4b78fb0593a958d165 Mon Sep 17 00:00:00 2001 From: nurana23799 Date: Tue, 3 Nov 2026 20:04:40 +0400 Subject: [PATCH 2/3] Test empty groups in Group filter --- .../src/panels/layer_filter_ui/group_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index b37b534c68..4dbc2a794a 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs @@ -115,4 +115,17 @@ fn group_is_set_by_name_through_the_control_channel() { let f = super::patched(&ui, &json!({"by": "group"})).unwrap(); assert_eq!(f.by, FilterBy::Group); assert!(super::patched(&ui, &json!({"by": "nope"})).is_err()); +} + +#[test] +fn lists_empty_groups() { + let mut s = new_session(); + + s.execute("layer.new.group", json!({"name": "Empty Group"})).unwrap(); + + assert_eq!( + listed(&s.active().unwrap().doc), + ["Empty Group"], + "empty groups should appear in the Group filter" + ); } \ No newline at end of file From 32ca273fe6d200e792ae13a0ceb4b664bc0dda6c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 15:49:53 +0100 Subject: [PATCH 3/3] PR #11092: the Artboard filter control test expects the error to list group Co-Authored-By: Nurana100 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"); }