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