mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 21:54:05 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c97f3f66 | ||
|
|
383371386a | ||
|
|
4433ff2bdc | ||
|
|
f211374da8 |
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user