mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-25 13:04:41 +01:00
- 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 <noreply@anthropic.com>
183 lines
7.6 KiB
Rust
183 lines
7.6 KiB
Rust
//! 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(())
|
||
}
|