Compare commits

...
Author SHA1 Message Date
Claude 16c36e3ebe Merge #1298
# Conflicts:
#	crates/ui-egui/src/panels.rs
2026-10-08 20:12:42 +00:00
Claude 58344dcf07 Merge #1403
# Conflicts:
#	crates/ui-egui/src/panels.rs
2026-10-08 20:12:36 +00:00
Claude 876506903f Merge #1200 2026-10-08 20:12:21 +00:00
Claude 2a3b8451ea Merge #1289 2026-10-08 20:12:20 +00:00
Claude c50a9b4213 Merge #1087 2026-10-08 20:12:19 +00:00
Claude 2c85b057b2 Merge #1390 2026-10-08 20:12:18 +00:00
Claude 05bfeaac95 Merge #1195 2026-10-08 20:12:18 +00:00
Claude e69b858052 Merge #1194 2026-10-08 20:12:17 +00:00
Claude 526066c681 Merge #1193 2026-10-08 20:12:16 +00:00
Claude 81d5a74108 Merge #1189 2026-10-08 20:12:15 +00:00
Claude acf41d154e Merge #1148 2026-10-08 20:12:15 +00:00
Claude e84e68c19c Merge #1124 2026-10-08 20:12:14 +00:00
Claude 353394dc0b Merge #1138 2026-10-08 20:12:13 +00:00
Claude 62ed8532ef Merge #1141 2026-10-08 20:12:13 +00:00
Claude f7f214ec73 Merge #1085 2026-10-08 20:12:12 +00:00
Claude 1505209547 Batch layer.moveTo test: make the target group before B moves into G
layer.new.group inserts above the active layer, which was B inside G, so
Target was created inside G and moving G into it was rightly refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 20:09:00 +00:00
Nigel Ihza Firdausy 1fd9e1fa45 style: remove extra blank line after deleted helper 2026-10-08 20:05:27 +00:00
Nigel Ihza Firdausy ee1e7b8c3c style: apply rustfmt layout for multi-layer drag handler 2026-10-08 20:05:27 +00:00
Nigel Ihza Firdausy a1dc20eb90 test(ui): verify multi-selected group drop payload and single-layer fallback 2026-10-08 20:05:27 +00:00
Nigel Ihza Firdausy 47c7319f20 test(engine): batch group move, stable stacking, undo/redo, nested selections and atomic rejection 2026-10-08 20:05:26 +00:00
Nigel Ihza Firdausy 702536f6a6 refactor(engine): share cycle checks through batch move implementation 2026-10-08 20:05:26 +00:00
Nigel Ihza Firdausy 5508eda9bd feat(ui): drag all selected layers into existing groups with a single drop (#1245) 2026-10-08 20:05:26 +00:00
Nigel Ihza Firdausy e1c9e8c451 feat(engine): expose batch layer.moveTo through the command registry 2026-10-08 20:05:26 +00:00
Nigel Ihza Firdausy ecb55d6531 feat(engine): move multiple selected layers in one atomic layer.moveTo operation (#1245) 2026-10-08 20:05:26 +00:00
Shayan Horbank d7758d3412 Merge main into feat/layer-color-labels-1283 2026-10-08 21:33:17 +02:00
CQMHV fd4403ded9 fix(ui): cancel transforms in their originating document 2026-10-08 19:25:18 +00:00
Brandon ThomasandClaude Opus 5.5 844f1fc21a edit.cut: refuse a pixel-locked layer before filling the clipboard
copy() ran before clear_selected noticed the lock, so a refused Cut still
replaced the clipboard. Check the lock first (check_pixels_unlocked, shared
with paint_surface) and assert the clipboard is unchanged in the test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:10:06 -04:00
prekabrekiandClaude Opus 5.5 fa25d217fb Clear, Cut and Image > Adjustments refuse a pixel-locked layer (#1103)
clear_area, destructive_adjust and auto_adjust took the layer's surface
directly. They now go through commands::paint_surface, which refuses a
pixel- or fully-locked layer with the same message painting uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:44:20 -04:00
Nigel Ihza Firdausy 9956e272da test(ui): cover drag autoscroll velocity, boundaries, stop conditions and short panels 2026-10-08 23:24:03 +07:00
Nigel Ihza Firdausy 4148a1740d fix(ui): import Pos2 for Layers panel drag-edge scrolling 2026-10-08 23:23:35 +07:00
Nigel Ihza Firdausy f2a8065911 feat(ui): auto-scroll Layers panel when dragging near viewport edges (#1246) 2026-10-08 23:23:07 +07:00
Shayan Horbank bd82dc508a Add layer and group color labels (#1283) 2026-10-08 18:18:06 +02:00
prekabrekiandClaude Opus 5.5 2a1e028f2d Filters keep alpha under lock transparency; render filters and Fill Path refuse a pixel-locked layer (#1102)
- run_filter puts the layer's alpha back in the tiles a filter changed when
  transparency is locked (the same tile walk as restrict_to_color).
- Flame / Tree / Picture Frame take the surface through paint_surface.
- path.fill refuses a pixel- or fully-locked layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:14:46 +02:00
prekabrekiandClaude Opus 5.5 4e563bad08 Toolbar: the Pro two-column toolbar fills every row (#1197)
Rows were cut per tool group, so a group with an odd number of tools left
a half-empty row (Move, Eyedropper). Pro draws no group dividers, so its
two columns now flow across groups; the other themes keep their per-group
rows between dividers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:00:40 +02:00
prekabrekiandClaude Opus 5.5 1afe4c781b Toolbar: the header chevron switches between one and two columns (#1197)
The Pro theme's chevron was painted with a hover-only sense, so clicking it
did nothing. It now toggles Panels::toolbar_double (serde default, so older
state still loads); the toolbar is two columns when that is on or when one
column doesn't fit, and the chevron points left while two are shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:53:46 +02:00
prekabrekiandClaude Opus 5.5 9e0bd3ed7d Filters refuse a pixel-locked layer (#1102)
run_filter never consulted layer locks. It now refuses a pixel- or
fully-locked layer with the same message painting uses. Channel, Quick
Mask and mask targets are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:30:44 +02:00
prekabrekiandClaude Opus 5.5 420de8f265 Paint Bucket and Gradient keep alpha on a transparency-locked layer (#1104)
composite_area already honours lock transparency, but paint_gradient and
bucket_fill(_src) always passed false, and paint.bucket / paint.gradient
dropped the flag target_surface returns. Thread it through, as Edit > Fill
and the brushes already do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:25:56 +02:00
prekabrekiandClaude Opus 5.5 5dc08a6538 layer.delete keeps a document's last layer (#1107)
The single-layer path removed the layer with no guard, leaving a document
with no layers and no active layer. It now refuses with the same error as
the multi-select path; the edit is discarded, so nothing changes and no
history step is recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:20:13 +02:00
CQMHV 953c0d2e8c Keep the active document when closing another tab 2026-10-08 17:24:01 +08:00
MokuLakshithReddy ee18c02534 Nearest Neighbor: sample single source pixels on reductions without averaging (#1114) 2026-10-08 14:09:33 +05:30
Elvis33LE 4c4f21e41c MCP doc_save: bridge mode refuses headless-only options instead of saving with defaults
The bridge branch forwarded only the path to the running app's
app.save, silently dropping quality, format, tiffLayers and index (the
headless branch honours all of them): a client asking for a
quality-50 JPEG got an ok reply and whatever the app's current
settings produce. Those params now fail fast with the reason - naming
headless mode - before anything is written.
2026-10-08 10:23:05 +02:00
Elvis33LE 18ddace84e Autosave: a thread that fails to start is reported, not a silent no-op
Autosaver::new discarded the spawn result with .ok(): under thread or
resource exhaustion the recovery channel pointed at a dead receiver,
every send failed silently and last_result stayed None - crash
recovery was off while looking on. The failure is now recorded where
last_result/flush report it (via the same note helper the worker uses),
so health checks and flush surface it.
2026-10-08 09:49:34 +02:00
3a34a49d18 Delete with nothing selected deletes the selected layers, of any kind (#1077)
Delete / Backspace is Edit › Clear's key, and Clear only works on pixel
layers, so on an adjustment, fill, type or shape layer the key reported
"not available" and nothing happened. As in Photoshop, with no
selection the key now deletes every selected layer (one undo step). A
selection, a targeted layer mask, a single channel or Quick Mask keeps
Clear.

Co-authored-by: Liohtml <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:17:46 +02:00
Minghang Chamling bd49334e9f fix(text): keep tiny EngineData reals parseable (#979) 2026-10-08 13:43:46 +08:00
45 changed files with 1702 additions and 98 deletions

No files matched your search

+9 -5
View File
@@ -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));
}
}
+91
View File
@@ -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)]
+27 -50
View File
@@ -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)))
}
+6 -5
View File
@@ -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)
+30
View File
@@ -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 {
+1
View File
@@ -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(),
+80
View File
@@ -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;
+123
View File
@@ -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());
}
+93
View File
@@ -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> {
+10 -1
View File
@@ -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)
}
+5 -5
View File
@@ -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)
+1 -2
View File
@@ -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))
+84
View File
@@ -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();
+5 -1
View File
@@ -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(())
+85
View File
@@ -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");
}
+65
View File
@@ -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");
}
+53
View File
@@ -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));
}
+61
View File
@@ -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);
}
+19
View File
@@ -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());
}
}
+9
View File
@@ -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,
}
}
+53
View File
@@ -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(_))));
}
+23
View File
@@ -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() {
+28
View File
@@ -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() {
+6
View File
@@ -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.
+7
View File
@@ -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.
+6
View File
@@ -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.
+6
View File
@@ -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.
+6
View File
@@ -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.
+6
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
+6
View File
@@ -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.
+5
View File
@@ -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 캔버스 경계에 효과 적용
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2719
2720
2721
2722
2723
2724
2725
2726
2727
+19
View File
@@ -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() {
+6
View File
@@ -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.
+6
View File
@@ -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.
+6
View File
@@ -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 应用画布边界的效果
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
+6
View File
@@ -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.
+71 -1
View File
@@ -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
View File
@@ -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);
}
}
+18
View File
@@ -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");
}
+4
View File
@@ -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,
}
}
}
+25
View File
@@ -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;
+121 -12
View File
@@ -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() {