mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 04:36:49 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf4f02d09 |
No files matched your search
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user