From 30c8f8d5f0d8345f068bcb9a52b6176bacdf0726 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:47:45 +0100 Subject: [PATCH 1/6] Layers panel: filter by layer attribute (#10881) Attribute comes after Mode in the filter-type dropdown, with Photoshop's 18 entries: Visible, Locked, Empty, Linked, Clipped, Layer Mask, Vector Mask, Layer Effects and Advanced Blending, and their opposites. Advanced Blending covers all eight options (excluded channels, knockout, the four switches and Blend If); Fill opacity alone doesn't count. ui.set {layerFilter} takes the attribute by camelCase id. --- crates/automation/src/server.rs | 13 +- crates/ui-egui/src/panels/layer_filter_ui.rs | 40 ++- .../src/panels/layer_filter_ui/attribute.rs | 154 ++++++++++++ .../panels/layer_filter_ui/attribute_tests.rs | 228 ++++++++++++++++++ crates/ui-egui/src/state.rs | 4 + docs/control-protocol.md | 2 +- 6 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/attribute.rs create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b3bf9f3f11..bccf44d729 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -358,11 +358,14 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or - /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a - /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": - /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not - /// over this channel). updatesOpen (bool, the Help › Check for Updates window). + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "mode" + /// or "attribute"), kinds (a list of layer kinds), name (matches part of the layer name), mode + /// (a blend mode name) and attribute ("visible", "locked", "empty", "linked", "clipped", + /// "layerMask", "vectorMask", "layerEffects", "advancedBlending", or "notVisible" … + /// "noAdvancedBlending") for the Layers panel filter). strokeTrace (true, false, "stderr" or + /// "off": the stroke latency trace at runtime; a CSV file is set at launch with + /// --stroke-trace, not over this channel). updatesOpen (bool, the Help › Check for Updates + /// window). /// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel /// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b", /// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage", diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index fd035f3284..ae2e2359f8 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,7 +1,7 @@ //! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the //! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode -//! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel -//! can read and set them. +//! picks one blend mode, Attribute one layer attribute or its opposite (#10881). The criteria +//! live in `UiState` (`layer_filter*`), so the control channel can read and set them. use photocraft_color::BlendMode; use photocraft_doc::{Layer, LayerContent}; @@ -18,6 +18,7 @@ pub enum FilterBy { Kind, Name, Mode, + Attribute, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -28,20 +29,28 @@ pub struct Filter { /// As typed; matched trimmed and ignoring case. pub name: String, pub mode: BlendMode, + pub attribute: attribute::Attribute, } impl Filter { pub fn of(ui: &UiState) -> Self { - Self { by: ui.layer_filter_by, kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode } + Self { + by: ui.layer_filter_by, + kinds: ui.layer_filter.clone(), + name: ui.layer_filter_name.clone(), + mode: ui.layer_filter_mode, + attribute: ui.layer_filter_attribute, + } } - /// Whether the panel is filtered. Like Photoshop, Mode always filters (by Normal at first); + /// Whether the panel is filtered. Like Photoshop, Mode and Attribute always filter (by Normal + /// and by Visible at first); /// Kind and Name only once a kind is toggled or some text is typed. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Mode => true, + FilterBy::Mode | FilterBy::Attribute => true, } } @@ -54,6 +63,7 @@ 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::Attribute => self.attribute.shows(l), } } } @@ -66,13 +76,12 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; /// `kinds` a list of [`KINDS`]; `mode` a blend mode name such as `Multiply` or `Pass Through`. 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", "mode"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode", "attribute"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode, attribute)")); } - let mut f = - Filter { by: current.layer_filter_by, kinds: current.layer_filter.clone(), name: current.layer_filter_name.clone(), mode: current.layer_filter_mode }; + let mut f = Filter::of(current); if let Some(by) = o.get("by") { - f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name or mode".to_string())?; + f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, mode or attribute".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -95,6 +104,9 @@ 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::Attribute, tl!("Attribute"))]; 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 +166,14 @@ 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::Attribute => attribute::controls(app, ui), } }); } +pub mod attribute; + +#[cfg(test)] +mod attribute_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs new file mode 100644 index 0000000000..91d621eae8 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -0,0 +1,154 @@ +//! The Layers panel's Attribute filter (#10881): lists the layers with one attribute (visible, +//! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. + +use photocraft_doc::{AdvancedBlending, Layer, LayerContent}; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The attributes the Attribute filter picks from, in Photoshop's order: the nine attributes, +/// then their opposites. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Attribute { + #[default] + Visible, + Locked, + Empty, + Linked, + Clipped, + LayerMask, + VectorMask, + LayerEffects, + AdvancedBlending, + NotVisible, + NotLocked, + NotEmpty, + NotLinked, + NotClipped, + NoLayerMask, + NoVectorMask, + NoLayerEffects, + NoAdvancedBlending, +} + +impl Attribute { + pub const ALL: [Attribute; 18] = [ + Attribute::Visible, + Attribute::Locked, + Attribute::Empty, + Attribute::Linked, + Attribute::Clipped, + Attribute::LayerMask, + Attribute::VectorMask, + Attribute::LayerEffects, + Attribute::AdvancedBlending, + Attribute::NotVisible, + Attribute::NotLocked, + Attribute::NotEmpty, + Attribute::NotLinked, + Attribute::NotClipped, + Attribute::NoLayerMask, + Attribute::NoVectorMask, + Attribute::NoLayerEffects, + Attribute::NoAdvancedBlending, + ]; + + /// The id `ui.set {layerFilter: {attribute}}` takes. + pub fn id(self) -> &'static str { + match self { + Attribute::Visible => "visible", + Attribute::Locked => "locked", + Attribute::Empty => "empty", + Attribute::Linked => "linked", + Attribute::Clipped => "clipped", + Attribute::LayerMask => "layerMask", + Attribute::VectorMask => "vectorMask", + Attribute::LayerEffects => "layerEffects", + Attribute::AdvancedBlending => "advancedBlending", + Attribute::NotVisible => "notVisible", + Attribute::NotLocked => "notLocked", + Attribute::NotEmpty => "notEmpty", + Attribute::NotLinked => "notLinked", + Attribute::NotClipped => "notClipped", + Attribute::NoLayerMask => "noLayerMask", + Attribute::NoVectorMask => "noVectorMask", + Attribute::NoLayerEffects => "noLayerEffects", + Attribute::NoAdvancedBlending => "noAdvancedBlending", + } + } + + fn label(self) -> &'static str { + match self { + Attribute::Visible => tl!("Visible"), + Attribute::Locked => tl!("Locked"), + Attribute::Empty => tl!("Empty"), + Attribute::Linked => tl!("Linked"), + Attribute::Clipped => tl!("Clipped"), + Attribute::LayerMask => tl!("Layer Mask"), + Attribute::VectorMask => tl!("Vector Mask"), + Attribute::LayerEffects => tl!("Layer Effects"), + Attribute::AdvancedBlending => tl!("Advanced Blending"), + Attribute::NotVisible => tl!("Not Visible"), + Attribute::NotLocked => tl!("Not Locked"), + Attribute::NotEmpty => tl!("Not Empty"), + Attribute::NotLinked => tl!("Not Linked"), + Attribute::NotClipped => tl!("Not Clipped"), + Attribute::NoLayerMask => tl!("No Layer Mask"), + Attribute::NoVectorMask => tl!("No Vector Mask"), + Attribute::NoLayerEffects => tl!("No Layer Effects"), + Attribute::NoAdvancedBlending => tl!("No Adv. Blending"), + } + } + + /// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers + /// (and groups) its positive entry doesn't. + pub fn shows(self, l: &Layer) -> bool { + match self { + Attribute::Visible => l.visible, + // Any lock counts, the Background's too. + Attribute::Locked => l.locks.any(), + // A pixel layer with no pixels; an empty group isn't listed. + Attribute::Empty => matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty()), + Attribute::Linked => l.link_group.is_some(), + // The clipped layer, not its base. + Attribute::Clipped => l.clipped, + Attribute::LayerMask => l.mask.is_some(), + // A shape layer's outline is its vector mask. + Attribute::VectorMask => l.vector_mask.is_some() || matches!(l.content, LayerContent::Shape(_)), + // Also a layer whose only effect is turned off, unlike the Effect filter. + Attribute::LayerEffects => !l.effects.items.is_empty(), + Attribute::AdvancedBlending => advanced_blending(l), + Attribute::NotVisible => !Attribute::Visible.shows(l), + Attribute::NotLocked => !Attribute::Locked.shows(l), + Attribute::NotEmpty => !Attribute::Empty.shows(l), + Attribute::NotLinked => !Attribute::Linked.shows(l), + Attribute::NotClipped => !Attribute::Clipped.shows(l), + Attribute::NoLayerMask => !Attribute::LayerMask.shows(l), + Attribute::NoVectorMask => !Attribute::VectorMask.shows(l), + Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l), + Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l), + } + } +} + +/// Whether any Advanced Blending option is away from its default: a channel left out, Knockout, +/// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range. +/// Fill opacity alone doesn't count. +fn advanced_blending(l: &Layer) -> bool { + l.excluded_channels != 0 || l.advanced != AdvancedBlending::default() || !l.blend_if.is_default() +} + +/// `layerFilter.attribute`: the attribute named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || Attribute::ALL.map(Attribute::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.attribute must be one of {}", ids()))?; + Attribute::ALL.into_iter().find(|a| a.id() == name).ok_or_else(|| format!("layerFilter.attribute: unknown attribute `{name}` (attributes: {})", ids())) +} + +/// The Attribute filter's controls: the attribute dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let attributes: Vec<(Attribute, &str)> = Attribute::ALL.into_iter().map(|a| (a, a.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-attribute", tl!("Attribute"), &mut app.ui.layer_filter_attribute, &attributes, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs new file mode 100644 index 0000000000..0c42e73b35 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -0,0 +1,228 @@ +//! #10881: the Layers panel's Attribute filter, against the cases measured in Photoshop. + +use std::collections::BTreeSet; + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::{BlendRange, Document, Group, Knockout, Layer, LayerContent, LayerMask, VectorMask}; +use serde_json::json; + +use super::attribute::Attribute; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// One layer per measured case. Every pixel layer has pixels except "empty"; the adjustment, fill +/// and shape layers come from their commands, so they carry the masks those give them. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let pixel_layers = [ + "pixels", + "hidden", + "position lock", + "all locked", + "empty", + "linked a", + "linked b", + "base", + "masked", + "vector masked", + "fx", + "fx off", + "fill 50", + "adv channels", + "adv knockout", + "adv interior", + "adv clipped", + "adv shapes", + "adv layer mask", + "adv vector mask", + "adv blend if", + ]; + for name in pixel_layers { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + match name { + "base" => { + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + rename_active(&mut s, "clipped"); + s.execute("layer.createClippingMask", json!({})).unwrap(); + } + "fx" => { + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + } + "fx off" => { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "params": {"enabled": false}}]})).unwrap(); + } + _ => {} + } + } + s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); + rename_active(&mut s, "fill"); + s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + rename_active(&mut s, "shape"); + + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + let full = photocraft_geom::Rect::new(0, 0, 64, 48); + for name in pixel_layers.into_iter().filter(|n| *n != "empty") { + let l = layer(doc, name); + let LayerContent::Raster(surface) = &mut l.content else { panic!("{name} is a pixel layer") }; + surface.fill_rect(full, &RED); + } + layer(doc, "hidden").visible = false; + layer(doc, "position lock").locks.position = true; + layer(doc, "all locked").locks.all = true; + layer(doc, "linked a").link_group = Some(7); + layer(doc, "linked b").link_group = Some(7); + layer(doc, "masked").mask = Some(LayerMask::reveal_all()); + layer(doc, "vector masked").vector_mask = Some(VectorMask { path: Default::default(), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + layer(doc, "fill 50").fill_opacity = 0.5; + layer(doc, "adv channels").excluded_channels = 0b010; + layer(doc, "adv knockout").advanced.knockout = Knockout::Shallow; + layer(doc, "adv interior").advanced.blend_interior = true; + layer(doc, "adv clipped").advanced.blend_clipped = false; + layer(doc, "adv shapes").advanced.transparency_shapes = false; + layer(doc, "adv layer mask").advanced.layer_mask_hides_effects = true; + layer(doc, "adv vector mask").advanced.vector_mask_hides_effects = true; + layer(doc, "adv blend if").blend_if.ranges = vec![[BlendRange { black: [40, 40], white: [255, 255] }, BlendRange::FULL]]; + // A visible group holding a pixel layer, and an empty group. + let mut inside = Layer::new("inside", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut inside.content { + surface.fill_rect(full, &RED); + } + doc.layers.push(Layer::new("group", LayerContent::Group(Group { children: vec![inside], expanded: true, artboard: None, frame_stroke: None }))); + doc.layers.push(Layer::new("empty group", LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }))); + s +} + +fn layer<'a>(doc: &'a mut Document, name: &str) -> &'a mut Layer { + let id = doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}")); + doc.layer_mut(id).unwrap() +} + +fn rename_active(s: &mut photocraft_engine::Session, name: &str) { + let d = s.active_mut().unwrap(); + let id = d.active_layer.unwrap(); + std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().name = name.into(); +} + +/// The names `attribute` lists. +fn listed(doc: &Document, attribute: Attribute) -> BTreeSet { + let filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() }); + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn names(list: &[&str]) -> BTreeSet { + list.iter().map(|n| n.to_string()).collect() +} + +fn all(doc: &Document) -> BTreeSet { + doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect() +} + +#[test] +fn each_attribute_lists_the_measured_layers() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + let but = |gone: &[&str]| -> BTreeSet { everything.difference(&names(gone)).cloned().collect() }; + // Every visible layer and every visible group. + assert_eq!(listed(doc, Attribute::Visible), but(&["hidden"])); + // Any lock counts, the Background's too. + assert_eq!(listed(doc, Attribute::Locked), names(&["position lock", "all locked", "Background"])); + // A pixel layer without pixels; the empty group isn't listed. + assert_eq!(listed(doc, Attribute::Empty), names(&["empty"])); + assert_eq!(listed(doc, Attribute::Linked), names(&["linked a", "linked b"])); + // The clipped Levels layer, not its base. + assert_eq!(listed(doc, Attribute::Clipped), names(&["clipped"])); + // The adjustment and fill layers count with the masks they're created with. + assert_eq!(listed(doc, Attribute::LayerMask), names(&["masked", "clipped", "fill"])); + // A shape layer counts as having a vector mask. + assert_eq!(listed(doc, Attribute::VectorMask), names(&["vector masked", "shape"])); + // A layer whose only effect is turned off counts. + assert_eq!(listed(doc, Attribute::LayerEffects), names(&["fx", "fx off"])); + // Each Advanced Blending option on its own; Fill opacity alone doesn't count. + assert_eq!( + listed(doc, Attribute::AdvancedBlending), + names(&["adv channels", "adv knockout", "adv interior", "adv clipped", "adv shapes", "adv layer mask", "adv vector mask", "adv blend if"]) + ); +} + +#[test] +fn each_opposite_lists_the_rest_groups_included() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + for (positive, negative) in Attribute::ALL[..9].iter().zip(&Attribute::ALL[9..]) { + let rest: BTreeSet = everything.difference(&listed(doc, *positive)).cloned().collect(); + assert_eq!(listed(doc, *negative), rest, "{negative:?} lists what {positive:?} doesn't"); + } + // The three measured ones, spelled out. + assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden"])); + assert!(listed(doc, Attribute::NotLocked).contains("group"), "groups are listed too"); + assert!(!listed(doc, Attribute::NotLocked).contains("Background")); + assert_eq!(listed(doc, Attribute::NotLinked), everything.difference(&names(&["linked a", "linked b"])).cloned().collect()); +} + +#[test] +fn attribute_filters_at_once_with_visible() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, ..UiState::default() }); + assert_eq!(f.attribute, Attribute::Visible); + assert!(f.active(), "Attribute filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_attribute_by_id() { + let mut ui = UiState::default(); + for attribute in Attribute::ALL { + let f = super::patched(&ui, &json!({"by": "attribute", "attribute": attribute.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_attribute), (FilterBy::Attribute, attribute)); + } + assert_eq!(Attribute::NoAdvancedBlending.id(), "noAdvancedBlending"); + let err = super::patched(&ui, &json!({"attribute": "hidden"})).unwrap_err(); + assert!(err.contains("hidden") && err.contains("notVisible") && err.contains("noAdvancedBlending"), "{err}"); + assert!(super::patched(&ui, &json!({"attribute": 3})).is_err()); + assert!(super::patched(&ui, &json!({"attribute": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_attribute_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_attribute: Attribute::NotLinked, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_attribute"], "notLinked"); + saved.as_object_mut().unwrap().remove("layer_filter_attribute"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_attribute, Attribute::Visible); +} + +/// The names of the rows the Layers panel drew. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> BTreeSet { + let doc = &h.state().session.active().unwrap().doc; + let rows = crate::layer_row_ui::recorded(&h.ctx); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_attribute_picked_in_the_dropdown() { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).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.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Attribute").last().expect("Attribute in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Attribute); + assert!(!drawn(&h).contains("hidden"), "Visible at first"); + h.get_by_value("Visible").click(); + h.run_steps(4); + h.query_all_by_label("Not Visible").last().expect("Not Visible in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_attribute, Attribute::NotVisible); + assert_eq!(drawn(&h), names(&["hidden"])); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c18a418c4c..1fb9aa5ffa 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -364,6 +364,9 @@ pub struct UiState { /// Layers panel blend-mode filter. #[serde(default = "default_layer_filter_mode")] pub layer_filter_mode: photocraft_color::BlendMode, + /// Layers panel attribute filter (#10881). + #[serde(default)] + pub layer_filter_attribute: crate::layer_filter_ui::attribute::Attribute, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -575,6 +578,7 @@ impl Default for UiState { layer_filter_by: Default::default(), layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), + layer_filter_attribute: Default::default(), pen: None, pen_doc: None, stroke_editor: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e74938dc2f..cbaf46d217 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,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?, 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?, attribute?}`, any subset. `by` is `kind`, `name`, `mode` or `attribute` (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`); `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`. Kind and Name filter only once a kind is on or text is typed; Mode and Attribute always filter, by Normal and by Visible at first, as in Photoshop. 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 2fa8afc7fe8de5f458b4563742afc6c937dffa2d Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:09:02 +0100 Subject: [PATCH 2/6] fix(ui): the Attribute filter's Empty counts only visible pixels (#10881) The Eraser only clears alpha, so a layer erased completely keeps its colour bytes and content_bounds still found content. Empty now looks for a pixel with alpha > 0, as Reveal All does (#9379). --- .../src/panels/layer_filter_ui/attribute.rs | 18 +++++++++++++-- .../panels/layer_filter_ui/attribute_tests.rs | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs index 91d621eae8..b48bac2fa9 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -2,6 +2,7 @@ //! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. use photocraft_doc::{AdvancedBlending, Layer, LayerContent}; +use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; use crate::{PhotocraftApp, widgets}; @@ -108,8 +109,8 @@ impl Attribute { Attribute::Visible => l.visible, // Any lock counts, the Background's too. Attribute::Locked => l.locks.any(), - // A pixel layer with no pixels; an empty group isn't listed. - Attribute::Empty => matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty()), + // A pixel layer with no visible pixels; an empty group isn't listed. + Attribute::Empty => matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s)), Attribute::Linked => l.link_group.is_some(), // The clipped layer, not its base. Attribute::Clipped => l.clipped, @@ -132,6 +133,19 @@ impl Attribute { } } +/// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps +/// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes, +/// #9379). Without an alpha channel, or over an opaque default, any content counts. +fn has_visible_pixels(s: &Surface) -> bool { + let fmt = s.format(); + if !fmt.alpha || s.default_pixel().last().is_none_or(|a| *a > 0.0) { + return !s.content_bounds().is_empty(); + } + let (n, bpp) = (fmt.channels(), fmt.bytes_per_pixel()); + let Some(alpha) = n.checked_sub(1).filter(|_| bpp > 0) else { return false }; + s.tiles().any(|(_, t)| t.bytes().chunks_exact(bpp).any(|px| photocraft_color::read_sample(px, fmt.sample, alpha) > 0.0)) +} + /// Whether any Advanced Blending option is away from its default: a channel left out, Knockout, /// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range. /// Fill opacity alone doesn't count. diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs index 0c42e73b35..21196237a3 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -151,6 +151,28 @@ fn each_attribute_lists_the_measured_layers() { ); } +#[test] +fn a_layer_erased_completely_is_empty() { + // The Eraser only clears alpha, so the erased pixels keep their colour bytes. + for depth in [8, 16, 32] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let name = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).next().unwrap(); + let erase = |s: &mut photocraft_engine::Session, x: i32| { + s.execute("paint.stroke", json!({"points": [[x, -10], [x, 40]], "size": 30, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + }; + erase(&mut s, 10); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[]), "{depth}: partly erased"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[&name]), "{depth}"); + erase(&mut s, 30); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[&name]), "{depth}: erased completely"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[]), "{depth}"); + } +} + #[test] fn each_opposite_lists_the_rest_groups_included() { let s = session(); From 69fe389555761dc5230e9374a883f7033fb36c2d Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:42:47 +0100 Subject: [PATCH 3/6] fix(ui): Empty counts an opaque default pixel and is found once per revision (#10881) A pixel layer whose default pixel is opaque covers the canvas without stored tiles, so it is not Empty. The empty layers are now found once per document revision (Filter::of_app, like Smart Object's missing links) instead of reading every pixel of an erased layer on each repaint of the Layers panel. --- crates/ui-egui/src/panels/layer_filter_ui.rs | 12 +++- .../src/panels/layer_filter_ui/attribute.rs | 65 ++++++++++++++----- .../panels/layer_filter_ui/attribute_tests.rs | 34 ++++++++++ 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index e6fb9224a1..1436d554b5 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -45,6 +45,9 @@ pub struct Filter { pub missing: HashSet, /// The rows the Artboard filter lists ([`board_rows`]); empty lists every row. 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. + pub empty: Option>>, } impl Filter { @@ -59,12 +62,14 @@ impl Filter { smart: ui.layer_filter_smart.clone(), missing: HashSet::new(), board_rows: HashSet::new(), + empty: None, } } /// Returns [`Filter::of`] for `app`'s settings, with the active document's missing linked /// files while the panel filters by Smart Object, and `board_rows` set to the rows of the - /// artboards that hold the active document's selected layers while it filters by Artboard. + /// artboards that hold the active document's selected layers while it filters by Artboard, + /// and `empty` set while it filters by Empty or Not Empty. pub fn of_app(app: &PhotocraftApp) -> Self { let mut f = Self::of(&app.ui); if f.by == FilterBy::SmartObject { @@ -75,6 +80,9 @@ impl Filter { { f.board_rows = board_rows(&d.doc, &d.selected_layers()); } + if f.by == FilterBy::Attribute { + f.empty = attribute::empty_layers(app, f.attribute); + } f } @@ -101,7 +109,7 @@ 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::Attribute => self.attribute.shows(l), + FilterBy::Attribute => self.attribute.shows(l, self.empty.as_deref()), FilterBy::Color => l.label == self.color, FilterBy::SmartObject => { let LayerContent::Smart(sm) = &l.content else { return false }; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs index b48bac2fa9..d35f9d3f57 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -1,7 +1,10 @@ //! The Layers panel's Attribute filter (#10881): lists the layers with one attribute (visible, //! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. -use photocraft_doc::{AdvancedBlending, Layer, LayerContent}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError}; + +use photocraft_doc::{AdvancedBlending, DocId, Layer, LayerContent, LayerId}; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; @@ -103,14 +106,15 @@ impl Attribute { } /// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers - /// (and groups) its positive entry doesn't. - pub fn shows(self, l: &Layer) -> bool { + /// (and groups) its positive entry doesn't. `empty` is the document's empty layers + /// ([`empty_layers`]); without it, Empty reads `l`'s pixels. + pub fn shows(self, l: &Layer, empty: Option<&HashSet>) -> bool { match self { Attribute::Visible => l.visible, // Any lock counts, the Background's too. Attribute::Locked => l.locks.any(), // A pixel layer with no visible pixels; an empty group isn't listed. - Attribute::Empty => matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s)), + Attribute::Empty => empty.map_or_else(|| is_empty(l), |set| set.contains(&l.id)), Attribute::Linked => l.link_group.is_some(), // The clipped layer, not its base. Attribute::Clipped => l.clipped, @@ -120,32 +124,63 @@ impl Attribute { // Also a layer whose only effect is turned off, unlike the Effect filter. Attribute::LayerEffects => !l.effects.items.is_empty(), Attribute::AdvancedBlending => advanced_blending(l), - Attribute::NotVisible => !Attribute::Visible.shows(l), - Attribute::NotLocked => !Attribute::Locked.shows(l), - Attribute::NotEmpty => !Attribute::Empty.shows(l), - Attribute::NotLinked => !Attribute::Linked.shows(l), - Attribute::NotClipped => !Attribute::Clipped.shows(l), - Attribute::NoLayerMask => !Attribute::LayerMask.shows(l), - Attribute::NoVectorMask => !Attribute::VectorMask.shows(l), - Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l), - Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l), + Attribute::NotVisible => !Attribute::Visible.shows(l, empty), + Attribute::NotLocked => !Attribute::Locked.shows(l, empty), + Attribute::NotEmpty => !Attribute::Empty.shows(l, empty), + Attribute::NotLinked => !Attribute::Linked.shows(l, empty), + Attribute::NotClipped => !Attribute::Clipped.shows(l, empty), + Attribute::NoLayerMask => !Attribute::LayerMask.shows(l, empty), + Attribute::NoVectorMask => !Attribute::VectorMask.shows(l, empty), + Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l, empty), + Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l, empty), } } } +/// Whether `l` is a pixel layer with no visible pixels; an empty group isn't listed. +fn is_empty(l: &Layer) -> bool { + matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s)) +} + /// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps /// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes, -/// #9379). Without an alpha channel, or over an opaque default, any content counts. +/// #9379). A surface without an alpha channel, or with an opaque default pixel, covers the +/// whole canvas. fn has_visible_pixels(s: &Surface) -> bool { let fmt = s.format(); if !fmt.alpha || s.default_pixel().last().is_none_or(|a| *a > 0.0) { - return !s.content_bounds().is_empty(); + return true; } let (n, bpp) = (fmt.channels(), fmt.bytes_per_pixel()); let Some(alpha) = n.checked_sub(1).filter(|_| bpp > 0) else { return false }; s.tiles().any(|(_, t)| t.bytes().chunks_exact(bpp).any(|px| photocraft_color::read_sample(px, fmt.sample, alpha) > 0.0)) } +/// The document, revision and document allocation [`empty_layers`] was found for. +type EmptyKey = (DocId, u64, usize); + +static EMPTY: Mutex>)>> = Mutex::new(None); + +/// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per +/// document revision: a layer erased to nothing has every pixel read, too much for every repaint +/// of the Layers panel. +pub fn empty_layers(app: &PhotocraftApp, attribute: Attribute) -> Option>> { + if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) { + return None; + } + let d = app.session.active()?; + let key = (d.doc.id, d.revision, Arc::as_ptr(&d.doc) as usize); + let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((k, set)) = cache.as_ref() + && *k == key + { + return Some(set.clone()); + } + let set: Arc> = Arc::new(d.doc.walk().into_iter().filter(|(_, _, l)| is_empty(l)).map(|(_, _, l)| l.id).collect()); + *cache = Some((key, set.clone())); + Some(set) +} + /// Whether any Advanced Blending option is away from its default: a channel left out, Knockout, /// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range. /// Fill opacity alone doesn't count. diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs index 21196237a3..328fc2f7b3 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -173,6 +173,40 @@ fn a_layer_erased_completely_is_empty() { } } +#[test] +fn a_layer_with_an_opaque_default_pixel_is_not_empty() { + // No stored tiles, but the default pixel covers the whole canvas. + let mut doc = Document::new("Opaque default", photocraft_geom::Size::new(40, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let opaque = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + doc.layers.push(Layer::new("opaque", LayerContent::Raster(opaque))); + doc.layers.push(Layer::new("clear", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8)))); + assert_eq!(listed(&doc, Attribute::Empty), names(&["clear"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["opaque"])); +} + +#[test] +fn the_empty_layers_are_found_once_per_revision() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter_by = FilterBy::Attribute; + app.ui.layer_filter_attribute = Attribute::Empty; + let first = Filter::of_app(&app).empty.expect("found for Empty"); + assert!(first.is_empty(), "the filled layer has pixels"); + let again = Filter::of_app(&app).empty.unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same revision reuses the result"); + app.session.execute("paint.stroke", json!({"points": [[20, -10], [20, 40]], "size": 60, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + let after = Filter::of_app(&app); + assert_eq!(after.empty.as_ref().map(|set| set.len()), Some(1), "an edit finds them again"); + let doc = &app.session.active().unwrap().doc; + assert!(doc.walk().into_iter().all(|(_, _, l)| after.shows(l)), "the erased layer is listed"); + app.ui.layer_filter_attribute = Attribute::NotEmpty; + assert!(Filter::of_app(&app).empty.is_some(), "Not Empty uses them too"); + app.ui.layer_filter_attribute = Attribute::Visible; + assert!(Filter::of_app(&app).empty.is_none(), "other attributes don't read pixels"); +} + #[test] fn each_opposite_lists_the_rest_groups_included() { let s = session(); From cf1ca93e75577c0e2068c565f3db41c7c01c437c Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:49:44 +0100 Subject: [PATCH 4/6] perf(ui): the Empty scan reads alpha bytes in parallel (#10881) Integer alpha is above 0 where any of its bytes is, so blocks of pixels are OR-ed together instead of decoding each sample, and tiles are scanned in parallel. Three completely erased 36 MP layers (release): 87 ms before, 11 ms after. --- .../src/panels/layer_filter_ui/attribute.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs index d35f9d3f57..9d1ea5d86a 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -4,8 +4,10 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex, PoisonError}; +use photocraft_color::SampleType; use photocraft_doc::{AdvancedBlending, DocId, Layer, LayerContent, LayerId}; use photocraft_raster::Surface; +use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::{PhotocraftApp, widgets}; @@ -151,9 +153,18 @@ fn has_visible_pixels(s: &Surface) -> bool { if !fmt.alpha || s.default_pixel().last().is_none_or(|a| *a > 0.0) { return true; } - let (n, bpp) = (fmt.channels(), fmt.bytes_per_pixel()); - let Some(alpha) = n.checked_sub(1).filter(|_| bpp > 0) else { return false }; - s.tiles().any(|(_, t)| t.bytes().chunks_exact(bpp).any(|px| photocraft_color::read_sample(px, fmt.sample, alpha) > 0.0)) + let (n, bpp, size) = (fmt.channels(), fmt.bytes_per_pixel(), fmt.sample.bytes()); + let Some(at) = n.checked_sub(1).and_then(|i| i.checked_mul(size)).filter(|_| bpp > 0) else { return false }; + // Integer alpha is above 0 where any of its bytes is, so a block of pixels is OR-ed together + // (which vectorises) before looking for one; only float alpha needs decoding. + let visible = |block: &[u8]| match fmt.sample { + SampleType::F32 => block.chunks_exact(bpp).any(|px| px.get(at..at + size).is_some_and(|a| a.try_into().is_ok_and(|b| f32::from_ne_bytes(b) > 0.0))), + SampleType::U8 | SampleType::U16 => { + block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0 + } + }; + let tiles: Vec<_> = s.tiles().map(|(_, t)| t).collect(); + tiles.par_iter().any(|t| t.bytes().chunks(bpp.saturating_mul(1024)).any(visible)) } /// The document, revision and document allocation [`empty_layers`] was found for. From f874701bd905d9d17ffe26603ec39acc6527032b Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:13:20 +0100 Subject: [PATCH 5/6] fix(ui): the Empty scan runs on one thread in the browser (#10881) rayon is a desktop-only dependency of photocraft-ui-egui, so the wasm build failed. The browser scans the tiles one after another. --- .../ui-egui/src/panels/layer_filter_ui/attribute.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs index 9d1ea5d86a..4bbb5fa94b 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -7,7 +7,6 @@ use std::sync::{Arc, Mutex, PoisonError}; use photocraft_color::SampleType; use photocraft_doc::{AdvancedBlending, DocId, Layer, LayerContent, LayerId}; use photocraft_raster::Surface; -use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::{PhotocraftApp, widgets}; @@ -163,8 +162,15 @@ fn has_visible_pixels(s: &Surface) -> bool { block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0 } }; - let tiles: Vec<_> = s.tiles().map(|(_, t)| t).collect(); - tiles.par_iter().any(|t| t.bytes().chunks(bpp.saturating_mul(1024)).any(visible)) + let tile_visible = |t: &Arc| t.bytes().chunks(bpp.saturating_mul(1024)).any(visible); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + let tiles: Vec<_> = s.tiles().map(|(_, t)| t).collect(); + tiles.par_iter().any(|t| tile_visible(t)) + } + #[cfg(target_arch = "wasm32")] + s.tiles().any(|(_, t)| tile_visible(t)) } /// The document, revision and document allocation [`empty_layers`] was found for. From f44510490f98e13ca03660b4d19d5239655bff1c Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:46:55 +0100 Subject: [PATCH 6/6] fix(ui): Empty checks an opaque default against the canvas, cached per document (#10881) A layer with an opaque default pixel is empty when its stored tiles cover the canvas and are clear on it; parts of the canvas without a tile show the default. The empty layers are cached per document (a Weak to it) instead of per revision, which a selection moves too. Tests: an opaque default erased over the canvas, a selection and a second document, a hidden group with a visible child. --- .../src/panels/layer_filter_ui/attribute.rs | 88 +++++++++++++------ .../panels/layer_filter_ui/attribute_tests.rs | 49 +++++++++-- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs index 4bbb5fa94b..e627a9d8a8 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -2,10 +2,11 @@ //! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. use std::collections::HashSet; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; use photocraft_color::SampleType; -use photocraft_doc::{AdvancedBlending, DocId, Layer, LayerContent, LayerId}; +use photocraft_doc::{AdvancedBlending, Document, Layer, LayerContent, LayerId}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; @@ -108,14 +109,15 @@ impl Attribute { /// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers /// (and groups) its positive entry doesn't. `empty` is the document's empty layers - /// ([`empty_layers`]); without it, Empty reads `l`'s pixels. + /// ([`empty_layers_of`]); without it, Empty reads `l`'s pixels without knowing the canvas, so a + /// layer with an opaque default pixel is never empty. pub fn shows(self, l: &Layer, empty: Option<&HashSet>) -> bool { match self { Attribute::Visible => l.visible, // Any lock counts, the Background's too. Attribute::Locked => l.locks.any(), // A pixel layer with no visible pixels; an empty group isn't listed. - Attribute::Empty => empty.map_or_else(|| is_empty(l), |set| set.contains(&l.id)), + Attribute::Empty => empty.map_or_else(|| is_empty(l, None), |set| set.contains(&l.id)), Attribute::Linked => l.link_group.is_some(), // The clipped layer, not its base. Attribute::Clipped => l.clipped, @@ -138,20 +140,27 @@ impl Attribute { } } -/// Whether `l` is a pixel layer with no visible pixels; an empty group isn't listed. -fn is_empty(l: &Layer) -> bool { - matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s)) +/// Whether `l` is a pixel layer with no visible pixels on `canvas`; an empty group isn't listed. +fn is_empty(l: &Layer, canvas: Option) -> bool { + matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s, canvas)) } /// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps /// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes, -/// #9379). A surface without an alpha channel, or with an opaque default pixel, covers the -/// whole canvas. -fn has_visible_pixels(s: &Surface) -> bool { +/// #9379). A surface without an alpha channel covers everything. With an opaque default pixel, +/// every part of `canvas` without a stored tile shows it, so only stored tiles that cover the +/// canvas and are clear on it make the layer empty (pixels outside the canvas are left out there, +/// as the default reaches beyond it anyway); without a `canvas` such a layer always counts. +fn has_visible_pixels(s: &Surface, canvas: Option) -> bool { let fmt = s.format(); - if !fmt.alpha || s.default_pixel().last().is_none_or(|a| *a > 0.0) { + if !fmt.alpha { return true; } + let clip = match (s.default_pixel().last().is_none_or(|a| *a > 0.0), canvas) { + (false, _) => None, + (true, Some(canvas)) if covers(s, canvas) => Some(canvas), + (true, _) => return true, + }; let (n, bpp, size) = (fmt.channels(), fmt.bytes_per_pixel(), fmt.sample.bytes()); let Some(at) = n.checked_sub(1).and_then(|i| i.checked_mul(size)).filter(|_| bpp > 0) else { return false }; // Integer alpha is above 0 where any of its bytes is, so a block of pixels is OR-ed together @@ -162,39 +171,68 @@ fn has_visible_pixels(s: &Surface) -> bool { block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0 } }; - let tile_visible = |t: &Arc| t.bytes().chunks(bpp.saturating_mul(1024)).any(visible); + let tile_visible = |c: &TileCoord, t: &Arc| { + let bytes = t.bytes(); + let r = c.rect(); + let Some(clip) = clip.filter(|clip| !clip.contains_rect(&r)) else { return bytes.chunks(bpp.saturating_mul(1024)).any(visible) }; + // Only the part of the tile on the canvas, row by row. + let on = r.intersect(&clip); + let row = (TILE_SIZE as usize).saturating_mul(bpp); + let (x0, x1) = ((on.x0 - r.x0) as usize * bpp, (on.x1 - r.x0) as usize * bpp); + (on.y0 - r.y0..on.y1 - r.y0).any(|y| bytes.get(y as usize * row + x0..y as usize * row + x1).is_some_and(visible)) + }; #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; - let tiles: Vec<_> = s.tiles().map(|(_, t)| t).collect(); - tiles.par_iter().any(|t| tile_visible(t)) + let tiles: Vec<_> = s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).collect(); + tiles.par_iter().any(|(c, t)| tile_visible(c, t)) } #[cfg(target_arch = "wasm32")] - s.tiles().any(|(_, t)| tile_visible(t)) + s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).any(|(c, t)| tile_visible(c, t)) } -/// The document, revision and document allocation [`empty_layers`] was found for. -type EmptyKey = (DocId, u64, usize); +/// Whether `s` stores a tile for every part of `canvas`. +fn covers(s: &Surface, canvas: Rect) -> bool { + if canvas.is_empty() { + return true; + } + let (first, last) = (TileCoord::containing(canvas.x0, canvas.y0), TileCoord::containing(canvas.x1 - 1, canvas.y1 - 1)); + let needed = (i64::from(last.tx) - i64::from(first.tx) + 1) * (i64::from(last.ty) - i64::from(first.ty) + 1); + let stored = s.tiles().filter(|(c, _)| (first.tx..=last.tx).contains(&c.tx) && (first.ty..=last.ty).contains(&c.ty)).count(); + i64::try_from(stored).is_ok_and(|stored| stored == needed) +} -static EMPTY: Mutex>)>> = Mutex::new(None); +/// The empty pixel layers of `doc`. +pub fn empty_layers_of(doc: &Document) -> HashSet { + let canvas = Some(doc.bounds()); + doc.walk().into_iter().filter(|(_, _, l)| is_empty(l, canvas)).map(|(_, _, l)| l.id).collect() +} + +/// The document [`empty_layers`] last read and what it found. A `Weak` keeps the document's +/// allocation from being reused while it is held, and makes `Arc::make_mut` copy the document on +/// its next edit even when nothing else holds it, so the same allocation always means the same +/// layers and pixels. +static EMPTY: Mutex> = Mutex::new(None); + +type EmptyCache = (Weak, Arc>); /// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per -/// document revision: a layer erased to nothing has every pixel read, too much for every repaint -/// of the Layers panel. +/// document: a layer erased to nothing has every pixel read, too much for every repaint of the +/// Layers panel. Keyed by the document itself rather than its revision, which also moves when only +/// the selection does. pub fn empty_layers(app: &PhotocraftApp, attribute: Attribute) -> Option>> { if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) { return None; } let d = app.session.active()?; - let key = (d.doc.id, d.revision, Arc::as_ptr(&d.doc) as usize); let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner); - if let Some((k, set)) = cache.as_ref() - && *k == key + if let Some((doc, set)) = cache.as_ref() + && std::ptr::eq(doc.as_ptr(), Arc::as_ptr(&d.doc)) { return Some(set.clone()); } - let set: Arc> = Arc::new(d.doc.walk().into_iter().filter(|(_, _, l)| is_empty(l)).map(|(_, _, l)| l.id).collect()); - *cache = Some((key, set.clone())); + let set = Arc::new(empty_layers_of(&d.doc)); + *cache = Some((Arc::downgrade(&d.doc), set.clone())); Some(set) } diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs index 328fc2f7b3..fad5460990 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -95,6 +95,14 @@ fn session() -> photocraft_engine::Session { } doc.layers.push(Layer::new("group", LayerContent::Group(Group { children: vec![inside], expanded: true, artboard: None, frame_stroke: None }))); doc.layers.push(Layer::new("empty group", LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }))); + // A hidden group whose child is visible: Visible goes by the layer's own flag. + let mut child = Layer::new("in hidden group", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut child.content { + surface.fill_rect(full, &RED); + } + let mut hidden_group = Layer::new("hidden group", LayerContent::Group(Group { children: vec![child], expanded: true, artboard: None, frame_stroke: None })); + hidden_group.visible = false; + doc.layers.push(hidden_group); s } @@ -111,7 +119,9 @@ fn rename_active(s: &mut photocraft_engine::Session, name: &str) { /// The names `attribute` lists. fn listed(doc: &Document, attribute: Attribute) -> BTreeSet { - let filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() }); + let mut filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() }); + // As the panel does ([`Filter::of_app`]): Empty knows the canvas. + filter.empty = Some(std::sync::Arc::new(super::attribute::empty_layers_of(doc))); crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() } @@ -130,7 +140,8 @@ fn each_attribute_lists_the_measured_layers() { let everything = all(doc); let but = |gone: &[&str]| -> BTreeSet { everything.difference(&names(gone)).cloned().collect() }; // Every visible layer and every visible group. - assert_eq!(listed(doc, Attribute::Visible), but(&["hidden"])); + assert_eq!(listed(doc, Attribute::Visible), but(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::Visible).contains("in hidden group"), "a visible child of a hidden group"); // Any lock counts, the Background's too. assert_eq!(listed(doc, Attribute::Locked), names(&["position lock", "all locked", "Background"])); // A pixel layer without pixels; the empty group isn't listed. @@ -185,7 +196,26 @@ fn a_layer_with_an_opaque_default_pixel_is_not_empty() { } #[test] -fn the_empty_layers_are_found_once_per_revision() { +fn an_opaque_default_layer_erased_on_the_whole_canvas_is_empty() { + // 300 px wide: the second column of tiles reaches past the canvas, where the default stays. + let mut doc = Document::new("Opaque erased", photocraft_geom::Size::new(300, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let clear = [1.0, 0.0, 0.0, 0.0]; + for (name, erased) in [("erased", photocraft_geom::Rect::new(0, 0, 300, 30)), ("half erased", photocraft_geom::Rect::new(0, 0, 200, 30))] { + let mut s = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + s.fill_rect(erased, &clear); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + let mut dot = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + dot.fill_rect(photocraft_geom::Rect::new(0, 0, 300, 30), &clear); + dot.fill_rect(photocraft_geom::Rect::new(299, 29, 300, 30), &RED); + doc.layers.push(Layer::new("one pixel", LayerContent::Raster(dot))); + // The half erased layer has no tile over x 256..300, so the default shows there. + assert_eq!(listed(&doc, Attribute::Empty), names(&["erased"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["half erased", "one pixel"])); +} + +#[test] +fn the_empty_layers_are_found_once_per_document() { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); @@ -195,7 +225,11 @@ fn the_empty_layers_are_found_once_per_revision() { let first = Filter::of_app(&app).empty.expect("found for Empty"); assert!(first.is_empty(), "the filled layer has pixels"); let again = Filter::of_app(&app).empty.unwrap(); - assert!(std::sync::Arc::ptr_eq(&first, &again), "the same revision reuses the result"); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same document reuses the result"); + // Selecting a layer moves the revision but changes no pixels. + let id = app.session.active().unwrap().doc.layers[0].id; + app.session.select_layer(id).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &Filter::of_app(&app).empty.unwrap()), "a selection reuses the result"); app.session.execute("paint.stroke", json!({"points": [[20, -10], [20, 40]], "size": 60, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); let after = Filter::of_app(&app); assert_eq!(after.empty.as_ref().map(|set| set.len()), Some(1), "an edit finds them again"); @@ -203,6 +237,9 @@ fn the_empty_layers_are_found_once_per_revision() { assert!(doc.walk().into_iter().all(|(_, _, l)| after.shows(l)), "the erased layer is listed"); app.ui.layer_filter_attribute = Attribute::NotEmpty; assert!(Filter::of_app(&app).empty.is_some(), "Not Empty uses them too"); + // Another document, with no empty layer, gets its own result. + app.session.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + assert_eq!(Filter::of_app(&app).empty.map(|set| set.len()), Some(0), "the new document's layers"); app.ui.layer_filter_attribute = Attribute::Visible; assert!(Filter::of_app(&app).empty.is_none(), "other attributes don't read pixels"); } @@ -217,7 +254,7 @@ fn each_opposite_lists_the_rest_groups_included() { assert_eq!(listed(doc, *negative), rest, "{negative:?} lists what {positive:?} doesn't"); } // The three measured ones, spelled out. - assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden"])); + assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden", "hidden group"])); assert!(listed(doc, Attribute::NotLocked).contains("group"), "groups are listed too"); assert!(!listed(doc, Attribute::NotLocked).contains("Background")); assert_eq!(listed(doc, Attribute::NotLinked), everything.difference(&names(&["linked a", "linked b"])).cloned().collect()); @@ -280,5 +317,5 @@ fn the_panel_filters_by_the_attribute_picked_in_the_dropdown() { h.query_all_by_label("Not Visible").last().expect("Not Visible in the open list").click(); h.run_steps(4); assert_eq!(h.state().ui.layer_filter_attribute, Attribute::NotVisible); - assert_eq!(drawn(&h), names(&["hidden"])); + assert_eq!(drawn(&h), names(&["hidden", "hidden group"])); }