Merge pull request #3289 from abc2cb/feat/layers-panel-photoshop-options

Layers Panel: flyout, options and thumbnail sizes (part 1/3)
This commit is contained in:
aiongg
2026-10-21 17:53:17 -04:00
committed by GitHub
38 changed files with 1513 additions and 74 deletions
+2 -1
View File
@@ -426,6 +426,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
};
let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32;
let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" };
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (nid, rect) = s.edit(label, |doc, active| {
if (sx, sy) != (0, 0) {
crate::image_cmds::translate_doc(doc, cmd, sx, sy)?;
@@ -440,7 +441,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
let nid = if duplicate {
let src_rect = doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?;
let (dx, dy) = (rect.x0.saturating_sub(src_rect.x0), rect.y0.saturating_sub(src_rect.y0));
let copy = crate::commands::layer_copy(doc, id)?;
let copy = crate::commands::layer_copy(doc, id, add_copy)?;
let nid = doc.insert_above(Some(id), copy);
// Grow the canvas first, so shapes on the board re-render uncut where they land.
let fit = |len: u32, edge: i32| i64::from(len).max(i64::from(edge)).clamp(1, 300_000) as u32;
+21
View File
@@ -612,3 +612,24 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() {
assert_eq!(doc(&s).size, before);
assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err());
}
/// Adding a duplicated artboard beside another appends "copy" to its name only while the Layers panel option is on.
#[test]
fn add_beside_duplicate_honors_panel_copy_name_preference() {
for add_copy in [false, true] {
let mut s = session(8);
let id = board_with_square(&mut s);
let name = doc(&s).layer(id).unwrap().name.clone();
s.edit_prefs(|prefs| {
prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"addCopyToCopiedLayersAndGroups": add_copy}));
});
let before = doc(&s).clone();
let result = s.execute("layer.artboard.addBeside", json!({"layer": id.0, "side": "bottom", "duplicate": true})).unwrap();
let copy = LayerId(result["layer"].as_u64().unwrap());
let expected = if add_copy { before.copy_name(&name) } else { name };
assert_eq!(doc(&s).layer(copy).unwrap().name, expected);
assert!(red_at(&s, 15, 155), "the copied artboard retains its contents");
s.undo();
assert_eq!(doc(&s), &before);
}
}
+12 -3
View File
@@ -408,6 +408,12 @@ pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result<La
Ok(mask)
}
/// Options from the Layers Panel flyout live in preferences so headless/keyboard commands
/// behave exactly like pointer-triggered commands. Old preferences preserve Photoshop defaults.
pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool {
s.prefs().dialogs.get("ui.layersPanelOptions").and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(fallback)
}
/// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the
/// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector
/// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its
@@ -751,8 +757,9 @@ fn build() -> Vec<CommandSpec> {
return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers");
}
let id = layer_param(s, p)?;
let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let nid = s.edit("Duplicate Layer", |doc, active| {
let mut dup = layer_copy(doc, id)?;
let mut dup = layer_copy(doc, id, add_copy)?;
if let Some(name) = &name {
dup.name.clone_from(name);
}
@@ -1698,12 +1705,14 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
/// A copy of layer `id` as Layer › Duplicate Layer makes it, not yet in the document: a fresh id,
/// "… copy" appended to the name, and a copy of the Background layer is an ordinary, unlocked
/// layer (Photoshop).
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
pub(crate) fn layer_copy(doc: &Document, id: LayerId, add_copy: bool) -> Result<Layer> {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
let from_background = doc.is_background(id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if add_copy {
dup.name = doc.copy_name(&dup.name);
}
if from_background {
dup.locks = Default::default();
}
+31
View File
@@ -748,6 +748,26 @@ impl Session {
}
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = self.admit(id, spec, &params)?;
// This UI-only disclosure preference applies to both `execute` and `start`:
// desktop UI normally calls `start`, even for synchronous Layer Style commands.
// The layer the command will change, named as the command accepts it (id or name), else the
// active one. `layer.layerStyle.clear` needs no case: it leaves no effects, so nothing expands.
// Transfer Effects changes its destination (`to`), not the layer it takes the effects from.
let new_fx_target = if id.starts_with("layer.layerStyle.") {
let target = if id == "layer.layerStyle.transferEffects" {
crate::commands::layer_param(self, &json!({"layer": params.get("to").cloned().unwrap_or(Value::Null)}))
} else {
crate::commands::layer_param(self, &params)
}
.ok();
self.active().and_then(|st| {
let target = target?;
(st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target))
})
} else {
None
};
let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true);
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.jobs.spawn = background;
@@ -788,6 +808,17 @@ impl Session {
if let Some(before) = before {
self.add_relayout_damage(&before);
}
if let Some((doc_id, layer_id)) = new_fx_target
&& let Some(st) = self.active_mut()
&& st.doc.id == doc_id
&& st.doc.layer(layer_id).is_some_and(|l| !l.effects.items.is_empty())
{
if expand_new_fx {
st.fx_collapsed.retain(|id| *id != layer_id);
} else if !st.fx_collapsed.contains(&layer_id) {
st.fx_collapsed.push(layer_id);
}
}
self.after_command(id, params, spec.journal, Some(nested_from));
Ok(Started::Done(v))
}
+336 -26
View File
@@ -1076,6 +1076,84 @@ fn distribute(s: &mut Session, kind: &str) -> Result<Value> {
/// Reorder one or multiple layers to a single destination in one undoable edit.
///
/// Explicit `layers` are ordered according to the source document, not caller ordering.
/// A transient snapshot for the Layers panel's "Preview on canvas when reordering layers".
/// Uses the same source ordering and insertion semantics as `move_to`, but never edits history.
/// Invalid/self-referential hover targets return None (the committed image stays visible).
pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option<Document> {
let target = LayerId(p.get("target")?.as_u64()?);
let position = p.get("position")?.as_str()?;
if !matches!(position, "above" | "below" | "into") {
return None;
}
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let ids = if let Some(ids) = p.get("layers") {
ids.as_array()?.iter().map(|v| v.as_u64().map(LayerId)).collect::<Option<Vec<_>>>()?
} else {
vec![LayerId(p.get("layer")?.as_u64()?)]
};
if ids.is_empty() || doc.layer(target).is_none() {
return None;
}
let moved_ids = top_level(doc, &ids);
if moved_ids.is_empty() || moved_ids.iter().any(|id| doc.layer(*id).is_none()) {
return None;
}
if moved_ids.len() == 1 && moved_ids[0] == target && !copy {
return None;
}
let target_path = doc.path_of(target)?;
for id in &moved_ids {
let path = doc.path_of(*id)?;
if target_path.starts_with(&path) && !(copy && *id == target && position != "into") {
return None;
}
}
if position == "into" && !doc.layer(target)?.is_group() {
return None;
}
// The very structural move the drop commits, so the preview shows the same clip groups,
// Background handling and group-depth refusals; a drop `move_to` would refuse has no preview.
let mut preview = doc.clone();
apply_move(&mut preview, &moved_ids, target, position, copy, add_copy).ok()?;
Some(preview)
}
/// The document-only part of `move_to`, shared with [`preview_move`] so the Layers panel's hover
/// preview cannot drift from what the drop commits. Moves (or, with `copy`, duplicates) the
/// top-level `moved_ids` to `pos` (`above`, `below` or `into`) `target`, then applies the
/// group-depth cap, the Background-stays-at-the-bottom rule and the clip-group rules
/// (`clip_group::settle`). Returns the ids of the new copies (empty for a move). On `Err` `doc` is
/// half edited: run it inside `Session::edit` or on a scratch copy.
fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: &str, copy: bool, add_copy: bool) -> Result<Vec<LayerId>> {
const CMD: &str = "layer.moveTo";
let background = crate::background_cmds::background_id(doc);
let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, moved_ids) };
let mut moved = Vec::with_capacity(moved_ids.len());
for &id in moved_ids {
moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
}
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
// Look up the destination AFTER removing every source: its indices may have shifted.
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
if pos == "into" {
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
children.extend(moved);
} else {
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
let siblings = if parent.is_empty() {
&mut doc.layers
} else {
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
};
let at = if pos == "below" { idx } else { idx + 1 };
siblings.splice(at..at, moved);
}
check_group_depth(doc, "Reorder Layer")?;
crate::background_cmds::keep_background_at_bottom(background, doc)?;
crate::clip_group::settle(doc, if copy { &copies } else { moved_ids }, &before, false);
Ok(copies)
}
/// Selected descendants of a moved group remain inside their parent; only top-level
/// selected nodes are detached, and every reference is validated before editing.
pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1133,32 +1211,9 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
}
let original_active = st.active_layer;
let background = crate::background_cmds::background_id(doc);
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| {
let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, &moved_ids) };
let mut moved = Vec::with_capacity(moved_ids.len());
for &id in &moved_ids {
moved.push(if copy { crate::commands::layer_copy(doc, id)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
}
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
// Look up the destination AFTER removing every source: its indices may have shifted.
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
if pos == "into" {
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
children.extend(moved);
} else {
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
let siblings = if parent.is_empty() {
&mut doc.layers
} else {
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
};
let at = if pos == "below" { idx } else { idx + 1 };
siblings.splice(at..at, moved);
}
check_group_depth(doc, "Reorder Layer")?;
crate::background_cmds::keep_background_at_bottom(background, doc)?;
crate::clip_group::settle(doc, if copy { &copies } else { &moved_ids }, &before, false);
let copies = apply_move(doc, &moved_ids, target, pos, copy, add_copy)?;
*active = if copy {
copies.last().copied()
} else if batch.is_some() {
@@ -1406,13 +1461,14 @@ pub fn delete_selected(s: &mut Session) -> Result<Value> {
pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result<Value> {
let sel = selected(s);
let old_active = s.active().and_then(|d| d.active_layer);
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (copies, active) = s.edit(label, |doc, active| {
let mut copies = Vec::new();
let mut new_active = None;
for id in top_level(doc, &sel) {
// Match the single-layer duplicate path: Background copies become
// ordinary layers, while normal copies retain their original locks.
let dup = crate::commands::layer_copy(doc, id)?;
let dup = crate::commands::layer_copy(doc, id, add_copy)?;
let nid = doc.insert_above(Some(id), dup);
if !in_place {
crate::artboard_cmds::place_copy(doc, nid)?;
@@ -2850,6 +2906,7 @@ mod tests {
assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err());
}
/// `document.inspect` lists the selected layers and marks them in the layer list.
#[test]
fn inspect_reports_selection() {
let mut s = session(8);
@@ -2861,3 +2918,256 @@ mod tests {
assert_eq!(v["layers"][0]["selected"], true);
}
}
#[cfg(test)]
mod layers_panel_parity_tests {
use super::*;
/// A 24×16 document with three layers, "Test 0" to "Test 2", above the Background; the last one is active.
fn new_session() -> (Session, Vec<LayerId>) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 16})).unwrap();
let ids = (0..3).map(|n| LayerId(s.execute("layer.new.layer", json!({"name": format!("Test {n}")})).unwrap()["layer"].as_u64().unwrap())).collect();
(s, ids)
}
/// Set one Layers panel option in the preferences, as the Panel Options dialog does.
fn option(s: &mut Session, key: &str, enabled: bool) {
s.prefs.edit(|p| {
let val = p.dialogs.entry("ui.layersPanelOptions".into()).or_insert_with(|| json!({}));
if let Some(o) = val.as_object_mut() {
o.insert(key.to_owned(), json!(enabled));
}
});
}
/// The drag preview equals the committed `layer.moveTo` result and never touches the document or its history.
#[test]
fn preview_matches_real_move_and_never_edits_document_or_history() {
let (mut s, ids) = new_session();
let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "above"});
let before = s.active().unwrap().doc.clone();
let undo_before = s.active().unwrap().history.entries().len();
let candidate = preview_move(&before, &params, true).unwrap();
assert_eq!(*before, *s.active().unwrap().doc);
assert_eq!(s.active().unwrap().history.entries().len(), undo_before);
s.execute("layer.moveTo", params).unwrap();
assert_eq!(candidate, *s.active().unwrap().doc, "visual preview must match committed layer order");
}
/// A previewed ⌥-drag copy names its layers like the committed copy, with and without the "copy" suffix option.
#[test]
fn preview_copy_and_no_copy_suffix_option_match_committed_layers() {
let (mut s, ids) = new_session();
option(&mut s, "addCopyToCopiedLayersAndGroups", false);
let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "below", "copy": true});
let before = s.active().unwrap().doc.clone();
let candidate = preview_move(&before, &params, false).unwrap();
s.execute("layer.moveTo", params).unwrap();
let actual = &s.active().unwrap().doc;
assert_eq!(candidate.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), actual.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>());
assert_eq!(actual.layer_count(), before.layer_count() + 1);
assert_eq!(actual.layers.iter().filter(|l| l.name == "Test 0").count(), 2);
let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap();
let duplicate = LayerId(d["layer"].as_u64().unwrap());
assert_eq!(s.active().unwrap().doc.layer(duplicate).unwrap().name, "Test 1");
option(&mut s, "addCopyToCopiedLayersAndGroups", true);
let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap();
assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy"));
}
/// Drops the command refuses (or ignores) give no preview and leave the document alone; a multi-layer preview follows the committed stack order.
#[test]
fn preview_rejects_invalid_drop_unchanged() {
let (mut s, ids) = new_session();
let before = s.active().unwrap().doc.clone();
// Dropping a layer on itself is a no-op for the command, the others are errors.
for (p, command_rejects) in [
(json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), false),
(json!({"layer": ids[1].0, "target": 999999, "position": "below"}), true),
(json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), true),
(json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), true),
] {
assert!(preview_move(&before, &p, true).is_none(), "{p}");
let result = s.execute("layer.moveTo", p.clone());
if command_rejects {
assert!(result.is_err(), "{p}");
} else {
assert_eq!(result.unwrap(), Value::Null, "{p}");
}
assert_eq!(*before, *s.active().unwrap().doc, "{p}");
}
// Multi-layer preview and commit follow the same stack order regardless of params order.
let p = json!({"layers": [ids[2].0, ids[0].0], "target": ids[1].0, "position": "below"});
let candidate = preview_move(&before, &p, true).unwrap();
s.execute("layer.moveTo", p).unwrap();
assert_eq!(candidate, *s.active().unwrap().doc);
}
/// A 16×16 document holding `spec` (bottom to top, `*` = clipped) above its Background. Every
/// layer is a translucent, differently placed rectangle, so the stack order and the clip
/// groups both show in the composite.
fn clip_stack(spec: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
for (n, name) in spec.split_whitespace().enumerate() {
let id = LayerId(s.execute("layer.new.layer", json!({"name": name.trim_end_matches('*')})).unwrap()["layer"].as_u64().unwrap());
let n = n as i32;
let colour = [(n % 3) as f32 * 0.5, ((n + 1) % 3) as f32 * 0.5, ((n + 2) % 3) as f32 * 0.5, 0.6];
s.edit("paint", |doc, _| {
let layer = doc.layer_mut(id).unwrap();
layer.clipped = name.ends_with('*');
layer.surface_mut().unwrap().fill_rect(Rect::new(n * 2, 0, n * 2 + 10, 16), &colour);
Ok(())
})
.unwrap();
}
s
}
/// Every layer of `doc` from the bottom, `*` marking the clipped ones.
fn stack_text(doc: &Document) -> String {
doc.layers.iter().map(|l| format!("{}{}", l.name, if l.clipped { "*" } else { "" })).collect::<Vec<_>>().join(" ")
}
/// The id of the layer called `name`, searching the whole layer tree.
fn id_of(s: &Session, name: &str) -> u64 {
s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0
}
/// The preview of `params` and the committed `layer.moveTo` show the same stack (names, clip
/// flags) and the same composite, and both are `want`.
fn assert_preview_is_commit(mut s: Session, params: Value, want: &str) {
let before = s.active().unwrap().doc.clone();
let preview = preview_move(&before, &params, true).expect("the drop has a preview");
s.execute("layer.moveTo", params.clone()).unwrap();
let committed = &s.active().unwrap().doc;
assert_eq!(stack_text(&preview), want, "preview of {params}");
assert_eq!(stack_text(committed), want, "commit of {params}");
assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px, "composite of {params}");
}
/// Previewed moves join and release clipping groups exactly as the committed reorder does, copies included.
#[test]
fn preview_follows_clip_groups_like_the_committed_reorder() {
// The fixtures of `clip_group::reorder_joins_and_leaves_groups`.
for (spec, layers, target, pos, want) in [
// A layer dropped into a clip group joins it.
("B C1* C2* U", vec!["U"], "C1", "above", "Background B C1* U* C2*"),
("B C1* C2* U V", vec!["U", "V"], "C1", "above", "Background B C1* U* V* C2*"),
// A clipped layer dropped off its group is released.
("B C1* C2* U", vec!["C1"], "U", "above", "Background B C2* U C1"),
// A base moved away releases the layers clipped to it.
("B C1* C2* U", vec!["B"], "U", "above", "Background C1 C2 U B"),
// A base moved up into its own group joins it, and the layers it left are released.
("B C1* C2*", vec!["B"], "C1", "above", "Background C1 B* C2"),
("X B C1* C2* U", vec!["B", "C1"], "U", "above", "Background X C2 U B C1*"),
] {
let s = clip_stack(spec);
let ids: Vec<u64> = layers.iter().map(|n| id_of(&s, n)).collect();
let t = id_of(&s, target);
assert_preview_is_commit(s, json!({"layers": ids, "target": t, "position": pos}), want);
}
// An option-drag copy joins the clip group it lands in too.
let s = clip_stack("B C1* C2* U");
let (u, c1) = (id_of(&s, "U"), id_of(&s, "C1"));
assert_preview_is_commit(s, json!({"layer": u, "target": c1, "position": "above", "copy": true}), "Background B C1* U copy* C2* U");
}
/// Copying the locked Background in a preview gives the same unlocked copy, joining the clip group it lands in, as the command.
#[test]
fn preview_copies_the_background_like_the_committed_copy() {
let mut s = clip_stack("A B*");
let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A"));
let before = s.active().unwrap().doc.clone();
assert_ne!(before.layer(bg).unwrap().locks, Default::default(), "the Background is locked");
let p = json!({"layer": bg.0, "target": a, "position": "above", "copy": true});
let preview = preview_move(&before, &p, true).unwrap();
s.execute("layer.moveTo", p).unwrap();
let committed = &s.active().unwrap().doc;
// The copy is an ordinary, unlocked layer that joins the clip group it lands in.
assert_eq!(stack_text(committed), "Background A Background copy* B*");
assert_eq!(stack_text(&preview), stack_text(committed));
for doc in [&preview, committed] {
assert_eq!(doc.layers.get(2).unwrap().locks, Default::default());
assert!(doc.layers.first().unwrap().locks != Default::default());
}
assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px);
}
/// A drop that would move the Background off the bottom has no preview, as the command refuses it.
#[test]
fn preview_refuses_what_would_leave_the_background_off_the_bottom() {
let mut s = clip_stack("A B");
let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A"));
let before = s.active().unwrap().doc.clone();
for p in [
json!({"layer": a, "target": bg.0, "position": "below"}),
json!({"layer": bg.0, "target": bg.0, "position": "below", "copy": true}),
json!({"layer": a, "target": bg.0, "position": "below", "copy": true}),
] {
assert!(preview_move(&before, &p, true).is_none(), "{p}");
assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}");
assert_eq!(*before, *s.active().unwrap().doc, "{p}");
}
}
/// The UI runs commands through `start`; Expand New Effects applies there as well as to `execute`.
#[test]
fn expand_new_effects_applies_to_ui_start_dispatch_too() {
let (mut s, ids) = new_session();
option(&mut s, "expandNewEffects", false);
let target = ids[0];
let started = s.start("layer.layerStyle.dropShadow", json!({"layer": target.0})).unwrap();
assert!(matches!(started, crate::jobs::Started::Done(_)));
assert!(s.active().unwrap().fx_collapsed.contains(&target));
}
/// Whether new effects start expanded is view state: it adds no history step, and editing an existing effect keeps its disclosure.
#[test]
fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() {
let (mut s, ids) = new_session();
option(&mut s, "expandNewEffects", false);
let target = ids[0];
let history = s.active().unwrap().history.entries().len();
s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 2})).unwrap();
assert_eq!(s.active().unwrap().history.entries().len(), history + 1);
assert!(s.active().unwrap().fx_collapsed.contains(&target));
option(&mut s, "expandNewEffects", true);
let other = ids[1];
s.execute("layer.layerStyle.stroke", json!({"layer": other.0, "size": 2})).unwrap();
assert!(!s.active().unwrap().fx_collapsed.contains(&other));
// Editing the existing effect must not unexpectedly change its disclosure.
s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 5})).unwrap();
assert!(s.active().unwrap().fx_collapsed.contains(&target));
}
/// The disclosure follows the layer the command changes, however the command names it: a
/// layer given by name is not the active one.
#[test]
fn expand_new_effects_follows_a_layer_named_by_its_name() {
let (mut s, ids) = new_session();
option(&mut s, "expandNewEffects", false);
let (named, active) = (ids[0], ids[2]);
assert_eq!(s.active().unwrap().active_layer, Some(active));
s.execute("layer.layerStyle.dropShadow", json!({"layer": "Test 0", "size": 2})).unwrap();
assert!(s.active().unwrap().doc.layer(named).is_some_and(|l| !l.effects.items.is_empty()));
assert!(s.active().unwrap().fx_collapsed.contains(&named), "the named layer's new effects start collapsed");
assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed");
}
/// Transfer Effects changes the layer it moves the effects to, so that is the layer whose
/// disclosure follows the option, not the active layer or the one the effects came from.
#[test]
fn expand_new_effects_follows_the_transfer_destination() {
let (mut s, ids) = new_session();
let (from, to, active) = (ids[0], ids[1], ids[2]);
s.execute("layer.layerStyle.dropShadow", json!({"layer": from.0, "size": 2})).unwrap();
option(&mut s, "expandNewEffects", false);
assert_eq!(s.active().unwrap().active_layer, Some(active));
s.execute("layer.layerStyle.transferEffects", json!({"from": from.0, "to": to.0, "copy": true})).unwrap();
assert!(s.active().unwrap().doc.layer(to).is_some_and(|l| !l.effects.items.is_empty()));
assert!(s.active().unwrap().fx_collapsed.contains(&to), "the destination's new effects start collapsed");
assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed");
}
}
+6 -1
View File
@@ -1205,10 +1205,15 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(shown) = crate::patch_preview::display_doc(app, idx) {
return shown;
}
// Content-Aware Move drag or box: the result, once computed in the background.
// Content-Aware Move drag or box: the result, once computed in the background. It comes
// before the reorder preview, which is built from the session document and would drop it.
if let Some(shown) = crate::cam_preview::display_doc(app, idx) {
return shown;
}
// Live Layers-panel reorder previews the actual composite before mouse release.
if let Some(shown) = crate::layers_panel_ui::display_reorder(app, idx) {
return shown;
}
// A blend mode hovered in the Layers panel.
if let Some(shown) = crate::blend_preview::display_doc(app, idx) {
return shown;
+4 -1
View File
@@ -83,14 +83,17 @@ fn the_clip_cursor_shows_only_with_alt_over_a_line() {
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "mid-row: no line");
}
/// A 32-point-high row for `layer` starting `top` points down, with no thumbnail, name or indicators.
fn row(layer: LayerId, top: f32) -> RowRects {
RowRects { layer: layer.0, row: Rect::from_min_size(pos2(0.0, top), vec2(240.0, 32.0)), name: None, indicators: Vec::new() }
RowRects { layer: layer.0, row: Rect::from_min_size(pos2(0.0, top), vec2(240.0, 32.0)), thumbnail: None, name: None, indicators: Vec::new() }
}
/// The id of the layer called `name`, wherever it sits in the tree.
fn named(doc: &Document, name: &str) -> LayerId {
doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id
}
/// A clipping line appears only between siblings that are neighbours in the stack, not at a group's edge, in the eye column or inside a row.
#[test]
fn only_siblings_next_to_each_other_meet_at_a_line() {
// Background, `d`, and the group `g { c }` above it.
+6
View File
@@ -638,6 +638,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
ui.close();
}
ui.separator();
if ui.button(tl!("Close")).clicked() {
if let Some(&tab) = indices.get(sel) {
actions.push(Action::CloseTab(g, tab));
}
ui.close();
}
if ui.button(tl!("Close Tab Group")).clicked() {
actions.push(Action::Close(g));
ui.close();
+21
View File
@@ -1148,3 +1148,24 @@ fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() {
apply(&mut workspace, &saved);
assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout");
}
/// Layers panel options live in the preferences on their own (like Photoshop's Panel Options),
/// so a workspace layout neither saves nor restores them.
#[test]
fn workspace_layouts_leave_the_layers_panel_options_alone() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.layers_panel_options.show_filters = false;
crate::layers_panel_ui::persist_options(&mut app);
let stored = app.session.prefs().dialogs.get("ui.layersPanelOptions").cloned();
assert!(stored.is_some(), "the options are saved in the preferences");
let mut saved = snapshot(&app);
assert!(saved.get("layersPanelOptions").is_none(), "a layout does not carry the options");
// A layout saved by an earlier build still has the key; it must not win over the preferences.
saved["layersPanelOptions"] = json!({"showFilters": true, "thumbnailSize": "large"});
apply(&mut app, &saved);
assert!(!app.ui.layers_panel_options.show_filters);
assert_ne!(app.ui.layers_panel_options.thumbnail_size, crate::state::LayerThumbnailSize::Large);
assert_eq!(app.session.prefs().dialogs.get("ui.layersPanelOptions").cloned(), stored);
}
+20 -3
View File
@@ -2667,6 +2667,26 @@ cameraData Lens Objektiv
Duplicate Layer Duplikovat vrstvu
Contract Zúžit
Animation Options Možnosti animace
Entire Document Celý dokument
Filter Options Možnosti filtrování
Layer Bounds Hranice vrstvy
Layers Panel Options Možnosti panelu Vrstvy
Lock Layers Zamknout vrstvy
New Artboard… Nové kreslicí plátno…
New Group Nová skupina
New Group… Nová skupina…
New Layer… Nová vrstva…
Panel Options… Možnosti panelu…
Prevent Auto-Nesting Zabránit automatickému vnoření
Show Layers Zobrazit vrstvy
Thumbnail Contents Obsah miniatur
Thumbnail Size Velikost miniatur
Expand New Effects Rozbalovat nové efekty
Add “copy” to Copied Layers and Groups Přidávat „kopie“ ke kopírovaným vrstvám a skupinám
Preview on canvas when reordering layers Zobrazovat náhled na plátně při změně pořadí vrstev
Show layer mask badges Zobrazovat odznaky masek vrstev
# Dynamic labels the tl! literal scanner cannot see: filter and Filter Gallery parameter names and
# choices (humanized from their keys), Camera Raw controls, render filters (Flame, Picture Frame, Tree),
# measurement columns, file info fields and the macOS application menu. Translated the same way as
@@ -3088,7 +3108,6 @@ cameraData Lens Objektiv
Liquify Zkapalnit
List Seznam
Load Selection Načíst výběr
Lock Layers Zamknout vrstvy
Locked Zamčeno
Long Lines Dlouhé čáry
Long Strokes Dlouhé tahy
@@ -3136,11 +3155,9 @@ cameraData Lens Objektiv
Neon Glow Neonová záře
Neutralize Neutralizovat
New Channel Nový kanál
New Group Nová skupina
New Guide Nové vodítko
New Guide Layout Nové rozvržení vodítek
New Layer Comp Nová kompozice vrstev
New Layer… Nová vrstva…
New Video Layer from File Nová video vrstva ze souboru
New document Nový dokument
New document… ⌘N Nový dokument… ⌘N
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -2698,6 +2698,25 @@ cameraData Lens Objektiv
Output To Ausgabe in
Duplicate Layer Ebene duplizieren
Contract Verkleinern
Animation Options Animationsoptionen
Entire Document Gesamtes Dokument
Filter Options Filteroptionen
Layer Bounds Ebenenbegrenzungen
Layers Panel Options Optionen des Ebenenbedienfelds
Lock Layers Ebenen sperren
New Artboard… Neue Zeichenfläche…
New Group… Neue Gruppe…
New Layer… Neue Ebene…
Panel Options… Bedienfeldoptionen…
Prevent Auto-Nesting Automatisches Verschachteln verhindern
Show Layers Ebenen einblenden
Thumbnail Contents Miniaturinhalt
Thumbnail Size Miniaturgröße
Expand New Effects Neue Effekte aufklappen
Add “copy” to Copied Layers and Groups „Kopie“ zu kopierten Ebenen und Gruppen hinzufügen
Preview on canvas when reordering layers Vorschau auf der Arbeitsfläche beim Neuanordnen von Ebenen
Show layer mask badges Ebenenmasken-Abzeichen anzeigen
Loading fonts… Schriften werden geladen…
Response curve Reaktionskurve
Tool Werkzeug
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -3752,6 +3752,22 @@ cameraData Lens Φακός
Mirror Κατοπτρισμός
Output Settings Ρυθμίσεις εξόδου
Output To Έξοδος σε
Animation Options Επιλογές κίνησης
Entire Document Ολόκληρο έγγραφο
Filter Options Επιλογές φίλτρου
Layer Bounds Όρια επιπέδου
Layers Panel Options Επιλογές πίνακα επιπέδων
New Artboard… Νέος πίνακας σχεδίασης…
New Group… Νέα ομάδα…
Prevent Auto-Nesting Αποτροπή αυτόματης ένθεσης
Show Layers Εμφάνιση επιπέδων
Thumbnail Contents Περιεχόμενο μικρογραφιών
Thumbnail Size Μέγεθος μικρογραφιών
Expand New Effects Ανάπτυξη νέων εφέ
Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες
Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων
Show layer mask badges Εμφάνιση σημάτων μασκών επιπέδου
Loading fonts… Φόρτωση γραμματοσειρών…
Response curve Καμπύλη απόκρισης
Simulate Pressure Προσομοίωση πίεσης
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+19 -3
View File
@@ -2738,6 +2738,25 @@ cameraData Lens Objetivo
Mirror Reflejar
Output Settings Ajustes de salida
Output To Salida a
Animation Options Opciones de animación
Entire Document Documento entero
Filter Options Opciones de filtro
Layer Bounds Límites de capa
Layers Panel Options Opciones del panel Capas
Lock Layers Bloquear capas
New Artboard… Nueva mesa de trabajo…
New Group… Nuevo grupo…
New Layer… Nueva capa…
Panel Options… Opciones de panel…
Prevent Auto-Nesting Impedir anidación automática
Show Layers Mostrar capas
Thumbnail Contents Contenido de miniaturas
Thumbnail Size Tamaño de miniaturas
Expand New Effects Expandir nuevos efectos
Add “copy” to Copied Layers and Groups Añadir «copia» a las capas y grupos duplicados
Preview on canvas when reordering layers Previsualizar en el lienzo al reordenar capas
Show layer mask badges Mostrar insignias de máscaras de capa
Loading fonts… Cargando fuentes…
Response curve Curva de respuesta
Tool Herramienta
@@ -2831,7 +2850,6 @@ curves Smooth Suavizar
@id select.all Seleccionar todo
Delete Smart Filters Eliminar filtros inteligentes
Enable Smart Filters Activar filtros inteligentes
New Layer… Nueva capa…
Delete Layer… Eliminar capa…
Select Similar Colors Seleccionar colores similares
Canvas rotation Rotación del lienzo
@@ -2908,7 +2926,6 @@ curves Smooth Suavizar
Automatically Create New Snapshot When Saving Crear automáticamente una instantánea nueva al guardar
Show New Snapshot Dialog by Default Mostrar de forma predeterminada el cuadro de diálogo Nueva instantánea
Clear History Borrar historial
Panel Options… Opciones de panel…
View menu Menú Vista
Keep view in sync Mantener la vista sincronizada
{zoom}% {zoom}%
@@ -3819,7 +3836,6 @@ layerLabel No Color Sin color
Lens Flare Destello
Lighting Effects Efectos de iluminación
Liquify Licuar
Lock Layers Bloquear capas
Lum Lum
Match Color Igualar color
Merge to HDR Pro Combinar en HDR Pro
Can't render this file because it contains an unexpected character in line 4 and column 47.
+17
View File
@@ -3074,6 +3074,23 @@ cameraData Lens Objectif
Output To Sortie vers
Duplicate Layer Dupliquer le calque
Contract Contracter
Animation Options Options d’animation
Entire Document Document entier
Filter Options Options de filtrage
Layer Bounds Limites du calque
Layers Panel Options Options du panneau Calques
Lock Layers Verrouiller les calques
New Artboard… Nouveau plan de travail…
New Group… Nouveau groupe…
Prevent Auto-Nesting Empêcher l’imbrication automatique
Show Layers Afficher les calques
Thumbnail Contents Contenu des vignettes
Thumbnail Size Taille des vignettes
Expand New Effects Développer les nouveaux effets
Add “copy” to Copied Layers and Groups Ajouter « copie » aux calques et groupes dupliqués
Preview on canvas when reordering layers Prévisualiser sur la zone de travail lors du réagencement des calques
Show layer mask badges Afficher les badges des masques de fusion
Loading fonts… Chargement des polices…
Response curve Courbe de réponse
Tool Outil
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17
View File
@@ -4151,6 +4151,23 @@ cameraRaw Yellow Sárga
Lines/Inch Vonal/hüvelyk
Lines/Centimeter Vonal/centiméter
Ellipse Ellipszis
# Layers panel options and Smart Filter editors
Add “copy” to Copied Layers and Groups „másolat” hozzáadása a másolt rétegekhez és csoportokhoz
Animation Options Animációs beállítások
Entire Document Teljes dokumentum
Expand New Effects Új effektusok kibontása
Filter Options Szűrési beállítások
Layer Bounds Réteghatárok
Layers Panel Options Rétegek panel beállításai
New Artboard… Új rajztábla…
New Group… Új csoport…
Prevent Auto-Nesting Automatikus beágyazás tiltása
Preview on canvas when reordering layers Előnézet a vásznon a rétegek átrendezésekor
Show Layers Rétegek megjelenítése
Show layer mask badges Rétegmaszk-jelvények megjelenítése
Thumbnail Contents Bélyegkép tartalma
Thumbnail Size Bélyegképméret
Accept and Go to Conflict Elfogadás és ugrás az ütközéshez
Changes undone. Változások visszavonva.
Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17
View File
@@ -3061,6 +3061,23 @@ cameraData Lens Lensa
Output To Keluaran Ke
Duplicate Layer Duplikat Layer
Contract Ciutkan
Animation Options Opsi Animasi
Entire Document Seluruh Dokumen
Filter Options Opsi Filter
Layer Bounds Batas Lapisan
Layers Panel Options Opsi Panel Lapisan
Lock Layers Kunci Lapisan
New Artboard… Artboard Baru…
New Group… Grup Baru…
Prevent Auto-Nesting Cegah Pengelompokan Otomatis
Show Layers Tampilkan Lapisan
Thumbnail Contents Isi Gambar Mini
Thumbnail Size Ukuran Gambar Mini
Expand New Effects Perluas Efek Baru
Add “copy” to Copied Layers and Groups Tambahkan “salinan” ke Lapisan dan Grup yang Disalin
Preview on canvas when reordering layers Pratinjau pada kanvas saat mengurutkan ulang lapisan
Show layer mask badges Tampilkan Lencana Masker Lapisan
Loading fonts… Memuat font…
Response curve Kurva respons
Tool Alat
Can't render this file because it contains an unexpected character in line 4 and column 47.
+16
View File
@@ -2726,6 +2726,22 @@ cameraData Lens Obiettivo
Mirror Rifletti
Output Settings Impostazioni di output
Output To Output in
Animation Options Opzioni animazione
Entire Document Documento intero
Filter Options Opzioni filtro
Layer Bounds Limiti livello
Layers Panel Options Opzioni pannello Livelli
New Artboard… Nuova tavola da disegno…
New Group… Nuovo gruppo…
Prevent Auto-Nesting Impedisci annidamento automatico
Show Layers Mostra livelli
Thumbnail Contents Contenuto miniature
Thumbnail Size Dimensione miniature
Expand New Effects Espandi nuovi effetti
Add “copy” to Copied Layers and Groups Aggiungi «copia» ai livelli e ai gruppi duplicati
Preview on canvas when reordering layers Anteprima sulla tela durante il riordino dei livelli
Show layer mask badges Mostra distintivi delle maschere di livello
Loading fonts… Caricamento font…
Response curve Curva di risposta
Tool Strumento
Can't render this file because it contains an unexpected character in line 2658 and column 53.
+16
View File
@@ -3667,6 +3667,22 @@ cameraData Lens レンズ
Mirror ミラー
Output Settings 出力設定
Output To 出力先
Animation Options アニメーションオプション
Entire Document ドキュメント全体
Filter Options フィルターオプション
Layer Bounds レイヤーの境界
Layers Panel Options レイヤーパネルオプション
New Artboard… 新規アートボード…
New Group… 新規グループ…
Prevent Auto-Nesting 自動的な入れ子を防ぐ
Show Layers レイヤーを表示
Thumbnail Contents サムネールの内容
Thumbnail Size サムネールのサイズ
Expand New Effects 新しい効果を展開
Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加
Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー
Show layer mask badges レイヤーマスクのバッジを表示
Loading fonts… フォントを読み込み中…
Response curve 応答カーブ
Simulate Pressure 筆圧をシミュレート
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -3242,6 +3242,25 @@ cameraData Lens 렌즈
Mirror 미러
Output Settings 출력 설정
Output To 출력 위치
Animation Options 애니메이션 옵션
Entire Document 전체 문서
Filter Options 필터 옵션
Layer Bounds 레이어 경계
Layers Panel Options 레이어 패널 옵션
New Artboard… 새 아트보드…
New Group 새 그룹
New Group… 새 그룹…
New Layer… 새 레이어…
Panel Options… 패널 옵션…
Prevent Auto-Nesting 자동 중첩 방지
Show Layers 레이어 표시
Thumbnail Contents 축소판 내용
Thumbnail Size 축소판 크기
Expand New Effects 새 효과 펼치기
Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가
Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기
Show layer mask badges 레이어 마스크 배지 표시
Loading fonts… 글꼴 불러오는 중…
Response curve 응답 곡선
Simulate Pressure 압력 시뮬레이션
Can't render this file because it contains an unexpected character in line 3172 and column 53.
+16
View File
@@ -3811,6 +3811,22 @@ cameraData Lens Lens
Mirror Spiegelen
Output Settings Uitvoerinstellingen
Output To Uitvoer naar
Animation Options Animatieopties
Entire Document Hele document
Filter Options Filteropties
Layer Bounds Laaggrenzen
Layers Panel Options Opties van het deelvenster Lagen
New Artboard… Nieuw tekengebied…
New Group… Nieuwe groep…
Prevent Auto-Nesting Automatisch nesten voorkomen
Show Layers Lagen tonen
Thumbnail Contents Inhoud van miniaturen
Thumbnail Size Grootte van miniaturen
Expand New Effects Nieuwe effecten uitvouwen
Add “copy” to Copied Layers and Groups „kopie” toevoegen aan gekopieerde lagen en groepen
Preview on canvas when reordering layers Voorvertoning op canvas bij herschikken van lagen
Show layer mask badges Badges voor laagmaskers tonen
Loading fonts… Lettertypen laden…
Response curve Responscurve
Simulate Pressure Druk simuleren
Can't render this file because it contains an unexpected character in line 8 and column 34.
+16
View File
@@ -3669,6 +3669,22 @@ cameraData Lens Obiektyw
Mirror Odbicie lustrzane
Output Settings Ustawienia wyjścia
Output To Wyjście do
Animation Options Opcje animacji
Entire Document Cały dokument
Filter Options Opcje filtrowania
Layer Bounds Granice warstwy
Layers Panel Options Opcje panelu Warstwy
New Artboard… Nowy obszar roboczy…
New Group… Nowa grupa…
Prevent Auto-Nesting Zapobiegaj automatycznemu zagnieżdżaniu
Show Layers Pokaż warstwy
Thumbnail Contents Zawartość miniaturek
Thumbnail Size Rozmiar miniaturek
Expand New Effects Rozwijaj nowe efekty
Add “copy” to Copied Layers and Groups Dodawaj „kopia” do skopiowanych warstw i grup
Preview on canvas when reordering layers Podgląd na obszarze roboczym podczas zmiany kolejności warstw
Show layer mask badges Pokaż odznaki masek warstw
Loading fonts… Wczytywanie czcionek…
Response curve Krzywa odpowiedzi
Simulate Pressure Symuluj nacisk
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -3078,6 +3078,22 @@ cameraData Lens Lente
New Layer Nova camada
Duplicate Layer Duplicar camada
Contract Contrair
Animation Options Opções de animação
Entire Document Documento inteiro
Filter Options Opções de filtro
Layer Bounds Limites da camada
Layers Panel Options Opções do painel Camadas
New Artboard… Nova prancheta…
New Group… Novo grupo…
Prevent Auto-Nesting Impedir aninhamento automático
Show Layers Mostrar camadas
Thumbnail Contents Conteúdo das miniaturas
Thumbnail Size Tamanho das miniaturas
Expand New Effects Expandir novos efeitos
Add “copy” to Copied Layers and Groups Adicionar “cópia” às camadas e grupos copiados
Preview on canvas when reordering layers Pré-visualizar na tela ao reordenar camadas
Show layer mask badges Mostrar emblemas de máscaras de camada
Loading fonts… Carregando fontes…
Response curve Curva de resposta
Tool Ferramenta
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17
View File
@@ -2998,6 +2998,23 @@ cameraData Lens Объектив
Output To Вывод в
Duplicate Layer Дубликат слоя
Contract Сжать
Animation Options Параметры анимации
Entire Document Весь документ
Filter Options Параметры фильтра
Layer Bounds Границы слоя
Layers Panel Options Параметры панели «Слои»
Lock Layers Блокировка слоёв
New Artboard… Новая монтажная область…
New Group… Новая группа…
Prevent Auto-Nesting Запретить автоматическое вложение
Show Layers Показать слои
Thumbnail Contents Содержимое миниатюр
Thumbnail Size Размер миниатюр
Expand New Effects Раскрывать новые эффекты
Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам
Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв
Show layer mask badges Показывать значки типов масок слоя
Loading fonts… Загрузка шрифтов…
Response curve Кривая отклика
Tool Инструмент
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17
View File
@@ -4305,6 +4305,23 @@ shape Appearance Izgled
Lines/Inch Linija/inč
Lines/Centimeter Linija/centimetar
Ellipse Elipsa
# Layers panel options
Add “copy” to Copied Layers and Groups Dodaj „kopija” kopiranim slojevima i grupama
Animation Options Opcije animacije
Entire Document Ceo dokument
Expand New Effects Proširi nove efekte
Filter Options Opcije filtera
Layer Bounds Granice sloja
Layers Panel Options Opcije panela Slojevi
New Artboard… Nova radna tabla…
New Group… Nova grupa…
Prevent Auto-Nesting Spreči automatsko ugnežđavanje
Preview on canvas when reordering layers Pregled na platnu pri promeni redosleda slojeva
Show Layers Prikaži slojeve
Show layer mask badges Prikaži oznake maske sloja
Thumbnail Contents Sadržaj sličice
Thumbnail Size Veličina sličice
Accept and Go to Conflict Prihvati i idi na sukob
Changes undone. Izmene su poništene.
Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17
View File
@@ -3962,6 +3962,23 @@ cameraData Lens Lens
Lines/Inch Çizgi/İnç
Lines/Centimeter Çizgi/Santimetre
Ellipse Elips
# Layers panel options and Smart Filter editors
Add “copy” to Copied Layers and Groups Kopyalanan Katman ve Gruplara “kopya” Ekle
Animation Options Animasyon Seçenekleri
Entire Document Tüm Belge
Expand New Effects Yeni Efektleri Genişlet
Filter Options Filtre Seçenekleri
Layer Bounds Katman Sınırları
Layers Panel Options Katmanlar Paneli Seçenekleri
New Artboard… Yeni Çalışma Yüzeyi…
New Group… Yeni Grup…
Prevent Auto-Nesting Otomatik Yerleştirmeyi Önle
Preview on canvas when reordering layers Katmanları yeniden sıralarken kanvasta önizle
Show Layers Katmanları Göster
Show layer mask badges Katman maskesi rozetlerini göster
Thumbnail Contents Küçük Resim İçeriği
Thumbnail Size Küçük Resim Boyutu
Accept and Go to Conflict Kabul Et ve Çakışmaya Git
Changes undone. Değişiklikler geri alındı.
Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+16
View File
@@ -3743,6 +3743,22 @@ cameraData Lens Об'єктив
Mirror Віддзеркалення
Output Settings Параметри виводу
Output To Вивести в
Animation Options Параметри анімації
Entire Document Увесь документ
Filter Options Параметри фільтрів
Layer Bounds Межі шару
Layers Panel Options Параметри панелі «Шари»
New Artboard… Нова монтажна область…
New Group… Нова група…
Prevent Auto-Nesting Заборонити автоматичне вкладення
Show Layers Показати шари
Thumbnail Contents Вміст мініатюр
Thumbnail Size Розмір мініатюр
Expand New Effects Розгортати нові ефекти
Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп
Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів
Show layer mask badges Показувати позначки масок шарів
Loading fonts… Завантаження шрифтів…
Response curve Крива відгуку
Simulate Pressure Імітувати натиск
Can't render this file because it contains an unexpected character in line 1393 and column 18.
+18
View File
@@ -3722,6 +3722,24 @@ curves Smooth Làm mượt
Lines/Inch Đường/inch
Lines/Centimeter Đường/centimet
Ellipse Elip
# Layers panel options and Smart Filter editors
Add “copy” to Copied Layers and Groups Thêm “bản sao” vào lớp và nhóm được sao chép
Animation Options Tùy chọn hoạt ảnh
Entire Document Toàn bộ tài liệu
Expand New Effects Mở rộng hiệu ứng mới
Filter Options Tùy chọn bộ lọc
Layer Bounds Đường biên lớp
Layers Panel Options Tùy chọn bảng Lớp
New Artboard… Bảng vẽ mới…
New Group… Nhóm mới…
Panel Options… Tùy chọn bảng…
Prevent Auto-Nesting Chặn tự lồng
Preview on canvas when reordering layers Xem trước trên Canvas khi sắp xếp lại lớp
Show Layers Hiện các lớp
Show layer mask badges Hiện huy hiệu mặt nạ lớp
Thumbnail Contents Nội dung hình thu nhỏ
Thumbnail Size Kích thước hình thu nhỏ
Accept and Go to Conflict Chấp nhận và đến xung đột
Changes undone. Đã hoàn tác thay đổi.
Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2674,6 +2674,26 @@ cameraData Lens 镜头
Output To 输出到
Duplicate Layer 复制图层
Contract 收缩
Animation Options 动画选项
Entire Document 整个文档
Filter Options 过滤选项
Layer Bounds 图层边界
Layers Panel Options 图层面板选项
Lock Layers 锁定图层
New Artboard… 新建画板…
New Group 新建组
New Group… 新建组…
New Layer… 新建图层…
Panel Options… 面板选项…
Prevent Auto-Nesting 防止自动嵌套
Show Layers 显示图层
Thumbnail Contents 缩略图内容
Thumbnail Size 缩略图大小
Expand New Effects 展开新效果
Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本”
Preview on canvas when reordering layers 重新排列图层时在画布上预览
Show layer mask badges 显示图层蒙版标记
Loading fonts… 正在加载字体…
Response curve 响应曲线
Tool 工具
Can't render this file because it contains an unexpected character in line 2606 and column 53.
+20
View File
@@ -2644,6 +2644,26 @@ cameraData Lens 鏡頭
Output To 輸出至
Duplicate Layer 複製圖層
Contract 縮減
Animation Options 動畫選項
Entire Document 整份文件
Filter Options 篩選選項
Layer Bounds 圖層邊界
Layers Panel Options 圖層面板選項
Lock Layers 鎖定圖層
New Artboard… 新增工作區域…
New Group 新增群組
New Group… 新增群組…
New Layer… 新增圖層…
Panel Options… 面板選項…
Prevent Auto-Nesting 防止自動巢狀
Show Layers 顯示圖層
Thumbnail Contents 縮圖內容
Thumbnail Size 縮圖大小
Expand New Effects 展開新效果
Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」
Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽
Show layer mask badges 顯示圖層遮色片標記
Loading fonts… 正在載入字體…
Response curve 回應曲線
Tool 工具
Can't render this file because it contains an unexpected character in line 6 and column 47.
+55 -3
View File
@@ -42,6 +42,8 @@ pub enum Indicator {
pub struct RowRects {
pub layer: u64,
pub row: Rect,
/// Visible layer thumbnail; None when thumbnails are disabled.
pub thumbnail: Option<Rect>,
/// The name as painted (after truncation); `None` when there was no room for it.
pub name: Option<Rect>,
pub indicators: Vec<(Indicator, Rect)>,
@@ -206,10 +208,60 @@ pub fn label(painter: &Painter, x: f32, cy: f32, right: f32, text: &str, font: F
Some(r)
}
/// The Layers panel's own items at the top of its panel menu (Photoshop's flyout).
/// Photoshop-style Layers flyout commands. The dock owns tab-group controls, not this menu.
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(tl!("Collapse All Groups"))).clicked() {
use crate::layers_panel_ui::run_menu_command;
use crate::state::LayersPanelDialog;
let has_doc = app.session.active().is_some();
if ui.add_enabled(has_doc, egui::Button::new(tl!("New Layer…"))).clicked() {
app.ui.layers_panel_dialog = Some(LayersPanelDialog::NewLayer(String::new()));
ui.close();
}
if ui.add_enabled(has_doc, egui::Button::new(tl!("New Group…"))).clicked() {
app.ui.layers_panel_dialog = Some(LayersPanelDialog::NewGroup(String::new()));
ui.close();
}
ui.separator();
run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage");
ui.separator();
if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() {
app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true });
ui.close();
}
run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer");
let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible)));
if any_hidden {
run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers");
} else {
run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers");
}
ui.separator();
run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard");
ui.separator();
ui.menu_button(tl!("Filter Options"), |ui| {
let mut show = app.ui.layers_panel_options.show_filters;
if ui.checkbox(&mut show, tl!("Show")).changed() {
app.ui.layers_panel_options.show_filters = show;
crate::layers_panel_ui::persist_options(app);
ui.close();
}
});
// Animation Options control Photoshop's frame-unification buttons. PhotoCraft's Layers
// panel does not expose those buttons; do not offer nonfunctional toggles.
ui.add_enabled_ui(false, |ui| {
ui.menu_button(tl!("Animation Options"), |_ui| {});
});
if ui.button(tl!("Panel Options…")).clicked() {
let mut options = app.ui.layers_panel_options.clone();
options.thumbnail_contents = crate::layers_panel_ui::thumbnail_contents(app);
app.ui.layers_panel_dialog = Some(LayersPanelDialog::PanelOptions(options));
ui.close();
}
ui.separator();
// Preserve PhotoCraft's existing group disclosure action when expanding the flyout.
let can_collapse = app.session.is_enabled("layer.setExpanded");
if ui.add_enabled(can_collapse, egui::Button::new(tl!("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;
+29
View File
@@ -537,6 +537,35 @@ fn press_drag_release_on_the_fx_button_opens_that_style() {
assert!(effects.iter().any(|e| e["kind"] == json!("dropShadow")), "on Drop Shadow: {effects:?}");
}
/// Integration regression: a display preference must resize the *actual painted*
/// thumbnails and row hitboxes, not only its Preview dialog.
#[test]
fn panel_thumbnail_sizes_change_real_row_geometry() {
use crate::state::LayerThumbnailSize;
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
session.execute("layer.new.layer", json!({"name": "Photo"})).unwrap();
session.execute("edit.fill", json!({"color": "#9acb32"})).unwrap();
let layer = session.active().unwrap().active_layer.unwrap();
let mut h = harness(session, 1.0, "promedium", 290.0);
for (size, expected_thumb, expected_row) in [
(LayerThumbnailSize::None, 0.0, 23.0),
(LayerThumbnailSize::Small, 18.0, 32.0),
(LayerThumbnailSize::Medium, 24.0, 32.0),
(LayerThumbnailSize::Large, 48.0, 56.0),
] {
h.state_mut().ui.layers_panel_options.thumbnail_size = size;
h.run_steps(5);
let found = recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).expect("visible layer row");
assert!((found.row.height() - expected_row).abs() < 0.1, "{size:?}: row {}", found.row.height());
match found.thumbnail {
Some(rect) => assert!((rect.width() - expected_thumb).abs() < 0.1, "{size:?}: thumbnail {}", rect.width()),
None => assert_eq!(expected_thumb, 0.0, "{size:?} should display a thumbnail"),
}
assert!(found.name.is_some(), "{size:?}: layer name still visible");
}
}
/// One layer per lock, each with only that lock set (#4158).
fn one_lock_each() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) {
let mut s = photocraft_engine::Session::new();
+454
View File
@@ -0,0 +1,454 @@
//! Layers panel flyout and display preferences, observed from Photoshop's panel menu.
//! UI options do not edit document pixels or add history steps.
use egui::{self, Context, Ui};
use photocraft_doc::Document;
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::{LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog, LayersPanelOptions};
const OPTIONS_PREF: &str = "ui.layersPanelOptions";
/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does.
pub fn persist_options(app: &mut PhotocraftApp) {
if let Ok(value) = serde_json::to_value(&app.ui.layers_panel_options) {
app.session.prefs.edit(|p| {
p.dialogs.insert(OPTIONS_PREF.into(), value);
});
}
}
/// Restore independently from a saved workspace. Older preferences retain the defaults.
pub fn restore_options(app: &mut PhotocraftApp) {
if let Some(opts) = app.session.prefs().dialogs.get(OPTIONS_PREF)
&& let Ok(value) = serde_json::from_value(opts.clone())
{
app.ui.layers_panel_options = value;
}
}
/// The real menu command dispatcher handles rename's in-place editor and other shell routing.
pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) {
if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() {
let ctx = ui.ctx().clone();
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
app.ui.status = e;
app.ui.status_error = true;
}
ui.close();
}
}
/// Thumbnail Contents as the shared preference has it: Layer Bounds is Clip Thumbnails to Layer
/// Bounds (#4281), which frames each layer's pixels instead of the canvas.
pub fn thumbnail_contents(app: &PhotocraftApp) -> LayerThumbnailContents {
if app.session.prefs().interface.clip_thumbnails_to_layer_bounds { LayerThumbnailContents::LayerBounds } else { LayerThumbnailContents::EntireDocument }
}
/// Panel Options › OK: the panel's own options, and Thumbnail Contents into the shared preference.
fn apply_options(app: &mut PhotocraftApp, options: LayersPanelOptions) -> Result<(), String> {
let contents = options.thumbnail_contents;
app.ui.layers_panel_options = options;
persist_options(app);
if contents == thumbnail_contents(app) {
return Ok(());
}
let clip = contents == LayerThumbnailContents::LayerBounds;
app.run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": clip})).map(|_| ())
}
/// Preview cache recomputes only when hovering a new target, not each pointer frame.
/// Compositor sees it as a temporary document and never commits it to history.
pub(crate) struct ReorderPreview {
pub source: photocraft_doc::DocId,
pub revision: u64,
pub signature: u64,
pub key: u64,
pub document: std::sync::Arc<Document>,
}
/// Cache the on-canvas preview of the layer drop in `payload` (a `layer.moveTo` request), unless Preview on Canvas is off.
/// Marks the destination as hovered every frame; the preview itself is rebuilt only when the target changes.
pub(crate) fn preview_reorder(app: &mut PhotocraftApp, payload: &serde_json::Value) {
if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers {
app.reorder_preview = None;
return;
}
// Mark the destination as hovered even if the cached preview already matches.
app.reorder_preview_hit = true;
let Some(st) = app.session.active() else { return };
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
payload.to_string().hash(&mut hasher);
st.doc.id.0.hash(&mut hasher);
st.revision.hash(&mut hasher);
let signature = hasher.finish();
if app.reorder_preview.as_ref().is_some_and(|cache| cache.signature == signature) {
return;
}
let (source, revision) = (st.doc.id, st.revision);
let add_copy = app.ui.layers_panel_options.add_copy_to_copied_layers_and_groups;
let preview = photocraft_engine::layer_multi_cmds::preview_move(&st.doc, payload, add_copy);
app.reorder_preview = preview.map(|document| ReorderPreview {
source,
revision,
signature,
key: (1u64 << 60) | (signature & ((1u64 << 60) - 1)),
document: std::sync::Arc::new(document),
});
}
/// The cached reorder preview and its compositor cache key for document `index`, if it was built for that document's current revision.
pub(crate) fn display_reorder(app: &PhotocraftApp, index: usize) -> Option<(std::sync::Arc<Document>, u64)> {
if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers {
return None;
}
let st = app.session.documents().get(index)?;
let cached = app.reorder_preview.as_ref()?;
(cached.source == st.doc.id && cached.revision == st.revision).then(|| (cached.document.clone(), cached.key))
}
/// Photoshop shows the same flower at three resolutions. This example is original,
/// vector-painted artwork, not an extracted Adobe asset, and it is independent of
/// whichever layer happens to be selected (including adjustment layers).
fn paint_flower_example(ui: &Ui, rect: egui::Rect) {
use egui::{Color32, Rect as UiRect, Shape, Stroke, pos2, vec2};
let painter = ui.painter().with_clip_rect(rect);
let side = rect.width();
let point = |x: f32, y: f32| pos2(rect.min.x + x * side, rect.min.y + y * side);
// A monochrome miniature landscape: light sky, shaded hills and a dark meadow.
for n in 0..22 {
let y0 = n as f32 / 22.0;
let y1 = (n + 1) as f32 / 22.0;
let f = n as f32 / 22.0;
painter.rect_filled(UiRect::from_min_max(point(0.0, y0), point(1.0, y1)), 0.0, Color32::from_gray((119.0 + f * 30.0) as u8));
}
painter.add(Shape::convex_polygon(
vec![point(0.0, 0.60), point(0.22, 0.36), point(0.46, 0.56), point(0.72, 0.33), point(1.0, 0.62)],
Color32::from_gray(80),
Stroke::NONE,
));
painter.add(Shape::convex_polygon(
vec![point(0.0, 0.76), point(0.32, 0.60), point(0.57, 0.69), point(1.0, 0.51), point(1.0, 1.0), point(0.0, 1.0)],
Color32::from_gray(64),
Stroke::NONE,
));
for n in 0..16 {
let x = ((n * 43 + 13) % 97) as f32 / 96.0;
let y = 0.78 + ((n * 11 % 19) as f32) / 100.0;
painter.line_segment([point(x, 1.0), point((x + 0.02).min(1.0), y)], Stroke::new(side * 0.01, Color32::from_gray(104)));
}
// A white daisy with layered petals and a dark central disc, in the same
// composition at all three sizes, so the controls genuinely preview scale.
let center = point(0.49, 0.57);
let unit = side * 0.34;
for n in 0..15 {
let theta = (n as f32 + 0.5) * std::f32::consts::TAU / 15.0;
let direction = vec2(theta.cos(), theta.sin());
let normal = vec2(-theta.sin(), theta.cos());
let mut outline = Vec::with_capacity(20);
for i in 0..=9 {
let t = i as f32 / 9.0;
let base = center + direction * unit * (0.22 + 0.83 * t);
outline.push(base + normal * (unit * 0.22 * (std::f32::consts::PI * t).sin()));
}
for i in (0..=9).rev() {
let t = i as f32 / 9.0;
let base = center + direction * unit * (0.22 + 0.83 * t);
outline.push(base - normal * (unit * 0.22 * (std::f32::consts::PI * t).sin()));
}
painter.add(Shape::convex_polygon(
outline,
if n % 3 == 0 { Color32::from_gray(213) } else { Color32::from_gray(238) },
Stroke::new(0.3, Color32::from_gray(155)),
));
}
painter.circle_filled(center, unit * 0.27, Color32::from_gray(33));
painter.circle_filled(center - vec2(unit * 0.065, unit * 0.055), unit * 0.16, Color32::from_gray(55));
for n in 0..16 {
let theta = (n as f32) * std::f32::consts::TAU / 16.0;
let r = unit * if n % 2 == 0 { 0.16 } else { 0.21 };
painter.circle_filled(center + vec2(theta.cos(), theta.sin()) * r, (side * 0.008).max(0.3), Color32::from_gray(119));
}
painter.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::from_gray(92)), egui::StrokeKind::Inside);
}
/// Returns both the actual thumbnail width and the height allocated by the Layers
/// row. Used by painting and by tests to keep the preference functional.
pub(crate) fn thumbnail_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32) {
let thumb: f32 = match size {
LayerThumbnailSize::None => 0.0,
LayerThumbnailSize::Small => 18.0,
LayerThumbnailSize::Medium => {
if pro {
24.0
} else {
34.0
}
}
LayerThumbnailSize::Large => 48.0,
};
let row = if thumb == 0.0 { 23.0 } else { (thumb + 8.0).max(if pro { 32.0 } else { 40.0 }) };
(thumb, row)
}
/// Content column of the Photoshop-style dialog, with fixed-size radio/preview
/// slots rather than text labels that change the position of each thumbnail.
fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) {
ui.group(|ui| {
ui.set_min_width(308.0);
ui.label(egui::RichText::new(tl!("Thumbnail Size")).strong());
ui.add_space(7.0);
// The original Photoshop dialog has None with a label, then progressively
// larger previews, with the radio controls aligned vertically.
ui.horizontal(|ui| {
let r = ui.radio_value(&mut draft.thumbnail_size, LayerThumbnailSize::None, tl!("None"));
r.on_hover_text(tl!("None"));
});
ui.add_space(3.0);
for (size, side, name) in [
(LayerThumbnailSize::Small, 38.0, tl!("Small")),
(LayerThumbnailSize::Medium, 68.0, tl!("Medium")),
(LayerThumbnailSize::Large, 96.0, tl!("Large")),
] {
ui.horizontal(|ui| {
// Empty radio captions are intentional: the sample itself conveys
// size. The accessible/hover label still names the setting.
let radio = ui.radio_value(&mut draft.thumbnail_size, size, "");
radio.on_hover_text(name);
ui.add_space(8.0);
let (rect, response) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::click());
paint_flower_example(ui, rect);
if response.clicked() {
draft.thumbnail_size = size;
}
response.on_hover_text(name);
});
ui.add_space(5.0);
}
});
ui.add_space(9.0);
ui.group(|ui| {
ui.set_min_width(308.0);
ui.label(egui::RichText::new(tl!("Thumbnail Contents")).strong());
ui.add_space(10.0);
ui.radio_value(&mut draft.thumbnail_contents, LayerThumbnailContents::LayerBounds, tl!("Layer Bounds"));
ui.add_space(4.0);
ui.radio_value(&mut draft.thumbnail_contents, LayerThumbnailContents::EntireDocument, tl!("Entire Document"));
ui.add_space(4.0);
});
ui.add_space(9.0);
ui.checkbox(&mut draft.expand_new_effects, tl!("Expand New Effects"));
ui.add_space(3.0);
ui.checkbox(&mut draft.add_copy_to_copied_layers_and_groups, tl!("Add “copy” to Copied Layers and Groups"));
ui.add_space(3.0);
ui.checkbox(&mut draft.preview_on_canvas_when_reordering_layers, tl!("Preview on canvas when reordering layers"));
ui.add_space(3.0);
ui.checkbox(&mut draft.show_layer_mask_badges, tl!("Show layer mask badges"));
}
/// Show the pending Layers panel dialog (Panel Options, New Layer, New Group, Lock Layers) as a modal and apply it on OK.
pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) {
let Some(mut dialog) = app.ui.layers_panel_dialog.take() else { return };
let mut confirmed = false;
let mut cancelled = ctx.input(|i| i.key_pressed(egui::Key::Escape));
let title = match &dialog {
LayersPanelDialog::PanelOptions(_) => tl!("Layers Panel Options"),
LayersPanelDialog::NewLayer(_) => tl!("New Layer"),
LayersPanelDialog::NewGroup(_) => tl!("New Group"),
LayersPanelDialog::LockLayers { .. } => tl!("Lock Layers"),
};
egui::Modal::new(egui::Id::new("layers-panel-options-modal")).show(ctx, |ui| {
ui.set_width(if matches!(dialog, LayersPanelDialog::PanelOptions(_)) { 480.0 } else { 380.0 });
ui.horizontal(|ui| {
ui.heading(title);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("×").on_hover_text(tl!("Cancel")).clicked() {
cancelled = true;
}
});
});
ui.separator();
match &mut dialog {
LayersPanelDialog::PanelOptions(opts) => {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(345.0);
ui.vertical(|ui| options_body(ui, opts));
});
ui.add_space(10.0);
ui.vertical(|ui| {
ui.set_min_width(74.0);
if ui.add_sized([74.0, 29.0], egui::Button::new(tl!("OK"))).clicked() {
confirmed = true;
}
ui.add_space(6.0);
if ui.add_sized([74.0, 29.0], egui::Button::new(tl!("Cancel"))).clicked() {
cancelled = true;
}
});
});
}
LayersPanelDialog::NewLayer(name) | LayersPanelDialog::NewGroup(name) => {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
}
LayersPanelDialog::LockLayers { transparency, pixels, position, artboard, all } => {
ui.checkbox(transparency, tl!("Transparency"));
ui.checkbox(pixels, tl!("Image"));
ui.checkbox(position, tl!("Position"));
ui.checkbox(artboard, tl!("Prevent Auto-Nesting"));
ui.checkbox(all, tl!("All"));
}
}
if !matches!(dialog, LayersPanelDialog::PanelOptions(_)) {
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button(tl!("OK")).clicked() {
confirmed = true;
}
if ui.button(tl!("Cancel")).clicked() {
cancelled = true;
}
});
}
});
if confirmed && !cancelled {
let r = match dialog {
LayersPanelDialog::PanelOptions(options) => apply_options(app, options),
LayersPanelDialog::NewLayer(name) => {
app.run("layer.new.layer", if name.trim().is_empty() { json!({}) } else { json!({"name": name.trim()}) }).map(|_| ())
}
LayersPanelDialog::NewGroup(name) => {
app.run("layer.new.group", if name.trim().is_empty() { json!({}) } else { json!({"name": name.trim()}) }).map(|_| ())
}
LayersPanelDialog::LockLayers { transparency, pixels, position, artboard, all } => app
.run("layer.lockLayers", json!({"transparency": transparency, "pixels": pixels, "position": position, "artboard": artboard, "all": all}))
.map(|_| ()),
};
if let Err(e) = r {
app.ui.status = e;
app.ui.status_error = true;
}
} else if !cancelled {
app.ui.layers_panel_dialog = Some(dialog);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh install shows Medium thumbnails of the whole document, like Photoshop's Panel Options defaults.
#[test]
fn default_panel_options_match_medium_document_previews() {
let options = LayersPanelOptions::default();
assert_eq!(options.thumbnail_size, LayerThumbnailSize::Medium);
assert_eq!(options.thumbnail_contents, LayerThumbnailContents::EntireDocument);
assert!(options.show_filters);
assert!(options.expand_new_effects);
assert!(options.add_copy_to_copied_layers_and_groups);
assert!(options.preview_on_canvas_when_reordering_layers);
assert!(!options.show_layer_mask_badges);
}
/// Thumbnail Contents is the same setting as the panel menu's Clip Thumbnails to Layer Bounds
/// (#4281): the dialog shows it and OK sets it, so the two never disagree.
#[test]
fn thumbnail_contents_is_clip_thumbnails_to_layer_bounds() {
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let mut app = PhotocraftApp::new(session, crate::Services::default());
assert_eq!(thumbnail_contents(&app), LayerThumbnailContents::EntireDocument);
let mut options = app.ui.layers_panel_options.clone();
options.thumbnail_contents = LayerThumbnailContents::LayerBounds;
apply_options(&mut app, options.clone()).unwrap();
assert!(app.session.prefs().interface.clip_thumbnails_to_layer_bounds);
assert_eq!(thumbnail_contents(&app), LayerThumbnailContents::LayerBounds);
assert!(app.session.prefs().dialogs[OPTIONS_PREF].get("thumbnailContents").is_none(), "saved once, in the shared preference");
options.thumbnail_contents = LayerThumbnailContents::EntireDocument;
apply_options(&mut app, options).unwrap();
assert!(!app.session.prefs().interface.clip_thumbnails_to_layer_bounds);
}
/// The options survive a UiState round trip; an open dialog is view state and is never serialized.
#[test]
fn options_round_trip_without_a_pending_dialog() {
let mut ui = crate::state::UiState::default();
ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large;
ui.layers_panel_options.show_filters = false;
ui.layers_panel_dialog = Some(LayersPanelDialog::NewGroup("test".into()));
let json = serde_json::to_value(&ui).unwrap();
assert!(json.get("layers_panel_dialog").is_none());
let restored: crate::state::UiState = serde_json::from_value(json).unwrap();
assert_eq!(restored.layers_panel_options, ui.layers_panel_options);
assert!(restored.layers_panel_dialog.is_none());
}
/// Every option, including the mask badges, goes to the preferences and comes back from them.
#[test]
fn prefs_options_save_and_restore_all_switches() {
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let mut app = PhotocraftApp::new(session, crate::Services::default());
let saved = LayersPanelOptions {
thumbnail_size: LayerThumbnailSize::Large,
thumbnail_contents: LayerThumbnailContents::EntireDocument,
show_filters: false,
expand_new_effects: false,
add_copy_to_copied_layers_and_groups: false,
preview_on_canvas_when_reordering_layers: false,
show_layer_mask_badges: true,
};
app.ui.layers_panel_options = saved.clone();
persist_options(&mut app);
assert_eq!(app.session.prefs().dialogs[OPTIONS_PREF]["showLayerMaskBadges"], true);
app.ui.layers_panel_options = LayersPanelOptions::default();
restore_options(&mut app);
assert_eq!(app.ui.layers_panel_options, saved);
}
/// The flyout's Flatten Image runs the real `layer.flattenImage` command, and Undo brings the layers back.
#[test]
fn flyout_flatten_routes_to_real_engine_and_undo_restores_layers() {
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 18, "height": 12})).unwrap();
session.execute("layer.new.layer", json!({"name": "Red pixels"})).unwrap();
session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
let mut app = PhotocraftApp::new(session, crate::Services::default());
let count = app.session.active().unwrap().doc.layers.len();
assert!(count >= 2);
// The flyout uses menus::invoke with this exact command id.
crate::menus::invoke(&mut app, &egui::Context::default(), "layer.flattenImage", json!({})).unwrap();
let flattened = app.session.active().unwrap();
assert_eq!(flattened.doc.layers.len(), 1);
assert_eq!(flattened.doc.layers[0].name, "Background");
assert!(flattened.doc.layers[0].surface().is_some());
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.layers.len(), count);
}
/// Thumbnail and row sizes grow from None to Large in both themes, with the rows at least as tall as the thumbnails.
#[test]
fn geometry_matches_four_real_panel_sizes() {
for pro in [false, true] {
let mut previous = -1.0;
for size in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] {
let (thumb, row) = thumbnail_geometry(size, pro);
assert!(thumb > previous && row >= thumb, "{size:?}: {thumb}/{row}");
previous = thumb;
}
}
assert_eq!(thumbnail_geometry(LayerThumbnailSize::None, true), (0.0, 23.0));
assert_eq!(thumbnail_geometry(LayerThumbnailSize::Large, true), (48.0, 56.0));
}
/// Preferences saved before an option existed still load, with the default for the missing ones.
#[test]
fn panel_options_missing_fields_deserialize_with_defaults() {
let options: LayersPanelOptions = serde_json::from_value(json!({})).unwrap();
assert_eq!(options, LayersPanelOptions::default());
let saved = serde_json::to_value(&options).unwrap();
assert_eq!(saved["thumbnailSize"], "medium");
}
}
+9
View File
@@ -126,6 +126,7 @@ pub mod layer_row_ui;
pub mod layer_style;
mod layer_transfer;
pub mod layer_tree_ui;
pub mod layers_panel_ui;
mod layout_keys;
pub mod links;
pub mod liquify_ui;
@@ -672,6 +673,10 @@ pub struct PhotocraftApp {
pub(crate) object_finder: Option<object_finder::Finder>,
/// A blend mode hovered in the Layers panel, shown live (`blend_preview`).
pub(crate) blend_preview: Option<blend_preview::BlendPreview>,
/// Layers panel drag: temporary reordered document displayed until mouse release.
pub(crate) reorder_preview: Option<layers_panel_ui::ReorderPreview>,
/// Whether the currently dragged layer is over a valid destination this frame.
pub(crate) reorder_preview_hit: bool,
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
/// Content-Aware Move drag or box shown with its result (`cam_preview`).
@@ -945,6 +950,8 @@ impl PhotocraftApp {
move_distance: None,
object_finder: None,
blend_preview: None,
reorder_preview: None,
reorder_preview_hit: false,
patch_preview: None,
cam_preview: None,
magnetic: Default::default(),
@@ -1059,6 +1066,7 @@ impl PhotocraftApp {
};
// Saved preferences are in place before the first frame; recovery starts in upkeep.
prefs_ui::load(&mut app);
layers_panel_ui::restore_options(&mut app);
// After the saved preferences, which say whether the set was already offered.
kys_import::offer_import(&mut app);
notices::wayland_file_drop_guidance(&mut app);
@@ -2189,6 +2197,7 @@ impl eframe::App for PhotocraftApp {
workspace_ui::windows(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
layers_panel_ui::show_dialog(self, &ctx);
rasterize_prompt::run_pending(self, &ctx);
jobs_ui::dialog(self, &ctx);
discard_ui::show(self, &ctx);
+13
View File
@@ -150,6 +150,12 @@ pub fn paint(
painter.line_segment([k.right_top(), k.left_bottom()], st);
}
}
if app.ui.layers_panel_options.show_layer_mask_badges {
// Distinguish pixel and vector masks without altering their thumbnails.
let badge = Rect::from_min_size(pos2(r.right() - 9.0, r.bottom() - 9.0), vec2(9.0, 9.0));
painter.rect_filled(badge, 1.0, t.card);
crate::icons::paint(ui, badge, if kind == MaskKind::Vector { "pen-tool" } else { "circle-dot" }, 8.0, t.text);
}
painter.rect_stroke(
r,
if t.pro { 0.0 } else { 4.0 },
@@ -166,6 +172,7 @@ pub fn paint(
type Rects = (Vec<(MaskKind, Rect)>, Vec<(MaskKind, Rect)>);
/// Memory key of the per-layer record of where the mask thumbnails and chains were drawn.
fn rects_id() -> egui::Id {
egui::Id::new("mask-thumb-rects")
}
@@ -175,6 +182,12 @@ pub fn recorded(ctx: &egui::Context, id: u64) -> Option<Rects> {
ctx.data(|d| d.get_temp::<std::collections::HashMap<u64, Rects>>(rects_id())).and_then(|m| m.get(&id).cloned())
}
/// Drop what was recorded for layer `id`, so a row drawn without mask thumbnails (thumbnail size
/// None) can't be hit through rects from an earlier frame.
pub fn forget(ctx: &egui::Context, id: u64) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<std::collections::HashMap<u64, Rects>>(rects_id()).remove(&id));
}
/// A small vertical chain: two interlocking links.
fn paint_chain(painter: &Painter, c: Pos2, color: Color32) {
let st = Stroke::new(1.2, color);
@@ -214,6 +214,36 @@ fn dragging_a_layer_row_to_trash_still_deletes_the_layer_with_a_mask_targeted()
assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none());
}
/// With the thumbnail size set to None no mask thumbnail is drawn, so a drag from where one used
/// to be moves the layer (the row) rather than a mask that is no longer on screen.
#[test]
fn thumbnails_off_forget_the_mask_rects_and_drag_the_layer() {
let (s, masked, _) = session();
let mut h = harness(s, 0, 1.0, 290.0);
let stale = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == MaskKind::Pixel).unwrap().1;
click_with(&mut h, stale.center(), Modifiers::NONE);
h.state_mut().ui.layers_panel_options.thumbnail_size = crate::state::LayerThumbnailSize::None;
h.run_steps(3);
assert!(recorded(&h.ctx, masked).is_none(), "no mask thumbnails are recorded while they are off");
let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == masked).unwrap().row;
let from = pos2(stale.center().x, row.center().y);
assert!(row.contains(from), "the old thumbnail position is still on the row");
let to = h.get_by_label("Delete Layer Mask").rect().center();
h.hover_at(from);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for k in 1..=8 {
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert!(!h.state().ui.dialogs.iter().any(|d| crate::mask_trash_prompt::owns(&d.fields)), "no mask prompt: the mask was not dragged");
assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none(), "the layer went to the trash");
}
/// #3113: dragging the layer mask's thumbnail onto the Trash asks "Apply mask to layer before
/// removing?" and keeps the layer; Apply applies the mask, Delete deletes it.
#[test]
+1 -1
View File
@@ -13,7 +13,7 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
if app.camera_raw.is_some() || crate::crop_ui::blocks(app, id) {
return false;
}
(app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id)
(app.ui.dialogs.is_empty() && app.ui.layers_panel_dialog.is_none() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id)
}
/// Top-level menus in Photoshop order.
+60 -32
View File
@@ -2471,7 +2471,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut actions: Vec<(String, Value)> = Vec::new();
let t = Tokens::get(ui.ctx());
if t.pro {
app.reorder_preview_hit = false;
if !ui.input(|i| i.pointer.primary_down()) || !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers {
app.reorder_preview = None;
}
if t.pro && app.ui.layers_panel_options.show_filters {
crate::layer_filter_ui::bar(app, ui);
ui.add_space(4.0);
}
@@ -2672,6 +2676,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
actions.push(done);
}
});
// Leaving the list or hovering an invalid target immediately cancels the temporary
// image. The release still uses the actual command, not the preview snapshot.
if !app.reorder_preview_hit {
app.reorder_preview = None;
}
// A layer being dragged can also be dropped on the footer's Delete, New Layer and Group
// buttons (#736); read the drag before it ends.
let footer_drag = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))).map(|id| {
@@ -3015,9 +3024,13 @@ fn layer_row(
) -> Rect {
let selected = row.selected;
let t = Tokens::get(ctx);
// Photoshop's default (medium) thumbnails: 32 pt rows.
let row_h = if t.pro { 32.0 } else { 46.0 };
// The Layers Panel Options thumbnail size controls rows and both layer/mask thumbnails.
let (base, row_h) = crate::layers_panel_ui::thumbnail_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
// With thumbnails off no mask thumbnail is drawn, so none may keep catching drags from older frames.
if base == 0.0 {
crate::mask_thumbs_ui::forget(ctx, l.id.0);
}
// A drag from a mask's thumbnail drags that mask, not the layer (#3113, #3119).
let mask_drag = starts_mask_drag(ctx, l, &resp);
if let Some(kind) = mask_drag {
@@ -3096,8 +3109,8 @@ fn layer_row(
+ if l.is_group() { crate::layer_tree_ui::TRIANGLE_W } else { 0.0 }
+ if l.clipped { 12.0 } else { 0.0 }
+ crate::layer_row_ui::reserved_width(l);
let ts = crate::mask_thumbs_ui::thumb_size(l, if t.pro { 24.0 } else { 34.0 }, rect.width() - others);
let fixed = others + ts + 6.0 + crate::mask_thumbs_ui::width(l, ts);
let ts = if base == 0.0 { 0.0 } else { crate::mask_thumbs_ui::thumb_size(l, base, rect.width() - others) };
let fixed = others + if ts == 0.0 { 0.0 } else { ts + 6.0 + crate::mask_thumbs_ui::width(l, ts) };
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 {
@@ -3105,15 +3118,17 @@ fn layer_row(
x += 12.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;
// Link chains and pixel / vector mask thumbnails (#153).
let masks = crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions);
if ts > 0.0 {
draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary);
x += ts + 6.0;
}
// With thumbnails disabled the labels and disclosure controls remain interactive.
let masks = if ts > 0.0 { crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions) } else { Default::default() };
let mask_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Pixel);
let vector_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Vector);
// Photoshop frames the targeted thumbnail (pixels, mask or vector mask) of the active layer
// with corner brackets.
if row.primary {
if row.primary && ts > 0.0 {
let target = if app.ui.vector_mask_target && vector_rect.is_some() {
vector_rect
} else if app.ui.mask_target {
@@ -3147,7 +3162,7 @@ fn layer_row(
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
let italic = !t.pro && doc.is_background(l.id);
// 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 is_pixel = t.pro || ts == 0.0 || matches!(l.content, LayerContent::Raster(_));
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());
@@ -3162,7 +3177,10 @@ fn layer_row(
};
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 });
crate::layer_row_ui::record(
ctx,
crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, thumbnail: (ts > 0.0).then_some(thumb), name: name_rect, indicators },
);
// ⌘-click a layer, mask or vector-mask thumbnail loads its transparency / mask / path 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)| {
@@ -3171,7 +3189,7 @@ fn layer_row(
return Some(crate::mask_thumbs_ui::load_params(l, kind, m));
}
// The Background has no transparency to load: Photoshop treats the click as a plain one.
(!doc.is_background(l.id) && thumb.expand(2.0).contains(p))
(ts > 0.0 && !doc.is_background(l.id) && thumb.expand(2.0).contains(p))
.then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)}))
});
// ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it.
@@ -3186,7 +3204,7 @@ fn layer_row(
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked {
let m = ui.input(|i| i.modifiers);
let pos = resp.interact_pointer_pos();
let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p));
let on_thumb = ts > 0.0 && pos.is_some_and(|p| thumb.expand(2.0).contains(p));
// ⌘-click on the Background thumbnail stands in for the plain click (there is no
// transparency to load): it selects the Background alone, not toggled into the set.
let mode = if m.command && on_thumb && doc.is_background(l.id) { "replace" } else { select_mode(m) };
@@ -3251,9 +3269,9 @@ fn layer_row(
}
} else if pos.and_then(|p| masks.hit(p)).is_none() {
let id = match &l.content {
LayerContent::Adjustment(_) | LayerContent::Fill(_) | LayerContent::Shape(_) if on(thumb) => "layer.layerContentOptions",
LayerContent::Smart(_) if on(thumb) => "layer.smartObjects.editContents",
LayerContent::Text(_) if on(thumb) => "type.editText",
LayerContent::Adjustment(_) | LayerContent::Fill(_) | LayerContent::Shape(_) if ts > 0.0 && on(thumb) => "layer.layerContentOptions",
LayerContent::Smart(_) if ts > 0.0 && on(thumb) => "layer.smartObjects.editContents",
LayerContent::Text(_) if ts > 0.0 && on(thumb) => "type.editText",
_ => "layer.layerStyle.blendingOptions",
};
if crate::menus::is_enabled(app, id) {
@@ -4276,7 +4294,7 @@ fn background_drop_refused(doc: &photocraft_doc::Document, payload: &Value, targ
/// release the layers stay put and copies land there instead (Photoshop).
/// Multi-layer moves are atomic (one undo step), using the engine's stable document order.
fn layer_drag_and_drop(
app: &PhotocraftApp,
app: &mut PhotocraftApp,
ctx: &egui::Context,
ui: &egui::Ui,
l: &Layer,
@@ -4320,12 +4338,15 @@ fn layer_drag_and_drop(
} else {
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent));
}
let mut payload = layer_drop_payload(dragged, l.id, position, &selected);
if let Some(o) = payload.as_object_mut() {
o.insert("copy".into(), json!(true));
}
if released {
let mut payload = layer_drop_payload(dragged, l.id, position, &selected);
if let Some(o) = payload.as_object_mut() {
o.insert("copy".into(), json!(true));
}
app.reorder_preview = None;
actions.push(("layer.moveTo".into(), payload));
} else if ctx.input(|i| i.pointer.primary_down()) {
crate::layers_panel_ui::preview_reorder(app, &payload);
}
}
return;
@@ -4359,10 +4380,14 @@ fn layer_drag_and_drop(
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent));
}
}
if copy && let Some(o) = payload.as_object_mut() {
o.insert("copy".into(), json!(true));
}
if !released && ctx.input(|i| i.pointer.primary_down()) {
crate::layers_panel_ui::preview_reorder(app, &payload);
}
if released {
if copy && let Some(o) = payload.as_object_mut() {
o.insert("copy".into(), json!(true));
}
app.reorder_preview = None;
actions.push(("layer.moveTo".into(), payload));
}
}
@@ -4372,7 +4397,7 @@ fn layer_drag_and_drop(
/// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it
/// was grabbed from is left alone: the engine can only copy it beside each of its own layers.
fn layer_drop_zone(
app: &PhotocraftApp,
app: &mut PhotocraftApp,
ctx: &egui::Context,
ui: &egui::Ui,
zone: Rect,
@@ -4392,14 +4417,17 @@ fn layer_drop_zone(
let t = Tokens::get(ctx);
let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] };
ui.painter().line_segment(line, Stroke::new(2.0, t.accent));
let mut payload = layer_drop_payload(dragged, target.0, position, &selected);
if ctx.input(|i| i.modifiers.alt)
&& let Some(o) = payload.as_object_mut()
{
o.insert("copy".into(), json!(true));
}
if ctx.input(|i| i.pointer.any_released()) {
let mut payload = layer_drop_payload(dragged, target.0, position, &selected);
if ctx.input(|i| i.modifiers.alt)
&& let Some(o) = payload.as_object_mut()
{
o.insert("copy".into(), json!(true));
}
app.reorder_preview = None;
actions.push(("layer.moveTo".into(), payload));
} else if ctx.input(|i| i.pointer.primary_down()) {
crate::layers_panel_ui::preview_reorder(app, &payload);
}
}
+74
View File
@@ -3,6 +3,72 @@
use serde::{Deserialize, Serialize};
/// Photoshop-style Layers panel display preferences; part of serialized UI state.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum LayerThumbnailSize {
None,
Small,
#[default]
Medium,
Large,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum LayerThumbnailContents {
LayerBounds,
#[default]
EntireDocument,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LayersPanelOptions {
pub thumbnail_size: LayerThumbnailSize,
/// Thumbnail Contents in the Panel Options dialog. Not saved with these options: the dialog
/// shows and sets `interface.clipThumbnailsToLayerBounds` (Layer Bounds = clipped, #4281),
/// which the panel menu's Clip Thumbnails to Layer Bounds toggles too.
#[serde(skip)]
pub thumbnail_contents: LayerThumbnailContents,
pub show_filters: bool,
pub expand_new_effects: bool,
pub add_copy_to_copied_layers_and_groups: bool,
pub preview_on_canvas_when_reordering_layers: bool,
pub show_layer_mask_badges: bool,
}
impl LayersPanelOptions {
/// Every option at the Photoshop default: Medium thumbnails, filters shown, new effects expanded, no mask badges.
pub fn defaults() -> Self {
Self {
thumbnail_size: LayerThumbnailSize::Medium,
thumbnail_contents: LayerThumbnailContents::EntireDocument,
show_filters: true,
expand_new_effects: true,
add_copy_to_copied_layers_and_groups: true,
preview_on_canvas_when_reordering_layers: true,
show_layer_mask_badges: false,
}
}
}
impl Default for LayersPanelOptions {
/// Same as [`LayersPanelOptions::defaults`], so serde fills missing fields with the Photoshop defaults.
fn default() -> Self {
Self::defaults()
}
}
/// Pending dialog data is transient: Cancel never changes persisted options.
#[derive(Clone, Debug, PartialEq)]
pub enum LayersPanelDialog {
PanelOptions(LayersPanelOptions),
NewLayer(String),
NewGroup(String),
LockLayers { transparency: bool, pixels: bool, position: bool, artboard: bool, all: bool },
}
/// Shared selection and capture for a point curve; document parameters remain with the host.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@@ -1219,6 +1285,12 @@ pub struct UiState {
/// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all.
#[serde(default)]
pub layer_filter: Vec<String>,
/// Thumbnail dimensions, content cropping and filter-strip visibility.
#[serde(default)]
pub layers_panel_options: LayersPanelOptions,
/// Transient dialog; never restored with a workspace.
#[serde(skip)]
pub layers_panel_dialog: Option<LayersPanelDialog>,
/// Layers panel filter type (#5048): kind (above), name or blend mode.
#[serde(default)]
pub layer_filter_by: crate::layer_filter_ui::FilterBy,
@@ -1398,6 +1470,8 @@ impl Default for UiState {
artboard_tool: Default::default(),
shell: Default::default(),
layer_filter: Vec::new(),
layers_panel_options: LayersPanelOptions::default(),
layers_panel_dialog: None,
layer_filter_by: Default::default(),
layer_filter_name: String::new(),
layer_filter_mode: default_layer_filter_mode(),