diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 4bc4a829b2..7d5d7bbb0e 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -426,6 +426,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result { }; 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 { 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; diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index f3053e21fd..0ad909ab5b 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -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); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b7dff3e9fd..d7b9e8740e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -408,6 +408,12 @@ pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result 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 { 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 { +pub(crate) fn layer_copy(doc: &Document, id: LayerId, add_copy: bool) -> Result { 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(); } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 0ee0573170..08b8927409 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -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, ¶ms)?; + // 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, ¶ms) + } + .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)) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index a9f121c9fb..b5dfc97e64 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1076,6 +1076,84 @@ fn distribute(s: &mut Session, kind: &str) -> Result { /// 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 { + 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::>>()? + } 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> { + 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 = 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 { @@ -1133,32 +1211,9 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } 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 = 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 { pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { 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) { + 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, ¶ms, 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, ¶ms, 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::>(), actual.layers.iter().map(|l| l.name.as_str()).collect::>()); + 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::>().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, ¶ms, 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 = 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"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 06e775c28e..1f9feefc30 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; diff --git a/crates/ui-egui/src/clip_line_ui/tests.rs b/crates/ui-egui/src/clip_line_ui/tests.rs index ba89495402..d7c443aab9 100644 --- a/crates/ui-egui/src/clip_line_ui/tests.rs +++ b/crates/ui-egui/src/clip_line_ui/tests.rs @@ -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. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5f0a95e915..1323852948 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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(); diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index f0a64562c4..f8b325ed92 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9f3ca51dd8..22b371ccd5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c4c03454ea..09df8369fe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 74ab59cba5..4566257947 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Προσομοίωση πίεσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cbbbbc14fa..f5795d4f15 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e8164e9674..f8b32f7965 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fe21a5fbf1..1d2b05d196 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0feaf28398..51bd0b5a46 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 57b58861a9..955947620d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 28fe0ccc60..165c491931 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 筆圧をシミュレート diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e9163eba80..880670699a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 압력 시뮬레이션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9b20a49dee..793ac9d27f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b223c0cd20..8697090cbb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5319f4f74d..501aa53067 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fb0af355c9..6ef050b669 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Инструмент diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2b02f33bc2..f1196f3969 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d3f6faf4a8..cf88721bd3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bc5e98c2a9..5f0d70f1ff 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Імітувати натиск diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index efba5ce8cb..f3ade594c3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f5f2aa37b0..030fa0103e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0e81f6b4ff..4d065bc7cc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 工具 diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 4bed6a7cc4..5257a554d9 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -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, /// The name as painted (after truncation); `None` when there was no room for it. pub name: Option, 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; diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 6ec1241b46..0bd2c7b097 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -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(); diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs new file mode 100644 index 0000000000..43955812dd --- /dev/null +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -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, +} + +/// 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, 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"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8784800ddd..4d4dd6451c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, + /// Layers panel drag: temporary reordered document displayed until mouse release. + pub(crate) reorder_preview: Option, + /// 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, /// 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); diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 20f0b965d4..66534b6f86 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -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 { ctx.data(|d| d.get_temp::>(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::>(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); diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 163c7cbae8..369e7468f5 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -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] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 63b46f4429..ca687e1b0a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1626eff450..33bf398b69 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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::(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); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 974d3fde35..d79abfd816 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, + /// 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, /// 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(),