Compare commits

...
Author SHA1 Message Date
Tanish Manku 37c97f3f66 Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856) 2026-10-15 13:18:01 -07:00
Tanish Manku 383371386a Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856) 2026-10-15 13:06:58 -07:00
Tanish Manku 4433ff2bdc Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes 2026-10-15 12:55:02 -07:00
Tanish Manku f211374da8 Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names
With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.

History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).
2026-10-15 12:55:02 -07:00
7 changed files with 319 additions and 74 deletions

No files matched your search

+79 -40
View File
@@ -724,10 +724,32 @@ fn build() -> Vec<CommandSpec> {
"Layer Properties",
[],
None,
r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
has_layer,
|s, p| {
let id = layer_param(s, p)?;
// Several layers at once (the Layers panel with several selected, as in Photoshop),
// else the given or active one.
let ids: Vec<LayerId> = match p.get("layers") {
None | Some(Value::Null) => vec![layer_param(s, p)?],
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut ids = Vec::with_capacity(a.len());
for v in a {
let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?;
if doc.layer(id).is_none() {
return Err(EngineError::NoLayer(id));
}
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))),
};
if ids.len() > 1 && p.get("name").is_some() {
return Err(bad("layer.setProps", "`name` renames one layer, not several"));
}
// Opacity and Fill are fractions here, while the Layer Style commands take
// percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242).
let unit = |key: &str| -> Result<Option<f32>> {
@@ -738,9 +760,19 @@ fn build() -> Vec<CommandSpec> {
}
};
let (opacity, fill) = (unit("opacity")?, unit("fill")?);
let only_visibility =
p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce")));
let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" };
// Photoshop's History names for a single kind of change; a mix is "Layer Properties".
let changed: Vec<&str> = p
.as_object()
.map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect())
.unwrap_or_default();
let label = match changed.as_slice() {
["visible"] => "Layer Visibility",
["opacity"] => "Opacity Change",
["fill"] => "Fill Opacity Change",
["blend"] => "Blending Change",
["name"] => "Rename Layer",
_ => "Layer Properties",
};
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
// Clipping and channel changes reach the layers around it: recomposite everything.
let local = p.get("clipped").is_none() && p.get("channels").is_none();
@@ -749,47 +781,54 @@ fn build() -> Vec<CommandSpec> {
// one that changes its look turns it into a normal layer ("Layer 0") first.
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal);
if num("opacity") || num("fill") || blends {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
l.name = v.to_string();
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
l.visible = v;
}
if let Some(v) = opacity {
l.opacity = v;
}
if let Some(v) = fill {
l.fill_opacity = v;
}
if let Some(v) = p.get("blend").and_then(Value::as_str) {
l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
}
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
l.clipped = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
l.locks.all = v;
}
if let Some(Value::Array(ch)) = p.get("channels") {
// Blending Options › Advanced Blending › Channels: unchecked = left out.
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
}
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
for &id in &ids {
if num("opacity") || num("fill") || blends {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
l.name = v.to_string();
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
l.visible = v;
}
if let Some(v) = opacity {
l.opacity = v;
}
if let Some(v) = fill {
l.fill_opacity = v;
}
if let Some(v) = p.get("blend").and_then(Value::as_str) {
let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
// Pass Through is a group's mode only, as in Photoshop.
if b == photocraft_color::BlendMode::PassThrough && !l.is_group() {
return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0)));
}
l.blend = b;
}
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
l.clipped = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
l.locks.all = v;
}
if let Some(Value::Array(ch)) = p.get("channels") {
// Blending Options › Advanced Blending › Channels: unchecked = left out.
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
}
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
}
}
}
}
Ok(())
})?;
if local {
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
crate::layer_multi_cmds::note_damage(s, &before, &ids);
}
Ok(Value::Null)
}
@@ -0,0 +1,72 @@
//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in
//! Photoshop), and Photoshop's History names for each kind of change.
use photocraft_engine::Session;
use serde_json::json;
fn doc() -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect();
(s, ids)
}
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
}
fn last_step(s: &Session) -> String {
s.active().unwrap().history.entries().last().cloned().unwrap_or_default()
}
#[test]
fn one_change_reaches_every_listed_layer_in_one_step() {
let (mut s, ids) = doc();
let steps = s.active().unwrap().history.entries().len();
s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap();
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step");
assert_eq!(last_step(&s), "Opacity Change");
assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4));
s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap();
assert_eq!(last_step(&s), "Blending Change");
assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply));
s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap();
assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless");
s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap();
assert_eq!(last_step(&s), "Fill Opacity Change");
s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap();
assert_eq!(last_step(&s), "Rename Layer");
s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap();
assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name");
}
#[test]
fn bad_layer_lists_change_nothing() {
let (mut s, ids) = doc();
let before = s.active().unwrap().doc.clone();
for p in [
json!({"layers": [], "opacity": 0.5}),
json!({"layers": "all", "opacity": 0.5}),
json!({"layers": ["x"], "opacity": 0.5}),
json!({"layers": [ids[0], 999_999], "opacity": 0.5}),
json!({"layers": [ids[0], ids[1]], "name": "Both"}),
json!({"layers": [ids[0], ids[1]], "opacity": 2.0}),
] {
assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}");
assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}");
}
}
#[test]
fn pass_through_is_for_groups_only() {
let (mut s, ids) = doc();
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
// A pixel layer, alone or with a group, can't take Pass Through; nothing changes.
assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err());
let before = (layer(&s, ids[0]).blend, layer(&s, g).blend);
assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err());
assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before);
s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap();
s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap();
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
}
+31 -15
View File
@@ -1,5 +1,5 @@
//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer
//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would
//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
//! closing it without a choice, shows the document again, and choosing a mode commits it through
//! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only
//! where the layer's blending can change pixels.
@@ -15,17 +15,17 @@ use crate::PhotocraftApp;
/// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode.
const BASE: u64 = 1 << 37;
/// A blend mode hovered for a layer of a document at a revision.
/// A blend mode hovered for layers of a document at a revision.
pub(crate) struct BlendPreview {
doc: DocId,
revision: u64,
layer: LayerId,
layers: Vec<LayerId>,
/// The mode under the pointer; `None` once it left the list (the rest stays for the
/// canvas's damage on its way back to the document).
mode: Option<BlendMode>,
/// The frame the list was last drawn in: a list no longer drawn previews nothing.
frame: u64,
/// Where the layer's blending can change pixels; `None` for anywhere.
/// Where the layers' blending can change pixels; `None` for anywhere.
area: Option<Rect>,
/// The document shown for `mode`, with its key.
shown: Option<(u64, Arc<Document>)>,
@@ -33,11 +33,11 @@ pub(crate) struct BlendPreview {
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
/// pointer in its open list, if any.
pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendMode>) {
pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option<BlendMode>) {
let frame = app.frame;
let Some(st) = app.session.active() else { return };
let (doc, revision) = (st.doc.id, st.revision);
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) {
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) {
p.frame = frame;
if mode.is_none() {
p.shown = None;
@@ -46,8 +46,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendM
return;
}
app.blend_preview = mode.and_then(|mode| {
let area = photocraft_compose::change_bounds(st.doc.layer(layer)?, st.doc.bounds());
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None })
// Anywhere when any layer can change anything; else the union of their areas.
let mut area: Option<Rect> = Some(Rect::EMPTY);
for &id in layers {
area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) {
(Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }),
_ => None,
};
}
Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None })
});
}
@@ -57,15 +64,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
let st = app.session.documents().get(idx)?;
let p = app.blend_preview.as_mut().filter(|p| p.doc == st.doc.id && p.revision == st.revision && p.frame + 1 >= frame)?;
let mode = p.mode?;
let key = preview_key(p.revision, p.layer, mode);
let key = preview_key(p.revision, &p.layers, mode);
if let Some((k, shown)) = &p.shown
&& *k == key
{
return Some((shown.clone(), key));
}
let mut doc = (*st.doc).clone();
let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?;
l.blend = mode;
let mut changed = false;
for &id in &p.layers {
if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) {
l.blend = mode;
changed = true;
}
}
if !changed {
return None;
}
// Duotone documents display through their inks.
let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
p.shown = Some((key, doc.clone()));
@@ -73,7 +88,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
}
/// What changed between preview (or document) key `seen` and `now` of document `doc` at
/// `revision`: the hovered layer's area. `None` when either key is another kind of preview, or
/// `revision`: the hovered layers' area. `None` when either key is another kind of preview, or
/// the layer can change anything (the canvas then recomposites everything).
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
@@ -85,8 +100,9 @@ fn is_preview_key(key: u64) -> bool {
(BASE..BASE << 1).contains(&key)
}
fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 {
let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 {
let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0);
let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
(h & (BASE - 1)) | BASE
}
+19 -5
View File
@@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
let (s, top) = session();
let mut app = PhotocraftApp::new(s, crate::Services::default());
let (undo, revision) = (steps(&app), app.session.active().unwrap().revision);
hover(&mut app, top, Some(BlendMode::Multiply));
hover(&mut app, &[top], Some(BlendMode::Multiply));
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply);
let (again, same) = display_doc(&mut app, 0).unwrap();
@@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
// From the document to the preview and between previews: only the layer's area redraws.
let doc = app.session.active().unwrap().doc.id;
assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16));
hover(&mut app, top, Some(BlendMode::Screen));
hover(&mut app, &[top], Some(BlendMode::Screen));
let (_, screen) = display_doc(&mut app, 0).unwrap();
assert_ne!(screen, key);
assert!(damage(&app, doc, revision, key, screen).is_some());
// The current mode, or no mode, shows the document; and so does a list no longer drawn.
hover(&mut app, top, Some(BlendMode::Normal));
hover(&mut app, &[top], Some(BlendMode::Normal));
assert!(display_doc(&mut app, 0).is_none());
hover(&mut app, top, None);
hover(&mut app, &[top], None);
assert!(display_doc(&mut app, 0).is_none());
assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area");
hover(&mut app, top, Some(BlendMode::Multiply));
hover(&mut app, &[top], Some(BlendMode::Multiply));
app.frame += 2;
assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing");
}
@@ -124,3 +124,17 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() {
}
assert_eq!(previewed(&mut h), Some(BlendMode::Multiply));
}
#[test]
fn a_hovered_mode_previews_on_every_selected_layer() {
let (mut s, top) = session();
let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap();
let mut app = PhotocraftApp::new(s, crate::Services::default());
hover(&mut app, &[top, other], Some(BlendMode::Multiply));
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply));
// A different set of layers is a different preview.
hover(&mut app, &[top], Some(BlendMode::Multiply));
assert_ne!(display_doc(&mut app, 0).unwrap().1, key);
}
@@ -0,0 +1,78 @@
//! The Layers panel's blend mode, Opacity, Fill and lock controls change every selected layer,
//! as in Photoshop, not only the active one.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::PhotocraftApp;
fn app() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app
});
h.run_steps(6);
h
}
/// The Layers panel's lock buttons change every selected layer (as do blend mode, Opacity and
/// Fill), not only the active one.
#[test]
fn the_layers_panel_locks_every_selected_layer() {
let mut h = app();
let ids: Vec<u64> = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect();
let extra = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().run("layer.select", json!({"layer": ids[1], "mode": "add"})).unwrap();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2);
h.query_by_label_contains("Lock all").expect("the Lock all button").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all;
assert!(locked(extra) && locked(ids[1]), "both selected layers lock");
assert!(!locked(ids[0]), "the unselected Background doesn't");
}
/// The Basic layout's single lock toggle also locks every selected layer.
#[test]
fn the_basic_lock_toggle_locks_every_selected_layer() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::Studio);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("prefs.set", json!({"path": "interface.theme", "value": crate::theme::ThemeKind::Studio.id()})).unwrap();
app.ui.theme = crate::theme::ThemeKind::Studio;
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
app
});
let a = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().run("layer.select", json!({"layer": a, "mode": "add"})).unwrap();
h.run_steps(4);
h.get_by_label("Lock layer").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all;
assert!(locked(a) && locked(b), "both selected layers lock");
}
/// The Background, selected with other layers, is left out of their change (Opacity here)
/// until Photoshop's behaviour is confirmed (#5856): it stays the Background.
#[test]
fn a_selected_background_is_left_out_of_a_several_layer_change() {
let mut h = app();
let st = h.state().session.active().unwrap();
let (bg, top) = (st.doc.layers[0].id.0, st.active_layer.unwrap().0);
h.state_mut().run("layer.select", json!({"layer": bg})).unwrap();
h.state_mut().run("layer.select", json!({"layer": top, "mode": "add"})).unwrap();
h.run_steps(3);
h.get_by_label("Lock all").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let l = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone();
assert!(l(top).locks.all, "the layer locks");
assert!(st.doc.is_background(photocraft_doc::LayerId(bg)) && !l(bg).locks.all, "the Background is left as it was");
}
+2
View File
@@ -2638,6 +2638,8 @@ mod pixel_aspect_tests;
#[cfg(test)]
mod transform_fx_tests;
#[cfg(test)]
mod layer_props_ui_tests;
#[cfg(test)]
mod transform_undo_tests;
+38 -14
View File
@@ -1518,8 +1518,9 @@ const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W;
/// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A
/// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop.
fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
let mut p = json!({"layer": l.id.0, key: pct / 100.0});
fn pct_action(target: &Value, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
let mut p = target.clone();
p[key] = json!(pct / 100.0);
if let Some(n) = drag {
p["coalesce"] = json!(format!("layer-{key}-slider:{n}"));
}
@@ -2159,13 +2160,17 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's
/// transparency lock (still showing as locked), and a second click would lock its pixels,
/// shutting out the Eraser and every other paint tool (#76).
fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) {
/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it
/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`).
fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) {
if background {
return ("layer.new.layerFromBackground".into(), json!({}));
}
let locks =
if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) };
("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks}))
let mut p = targets.clone();
p["locks"] = locks;
("layer.setProps".into(), p)
}
/// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one
@@ -2231,8 +2236,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(4.0);
}
if let Some(l) = active_layer {
// Photoshop greys blend mode, Opacity and Fill for the Background layer.
// With several layers selected, blend mode, Opacity, Fill and the locks change every one
// of them, as in Photoshop; the controls show the active layer's values.
// The Background stays out of a several-layer change until Photoshop's behaviour is
// confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0.
let ids: Vec<LayerId> = if selection.len() > 1 && selection.contains(&l.id) {
selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect()
} else {
vec![l.id]
};
let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::<Vec<_>>()}) } else { json!({"layer": l.id.0}) };
// Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the
// Background among several selected, what Photoshop does awaits a check: #5856.)
let bg = crate::doc_props_ui::is_background(&doc, l);
// Pass Through is a group's mode: offered only when every target is a group.
let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group()));
ui.horizontal(|ui| {
ui.add_enabled_ui(!bg, |ui| {
let mut m = l.blend;
@@ -2242,13 +2260,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") };
let (blend_width, show_opacity_label) =
opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width);
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width);
// One step per choice: a click, an arrow key or each wheel notch (#1747).
for m in &chosen {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()})));
let mut p = targets.clone();
p["blend"] = json!(m.label());
actions.push(("layer.setProps".into(), p));
}
// Hovering a mode previews it on the canvas (#970).
crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty()));
crate::blend_preview::hover(app, &ids, hovered.filter(|_| chosen.is_empty()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut o = l.opacity * 100.0;
let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
@@ -2259,7 +2279,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::PopupFieldResponse::default()
};
if field.changed || scrub.changed {
actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag)));
actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag)));
}
});
});
@@ -2295,7 +2315,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892).
let enabled = key != "transparency" || !lk.pixels;
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }})));
let mut p = targets.clone();
p["locks"] = json!({ key: !on });
actions.push(("layer.setProps".into(), p));
}
}
});
@@ -2303,7 +2325,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if !t.pro {
let locked = l.locks.any();
if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() {
actions.push(simple_lock_toggle(bg, l));
actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets));
}
}
ui.add_enabled_ui(!bg, |ui| {
@@ -2312,7 +2334,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
if field.changed || scrub.changed {
actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag)));
actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag)));
}
})
});
@@ -2877,7 +2899,7 @@ fn layer_row(
if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock)
&& ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked()
{
actions.push(simple_lock_toggle(doc.is_background(l.id), l));
actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0})));
}
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
@@ -3139,6 +3161,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let icon = match e.as_str() {
s if s.contains("Brush") || s.contains("Eraser") => "brush",
// The Layers panel's controls (layer.setProps).
"Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers",
s if s.contains("Layer") => "layers",
s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed",
s if s.contains("Fill") => "paint-bucket",
@@ -4992,7 +5016,7 @@ mod lock_tests {
fn click_lock(app: &mut PhotocraftApp) {
let st = app.session.active().unwrap();
let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap();
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l);
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0}));
app.run(&cmd, p).unwrap();
}