Merge PR #11077: Layers panel: filter by layer attribute (#10881)

# Conflicts:
#	crates/ui-egui/src/panels/layer_filter_ui.rs
#	docs/control-protocol.md
This commit is contained in:
David Mann committed 2026-11-04 14:51:17 +01:00
commit d7e2bbe4a4
7 files changed
+638 -27

No files matched your search

+7 -5
View File
@@ -370,11 +370,13 @@ pub struct UiSetParams {
/// `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", "effect",
/// "mode", "color", "smartObject" or "artboard", which lists the artboards that hold the
/// selected layers), kinds (a list of layer kinds), name (matches part of the layer name),
/// effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay",
/// "outerGlow" or "dropShadow"), mode (a blend mode name), color (a layer colour label, or
/// "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel
/// "mode", "attribute", "color", "smartObject" or "artboard", which lists the artboards that
/// hold the selected layers), kinds (a list of layer kinds), name (matches part of the layer
/// name), effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay",
/// "outerGlow" or "dropShadow"), mode (a blend mode name), attribute ("visible", "locked",
/// "empty", "linked", "clipped", "layerMask", "vectorMask", "layerEffects",
/// "advancedBlending", or "notVisible" … "noAdvancedBlending"), color (a layer colour label,
/// or "none") and smart (a list of "linked", "missing" and "embedded") 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).
+1 -1
View File
@@ -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, color, smartObject or artboard"), "{bad}");
assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}");
assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing");
}
+46 -20
View File
@@ -1,9 +1,10 @@
//! 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, Effect
//! picks one effect kind (#10880), Mode picks one blend mode, 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.
//! 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.
use std::collections::HashSet;
@@ -23,6 +24,7 @@ pub enum FilterBy {
Name,
Effect,
Mode,
Attribute,
Color,
SmartObject,
Artboard,
@@ -37,6 +39,7 @@ pub struct Filter {
pub name: String,
pub mode: BlendMode,
pub effect: effect::EffectKind,
pub attribute: attribute::Attribute,
pub color: LabelColor,
/// The Smart Object toggles that are on, from [`SMART`].
pub smart: Vec<String>,
@@ -45,6 +48,9 @@ pub struct Filter {
pub missing: HashSet<String>,
/// The rows the Artboard filter lists ([`board_rows`]); empty lists every row.
pub board_rows: HashSet<LayerId>,
/// The empty layers the Attribute filter's Empty and Not Empty use
/// ([`attribute::empty_layers`]); `None` reads each layer's pixels.
pub empty: Option<std::sync::Arc<HashSet<LayerId>>>,
}
impl Filter {
@@ -55,16 +61,19 @@ impl Filter {
name: ui.layer_filter_name.clone(),
mode: ui.layer_filter_mode,
effect: ui.layer_filter_effect,
attribute: ui.layer_filter_attribute,
color: ui.layer_filter_color,
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,18 +84,21 @@ 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
}
/// Whether the panel is filtered. Like Photoshop, Effect, Mode and Color always filter (by
/// Bevel & Emboss, by Normal and by no colour at first); 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.
/// 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
/// 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 {
match self.by {
FilterBy::Kind => !self.kinds.is_empty(),
FilterBy::Name => !self.name.trim().is_empty(),
FilterBy::Effect | FilterBy::Mode | FilterBy::Color => true,
FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true,
FilterBy::SmartObject => !self.smart.is_empty(),
FilterBy::Artboard => !self.board_rows.is_empty(),
}
@@ -102,6 +114,7 @@ impl Filter {
FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()),
FilterBy::Effect => self.effect.shows(l),
FilterBy::Mode => l.blend == self.mode,
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 };
@@ -157,21 +170,23 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"];
/// not there, and embedded in the document.
pub const SMART: [&str; 3] = ["linked", "missing", "embedded"];
/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, color?, smart?}}`: `current` with
/// the given fields replaced, or an error naming the bad field (nothing changes then). `by` is
/// `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard`; `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); `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.
/// `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
/// 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.
pub fn patched(current: &UiState, v: &serde_json::Value) -> Result<Filter, String> {
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", "color", "smart"].contains(&k.as_str())) {
return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, color, smart)"));
if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) {
return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, attribute, color, smart)"));
}
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, effect, mode, color, smartObject or artboard".to_string())?;
f.by = serde_json::from_value(by.clone())
.map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject or artboard".to_string())?;
}
if let Some(kinds) = o.get("kinds") {
let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?;
@@ -198,6 +213,9 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result<Filter, Strin
if let Some(e) = o.get("effect") {
f.effect = effect::parse(e)?;
}
if let Some(a) = o.get("attribute") {
f.attribute = attribute::parse(a)?;
}
if let Some(color) = o.get("color") {
let id = color.as_str().ok_or("layerFilter.color must be a label colour id")?;
f.color = LabelColor::ALL.into_iter().find(|c| c.id() == id).ok_or_else(|| {
@@ -227,6 +245,7 @@ pub fn apply(ui: &mut UiState, f: Filter) {
ui.layer_filter_name = f.name;
ui.layer_filter_mode = f.mode;
ui.layer_filter_effect = f.effect;
ui.layer_filter_attribute = f.attribute;
ui.layer_filter_color = f.color;
ui.layer_filter_smart = f.smart;
}
@@ -256,6 +275,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
(FilterBy::Name, tl!("Name")),
(FilterBy::Effect, tl!("Effect")),
(FilterBy::Mode, tl!("Mode")),
(FilterBy::Attribute, tl!("Attribute")),
(FilterBy::Color, tl!("Color")),
(FilterBy::SmartObject, tl!("Smart Object")),
(FilterBy::Artboard, tl!("Artboard")),
@@ -293,6 +313,7 @@ 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),
FilterBy::Color => {
// Photoshop's list: None, then the label colours in the Layers panel menu's order.
let colors: Vec<(LabelColor, &str)> =
@@ -328,6 +349,11 @@ pub mod selection;
#[cfg(test)]
mod effect_tests;
pub mod attribute;
#[cfg(test)]
mod attribute_tests;
#[cfg(test)]
mod selection_tests;
#[cfg(test)]
@@ -0,0 +1,258 @@
//! 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 std::collections::HashSet;
use std::sync::{Arc, Mutex, PoisonError, Weak};
use photocraft_color::SampleType;
use photocraft_doc::{AdvancedBlending, Document, Layer, LayerContent, LayerId};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
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. `empty` is the document's empty layers
/// ([`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<LayerId>>) -> 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, None), |set| set.contains(&l.id)),
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, 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 on `canvas`; an empty group isn't listed.
fn is_empty(l: &Layer, canvas: Option<Rect>) -> 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 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<Rect>) -> bool {
let fmt = s.format();
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
// (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 tile_visible = |c: &TileCoord, t: &Arc<photocraft_raster::Tile>| {
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().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().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).any(|(c, t)| tile_visible(c, t))
}
/// 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)
}
/// The empty pixel layers of `doc`.
pub fn empty_layers_of(doc: &Document) -> HashSet<LayerId> {
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<Option<EmptyCache>> = Mutex::new(None);
type EmptyCache = (Weak<Document>, Arc<HashSet<LayerId>>);
/// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per
/// 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<Arc<HashSet<LayerId>>> {
if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) {
return None;
}
let d = app.session.active()?;
let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner);
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::new(empty_layers_of(&d.doc));
*cache = Some((Arc::downgrade(&d.doc), 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.
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<Attribute, String> {
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);
}
@@ -0,0 +1,321 @@
//! #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 })));
// 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
}
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<String> {
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()
}
fn names(list: &[&str]) -> BTreeSet<String> {
list.iter().map(|n| n.to_string()).collect()
}
fn all(doc: &Document) -> BTreeSet<String> {
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<String> { everything.difference(&names(gone)).cloned().collect() };
// Every visible layer and every visible group.
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.
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 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 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 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();
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 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");
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");
// 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");
}
#[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<String> = 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", "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());
}
#[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<String> {
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", "hidden group"]));
}
+4
View File
@@ -370,6 +370,9 @@ pub struct UiState {
/// Layers panel effect filter (#10880).
#[serde(default)]
pub layer_filter_effect: crate::layer_filter_ui::effect::EffectKind,
/// Layers panel attribute filter (#10881).
#[serde(default)]
pub layer_filter_attribute: crate::layer_filter_ui::attribute::Attribute,
/// Layers panel colour filter (#5898): the label colour listed (None: layers without one).
#[serde(default)]
pub layer_filter_color: photocraft_doc::LabelColor,
@@ -586,6 +589,7 @@ impl Default for UiState {
layer_filter_name: String::new(),
layer_filter_mode: default_layer_filter_mode(),
layer_filter_effect: Default::default(),
layer_filter_attribute: Default::default(),
layer_filter_color: Default::default(),
pen: None,
pen_doc: None,
+1 -1
View File
@@ -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=<file.csv>` / `--stroke-trace <file.csv>`): 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?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `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); `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 and Color always filter, by Bevel & Emboss, by Normal 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` 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 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.