Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 cbf4f02d09 Layers rows: name truncates before the fx/lock/link indicators; fx triangle; Collapse All Groups (#144, #143)
- Layers panel rows lay out their right-hand indicators first (lock, fx badge
  with an effects disclosure triangle, link, optional blend-mode label) and
  give the name the remaining width, cut with an ellipsis. The Studio kind
  sub-label and the Effects sub-rows truncate the same way; a narrow panel
  squeezes group indentation before the name. Rows leave room for the
  overlay scrollbar. Drawn rects are recorded for tests (layer_row_ui).
- New view-state command layer.setEffectsExpanded (DocState::fx_collapsed,
  no history step), driven by the fx triangle (Alt-click: all layers).
- Layers panel menu: Collapse All Groups (layer.setExpanded all=false).
  Group open/closed state is document data (PSD lsct, .pcraft).
- ui.set {dockWidth} sets the right dock width (250..520) for agents and
  snapshots.
- engine example designer_psd: ~140-layer designer PSD (4-deep groups, long,
  CJK/Greek/accented names, fx, clipping, masks, vector masks, locks, links,
  type/shape/fill/adjustment layers, hidden layers) for visual QA.
- Tests: name never intersects an indicator at 1x/1.5x/2x, 250/290/520 pt and
  in every theme; fx triangle hides/shows effect rows without selecting;
  Collapse All Groups from the panel menu, state survives PSD and .pcraft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 03:38:35 -04:00
11 changed files with 775 additions and 36 deletions

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
//! Generate a realistic "designer" PSD for Layers-panel QA (#143, #144): about 150 layers in
//! groups nested four deep, long and short names (Latin, accented, CJK), many layers with
//! effects, clipping masks, layer and vector masks, locks, links, smart objects, adjustment,
//! fill, shape and type layers, hidden layers and a mix of open and closed groups. It is built
//! through engine commands and saved through the real PSD exporter.
//!
//! ```sh
//! cargo run -p photocraft-engine --example designer_psd -- out.psd
//! ```
use photocraft_engine::Session;
use serde_json::{Value, json};
const NAMES: &[&str] = &[
"BG",
"a",
"Button / Primary / Hover state with a very long descriptive name that keeps going",
"背景のテクスチャ 夕焼け",
"Überschrift – Größe ½ (Entwurf)",
"Shadow copy 3",
"Rectangle 12",
"Photo — Model portrait, retouched final FINAL v7 (approved by client)",
"图层 12 副本",
"Icon",
"Highlight",
"Card / Product thumbnail / Hover",
"Ελληνικά κείμενο",
"Glow",
"Divider line",
"카드 배경",
];
struct Gen {
s: Session,
n: usize,
}
impl Gen {
fn run(&mut self, id: &str, p: Value) -> Value {
match self.s.execute(id, p.clone()) {
Ok(v) => v,
Err(e) => {
eprintln!("{id} {p}: {e}");
Value::Null
}
}
}
fn active(&self) -> u64 {
self.s.active().and_then(|d| d.active_layer).map_or(0, |l| l.0)
}
/// One leaf layer of a kind picked from the running counter; returns its id.
fn leaf(&mut self) -> u64 {
let i = self.n;
self.n += 1;
let name = format!("{} {}", NAMES[i % NAMES.len()], if i.is_multiple_of(3) { String::new() } else { format!("#{i}") }).trim().to_string();
let (x, y) = ((i * 97 % 1400) as i64, (i * 53 % 1000) as i64);
let colour = format!("#{:02x}{:02x}{:02x}", (i * 47 % 256), (i * 91 % 256), (i * 23 % 256));
let id = match i % 17 {
3 => {
let v = self.run("type.create", json!({"x": x, "y": y + 40, "text": format!("Headline {i}: Autumn collection"), "size": 36, "name": name}));
v.get("layer").and_then(Value::as_u64).unwrap_or_else(|| self.active())
}
7 => {
self.run("layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 150], [255, 255]]}));
let id = self.active();
self.run("layer.setProps", json!({"layer": id, "name": format!("Curves {i}")}));
id
}
11 => {
self.run("shape.create", json!({"kind": "roundedRect", "rect": [x, y, 220, 90], "fill": colour, "name": name}));
self.active()
}
14 => {
self.run("layer.newFillLayer.solidColor", json!({"color": colour}));
self.active()
}
_ => {
self.run("layer.new.layer", json!({"name": name}));
self.run("select.rect", json!({"x": x, "y": y, "width": 180 + i % 5 * 40, "height": 120 + i % 3 * 30}));
self.run("edit.fill", json!({"contents": "color", "color": colour}));
self.run("select.deselect", json!({}));
self.active()
}
};
if i % 4 == 1 {
self.run("layer.layerStyle.dropShadow", json!({"layer": id, "distance": 6, "size": 10}));
if i % 8 == 1 {
self.run("layer.select", json!({"layer": id}));
self.run("layer.layerStyle.stroke", json!({"size": 2, "color": "#ffffff", "add": true}));
}
}
if i % 6 == 2 {
self.run("layer.layerMask.revealAll", json!({"layer": id}));
}
if i % 10 == 5 {
self.run("layer.select", json!({"layer": id}));
let path = json!({"subpaths": [{"knots": [[x, y], [x + 300, y], [x + 300, y + 200], [x, y + 200]]}]});
self.run("layer.vectorMask.add", json!({"layer": id, "path": path}));
}
if i % 9 == 4 {
self.run("layer.setProps", json!({"layer": id, "locks": {"all": true}}));
} else if i % 9 == 6 {
self.run("layer.setProps", json!({"layer": id, "locks": {"position": true}}));
}
if i % 8 == 3 {
self.run("layer.setProps", json!({"layer": id, "visible": false}));
}
if i % 12 == 10 {
self.run("layer.setProps", json!({"layer": id, "blend": "Multiply"}));
}
if i % 13 == 6 {
self.run("layer.select", json!({"layer": id}));
self.run("layer.smartObjects.convertToSmartObject", json!({"layer": id}));
}
self.s.active().and_then(|d| d.active_layer).map_or(id, |l| l.0)
}
/// `count` items above the active layer, the item at `nest` (if any) a group built one
/// level deeper, then everything grouped as `name`. Returns the group's id.
fn group(&mut self, name: &str, depth: usize, count: usize, open: bool) -> u64 {
let mut ids = Vec::new();
for k in 0..count {
if depth < 3 && k == count / 2 {
let sub = format!("{name} › Level {} {}", depth + 2, ["Cards", "サブグループ", "Details", "Variants"][depth % 4]);
ids.push(self.group(&sub, depth + 1, count.saturating_sub(2).max(3), !self.n.is_multiple_of(3)));
} else {
let id = self.leaf();
// Clip every 7th layer to the one below it (inside the same group).
if self.n.is_multiple_of(7) && !ids.is_empty() {
self.run("layer.setProps", json!({"layer": id, "clipped": true}));
}
ids.push(id);
}
}
self.run("layer.select", json!({"layer": ids[0]}));
for id in &ids[1..] {
self.run("layer.select", json!({"layer": id, "mode": "toggle"}));
}
let g = self.run("layer.groupLayers", json!({"name": name}))["layer"].as_u64().unwrap_or_else(|| self.active());
if depth == 1 && self.n.is_multiple_of(2) {
self.run("layer.layerStyle.dropShadow", json!({"layer": g}));
}
self.run("layer.setExpanded", json!({"layer": g, "expanded": open}));
self.run("layer.select", json!({"layer": g}));
g
}
}
fn main() -> Result<(), String> {
let out = std::env::args().nth(1).unwrap_or_else(|| "designer.psd".into());
let mut g = Gen { s: Session::new(), n: 0 };
g.run("file.new", json!({"width": 1600, "height": 1200, "name": "designer"}));
let groups = [
("Footer", 6, false),
("Retouching — dodge & burn, frequency separation", 7, true),
("Typography / 本文", 6, false),
("Product Grid — Autumn/Winter 2026 campaign (final, do not edit)", 8, true),
("ヒーローセクション", 7, true),
("Navigation", 5, false),
("Header / Logo lockup", 6, true),
];
for (name, count, open) in groups {
g.group(name, 0, count, open);
}
for _ in 0..10 {
g.leaf();
}
// Two linked layers at the top.
let a = g.leaf();
let b = g.leaf();
g.run("layer.select", json!({"layer": a}));
g.run("layer.select", json!({"layer": b, "mode": "toggle"}));
g.run("layer.linkLayers", json!({}));
let doc = g.s.active().map(|d| (*d.doc).clone()).ok_or("no document")?;
let layers = doc.walk().len();
let bytes = photocraft_io::export(&doc, &out, &Default::default()).map_err(|e| e.to_string())?.bytes;
std::fs::write(&out, &bytes).map_err(|e| e.to_string())?;
println!("wrote {out}: {layers} layers, {} bytes", bytes.len());
Ok(())
}
+1
View File
@@ -989,6 +989,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::variables_cmds::specs());
v.extend(crate::plugin_cmds::specs());
v.extend(crate::group_view_cmds::specs());
v.extend(crate::fx_view_cmds::specs());
v
}
+134
View File
@@ -0,0 +1,134 @@
//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside
//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
//! clean document stays clean.
use photocraft_doc::LayerId;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const ID: &str = "layer.setEffectsExpanded";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
fn has_effects(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) }
}
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
}
}
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: show or hide a layer's effects list (no
/// `expanded`: toggle). With `all`, every layer with effects takes the new state (⌥-click).
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
let want = opt_bool(p, "expanded")?;
let all = opt_bool(p, "all")?.unwrap_or(false);
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
let layer = match p.get("layer") {
None | Some(Value::Null) if all => None,
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
};
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect();
let current = match layer {
Some(id) => {
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if l.effects.items.is_empty() {
return Err(bad(format!("layer {} has no effects", id.0)));
}
!st.fx_collapsed.contains(&id)
}
None => with_fx.iter().any(|id| !st.fx_collapsed.contains(id)),
};
let expanded = want.unwrap_or(!current);
let targets = if all { with_fx } else { layer.into_iter().collect() };
// Forget ids no longer in the document so the list can't grow without bound.
let doc = st.doc.clone();
st.fx_collapsed.retain(|id| doc.layer(*id).is_some() && !targets.contains(id));
if !expanded {
st.fx_collapsed.extend(targets.iter().copied());
}
Ok(json!({"expanded": expanded, "layers": targets.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Expand/Collapse Effects",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##,
enabled: has_effects,
run: set_expanded,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
s.execute("layer.layerStyle.stroke", json!({})).unwrap();
(s, a, b)
}
fn open(s: &Session, id: u64) -> bool {
!s.active().unwrap().fx_collapsed.contains(&LayerId(id))
}
#[test]
fn toggles_one_layer_or_all_without_history() {
let (mut s, a, b) = session();
let st = s.active_mut().unwrap();
st.saved_revision = st.revision;
let steps = s.active().unwrap().history.entries().len();
assert!(open(&s, a) && open(&s, b), "effects lists start open");
assert_eq!(s.execute(ID, json!({"layer": a})).unwrap()["expanded"], false);
assert!(!open(&s, a) && open(&s, b));
assert_eq!(s.active().unwrap().history.entries().len(), steps, "no history step");
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
s.execute(ID, json!({"layer": a, "all": true})).unwrap();
assert!(open(&s, a) && open(&s, b), "toggling an open-and-closed set from a closed layer opens all");
s.execute(ID, json!({"all": true, "expanded": false})).unwrap();
assert!(!open(&s, a) && !open(&s, b));
s.execute(ID, json!({"layer": b, "expanded": true})).unwrap();
assert!(!open(&s, a) && open(&s, b));
assert_eq!(s.active().unwrap().fx_collapsed.len(), 1, "no duplicates");
}
#[test]
fn bad_params_fail_gracefully() {
let (mut s, _, _) = session();
let bg = s.active().unwrap().doc.layers[0].id.0;
for p in [
json!({"layer": bg}),
json!({"layer": 9999}),
json!({"layer": "x"}),
json!({"layer": -1}),
json!({"expanded": "yes", "all": true}),
json!({"all": 3}),
] {
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
}
let mut empty = Session::new();
assert!(empty.execute(ID, json!({})).is_err());
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no effects: disabled");
}
}
+5
View File
@@ -30,6 +30,7 @@ pub mod file_cmds;
pub mod filters;
pub mod filters_ext;
mod frame_cmds;
pub mod fx_view_cmds;
pub mod gallery_cmds;
pub mod group_view_cmds;
pub mod image_cmds;
@@ -144,6 +145,9 @@ pub struct DocState {
pub channel_view: channel_cmds::ChannelView,
/// Select › Isolate Layers: the Layers panel lists only these layers (empty = off; view state).
pub isolated_layers: Vec<LayerId>,
/// Layers panel: layers whose effects list is collapsed under their row (the fx triangle;
/// view state, not history). Effects lists start open.
pub fx_collapsed: Vec<LayerId>,
}
impl DocState {
@@ -162,6 +166,7 @@ impl DocState {
coalesce: None,
channel_view: Default::default(),
isolated_layers: Vec::new(),
fx_collapsed: Vec::new(),
}
}
/// The selected layers in bottom-to-top document order, always including the active layer.
+4
View File
@@ -142,6 +142,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
Err(e) => return err(e),
}
}
// Right dock width in points (clamped to the dock's 250..=520 range), applied next frame.
if let Some(w) = p.get("dockWidth").and_then(Value::as_f64) {
crate::panels::request_dock_width(ctx, w as f32);
}
if let Some(i) = app.session.active_index() {
if let Some(z) = p.get("zoom").and_then(Value::as_f64) {
app.ui.views[i].zoom = (z as f32).clamp(0.01, 64.0);
+4
View File
@@ -323,6 +323,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
}
egui::Popup::menu(&resp.menu).show(|ui| {
ui.set_min_width(170.0);
if tabs.get(sel) == Some(&"Layers") {
crate::layer_row_ui::panel_menu(app, ui);
ui.separator();
}
if ui.button(if collapsed { "Expand Panel Group" } else { "Collapse Panel Group" }).clicked() {
actions.push(Action::ToggleCollapse(g));
ui.close();
+226
View File
@@ -0,0 +1,226 @@
//! Layers panel row: the right-hand indicators and the name between them and the thumbnails
//! (#144).
//!
//! The indicators (lock, fx badge with its effects triangle, link, the blend-mode label) are
//! laid out first, from the row's right edge inward; the name gets the width that is left and
//! is cut with an ellipsis, so a long name never runs under an indicator at any panel width or
//! UI scale. The blend-mode label is optional: it is dropped before the name gets too narrow.
//! The rects drawn each frame are recorded ([`recorded`]) for tests and automation.
use egui::{Align2, FontId, Galley, Painter, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_color::BlendMode;
use photocraft_doc::Layer;
use serde_json::{Value, json};
use std::sync::Arc;
use crate::icons;
use crate::theme::{self, Tokens};
/// Space kept free at a row's right edge: the overlay scrollbar sits there.
pub const RIGHT_PAD: f32 = 10.0;
/// Gap between indicators, and between the name and the first indicator.
const GAP: f32 = 4.0;
/// Width of an icon indicator (lock, link).
const ICON_W: f32 = 14.0;
/// Width of the effects triangle beside the fx badge.
const TRIANGLE_W: f32 = 10.0;
/// The blend-mode label is shown only while the name keeps at least this much room.
const MIN_NAME_W: f32 = 64.0;
/// A right-hand row indicator.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Indicator {
Lock,
FxTriangle,
Fx,
Link,
Blend,
}
/// Where one row's name and indicators went this frame (screen points).
#[derive(Clone, Debug)]
pub struct RowRects {
pub layer: u64,
pub row: Rect,
/// The name as painted (after truncation); `None` when there was no room for it.
pub name: Option<Rect>,
pub indicators: Vec<(Indicator, Rect)>,
}
/// Lay out `items` (right to left, with their widths; `Blend` is optional) inside `row`,
/// leaving the name the span from `name_left`. Returns the indicator rects and the name's
/// right limit.
pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(Indicator, Rect)>, f32) {
let mut right = row.right() - RIGHT_PAD;
let mut out = Vec::with_capacity(items.len());
for &(kind, w) in items {
let w = w.max(0.0);
// The fx badge hugs its triangle; everything else keeps a gap.
let gap = if kind == Indicator::Fx && out.last().is_some_and(|(k, _)| *k == Indicator::FxTriangle) { 1.0 } else { GAP };
let left = right - w;
if kind == Indicator::Blend && left - GAP - name_left < MIN_NAME_W {
continue;
}
out.push((kind, Rect::from_min_max(pos2(left, row.top()), pos2(right, row.bottom()))));
right = left - gap;
}
let name_right = out.last().map_or(right, |(_, r)| r.left() - GAP);
(out, name_right)
}
/// Width the always-shown indicators of `l` take at the row's right (blend label excluded).
pub fn reserved_width(l: &Layer) -> f32 {
let mut w = RIGHT_PAD;
if l.locks.transparency || l.locks.position || l.locks.all {
w += ICON_W + GAP;
}
if !l.effects.items.is_empty() {
w += TRIANGLE_W + 1.0 + 13.0 + GAP;
}
if l.link_group.is_some() {
w += ICON_W + GAP;
}
w
}
/// Group indentation for a row at `depth`: 14 pt a level, squeezed when the panel is so narrow
/// that it would leave the name less than [`MIN_NAME_W`] (`fixed` = everything else in the row).
pub fn indent(depth: usize, row_width: f32, fixed: f32) -> f32 {
(depth as f32 * 14.0).min((row_width - fixed - MIN_NAME_W).max(0.0))
}
/// A single-line galley cut with "…" to `max_width` (nothing when there's no room).
pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color32, italics: bool, max_width: f32) -> Option<Arc<Galley>> {
if max_width < 8.0 {
return None;
}
let mut job = egui::text::LayoutJob::default();
job.append(text, 0.0, egui::TextFormat { font_id: font, color, italics, ..Default::default() });
job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
Some(painter.layout_job(job))
}
/// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the
/// name's right limit, the indicator rects, and whether the triangle was clicked this frame (so
/// the row doesn't also treat the click as a selection).
pub fn indicators(
ui: &egui::Ui,
painter: &Painter,
row: Rect,
name_left: f32,
l: &Layer,
fx_open: bool,
actions: &mut Vec<(String, Value)>,
) -> (f32, Vec<(Indicator, Rect)>, bool) {
let t = Tokens::get(ui.ctx());
let locked = l.locks.transparency || l.locks.position || l.locks.all;
let fx_font = theme::semibold(11.0);
let blend_font = FontId::proportional(10.5);
let has_fx = !l.effects.items.is_empty();
let show_blend = l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough;
let fx_galley = has_fx.then(|| painter.layout_no_wrap("fx".into(), fx_font, t.text_dim));
let blend_galley = show_blend.then(|| painter.layout_no_wrap(l.blend.label().into(), blend_font, t.text_faint));
let mut items = Vec::new();
if locked {
items.push((Indicator::Lock, ICON_W));
}
if let Some(g) = &fx_galley {
items.push((Indicator::FxTriangle, TRIANGLE_W));
items.push((Indicator::Fx, g.size().x));
}
if l.link_group.is_some() {
items.push((Indicator::Link, ICON_W));
}
if let Some(g) = &blend_galley {
items.push((Indicator::Blend, g.size().x));
}
let (rects, name_right) = layout(row, name_left, &items);
let mut clicked = false;
let cy = row.center().y;
for &(kind, r) in &rects {
match kind {
Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint),
Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link", 12.0, t.text_faint),
Indicator::Fx => {
if let Some(g) = fx_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_dim);
}
}
Indicator::Blend => {
if let Some(g) = blend_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_faint);
}
}
Indicator::FxTriangle => {
let resp = ui.interact(r, ui.id().with(("fx-disclosure", l.id.0)), Sense::click());
paint_triangle(painter, r.center(), fx_open, if resp.hovered() { t.text } else { t.icon });
if resp.clicked() {
clicked = true;
let all = ui.input(|i| i.modifiers.alt);
actions.push(("layer.setEffectsExpanded".into(), json!({"layer": l.id.0, "expanded": !fx_open, "all": all})));
}
let (verb, name) = (if fx_open { "Collapse" } else { "Expand" }, l.name.clone());
let resp = resp.on_hover_text(if fx_open {
"Hide the layer's effects (⌥-click: all layers)"
} else {
"Show the layer's effects (⌥-click: all layers)"
});
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} effects {name}")));
}
}
}
(name_right, rects, clicked)
}
/// A small disclosure triangle: pointing down when open, right when closed.
pub fn paint_triangle(painter: &Painter, c: Pos2, open: bool, color: egui::Color32) {
let s = 3.0;
let pts = if open {
vec![pos2(c.x - s, c.y - s * 0.6), pos2(c.x + s, c.y - s * 0.6), pos2(c.x, c.y + s * 0.6)]
} else {
vec![pos2(c.x - s * 0.6, c.y - s), pos2(c.x + s * 0.6, c.y), pos2(c.x - s * 0.6, c.y + s)]
};
painter.add(Shape::convex_polygon(pts, color, Stroke::NONE));
}
/// Paint `text` left-aligned at `x`, vertically centred on `cy`, cut to end before `right`.
/// Returns the painted rect.
pub fn label(painter: &Painter, x: f32, cy: f32, right: f32, text: &str, font: FontId, color: egui::Color32) -> Option<Rect> {
let g = truncated(painter, text, font, color, false, right - x)?;
let r = Align2::LEFT_CENTER.anchor_size(pos2(x, cy), g.size());
painter.galley(r.min, g, color);
Some(r)
}
/// The Layers panel's own items at the top of its panel menu (Photoshop's flyout).
pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) {
let can = app.session.is_enabled("layer.setExpanded");
if ui.add_enabled(can, egui::Button::new("Collapse All Groups")).clicked() {
if let Err(e) = app.run("layer.setExpanded", json!({"all": true, "expanded": false})) {
app.ui.status = e;
app.ui.status_error = true;
}
ui.close();
}
}
fn rects_id() -> egui::Id {
egui::Id::new("layer-row-rects")
}
/// Start a frame's record (the Layers panel calls it before drawing its rows).
pub fn begin(ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(rects_id(), Vec::<RowRects>::new()));
}
pub fn record(ctx: &egui::Context, r: RowRects) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<Vec<RowRects>>(rects_id()).push(r));
}
/// The rows the Layers panel drew last frame.
pub fn recorded(ctx: &egui::Context) -> Vec<RowRects> {
ctx.data(|d| d.get_temp::<Vec<RowRects>>(rects_id())).unwrap_or_default()
}
#[cfg(test)]
mod tests;
+161
View File
@@ -0,0 +1,161 @@
//! #144: the name never runs under the right-hand indicators; the fx triangle; #143: Collapse
//! All Groups.
use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerContent;
use serde_json::json;
use super::{Indicator, RowRects, layout, recorded};
use crate::PhotocraftApp;
const LONG: &str = "Button / Primary / Hover state — a very long descriptive layer name 背景のテクスチャ that keeps going";
/// Long-named layers with every indicator, nested four groups deep.
fn busy() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let mut prev = None;
for depth in 0..4 {
let a = s.execute("layer.new.layer", json!({"name": format!("{LONG} {depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "blend": "Multiply", "locks": {"all": true}})).unwrap();
s.execute("layer.layerMask.revealAll", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": format!("{LONG} b{depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": b, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap();
s.execute("layer.linkLayers", json!({})).unwrap();
if let Some(p) = prev {
s.execute("layer.select", json!({"layer": p, "mode": "toggle"})).unwrap();
}
let g = s.execute("layer.groupLayers", json!({"name": format!("{LONG} group {depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": g})).unwrap();
prev = Some(g);
}
s
}
fn harness(session: photocraft_engine::Session, ppp: f32, theme: &str, dock_width: f32) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(session, crate::Services::default())
});
let ctx = h.ctx.clone();
let (req, _rx) = crate::control::ControlRequest::new(
"ui.set",
json!({"theme": theme, "dockWidth": dock_width, "dock": {"collapsed": ["color", "properties", "history", "navigator"]}}),
);
crate::control::handle(h.state_mut(), &ctx, &req);
h.run_steps(8);
h
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.intersects(b) && a.intersect(b).area() > 0.0
}
fn check(rows: &[RowRects], what: &str) {
assert!(rows.len() >= 8, "{what}: rows drawn ({})", rows.len());
let mut with_fx = 0;
for r in rows {
let name = r.name.unwrap_or_else(|| panic!("{what}: layer {} has no room for its name", r.layer));
assert!(name.right() <= r.row.right(), "{what}: name inside its row");
for (k, ind) in &r.indicators {
assert!(!overlaps(name, *ind), "{what}: layer {} name {name:?} overlaps {k:?} {ind:?}", r.layer);
assert!(ind.right() <= r.row.right() - super::RIGHT_PAD + 0.01, "{what}: {k:?} clear of the scrollbar");
}
for (i, (ka, a)) in r.indicators.iter().enumerate() {
for (kb, b) in &r.indicators[i + 1..] {
assert!(!overlaps(*a, *b), "{what}: {ka:?} overlaps {kb:?}");
}
}
with_fx += r.indicators.iter().any(|(k, _)| *k == Indicator::Fx) as usize;
}
assert!(with_fx >= 4, "{what}: fx badges drawn ({with_fx})");
}
#[test]
fn layout_gives_the_name_what_the_indicators_leave() {
let row = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 32.0));
let items = [(Indicator::Lock, 14.0), (Indicator::FxTriangle, 10.0), (Indicator::Fx, 12.0), (Indicator::Link, 14.0), (Indicator::Blend, 40.0)];
let (rects, right) = layout(row, 100.0, &items);
assert_eq!(rects.len(), 5);
assert!(rects.iter().all(|(_, r)| r.left() > right && r.right() <= 300.0 - super::RIGHT_PAD));
// Narrow: the optional blend label goes first; the rest stay.
let (rects, right) = layout(row, 200.0, &items);
assert_eq!(rects.iter().map(|(k, _)| *k).collect::<Vec<_>>(), [Indicator::Lock, Indicator::FxTriangle, Indicator::Fx, Indicator::Link]);
assert!(right < rects[3].1.left());
}
#[test]
fn long_names_never_run_under_the_indicators() {
for ppp in [1.0, 1.5, 2.0] {
for width in [250.0, 290.0, 520.0] {
let h = harness(busy(), ppp, "promedium", width);
check(&recorded(&h.ctx), &format!("@{ppp}x {width}pt"));
}
}
for theme in ["pro", "studio", "studiolight", "classic"] {
let h = harness(busy(), 1.0, theme, 250.0);
check(&recorded(&h.ctx), theme);
}
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn the_fx_triangle_hides_and_shows_the_effects_rows() {
let mut h = harness(busy(), 1.0, "promedium", 290.0);
let top = h.state().session.active().unwrap().doc.layers.last().unwrap().clone();
let rows = |h: &Harness<'_, PhotocraftApp>| recorded(&h.ctx).iter().map(|r| r.row.top()).collect::<Vec<_>>();
let before = rows(&h);
let label = format!("Collapse effects {}", top.name);
let p = h.get_by_label(&label).rect().center();
let active = h.state().session.active().unwrap().active_layer;
click(&mut h, p);
assert!(h.state().session.active().unwrap().fx_collapsed.contains(&top.id));
assert_eq!(h.state().session.active().unwrap().active_layer, active, "the triangle doesn't select");
// Its Effects / Drop Shadow sub-rows are gone, so the next layer row moved up.
let after = rows(&h);
assert!(after[1] < before[1], "rows moved up: {:?} -> {:?}", &before[..2], &after[..2]);
let p = h.get_by_label(&format!("Expand effects {}", top.name)).rect().center();
click(&mut h, p);
assert!(h.state().session.active().unwrap().fx_collapsed.is_empty());
}
fn groups_open(s: &photocraft_engine::Session) -> Vec<bool> {
s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| if let LayerContent::Group(g) = &l.content { Some(g.expanded) } else { None }).collect()
}
#[test]
fn collapse_all_groups_from_the_panel_menu_and_it_is_saved() {
let mut h = harness(busy(), 1.0, "promedium", 290.0);
assert!(groups_open(&h.state().session).iter().all(|o| *o));
let menu = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("layers group").1;
// The hamburger sits at the right end of the group's tab strip.
click(&mut h, pos2(menu.right() - 14.0, menu.top() + 14.0));
let item = h.get_by_label("Collapse All Groups").rect().center();
click(&mut h, item);
let open = groups_open(&h.state().session);
assert_eq!(open.len(), 4);
assert!(open.iter().all(|o| !o), "every group closed: {open:?}");
// Collapsed state is document data: it survives a PSD and a .pcraft round trip.
let doc = (*h.state().session.active().unwrap().doc).clone();
for name in ["c.psd", "c.pcraft"] {
let bytes = photocraft_io::export(&doc, name, &Default::default()).unwrap().bytes;
let back = photocraft_io::import(name, &bytes).unwrap().document;
let mut s = photocraft_engine::Session::new();
s.add_document(back, None);
assert!(groups_open(&s).iter().all(|o| !o), "{name}: groups stay closed");
}
}
+1
View File
@@ -44,6 +44,7 @@ mod icon_data;
pub mod icons;
pub mod layer_menu_ui;
pub mod layer_props_ui;
pub mod layer_row_ui;
pub mod layer_style;
pub mod layer_tree_ui;
pub mod links;
+56 -35
View File
@@ -748,6 +748,19 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::dock::persist(app, ui.ctx());
}
/// The right dock's width range (points).
const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
fn dock_width_id() -> egui::Id {
egui::Id::new("dock-width-request")
}
/// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range.
pub fn request_dock_width(ctx: &egui::Context, w: f32) {
let w = if w.is_finite() { w.clamp(*DOCK_WIDTH.start(), *DOCK_WIDTH.end()) } else { *DOCK_WIDTH.start() };
ctx.data_mut(|d| d.insert_temp(dock_width_id(), w));
}
fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Panels, t: &Tokens) {
use crate::dock::Group;
// Floating in Studio, Properties docks only in Pro (Photoshop).
@@ -765,15 +778,14 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
return;
}
let margin = if t.pro { 2 } else { 8 };
egui::Panel::right("dock")
.resizable(true)
.default_size(if t.pro { 290.0 } else { 300.0 })
.size_range(250.0..=520.0)
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin)))
.show(ui, |ui| {
// Groups keep their heights whatever they show (#88): see `dock`.
crate::dock::show(app, ui, &shown, dock_body);
});
let mut panel = egui::Panel::right("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH);
if let Some(w) = ui.ctx().data_mut(|d| d.remove_temp::<f32>(dock_width_id())) {
panel = panel.exact_size(w);
}
panel.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin))).show(ui, |ui| {
// Groups keep their heights whatever they show (#88): see `dock`.
crate::dock::show(app, ui, &shown, dock_body);
});
}
/// One dock group's tab content; `dock` bounds it and scrolls it when it's taller.
@@ -1142,6 +1154,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.auto_shrink([false, !fill])
.show(ui, |ui| {
let filter = app.ui.layer_filter.clone();
let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default();
crate::layer_row_ui::begin(ui.ctx());
for &(depth, l) in &rows {
// Select › Isolate Layers.
if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) {
@@ -1162,7 +1176,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
if !l.effects.items.is_empty() {
if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) {
effect_rows(app, ui, l, depth);
}
crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions);
@@ -1327,13 +1341,20 @@ fn layer_row(
if eye_resp.clicked() {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible})));
}
x += 28.0 + depth as f32 * 14.0;
let ts = if t.pro { 24.0 } else { 34.0 };
// Everything but the indentation and the name, so a narrow panel squeezes the indentation first.
let fixed = 6.0
+ 28.0
+ if l.is_group() { crate::layer_tree_ui::TRIANGLE_W } else { 0.0 }
+ if l.clipped { 12.0 } else { 0.0 }
+ (ts + 6.0) * if l.mask.is_some() { 2.0 } else { 1.0 }
+ crate::layer_row_ui::reserved_width(l);
x += 28.0 + crate::layer_row_ui::indent(depth, rect.width(), fixed);
let toggled = crate::layer_tree_ui::disclosure(ui, rect, &mut x, l, actions);
if l.clipped {
painter.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, "↳", egui::FontId::proportional(13.0), t.text_faint);
x += 12.0;
}
let ts = if t.pro { 24.0 } else { 34.0 };
let thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts));
draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary);
x += ts + 6.0;
@@ -1366,39 +1387,30 @@ fn layer_row(
}
}
}
// Right-hand indicators first; the name gets what is left and ends in "…" (#144).
let fx_open = app.session.active().is_none_or(|d| !d.fx_collapsed.contains(&l.id));
let (name_right, indicators, fx_toggled) = crate::layer_row_ui::indicators(ui, &painter, rect, x, l, fx_open, actions);
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
let italic = !t.pro && l.name == "Background" && l.locks.transparency;
let mut job = egui::text::LayoutJob::default();
job.append(&l.name, 0.0, egui::TextFormat { font_id: font, color: name_color, italics: italic, ..Default::default() });
let galley = painter.layout_job(job);
// Photoshop rows show only the name; the kind sub-label is a Studio-theme addition.
let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_));
let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 });
painter.galley(text_pos, galley, name_color);
let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| {
let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 });
let r = Rect::from_min_size(text_pos, galley.size());
painter.galley(text_pos, galley, name_color);
r
});
if !is_pixel {
let sub = match &l.content {
LayerContent::Adjustment(a) => a.label().to_string(),
LayerContent::Group(g) => format!("Group · {} layers", g.children.len()),
other => other.kind_name().to_string(),
};
painter.text(pos2(x, rect.center().y + 8.0), Align2::LEFT_CENTER, sub, egui::FontId::proportional(11.0), t.text_faint);
}
if !l.effects.items.is_empty() {
painter.text(pos2(rect.right() - 34.0, rect.center().y), Align2::RIGHT_CENTER, "fx", theme::semibold(11.0), t.text_dim);
}
if l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough {
painter.text(pos2(rect.right() - 30.0, rect.center().y), Align2::RIGHT_CENTER, l.blend.label(), egui::FontId::proportional(10.5), t.text_faint);
}
let locked = l.locks.transparency || l.locks.position || l.locks.all;
if locked {
icons::paint(ui, Rect::from_center_size(pos2(rect.right() - 14.0, rect.center().y), vec2(14.0, 14.0)), "lock", 12.0, t.text_faint);
}
if l.link_group.is_some() {
let x = rect.right() - if locked { 30.0 } else { 14.0 };
icons::paint(ui, Rect::from_center_size(pos2(x, rect.center().y), vec2(14.0, 14.0)), "link", 12.0, t.text_faint);
crate::layer_row_ui::label(&painter, x, rect.center().y + 8.0, name_right, &sub, egui::FontId::proportional(11.0), t.text_faint);
}
crate::layer_row_ui::record(ctx, crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, name: name_rect, indicators });
// ⌘-click a layer or mask thumbnail loads its transparency / mask as a selection (⇧ add,
// ⌥ subtract, ⇧⌥ intersect) instead of changing the layer selection.
let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| {
@@ -1409,7 +1421,7 @@ fn layer_row(
});
if let Some(p) = thumb_load {
actions.push(("select.loadSelection".into(), p));
} else if resp.clicked() && !eye_resp.clicked() && !toggled {
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled {
let mode = select_mode(ui.input(|i| i.modifiers));
actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
// Clicking a thumbnail picks what painting targets; adjustment/fill layers target their mask.
@@ -1429,7 +1441,7 @@ fn layer_row(
ctx.data_mut(|d| d.insert_temp(rename_id, l.name.clone()));
}
if let Some(mut text) = ctx.data(|d| d.get_temp::<String>(rename_id)) {
let edit_rect = Rect::from_min_max(pos2(x - 3.0, rect.center().y - 11.0), pos2(rect.right() - 36.0, rect.center().y + 11.0));
let edit_rect = Rect::from_min_max(pos2(x - 3.0, rect.center().y - 11.0), pos2(name_right.max(x + 40.0), rect.center().y + 11.0));
let te = ui.put(edit_rect, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(12.5)));
te.request_focus();
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
@@ -2099,7 +2111,16 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi
if i == 0 {
icons::paint(ui, Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 14.0)), "sparkles", 11.0, t.text_dim);
}
ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint });
let right = rect.right() - crate::layer_row_ui::RIGHT_PAD;
crate::layer_row_ui::label(
ui.painter(),
x,
rect.center().y,
right,
&name,
egui::FontId::proportional(11.5),
if on { t.text_dim } else { t.text_faint },
);
if resp.double_clicked() {
crate::layer_style::open(app, kind);
}
+1 -1
View File
@@ -35,7 +35,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given
- `engine.commands`: list commands with enablement
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options
- `ui.set {tool?, panels?, dockTabs?, dock?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
- `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}`
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows