mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 13:16:54 -04:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c36e3ebe | ||
|
|
58344dcf07 | ||
|
|
876506903f | ||
|
|
2a3b8451ea | ||
|
|
c50a9b4213 | ||
|
|
2c85b057b2 | ||
|
|
05bfeaac95 | ||
|
|
e69b858052 | ||
|
|
526066c681 | ||
|
|
81d5a74108 | ||
|
|
acf41d154e | ||
|
|
e84e68c19c | ||
|
|
353394dc0b | ||
|
|
62ed8532ef | ||
|
|
f7f214ec73 | ||
|
|
1505209547 | ||
|
|
1fd9e1fa45 | ||
|
|
ee1e7b8c3c | ||
|
|
a1dc20eb90 | ||
|
|
47c7319f20 | ||
|
|
702536f6a6 | ||
|
|
5508eda9bd | ||
|
|
e1c9e8c451 | ||
|
|
ecb55d6531 | ||
|
|
d7758d3412 | ||
|
|
fd4403ded9 | ||
|
|
844f1fc21a | ||
|
|
fa25d217fb | ||
|
|
9956e272da | ||
|
|
4148a1740d | ||
|
|
f2a8065911 | ||
|
|
bd82dc508a | ||
|
|
2a1e028f2d | ||
|
|
4e563bad08 | ||
|
|
1afe4c781b | ||
|
|
9e0bd3ed7d | ||
|
|
420de8f265 | ||
|
|
5dc08a6538 | ||
|
|
953c0d2e8c | ||
|
|
ee18c02534 | ||
|
|
4c4f21e41c | ||
|
|
18ddace84e | ||
|
|
3a34a49d18 | ||
|
|
bd49334e9f |
No files matched your search
@@ -124,6 +124,7 @@ pub fn paint_gradient(
|
||||
blend_mode: BlendMode,
|
||||
dither: bool,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
) {
|
||||
composite_area(
|
||||
s,
|
||||
@@ -142,7 +143,7 @@ pub fn paint_gradient(
|
||||
}
|
||||
c
|
||||
},
|
||||
false,
|
||||
lock_transparency,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,8 +160,9 @@ pub fn bucket_fill(
|
||||
color: [f32; 4],
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
) -> bool {
|
||||
bucket_fill_src(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, |_, _| color)
|
||||
bucket_fill_src(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, |_, _| color)
|
||||
}
|
||||
|
||||
/// Like [`bucket_fill`] but the fill colour comes from `src(x, y)` — e.g. a pattern sampled at the
|
||||
@@ -175,6 +177,7 @@ pub fn bucket_fill_src(
|
||||
anti_alias: bool,
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) -> bool {
|
||||
if !area.contains(seed.0, seed.1) {
|
||||
@@ -185,7 +188,7 @@ pub fn bucket_fill_src(
|
||||
drop(img);
|
||||
// Composite only over the filled region's box.
|
||||
let b = region.bbox;
|
||||
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, false);
|
||||
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -223,6 +226,7 @@ mod tests {
|
||||
BlendMode::Normal,
|
||||
false,
|
||||
Some(&sel),
|
||||
false,
|
||||
);
|
||||
assert!(s.pixel(0, 0)[0] < 0.01 && s.pixel(10, 0)[0] > 0.99);
|
||||
assert!((s.pixel(5, 0)[0] - 0.5).abs() < 0.01);
|
||||
@@ -235,9 +239,9 @@ mod tests {
|
||||
let a = Rect::new(0, 0, 10, 10);
|
||||
s.fill_rect(a, &[1.0, 1.0, 1.0, 1.0]);
|
||||
s.fill_rect(Rect::new(5, 0, 6, 10), &[0.0, 0.0, 0.0, 1.0]);
|
||||
assert!(bucket_fill(&mut s, a, (1, 1), 10.0, true, false, [1.0, 0.0, 0.0, 1.0], 1.0, None));
|
||||
assert!(bucket_fill(&mut s, a, (1, 1), 10.0, true, false, [1.0, 0.0, 0.0, 1.0], 1.0, None, false));
|
||||
assert_eq!(s.pixel(2, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(s.pixel(8, 2), vec![1.0, 1.0, 1.0, 1.0]);
|
||||
assert!(!bucket_fill(&mut s, a, (50, 1), 10.0, true, false, [1.0, 0.0, 0.0, 1.0], 1.0, None));
|
||||
assert!(!bucket_fill(&mut s, a, (50, 1), 10.0, true, false, [1.0, 0.0, 0.0, 1.0], 1.0, None, false));
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,97 @@ pub enum LabelColor {
|
||||
Blue,
|
||||
Violet,
|
||||
Gray,
|
||||
Seafoam,
|
||||
Indigo,
|
||||
Magenta,
|
||||
Fuchsia,
|
||||
}
|
||||
|
||||
impl LabelColor {
|
||||
/// Display order, independent of the PSD sheet-colour indices.
|
||||
pub const ALL: [Self; 12] = [
|
||||
Self::None,
|
||||
Self::Red,
|
||||
Self::Orange,
|
||||
Self::Yellow,
|
||||
Self::Green,
|
||||
Self::Seafoam,
|
||||
Self::Blue,
|
||||
Self::Indigo,
|
||||
Self::Magenta,
|
||||
Self::Fuchsia,
|
||||
Self::Violet,
|
||||
Self::Gray,
|
||||
];
|
||||
|
||||
/// Stable, language-independent command and inspection value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Red => "red",
|
||||
Self::Orange => "orange",
|
||||
Self::Yellow => "yellow",
|
||||
Self::Green => "green",
|
||||
Self::Seafoam => "seafoam",
|
||||
Self::Blue => "blue",
|
||||
Self::Indigo => "indigo",
|
||||
Self::Magenta => "magenta",
|
||||
Self::Fuchsia => "fuchsia",
|
||||
Self::Violet => "violet",
|
||||
Self::Gray => "gray",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "No Color",
|
||||
Self::Red => "Red",
|
||||
Self::Orange => "Orange",
|
||||
Self::Yellow => "Yellow",
|
||||
Self::Green => "Green",
|
||||
Self::Seafoam => "Seafoam",
|
||||
Self::Blue => "Blue",
|
||||
Self::Indigo => "Indigo",
|
||||
Self::Magenta => "Magenta",
|
||||
Self::Fuchsia => "Fuchsia",
|
||||
Self::Violet => "Violet",
|
||||
Self::Gray => "Gray",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod label_color_tests {
|
||||
use super::LabelColor;
|
||||
|
||||
#[test]
|
||||
fn layer_color_ids_and_existing_serialized_names_stay_stable() {
|
||||
for (name, color) in [
|
||||
("None", LabelColor::None),
|
||||
("Red", LabelColor::Red),
|
||||
("Orange", LabelColor::Orange),
|
||||
("Yellow", LabelColor::Yellow),
|
||||
("Green", LabelColor::Green),
|
||||
("Blue", LabelColor::Blue),
|
||||
("Violet", LabelColor::Violet),
|
||||
("Gray", LabelColor::Gray),
|
||||
] {
|
||||
assert_eq!(serde_json::to_value(color).unwrap(), name);
|
||||
assert_eq!(serde_json::from_value::<LabelColor>(serde_json::json!(name)).unwrap(), color);
|
||||
}
|
||||
for c in LabelColor::ALL {
|
||||
assert_eq!(LabelColor::from_id(c.id()), Some(c));
|
||||
assert_eq!(serde_json::from_value::<LabelColor>(serde_json::to_value(c).unwrap()).unwrap(), c);
|
||||
}
|
||||
for id in ["", "Red", "0", "unknown", "🔴"] {
|
||||
assert_eq!(LabelColor::from_id(id), None);
|
||||
}
|
||||
assert!(serde_json::from_value::<LabelColor>(serde_json::json!("Unknown")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -63,11 +63,10 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// Surface a paint command writes to: the layer's pixels, or its mask with `"target":"mask"`.
|
||||
pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -> Result<&'a mut photocraft_raster::Surface> {
|
||||
let locks = doc.effective_locks(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if !is_mask_target(p) && (locks.pixels || locks.all) {
|
||||
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
|
||||
if !is_mask_target(p) {
|
||||
check_pixels_unlocked(doc, id)?;
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if is_mask_target(p) {
|
||||
l.mask.as_mut().map(|m| &mut m.surface).ok_or_else(|| EngineError::Other("layer has no mask".into()))
|
||||
} else {
|
||||
@@ -75,6 +74,16 @@ pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a layer whose pixels are locked (directly or through a group), with Photoshop's message.
|
||||
pub(crate) fn check_pixels_unlocked(doc: &Document, id: LayerId) -> Result<()> {
|
||||
let locks = doc.effective_locks(id);
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if locks.pixels || locks.all {
|
||||
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_mask_target(p: &Value) -> bool {
|
||||
p.get("target").and_then(Value::as_str) == Some("mask")
|
||||
}
|
||||
@@ -226,8 +235,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
|
||||
s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let mode = doc.mode;
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?;
|
||||
let surf = paint_surface(doc, id, &Value::Null)?;
|
||||
pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -482,6 +490,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
let id = layer_param(s, p)?;
|
||||
s.edit("Delete Layer", |doc, active| {
|
||||
doc.remove(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if doc.layers.is_empty() {
|
||||
return Err(EngineError::Other("a document must keep at least one layer".into()));
|
||||
}
|
||||
if *active == Some(id) {
|
||||
*active = doc.top_layer();
|
||||
}
|
||||
@@ -706,45 +717,15 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("layer.moveTo", "Reorder Layer", [], None, r##"{"layer":id?,"target":id,"position":"above|below|into"="above"}"##, has_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad("layer.moveTo", "missing `target`"))?);
|
||||
let pos = p.get("position").and_then(Value::as_str).unwrap_or("above").to_string();
|
||||
if id == target {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.edit("Reorder Layers", |doc, active| {
|
||||
// Refuse to move a group into itself or its descendants.
|
||||
if doc.layer(id).is_some_and(|l| contains_layer(l, target)) {
|
||||
return Err(EngineError::Other("can't move a group into itself".into()));
|
||||
}
|
||||
let layer = doc.remove(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
|
||||
match pos.as_str() {
|
||||
"into" => {
|
||||
let g = doc.layer_at_mut(&path).and_then(|t| t.children_mut()).ok_or_else(|| EngineError::Other("target is not a group".into()))?;
|
||||
g.push(layer);
|
||||
}
|
||||
other => {
|
||||
let (&last, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
|
||||
let sib = if parent.is_empty() {
|
||||
&mut doc.layers
|
||||
} else {
|
||||
doc.layer_at_mut(parent)
|
||||
.and_then(|t| t.children_mut())
|
||||
.ok_or_else(|| EngineError::Other("target's parent is not a group".into()))?
|
||||
};
|
||||
let at = if other == "below" { last } else { last + 1 };
|
||||
sib.insert(at.min(sib.len()), layer);
|
||||
}
|
||||
}
|
||||
// Moving a group into (or beside) a deeply nested layer can pass the nesting cap.
|
||||
crate::layer_multi_cmds::check_group_depth(doc, "Reorder Layer")?;
|
||||
*active = Some(id);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!(
|
||||
"layer.moveTo",
|
||||
"Reorder Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?| "layers":[id,…]?, "target":id,"position":"above|below|into"="above"} (layers: one undoable move, document order preserved)"##,
|
||||
has_layer,
|
||||
crate::layer_multi_cmds::move_to
|
||||
),
|
||||
cmd!(
|
||||
"layer.translate",
|
||||
"Move Layer",
|
||||
@@ -1052,6 +1033,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::channel_cmds::specs());
|
||||
v.extend(crate::adjust_cmds::specs());
|
||||
v.extend(crate::layer_menu_cmds::specs());
|
||||
v.extend(crate::layer_label_cmds::specs());
|
||||
v.extend(crate::mode_cmds::specs());
|
||||
v.extend(crate::multichannel_cmds::specs());
|
||||
v.extend(crate::pattern_cmds::specs());
|
||||
@@ -1195,8 +1177,3 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `l` (or any descendant) have id `target`?
|
||||
fn contains_layer(l: &Layer, target: LayerId) -> bool {
|
||||
l.id == target || l.children().is_some_and(|c| c.iter().any(|c| contains_layer(c, target)))
|
||||
}
|
||||
@@ -138,9 +138,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
|
||||
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
|
||||
/// transparency, so there the area is filled with the background colour instead (Photoshop).
|
||||
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let bg = crate::extra_cmds::is_background(l);
|
||||
let surf = l.surface_mut().ok_or(EngineError::Other("the active layer has no pixels".into()))?;
|
||||
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
if bg {
|
||||
crate::pixels::fill_surface(surf, area, background, sel, true);
|
||||
} else {
|
||||
@@ -412,7 +411,7 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let mode = doc.mode;
|
||||
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or(EngineError::NoLayer(id))?;
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -537,8 +536,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
vec![
|
||||
spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{}", has_pixels, |s, _| {
|
||||
let r = copy(s, false)?;
|
||||
// Refuse a locked layer before copying, so a refused Cut leaves the clipboard alone.
|
||||
let id = active_id(s)?;
|
||||
crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
|
||||
let r = copy(s, false)?;
|
||||
let bg = s.tools.background;
|
||||
s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?;
|
||||
Ok(r)
|
||||
|
||||
@@ -232,7 +232,11 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
*surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?;
|
||||
return Ok(fp.clone());
|
||||
}
|
||||
let locks = doc.effective_locks(layer);
|
||||
let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?;
|
||||
if locks.pixels || locks.all {
|
||||
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
let mut fp = fp.clone();
|
||||
crate::filters_ext::resolve_in_layer(&mut fp, l, bounds);
|
||||
let surf = match &mut l.content {
|
||||
@@ -245,15 +249,41 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
}
|
||||
_ => return Err(EngineError::Other("not a pixel layer".into())),
|
||||
};
|
||||
let before = locks.transparency.then(|| surf.clone());
|
||||
let content = surf.content_bounds();
|
||||
let area = algo::output_area(&fp, content, bounds, sel_bounds);
|
||||
*surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?;
|
||||
if let Some(before) = &before {
|
||||
keep_alpha(before, surf);
|
||||
}
|
||||
Ok(fp)
|
||||
},
|
||||
move |fp| json!({ "layer": layer_id, "filter": serde_json::to_value(&fp).unwrap_or(Value::Null) }),
|
||||
)
|
||||
}
|
||||
|
||||
/// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed.
|
||||
fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) {
|
||||
if old.format() != new.format() || !new.format().alpha {
|
||||
return;
|
||||
}
|
||||
let mut coords: Vec<_> = new.tiles().filter(|(c, t)| old.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))).map(|(c, _)| *c).collect();
|
||||
coords.extend(old.tiles().filter(|(c, _)| new.tile(**c).is_none()).map(|(c, _)| *c));
|
||||
let n = new.channels();
|
||||
for c in coords {
|
||||
let r = c.rect();
|
||||
let o = old.read_region(r);
|
||||
let mut v = new.read_region(r);
|
||||
for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) {
|
||||
if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) {
|
||||
*a = *b;
|
||||
}
|
||||
}
|
||||
new.write_region(r, &v);
|
||||
}
|
||||
new.prune();
|
||||
}
|
||||
|
||||
macro_rules! filter_cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
|
||||
CommandSpec {
|
||||
|
||||
@@ -74,6 +74,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
|
||||
"name": l.name,
|
||||
"kind": l.content.kind_name(),
|
||||
"visible": l.visible,
|
||||
"labelColor": l.label.id(),
|
||||
"opacity": l.opacity,
|
||||
"fill": l.fill_opacity,
|
||||
"blend": l.blend.label(),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Undoable layer colour labels. A group applies the label to its existing descendants,
|
||||
//! without changing pixels, visibility, locks or the layer selection.
|
||||
|
||||
use photocraft_doc::{LabelColor, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ID: &str = "layer.setLabelColor";
|
||||
|
||||
fn bad(message: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: ID.into(), msg: message.into() }
|
||||
}
|
||||
|
||||
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selected_layers().is_empty() { Err("no layer selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn set_label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = p.as_object().ok_or_else(|| bad("expected an object"))?;
|
||||
let color =
|
||||
p.get("color").and_then(Value::as_str).and_then(LabelColor::from_id).ok_or_else(|| {
|
||||
bad("`color` must be a layer colour id (none, red, orange, yellow, green, seafoam, blue, indigo, magenta, fuchsia, violet, gray)")
|
||||
})?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let roots = match p.get("layer") {
|
||||
Some(v) => vec![LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be an unsigned integer"))?)],
|
||||
None => st.selected_layers(),
|
||||
};
|
||||
if roots.is_empty() {
|
||||
return Err(bad("no layer selected"));
|
||||
}
|
||||
let walk = st.doc.walk();
|
||||
let mut paths = Vec::with_capacity(roots.len());
|
||||
for id in roots {
|
||||
let (path, _, _) = walk.iter().find(|(_, _, l)| l.id == id).ok_or(EngineError::NoLayer(id))?;
|
||||
paths.push(path);
|
||||
}
|
||||
// A single walk also deduplicates a selected child whose parent is selected.
|
||||
let mut changed = false;
|
||||
let targets: Vec<_> = walk
|
||||
.iter()
|
||||
.filter(|(path, _, _)| paths.iter().any(|root| path.starts_with(root)))
|
||||
.map(|(path, _, l)| {
|
||||
changed |= l.label != color;
|
||||
(path.clone(), l.id)
|
||||
})
|
||||
.collect();
|
||||
if changed {
|
||||
s.edit("Layer Color", |doc, _| {
|
||||
for (path, id) in &targets {
|
||||
doc.layer_at_mut(path).ok_or(EngineError::NoLayer(*id))?.label = color;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
// This is saved document metadata; no canvas pixels need recompositing.
|
||||
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
|
||||
}
|
||||
}
|
||||
Ok(json!({"color": color.id(), "layers": targets.iter().map(|(_, id)| id.0).collect::<Vec<_>>(), "changed": changed}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: ID,
|
||||
label: "Layer Color",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray","layer":id?} (no layer: selected layers; groups include descendants)"#,
|
||||
enabled,
|
||||
run: set_label,
|
||||
journal: true,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
|
||||
fn fixture(depth: u8) -> (Session, [LayerId; 4]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "depth": depth})).unwrap();
|
||||
let ids = s
|
||||
.edit("Fixture", |doc, _| {
|
||||
let mut leaf = Layer::raster("Hidden, locked", doc.pixel_format());
|
||||
leaf.visible = false;
|
||||
leaf.locks.all = true;
|
||||
leaf.label = LabelColor::Blue;
|
||||
let leaf_id = leaf.id;
|
||||
let mut inner = Layer::group("Inner", vec![leaf]);
|
||||
if let LayerContent::Group(g) = &mut inner.content {
|
||||
g.expanded = false;
|
||||
}
|
||||
let inner_id = inner.id;
|
||||
let outer = Layer::group("Outer", vec![inner]);
|
||||
let outer_id = outer.id;
|
||||
let other = Layer::raster("Other", doc.pixel_format());
|
||||
let other_id = other.id;
|
||||
doc.layers = vec![outer, other];
|
||||
Ok([outer_id, inner_id, leaf_id, other_id])
|
||||
})
|
||||
.unwrap();
|
||||
s.select_layer(ids[0]).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_groups_and_overlapping_selection_are_one_undo_step() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, [outer, inner, leaf, other]) = fixture(depth);
|
||||
crate::layer_multi_cmds::set_selection(&mut s, vec![outer, leaf], Some(leaf), Some(outer)).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let selected = s.active().unwrap().selected_layers();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let r = s.execute(ID, json!({"color": "seafoam"})).unwrap();
|
||||
assert_eq!(r["layers"], json!([outer.0, inner.0, leaf.0]));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1);
|
||||
assert_eq!(s.active().unwrap().selected_layers(), selected);
|
||||
assert_eq!(s.active().unwrap().last_damage, Some(photocraft_geom::Rect::EMPTY));
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
for id in [outer, inner, leaf] {
|
||||
assert_eq!(after.layer(id).unwrap().label, LabelColor::Seafoam);
|
||||
}
|
||||
assert_eq!(after.layer(other), before.layer(other));
|
||||
let mut expected = (*before).clone();
|
||||
for id in [outer, inner, leaf] {
|
||||
expected.layer_mut(id).unwrap().label = LabelColor::Seafoam;
|
||||
}
|
||||
assert_eq!(*after, expected, "only labels change at depth {depth}");
|
||||
assert_eq!(crate::inspect::layer(after.layer(leaf).unwrap())["labelColor"], "seafoam");
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let revision = s.active().unwrap().revision;
|
||||
assert_eq!(s.execute(ID, json!({"color": "seafoam"})).unwrap()["changed"], false);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_every_color_and_reset_target_only_the_requested_layer() {
|
||||
let (mut s, [outer, _, leaf, other]) = fixture(8);
|
||||
for color in LabelColor::ALL {
|
||||
s.execute(ID, json!({"color": color.id(), "layer": other.0})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!(d.layer(other).unwrap().label, color);
|
||||
assert_eq!(d.layer(outer).unwrap().label, LabelColor::None);
|
||||
assert_eq!(d.layer(leaf).unwrap().label, LabelColor::Blue);
|
||||
}
|
||||
s.execute(ID, json!({"color": "none", "layer": outer.0})).unwrap();
|
||||
assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, l)| l.label == LabelColor::None || l.id == other));
|
||||
s.execute(ID, json!({"color": "green", "layer": outer.0})).unwrap();
|
||||
s.select_layer(leaf).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Created later"})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().label, LabelColor::None);
|
||||
assert!(matches!(st.doc.layer(outer).unwrap().content, LayerContent::Group(_)));
|
||||
assert_eq!(st.doc.depth, SampleType::U8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_invalid_params_never_change_the_document_or_history() {
|
||||
let (mut s, _) = fixture(8);
|
||||
for params in [
|
||||
Value::Null,
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!({"color": "unknown"}),
|
||||
json!({"color": 1}),
|
||||
json!({"color": "red", "layer": "1"}),
|
||||
json!({"color": "red", "layer": -1}),
|
||||
json!({"color": "red", "layer": 1.5}),
|
||||
json!({"color": "red", "layer": null}),
|
||||
json!({"color": "red", "layer": u64::MAX}),
|
||||
] {
|
||||
let st = s.active().unwrap();
|
||||
let before = (st.doc.clone(), st.revision, st.history.past_len());
|
||||
assert!(s.execute(ID, params).is_err());
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!((st.doc.clone(), st.revision, st.history.past_len()), before);
|
||||
}
|
||||
let mut empty = Session::new();
|
||||
assert!(!empty.is_enabled(ID));
|
||||
assert!(empty.execute(ID, json!({"color": "red"})).is_err());
|
||||
empty.execute("file.new", json!({"width": 2, "height": 2})).unwrap();
|
||||
empty
|
||||
.edit("Empty", |doc, active| {
|
||||
doc.layers.clear();
|
||||
*active = None;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!empty.is_enabled(ID));
|
||||
assert!(empty.execute(ID, json!({"color": "red"})).is_err());
|
||||
}
|
||||
@@ -523,6 +523,99 @@ fn distribute(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
Ok(json!({"moved": moved}))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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> {
|
||||
const CMD: &str = "layer.moveTo";
|
||||
let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "missing `target`"))?);
|
||||
let pos = p.get("position").and_then(Value::as_str).unwrap_or("above");
|
||||
if !matches!(pos, "above" | "below" | "into") {
|
||||
return Err(bad(CMD, "position must be above, below, or into"));
|
||||
}
|
||||
|
||||
let batch = p.get("layers");
|
||||
if batch.is_some() && p.get("layer").is_some() {
|
||||
return Err(bad(CMD, "give either `layer` or `layers`, not both"));
|
||||
}
|
||||
let requested = if let Some(raw) = batch {
|
||||
let arr = raw.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(CMD, "`layers` must be a nonempty array of ids"))?;
|
||||
let mut ids = Vec::with_capacity(arr.len());
|
||||
for item in arr {
|
||||
let id = LayerId(item.as_u64().ok_or_else(|| bad(CMD, "every layer id must be an unsigned integer"))?);
|
||||
if ids.contains(&id) {
|
||||
return Err(bad(CMD, "duplicate layer id"));
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
ids
|
||||
} else {
|
||||
vec![crate::commands::layer_param(s, p)?]
|
||||
};
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = &st.doc;
|
||||
let target_path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
|
||||
for &id in &requested {
|
||||
if doc.layer(id).is_none() {
|
||||
return Err(EngineError::NoLayer(id));
|
||||
}
|
||||
}
|
||||
let moved_ids = top_level(doc, &requested);
|
||||
if moved_ids.len() == 1 && moved_ids[0] == target && batch.is_none() {
|
||||
// Preserve single-layer drag-to-self semantics.
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Also rejects dragging a selected group onto any of its descendants.
|
||||
for id in &moved_ids {
|
||||
let path = doc.path_of(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
if target_path.starts_with(&path) {
|
||||
return Err(bad(CMD, "target cannot be one of the moved layers or its descendant"));
|
||||
}
|
||||
}
|
||||
if pos == "into" && doc.layer(target).is_none_or(|l| !l.is_group()) {
|
||||
return Err(bad(CMD, "target is not a group"));
|
||||
}
|
||||
|
||||
let original_active = st.active_layer;
|
||||
let count = s.edit("Reorder Layers", |doc, active| {
|
||||
let mut moved = Vec::with_capacity(moved_ids.len());
|
||||
for &id in &moved_ids {
|
||||
moved.push(doc.remove(id).ok_or(EngineError::NoLayer(id))?);
|
||||
}
|
||||
// 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")?;
|
||||
*active = if batch.is_some() {
|
||||
original_active.filter(|id| requested.contains(id)).or_else(|| requested.last().copied())
|
||||
} else {
|
||||
moved_ids.first().copied()
|
||||
};
|
||||
Ok(moved_ids.len())
|
||||
})?;
|
||||
if batch.is_some() {
|
||||
let active = original_active.filter(|id| requested.contains(id)).or_else(|| requested.last().copied());
|
||||
reselect(s, requested, active);
|
||||
Ok(json!({"moved": count}))
|
||||
} else {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- structural commands ----------
|
||||
|
||||
fn link_layers(s: &mut Session) -> Result<Value> {
|
||||
|
||||
@@ -46,6 +46,7 @@ pub mod image_cmds;
|
||||
pub mod inspect;
|
||||
pub mod jobs;
|
||||
pub mod layer_copy_cmds;
|
||||
pub mod layer_label_cmds;
|
||||
pub mod layer_menu_cmds;
|
||||
pub mod layer_multi_cmds;
|
||||
pub mod layer_nav_cmds;
|
||||
@@ -386,7 +387,15 @@ impl Session {
|
||||
self.cancel_jobs_on(id);
|
||||
}
|
||||
let d = self.docs.remove(index);
|
||||
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
|
||||
self.active = if self.docs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(match self.active {
|
||||
Some(active) if active > index => active - 1,
|
||||
Some(active) if active < index => active,
|
||||
_ => index.min(self.docs.len() - 1),
|
||||
})
|
||||
};
|
||||
Some(d)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,17 +54,17 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filled = s.edit("Paint Bucket", |doc, active| {
|
||||
let area = doc.bounds();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, p)?;
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, *active, p)?;
|
||||
let ok = if let Some((tile, scale, angle, phase)) = &pattern {
|
||||
// Render the pattern over the canvas once, then sample it at each filled pixel.
|
||||
let place = photocraft_compose::pattern::Placement::new(photocraft_geom::Rect::EMPTY, false, *phase, *scale, *angle);
|
||||
let rendered = photocraft_compose::pattern::render(tile, &place, area);
|
||||
let w = area.width() as usize;
|
||||
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), |px, py| {
|
||||
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |px, py| {
|
||||
rendered[(py - area.y0) as usize * w + (px - area.x0) as usize]
|
||||
})
|
||||
} else {
|
||||
bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref())
|
||||
bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock)
|
||||
};
|
||||
surf.prune();
|
||||
Ok(ok)
|
||||
@@ -102,8 +102,8 @@ fn gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Gradient", |doc, active| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).filter(|r| !r.is_empty()).unwrap_or_else(|| doc.bounds()).intersect(&doc.bounds());
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, p)?;
|
||||
paint_gradient(surf, area, from, to, shape, &stops, reverse, opacity, blend, dither, sel.as_ref());
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, *active, p)?;
|
||||
paint_gradient(surf, area, from, to, shape, &stops, reverse, opacity, blend, dither, sel.as_ref(), lock);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -104,8 +104,7 @@ fn draw(s: &mut Session, label: &str, p: &Value, prims: Vec<Prim>, extra: Value)
|
||||
if let Some(sel) = &selection {
|
||||
clip = clip.intersect(&sel.content_bounds());
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Raster(surf) = &mut l.content else { return Err(EngineError::Other("not a pixel layer".into())) };
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
let used = render2::composite(surf, &prims, clip, selection.as_ref());
|
||||
surf.prune();
|
||||
Ok((id, created, used))
|
||||
|
||||
@@ -634,6 +634,90 @@ fn move_to_reorders_and_nests() {
|
||||
assert_eq!(names(&s), ["Background", "B", "A", "G"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_selected_layers_into_existing_group_preserves_order_selection_and_one_undo() {
|
||||
let mut s = session_with_doc();
|
||||
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.new.layer", json!({"name": "C"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let g = s.execute("layer.new.group", json!({"name": "Existing Group"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layer": a})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": c, "mode": "add"})).unwrap();
|
||||
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
// Pass reversed IDs deliberately. The document order (A below C) must win.
|
||||
let r = s.execute("layer.moveTo", json!({"layers": [c, a], "target": g, "position": "into"})).unwrap();
|
||||
assert_eq!(r["moved"], 2);
|
||||
let st = s.active().unwrap();
|
||||
let root: Vec<_> = st.doc.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(root, ["Background", "B", "Existing Group"]);
|
||||
let names: Vec<_> = st.doc.layer(LayerId(g)).unwrap().children().unwrap().iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["A", "C"]);
|
||||
assert_eq!(st.history.past_len(), past + 1, "one drag = one undo step");
|
||||
assert_eq!(st.active_layer, Some(LayerId(c)), "preserve the active member");
|
||||
assert!(st.selected_layers().contains(&LayerId(a)));
|
||||
assert!(st.selected_layers().contains(&LayerId(c)));
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let root: Vec<_> = s.active().unwrap().doc.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(root, ["Background", "A", "B", "C", "Existing Group"]);
|
||||
assert!(s.active().unwrap().doc.layer(LayerId(g)).unwrap().children().unwrap().is_empty());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layer(LayerId(g)).unwrap().children().unwrap().len(), 2);
|
||||
assert_eq!(b, s.active().unwrap().doc.layers[1].id.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_selected_layers_keeps_selected_group_children_and_supports_above_below() {
|
||||
let mut s = session_with_doc();
|
||||
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.new.layer", json!({"name": "C"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let group = s.execute("layer.new.group", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Made before B moves into G: a new group goes above the active layer, which would then be inside G.
|
||||
let target = s.execute("layer.new.group", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.moveTo", json!({"layer": b, "target": group, "position": "into"})).unwrap();
|
||||
|
||||
let r = s.execute("layer.moveTo", json!({"layers": [b, group, a], "target": target, "position": "into"})).unwrap();
|
||||
assert_eq!(r["moved"], 2, "moving a group already carries its selected child");
|
||||
let target_children = s.active().unwrap().doc.layer(LayerId(target)).unwrap().children().unwrap();
|
||||
assert_eq!(target_children.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["A", "G"]);
|
||||
assert_eq!(target_children[1].children().unwrap()[0].id.0, b);
|
||||
assert!(s.active().unwrap().selected_layers().contains(&LayerId(b)), "nested selected child is still selected");
|
||||
|
||||
s.execute("layer.moveTo", json!({"layers": [group, a], "target": c, "position": "above"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Background", "C", "A", "G", "Target"]);
|
||||
s.execute("layer.moveTo", json!({"layers": [group, a], "target": c, "position": "below"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Background", "A", "G", "C", "Target"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_selected_layers_invalid_targets_leave_document_and_history_unchanged() {
|
||||
let mut s = session_with_doc();
|
||||
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let g = s.execute("layer.new.group", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.moveTo", json!({"layer": a, "target": g, "position": "into"})).unwrap();
|
||||
let outside = s.execute("layer.new.layer", json!({"name": "Outside"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let root = s.active().unwrap().doc.layers.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
for params in [
|
||||
json!({"layers": [g, a], "target": a, "position": "into"}),
|
||||
json!({"layers": [g], "target": g, "position": "above"}),
|
||||
json!({"layers": [g, outside], "target": g, "position": "into"}),
|
||||
json!({"layers": [g, outside], "target": a, "position": "below"}),
|
||||
json!({"layers": [a], "target": outside, "position": "into"}),
|
||||
json!({"layers": [a, a], "target": outside}),
|
||||
json!({"layers": [a, "bogus"], "target": outside}),
|
||||
json!({"layers": [], "target": outside}),
|
||||
json!({"layers": [u64::MAX], "target": outside}),
|
||||
json!({"layers": [a], "target": outside, "position": "sideways"}),
|
||||
] {
|
||||
assert!(s.execute("layer.moveTo", params.clone()).is_err(), "{params}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{params}: no history step");
|
||||
assert_eq!(s.active().unwrap().doc.layers, root, "{params}: layers unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_layer_does_not_dirty_the_document() {
|
||||
let mut s = session_with_doc();
|
||||
|
||||
@@ -964,8 +964,12 @@ fn path_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if feather > 0.0 {
|
||||
cov = sel::feather(&cov, area.width() as usize, area.height() as usize, feather);
|
||||
}
|
||||
let lock = doc.effective_locks(id).transparency;
|
||||
let locks = doc.effective_locks(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if locks.pixels || locks.all {
|
||||
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
let lock = locks.transparency;
|
||||
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Fill Path needs a pixel layer".into()))?;
|
||||
paint_coverage(surf, area, &cov, src, opacity, mode, lock);
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Closing a background tab must preserve the active document, not its old index.
|
||||
use photocraft_doc::DocId;
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session_with_documents(count: usize) -> (Session, Vec<DocId>) {
|
||||
let mut session = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..count {
|
||||
session.execute("file.new", json!({"name": format!("Document {i}"), "width": 8, "height": 8})).unwrap();
|
||||
ids.push(session.active().unwrap().doc.id);
|
||||
}
|
||||
(session, ids)
|
||||
}
|
||||
|
||||
fn document_ids(session: &Session) -> Vec<DocId> {
|
||||
session.documents().iter().map(|state| state.doc.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_background_document_keeps_the_active_document() {
|
||||
for (active, closing) in [(0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 1)] {
|
||||
let (mut session, mut ids) = session_with_documents(3);
|
||||
assert!(session.set_active(active));
|
||||
let active_id = ids[active];
|
||||
ids.remove(closing);
|
||||
|
||||
session.execute("file.close", json!({"document": closing})).unwrap();
|
||||
|
||||
assert_eq!(session.active().unwrap().doc.id, active_id, "active {active}, closing {closing}");
|
||||
assert_eq!(session.active_index(), ids.iter().position(|id| *id == active_id));
|
||||
assert_eq!(document_ids(&session), ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_active_document_selects_the_next_or_previous_document() {
|
||||
for (closing, next) in [(0, 1), (1, 2), (2, 1)] {
|
||||
let (mut session, mut ids) = session_with_documents(3);
|
||||
assert!(session.set_active(closing));
|
||||
let next_id = ids[next];
|
||||
ids.remove(closing);
|
||||
|
||||
session.execute("file.close", json!({})).unwrap();
|
||||
|
||||
assert_eq!(session.active().unwrap().doc.id, next_id);
|
||||
assert_eq!(session.active_index(), ids.iter().position(|id| *id == next_id));
|
||||
assert_eq!(document_ids(&session), ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_last_document_clears_the_active_document() {
|
||||
let (mut session, _) = session_with_documents(1);
|
||||
session.execute("file.close", json!({})).unwrap();
|
||||
assert!(session.documents().is_empty());
|
||||
assert_eq!(session.active_index(), None);
|
||||
assert!(session.active().is_none());
|
||||
assert!(session.close(0).is_none());
|
||||
assert!(session.execute("file.close", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_an_invalid_document_preserves_the_session() {
|
||||
let (mut session, ids) = session_with_documents(3);
|
||||
assert!(session.set_active(1));
|
||||
for index in [3, usize::MAX] {
|
||||
assert!(session.close(index).is_none());
|
||||
assert!(session.execute("file.close", json!({"document": index})).is_err());
|
||||
assert_eq!(session.active_index(), Some(1));
|
||||
assert_eq!(session.active().unwrap().doc.id, ids[1]);
|
||||
assert_eq!(document_ids(&session), ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_other_documents_keeps_the_requested_document() {
|
||||
let (mut session, ids) = session_with_documents(3);
|
||||
session.execute("file.closeOthers", json!({"document": 1})).unwrap();
|
||||
assert_eq!(document_ids(&session), vec![ids[1]]);
|
||||
assert_eq!(session.active_index(), Some(0));
|
||||
session.execute("file.closeAll", json!({})).unwrap();
|
||||
assert!(session.documents().is_empty());
|
||||
assert_eq!(session.active_index(), None);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Paint Bucket and Gradient keep alpha on a transparency-locked layer, as Edit › Fill and the
|
||||
//! brushes do (#1104).
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
/// A transparent layer with a red stroke across it, transparency locked.
|
||||
fn locked() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let st = s.active().unwrap();
|
||||
st.active_layer.and_then(|id| st.doc.layer(id)).unwrap().surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_keeps_alpha() {
|
||||
let mut s = locked();
|
||||
s.execute("paint.bucket", json!({"x": 30, "y": 25, "color": "#0000ff", "antiAlias": false})).unwrap();
|
||||
assert_eq!(px(&s, 30, 25)[3], 0.0, "transparent pixel became opaque");
|
||||
s.execute("paint.bucket", json!({"x": 12, "y": 10, "color": "#0000ff", "antiAlias": false})).unwrap();
|
||||
assert_eq!(px(&s, 12, 10), [0.0, 0.0, 1.0, 1.0], "the opaque stroke is still recoloured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_keeps_alpha() {
|
||||
let mut s = locked();
|
||||
s.execute("paint.gradient", json!({"from": [0, 0], "to": [40, 30]})).unwrap();
|
||||
assert_eq!(px(&s, 30, 25)[3], 0.0, "transparent pixel became opaque");
|
||||
assert_eq!(px(&s, 12, 10)[3], 1.0);
|
||||
assert_ne!(px(&s, 12, 10), [1.0, 0.0, 0.0, 1.0], "the opaque stroke is still painted");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Filters refuse a pixel-locked (or fully locked) layer and leave it unchanged, as painting
|
||||
//! does (#1102).
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn layered() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session) -> [f32; 4] {
|
||||
let st = s.active().unwrap();
|
||||
st.active_layer.and_then(|id| st.doc.layer(id)).unwrap().surface().unwrap().rgba(12, 6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_refuse_a_locked_layer() {
|
||||
for lock in [json!({"pixels": true}), json!({"all": true})] {
|
||||
let mut s = layered();
|
||||
s.execute("layer.lockLayers", lock.clone()).unwrap();
|
||||
let before = px(&s);
|
||||
let r = s.execute("filter.blur.gaussianBlur", json!({"radius": 4}));
|
||||
assert!(r.is_err(), "blurred a layer locked with {lock}: {r:?}");
|
||||
assert_eq!(px(&s), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_still_run_on_an_unlocked_layer() {
|
||||
let mut s = layered();
|
||||
let before = px(&s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 4})).unwrap();
|
||||
assert_ne!(px(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_filters_and_fill_path_refuse_a_locked_layer() {
|
||||
for (cmd, p) in [("filter.render.tree", json!({})), ("filter.render.pictureFrame", json!({})), ("path.fill", json!({"color": "#0000ff"}))] {
|
||||
let mut s = layered();
|
||||
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[2, 2], [38, 2], [38, 28], [2, 28]]}]}})).unwrap();
|
||||
s.execute("layer.lockLayers", json!({"pixels": true})).unwrap();
|
||||
let before = px(&s);
|
||||
let r = s.execute(cmd, p);
|
||||
assert!(r.is_err(), "{cmd} drew on a locked layer: {r:?}");
|
||||
assert_eq!(px(&s), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_keep_alpha_on_a_transparency_locked_layer() {
|
||||
let mut s = layered();
|
||||
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
|
||||
let alpha = |s: &Session, x, y| {
|
||||
let st = s.active().unwrap();
|
||||
st.active_layer.and_then(|id| st.doc.layer(id)).unwrap().surface().unwrap().rgba(x, y)[3]
|
||||
};
|
||||
let (edge, outside) = (alpha(&s, 12, 6), alpha(&s, 12, 2));
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 4})).unwrap();
|
||||
assert_eq!(alpha(&s, 12, 6), edge, "the blur changed alpha at the stroke edge");
|
||||
assert_eq!(alpha(&s, 12, 2), outside, "the blur spread into the transparent area");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Delete Layer never leaves a document without layers (#1107): the single-layer path refuses
|
||||
//! like the multi-select path does, and changes nothing.
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn one_layer_doc() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 10, "height": 5, "background": "transparent"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_only_layer_is_refused() {
|
||||
let mut s = one_layer_doc();
|
||||
let id = s.active().unwrap().doc.layers[0].id;
|
||||
let undo_before = s.active().unwrap().history.can_undo();
|
||||
let r = s.execute("layer.delete", json!({"layer": id.0}));
|
||||
assert!(r.is_err(), "deleted the last layer ({r:?})");
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 1);
|
||||
assert_eq!(st.active_layer, Some(id));
|
||||
assert_eq!(st.history.can_undo(), undo_before, "a refused delete left a history step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_only_layer_without_a_param_is_refused() {
|
||||
let mut s = one_layer_doc();
|
||||
assert!(s.execute("layer.delete", json!({})).is_err());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_group_that_holds_every_layer_is_refused() {
|
||||
let mut s = one_layer_doc();
|
||||
let r = s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let group = r["layer"].as_u64().unwrap_or_else(|| s.active().unwrap().active_layer.unwrap().0);
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1, "the group should be the only top-level layer");
|
||||
assert!(s.execute("layer.delete", json!({"layer": group})).is_err());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_of_two_layers_still_works() {
|
||||
let mut s = one_layer_doc();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let top = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.delete", json!({"layer": top.0})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 1);
|
||||
assert!(st.doc.layer(top).is_none());
|
||||
assert_eq!(st.active_layer, Some(st.doc.layers[0].id));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Clear, Cut and Image › Adjustments refuse a pixel-locked layer and leave it unchanged, as
|
||||
//! painting does (#1103).
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
/// A layer with a red stroke across it, pixels locked.
|
||||
fn locked() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.lockLayers", json!({"pixels": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session) -> [f32; 4] {
|
||||
let st = s.active().unwrap();
|
||||
st.active_layer.and_then(|id| st.doc.layer(id)).unwrap().surface().unwrap().rgba(12, 10)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_layer_edits_are_refused() {
|
||||
for (cmd, p) in [
|
||||
("edit.clear", json!({})),
|
||||
("edit.cut", json!({})),
|
||||
("image.adjustments.invert", json!({})),
|
||||
("image.autoTone", json!({})),
|
||||
("image.autoContrast", json!({})),
|
||||
("image.autoColor", json!({})),
|
||||
] {
|
||||
let mut s = locked();
|
||||
let before = px(&s);
|
||||
let r = s.execute(cmd, p);
|
||||
assert!(r.is_err(), "{cmd} edited a locked layer: {r:?}");
|
||||
assert_eq!(px(&s), before, "{cmd} changed a locked layer's pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_cut_leaves_the_clipboard_alone() {
|
||||
let mut s = locked();
|
||||
// Copy the whole canvas first, then select a smaller area: a Cut that went through would
|
||||
// replace the clipboard with a smaller clip.
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let before = s.clipboard.as_ref().map(|c| c.bounds);
|
||||
assert!(before.is_some());
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.execute("edit.cut", json!({})).is_err());
|
||||
assert_eq!(s.clipboard.as_ref().map(|c| c.bounds), before, "a refused Cut replaced the clipboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_layer_edits_still_work() {
|
||||
let mut s = locked();
|
||||
s.execute("layer.lockLayers", json!({"pixels": false})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
assert_eq!(px(&s), [0.0, 1.0, 1.0, 1.0]);
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(px(&s)[3], 0.0);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, LabelColor, Layer, Size};
|
||||
|
||||
#[test]
|
||||
fn layer_labels_pcraft_roundtrip_all_colors_and_depths() {
|
||||
for depth in SampleType::ALL {
|
||||
let mut doc = Document::new("Labels", Size::new(2, 2), ColorMode::Rgb, depth);
|
||||
for color in LabelColor::ALL {
|
||||
let mut child = Layer::raster("Child", doc.pixel_format());
|
||||
child.label = color;
|
||||
let mut group = Layer::group(color.label(), vec![child]);
|
||||
group.label = color;
|
||||
doc.layers.push(group);
|
||||
}
|
||||
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap(), doc, "{depth:?}");
|
||||
assert!(photocraft_format::load_from_bytes(&bytes[..bytes.len() / 2]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,11 @@ pub fn label_from_index(v: u16) -> LabelColor {
|
||||
5 => LabelColor::Blue,
|
||||
6 => LabelColor::Violet,
|
||||
7 => LabelColor::Gray,
|
||||
// Photoshop 2024 added these without renumbering the original labels.
|
||||
8 => LabelColor::Seafoam,
|
||||
9 => LabelColor::Indigo,
|
||||
10 => LabelColor::Magenta,
|
||||
11 => LabelColor::Fuchsia,
|
||||
_ => LabelColor::None,
|
||||
}
|
||||
}
|
||||
@@ -83,6 +88,10 @@ pub fn label_index(l: LabelColor) -> u16 {
|
||||
LabelColor::Blue => 5,
|
||||
LabelColor::Violet => 6,
|
||||
LabelColor::Gray => 7,
|
||||
LabelColor::Seafoam => 8,
|
||||
LabelColor::Indigo => 9,
|
||||
LabelColor::Magenta => 10,
|
||||
LabelColor::Fuchsia => 11,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, LabelColor, Layer, Size};
|
||||
|
||||
#[test]
|
||||
fn layer_labels_keep_the_psd_indices() {
|
||||
use photocraft_io::blocks::{label_from_index, label_index};
|
||||
let labels = [
|
||||
LabelColor::None,
|
||||
LabelColor::Red,
|
||||
LabelColor::Orange,
|
||||
LabelColor::Yellow,
|
||||
LabelColor::Green,
|
||||
LabelColor::Blue,
|
||||
LabelColor::Violet,
|
||||
LabelColor::Gray,
|
||||
LabelColor::Seafoam,
|
||||
LabelColor::Indigo,
|
||||
LabelColor::Magenta,
|
||||
LabelColor::Fuchsia,
|
||||
];
|
||||
for (i, color) in labels.into_iter().enumerate() {
|
||||
assert_eq!(label_index(color), i as u16);
|
||||
assert_eq!(label_from_index(i as u16), color);
|
||||
}
|
||||
for i in [12, 255, u16::MAX] {
|
||||
assert_eq!(label_from_index(i), LabelColor::None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_labels_psd_roundtrip_all_colors_and_depths() {
|
||||
for depth in SampleType::ALL {
|
||||
let mut doc = Document::new("Labels", Size::new(2, 2), ColorMode::Rgb, depth);
|
||||
for color in LabelColor::ALL {
|
||||
let mut child = Layer::raster("Child", doc.pixel_format());
|
||||
child.label = color;
|
||||
let mut group = Layer::group(color.label(), vec![child]);
|
||||
group.label = color;
|
||||
doc.layers.push(group);
|
||||
}
|
||||
let bytes = photocraft_io::export(&doc, "labels.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("labels.psd", &bytes).unwrap().document;
|
||||
let labels = |d: &Document| d.walk().into_iter().map(|(_, _, l)| (l.name.clone(), l.label)).collect::<Vec<_>>();
|
||||
assert_eq!(labels(&back), labels(&doc), "{depth:?}");
|
||||
assert_eq!(back.depth, depth);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_labels_truncated_sheet_color_is_not_a_label() {
|
||||
let block = photocraft_psd::TaggedBlock::new(*b"lclr", vec![0]);
|
||||
assert!(matches!(block.parsed(), Some(Err(_))));
|
||||
}
|
||||
@@ -319,6 +319,9 @@ fn format_real(r: f64) -> String {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if matches!(s.as_str(), "0." | "-0.") {
|
||||
return "0.0".into();
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("0.") {
|
||||
s = format!(".{rest}");
|
||||
} else if let Some(rest) = s.strip_prefix("-0.") {
|
||||
@@ -361,6 +364,26 @@ mod tests {
|
||||
assert_eq!(format_real(12.125), "12.125");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_reals_round_trip_as_numbers() {
|
||||
for value in [0.000004, -0.000004] {
|
||||
assert_eq!(format_real(value), "0.0");
|
||||
let source = Value::Dict(vec![(
|
||||
"EngineDict".into(),
|
||||
Value::Dict(vec![("StyleRun".into(), Value::Array(vec![Value::Dict(vec![("BaselineShift".into(), Value::Real(value))])]))]),
|
||||
)]);
|
||||
let encoded = write(&source);
|
||||
let parsed = parse(&encoded).expect("formatted tiny real should parse");
|
||||
let baseline_shift = parsed
|
||||
.path(&["EngineDict", "StyleRun"])
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|runs| runs.first())
|
||||
.and_then(|run| run.get("BaselineShift"))
|
||||
.and_then(Value::as_f64);
|
||||
assert_eq!(baseline_shift, Some(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input_never_panics() {
|
||||
for cut in 0..SAMPLE.len() {
|
||||
|
||||
@@ -388,6 +388,34 @@ fn files_dropped_onto_the_canvas_are_placed_in_free_transform_one_by_one() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1099: switching tabs after an actual canvas drop cancels the place in its source document.
|
||||
#[test]
|
||||
fn switching_documents_after_a_canvas_drop_undoes_only_the_place() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_canvas();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let steps = history_steps(&app);
|
||||
app.open_dropped(&ctx, vec![dropped("dot.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0)));
|
||||
app.place_next_dropped(&ctx);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().made, Some(crate::state::MadeLayer::Place));
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
assert!(app.session.undo());
|
||||
let st = app.session.active().unwrap();
|
||||
let (other, history, redo, revision) = (st.doc.clone(), st.history.entries(), st.history.redo_labels().map(str::to_owned).collect::<Vec<_>>(), st.revision);
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&st.doc, &other));
|
||||
assert_eq!((st.history.entries(), st.revision), (history, revision));
|
||||
assert_eq!(st.history.redo_labels().collect::<Vec<_>>(), redo);
|
||||
let origin = &app.session.documents()[0];
|
||||
assert!(std::sync::Arc::ptr_eq(&origin.doc, &original));
|
||||
assert_eq!(origin.history.past_len(), steps);
|
||||
assert!(!origin.history.can_redo());
|
||||
}
|
||||
|
||||
/// Moving a placed file in its Free Transform and committing makes one Place Embedded step.
|
||||
#[test]
|
||||
fn a_transformed_place_is_one_history_step() {
|
||||
|
||||
@@ -2135,4 +2135,10 @@ cameraRaw Color Barvy
|
||||
% of all commits % všech commitů
|
||||
Lines +/− Řádky +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, řádky +{added} / −{deleted} (Δ {delta}), binární soubory +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Mořská pěna
|
||||
Indigo Indigová
|
||||
Fuchsia Fuchsiová
|
||||
Violet Fialová
|
||||
Apply At Canvas Bounds Použít efekt na hranicích plátna
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2174,4 +2174,11 @@
|
||||
% of all commits % aller Commits
|
||||
Lines +/− Zeilen +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{binAdded} / −{binDeleted} Binärdateien, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
layerLabel No Color Keine Farbe
|
||||
Seafoam Meeresschaum
|
||||
Indigo Indigo
|
||||
Fuchsia Fuchsia
|
||||
Violet Violett
|
||||
Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2206,4 +2206,10 @@ cameraRaw Amount Cantidad
|
||||
% of all commits % de todos los commits
|
||||
Lines +/− Líneas +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{binAdded} / −{binDeleted} archivos binarios, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Espuma de mar
|
||||
Indigo Índigo
|
||||
Fuchsia Fucsia
|
||||
Violet Violeta
|
||||
Apply At Canvas Bounds Aplicar efecto en los límites del lienzo
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2566,4 +2566,10 @@ cameraRaw Blending Fusion
|
||||
% of all commits % de tous les commits
|
||||
Lines +/− Lignes +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{binAdded} / −{binDeleted} fichiers binaires, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Écume de mer
|
||||
Indigo Indigo
|
||||
Fuchsia Fuchsia
|
||||
Violet Violet
|
||||
Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2553,4 +2553,10 @@ cameraRaw Blending Pembauran
|
||||
% of all commits % dari semua commit
|
||||
Lines +/− Baris +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{binAdded} / −{binDeleted} berkas biner, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Busa Laut
|
||||
Indigo Nila
|
||||
Fuchsia Fuksia
|
||||
Violet Ungu
|
||||
Apply At Canvas Bounds Terapkan efek di batas kanvas
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2208,4 +2208,10 @@ cameraRaw Amount Quantità
|
||||
Lines +/− Righe +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{binAdded} / −{binDeleted} risorse binarie, {first} – {last}
|
||||
Convert to Layers Converti in livelli
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Schiuma marina
|
||||
Indigo Indaco
|
||||
Fuchsia Fucsia
|
||||
Violet Viola
|
||||
Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro
|
||||
|
@@ -2118,4 +2118,10 @@ cameraRaw Color カラー
|
||||
% of all commits 全コミットに占める割合 (%)
|
||||
Lines +/− 行数 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs} 件、コミット {commits} 件、行 +{added} / −{deleted} (Δ {delta})、バイナリ +{binAdded} / −{binDeleted}、{first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam シーフォーム
|
||||
Indigo インディゴ
|
||||
Fuchsia フクシア
|
||||
Violet 紫
|
||||
Apply At Canvas Bounds カンバスの境界に効果を適用
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2719,4 +2719,9 @@ cameraRaw Color 색상
|
||||
% of all commits 전체 커밋 대비 %
|
||||
Lines +/− 줄 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs}개, 커밋 {commits}개, 줄 +{added} / −{deleted} (Δ {delta}), 바이너리 +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam 바다 거품색
|
||||
Indigo 남색
|
||||
Fuchsia 자홍색
|
||||
Apply At Canvas Bounds 캔버스 경계에 효과 적용
|
||||
|
@@ -647,6 +647,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_names_are_translated() {
|
||||
for lang in Lang::all().filter(|l| l.complete_menus()) {
|
||||
for color in photocraft_doc::LabelColor::ALL {
|
||||
let cat = lang.0.catalog();
|
||||
assert!(
|
||||
cat.contextual("layerLabel", color.label()).or_else(|| cat.plain(color.label())).is_some(),
|
||||
"{} missing {}",
|
||||
lang.code(),
|
||||
color.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
let de = lang_from_tag("de").unwrap();
|
||||
assert_eq!(tr_ctx(de, "layerLabel", "No Color"), "Keine Farbe");
|
||||
assert_eq!(tr_ctx(de, "layerLabel", "Seafoam"), "Meeresschaum");
|
||||
assert_eq!(tr(Lang::EN, "Seafoam"), "Seafoam");
|
||||
}
|
||||
|
||||
/// Blend mode names come from the colour crate; each must be translated.
|
||||
#[test]
|
||||
fn blend_mode_names_are_translated() {
|
||||
|
||||
@@ -2573,4 +2573,10 @@ cameraRaw Whites Brancos
|
||||
% of all commits % de todos os commits
|
||||
Lines +/− Linhas +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{binAdded} / −{binDeleted} arquivos binários, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Espuma do mar
|
||||
Indigo Índigo
|
||||
Fuchsia Fúcsia
|
||||
Violet Violeta
|
||||
Apply At Canvas Bounds Aplicar efeito nos limites da tela
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2409,4 +2409,10 @@ cameraRaw Roughness Неровность
|
||||
% of all commits % от всех коммитов
|
||||
Lines +/− Строки +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, коммиты: {commits}, строки +{added} / −{deleted} (Δ {delta}), двоичные файлы +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam Морская пена
|
||||
Indigo Индиго
|
||||
Fuchsia Фуксия
|
||||
Violet Фиолетовый
|
||||
Apply At Canvas Bounds Применить эффект на границах холста
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2143,4 +2143,10 @@ cameraRaw Blending 混合
|
||||
% of all commits 占全部提交的 %
|
||||
Lines +/− 行数 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 个 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二进制文件 +{binAdded} / −{binDeleted},{first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam 海沫色
|
||||
Indigo 靛蓝色
|
||||
Fuchsia 紫红色
|
||||
Violet 紫罗兰色
|
||||
Apply At Canvas Bounds 应用画布边界的效果
|
||||
|
@@ -2112,4 +2112,10 @@ cameraRaw Blending 混合
|
||||
% of all commits 佔全部提交的 %
|
||||
Lines +/− 行數 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 個 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二進位檔 +{binAdded} / −{binDeleted},{first} – {last}
|
||||
|
||||
# Layer colour labels.
|
||||
Seafoam 海沫色
|
||||
Indigo 靛藍色
|
||||
Fuchsia 紫紅色
|
||||
Violet 紫羅蘭色
|
||||
Apply At Canvas Bounds 套用效果至版面邊界
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2,7 +2,7 @@
|
||||
//! unavailable ones are greyed using the same enablement as the main menus. Items marked as menu
|
||||
//! invocations (`Value::Null` params) go through `menus::invoke`, so dialogs open like in the menu bar.
|
||||
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use photocraft_doc::{LabelColor, Layer, LayerContent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// One entry: label, command id. `None` = separator.
|
||||
@@ -118,10 +118,80 @@ pub fn show(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, on_set: bo
|
||||
rename = true;
|
||||
ui.close();
|
||||
}
|
||||
color_menu(app, ui, l, on_set, actions);
|
||||
rename
|
||||
})
|
||||
}
|
||||
|
||||
fn color_name(color: LabelColor) -> &'static str {
|
||||
crate::i18n::tr_ctx(crate::i18n::current(), "layerLabel", color.label())
|
||||
}
|
||||
|
||||
fn common_color(app: &crate::PhotocraftApp, l: &Layer, on_set: bool) -> Option<LabelColor> {
|
||||
if !on_set {
|
||||
return Some(l.label);
|
||||
}
|
||||
let st = app.session.active()?;
|
||||
let ids = st.selected_layers();
|
||||
let mut labels = ids.iter().filter_map(|id| st.doc.layer(*id).map(|l| l.label));
|
||||
let first = labels.next()?;
|
||||
labels.all(|c| c == first).then_some(first)
|
||||
}
|
||||
|
||||
fn color_menu(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, on_set: bool, actions: &mut Vec<(String, Value)>) {
|
||||
let current = common_color(app, l, on_set);
|
||||
ui.menu_button(tl!("Color"), |ui| {
|
||||
crate::widgets::menu_scroll(ui, |ui| {
|
||||
ui.set_min_width(170.0);
|
||||
for color in LabelColor::ALL {
|
||||
if color == LabelColor::Red {
|
||||
ui.separator();
|
||||
}
|
||||
if color_button(ui, color, current == Some(color)).clicked() {
|
||||
let params = if on_set {
|
||||
json!({"color": color.id()})
|
||||
} else {
|
||||
actions.push(("layer.select".into(), json!({"layer": l.id.0})));
|
||||
json!({"layer": l.id.0, "color": color.id()})
|
||||
};
|
||||
actions.push(("layer.setLabelColor".into(), params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn color_button(ui: &mut egui::Ui, color: LabelColor, checked: bool) -> egui::Response {
|
||||
use egui::{Atom, Rect, Stroke, StrokeKind, vec2};
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let check_id = ui.id().with(("label-check", color.id()));
|
||||
let swatch_id = ui.id().with(("label-swatch", color.id()));
|
||||
let name = color_name(color);
|
||||
let button = egui::Button::new((Atom::custom(check_id, vec2(14.0, 16.0)), Atom::custom(swatch_id, vec2(16.0, 16.0)), name, Atom::grow())).atom_ui(ui);
|
||||
if checked && let Some(rect) = button.rect(check_id) {
|
||||
crate::icons::paint(ui, rect, "check", 14.0, t.icon);
|
||||
}
|
||||
if let Some(rect) = button.rect(swatch_id) {
|
||||
let rect = Rect::from_center_size(rect.center(), vec2(16.0, 16.0));
|
||||
let p = ui.painter();
|
||||
if let Some((swatch, _)) = t.layer_label_colors(color) {
|
||||
p.rect_filled(rect, t.radius_sm, swatch);
|
||||
} else {
|
||||
let stroke = Stroke::new(1.0, t.text_dim);
|
||||
p.rect_stroke(rect, t.radius_sm, stroke, StrokeKind::Inside);
|
||||
let r = rect.shrink(4.0);
|
||||
p.line_segment([r.left_top(), r.right_bottom()], stroke);
|
||||
p.line_segment([r.right_top(), r.left_bottom()], stroke);
|
||||
}
|
||||
}
|
||||
button.response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), checked, name));
|
||||
button.response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::*;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_doc::LayerId;
|
||||
|
||||
fn harness(kind: crate::theme::ThemeKind, ppp: f32) -> (Harness<'static, crate::PhotocraftApp>, LayerId, LayerId) {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let red = LayerId(s.execute("layer.new.layer", json!({"name": "Red layer"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.setLabelColor", json!({"color": "red"})).unwrap();
|
||||
let other = LayerId(s.execute("layer.new.layer", json!({"name": "Other layer"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
|
||||
crate::PhotocraftApp::setup_context(&cc.egui_ctx, kind);
|
||||
let mut app = crate::PhotocraftApp::new(s, crate::Services::default());
|
||||
app.ui.theme = kind;
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
(h, red, other)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, crate::PhotocraftApp>, at: Pos2, button: PointerButton, modifiers: Modifiers) {
|
||||
h.event(Event::ModifiersChanged(modifiers));
|
||||
h.hover_at(at);
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button, pressed, modifiers });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn row(h: &Harness<'_, crate::PhotocraftApp>, id: LayerId) -> egui::Rect {
|
||||
crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap().row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_context_menu_edits_the_clicked_layer_and_preserves_multi_selection() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
let (mut h, red, other) = harness(crate::theme::ThemeKind::Pro, ppp);
|
||||
let at = row(&h, red).center();
|
||||
click(&mut h, at, PointerButton::Secondary, Modifiers::NONE);
|
||||
let at = h.get_by_label("Color ⏵").rect().center();
|
||||
click(&mut h, at, PointerButton::Primary, Modifiers::NONE);
|
||||
let at = h.get_by_label("Seafoam").rect().center();
|
||||
click(&mut h, at, PointerButton::Primary, Modifiers::NONE);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.active_layer, Some(red));
|
||||
assert_eq!(st.doc.layer(red).unwrap().label, LabelColor::Seafoam);
|
||||
assert_eq!(st.doc.layer(other).unwrap().label, LabelColor::None);
|
||||
h.state_mut().run("layer.select", json!({"layer": other.0, "mode": "add"})).unwrap();
|
||||
h.run_steps(4);
|
||||
let clicked = h.state().session.active().unwrap().doc.layer(red).unwrap();
|
||||
assert_eq!(common_color(h.state(), clicked, true), None);
|
||||
let at = row(&h, red).center();
|
||||
click(&mut h, at, PointerButton::Secondary, Modifiers::NONE);
|
||||
let at = h.get_by_label("Color ⏵").rect().center();
|
||||
click(&mut h, at, PointerButton::Primary, Modifiers::NONE);
|
||||
let at = h.get_by_label("Indigo").rect().center();
|
||||
click(&mut h, at, PointerButton::Primary, Modifiers::NONE);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.selected_layers().len(), 2);
|
||||
assert_eq!(st.doc.layer(red).unwrap().label, LabelColor::Indigo);
|
||||
assert_eq!(st.doc.layer(other).unwrap().label, LabelColor::Indigo);
|
||||
assert_eq!(common_color(h.state(), st.doc.layer(red).unwrap(), true), Some(LabelColor::Indigo));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_background_is_confined_to_the_eye_column_in_every_theme() {
|
||||
fn rects(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<egui::Rect>) {
|
||||
match shape {
|
||||
egui::Shape::Rect(r) if r.fill == color => out.push(r.rect),
|
||||
egui::Shape::Vec(shapes) => {
|
||||
for s in shapes {
|
||||
rects(s, color, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn has_icon(shape: &egui::Shape, rect: egui::Rect) -> bool {
|
||||
match shape {
|
||||
egui::Shape::Rect(r) => r.brush.is_some() && r.rect == rect,
|
||||
egui::Shape::Vec(shapes) => shapes.iter().any(|s| has_icon(s, rect)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
for kind in crate::theme::ThemeKind::ALL {
|
||||
let (mut h, red, _) = harness(kind, 1.0);
|
||||
for visible in [true, false] {
|
||||
h.state_mut().run("layer.setProps", json!({"layer": red.0, "visible": visible})).unwrap();
|
||||
h.run_steps(4);
|
||||
let r = row(&h, red);
|
||||
let t = crate::theme::Tokens::for_kind(kind);
|
||||
let (_, bg) = t.layer_label_colors(LabelColor::Red).unwrap();
|
||||
let mut fills = Vec::new();
|
||||
for s in &h.output().shapes {
|
||||
rects(&s.shape, bg, &mut fills);
|
||||
}
|
||||
assert_eq!(fills, vec![egui::Rect::from_min_max(r.left_top(), pos2(r.left() + 30.0, r.bottom()))], "{kind:?}");
|
||||
use egui::emath::GuiRounding;
|
||||
let icon = egui::Rect::from_center_size(pos2(r.left() + 15.0, r.center().y), vec2(15.0, 15.0)).round_to_pixels(h.ctx.pixels_per_point());
|
||||
assert_eq!(h.output().shapes.iter().any(|s| has_icon(&s.shape, icon)), visible, "centred eye in {kind:?}");
|
||||
}
|
||||
let eye = pos2(row(&h, red).left() + 17.0, row(&h, red).center().y);
|
||||
click(&mut h, eye, PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(h.state().session.active().unwrap().doc.layer(red).unwrap().visible);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layer(red).unwrap().label, LabelColor::Red);
|
||||
click(&mut h, eye, PointerButton::Primary, Modifiers::ALT);
|
||||
assert!(h.state().session.active().unwrap().doc.layer(red).unwrap().visible);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_menu_marks_only_the_common_color() {
|
||||
let mut h = Harness::new_ui(|ui| {
|
||||
for c in LabelColor::ALL {
|
||||
color_button(ui, c, c == LabelColor::Blue);
|
||||
}
|
||||
});
|
||||
h.run();
|
||||
assert_eq!(h.get_by_label("Blue").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
|
||||
assert_eq!(h.get_by_label("Red").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False));
|
||||
}
|
||||
+188
-16
@@ -1,6 +1,6 @@
|
||||
//! Chrome around the canvas: title bar, options bar, toolbar, status bar, dock cards, Properties.
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, RichText, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_doc::{Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
@@ -54,9 +54,9 @@ fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool
|
||||
pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) };
|
||||
// Photoshop switches to a double-column toolbar only when one column doesn't fit.
|
||||
// Two columns when the header chevron asks for them, or when one column doesn't fit.
|
||||
let slots: usize = TOOL_SECTIONS.iter().map(|g| g.len()).sum();
|
||||
let double = toolbar_needs_double(slots, TOOL_SECTIONS.len(), bx, t.pro, ui.available_rect_before_wrap().height());
|
||||
let double = app.ui.panels.toolbar_double || toolbar_needs_double(slots, TOOL_SECTIONS.len(), bx, t.pro, ui.available_rect_before_wrap().height());
|
||||
let w = if double { w1 + bx + 2.0 } else { w1 };
|
||||
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(
|
||||
ui,
|
||||
@@ -64,9 +64,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if t.pro {
|
||||
let r = ui.max_rect();
|
||||
ui.painter().line_segment([r.right_top() + vec2(m as f32, -8.0), r.right_bottom() + vec2(m as f32, 8.0)], Stroke::new(1.0, t.separator));
|
||||
// collapse chevrons like Photoshop's toolbar header
|
||||
let (cr, _) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::hover());
|
||||
icons::paint(ui, cr, "chevrons-right", 11.0, t.text_faint);
|
||||
// Photoshop's toolbar header chevrons switch between one and two columns.
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click());
|
||||
icons::paint(ui, cr, if double { "chevrons-left" } else { "chevrons-right" }, 11.0, if resp.hovered() { t.text } else { t.text_faint });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar")));
|
||||
if resp.clicked() {
|
||||
app.ui.panels.toolbar_double = !app.ui.panels.toolbar_double;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
// Subtle violet wash at the bottom of the toolbar.
|
||||
@@ -91,7 +95,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
|
||||
}
|
||||
let mut slot_index = 0usize;
|
||||
for (si, section) in TOOL_SECTIONS.iter().enumerate() {
|
||||
// Pro has no group dividers, so its two columns fill every row across groups.
|
||||
let flat: Vec<&[Tool]> = TOOL_SECTIONS.iter().flat_map(|s| s.iter().copied()).collect();
|
||||
let sections: Vec<&[&[Tool]]> = if t.pro { vec![&flat[..]] } else { TOOL_SECTIONS.to_vec() };
|
||||
for (si, section) in sections.iter().enumerate() {
|
||||
// Photoshop 2026 draws one uninterrupted column (no group dividers).
|
||||
if si > 0 && !t.pro {
|
||||
ui.add_space(4.0);
|
||||
@@ -1371,6 +1378,28 @@ fn blend_options(groups: bool) -> Vec<(BlendMode, &'static str)> {
|
||||
std::iter::once(BlendMode::PassThrough).filter(|_| groups).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect()
|
||||
}
|
||||
|
||||
/// Scroll the Layers panel while holding a layer drag over its top/bottom edge.
|
||||
///
|
||||
/// Returns the *content* displacement in points for this frame, so positive moves the
|
||||
/// list downward (reveals rows above) and negative upward (reveals rows below).
|
||||
/// The speed ramps with proximity to the edge and uses elapsed time instead of
|
||||
/// assuming a particular refresh rate.
|
||||
fn layer_drag_edge_scroll(pointer: Option<Pos2>, viewport: Rect, dragging: bool, dt: f32) -> f32 {
|
||||
if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let Some(pointer) = pointer.filter(|p| viewport.contains(*p)) else { return 0.0 };
|
||||
let edge = 32.0_f32.min(viewport.height() * 0.25);
|
||||
let top = (edge - (pointer.y - viewport.top())).max(0.0) / edge;
|
||||
let bottom = (edge - (viewport.bottom() - pointer.y)).max(0.0) / edge;
|
||||
let direction = top - bottom;
|
||||
if direction == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let velocity = 80.0 + 520.0 * direction.abs();
|
||||
direction.signum() * velocity * dt.clamp(0.0, 0.05)
|
||||
}
|
||||
|
||||
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// A new active layer opens its parent groups and is scrolled into view (#152).
|
||||
let reveal = crate::layer_reveal::track(app, ui.ctx());
|
||||
@@ -1499,6 +1528,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
.min_scrolled_height(if fill { rows_h } else { 0.0 })
|
||||
.auto_shrink([false, !fill])
|
||||
.show(ui, |ui| {
|
||||
// The drag key is set only by actual layer-row drags, not clicks or
|
||||
// ordinary scrolling. The ScrollArea applies this to its own content.
|
||||
let dragging = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))).is_some() && ctx.input(|i| i.pointer.primary_down());
|
||||
let pointer = ctx.input(|i| i.pointer.interact_pos());
|
||||
let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt));
|
||||
if delta != 0.0 {
|
||||
ui.scroll_with_delta(vec2(0.0, delta));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let filter = app.ui.layer_filter.clone();
|
||||
let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default();
|
||||
crate::layer_row_ui::begin(ui.ctx());
|
||||
@@ -1769,7 +1807,7 @@ fn layer_row(
|
||||
// Photoshop's default (medium) thumbnails: 32 pt rows.
|
||||
let row_h = if t.pro { 32.0 } else { 46.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
|
||||
layer_drag_and_drop(ctx, ui, l, rect, &resp, actions);
|
||||
layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions);
|
||||
if resp.drag_started() {
|
||||
crate::layer_transfer::begin_from_panel(app, ctx, l.id);
|
||||
}
|
||||
@@ -1788,15 +1826,21 @@ fn layer_row(
|
||||
} else if resp.hovered() {
|
||||
painter.rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5));
|
||||
}
|
||||
// eye column divider + row divider, as in Photoshop
|
||||
painter.line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator));
|
||||
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
} else if selected {
|
||||
painter.rect_filled(rect, t.radius, t.hover);
|
||||
painter.rect_stroke(rect, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
painter.rect_filled(rect, t.radius, t.hover.gamma_multiply(0.5));
|
||||
}
|
||||
let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom()));
|
||||
if let Some((_, background)) = t.layer_label_colors(l.label) {
|
||||
painter.rect_filled(eye_cell, 0.0, background);
|
||||
}
|
||||
if t.pro {
|
||||
// Paint dividers over the label so the eye column stays distinct.
|
||||
painter.line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator));
|
||||
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
let mut x = rect.left() + 6.0;
|
||||
let eye = Rect::from_min_size(pos2(x, rect.center().y - 11.0), vec2(22.0, 22.0));
|
||||
// The eye takes drags too (so a drag starting on it never reorders the row): dragging down
|
||||
@@ -1804,7 +1848,8 @@ fn layer_row(
|
||||
let eye_resp = ui.interact(eye, ui.id().with(("eye", l.id.0)), Sense::click_and_drag());
|
||||
// A hidden layer's eye box is left empty (still clickable).
|
||||
if l.visible {
|
||||
icons::paint(ui, eye, "eye", 15.0, t.icon);
|
||||
// Centre the drawing in the column while preserving the visibility hit area.
|
||||
icons::paint(ui, eye_cell, "eye", 15.0, t.layer_label_icon(l.label));
|
||||
}
|
||||
eye_sweep(ctx, l, rect, &eye_resp, actions);
|
||||
if eye_resp.clicked() {
|
||||
@@ -2629,9 +2674,27 @@ fn brush_preset_chip(
|
||||
.and_then(|r| r.inner)
|
||||
}
|
||||
|
||||
/// Drag a layer row to reorder: drop on the upper/lower half to place above/below, or on the middle
|
||||
/// of a group to move into it. One `layer.moveTo` command (one undo step).
|
||||
fn layer_drag_and_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, resp: &egui::Response, actions: &mut Vec<(String, Value)>) {
|
||||
/// A drag from within a multi-selection moves the whole selection, not only the grabbed
|
||||
/// row. Dragging an unselected row keeps the existing single-layer behavior.
|
||||
fn layer_drop_payload(dragged: u64, target: LayerId, position: &str, selected: &[LayerId]) -> Value {
|
||||
if selected.len() > 1 && selected.contains(&LayerId(dragged)) {
|
||||
json!({"layers": selected.iter().map(|id| id.0).collect::<Vec<_>>(), "target": target.0, "position": position})
|
||||
} else {
|
||||
json!({"layer": dragged, "target": target.0, "position": position})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag a layer row to reorder: drop above, below, or inside an existing group.
|
||||
/// Multi-layer moves are atomic (one undo step), using the engine's stable document order.
|
||||
fn layer_drag_and_drop(
|
||||
app: &PhotocraftApp,
|
||||
ctx: &egui::Context,
|
||||
ui: &egui::Ui,
|
||||
l: &Layer,
|
||||
rect: Rect,
|
||||
resp: &egui::Response,
|
||||
actions: &mut Vec<(String, Value)>,
|
||||
) {
|
||||
let t = Tokens::get(ctx);
|
||||
let key = egui::Id::new("layer-drag");
|
||||
if resp.drag_started() {
|
||||
@@ -2672,7 +2735,8 @@ fn layer_drag_and_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect
|
||||
}
|
||||
}
|
||||
if released {
|
||||
actions.push(("layer.moveTo".into(), json!({"layer": dragged, "target": l.id.0, "position": position})));
|
||||
let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default();
|
||||
actions.push(("layer.moveTo".into(), layer_drop_payload(dragged, l.id, position, &selected)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3223,3 +3287,111 @@ mod properties_card_tests {
|
||||
assert_eq!(ids.iter().map(|&id| doc.layer(id).unwrap().opacity).collect::<Vec<_>>(), [0.25, 0.25]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod toolbar_tests {
|
||||
use super::*;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
/// #1197: the header chevron switches the toolbar between one and two columns.
|
||||
#[test]
|
||||
fn header_chevron_toggles_two_columns() {
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
toolbar(app, ui);
|
||||
let left = ui.available_rect_before_wrap().left();
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left));
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
|
||||
h.run_steps(2);
|
||||
let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0);
|
||||
let single = left(&h);
|
||||
assert!(!h.state().ui.panels.toolbar_double);
|
||||
|
||||
h.get_by_label("Toolbar").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.panels.toolbar_double);
|
||||
assert!(left(&h) > single + 20.0, "the toolbar did not widen: {single} -> {}", left(&h));
|
||||
|
||||
h.get_by_label("Toolbar").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.panels.toolbar_double);
|
||||
assert_eq!(left(&h), single);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod group_drag_selection_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dragging_a_selected_layer_moves_the_complete_selection_into_a_group() {
|
||||
let a = LayerId(10);
|
||||
let b = LayerId(11);
|
||||
let group = LayerId(20);
|
||||
let payload = layer_drop_payload(a.0, group, "into", &[a, b]);
|
||||
assert_eq!(payload, json!({"layers": [10, 11], "target": 20, "position": "into"}));
|
||||
assert_eq!(payload.get("layer"), None, "batch drops must not also send a single layer");
|
||||
|
||||
// Above/below use the same batch route; engine preserves the document stack order.
|
||||
assert_eq!(layer_drop_payload(b.0, group, "above", &[a, b])["position"], "above");
|
||||
assert_eq!(layer_drop_payload(b.0, group, "below", &[a, b])["position"], "below");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_unselected_or_singular_row_remains_a_single_layer_move() {
|
||||
let a = LayerId(10);
|
||||
let b = LayerId(11);
|
||||
let group = LayerId(20);
|
||||
assert_eq!(layer_drop_payload(9, group, "into", &[a, b]), json!({"layer": 9, "target": 20, "position": "into"}));
|
||||
assert_eq!(layer_drop_payload(a.0, group, "above", &[a]), json!({"layer": 10, "target": 20, "position": "above"}));
|
||||
assert_eq!(layer_drop_payload(a.0, group, "below", &[]), json!({"layer": 10, "target": 20, "position": "below"}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layer_drag_edge_scroll_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scrolls_both_edges_with_distance_dependent_velocity() {
|
||||
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
|
||||
let point = |y| Some(pos2(100.0, y));
|
||||
let near_top = layer_drag_edge_scroll(point(33.0), viewport, true, 1.0 / 60.0);
|
||||
let far_top = layer_drag_edge_scroll(point(53.0), viewport, true, 1.0 / 60.0);
|
||||
let near_bottom = layer_drag_edge_scroll(point(327.0), viewport, true, 1.0 / 60.0);
|
||||
let far_bottom = layer_drag_edge_scroll(point(307.0), viewport, true, 1.0 / 60.0);
|
||||
assert!(near_top > far_top && far_top > 0.0, "approaching the top reveals earlier rows");
|
||||
assert!(near_bottom < far_bottom && far_bottom < 0.0, "approaching the bottom reveals later rows");
|
||||
assert!((near_top + near_bottom).abs() < 1e-5, "symmetric edge behavior");
|
||||
assert_eq!(layer_drag_edge_scroll(point(160.0), viewport, true, 1.0 / 60.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_stops_outside_or_after_the_drag_finishes() {
|
||||
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
|
||||
let active = Some(pos2(100.0, 325.0));
|
||||
assert_eq!(layer_drag_edge_scroll(active, viewport, false, 1.0 / 60.0), 0.0);
|
||||
assert_eq!(layer_drag_edge_scroll(None, viewport, true, 1.0 / 60.0), 0.0);
|
||||
assert_eq!(layer_drag_edge_scroll(Some(pos2(9.0, 325.0)), viewport, true, 1.0 / 60.0), 0.0);
|
||||
assert_eq!(layer_drag_edge_scroll(Some(pos2(100.0, 335.0)), viewport, true, 1.0 / 60.0), 0.0);
|
||||
assert_eq!(layer_drag_edge_scroll(active, viewport, true, 0.0), 0.0);
|
||||
let step = layer_drag_edge_scroll(active, viewport, true, 1.0 / 60.0);
|
||||
let twice = layer_drag_edge_scroll(active, viewport, true, 2.0 / 60.0);
|
||||
assert!((twice - step * 2.0).abs() < 1e-4, "time-based scrolling scales across refresh rates");
|
||||
assert!(layer_drag_edge_scroll(active, viewport, true, 0.5).abs() <= 30.0, "long frames have a bounded step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_viewports_keep_the_edge_zones_disjoint() {
|
||||
let viewport = Rect::from_min_max(pos2(0.0, 0.0), pos2(150.0, 40.0));
|
||||
assert!(layer_drag_edge_scroll(Some(pos2(10.0, 2.0)), viewport, true, 0.016) > 0.0);
|
||||
assert!(layer_drag_edge_scroll(Some(pos2(10.0, 38.0)), viewport, true, 0.016) < 0.0);
|
||||
assert_eq!(layer_drag_edge_scroll(Some(pos2(10.0, 20.0)), viewport, true, 0.016), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
||||
Some(id)
|
||||
});
|
||||
let Some(id) = next else { break };
|
||||
let id = delete_key_command(app, id);
|
||||
let owner = key_owner(app, ctx);
|
||||
dispatch(app, ctx, &id);
|
||||
ran = true;
|
||||
@@ -199,6 +200,23 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
||||
ran
|
||||
}
|
||||
|
||||
/// Clear's key (Delete / Backspace) with nothing selected deletes the selected layers, of any
|
||||
/// kind, as in Photoshop (#1077): Edit › Clear only clears pixel layers, so on an adjustment, fill,
|
||||
/// type or shape layer the key did nothing. A selection, a targeted layer mask, a single channel
|
||||
/// or Quick Mask keeps Clear.
|
||||
fn delete_key_command(app: &PhotocraftApp, id: String) -> String {
|
||||
if id != "edit.clear" {
|
||||
return id;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return id };
|
||||
let composite = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite && st.doc.quick_mask.is_none();
|
||||
let mask = app.ui.mask_target && st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some());
|
||||
if st.doc.selection.is_some() || !composite || mask {
|
||||
return id;
|
||||
}
|
||||
"layer.delete".into()
|
||||
}
|
||||
|
||||
/// Why `id` can't run now (the engine's reason when it has one).
|
||||
pub fn disabled_reason(app: &PhotocraftApp, id: &str) -> String {
|
||||
photocraft_engine::commands::find(id).and_then(|_| app.session.disabled_reason_with(id, &app.with_mask_target(id, serde_json::Value::Null))).unwrap_or_else(
|
||||
|
||||
@@ -426,3 +426,62 @@ fn a_shortcut_opening_a_dialog_takes_the_frames_later_keys() {
|
||||
assert_eq!(layer_count(&h), 5, "⌘Z didn't undo behind the dialog");
|
||||
assert_eq!(logged(&h), ["image.adjustments.levels"]);
|
||||
}
|
||||
|
||||
fn names(h: &Harness<'_, PhotocraftApp>) -> Vec<String> {
|
||||
h.state().session.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// #1077: with nothing selected, Delete (and Backspace) deletes the selected layer whatever its
|
||||
/// kind, from the canvas or the Layers panel, in one undo step; it used to say Clear needs a
|
||||
/// pixel layer on an adjustment layer.
|
||||
#[test]
|
||||
fn delete_without_a_selection_deletes_the_selected_layer() {
|
||||
for place in [Place::Canvas, Place::LayersRow] {
|
||||
for key in ["Delete", "Backspace"] {
|
||||
let mut h = harness();
|
||||
put_focus(&mut h, place);
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let adj = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
h.run_steps(2);
|
||||
let before = names(&h);
|
||||
press(&mut h, key);
|
||||
assert_eq!(logged(&h), ["layer.delete"], "{place:?} {key}");
|
||||
assert!(h.state().session.active().unwrap().doc.layer(adj).is_none(), "{place:?} {key}: the adjustment layer is gone");
|
||||
assert_eq!(names(&h).len(), before.len() - 1, "{place:?} {key}: only it");
|
||||
press(&mut h, "Cmd+Z");
|
||||
assert_eq!(names(&h), before, "{place:?} {key}: one undo brings it back");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every selected layer goes, pixel layers too (Photoshop deletes, it doesn't clear the layer).
|
||||
#[test]
|
||||
fn delete_without_a_selection_deletes_every_selected_layer() {
|
||||
let mut h = harness();
|
||||
put_focus(&mut h, Place::Canvas);
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let paint = s.active().unwrap().active_layer.unwrap();
|
||||
let adj = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": adj.0, "mode": "add"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let n = names(&h).len();
|
||||
press(&mut h, "Delete");
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
assert!(doc.layer(paint).is_none() && doc.layer(adj).is_none(), "both selected layers are gone");
|
||||
assert_eq!(names(&h).len(), n - 2);
|
||||
}
|
||||
|
||||
/// A selection keeps Edit › Clear: the selected pixels go, the layer stays.
|
||||
#[test]
|
||||
fn delete_with_a_selection_clears_it() {
|
||||
let mut h = harness();
|
||||
put_focus(&mut h, Place::Canvas);
|
||||
let before = names(&h);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some());
|
||||
press(&mut h, "Delete");
|
||||
assert_eq!(logged(&h), ["edit.clear"]);
|
||||
assert_eq!(names(&h), before, "no layer was deleted");
|
||||
}
|
||||
@@ -315,6 +315,9 @@ pub struct Panels {
|
||||
/// Window › Character / Paragraph: the Character | Paragraph dock group (#150).
|
||||
#[serde(default)]
|
||||
pub character: bool,
|
||||
/// The toolbar's header chevron: two columns even when one fits (#1197).
|
||||
#[serde(default)]
|
||||
pub toolbar_double: bool,
|
||||
}
|
||||
|
||||
impl Default for Panels {
|
||||
@@ -330,6 +333,7 @@ impl Default for Panels {
|
||||
status_bar: true,
|
||||
brush_settings: false,
|
||||
character: false,
|
||||
toolbar_double: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,31 @@ impl Tokens {
|
||||
self.histogram_color(mask).gamma_multiply(0.3)
|
||||
}
|
||||
|
||||
/// Semantic label hues stay recognisable across themes; None keeps the normal row surface.
|
||||
pub fn layer_label_colors(&self, label: photocraft_doc::LabelColor) -> Option<(Color32, Color32)> {
|
||||
use photocraft_doc::LabelColor;
|
||||
let (swatch, eye) = match label {
|
||||
LabelColor::None => return None,
|
||||
LabelColor::Red => ([0xFC, 0x5D, 0x5B], [0x9F, 0x2F, 0x30]),
|
||||
LabelColor::Orange => ([0xF7, 0x97, 0x44], [0x93, 0x4F, 0x0C]),
|
||||
LabelColor::Yellow => ([0xDC, 0xD6, 0x4B], [0x99, 0x78, 0x0C]),
|
||||
LabelColor::Green => ([0x85, 0xDC, 0x6A], [0x4E, 0x71, 0x2E]),
|
||||
LabelColor::Seafoam => ([0x1C, 0x84, 0x88], [0x0B, 0x54, 0x4F]),
|
||||
LabelColor::Blue => ([0x78, 0xAD, 0xF6], [0x41, 0x5B, 0x87]),
|
||||
LabelColor::Indigo => ([0x54, 0x4B, 0xE7], [0x36, 0x34, 0x8E]),
|
||||
LabelColor::Magenta => ([0xCC, 0x1A, 0x7E], [0x98, 0x1B, 0x51]),
|
||||
LabelColor::Fuchsia => ([0xB0, 0x14, 0xC0], [0x71, 0x0F, 0x74]),
|
||||
LabelColor::Violet => ([0x91, 0x76, 0xD5], [0x5D, 0x3F, 0x8E]),
|
||||
LabelColor::Gray => ([0x9C, 0x9C, 0x9C], [0x57, 0x57, 0x57]),
|
||||
};
|
||||
let rgb = |[r, g, b]: [u8; 3]| Color32::from_rgb(r, g, b);
|
||||
Some((rgb(swatch), rgb(eye)))
|
||||
}
|
||||
|
||||
pub fn layer_label_icon(&self, label: photocraft_doc::LabelColor) -> Color32 {
|
||||
if label == photocraft_doc::LabelColor::None { self.icon } else { Color32::from_gray(226) }
|
||||
}
|
||||
|
||||
/// Analysis colours are semantic hues, independent of the application accent palette.
|
||||
pub fn scope_hue(&self, h: f32, s: f32) -> Color32 {
|
||||
let v = f32::from(self.histogram_level) / 255.0;
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2};
|
||||
use photocraft_algo::transform::Homography;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
|
||||
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -202,8 +202,11 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||||
let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some());
|
||||
app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, json!({}))?;
|
||||
let made = app.session.active().and_then(|st| st.active_layer.map(|layer| (st.doc.id, layer)));
|
||||
if let Err(e) = begin(app, ctx) {
|
||||
take_back_made(app);
|
||||
if let Some((document, layer)) = made {
|
||||
take_back_made(app, document, layer);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if let Some(t) = app.ui.transform.as_mut() {
|
||||
@@ -224,11 +227,26 @@ pub fn begin_placed(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(),
|
||||
|
||||
/// Undoes the layer a cancelled or failed session made (⌥⌘T's copy, a placed file), leaving
|
||||
/// nothing to redo.
|
||||
fn take_back_made(app: &mut PhotocraftApp) {
|
||||
app.session.undo();
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) {
|
||||
// Tabs can move or close while transforming. Only undo the creation in its own document,
|
||||
// and only while it is still the latest step (it may already have been undone).
|
||||
let Some(index) = app.session.documents().iter().position(|st| {
|
||||
st.doc.id == document
|
||||
&& st.doc.layer(layer).is_some()
|
||||
&& st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).is_some_and(|before| before.layer(layer).is_none())
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let active = app.session.active_index();
|
||||
app.session.set_active(index);
|
||||
if app.session.undo()
|
||||
&& let Some(st) = app.session.active_mut()
|
||||
{
|
||||
st.history.clear_redo();
|
||||
}
|
||||
if let Some(active) = active {
|
||||
app.session.set_active(active);
|
||||
}
|
||||
app.sync_views();
|
||||
}
|
||||
|
||||
@@ -487,6 +505,11 @@ fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool {
|
||||
}
|
||||
|
||||
pub fn commit(app: &mut PhotocraftApp) {
|
||||
let document = app.transform_preview.as_ref().map(|pv| pv.doc.id);
|
||||
if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) {
|
||||
cancel(app);
|
||||
return;
|
||||
}
|
||||
let Some(t) = app.ui.transform.take() else { return };
|
||||
app.transform_preview = None;
|
||||
if t.selection {
|
||||
@@ -525,8 +548,10 @@ pub fn commit(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if made.is_some() {
|
||||
take_back_made(app);
|
||||
if made.is_some()
|
||||
&& let Some(document) = document
|
||||
{
|
||||
take_back_made(app, document, LayerId(t.layer));
|
||||
}
|
||||
app.ui.status = e;
|
||||
}
|
||||
@@ -538,7 +563,8 @@ pub fn commit(app: &mut PhotocraftApp) {
|
||||
/// just before it (`ui.pointer`'s `tool`) goes to that tool.
|
||||
pub fn end_if_left(app: &mut PhotocraftApp) {
|
||||
let Some(t) = &app.ui.transform else { return };
|
||||
if app.session.active().and_then(|s| s.doc.layer(LayerId(t.layer))).is_none() {
|
||||
if app.session.active().is_none_or(|st| app.transform_preview.as_ref().is_some_and(|pv| pv.doc.id != st.doc.id) || st.doc.layer(LayerId(t.layer)).is_none())
|
||||
{
|
||||
cancel(app);
|
||||
} else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) {
|
||||
commit(app);
|
||||
@@ -546,10 +572,12 @@ pub fn end_if_left(app: &mut PhotocraftApp) {
|
||||
}
|
||||
|
||||
pub fn cancel(app: &mut PhotocraftApp) {
|
||||
let made = app.ui.transform.take().is_some_and(|t| t.made.is_some());
|
||||
app.transform_preview = None;
|
||||
if made {
|
||||
take_back_made(app);
|
||||
let transform = app.ui.transform.take();
|
||||
let preview = app.transform_preview.take();
|
||||
if let (Some(t), Some(pv)) = (transform, preview)
|
||||
&& t.made.is_some()
|
||||
{
|
||||
take_back_made(app, pv.doc.id, LayerId(t.layer));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1938,6 +1966,87 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers);
|
||||
}
|
||||
|
||||
/// #1099: cancel, frame cleanup and a commit before the next frame all belong to the
|
||||
/// original document, including after tab reordering and with shared layer IDs.
|
||||
#[test]
|
||||
fn cancelling_a_copy_after_switching_documents_preserves_the_active_document() {
|
||||
for reorder in [false, true] {
|
||||
for finish in [cancel, end_if_left, commit] {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||||
app.ui.transform.as_mut().unwrap().quad = corners([12.0, 8.0, 28.0, 24.0]);
|
||||
// Cloning a document deliberately shares layer IDs, but gets a fresh document ID.
|
||||
let other = app.session.active().unwrap().doc.as_ref().clone();
|
||||
app.session.add_document(other, None);
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
assert!(app.session.undo());
|
||||
let st = app.session.active().unwrap();
|
||||
let (doc, history, redo, revision, layer) =
|
||||
(st.doc.clone(), st.history.entries(), st.history.redo_labels().map(str::to_owned).collect::<Vec<_>>(), st.revision, st.active_layer);
|
||||
if reorder {
|
||||
app.session.move_document(0, 1);
|
||||
}
|
||||
finish(&mut app);
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &doc), "the selected document's pixels are unchanged");
|
||||
assert_eq!((st.history.entries(), st.revision, st.active_layer), (history, revision, layer));
|
||||
assert_eq!(st.history.redo_labels().collect::<Vec<_>>(), redo);
|
||||
let origin = app.session.documents().iter().find(|st| st.doc.id == original.id).unwrap();
|
||||
assert!(Arc::ptr_eq(&origin.doc, &original), "only the originating copy is undone");
|
||||
assert_eq!(origin.history.past_len(), steps);
|
||||
assert!(!origin.history.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_copy_after_its_document_closes_does_not_undo_another_document() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
app.session.close(0);
|
||||
end_if_left(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &doc));
|
||||
assert_eq!(st.history.past_len(), steps);
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_an_already_undone_copy_preserves_history_and_redo() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||||
assert!(app.session.undo());
|
||||
let st = app.session.active().unwrap();
|
||||
let (doc, history, redo) = (st.doc.clone(), st.history.entries(), st.history.redo_labels().map(str::to_owned).collect::<Vec<_>>());
|
||||
end_if_left(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &doc));
|
||||
assert_eq!(st.history.entries(), history);
|
||||
assert_eq!(st.history.redo_labels().collect::<Vec<_>>(), redo);
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_copy_does_not_undo_a_later_edit() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
let (doc, history) = (st.doc.clone(), st.history.entries());
|
||||
cancel(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &doc));
|
||||
assert_eq!(st.history.entries(), history);
|
||||
}
|
||||
|
||||
/// #670: picking another tool applies the open transform, as one history step.
|
||||
#[test]
|
||||
fn picking_another_tool_applies_the_transform() {
|
||||
|
||||
Reference in new issue
Block a user