From 6e0b3330c9419da72501f0eefce54cb8ffd51733 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Sun, 11 Oct 2026 02:06:56 +0300 Subject: [PATCH 01/19] feat(ui): bring Photoshop-style Layers Panel options and scalable Smart Filters Add a functional Layers flyout and Layers Panel Options dialog, configurable thumbnail sizes and bounds, mask/effect/copy preferences, and canvas reorder previews. Scale smart filter and layer effect rows with layer thumbnail size, and add regression coverage and translations. Known limitation: Animation Options remains disabled because frame-property unification controls are not implemented. --- crates/engine/src/commands.rs | 27 +- crates/engine/src/jobs.rs | 22 ++ crates/engine/src/layer_multi_cmds.rs | 181 ++++++++- crates/engine/src/pattern_cmds.rs | 3 +- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/clip_line_ui/tests.rs | 2 +- crates/ui-egui/src/dock.rs | 11 +- crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/de.tsv | 20 + crates/ui-egui/src/i18n/el.tsv | 16 + crates/ui-egui/src/i18n/es.tsv | 20 + crates/ui-egui/src/i18n/fr.tsv | 18 + crates/ui-egui/src/i18n/id.tsv | 18 + crates/ui-egui/src/i18n/it.tsv | 20 + crates/ui-egui/src/i18n/ja.tsv | 16 + crates/ui-egui/src/i18n/ko.tsv | 19 + crates/ui-egui/src/i18n/nl.tsv | 16 + crates/ui-egui/src/i18n/pl.tsv | 16 + crates/ui-egui/src/i18n/pt-br.tsv | 18 + crates/ui-egui/src/i18n/ru.tsv | 18 + crates/ui-egui/src/i18n/uk.tsv | 16 + crates/ui-egui/src/i18n/zh-hans.tsv | 21 + crates/ui-egui/src/i18n/zh-hant.tsv | 21 + crates/ui-egui/src/layer_row_ui.rs | 56 ++- crates/ui-egui/src/layer_row_ui/tests.rs | 29 ++ crates/ui-egui/src/layers_panel_ui.rs | 469 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 44 ++- crates/ui-egui/src/mask_thumbs_ui.rs | 6 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels.rs | 104 +++-- crates/ui-egui/src/smart_ui.rs | 68 +++- crates/ui-egui/src/state.rs | 70 ++++ 32 files changed, 1322 insertions(+), 70 deletions(-) create mode 100644 crates/ui-egui/src/layers_panel_ui.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e7d086d3b6..c53a0e80ef 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -245,12 +245,18 @@ pub(crate) fn selection_mask(doc: &Document) -> Option { doc.selection.as_ref().map(|sel| LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }) } +/// Options from the Layers Panel flyout live in preferences so headless/keyboard commands +/// behave exactly like pointer-triggered commands. Old preferences preserve Photoshop defaults. +pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool { + s.prefs().dialogs.get("ui.layersPanelOptions").and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(fallback) +} + /// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the /// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its /// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). /// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> { +pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -261,7 +267,7 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => l.mask = Some(selection_mask(doc).unwrap_or_else(LayerMask::reveal_all)), + None => l.mask = selection_mask(doc).or_else(|| default_mask.then(LayerMask::reveal_all)), } Ok(()) } @@ -275,7 +281,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res let name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); - mask_new_layer(doc, &mut l, p, cmd)?; + mask_new_layer(doc, &mut l, p, cmd, true)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -560,8 +566,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 dup = layer_copy(doc, id)?; + let dup = layer_copy(doc, id, add_copy)?; let nid = doc.insert_above(Some(id), dup); if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; @@ -771,10 +778,11 @@ fn build() -> Vec { has_doc, |s, p| { let c = color_param(p, "color", s.tools.foreground); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -795,6 +803,7 @@ fn build() -> Vec { let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], @@ -805,7 +814,7 @@ fn build() -> Vec { ) .in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -1349,12 +1358,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 = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == 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 9b612e3c4c..9aebb68d39 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -635,6 +635,17 @@ impl Session { if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) { return Err(EngineError::Other(why.into())); } + // This UI-only disclosure preference applies to both `execute` and `start`: + // desktop UI normally calls `start`, even for synchronous Layer Style commands. + let new_fx_target = if id.starts_with("layer.layerStyle.") { + self.active().and_then(|st| { + let target = params.get("layer").and_then(Value::as_u64).map(LayerId).or(st.active_layer)?; + (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; @@ -662,6 +673,17 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { + 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); Ok(Started::Done(v)) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9408a32b12..cf7231259a 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -877,6 +877,66 @@ 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; + } + let mut preview = doc.clone(); + let mut moved = Vec::with_capacity(moved_ids.len()); + for &id in &moved_ids { + if copy { + let mut layer = preview.layer(id)?.duplicate(); + if add_copy { + layer.name = preview.copy_name(&layer.name); + } + moved.push(layer); + } else { + moved.push(preview.remove(id)?); + } + } + let path = preview.path_of(target)?; + if position == "into" { + preview.layer_at_mut(&path)?.children_mut()?.extend(moved); + } else { + let (&idx, parent) = path.split_last()?; + let siblings = if parent.is_empty() { &mut preview.layers } else { preview.layer_at_mut(parent)?.children_mut()? }; + siblings.splice((idx + usize::from(position == "above"))..(idx + usize::from(position == "above")), moved); + } + check_group_depth(&preview, "Preview Reorder Layers").ok()?; + Some(preview) +} + /// 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 { @@ -934,10 +994,11 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } let original_active = st.active_layer; + 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 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))? }); + 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. @@ -1194,13 +1255,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)?; @@ -2256,3 +2318,118 @@ mod tests { assert_eq!(v["layers"][0]["selected"], true); } } + +#[cfg(test)] +mod layers_panel_parity_tests { + use super::*; + + 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) + } + + 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)); + } + }); + } + + #[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"); + } + + #[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")); + } + + #[test] + fn preview_rejects_invalid_drop_unchanged() { + let (mut s, ids) = new_session(); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), + json!({"layer": ids[1].0, "target": 999999, "position": "below"}), + json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), + json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), + ] { + assert!(preview_move(&before, &p, true).is_none(), "{p}"); + } + assert_eq!(*before, *s.active().unwrap().doc); + // 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); + } + + #[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)); + } + + #[test] + fn fill_layers_respect_use_default_masks_option() { + let (mut s, _) = new_session(); + option(&mut s, "useDefaultMasksOnFillLayers", false); + let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none()); + option(&mut s, "useDefaultMasksOnFillLayers", true); + let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); + } + + #[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)); + } +} diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 2cf3cf144a..449f0540b9 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -308,11 +308,12 @@ pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { fn new_fill_layer(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; let (scale, angle, link, phase) = placement(p); + let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Pattern Fill Layer", |doc, active| { ensure_in_doc(doc, &pat); let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); - crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?; + crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8f321faf07..197b212ab6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -903,6 +903,10 @@ 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; } + // 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..aeab35f23c 100644 --- a/crates/ui-egui/src/clip_line_ui/tests.rs +++ b/crates/ui-egui/src/clip_line_ui/tests.rs @@ -84,7 +84,7 @@ fn the_clip_cursor_shows_only_with_alt_over_a_line() { } 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() } } fn named(doc: &Document, name: &str) -> LayerId { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 00c483b93f..00e50e5768 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -558,6 +558,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(); @@ -694,7 +700,7 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) /// What `prefs.panelLayout` holds: the live layout and open panels. fn snapshot(app: &PhotocraftApp) -> Value { - json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open}) + json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "layersPanelOptions": app.ui.layers_panel_options, "timelineOpen": app.ui.timeline.open}) } /// Remember the layout in the preferences once the user lets go of the mouse (Workspace › @@ -737,6 +743,9 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) { if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) { app.ui.dock = d; } + if let Some(opts) = v.get("layersPanelOptions").and_then(|o| serde_json::from_value(o.clone()).ok()) { + app.ui.layers_panel_options = opts; + } app.ui.timeline.open = v.get("timelineOpen").and_then(Value::as_bool).unwrap_or(false); if !app.ui.timeline.open { app.ui.timeline.playing = false; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 335ae28734..49795c72dd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2435,3 +2435,24 @@ fontWeight Italic Kurzíva Front Image Přední obraz Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení + 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 + 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 + Use Default Masks on Fill Layers Používat výchozí masky u vrstev výplně + 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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9a2be43ae4..4eeb8c263e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2473,3 +2473,23 @@ fontWeight Italic Kursiv Front Image Vorderes Bild Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen + 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 + New Layer… Neue Ebene… + Panel Options… Bedienfeldoptionen… + Prevent Auto-Nesting Automatisches Verschachteln verhindern + Show Layers Ebenen einblenden + Thumbnail Contents Miniaturinhalt + Thumbnail Size Miniaturgröße + Use Default Masks on Fill Layers Standardmasken für Füllebenen verwenden + 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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 58df5ad70b..7fc1e028c1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3543,3 +3543,19 @@ fontWeight Thin Πολύ λεπτά Front Image Μπροστινή εικόνα Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις + Animation Options Επιλογές κίνησης + Entire Document Ολόκληρο έγγραφο + Filter Options Επιλογές φίλτρου + Layer Bounds Όρια επιπέδου + Layers Panel Options Επιλογές πίνακα επιπέδων + New Artboard… Νέος πίνακας σχεδίασης… + New Group… Νέα ομάδα… + Prevent Auto-Nesting Αποτροπή αυτόματης ένθεσης + Show Layers Εμφάνιση επιπέδων + Thumbnail Contents Περιεχόμενο μικρογραφιών + Thumbnail Size Μέγεθος μικρογραφιών + Use Default Masks on Fill Layers Χρήση προεπιλεγμένων μασκών σε επίπεδα γεμίσματος + Expand New Effects Ανάπτυξη νέων εφέ + Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες + Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων + Show layer mask badges Εμφάνιση σημάτων μασκών επιπέδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4815e5b244..a85cdb1ae4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2514,3 +2514,23 @@ fontWeight Italic Cursiva Front Image Imagen frontal Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan + 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 + 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 + Use Default Masks on Fill Layers Usar máscaras predeterminadas en capas de relleno + 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 21f7232759..859713e9fe 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2853,3 +2853,21 @@ fontWeight Italic Italique Front Image Image de premier plan Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant + 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… + New Layer Nouveau calque + Prevent Auto-Nesting Empêcher l’imbrication automatique + Show Layers Afficher les calques + Thumbnail Contents Contenu des vignettes + Thumbnail Size Taille des vignettes + Use Default Masks on Fill Layers Utiliser les masques par défaut sur les calques de remplissage + 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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index efd0962b32..425ffb7038 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2840,3 +2840,21 @@ fontWeight Italic Miring Front Image Gambar Depan Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok + 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… + New Layer Lapisan Baru + Prevent Auto-Nesting Cegah Pengelompokan Otomatis + Show Layers Tampilkan Lapisan + Thumbnail Contents Isi Gambar Mini + Thumbnail Size Ukuran Gambar Mini + Use Default Masks on Fill Layers Gunakan Masker Default pada Lapisan Isian + 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c7bb027e79..b5462bd381 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2504,3 +2504,23 @@ fontWeight Italic Corsivo Front Image Immagine in primo piano Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente + Animation Options Opzioni animazione + Entire Document Documento intero + Filter Options Opzioni filtro + Layer Bounds Limiti livello + Layers Panel Options Opzioni pannello Livelli + Lock Layers Blocca livelli + New Artboard… Nuova tavola da disegno… + New Group… Nuovo gruppo… + New Layer Nuovo livello + New Layer… Nuovo livello… + Panel Options… Opzioni pannello… + Prevent Auto-Nesting Impedisci annidamento automatico + Show Layers Mostra livelli + Thumbnail Contents Contenuto miniature + Thumbnail Size Dimensione miniature + Use Default Masks on Fill Layers Usa maschere predefinite sui livelli di riempimento + 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1a9dd16e1c..ecf28e8a57 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3454,3 +3454,19 @@ fontWeight Italic 斜体 Front Image 前面の画像 Search settings… 設定を検索… No matching settings 一致する設定はありません + Animation Options アニメーションオプション + Entire Document ドキュメント全体 + Filter Options フィルターオプション + Layer Bounds レイヤーの境界 + Layers Panel Options レイヤーパネルオプション + New Artboard… 新規アートボード… + New Group… 新規グループ… + Prevent Auto-Nesting 自動的な入れ子を防ぐ + Show Layers レイヤーを表示 + Thumbnail Contents サムネールの内容 + Thumbnail Size サムネールのサイズ + Use Default Masks on Fill Layers 塗りつぶしレイヤーにデフォルトのマスクを使用 + Expand New Effects 新しい効果を展開 + Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加 + Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー + Show layer mask badges レイヤーマスクのバッジを表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991fc3328e..f109bfdc3f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3013,3 +3013,22 @@ fontWeight Italic 기울임 Front Image 전면 이미지 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 + 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 축소판 크기 + Use Default Masks on Fill Layers 칠 레이어에 기본 마스크 사용 + Expand New Effects 새 효과 펼치기 + Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가 + Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기 + Show layer mask badges 레이어 마스크 배지 표시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4836d7e8b3..d7ec6cab69 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3613,3 +3613,19 @@ cameraRaw Whites Witten Search settings… Instellingen zoeken… No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer + 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 + Use Default Masks on Fill Layers Standaardmaskers gebruiken op opvullagen + 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f33ce9990e..d44d16c670 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3454,3 +3454,19 @@ cameraRaw Roughness Chropowatość Front Image Obraz na wierzchu Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień + 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 + Use Default Masks on Fill Layers Użyj domyślnych masek na warstwach wypełnienia + 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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 11b69ca234..cb133561fb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2861,3 +2861,21 @@ fontWeight Italic Itálico Front Image Imagem frontal Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada + 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 + Lock Layers Bloquear camadas + New Artboard… Nova prancheta… + New Group… Novo grupo… + New Layer Nova camada + Prevent Auto-Nesting Impedir aninhamento automático + Show Layers Mostrar camadas + Thumbnail Contents Conteúdo das miniaturas + Thumbnail Size Tamanho das miniaturas + Use Default Masks on Fill Layers Usar máscaras padrão nas camadas de preenchimento + 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7163992a15..f069459064 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2719,3 +2719,21 @@ fontWeight Italic Курсив Swaps height and width Меняет местами высоту и ширину Search settings… Поиск настроек… No matching settings Нет подходящих настроек + Animation Options Параметры анимации + Entire Document Весь документ + Filter Options Параметры фильтра + Layer Bounds Границы слоя + Layers Panel Options Параметры панели «Слои» + Lock Layers Блокировка слоёв + New Artboard… Новая монтажная область… + New Group… Новая группа… + New Layer Новый слой + Prevent Auto-Nesting Запретить автоматическое вложение + Show Layers Показать слои + Thumbnail Contents Содержимое миниатюр + Thumbnail Size Размер миниатюр + Use Default Masks on Fill Layers Использовать маски по умолчанию для слоёв-заливок + Expand New Effects Раскрывать новые эффекты + Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам + Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв + Show layer mask badges Показывать значки типов масок слоя diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6ca10067a0..fe7f3c9835 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3541,3 +3541,19 @@ cameraRaw Masking Маскування Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана + Animation Options Параметри анімації + Entire Document Увесь документ + Filter Options Параметри фільтрів + Layer Bounds Межі шару + Layers Panel Options Параметри панелі «Шари» + New Artboard… Нова монтажна область… + New Group… Нова група… + Prevent Auto-Nesting Заборонити автоматичне вкладення + Show Layers Показати шари + Thumbnail Contents Вміст мініатюр + Thumbnail Size Розмір мініатюр + Use Default Masks on Fill Layers Використовувати типові маски для шарів заливки + Expand New Effects Розгортати нові ефекти + Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп + Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів + Show layer mask badges Показувати позначки масок шарів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a39ab4cfce..6cb6d5696f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2443,3 +2443,24 @@ fontWeight Italic 斜体 Front Image 前面的图像 Search settings… 搜索设置… No matching settings 没有匹配的设置 + Animation Options 动画选项 + Entire Document 整个文档 + Filter Options 过滤选项 + Layer Bounds 图层边界 + Layers Panel Options 图层面板选项 + Lock Layers 锁定图层 + New Artboard… 新建画板… + New Group 新建组 + New Group… 新建组… + New Layer 新建图层 + New Layer… 新建图层… + Panel Options… 面板选项… + Prevent Auto-Nesting 防止自动嵌套 + Show Layers 显示图层 + Thumbnail Contents 缩略图内容 + Thumbnail Size 缩略图大小 + Use Default Masks on Fill Layers 在填充图层上使用默认蒙版 + Expand New Effects 展开新效果 + Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本” + Preview on canvas when reordering layers 重新排列图层时在画布上预览 + Show layer mask badges 显示图层蒙版标记 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4454a7d61b..0e6ff387fe 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2412,3 +2412,24 @@ fontWeight Italic 斜體 Front Image 前面的影像 Search settings… 搜尋設定… No matching settings 沒有符合的設定 + Animation Options 動畫選項 + Entire Document 整份文件 + Filter Options 篩選選項 + Layer Bounds 圖層邊界 + Layers Panel Options 圖層面板選項 + Lock Layers 鎖定圖層 + New Artboard… 新增工作區域… + New Group 新增群組 + New Group… 新增群組… + New Layer 新增圖層 + New Layer… 新增圖層… + Panel Options… 面板選項… + Prevent Auto-Nesting 防止自動巢狀 + Show Layers 顯示圖層 + Thumbnail Contents 縮圖內容 + Thumbnail Size 縮圖大小 + Use Default Masks on Fill Layers 在填色圖層使用預設遮色片 + Expand New Effects 展開新效果 + Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」 + Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽 + Show layer mask badges 顯示圖層遮色片標記 diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index d265690597..67afffb1cc 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)>, @@ -193,10 +195,58 @@ 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() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::PanelOptions(app.ui.layers_panel_options.clone())); + 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 791095c1db..28a8e23d36 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -534,3 +534,32 @@ fn press_drag_release_on_the_fx_button_opens_that_style() { let effects = open[0].fields["effects"].as_array().unwrap(); 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"); + } +} 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..181f60adf1 --- /dev/null +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -0,0 +1,469 @@ +//! 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, Layer}; +use photocraft_geom::Rect; +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(); + } +} + +/// Layer Bounds excludes fully transparent pixels and retains the layer's aspect ratio; +/// fully blank/non-raster layers fall back to document space. +pub fn thumbnail_region(app: &mut PhotocraftApp, doc: &Document, layer: &Layer, mode: LayerThumbnailContents) -> Rect { + if mode == LayerThumbnailContents::LayerBounds + && let Some(surface) = layer.surface() + { + let bounds = app.cached_bounds(layer.id.0, surface).intersect(&doc.bounds()); + if !bounds.is_empty() { + return bounds; + } + } + doc.bounds() +} + +/// 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, +} + +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), + }); +} + +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.use_default_masks_on_fill_layers, tl!("Use Default Masks on Fill Layers")); + ui.add_space(3.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")); +} + +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) => { + app.ui.layers_panel_options = options; + persist_options(app); + Ok(()) + } + 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::*; + + #[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.use_default_masks_on_fill_layers); + 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); + } + + #[test] + fn thumbnail_contents_crop_to_pixels_and_fall_back_for_blank_layers() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 100, "height": 60})).unwrap(); + let id = session.execute("layer.new.layer", json!({"name": "mark"})).unwrap()["layer"].as_u64().unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let doc = app.session.active().unwrap().doc.clone(); + let layer = doc.layer(photocraft_doc::LayerId(id)).unwrap(); + let original = thumbnail_region(&mut app, &doc, layer, LayerThumbnailContents::LayerBounds); + assert_eq!(original, doc.bounds(), "empty raster layers use document dimensions"); + app.run("select.rect", json!({"x": 70, "y": 10, "width": 5, "height": 12})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + let updated = app.session.active().unwrap().doc.clone(); + let layer = updated.layer(photocraft_doc::LayerId(id)).unwrap(); + let cropped = thumbnail_region(&mut app, &updated, layer, LayerThumbnailContents::LayerBounds); + assert_eq!(cropped, Rect::new(70, 10, 75, 22)); + assert_eq!(thumbnail_region(&mut app, &updated, layer, LayerThumbnailContents::EntireDocument), updated.bounds()); + } + + #[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.thumbnail_contents = LayerThumbnailContents::LayerBounds; + 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()); + } + + #[test] + fn disabling_default_fill_masks_never_targets_an_absent_mask() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = false; + persist_options(&mut app); + app.run("layer.newFillLayer.solidColor", json!({"color": "#778899"})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_none()); + assert!(!app.ui.mask_target && !app.ui.vector_mask_target); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = true; + persist_options(&mut app); + app.run("layer.newFillLayer.gradient", json!({})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_some()); + assert!(app.ui.mask_target); + } + + #[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::LayerBounds, + show_filters: false, + use_default_masks_on_fill_layers: 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); + } + + #[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); + } + + #[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)); + } + + #[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 012b4ac835..f395aaeeb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -92,6 +92,7 @@ pub mod layer_row_ui; pub mod layer_style; mod layer_transfer; pub mod layer_tree_ui; +pub mod layers_panel_ui; pub mod links; pub mod liquify_ui; pub mod magnetic_lasso_ui; @@ -425,6 +426,10 @@ pub struct PhotocraftApp { pub(crate) move_preview: 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, pub(crate) shape_stroke_preview: Option, @@ -626,6 +631,8 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, blend_preview: None, + reorder_preview: None, + reorder_preview_hit: false, patch_preview: None, shape_stroke_preview: None, magnetic: Default::default(), @@ -715,6 +722,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); @@ -843,10 +851,21 @@ impl PhotocraftApp { jobs_ui::run(self, id, params) }; let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); - if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { - // Adding a layer mask targets it, as in Photoshop (#2166). + if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { + // Adding an explicit mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; + } else if r.is_ok() && creates_adjustment_or_fill { + // Panel Options may suppress a fill layer's default mask. Never target a + // nonexistent mask; an active path creates a vector mask instead. + let masks = self + .session + .active() + .and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))) + .map(|layer| (layer.mask.is_some(), layer.vector_mask.is_some())) + .unwrap_or((false, false)); + self.ui.mask_target = masks.0; + self.ui.vector_mask_target = !masks.0 && masks.1; } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. @@ -1482,6 +1501,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); jobs_ui::dialog(self, &ctx); discard_ui::show(self, &ctx); tiff_options_ui::show(self, &ctx); @@ -1629,7 +1649,13 @@ impl PhotocraftApp { pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> egui::TextureId { // Key by content, not document revision: COW tiles change pointer only when their pixels // change, so unrelated edits (e.g. painting another layer) don't rebuild this thumbnail. - let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40; + let mode = self.ui.layers_panel_options.thumbnail_contents; + let region = layers_panel_ui::thumbnail_region(self, doc, layer, mode); + let rev = layer.surface().map_or(0, surface_fingerprint) + ^ (region.x0 as i64 as u64).rotate_left(9) + ^ (region.y0 as i64 as u64).rotate_left(21) + ^ (region.width() as u64).rotate_left(33) + ^ (region.height() as u64).rotate_left(45); let key = (layer.id, mask_thumbs_ui::THUMB_LAYER); if let Some((r, tex)) = self.thumbs.get(&key) && *r == rev @@ -1637,7 +1663,7 @@ impl PhotocraftApp { return tex.id(); } let mut px = [0.0f32; 8]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image_region(region, 64, |x, y| { let Some(s) = layer.surface() else { return [0.0; 4] }; let n = s.channels(); s.read_pixel(x, y, &mut px[..n]); @@ -1655,7 +1681,7 @@ impl PhotocraftApp { return tex.id(); } let mut v = [0.0f32; 1]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image_region(doc.bounds(), 64, |x, y| { mask.surface.read_pixel(x, y, &mut v); [v[0], v[0], v[0], 1.0] }); @@ -1694,9 +1720,9 @@ pub fn surface_fingerprint(s: &photocraft_raster::Surface) -> u64 { h } -/// Square thumbnail of the canvas area, letterboxed, sampling `f(x, y)` in document space. -fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { - let (w, h) = (doc.size.width.max(1) as f32, doc.size.height.max(1) as f32); +/// Sample a thumbnail from an arbitrary document-space region (Layer Bounds or Entire Document). +fn thumb_image_region(region: photocraft_geom::Rect, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { + let (w, h) = (region.width().max(1) as f32, region.height().max(1) as f32); let scale = w.max(h) / side as f32; let (ox, oy) = ((side as f32 - w / scale) / 2.0, (side as f32 - h / scale) / 2.0); let mut px = vec![egui::Color32::TRANSPARENT; side * side]; @@ -1707,7 +1733,7 @@ fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; if dx < 0.0 || dy < 0.0 || dx >= w || dy >= h { continue; } - let c = f(dx as i32, dy as i32); + let c = f(region.x0.saturating_add(dx as i32), region.y0.saturating_add(dy as i32)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; px[ty * side + tx] = egui::Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])); } diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 562b2b7521..4e74a92ba3 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -143,6 +143,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 }, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e2aa0f5f5..2e31032476 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 2afb9fd44a..0eb2be8d7e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1857,7 +1857,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 { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let (r, _) = ui.allocate_exact_size(vec2(64.0, 22.0), Sense::hover()); @@ -2050,6 +2054,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| { @@ -2307,8 +2316,8 @@ 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()); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); fx_drop(ctx, ui, l, rect, actions); @@ -2371,8 +2380,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 { @@ -2380,15 +2389,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 { @@ -2415,7 +2426,7 @@ fn layer_row( // Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright. let italic = !t.pro && l.name == "Background" && l.locks.transparency; // 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()); @@ -2430,7 +2441,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)| { @@ -2438,7 +2452,8 @@ fn layer_row( if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } - thumb.expand(2.0).contains(p).then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) + (ts > 0.0 && 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. let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { @@ -2456,7 +2471,7 @@ fn layer_row( // defaults to its mask, but clicking its content thumbnail must leave the mask target. let pos = resp.interact_pointer_pos(); let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p)); - 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)); let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector); // Clicking the layer thumbnail leaves mask view (#196). let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id); @@ -2499,9 +2514,9 @@ fn layer_row( } } else if pos.and_then(|p| masks.hit(p)).is_none() { let id = match &l.content { - LayerContent::Adjustment(_) | LayerContent::Fill(_) if on(thumb) => "layer.layerContentOptions", - LayerContent::Smart(_) if on(thumb) => "layer.smartObjects.editContents", - LayerContent::Text(_) if on(thumb) => "type.editText", + LayerContent::Adjustment(_) | LayerContent::Fill(_) 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) { @@ -2561,7 +2576,9 @@ fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, // Keep row layout and outside thumbnail decorations when the image is clipped. if ui.is_rect_visible(rect) { // The texture is a letterboxed square: draw only the part the document fills. - let (fitted, uv) = layer_thumb_fit(rect, doc.size.width, doc.size.height); + let mode = app.ui.layers_panel_options.thumbnail_contents; + let bounds = crate::layers_panel_ui::thumbnail_region(app, doc, l, mode); + let (fitted, uv) = layer_thumb_fit(rect, bounds.width().max(1), bounds.height().max(1)); widgets::checker(p, fitted, 5.0); let tex = app.layer_thumb(ctx, doc, l); p.image(tex, fitted, uv, Color32::WHITE); @@ -3207,7 +3224,7 @@ fn layer_drop_payload(dragged: u64, target: LayerId, position: &str, selected: & /// 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, @@ -3249,12 +3266,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; @@ -3283,12 +3303,16 @@ fn layer_drag_and_drop( painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); } } + let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); + let mut payload = layer_drop_payload(dragged, l.id, position, &selected); + 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 { - let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); - let mut payload = layer_drop_payload(dragged, l.id, position, &selected); - 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)); } } @@ -3298,7 +3322,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, @@ -3318,14 +3342,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); } } @@ -3387,6 +3414,7 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); + let (row_height, _, eye_size, font_size) = crate::smart_ui::smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; for e in &l.effects.items { @@ -3394,7 +3422,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click_and_drag()); // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. if resp.drag_started() { start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); @@ -3411,7 +3439,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", eye_size, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); @@ -3433,7 +3461,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rect.center().y, right, &name, - egui::FontId::proportional(11.5), + egui::FontId::proportional(font_size), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..89b70e927b 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,10 +1,11 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; +use crate::state::LayerThumbnailSize; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; @@ -32,6 +33,18 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } +/// Smart-filter sub-rows scale with the main Layers panel setting. The eye column +/// remains independently clickable, while the filter mask and text keep their +/// own alignment and avoid overlap when the thumbnail is set to None. +pub(crate) fn smart_filter_row_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32, f32, f32) { + let (thumb, row) = crate::layers_panel_ui::thumbnail_geometry(size, pro); + let height = row.max(22.0); + let mask = if thumb == 0.0 { 0.0 } else { thumb.min(height - 8.0) }; + let eye = if thumb >= 40.0 { 17.0 } else { 12.0 }; + let font = if thumb >= 40.0 { 13.0 } else { 11.5 }; + (height, mask, eye, font) +} + /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { @@ -40,10 +53,11 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: return; } let t = Tokens::get(ui.ctx()); + let (row_height, mask_size, eye_size, font_size) = smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -61,7 +75,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", eye_size, t.icon); } if eye_resp.clicked() { actions.push(match index { @@ -70,12 +84,24 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: }); } let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; - if index.is_none() { - let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); + if index.is_none() && mask_size > 0.0 { + // The Smart Filters mask scales along with the thumbnail size, not + // independently of the selected Layers panel display preference. + let sw = Rect::from_center_size(pos2(x + mask_size / 2.0, rect.center().y), vec2(mask_size, mask_size)); ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + let text_x = if index.is_none() && mask_size > 0.0 { x + mask_size + 7.0 } else { x }; + crate::layer_row_ui::label( + ui.painter(), + text_x, + rect.center().y, + rect.right() - crate::layer_row_ui::RIGHT_PAD, + &name, + egui::FontId::proportional(font_size), + if on { t.text_dim } else { t.text_faint }, + ); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); if let Some(i) = index && resp.double_clicked() { @@ -121,3 +147,33 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } + +#[cfg(test)] +mod layer_thumbnail_scaling_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn smart_filters_and_layer_effect_rows_follow_thumbnail_setting() { + for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); + session.execute("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + session.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + session.execute("filter.sharpen.unsharpMask", json!({"amount": 80, "radius": 1, "threshold": 0})).unwrap(); + assert!(matches!(session.active().unwrap().doc.layers.last().unwrap().content, LayerContent::Smart(_))); + let mut h = Harness::builder().with_size(vec2(1440.0, 950.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.state_mut().ui.layers_panel_options.thumbnail_size = setting; + h.run_steps(8); + let expected = smart_filter_row_geometry(setting, true).0; + for label in ["Smart Filters", "Gaussian Blur", "Unsharp Mask"] { + let rect = h.get_by_label(label).rect(); + assert!((rect.height() - expected).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {expected}"); + } + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5e6021b668..4665db2d63 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -3,6 +3,68 @@ 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, + pub thumbnail_contents: LayerThumbnailContents, + pub show_filters: bool, + pub use_default_masks_on_fill_layers: 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 { + pub fn defaults() -> Self { + Self { + thumbnail_size: LayerThumbnailSize::Medium, + thumbnail_contents: LayerThumbnailContents::EntireDocument, + show_filters: true, + use_default_masks_on_fill_layers: 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 { + 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)] @@ -948,6 +1010,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, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -1082,6 +1150,8 @@ impl Default for UiState { slices: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layers_panel_options: LayersPanelOptions::default(), + layers_panel_dialog: None, pen: None, stroke_editor: None, direct_selection: Default::default(), From 8911a4ffbe85c4fd329585669c0d80a0fd193375 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:26:13 +0300 Subject: [PATCH 02/19] test(engine): expect signed HDR outputs from destructive Levels --- crates/engine/src/adjust_params.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 00fb67dd03..8bc99a40bc 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,9 +1240,12 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). + // integer documents. A 32-bit document evaluates the signed power curve beyond both + // ranges, retaining negative values and highlights above 1. The float expectations + // below were evaluated independently from the configured curve, not a clipped LUT. let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { + for depth in [8, 16, 32] { + let (low, high, tolerance) = if depth == 32 { (-0.079_642_7, 1.042_158, 1e-6) } else { (10.0 / 255.0, 245.0 / 255.0, 0.6 / 255.0) }; let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1250,8 +1253,10 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); + assert!((px[0][0] - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); + assert_eq!(px[0][3], 1.0, "Levels keeps alpha"); + assert_eq!(px[1][3], 1.0, "Levels keeps alpha"); } } From 28a3514a73d00ef178085009c314e67c33aa5841 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:29:15 +0300 Subject: [PATCH 03/19] Deny ambient installed profile discovery in automation --- crates/automation/src/workspace.rs | 19 ++++++++++++++++++- docs/control-protocol.md | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index d8e82503eb..400c0d9e15 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -307,7 +307,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { - if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") { + // Installed profiles scan OS and user directories even without a request path. + if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { return true; } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") @@ -752,6 +753,22 @@ mod tests { assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); } + #[test] + fn installed_profile_discovery_requires_filesystem_authority() { + for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"}), serde_json::json!({"path": "/outside/profile.icc"})] + { + assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err()); + assert!(authorize_desktop_engine_step("edit.installedProfiles", ¶ms).is_err()); + } + // Built-in profiles remain usable without reading installed profile files. + for id in ["edit.assignProfile", "edit.convertToProfile", "edit.profileInfo"] { + assert!(authorize_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); + assert!(authorize_desktop_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); + } + } + #[test] fn registry_filesystem_path_params_are_classified() { let probe = serde_json::json!({ diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 87d1bbf65f..cf4a1159a3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -145,7 +145,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. +Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. `edit.installedProfiles` is also refused because it scans OS and user profile directories outside the automation roots, even without a request path. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources From 3ad6011bc502cd58d5acda6d75866a9f60cb054d Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:48 +0300 Subject: [PATCH 04/19] Remove shared main repairs from Layers Panel feature scope --- Cargo.toml | 5 ----- crates/automation/src/workspace.rs | 19 +------------------ crates/engine/src/adjust_params.rs | 13 ++++--------- docs/control-protocol.md | 2 +- 4 files changed, 6 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 36e608b71e..7502ff4fd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,11 +100,6 @@ opt-level = "s" [profile.wasm-release.package.photocraft-ui-egui] opt-level = "z" -# Prefer compact command dispatch and parameter handling in the browser. Native profiles -# and the speed-oriented pixel-processing crates below keep their existing settings. -[profile.wasm-release.package.photocraft-engine] -opt-level = "z" - [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 400c0d9e15..d8e82503eb 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -307,8 +307,7 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { - // Installed profiles scan OS and user directories even without a request path. - if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { + if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") { return true; } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") @@ -753,22 +752,6 @@ mod tests { assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); } - #[test] - fn installed_profile_discovery_requires_filesystem_authority() { - for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"}), serde_json::json!({"path": "/outside/profile.icc"})] - { - assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err()); - assert!(authorize_desktop_engine_step("edit.installedProfiles", ¶ms).is_err()); - } - // Built-in profiles remain usable without reading installed profile files. - for id in ["edit.assignProfile", "edit.convertToProfile", "edit.profileInfo"] { - assert!(authorize_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); - assert!(authorize_desktop_engine_command(id, &serde_json::json!({"profile": "srgb"})).is_ok()); - } - } - #[test] fn registry_filesystem_path_params_are_classified() { let probe = serde_json::json!({ diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 8bc99a40bc..00fb67dd03 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,12 +1240,9 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents. A 32-bit document evaluates the signed power curve beyond both - // ranges, retaining negative values and highlights above 1. The float expectations - // below were evaluated independently from the configured curve, not a clipped LUT. + // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for depth in [8, 16, 32] { - let (low, high, tolerance) = if depth == 32 { (-0.079_642_7, 1.042_158, 1e-6) } else { (10.0 / 255.0, 245.0 / 255.0, 0.6 / 255.0) }; + for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1253,10 +1250,8 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); - assert_eq!(px[0][3], 1.0, "Levels keeps alpha"); - assert_eq!(px[1][3], 1.0, "Levels keeps alpha"); + assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cf4a1159a3..87d1bbf65f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -145,7 +145,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. `edit.installedProfiles` is also refused because it scans OS and user profile directories outside the automation roots, even without a request path. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. +Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources From 44c52a013ad710a64069070361960fa104ec76b0 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:17:55 +0300 Subject: [PATCH 05/19] Keep Layers Panel menu and options; extract sub-row scaling and fill masks --- crates/engine/src/commands.rs | 14 ++---- crates/engine/src/layer_multi_cmds.rs | 13 ----- crates/engine/src/pattern_cmds.rs | 13 ++--- crates/engine/src/pattern_fill_cmds.rs | 32 +----------- crates/ui-egui/src/i18n/cs.tsv | 1 - crates/ui-egui/src/i18n/de.tsv | 1 - crates/ui-egui/src/i18n/el.tsv | 1 - crates/ui-egui/src/i18n/es.tsv | 1 - crates/ui-egui/src/i18n/fr.tsv | 1 - crates/ui-egui/src/i18n/id.tsv | 1 - crates/ui-egui/src/i18n/it.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - crates/ui-egui/src/i18n/ko.tsv | 1 - crates/ui-egui/src/i18n/nl.tsv | 1 - crates/ui-egui/src/i18n/pl.tsv | 1 - crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - crates/ui-egui/src/i18n/uk.tsv | 1 - crates/ui-egui/src/i18n/zh-hans.tsv | 1 - crates/ui-egui/src/i18n/zh-hant.tsv | 1 - crates/ui-egui/src/layers_panel_ui.rs | 23 --------- crates/ui-egui/src/lib.rs | 21 ++------ crates/ui-egui/src/panels.rs | 7 ++- crates/ui-egui/src/smart_ui.rs | 68 +++----------------------- crates/ui-egui/src/state.rs | 2 - 25 files changed, 23 insertions(+), 186 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ad78fe6dfa..9bb9ed37dd 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -282,7 +282,7 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its /// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). /// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { +pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -293,9 +293,7 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => { - l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; - } + None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?), } Ok(()) } @@ -309,7 +307,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res let name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); - mask_new_layer(doc, &mut l, p, cmd, true)?; + mask_new_layer(doc, &mut l, p, cmd)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -800,11 +798,10 @@ fn build() -> Vec { has_doc, |s, p| { let c = color_param(p, "color", s.tools.foreground); - let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -825,7 +822,6 @@ fn build() -> Vec { let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); - let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], @@ -836,7 +832,7 @@ fn build() -> Vec { ) .in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 654c2c8ec4..f5fccfc3a3 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2518,19 +2518,6 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().fx_collapsed.contains(&target)); } - #[test] - fn fill_layers_respect_use_default_masks_option() { - let (mut s, _) = new_session(); - option(&mut s, "useDefaultMasksOnFillLayers", false); - let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap(); - let id = LayerId(new["layer"].as_u64().unwrap()); - assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none()); - option(&mut s, "useDefaultMasksOnFillLayers", true); - let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); - let id = LayerId(new["layer"].as_u64().unwrap()); - assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); - } - #[test] fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { let (mut s, ids) = new_session(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index a56590ad2b..21bee1283c 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -307,18 +307,12 @@ pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { /// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks, /// above `active`. The Pattern Fill dialog previews it on a copy of the document. -pub(crate) fn insert_fill_layer( - doc: &mut Document, - active: &mut Option, - pat: &Pattern, - p: &Value, - default_mask: bool, -) -> Result { +pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option, pat: &Pattern, p: &Value) -> Result { let (scale, angle, link, phase) = placement(p); ensure_in_doc(doc, pat); let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); - crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; + crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -326,8 +320,7 @@ pub(crate) fn insert_fill_layer( fn new_fill_layer(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; - let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); - let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p, default_mask))?; + let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p))?; Ok(json!({"layer": id.0, "pattern": pat.id})) } diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index 36adffcf0c..daa7e9660f 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -124,8 +124,7 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result { CREATE => { let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?; let mut active = st.active_layer; - let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); - crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?; + crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p)?; } other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }), } @@ -217,35 +216,6 @@ mod tests { assert!(preview(&s, "edit.fill", &json!({})).is_err()); } - #[test] - fn create_preview_and_commit_honor_default_masks_option() { - for depth in [8, 16, 32] { - for default_mask in [false, true] { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); - s.edit_prefs(|prefs| { - prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask})); - }); - let before = s.active().unwrap().doc.clone(); - let count = s.active().unwrap().history.entries().len(); - let p = json!({"pattern": "Grid", "scale": 50}); - let shown = preview(&s, CREATE, &p).unwrap(); - assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); - assert_eq!(s.active().unwrap().history.entries().len(), count); - let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap(); - assert_eq!(previewed.mask.is_some(), default_mask); - let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap()); - let committed = s.active().unwrap().doc.layer(id).unwrap(); - assert_eq!(committed.mask, previewed.mask); - assert_eq!(committed.content, previewed.content); - s.undo(); - assert_eq!(*s.active().unwrap().doc, *before); - s.redo(); - assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask); - } - } - } - #[test] fn bad_params_and_targets_are_errors_not_panics() { let (mut s, id) = session(8); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bc0113ebf7..bcb7e0070c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2594,7 +2594,6 @@ cameraData Lens Objektiv Show Layers Zobrazit vrstvy Thumbnail Contents Obsah miniatur Thumbnail Size Velikost miniatur - Use Default Masks on Fill Layers Používat výchozí masky u vrstev výplně 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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 73d817922c..fa5e8d5cbb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2624,7 +2624,6 @@ cameraData Lens Objektiv Show Layers Ebenen einblenden Thumbnail Contents Miniaturinhalt Thumbnail Size Miniaturgröße - Use Default Masks on Fill Layers Standardmasken für Füllebenen verwenden 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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ed2d9ea23d..789a9c7a98 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3681,7 +3681,6 @@ cameraData Lens Φακός Show Layers Εμφάνιση επιπέδων Thumbnail Contents Περιεχόμενο μικρογραφιών Thumbnail Size Μέγεθος μικρογραφιών - Use Default Masks on Fill Layers Χρήση προεπιλεγμένων μασκών σε επίπεδα γεμίσματος Expand New Effects Ανάπτυξη νέων εφέ Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7ea34ccc95..a729292a92 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2665,7 +2665,6 @@ cameraData Lens Objetivo Show Layers Mostrar capas Thumbnail Contents Contenido de miniaturas Thumbnail Size Tamaño de miniaturas - Use Default Masks on Fill Layers Usar máscaras predeterminadas en capas de relleno 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 846ba14b0f..3ee30e25b6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3000,7 +3000,6 @@ cameraData Lens Objectif Show Layers Afficher les calques Thumbnail Contents Contenu des vignettes Thumbnail Size Taille des vignettes - Use Default Masks on Fill Layers Utiliser les masques par défaut sur les calques de remplissage 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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ec089c2a36..f3b54c2437 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2987,7 +2987,6 @@ cameraData Lens Lensa Show Layers Tampilkan Lapisan Thumbnail Contents Isi Gambar Mini Thumbnail Size Ukuran Gambar Mini - Use Default Masks on Fill Layers Gunakan Masker Default pada Lapisan Isian 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index afa0df18ff..57332dd07a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2656,7 +2656,6 @@ cameraData Lens Obiettivo Show Layers Mostra livelli Thumbnail Contents Contenuto miniature Thumbnail Size Dimensione miniature - Use Default Masks on Fill Layers Usa maschere predefinite sui livelli di riempimento 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 700f3919e9..88fa61f2e1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3595,7 +3595,6 @@ cameraData Lens レンズ Show Layers レイヤーを表示 Thumbnail Contents サムネールの内容 Thumbnail Size サムネールのサイズ - Use Default Masks on Fill Layers 塗りつぶしレイヤーにデフォルトのマスクを使用 Expand New Effects 新しい効果を展開 Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加 Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e8d6c3ffb8..e31501b8df 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3167,7 +3167,6 @@ cameraData Lens 렌즈 Show Layers 레이어 표시 Thumbnail Contents 축소판 내용 Thumbnail Size 축소판 크기 - Use Default Masks on Fill Layers 칠 레이어에 기본 마스크 사용 Expand New Effects 새 효과 펼치기 Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가 Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c3dd81b300..fe08dd0753 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3740,7 +3740,6 @@ cameraData Lens Lens Show Layers Lagen tonen Thumbnail Contents Inhoud van miniaturen Thumbnail Size Grootte van miniaturen - Use Default Masks on Fill Layers Standaardmaskers gebruiken op opvullagen 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e273ba32bc..3bb0dbe81e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3597,7 +3597,6 @@ cameraData Lens Obiektyw Show Layers Pokaż warstwy Thumbnail Contents Zawartość miniaturek Thumbnail Size Rozmiar miniaturek - Use Default Masks on Fill Layers Użyj domyślnych masek na warstwach wypełnienia 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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7365a389d7..25331f5f27 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3004,7 +3004,6 @@ cameraData Lens Lente Show Layers Mostrar camadas Thumbnail Contents Conteúdo das miniaturas Thumbnail Size Tamanho das miniaturas - Use Default Masks on Fill Layers Usar máscaras padrão nas camadas de preenchimento 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5a7abd5b4e..a36277b34a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2923,7 +2923,6 @@ cameraData Lens Объектив Show Layers Показать слои Thumbnail Contents Содержимое миниатюр Thumbnail Size Размер миниатюр - Use Default Masks on Fill Layers Использовать маски по умолчанию для слоёв-заливок Expand New Effects Раскрывать новые эффекты Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b55771d337..afb006e3bb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3671,7 +3671,6 @@ cameraData Lens Об'єктив Show Layers Показати шари Thumbnail Contents Вміст мініатюр Thumbnail Size Розмір мініатюр - Use Default Masks on Fill Layers Використовувати типові маски для шарів заливки Expand New Effects Розгортати нові ефекти Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 27b5e0de63..052f096fbc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2595,7 +2595,6 @@ cameraData Lens 镜头 Show Layers 显示图层 Thumbnail Contents 缩略图内容 Thumbnail Size 缩略图大小 - Use Default Masks on Fill Layers 在填充图层上使用默认蒙版 Expand New Effects 展开新效果 Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本” Preview on canvas when reordering layers 重新排列图层时在画布上预览 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b69bd18fd0..79a67f681e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2565,7 +2565,6 @@ cameraData Lens 鏡頭 Show Layers 顯示圖層 Thumbnail Contents 縮圖內容 Thumbnail Size 縮圖大小 - Use Default Masks on Fill Layers 在填色圖層使用預設遮色片 Expand New Effects 展開新效果 Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」 Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽 diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index 181f60adf1..43c2df5c08 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -232,8 +232,6 @@ fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { ui.add_space(4.0); }); ui.add_space(9.0); - ui.checkbox(&mut draft.use_default_masks_on_fill_layers, tl!("Use Default Masks on Fill Layers")); - ui.add_space(3.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")); @@ -344,7 +342,6 @@ mod tests { assert_eq!(options.thumbnail_size, LayerThumbnailSize::Medium); assert_eq!(options.thumbnail_contents, LayerThumbnailContents::EntireDocument); assert!(options.show_filters); - assert!(options.use_default_masks_on_fill_layers); assert!(options.expand_new_effects); assert!(options.add_copy_to_copied_layers_and_groups); assert!(options.preview_on_canvas_when_reordering_layers); @@ -384,25 +381,6 @@ mod tests { assert!(restored.layers_panel_dialog.is_none()); } - #[test] - fn disabling_default_fill_masks_never_targets_an_absent_mask() { - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); - let mut app = PhotocraftApp::new(session, crate::Services::default()); - app.ui.layers_panel_options.use_default_masks_on_fill_layers = false; - persist_options(&mut app); - app.run("layer.newFillLayer.solidColor", json!({"color": "#778899"})).unwrap(); - let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); - assert!(layer.mask.is_none()); - assert!(!app.ui.mask_target && !app.ui.vector_mask_target); - app.ui.layers_panel_options.use_default_masks_on_fill_layers = true; - persist_options(&mut app); - app.run("layer.newFillLayer.gradient", json!({})).unwrap(); - let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); - assert!(layer.mask.is_some()); - assert!(app.ui.mask_target); - } - #[test] fn prefs_options_save_and_restore_all_switches() { let mut session = photocraft_engine::Session::new(); @@ -412,7 +390,6 @@ mod tests { thumbnail_size: LayerThumbnailSize::Large, thumbnail_contents: LayerThumbnailContents::LayerBounds, show_filters: false, - use_default_masks_on_fill_layers: false, expand_new_effects: false, add_copy_to_copied_layers_and_groups: false, preview_on_canvas_when_reordering_layers: false, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 08851883a9..8438551596 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -954,24 +954,13 @@ impl PhotocraftApp { jobs_ui::run(self, id, params) }; let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); - if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { - // Adding an explicit mask targets it, as in Photoshop (#2166). + if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { + // Adding a layer mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; - self.ui.properties_layer = None; - } else if r.is_ok() && creates_adjustment_or_fill { - // Panel Options may suppress a fill layer's default mask. Never target a - // nonexistent mask; an active path creates a vector mask instead. - let masks = self - .session - .active() - .and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))) - .map(|layer| (layer.mask.is_some(), layer.vector_mask.is_some())) - .unwrap_or((false, false)); - self.ui.mask_target = masks.0; - self.ui.vector_mask_target = !masks.0 && masks.1; - // Keep Properties on the new layer contents, as on main (#3369). - self.ui.properties_layer = self.session.active().and_then(|st| st.active_layer).map(|l| l.0); + // Properties still shows the new adjustment or fill layer's settings, not its mask, + // as in Photoshop (#3369): clicking the mask thumbnail switches it to the mask. + self.ui.properties_layer = if creates_adjustment_or_fill { self.session.active().and_then(|st| st.active_layer).map(|l| l.0) } else { None }; } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 483539ae44..87336ce045 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3859,7 +3859,6 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); - let (row_height, _, eye_size, font_size) = crate::smart_ui::smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; for e in &l.effects.items { @@ -3867,7 +3866,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click_and_drag()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. if resp.drag_started() { start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); @@ -3884,7 +3883,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { - icons::paint(ui, eye, "eye", eye_size, t.icon); + icons::paint(ui, eye, "eye", 12.0, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); @@ -3906,7 +3905,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rect.center().y, right, &name, - egui::FontId::proportional(font_size), + egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }, ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 89b70e927b..99deaa60fe 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,11 +1,10 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). -use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; -use crate::state::LayerThumbnailSize; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; @@ -33,18 +32,6 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } -/// Smart-filter sub-rows scale with the main Layers panel setting. The eye column -/// remains independently clickable, while the filter mask and text keep their -/// own alignment and avoid overlap when the thumbnail is set to None. -pub(crate) fn smart_filter_row_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32, f32, f32) { - let (thumb, row) = crate::layers_panel_ui::thumbnail_geometry(size, pro); - let height = row.max(22.0); - let mask = if thumb == 0.0 { 0.0 } else { thumb.min(height - 8.0) }; - let eye = if thumb >= 40.0 { 17.0 } else { 12.0 }; - let font = if thumb >= 40.0 { 13.0 } else { 11.5 }; - (height, mask, eye, font) -} - /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { @@ -53,11 +40,10 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: return; } let t = Tokens::get(ui.ctx()); - let (row_height, mask_size, eye_size, font_size) = smart_filter_row_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } @@ -75,7 +61,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", eye_size, t.icon); + icons::paint(ui, eye, "eye", 12.0, t.icon); } if eye_resp.clicked() { actions.push(match index { @@ -84,24 +70,12 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: }); } let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; - if index.is_none() && mask_size > 0.0 { - // The Smart Filters mask scales along with the thumbnail size, not - // independently of the selected Layers panel display preference. - let sw = Rect::from_center_size(pos2(x + mask_size / 2.0, rect.center().y), vec2(mask_size, mask_size)); + if index.is_none() { + let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - let text_x = if index.is_none() && mask_size > 0.0 { x + mask_size + 7.0 } else { x }; - crate::layer_row_ui::label( - ui.painter(), - text_x, - rect.center().y, - rect.right() - crate::layer_row_ui::RIGHT_PAD, - &name, - egui::FontId::proportional(font_size), - if on { t.text_dim } else { t.text_faint }, - ); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); + ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); if let Some(i) = index && resp.double_clicked() { @@ -147,33 +121,3 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } - -#[cfg(test)] -mod layer_thumbnail_scaling_tests { - use super::*; - use egui_kittest::{Harness, kittest::Queryable}; - - #[test] - fn smart_filters_and_layer_effect_rows_follow_thumbnail_setting() { - for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); - session.execute("edit.fill", json!({"color": "#8A42B3"})).unwrap(); - session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); - session.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); - session.execute("filter.sharpen.unsharpMask", json!({"amount": 80, "radius": 1, "threshold": 0})).unwrap(); - assert!(matches!(session.active().unwrap().doc.layers.last().unwrap().content, LayerContent::Smart(_))); - let mut h = Harness::builder().with_size(vec2(1440.0, 950.0)).with_max_steps(64).build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - PhotocraftApp::new(session, crate::Services::default()) - }); - h.state_mut().ui.layers_panel_options.thumbnail_size = setting; - h.run_steps(8); - let expected = smart_filter_row_geometry(setting, true).0; - for label in ["Smart Filters", "Gaussian Blur", "Unsharp Mask"] { - let rect = h.get_by_label(label).rect(); - assert!((rect.height() - expected).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {expected}"); - } - } - } -} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9a257c7290..4fb519ef64 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -28,7 +28,6 @@ pub struct LayersPanelOptions { pub thumbnail_size: LayerThumbnailSize, pub thumbnail_contents: LayerThumbnailContents, pub show_filters: bool, - pub use_default_masks_on_fill_layers: bool, pub expand_new_effects: bool, pub add_copy_to_copied_layers_and_groups: bool, pub preview_on_canvas_when_reordering_layers: bool, @@ -41,7 +40,6 @@ impl LayersPanelOptions { thumbnail_size: LayerThumbnailSize::Medium, thumbnail_contents: LayerThumbnailContents::EntireDocument, show_filters: true, - use_default_masks_on_fill_layers: true, expand_new_effects: true, add_copy_to_copied_layers_and_groups: true, preview_on_canvas_when_reordering_layers: true, From cfe712c34017198bdbb0cedd4fa1c447da53da43 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:22:30 +0300 Subject: [PATCH 06/19] Apply panel copy-name setting to the new artboard duplication path --- crates/engine/src/artboard_cmds.rs | 3 ++- crates/engine/src/artboard_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..7b7fb8ea90 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -424,6 +424,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)?; @@ -438,7 +439,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 1faf4cb75d..dc48dd3669 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -585,3 +585,23 @@ 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()); } + +#[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); + } +} From 6bf45d05acb3550f2cd87169b9fb5a9564e0257b Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:10:38 +0300 Subject: [PATCH 07/19] i18n: translate the Layers Panel strings in hu, tr and vi; drop Italian duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was written, so every_tl_literal_is_translated failed on the merged tree. Main also translated New Layer…, Panel Options… and Lock Layers in Italian (#3742); drop this branch's copies, which bundled_catalogs_are_consistent rejects as duplicates. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/hu.tsv | 17 +++++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 3 --- crates/ui-egui/src/i18n/tr.tsv | 17 +++++++++++++++++ crates/ui-egui/src/i18n/vi.tsv | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0eff7b8a7..b401617443 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,3 +3972,20 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + +# 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 94c3b78e53..3235de7200 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2649,11 +2649,8 @@ cameraData Lens Obiettivo Filter Options Opzioni filtro Layer Bounds Limiti livello Layers Panel Options Opzioni pannello Livelli - Lock Layers Blocca livelli New Artboard… Nuova tavola da disegno… New Group… Nuovo gruppo… - New Layer… Nuovo livello… - Panel Options… Opzioni pannello… Prevent Auto-Nesting Impedisci annidamento automatico Show Layers Mostra livelli Thumbnail Contents Contenuto miniature diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8401f8e5ac..48e2b25c0b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,3 +3784,20 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + +# 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 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f7cd59aebb..66810fe329 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3536,3 +3536,21 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + +# 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ỏ From 12ec8fceea65596de23fb866914529843754d8d5 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:51:53 +0300 Subject: [PATCH 08/19] i18n(sr): translate this PR's strings in the new Serbian catalog Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sr.tsv | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b2da769bf1..5e2bcc73cc 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4204,3 +4204,20 @@ shape Appearance Izgled Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: + +# 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 From 1d1cb4e4620a4c239d5c17e3d23a68fc4ba50982 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:34:47 +0000 Subject: [PATCH 09/19] Name layerFilter in the ui_set tool description main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not, so ui_set_description_names_every_accepted_field fails on ubuntu. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT --- crates/automation/src/server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c5a0c3b180..be0764e6d9 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,7 +325,9 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching by (kind, name, mode), kinds + /// (a list of pixel, adjustment, type, shape, smart), name and mode, the Layers panel's filter). + /// Other fields are an error. pub fields: Value, } From e3f95672df9a24a11a8ed2e2145f44636d1408f9 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:35:59 +0300 Subject: [PATCH 10/19] i18n(es): drop this PR's copies of rows main's catalog now has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's Spanish catalog (#4306) added New Layer…, Panel Options… and Lock Layers, so bundled_catalogs_are_consistent failed on the duplicates. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 164b533618..5184c370c5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2826,7 +2826,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 @@ -2903,7 +2902,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}% @@ -3814,7 +3812,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 From b0350c64c1d3e3466aee456c979f19ff8fd1edc9 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:41:04 +0300 Subject: [PATCH 11/19] Session::start: drop the checks main moved into admit The merge with main kept the old precondition, job conflict and hidden layer checks next to main's `self.admit(...)` (#5684), which runs the same checks, so every started command was checked twice. The New Effects disclosure block now follows admit, as it followed those checks before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9f71e6a3ff..6d325ccd5e 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -713,17 +713,7 @@ impl Session { return Ok(Started::Done(v)); } // Pixel commands follow the Channels panel target unless the caller names one. - let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone())); - if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - if let Some(why) = self.job_conflict(id, spec.journal) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - // Painting on or moving a hidden layer is refused, as in Photoshop (#571). - if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) { - return Err(EngineError::Other(why.into())); - } + 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. let new_fx_target = if id.starts_with("layer.layerStyle.") { @@ -735,7 +725,6 @@ impl Session { None }; let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true); - let run_params = self.admit(id, spec, ¶ms)?; 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; From e727c3dcf8d6d3f32a51112878e589b3ffc8b4b3 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:37 +0300 Subject: [PATCH 12/19] Layers panel reorder preview: share move_to's structural move, clipping groups included The hover preview (preview_move) re-implemented the layer removal and insertion but skipped the rest of move_to: the clip-group rules (clip_group::Before/settle), the Background copy rules (unlocked copy) and the Background-stays-at-the-bottom check. So the preview showed different clipping than the drop commits, e.g. B C1* C2* U with U dropped above C1 committed B C1* U* C2* but previewed U unclipped, and moving a clipping base away left its layers clipped in the preview. Factor the document-only part of move_to into apply_move and call it from both, so they cannot drift. A drop move_to would refuse (Background off the bottom, groups nested too deep) now has no preview either. Tests compare preview and committed documents (stack order, clip flags and the flattened composite) for clip-group drops, a base moved away or up into its own group, option-drag copies, a Background copy, and refused Background drops. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 190 +++++++++++++++++++------- 1 file changed, 144 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index d4ea64bd8d..1ab67610d3 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1092,31 +1092,49 @@ pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option 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 { @@ -1175,32 +1193,8 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { let original_active = st.active_layer; let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); - let background = crate::background_cmds::background_id(doc); 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, 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); + let copies = apply_move(doc, &moved_ids, target, pos, copy, add_copy)?; *active = if copy { copies.last().copied() } else if batch.is_some() { @@ -2862,6 +2856,110 @@ mod layers_panel_parity_tests { 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(" ") + } + + 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}"); + } + + #[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"); + } + + #[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); + } + + #[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}"); + } + } + #[test] fn expand_new_effects_applies_to_ui_start_dispatch_too() { let (mut s, ids) = new_session(); From 3f5c65e166f0b3c59f959c50f18a83d497f50122 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:48 +0300 Subject: [PATCH 13/19] Workspace layouts no longer save or restore the Layers panel options The options persist on their own in the preferences (persist_options and restore_options, as Photoshop's Panel Options do), and engine commands read them from there. Restoring a workspace layout that carried the key put the UI out of step with the preferences, so the panel and the commands disagreed. Drop layersPanelOptions from the layout snapshot and from apply; layouts saved by earlier builds keep the key, which is now ignored. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 5 +---- crates/ui-egui/src/dock_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7bcf31063a..3945ef728a 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -809,7 +809,7 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) /// What `prefs.panelLayout` holds: the live layout and open panels. fn snapshot(app: &PhotocraftApp) -> Value { - json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "layersPanelOptions": app.ui.layers_panel_options, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingHidden": app.ui.docking_hidden, "dockingCollapsed": app.ui.docking_collapsed}) + json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingHidden": app.ui.docking_hidden, "dockingCollapsed": app.ui.docking_collapsed}) } /// Remember the layout in the preferences once the user lets go of the mouse (Workspace › @@ -861,9 +861,6 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) { if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) { app.ui.dock = d; } - if let Some(opts) = v.get("layersPanelOptions").and_then(|o| serde_json::from_value(o.clone()).ok()) { - app.ui.layers_panel_options = opts; - } crate::panel_docking::sync_visibility(app); app.ui.timeline.open = v.get("timelineOpen").and_then(Value::as_bool).unwrap_or(false); if !app.ui.timeline.open { 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); +} From 62dbd6ced770d4bfd217c0e2ed85a1d2c794005a Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:48 +0300 Subject: [PATCH 14/19] Layers panel: thumbnails off no longer leave mask thumbnails that catch drags starts_mask_drag reads the mask thumbnail rects recorded in egui memory on an earlier frame. With the thumbnail size set to None no mask thumbnail is drawn and nothing is recorded, so rects from when thumbnails were on stayed behind and a drag of the row that started there was classified as a mask drag. Forget the layer's recorded rects before the drag is classified when thumbnails are off. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/mask_thumbs_ui.rs | 6 +++++ crates/ui-egui/src/mask_thumbs_ui/tests.rs | 30 ++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 4 +++ 3 files changed, 40 insertions(+) diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 1a8ff55d75..225cd47e8f 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -181,6 +181,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/panels.rs b/crates/ui-egui/src/panels.rs index d4e495ae14..875014273b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2954,6 +2954,10 @@ fn layer_row( // 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 { From 792413b5281ca157199ca182e86290fc29de83b9 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:49 +0300 Subject: [PATCH 15/19] Expand New Effects follows the layer a Layer Style command changes The disclosure target only read a numeric "layer" param, so a layer named by its name (which every command accepts through explicit_layer) fell back to the active layer and the wrong layer's effects were expanded or collapsed. Resolve the target with commands::layer_param, which understands every explicit form and still falls back to the active layer. layer.layerStyle.clear needs no case: it leaves the layer without effects, which the existing post-run check already excludes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 5 ++++- crates/engine/src/layer_multi_cmds.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 00b83682b7..5566d39c3f 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -725,9 +725,12 @@ impl Session { 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. let new_fx_target = if id.starts_with("layer.layerStyle.") { + let target = crate::commands::layer_param(self, ¶ms).ok(); self.active().and_then(|st| { - let target = params.get("layer").and_then(Value::as_u64).map(LayerId).or(st.active_layer)?; + let target = target?; (st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target)) }) } else { diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1ab67610d3..a87c4badda 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2987,4 +2987,18 @@ mod layers_panel_parity_tests { 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"); + } } From 7a671f1486bc24bc8938207b4ba7cb1ae4a61fd5 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:41:58 +0300 Subject: [PATCH 16/19] Layers panel options: document the functions and tests the PR adds CodeRabbit measured the docstring coverage of the PR at 79.41%, under its 80% threshold. Add short doc comments saying what each helper does and which Photoshop behaviour each test pins. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/artboard_cmds/tests.rs | 1 + crates/engine/src/layer_multi_cmds.rs | 11 +++++++++++ crates/ui-egui/src/layers_panel_ui.rs | 10 ++++++++++ crates/ui-egui/src/state.rs | 2 ++ 4 files changed, 24 insertions(+) diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index f225f1244c..28e8d0587c 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -586,6 +586,7 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() { 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] { diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index a87c4badda..2081db277c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2787,6 +2787,7 @@ mod tests { 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(); @@ -2794,6 +2795,7 @@ mod layers_panel_parity_tests { (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!({})); @@ -2803,6 +2805,7 @@ mod layers_panel_parity_tests { }); } + /// 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(); @@ -2816,6 +2819,7 @@ mod layers_panel_parity_tests { 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(); @@ -2836,6 +2840,7 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy")); } + /// Drops the command refuses 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(); @@ -2882,6 +2887,7 @@ mod layers_panel_parity_tests { 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 } @@ -2898,6 +2904,7 @@ mod layers_panel_parity_tests { 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`. @@ -2924,6 +2931,7 @@ mod layers_panel_parity_tests { 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*"); @@ -2944,6 +2952,7 @@ mod layers_panel_parity_tests { 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"); @@ -2960,6 +2969,7 @@ mod layers_panel_parity_tests { } } + /// 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(); @@ -2970,6 +2980,7 @@ mod layers_panel_parity_tests { 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(); diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index ef6bbccf7a..43955812dd 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -67,6 +67,8 @@ pub(crate) struct ReorderPreview { 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; @@ -96,6 +98,7 @@ pub(crate) fn preview_reorder(app: &mut PhotocraftApp, payload: &serde_json::Val }); } +/// 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; @@ -244,6 +247,7 @@ fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { 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; @@ -335,6 +339,7 @@ pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) { 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(); @@ -366,6 +371,7 @@ mod tests { 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(); @@ -379,6 +385,7 @@ mod tests { 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(); @@ -401,6 +408,7 @@ mod tests { 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(); @@ -420,6 +428,7 @@ mod tests { 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] { @@ -434,6 +443,7 @@ mod tests { 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(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0922e800fa..7c227e186d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -39,6 +39,7 @@ pub struct LayersPanelOptions { } 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, @@ -53,6 +54,7 @@ impl LayersPanelOptions { } impl Default for LayersPanelOptions { + /// Same as [`LayersPanelOptions::defaults`], so serde fills missing fields with the Photoshop defaults. fn default() -> Self { Self::defaults() } From a83ec3d2c95fab263d6b18b7f7ea9ad413badcb8 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:54:54 +0300 Subject: [PATCH 17/19] Layers panel options: document the functions next to the ones the PR changes CodeRabbit counts the functions around the lines a PR changes, not only the changed ones, so the PR's docstring coverage stays under its 80% threshold unless the untouched neighbours are documented too. Comments only; no code changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 1 + crates/ui-egui/src/clip_line_ui/tests.rs | 3 +++ crates/ui-egui/src/mask_thumbs_ui.rs | 1 + 3 files changed, 5 insertions(+) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2081db277c..2705077d6c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2771,6 +2771,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); diff --git a/crates/ui-egui/src/clip_line_ui/tests.rs b/crates/ui-egui/src/clip_line_ui/tests.rs index aeab35f23c..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)), 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/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 225cd47e8f..66534b6f86 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -172,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") } From 957e68f600fbe3094527ca3be4ead37f53becad6 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:46:18 +0300 Subject: [PATCH 18/19] Expand New Effects follows the destination of Transfer Effects layer.layerStyle.transferEffects names the layer that receives the effects in "to"; it has no "layer" param, so the disclosure target fell back to the active layer and the wrong layer's effects were expanded or collapsed. Resolve "to" for that command before recording new_fx_target. Every other Layer Style command keeps resolving "layer" as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 8 +++++++- crates/engine/src/layer_multi_cmds.rs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5566d39c3f..590a1a77cc 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -727,8 +727,14 @@ impl Session { // 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 = crate::commands::layer_param(self, ¶ms).ok(); + 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)) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2705077d6c..935559192e 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -3013,4 +3013,19 @@ mod layers_panel_parity_tests { 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"); + } } From 743a38e64d7abfbe57a6f2e1295aa43330675e43 Mon Sep 17 00:00:00 2001 From: abc2cb <271337822+abc2cb@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:09:58 +0300 Subject: [PATCH 19/19] Layers panel reorder: Content-Aware Move preview wins, moveTo drops asserted - canvas.rs: the Content-Aware Move preview now comes before the Layers-panel reorder preview. The reorder preview is built from the session document and would drop a pending Content-Aware Move result. - layer_multi_cmds.rs: preview_rejects_invalid_drop_unchanged also runs layer.moveTo for each rejected drop: the missing target, `into` on a non-group and the invalid position are errors, the self-drop is a no-op, and the document stays unchanged each time. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 21 ++++++++++++++------- crates/ui-egui/src/canvas.rs | 9 +++++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index fbe345038f..b5dfc97e64 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -2976,20 +2976,27 @@ mod layers_panel_parity_tests { assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy")); } - /// Drops the command refuses give no preview and leave the document alone; a multi-layer preview follows the committed stack order. + /// 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(); - for p in [ - json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), - json!({"layer": ids[1].0, "target": 999999, "position": "below"}), - json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), - json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), + // 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}"); } - assert_eq!(*before, *s.active().unwrap().doc); // 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(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d426761dd5..1f9feefc30 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1205,12 +1205,13 @@ 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; } - // Live Layers-panel reorder previews the actual composite before mouse release. - if let Some(shown) = crate::layers_panel_ui::display_reorder(app, idx) { + // 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; } - // Content-Aware Move drag or box: the result, once computed in the background. - if let Some(shown) = crate::cam_preview::display_doc(app, idx) { + // 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.