mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-06 18:34:01 +00:00
# Conflicts: # crates/ui-egui/src/panels/layer_filter_ui.rs # docs/control-protocol.md
This commit is contained in:
commit
d7e2bbe4a4
7 files changed
+638
-27
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user