Merge remote-tracking branch 'origin/main' into integration-batch-7

# Conflicts:
#	crates/ui-egui/src/i18n/tr.tsv
#	docs/ui-parity.md
This commit is contained in:
David Mann
2026-10-24 00:34:11 +02:00
21 changed files with 866 additions and 130 deletions
+7
View File
@@ -255,5 +255,12 @@ sync_github_name = true
[people.benandez] [people.benandez]
real_name = "Martin Ball" real_name = "Martin Ball"
[people.ronibalthazar]
sync_github_name = true
[people.batuhangozegu]
real_name = "Batuhan Gözegü"
sync_github_name = false
[people.drunkeninja42] [people.drunkeninja42]
real_name = "Kushagra Sharma" real_name = "Kushagra Sharma"
+9 -1
View File
@@ -2033,7 +2033,15 @@ fn transform_from(d: &Descriptor) -> Mapped {
let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0));
let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels;
// The resampling applies to a selection as much as to a layer (#7515). // The resampling applies to a selection as much as to a layer (#7515).
let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")); // Photoshop's bicubic variants are kept (the transforms render them as Bicubic, and they export
// back unchanged); Automatic and Preserve Details become the nearest bicubic choice. Dropping
// them fell back to the command's default, bilinear for a selection (#8697).
let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).and_then(|i| match i {
"bicubic" | "bilinear" | "nearest" | "bicubicSharper" | "bicubicSmoother" | "bicubicAutomatic" => Some(i),
"automatic" => Some("bicubicAutomatic"),
"preserveDetails" => Some("bicubic"),
_ => None,
});
match (ref_class(r).as_deref(), target(r)) { match (ref_class(r).as_deref(), target(r)) {
(Some("Chnl"), Target::Property(prop)) if prop == "fsel" => { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => {
if let Some(i) = interpolation { if let Some(i) = interpolation {
+22
View File
@@ -2172,6 +2172,28 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() {
} }
} }
/// #8697: Photoshop's other transform interpolation modes survive import: its bicubic variants
/// are kept (they render as Bicubic and export back unchanged), Automatic becomes Bicubic
/// Automatic and Preserve Details Bicubic; before, they fell back to the command's default.
#[test]
fn photoshop_transform_interpolation_modes_survive_import() {
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
for id in ["select.transformSelection", "edit.transform"] {
for (mode, imported) in [
("bicubicSharper", "bicubicSharper"),
("bicubicSmoother", "bicubicSmoother"),
("bicubicAutomatic", "bicubicAutomatic"),
("automatic", "bicubicAutomatic"),
("preserveDetails", "bicubic"),
] {
let (bytes, _) = export("Audit8", &[&one(id, json!({"scaleX": 140, "interpolation": mode}))], &[], false).unwrap();
let steps = import(&bytes).unwrap().actions[0].steps.clone();
let got = steps.first().and_then(|(_, p)| p.get("interpolation")).and_then(Value::as_str).map(str::to_owned);
assert_eq!(got.as_deref(), Some(imported), "{id} {mode}: {steps:?}");
}
}
}
/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written /// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written
/// (their descriptors carry every setting, filling absent ones with defaults), so strict export /// (their descriptors carry every setting, filling absent ones with defaults), so strict export
/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export. /// refuses it instead of replaying an edit that also resets the rest. Complete ones still export.
+73 -1
View File
@@ -416,7 +416,7 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
*l = l.duplicate(); *l = l.duplicate();
} }
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?; crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy); crate::layer_copy_cmds::translate_copies(&mut copies, &d.doc, dx, dy);
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect(); let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584). // A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
let mut linked = Vec::new(); let mut linked = Vec::new();
@@ -1932,6 +1932,78 @@ mod tests {
assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
} }
#[test]
fn cross_document_group_paste_renders_shapes_on_destination_canvas() {
for (copy_to_document, depth) in [(false, 8), (true, 16)] {
let mut s = Session::new();
s.execute("file.new", json!({"width":100,"height":100})).unwrap();
let mut ids = Vec::new();
for x in [10, 50] {
s.execute("shape.create", json!({"kind":"rect","rect":[x,20,20,30],"fill":"#ff0000"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
s.active_mut().unwrap().selected_layers = ids;
s.execute("layer.groupLayers", json!({})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width":800,"height":600,"depth":depth})).unwrap();
if copy_to_document {
s.execute("layer.copyToDocument", json!({"source":0,"document":1,"center":true})).unwrap();
} else {
s.execute("edit.paste", json!({"center":[400,300]})).unwrap();
}
let st = s.active().unwrap();
let group = st.doc.layer(st.active_layer.unwrap()).unwrap();
assert!(!crate::transform_cmds::transform_bounds(&st.doc, group).is_empty());
assert_eq!(group.children().unwrap().len(), 2);
for child in group.children().unwrap() {
let LayerContent::Shape(shape) = &child.content else { panic!("shape") };
let cache = shape.cache.as_ref().unwrap();
assert_eq!(cache.format().sample, st.doc.depth);
assert!(!cache.content_bounds().is_empty(), "destination cache must not be clipped to source canvas");
assert!(cache.content_bounds().x0 > 100);
let fresh = photocraft_vector::render_shape(shape, st.doc.pixel_format(), st.doc.bounds());
assert_eq!(cache, &fresh);
}
let before = st.doc.clone();
s.execute("layer.translate", json!({"dx":10,"dy":10})).unwrap();
assert_ne!(s.active().unwrap().doc, before);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
#[test]
fn paste_repairs_uncached_shapes_before_transform_and_effect_rendering() {
let mut s = session();
s.execute("shape.create", json!({"kind":"rect","rect":[10,10,40,20],"fill":"#ff0000"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let layers = s.clipboard.as_mut().unwrap().layers.as_mut().unwrap();
let LayerContent::Shape(sh) = &mut layers.layers[0].content else { panic!("shape") };
sh.cache = None;
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let st = s.active().unwrap();
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap();
let LayerContent::Shape(sh) = &layer.content else { panic!("shape") };
assert!(sh.cache.is_some());
assert!(!crate::transform_cmds::transform_bounds(&st.doc, layer).is_empty());
let expected = photocraft_vector::render_shape(sh, st.doc.pixel_format(), st.doc.bounds());
assert_eq!(sh.cache.as_ref().unwrap(), &expected);
let rendered = photocraft_compose::render_layer_in(&st.doc, layer, st.doc.bounds());
let mut fresh_doc = (*st.doc).clone();
let snapshot = fresh_doc.clone();
if let LayerContent::Shape(shape) = &mut fresh_doc.layer_mut(layer.id).unwrap().content {
crate::vector_cmds::refresh_shape(&snapshot, shape);
}
let fresh = photocraft_compose::render_layer_in(&fresh_doc, fresh_doc.layer(layer.id).unwrap(), fresh_doc.bounds());
assert_eq!(rendered.px, fresh.px, "pasted effects match a freshly rebuilt shape");
assert!(s.undo());
assert!(s.redo());
let st = s.active().unwrap();
assert!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().is_some());
}
/// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy
/// takes them whole and Paste recreates them, in the same or another document, in one step. /// takes them whole and Paste recreates them, in the same or another document, in one step.
#[test] #[test]
+27 -6
View File
@@ -11,7 +11,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use photocraft_color::ColorMode; use photocraft_color::ColorMode;
use photocraft_doc::{Document, LayerContent, LayerId, SmartContentsId}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId};
use photocraft_geom::Rect; use photocraft_geom::Rect;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -59,6 +59,17 @@ fn center(r: Rect) -> [f64; 2] {
[(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0] [(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0]
} }
/// Restore missing shape caches, or refresh every shape for a changed canvas, including groups.
fn prepare_shapes(layers: &mut [Layer], doc: &Document, new_canvas: bool) {
for layer in layers {
match &mut layer.content {
LayerContent::Shape(shape) if new_canvas || shape.cache.is_none() => crate::vector_cmds::refresh_shape(doc, shape),
LayerContent::Group(group) => prepare_shapes(&mut group.children, doc, new_canvas),
_ => {}
}
}
}
/// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for /// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for
/// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the /// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the
/// batch (contents ids belong to a document, so the copies never join objects already in `dest`, /// batch (contents ids belong to a document, so the copies never join objects already in `dest`,
@@ -107,13 +118,20 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document)
*surf = surf.convert(f); *surf = surf.convert(f);
}); });
} }
let new_canvas = copies.bounds() != dest.bounds();
prepare_shapes(&mut copies.layers, dest, new_canvas);
Ok(()) Ok(())
} }
/// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement). /// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement).
pub(crate) fn translate_copies(copies: &mut Document, dx: i32, dy: i32) { pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx: i32, dy: i32) {
if dx != 0 || dy != 0 { if dx != 0 || dy != 0 {
let snapshot = copies.clone(); let mut snapshot = copies.clone();
// Vector caches are clipped and quantized for the destination, not the clipboard's source.
snapshot.size = destination.size;
snapshot.mode = destination.mode;
snapshot.depth = destination.depth;
snapshot.icc_profile = destination.icc_profile.clone();
for l in &mut copies.layers { for l in &mut copies.layers {
crate::commands::translate_layer(&snapshot, l, dx, dy); crate::commands::translate_layer(&snapshot, l, dx, dy);
crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy)); crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));
@@ -172,11 +190,14 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) { if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) {
l.name = name.to_string(); l.name = name.to_string();
} }
// Measure in source space: destination refresh may clip a shape before it is moved.
// Missing caches must participate too, including shapes nested inside groups.
prepare_shapes(&mut copies.layers, &sdoc, false);
let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b));
let content = if bounds.is_empty() { sdoc.bounds() } else { bounds };
adapt_copies(s, &mut copies, &ddoc)?; adapt_copies(s, &mut copies, &ddoc)?;
// Placement: centred on the destination's canvas, centred on a point, or offset from where // Placement: centred on the destination's canvas, centred on a point, or offset from where
// the layers are in the source (default: the same canvas position). // the layers are in the source (default: the same canvas position).
let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b));
let content = if bounds.is_empty() { sdoc.bounds() } else { bounds };
let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) { let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) {
let (c, d) = (center(content), center(ddoc.bounds())); let (c, d) = (center(content), center(ddoc.bounds()));
(d[0] - c[0], d[1] - c[1]) (d[0] - c[0], d[1] - c[1])
@@ -187,7 +208,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1])) point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1]))
}; };
let (dx, dy) = (dx.round() as i32, dy.round() as i32); let (dx, dy) = (dx.round() as i32, dy.round() as i32);
translate_copies(&mut copies, dx, dy); translate_copies(&mut copies, &ddoc, dx, dy);
let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect(); let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect();
s.set_active(dest); s.set_active(dest);
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" }; let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
+21 -1
View File
@@ -250,7 +250,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Transform Selection", label: "Transform Selection",
menu: &["Select"], menu: &["Select"],
shortcut: None, shortcut: None,
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest"="bilinear"}"##, params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##,
enabled: has_selection, enabled: has_selection,
run: transform_selection, run: transform_selection,
journal: true, journal: true,
@@ -297,6 +297,26 @@ mod tests {
s s
} }
/// #8697: Photoshop's bicubic variants are accepted and render as Bicubic, for a selection
/// and for a layer (an imported action keeps them).
#[test]
fn photoshop_bicubic_variants_transform_as_bicubic() {
let run = |id: &str, interpolation: &str| {
let mut s = landscape(8);
s.execute("select.rect", json!({"x": 20, "y": 10, "width": 60, "height": 40, "feather": 3})).unwrap();
s.execute(id, json!({"scaleX": 137, "scaleY": 81, "rotate": 7, "interpolation": interpolation})).unwrap();
let st = s.active().unwrap();
(st.doc.selection.clone(), st.doc.layers.iter().map(|l| l.surface().cloned()).collect::<Vec<_>>())
};
for id in ["select.transformSelection", "edit.transform"] {
let bicubic = run(id, "bicubic");
assert_ne!(bicubic, run(id, "bilinear"), "{id}: the interpolation matters here");
for variant in ["bicubicSharper", "bicubicSmoother", "bicubicAutomatic"] {
assert_eq!(run(id, variant), bicubic, "{id} {variant}");
}
}
}
#[test] #[test]
fn sky_selects_the_sky_at_every_depth() { fn sky_selects_the_sky_at_every_depth() {
for depth in [8, 16, 32] { for depth in [8, 16, 32] {
+1 -1
View File
@@ -859,7 +859,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Free Transform", label: "Free Transform",
menu: &[], menu: &[],
shortcut: None, shortcut: None,
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
enabled: has_layer, enabled: has_layer,
journal: true, journal: true,
run: transform, run: transform,
+2 -1
View File
@@ -506,7 +506,8 @@ pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64)
let canvas = doc.bounds(); let canvas = doc.bounds();
let inside = sh.cache.as_ref().is_some_and(|c| { let inside = sh.cache.as_ref().is_some_and(|c| {
let b = photocraft_compose::bounds::content_bounds(c); let b = photocraft_compose::bounds::content_bounds(c);
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) // An empty cache can be an off-canvas shape moving back into view.
!b.is_empty() && b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1
}); });
let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil()));
let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| {
@@ -0,0 +1,74 @@
//! Copy placement must use source-space bounds before destination cache clipping.
use photocraft_doc::LayerContent;
use photocraft_engine::Session;
use serde_json::json;
/// Cover source clipping, groups, missing caches, destination depth, placement and Undo/Redo.
#[test]
fn copying_shapes_to_smaller_documents_preserves_requested_center() {
for x in [90, 700] {
for grouped in [false, true] {
for missing_cache in [false, true] {
for depth in [8, 16] {
for at in [None, Some([40, 60])] {
let mut s = Session::new();
s.execute("file.new", json!({"width":800,"height":600,"background":"transparent"})).unwrap();
let mut ids = Vec::new();
for offset in if grouped { vec![0, 30] } else { vec![0] } {
s.execute("shape.create", json!({"kind":"rect","rect":[x+offset,20,20,20],"fill":"#ff0000"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
if grouped {
s.active_mut().unwrap().selected_layers = ids.clone();
s.execute("layer.groupLayers", json!({})).unwrap();
}
if missing_cache {
s.edit("drop cached shapes", |doc, _| {
for id in &ids {
let LayerContent::Shape(shape) = &mut doc.layer_mut(*id).unwrap().content else { panic!("shape") };
shape.cache = None;
}
Ok(())
})
.unwrap();
}
s.execute("file.new", json!({"width":100,"height":100,"depth":depth,"background":"transparent"})).unwrap();
let before = s.active().unwrap().doc.clone();
let mut params = json!({"source":0,"document":1,"center":true});
if let Some(at) = at {
params["center"] = json!(false);
params["at"] = json!(at);
}
let result = s.execute("layer.copyToDocument", params).unwrap();
let target = at.unwrap_or([50, 50]);
let dx = target[0] - (x + if grouped { 25 } else { 10 });
let dy = target[1] - 30;
assert_eq!(result["offset"], json!([dx, dy]), "x={x}, grouped={grouped}, cache missing={missing_cache}");
let st = s.active().unwrap();
let shapes: Vec<_> = st
.doc
.walk()
.into_iter()
.filter_map(|(_, _, layer)| if let LayerContent::Shape(shape) = &layer.content { Some(shape) } else { None })
.collect();
assert_eq!(shapes.len(), if grouped { 2 } else { 1 });
for shape in shapes {
let fresh = photocraft_vector::render_shape(shape, st.doc.pixel_format(), st.doc.bounds());
assert!(
shape.cache.as_ref() == Some(&fresh),
"cache mismatch: x={x}, grouped={grouped}, cache missing={missing_cache}, depth={depth}, at={at:?}"
);
assert!(!fresh.content_bounds().is_empty());
}
let after = st.doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
}
}
}
}
}
}
+1
View File
@@ -14,6 +14,7 @@ mod clipping_semantics;
mod close_document; mod close_document;
mod command_lookup; mod command_lookup;
mod convert_keeps_references; mod convert_keeps_references;
mod copy_shape_placement;
mod corpus_atn; mod corpus_atn;
mod crop_content_aware; mod crop_content_aware;
mod current_affinity_artboards; mod current_affinity_artboards;
+46
View File
@@ -450,6 +450,12 @@ fn read_lookup(d: &[u8]) -> Result<Adjustment, String> {
_ => ("x.cube", "CUBE"), _ => ("x.cube", "CUBE"),
}; };
let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?; let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?;
if !lut.domain_is_default() {
return Err(format!(
"its LUT has a non-default input domain (DOMAIN_MIN {:?}, DOMAIN_MAX {:?}), which is not supported yet",
lut.domain_min, lut.domain_max
));
}
let name = desc_text(&desc, "LUT3DFileName").or_else(|| desc_text(&desc, "NM ")).unwrap_or_default(); let name = desc_text(&desc, "LUT3DFileName").or_else(|| desc_text(&desc, "NM ")).unwrap_or_default();
Ok(Adjustment::ColorLookup { Ok(Adjustment::ColorLookup {
name, name,
@@ -1112,6 +1118,46 @@ mod tests {
assert!(matches!(parse(b"selc", &[0, 1, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"selc", &[0, 1, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. }));
} }
fn cube_lookup_block(cube: &[u8]) -> Vec<u8> {
let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) };
let desc = Descriptor::new("null")
.with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE"))
.with("LUT3DFileName", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("domain.cube")))
.with("LUT3DFileData", Value::RawData(cube.to_vec()));
let mut block = vec![0, 1];
block.extend(VersionedDescriptor::new(desc).to_bytes());
block
}
fn identity_cube(domain: &str) -> Vec<u8> {
format!("{domain}LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n").into_bytes()
}
#[test]
fn color_lookup_custom_domains_are_kept_unsupported() {
let domains = ["DOMAIN_MIN 0 0 0\nDOMAIN_MAX 0.5 0.5 0.5\n", "DOMAIN_MIN 0 0.25 0\nDOMAIN_MAX 1 0.75 1\n", "LUT_3D_INPUT_RANGE 0 0.5\n"];
for domain in domains {
let raw = cube_lookup_block(&identity_cube(domain));
let Adjustment::Unsupported { psd_key, raw: kept } = parse(b"clrL", &raw, None, Channels::Rgb) else {
panic!("custom LUT domain was applied instead of kept unsupported: {domain}")
};
assert_eq!(psd_key, "clrL");
assert_eq!(*kept, *raw);
let reason = unreadable_reason(b"clrL", &raw).expect("custom domain has an explanation");
assert!(reason.contains("DOMAIN_MIN") && reason.contains("DOMAIN_MAX"), "{reason}");
assert!(reason.to_ascii_lowercase().contains("not supported"), "{reason}");
}
}
#[test]
fn color_lookup_default_domains_stay_supported() {
for domain in ["", "DOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n"] {
let raw = cube_lookup_block(&identity_cube(domain));
assert!(matches!(parse(b"clrL", &raw, None, Channels::Rgb), Adjustment::ColorLookup { size: 2, .. }));
assert_eq!(unreadable_reason(b"clrL", &raw), None);
}
}
#[test] #[test]
fn malformed_falls_back_to_unsupported() { fn malformed_falls_back_to_unsupported() {
assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. }));
+101
View File
@@ -6,6 +6,7 @@ use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
use photocraft_geom::Size; use photocraft_geom::Size;
use photocraft_io::*; use photocraft_io::*;
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
use photocraft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen};
use photocraft_raster::Surface; use photocraft_raster::Surface;
fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec<String>) { fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec<String>) {
@@ -48,6 +49,10 @@ fn readable_adjustments_open_without_warnings() {
/// cyan, so the table is not the identity). /// cyan, so the table is not the identity).
fn photoshop_clr_l_block() -> Vec<u8> { fn photoshop_clr_l_block() -> Vec<u8> {
let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
photoshop_clr_l_block_from_cube(cube)
}
fn photoshop_clr_l_block_from_cube(cube: &[u8]) -> Vec<u8> {
let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) }; let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) };
let text = |t: &str| Value::Text(UnicodeString::new_nul(t)); let text = |t: &str| Value::Text(UnicodeString::new_nul(t));
let desc = Descriptor::new("null") let desc = Descriptor::new("null")
@@ -61,6 +66,46 @@ fn photoshop_clr_l_block() -> Vec<u8> {
raw raw
} }
fn identity_cube(domain: &str) -> Vec<u8> {
format!("{domain}LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n").into_bytes()
}
fn synthetic_lookup_psd(version: Version, depth: u16, cube: &[u8]) -> Vec<u8> {
const LAYER_NAME: &str = "Custom-domain LUT";
let mut file = testgen::layered(version, photocraft_psd::ColorMode::Rgb, depth, Compression::Rle);
let mut flags = LayerFlags::default();
flags.set_pixel_data_irrelevant(true);
// Photoshop stores layer-block alignment inside the block length. Make the source payload
// canonical so the exporter can preserve it exactly rather than adding alignment on save.
let mut clrl = photoshop_clr_l_block_from_cube(cube);
clrl.resize(clrl.len().next_multiple_of(2), 0);
let record = LayerRecord {
name: LAYER_NAME.as_bytes().to_vec(),
flags,
blocks: vec![TaggedBlock::new(*b"clrL", clrl), TaggedBlock::unicode_name(LAYER_NAME), TaggedBlock::layer_id(9876)],
..Default::default()
};
file.layers_mut().insert(0, record);
file.to_bytes().expect("serialize synthetic PSD/PSB")
}
fn unsupported_lookup_raw(doc: &Document) -> &[u8] {
let layer = doc.layers.iter().find(|layer| layer.name == "Custom-domain LUT").expect("lookup layer");
let LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) = &layer.content else {
panic!("expected preserved unsupported Color Lookup, got {:?}", layer.content)
};
assert_eq!(psd_key, "clrL");
raw
}
fn parsed_lookup_block(bytes: &[u8]) -> (String, Vec<u8>) {
let file = PsdFile::from_bytes(bytes).expect("parse PSD/PSB");
let layer = file.layers().iter().find(|layer| layer.name() == "Custom-domain LUT").expect("lookup layer record");
let blocks: Vec<_> = layer.blocks.iter().filter(|block| block.key == *b"clrL").collect();
assert_eq!(blocks.len(), 1, "lookup blocks on layer: {blocks:?}");
(layer.name(), blocks[0].data.clone())
}
#[test] #[test]
fn photoshop_layout_color_lookup_round_trips_through_psb() { fn photoshop_layout_color_lookup_round_trips_through_psb() {
// A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a // A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a
@@ -109,3 +154,59 @@ fn short_color_lookup_table_warns_on_open() {
let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default()); let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default());
assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}");
} }
#[test]
fn custom_domain_lookup_warns_and_round_trips_verbatim() {
let domains = ["DOMAIN_MIN 0 0 0\nDOMAIN_MAX 0.5 0.5 0.5\n", "DOMAIN_MIN 0 0.25 0\nDOMAIN_MAX 1 0.75 1\n", "LUT_3D_INPUT_RANGE 0 0.5\n"];
for domain in domains {
let cube = identity_cube(domain);
for (version, depth, extension) in [
(Version::Psd, 8, "psd"),
(Version::Psd, 16, "psd"),
(Version::Psd, 32, "psd"),
(Version::Psb, 8, "psb"),
(Version::Psb, 16, "psb"),
(Version::Psb, 32, "psb"),
] {
let input = synthetic_lookup_psd(version, depth, &cube);
let (_, source_block) = parsed_lookup_block(&input);
let name = format!("input.{extension}");
let opened = import(&name, &input).expect("open synthetic PSD/PSB");
assert_eq!(unsupported_lookup_raw(&opened.document), source_block, "{extension}, {depth}-bit, {domain:?}");
let warnings: Vec<_> = opened.warnings.iter().filter(|warning| warning.contains("Custom-domain LUT")).collect();
assert_eq!(warnings.len(), 1, "{extension}, {depth}-bit: {:?}", opened.warnings);
assert!(
warnings[0].contains("Color Lookup")
&& warnings[0].contains("DOMAIN_MIN")
&& warnings[0].contains("DOMAIN_MAX")
&& warnings[0].to_ascii_lowercase().contains("not supported"),
"{}",
warnings[0]
);
let native = export(&opened.document, "preserved.pcraft", &ExportOptions::default()).expect("save native document");
let native_open = import("preserved.pcraft", &native.bytes).expect("reopen native document");
assert_eq!(unsupported_lookup_raw(&native_open.document), source_block, "native preservation, {extension}, {depth}-bit");
let output_name = format!("roundtrip.{extension}");
let options = ExportOptions { force_psb: version.is_psb(), ..Default::default() };
let saved = export(&native_open.document, &output_name, &options).expect("re-export PSD/PSB");
let (_, output_block) = parsed_lookup_block(&saved.bytes);
assert_eq!(output_block, source_block, "saved block, {extension}, {depth}-bit, {domain:?}");
let reopened = import(&output_name, &saved.bytes).expect("reopen re-exported PSD/PSB");
assert_eq!(unsupported_lookup_raw(&reopened.document), source_block, "reopened block, {extension}, {depth}-bit");
}
}
}
#[test]
fn default_domain_lookup_stays_readable() {
for (version, domain, extension) in [(Version::Psd, "", "psd"), (Version::Psb, "DOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n", "psb")] {
let cube = identity_cube(domain);
let input = synthetic_lookup_psd(version, 8, &cube);
let opened = import(&format!("default.{extension}"), &input).expect("open default-domain LUT");
let layer = opened.document.layers.iter().find(|layer| layer.name == "Custom-domain LUT").expect("lookup layer");
assert!(matches!(layer.content, LayerContent::Adjustment(Adjustment::ColorLookup { size: 2, .. })), "{:?}", layer.content);
assert!(opened.warnings.iter().all(|warning| !warning.contains("Custom-domain LUT")), "{:?}", opened.warnings);
}
}
+109 -76
View File
@@ -323,56 +323,31 @@ pub struct Reply {
pub attributes: Vec<Attribute>, pub attributes: Vec<Attribute>,
} }
/// The attribute range for one group delimiter in a parsed message. Ranges are half-open and
/// include empty groups, so repeated group tags remain distinct.
#[derive(Debug, Clone, Copy)]
struct GroupRange {
tag: u8,
start: usize,
end: usize,
}
/// Parsed message details kept private so the public flat `Reply` API stays source-compatible.
struct ParsedMessage {
reply: Reply,
end: usize,
groups: Vec<GroupRange>,
}
impl Reply { impl Reply {
/// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag. /// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag.
pub fn parse(b: &[u8]) -> Result<Reply> { pub fn parse(b: &[u8]) -> Result<Reply> {
Self::parse_at(b).map(|(r, _)| r) parse_message(b).map(|parsed| parsed.reply)
} }
/// Parses a message and returns where its attributes end (document data may follow). /// Parses a message and returns where its attributes end (document data may follow).
fn parse_at(b: &[u8]) -> Result<(Reply, usize)> { fn parse_at(b: &[u8]) -> Result<(Reply, usize)> {
let bad = |m: &str| Error::Protocol(m.to_string()); parse_message(b).map(|parsed| (parsed.reply, parsed.end))
if b.len() < 9 {
return Err(bad("reply shorter than an IPP header"));
}
let status = u16::from_be_bytes([b[2], b[3]]);
let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]);
let mut pos = 8;
let mut group = 0u8;
let mut attributes: Vec<Attribute> = Vec::new();
loop {
let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?;
pos += 1;
if tag == TAG_END {
return Ok((Reply { status, request_id, attributes }, pos));
}
if tag < 0x10 {
group = tag;
continue;
}
let name_len =
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
pos += 2;
let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?;
pos += name_len;
let value_len =
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
pos += 2;
let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec();
pos += value_len;
if name_len == 0 {
// An additional value of the previous attribute.
match attributes.last_mut() {
Some(last) if last.group == group => last.values.push(value),
_ => return Err(bad("an additional value with no attribute before it")),
}
} else {
attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] });
}
if attributes.len() > MAX_ATTRIBUTES {
return Err(bad("too many attributes"));
}
}
} }
/// The server's own message for an error status, or a name for the code. /// The server's own message for an error status, or a name for the code.
@@ -394,8 +369,72 @@ impl Reply {
} }
} }
/// POSTs `body` (IPP, with any document data after it) to `path` and returns the reply. /// Parses one IPP message while preserving each group instance for private consumers that need
fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<Reply> { /// object boundaries. The public reply remains a flat list of attributes.
fn parse_message(b: &[u8]) -> Result<ParsedMessage> {
let bad = |m: &str| Error::Protocol(m.to_string());
if b.len() < 9 {
return Err(bad("reply shorter than an IPP header"));
}
let status = u16::from_be_bytes([b[2], b[3]]);
let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]);
let mut pos = 8;
let mut group = 0u8;
let mut active_group = None;
let mut last_attribute_group = None;
let mut attributes: Vec<Attribute> = Vec::new();
let mut groups: Vec<GroupRange> = Vec::new();
loop {
let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?;
pos += 1;
if tag == TAG_END {
if let Some(last) = groups.last_mut() {
last.end = attributes.len();
}
return Ok(ParsedMessage { reply: Reply { status, request_id, attributes }, end: pos, groups });
}
if tag < 0x10 {
if let Some(last) = groups.last_mut() {
last.end = attributes.len();
}
if groups.len() >= MAX_ATTRIBUTES {
return Err(bad("too many attribute groups"));
}
group = tag;
let group_index = groups.len();
groups.push(GroupRange { tag, start: attributes.len(), end: attributes.len() });
active_group = Some(group_index);
continue;
}
let name_len =
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
pos += 2;
let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?;
pos += name_len;
let value_len =
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
pos += 2;
let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec();
pos += value_len;
if name_len == 0 {
// Additional values can continue an attribute only within this exact group instance.
match attributes.last_mut() {
Some(last) if last_attribute_group == active_group => last.values.push(value),
_ => return Err(bad("an additional value with no attribute before it")),
}
} else {
attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] });
last_attribute_group = active_group;
}
if attributes.len() > MAX_ATTRIBUTES {
return Err(bad("too many attributes"));
}
}
}
/// POSTs `body` (IPP, with any document data after it) to `path` and returns the parsed reply with
/// its private group boundaries.
fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<ParsedMessage> {
let mut stream = endpoint.connect()?; let mut stream = endpoint.connect()?;
let head = format!( let head = format!(
"POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", "POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -417,9 +456,9 @@ fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<Reply> {
})); }));
} }
let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() }; let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() };
let reply = Reply::parse(&body)?; let reply = parse_message(&body)?;
if reply.status >= 0x0400 { if reply.reply.status >= 0x0400 {
return Err(Error::Status(reply.status, reply.status_message())); return Err(Error::Status(reply.reply.status, reply.reply.status_message()));
} }
Ok(reply) Ok(reply)
} }
@@ -563,41 +602,35 @@ pub fn printers(endpoint: &Endpoint) -> Result<Vec<Printer>> {
fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result<Vec<Printer>> { fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result<Vec<Printer>> {
let mut r = Request::new(op, 1); let mut r = Request::new(op, 1);
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]); r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]);
let reply = match post(endpoint, "/", &r.end()) { let parsed = match post(endpoint, "/", &r.end()) {
Ok(reply) => reply, Ok(reply) => reply,
// CUPS answers "not found" ("No destinations added.") when no printer is set up. // CUPS answers "not found" ("No destinations added.") when no printer is set up.
Err(Error::Status(0x0406, _)) => return Ok(Vec::new()), Err(Error::Status(0x0406, _)) => return Ok(Vec::new()),
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
let mut out = Vec::new(); let mut out = Vec::new();
let mut current: Option<Printer> = None; let attributes = &parsed.reply.attributes;
for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) { for group in parsed.groups.iter().filter(|group| group.tag == TAG_PRINTER_GROUP) {
match a.name.as_str() { let Some(group_attributes) = attributes.get(group.start..group.end) else {
"printer-name" => { return Err(Error::Protocol("invalid printer attribute group range".into()));
if let Some(p) = current.take() { };
out.push(p); let mut name = None;
} let mut info = String::new();
current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class }); let mut state = 3;
let mut accepting_jobs = true;
for a in group_attributes {
match a.name.as_str() {
"printer-name" if name.is_none() => name = Some(a.text()),
"printer-info" => info = a.text(),
"printer-state" => state = a.int().unwrap_or(3).clamp(0, 5) as u32,
"printer-is-accepting-jobs" => accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)),
_ => {}
} }
"printer-info" => { }
if let Some(p) = &mut current { if let Some(name) = name.filter(|name| !name.is_empty()) {
p.info = a.text(); out.push(Printer { name, info, state, accepting_jobs, is_default: false, is_class });
}
}
"printer-state" => {
if let Some(p) = &mut current {
p.state = a.int().unwrap_or(3).clamp(0, 5) as u32;
}
}
"printer-is-accepting-jobs" => {
if let Some(p) = &mut current {
p.accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0));
}
}
_ => {}
} }
} }
out.extend(current);
Ok(out) Ok(out)
} }
@@ -606,7 +639,7 @@ pub fn default_printer(endpoint: &Endpoint) -> Result<Option<String>> {
let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2); let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2);
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]); r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]);
match post(endpoint, "/", &r.end()) { match post(endpoint, "/", &r.end()) {
Ok(reply) => Ok(reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())), Ok(reply) => Ok(reply.reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())),
// CUPS answers "not found" when no default is set. // CUPS answers "not found" when no default is set.
Err(Error::Status(0x0406, _)) => Ok(None), Err(Error::Status(0x0406, _)) => Ok(None),
Err(e) => Err(e), Err(e) => Err(e),
@@ -642,7 +675,7 @@ pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result<u32> {
} }
let mut body = r.end(); let mut body = r.end();
body.extend_from_slice(job.document); body.extend_from_slice(job.document);
let reply = post(endpoint, &path, &body)?; let reply = post(endpoint, &path, &body)?.reply;
reply reply
.attributes .attributes
.iter() .iter()
+155
View File
@@ -137,6 +137,161 @@ fn printers_are_listed_with_state_and_default() {
} }
} }
#[test]
fn printer_attributes_before_name_are_preserved() {
let state = 5u32.to_be_bytes();
let base: [Attr<'_>; 4] = [
(TAG_TEXT, "printer-info", &[b"Studio Printer"]),
(TAG_ENUM, "printer-state", &[&state]),
(TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]),
(TAG_NAME, "printer-name", &[b"studio"]),
];
let body = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &base)]);
for chunked in [false, true] {
let (ep, _) = fake(body.clone(), chunked, 200);
let list = printers(&ep).unwrap();
assert_eq!(list.len(), 1, "chunked {chunked}");
assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state, list[0].accepting_jobs), ("studio", "Studio Printer", 5, false));
assert!(!list[0].is_class);
}
}
#[test]
fn printer_groups_do_not_share_metadata() {
let state_a = 4u32.to_be_bytes();
let state_b = 5u32.to_be_bytes();
let body = ipp(
0,
&[
ok_header(),
(
TAG_PRINTER_GROUP,
&[(TAG_NAME, "printer-name", &[b"first"]), (TAG_TEXT, "printer-info", &[b"First printer"]), (TAG_ENUM, "printer-state", &[&state_a])],
),
(
TAG_PRINTER_GROUP,
&[(TAG_TEXT, "printer-info", &[b"Second printer"]), (TAG_ENUM, "printer-state", &[&state_b]), (TAG_NAME, "printer-name", &[b"second"])],
),
],
);
let (ep, _) = fake(body, false, 200);
let list = queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap();
assert_eq!(list.len(), 2);
assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state), ("first", "First printer", 4));
assert_eq!((list[1].name.as_str(), list[1].info.as_str(), list[1].state), ("second", "Second printer", 5));
}
#[test]
fn printer_attribute_order_does_not_change_results() {
let state = 5u32.to_be_bytes();
let fields: [Attr<'_>; 4] = [
(TAG_TEXT, "printer-info", &[b"Studio Printer"]),
(TAG_ENUM, "printer-state", &[&state]),
(TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]),
(TAG_NAME, "printer-name", &[b"studio"]),
];
let expected = Printer { name: "studio".into(), info: "Studio Printer".into(), state: 5, accepting_jobs: false, is_default: false, is_class: false };
let mut permutations = 0;
for first in 0..4 {
for second in 0..4 {
for third in 0..4 {
for fourth in 0..4 {
let order = [first, second, third, fourth];
if order[0] == order[1]
|| order[0] == order[2]
|| order[0] == order[3]
|| order[1] == order[2]
|| order[1] == order[3]
|| order[2] == order[3]
{
continue;
}
permutations += 1;
let ordered = order.iter().map(|index| fields[*index]).collect::<Vec<_>>();
let body = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &ordered)]);
let (ep, _) = fake(body, false, 200);
assert_eq!(queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap(), vec![expected.clone()], "order {order:?}");
}
}
}
}
assert_eq!(permutations, 24);
}
#[test]
fn class_attributes_before_name_are_preserved() {
let state = 5u32.to_be_bytes();
let body = ipp(
0,
&[
ok_header(),
(
TAG_PRINTER_GROUP,
&[
(TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]),
(TAG_TEXT, "printer-info", &[b"Shared studio"]),
(TAG_ENUM, "printer-state", &[&state]),
(TAG_NAME, "printer-name", &[b"studio-class"]),
],
),
],
);
let (ep, rx) = fake(body, true, 200);
let classes = queues(&ep, OP_CUPS_GET_CLASSES, true).unwrap();
assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_CLASSES);
assert_eq!(classes.len(), 1);
assert_eq!(
(classes[0].name.as_str(), classes[0].info.as_str(), classes[0].state, classes[0].accepting_jobs, classes[0].is_class),
("studio-class", "Shared studio", 5, false, true)
);
}
#[test]
fn empty_or_unnamed_groups_do_not_leak_metadata() {
let body = ipp(
0,
&[
ok_header(),
(TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"named"])]),
(TAG_PRINTER_GROUP, &[]),
(TAG_PRINTER_GROUP, &[(TAG_TEXT, "printer-info", &[b"orphan metadata"])]),
(TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"next"]), (TAG_ENUM, "printer-state", &[&3u32.to_be_bytes()])]),
],
);
let (ep, _) = fake(body, false, 200);
let list = queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap();
assert_eq!(list.len(), 2);
assert_eq!((list[0].name.as_str(), list[0].info.as_str()), ("named", ""));
assert_eq!((list[1].name.as_str(), list[1].info.as_str()), ("next", ""));
}
#[test]
fn additional_values_do_not_cross_group_boundaries() {
let valid = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_KEYWORD, "supported", &[b"one", b"two"])])]);
let reply = Reply::parse(&valid).unwrap();
assert_eq!(reply.attributes.iter().find(|a| a.name == "supported").map(|a| a.values.as_slice()), Some(&[b"one".to_vec(), b"two".to_vec()][..]));
let mut body = vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_PRINTER_GROUP];
body.extend_from_slice(&[TAG_NAME, 0, 12]);
body.extend_from_slice(b"printer-name");
body.extend_from_slice(&[0, 5]);
body.extend_from_slice(b"first");
body.extend_from_slice(&[TAG_TEXT, 0, 12]);
body.extend_from_slice(b"printer-info");
body.extend_from_slice(&[0, 6]);
body.extend_from_slice(b"before");
body.push(TAG_PRINTER_GROUP);
body.extend_from_slice(&[TAG_TEXT, 0, 0, 0, 5]);
body.extend_from_slice(b"after");
body.extend_from_slice(&[TAG_NAME, 0, 12]);
body.extend_from_slice(b"printer-name");
body.extend_from_slice(&[0, 6]);
body.extend_from_slice(b"second");
body.push(TAG_END);
let (ep, _) = fake(body, true, 200);
assert!(matches!(queues(&ep, OP_CUPS_GET_PRINTERS, false), Err(Error::Protocol(_))));
}
#[test] #[test]
fn default_printer_is_read_and_absence_is_not_an_error() { fn default_printer_is_read_and_absence_is_not_an_error() {
let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200); let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200);
+27
View File
@@ -279,6 +279,33 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields))
} }
/// Opens the dialog for `command` at the settings last confirmed in it (Photoshop's ⌥ plus the
/// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they
/// don't fit the current colour mode.
pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
let id = open(app, command)?;
let kind = command.strip_prefix(PREFIX).unwrap_or_default();
let mode = curve_mode(app, kind);
let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) };
let Ok(adjustment) = photocraft_engine::adjust_params::from_params(kind, &last, None, mode) else { return Some(id) };
if let (Some(dialog), Value::Object(values)) = (app.ui.dialog_mut(id), photocraft_engine::adjust_params::to_params(&adjustment)) {
dialog.fields.retain(|k, _| k.starts_with("__"));
dialog.fields.extend(values);
}
Some(id)
}
/// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a
/// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`).
pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map<String, Value>) {
if fields.contains_key("__smartFilter") {
return;
}
let Some(command) = fields.get("__command").and_then(Value::as_str).filter(|c| has_dialog(c)) else { return };
let values = crate::filter_dialog::params_of(fields);
app.session.prefs.edit(|prefs| prefs.dialogs.insert(command.into(), values));
}
/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask /// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask
/// selection — both ways of selecting a mask must give the same dropdown (#5335). /// selection — both ways of selecting a mask must give the same dropdown (#5335).
fn isolated(app: &PhotocraftApp) -> Option<photocraft_engine::channel_cmds::IsolatedCurveChannel> { fn isolated(app: &PhotocraftApp) -> Option<photocraft_engine::channel_cmds::IsolatedCurveChannel> {
+5 -1
View File
@@ -859,7 +859,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
app.ui.view.guide_layout = params; app.ui.view.guide_layout = params;
} }
if result.is_ok() { if result.is_ok() {
crate::filter_dialog::remember(app, &cmd, &d.fields); if crate::adjust_dialog::owns(&d.fields) {
crate::adjust_dialog::remember(app, &d.fields);
} else {
crate::filter_dialog::remember(app, &cmd, &d.fields);
}
// A step dialog of a playing action: the action continues after it. // A step dialog of a playing action: the action continues after it.
crate::actions::resume(app, &d.fields)?; crate::actions::resume(app, &d.fields)?;
} else { } else {
+35 -35
View File
@@ -89,7 +89,7 @@
Batch… Toplu… Batch… Toplu…
Create Droplet… Damlacık Oluştur… Create Droplet… Damlacık Oluştur…
Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt
Contact Sheet II… İletişim Sayfası II… Contact Sheet II… Kontak Sayfası II…
Conditional Mode Change… Koşullu Mod Değişikliği… Conditional Mode Change… Koşullu Mod Değişikliği…
Fit Image… Görüntüyü Sığdır… Fit Image… Görüntüyü Sığdır…
Lens Correction… Mercek Düzeltme… Lens Correction… Mercek Düzeltme…
@@ -247,7 +247,7 @@
Threshold… Eşik… Threshold… Eşik…
Gradient Map… Degrade Eşlemesi… Gradient Map… Degrade Eşlemesi…
Selective Color… Seçici Renk… Selective Color… Seçici Renk…
Shadows/Highlights… Gölgeler/Vurgular… Shadows/Highlights… Gölgeler/Parlak Tonlar…
HDR Toning… HDR Tonlama… HDR Toning… HDR Tonlama…
Desaturate Doygunluğu Azalt Desaturate Doygunluğu Azalt
Match Color… Rengi Eşleştir… Match Color… Rengi Eşleştir…
@@ -410,7 +410,7 @@
Stamp Down Aşağıya Damgala Stamp Down Aşağıya Damgala
Flatten Image Görüntüyü Düzleştir Flatten Image Görüntüyü Düzleştir
Matting Matlama Matting Matlama
Color Decontaminate… Renkli Dekontamine… Color Decontaminate… Renkleri Arındır…
Remove Black Matte Siyah Matı Kaldır Remove Black Matte Siyah Matı Kaldır
Remove White Matte Beyaz Matı Kaldır Remove White Matte Beyaz Matı Kaldır
Defringe… Kenar Halesini Kaldır… Defringe… Kenar Halesini Kaldır…
@@ -438,7 +438,7 @@
Right Edges Sağ Kenarlar Right Edges Sağ Kenarlar
Distribute Dağıt Distribute Dağıt
Vertically Dikey Olarak Vertically Dikey Olarak
Horizontally Yatay olarak Horizontally Yatay Olarak
Layer Edges Katman Kenarları Layer Edges Katman Kenarları
Isolate Layers Katmanları Yalıt Isolate Layers Katmanları Yalıt
Layer Content Options… Katman İçerik Seçenekleri… Layer Content Options… Katman İçerik Seçenekleri…
@@ -777,9 +777,9 @@
Other… Diğer… Other… Diğer…
Remove Warp Split Çarpıtma Bölünmesini Kaldır Remove Warp Split Çarpıtma Bölünmesini Kaldır
Shape Şekil Shape Şekil
Split Warp Crosswise Çözgüyü Çapraz Olarak Böl Split Warp Crosswise Çarpıtmayı Çapraz Olarak Böl
Split Warp Horizontally Çözgüyü Yatay Olarak Böl Split Warp Horizontally Çarpıtmayı Yatay Olarak Böl
Split Warp Vertically Çözgüyü Dikey Olarak Böl Split Warp Vertically Çarpıtmayı Dikey Olarak Böl
Statistics… İstatistikler… Statistics… İstatistikler…
Refine Strokes Fırça Vuruşlarını Hassaslaştır Refine Strokes Fırça Vuruşlarını Hassaslaştır
Edits Düzenlemeler Edits Düzenlemeler
@@ -889,7 +889,7 @@
Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc) Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc)
Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc) Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc)
Cancel transform (Esc) Dönüşümü iptal et (Esc) Cancel transform (Esc) Dönüşümü iptal et (Esc)
Cancel warp (Esc) Çözgüyü iptal et (Esc) Cancel warp (Esc) Çarpıtmayı İptal Et (Esc)
Canvas Tuval Canvas Tuval
Canvas extension color: Tuval uzatma rengi: Canvas extension color: Tuval uzatma rengi:
Capture Yakala Capture Yakala
@@ -946,7 +946,7 @@
Commit warp ({key}) Çarpıtmayı onayla ({key}) Commit warp ({key}) Çarpıtmayı onayla ({key})
Constant Sabit Constant Sabit
Constrain proportions Oranları koru Constrain proportions Oranları koru
Contact Sheet II İletişim Sayfası II Contact Sheet II Kontak Sayfası II
Content-Aware İçeriğe Duyarlı Content-Aware İçeriğe Duyarlı
Contiguous Bitişik Contiguous Bitişik
Contrast Kontrast Contrast Kontrast
@@ -1056,7 +1056,7 @@
Dots Noktalar Dots Noktalar
Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın. Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın.
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın. Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift tuşunu kullanın; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın.
Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı
Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun. Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun.
@@ -1147,7 +1147,7 @@
Hide Properties Özellikleri Gizle Hide Properties Özellikleri Gizle
Highlight Compression Parlak Ton Sıkıştırma Highlight Compression Parlak Ton Sıkıştırma
Highlight Priority Parlak Ton Önceliği Highlight Priority Parlak Ton Önceliği
Highlights Öne Çıkanlar Highlights Parlak Tonlar
Home Ana sayfa Home Ana sayfa
Horizontal scale Yatay ölçek Horizontal scale Yatay ölçek
Hue Ton Hue Ton
@@ -1183,7 +1183,7 @@
Kerning Karakter Aralığı Kerning Karakter Aralığı
Keyboard Shortcuts Klavye Kısayolları Keyboard Shortcuts Klavye Kısayolları
Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler
Kind tür Kind Tür
Label Size Etiket Boyutu Label Size Etiket Boyutu
Labels Etiketler Labels Etiketler
Lanczos (sharp) Lanczos (keskin) Lanczos (sharp) Lanczos (keskin)
@@ -1219,7 +1219,7 @@
Lock: Kilit: Lock: Kilit:
Logical Length: Mantıksal Uzunluk: Logical Length: Mantıksal Uzunluk:
Logical Units: Mantıksal Birimler: Logical Units: Mantıksal Birimler:
Luminance parlaklık Luminance Lüminans
Luminance Detail Lüminans Ayrıntısı Luminance Detail Lüminans Ayrıntısı
Magenta Macenta Magenta Macenta
Magenta · Green Macenta · Yeşil Magenta · Green Macenta · Yeşil
@@ -1521,7 +1521,7 @@
Tracking (1/1000 em) İzleme (1/1000 em) Tracking (1/1000 em) İzleme (1/1000 em)
Transparency Saydamlık Transparency Saydamlık
Transparent Saydam Transparent Saydam
Trilinear Üç çizgili Trilinear Üç Doğrusal
Type Styles Metin Stilleri Type Styles Metin Stilleri
Type: Tür: Type: Tür:
Underline Altı çizili Underline Altı çizili
@@ -1644,7 +1644,7 @@
ExtraBold Ekstra Kalın ExtraBold Ekstra Kalın
ExtraLight Ekstra Hafif ExtraLight Ekstra Hafif
Eyedropper Tool Damlalık Aracı Eyedropper Tool Damlalık Aracı
Fade Solmaya Fade Solma
Fill Opacity Dolgu Opaklığı Fill Opacity Dolgu Opaklığı
Film & Video Film ve Video Film & Video Film ve Video
From Şuradan From Şuradan
@@ -1657,14 +1657,14 @@
Hard Sert Hard Sert
Hard Mix Sert Karışım Hard Mix Sert Karışım
Healing Brush Tool Düzeltme Fırçası Aracı Healing Brush Tool Düzeltme Fırçası Aracı
Highlights from: Öne çıkanlar: Highlights from: Parlak tonlar (başlangıç):
History Brush Tool Geçmiş Fırçası Aracı History Brush Tool Geçmiş Fırçası Aracı
Horizontal Type Tool Yatay Metin Aracı Horizontal Type Tool Yatay Metin Aracı
Horizontal Type Mask Tool Yatay Metin Maskesi Aracı Horizontal Type Mask Tool Yatay Metin Maskesi Aracı
Vertical Type Mask Tool Dikey Metin Maskesi Aracı Vertical Type Mask Tool Dikey Metin Maskesi Aracı
Image Size: Görüntü Boyutu: Image Size: Görüntü Boyutu:
Inches İnç Inches İnç
Indexed Dizine eklendi Indexed İndekslenmiş
Initial Direction Başlangıç Yönü Initial Direction Başlangıç Yönü
Inner Bevel İç Eğim Inner Bevel İç Eğim
Inner Glow İç Işıma Inner Glow İç Işıma
@@ -1676,7 +1676,7 @@
Lasso Kement Lasso Kement
Lasso Tool Kement Aracı Lasso Tool Kement Aracı
Layer Count Katman Sayısı Layer Count Katman Sayısı
Layer Knocks Out Drop Shadow Katman Düşen Gölgeyi Deler Layer Knocks Out Drop Shadow Katman Alt Gölgeyi Gizler
Legal Legal Legal Legal
Letter Letter Letter Letter
Light Hafif Light Hafif
@@ -2037,7 +2037,7 @@
Expand Genişlet Expand Genişlet
Expand Panel Group Panel Grubunu Genişlet Expand Panel Group Panel Grubunu Genişlet
Expand group ({key}-click: all groups) Grubu genişlet ({key}-tıklayın: tüm gruplar) Expand group ({key}-click: all groups) Grubu genişlet ({key}-tıklayın: tüm gruplar)
Fade steps Adımları soldur Fade steps Solma Adımı Sayısı
Hide the layer's effects ({key}-click: all layers) Katmanın efektlerini gizle ({key}-tıklama: tüm katmanlar) Hide the layer's effects ({key}-click: all layers) Katmanın efektlerini gizle ({key}-tıklama: tüm katmanlar)
Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr) Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr)
Move Group Down Grubu Aşağı Taşı Move Group Down Grubu Aşağı Taşı
@@ -2059,10 +2059,10 @@
Reset GPU Backend GPU Arka Ucunu Sıfırla Reset GPU Backend GPU Arka Ucunu Sıfırla
Applies at next launch. Bir sonraki açılışta geçerlidir. Applies at next launch. Bir sonraki açılışta geçerlidir.
Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın
{key}-click to set the source {key}-kaynağı ayarlamak için tıklayın {key}-click to set the source Kaynağı ayarlamak için {key} tuşuna basarak tıklayın
{key} to subtract {key} çıkarmak için {key} to subtract {key} çıkarmak için
Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın) Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın)
Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı ({key}-Klon Damgası ile tıklayın) Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı (Klonlama Damgası ile {key} tuşuna basarak tıklayın)
Pixel Layer Piksel Katmanı Pixel Layer Piksel Katmanı
Type Layer Metin Katmanı Type Layer Metin Katmanı
Shape Layer Şekil Katmanı Shape Layer Şekil Katmanı
@@ -2468,7 +2468,7 @@ cameraRaw Blue Mavi
cameraRaw Purple Mor cameraRaw Purple Mor
cameraRaw Magenta Macenta cameraRaw Magenta Macenta
cameraRaw Blending Karıştırma cameraRaw Blending Karıştırma
cameraRaw Luminance Parlaklık cameraRaw Luminance Lüminans
cameraRaw Temperature Sıcaklık cameraRaw Temperature Sıcaklık
cameraRaw Color Renk cameraRaw Color Renk
Patch Tool Yama Aracı Patch Tool Yama Aracı
@@ -2937,9 +2937,9 @@ cameraRaw Color Renk
Gray Value (Median) Gri Değeri (Medyan) Gray Value (Median) Gri Değeri (Medyan)
Integrated Density Entegre Yoğunluk Integrated Density Entegre Yoğunluk
Length Uzunluk Length Uzunluk
In Black Siyah In Black Giriş Siyahı
In White Beyaz In White Giriş Beyazı
Out Black Siyah Out Black Çıkış Siyahı
Out White Çıkış Beyazı Out White Çıkış Beyazı
Tint Color Ton Rengi Tint Color Ton Rengi
Stops Duraklar Stops Duraklar
@@ -3280,7 +3280,7 @@ cameraRaw Color Renk
Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler. Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler.
Adobe PDF Presets… Adobe PDF Ön Ayarları… Adobe PDF Presets… Adobe PDF Ön Ayarları…
Advanced Blending Gelişmiş Karıştırma Advanced Blending Gelişmiş Karıştırma
After (X) (X)'ten sonra After (X) Sonra (X)
Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder. Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder.
Alternates for Current Selection Geçerli Seçimin Alternatifleri Alternates for Current Selection Geçerli Seçimin Alternatifleri
Apply Data Set Veri Kümesini Uygula Apply Data Set Veri Kümesini Uygula
@@ -3314,7 +3314,7 @@ cameraRaw Color Renk
Channel4 Kanal 4 Channel4 Kanal 4
Clipping Path Kırpma Yolu Clipping Path Kırpma Yolu
Clone Klon Clone Klon
Color Decontaminate Renkli Dekontaminasyon Color Decontaminate Renkleri Arındır
Color Halftone Renk Yarım Tonu Color Halftone Renk Yarım Tonu
Color Lookup Tables Renk Arama Tabloları Color Lookup Tables Renk Arama Tabloları
Color Settings Renk Ayarları Color Settings Renk Ayarları
@@ -3490,7 +3490,7 @@ cameraRaw Color Renk
Select and Mask Seç ve Maskele Select and Mask Seç ve Maskele
Semi Bold Yarı Kalın Semi Bold Yarı Kalın
Set Measurement Scale Ölçüm Ölçeğini Ayarla Set Measurement Scale Ölçüm Ölçeğini Ayarla
Shadows/Highlights Gölgeler/Vurgular Shadows/Highlights Gölgeler/Parlak Tonlar
Shape Blur Şekil Bulanıklığı Shape Blur Şekil Bulanıklığı
Shear Kesme Shear Kesme
Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar) Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar)
@@ -3792,8 +3792,8 @@ cameraData Lens Lens
Loading fonts… Yazı tipleri yükleniyor… Loading fonts… Yazı tipleri yükleniyor…
Onion Skin Soğan Kabuğu Onion Skin Soğan Kabuğu
Marching Ants Yürüyen Karıncalar Marching Ants Yürüyen Karıncalar
On Black Siyah On Black Siyah Üzerinde
On White Beyaz On White Beyaz Üzerinde
On Layers Katmanlarda On Layers Katmanlarda
Show Edge Kenarı Göster Show Edge Kenarı Göster
Show Original Orijinali Göster Show Original Orijinali Göster
@@ -4088,7 +4088,7 @@ cameraData Lens Lens
Next Brush Sonraki fırça Next Brush Sonraki fırça
First Brush İlk fırça First Brush İlk fırça
Last Brush Son fırça Last Brush Son fırça
Preview size Preview size Preview size Önizleme Boyutu
Memory Usage Bellek kullanımı Memory Usage Bellek kullanımı
Per-document RAM limit (MB) Belge başına RAM sınırı (MB) Per-document RAM limit (MB) Belge başına RAM sınırı (MB)
Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}% Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}%
@@ -4120,10 +4120,10 @@ cameraData Lens Lens
# Actions: a step dialog holds a suspended recording (#7365) # Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin
Pro Medium Gray Pro Orta Gri Pro Medium Gray Pro Orta Gri
Solarized Solarize Solarized Solarized
Breeze Esinti Breeze Breeze
Pro Light Gray Pro Açık Gri Pro Light Gray Pro Açık Gri
Pro Light Profesyonel Işık Pro Light Pro Açık
Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır
EXR EXR EXR EXR
# Paragraph direction (TYPE-218-2) # Paragraph direction (TYPE-218-2)
@@ -4132,7 +4132,7 @@ cameraData Lens Lens
Right-to-left paragraph direction Sağdan sola paragraf yönü Right-to-left paragraph direction Sağdan sola paragraf yönü
Not rendered: this adjustment ({key}) is kept as imported. Görüntülenmiyor: bu ayarlama ({key}) içe aktarıldığı gibi korunuyor. Not rendered: this adjustment ({key}) is kept as imported. Görüntülenmiyor: bu ayarlama ({key}) içe aktarıldığı gibi korunuyor.
# Print preview image (#8236) # Print preview image (#8236)
Print preview image Print preview image Print preview image Baskı Önizleme Görüntüsü
# Color panel menu: modes, copy commands and colour ramps (#6000) # Color panel menu: modes, copy commands and colour ramps (#6000)
Hue Cube Renk Tonu Küpü Hue Cube Renk Tonu Küpü
Brightness Cube Parlaklık Küpü Brightness Cube Parlaklık Küpü
Can't render this file because it contains an unexpected character in line 8 and column 34.
+13 -2
View File
@@ -1302,8 +1302,19 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
} }
if let Some(id) = clicked { if let Some(id) = clicked {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let id = alt_click(id, ctx.input(|i| i.modifiers.alt)); let alt = ctx.input(|i| i.modifiers.alt);
if let Err(e) = invoke(app, &ctx, &id, json!({})) { // ⌥ while choosing an Image › Adjustments item opens it at its last settings (#8900).
let r = if alt && crate::adjust_dialog::has_dialog(&id) {
let opened = crate::adjust_dialog::open_last(app, &id).map(|_| Value::Null).ok_or_else(|| "no document".to_string());
// Edit › Search's recents, as `invoke` notes a plain click.
if opened.is_ok() {
crate::palette::note_recent(&ctx, &id);
}
opened
} else {
invoke(app, &ctx, &alt_click(id, alt), json!({}))
};
if let Err(e) = r {
app.ui.status = e; app.ui.status = e;
app.ui.status_error = true; app.ui.status_error = true;
} }
+21 -4
View File
@@ -239,7 +239,15 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
let by_position = physical_key let by_position = physical_key
.filter(|p| POSITIONAL.contains(p)) .filter(|p| POSITIONAL.contains(p))
.and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers))); .and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers)));
by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab)) let bound =
by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab, false));
// Photoshop: ⌥ added to an adjustment's own key opens it at its last settings (⌥⌘L,
// ⌥⌘M, #8900), unless that key is bound to something else.
bound.or_else(|| {
let plain = egui::Modifiers { alt: false, ..*modifiers };
let last = modifiers.alt.then(|| table.iter().find(|(id, sc)| crate::adjust_dialog::has_dialog(id) && key_matches(sc, *key, plain))).flatten();
last.map(|(id, _)| (id.clone(), false, true))
})
} }
_ => None, _ => None,
}; };
@@ -250,7 +258,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
i.events.remove(at); i.events.remove(at);
Some(hit) Some(hit)
}); });
let Some((id, tab)) = next else { break }; let Some((id, tab, last)) = next else { break };
if tab { if tab {
// egui already read this Tab as "focus the next widget" (Photoshop's Tab hides the // egui already read this Tab as "focus the next widget" (Photoshop's Tab hides the
// panels instead, #1313): the shortcut took it, so cancel that move. // panels instead, #1313): the shortcut took it, so cancel that move.
@@ -258,7 +266,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
} }
let id = clip_key_command(app, delete_key_command(app, id)); let id = clip_key_command(app, delete_key_command(app, id));
let owner = key_owner(app, ctx); let owner = key_owner(app, ctx);
dispatch(app, ctx, &id); dispatch_as(app, ctx, &id, last);
ran = true; ran = true;
if key_owner(app, ctx) != owner { if key_owner(app, ctx) != owner {
break; break;
@@ -345,6 +353,11 @@ fn label(id: &str) -> String {
/// Run a shortcut's command like its menu item (dialogs included), or say why it can't run. /// Run a shortcut's command like its menu item (dialogs included), or say why it can't run.
pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) { pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
dispatch_as(app, ctx, id, false);
}
/// [`dispatch`]; with `last`, an adjustment dialog opens at its last confirmed settings.
fn dispatch_as(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, last: bool) {
let action = id.strip_prefix(crate::actions::SHORTCUT_PREFIX); let action = id.strip_prefix(crate::actions::SHORTCUT_PREFIX);
let command_id = if action.is_some() { "actions.play" } else { id }; let command_id = if action.is_some() { "actions.play" } else { id };
let outcome = if !crate::menus::is_enabled(app, command_id) { let outcome = if !crate::menus::is_enabled(app, command_id) {
@@ -364,7 +377,11 @@ pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
if v.get("failed").is_some_and(|f| f.is_object()) { Err(app.ui.status.clone()) } else { Ok(v) } if v.get("failed").is_some_and(|f| f.is_object()) { Err(app.ui.status.clone()) } else { Ok(v) }
}) })
} else if crate::adjust_dialog::has_dialog(id) { } else if crate::adjust_dialog::has_dialog(id) {
crate::adjust_dialog::open(app, id); if last {
crate::adjust_dialog::open_last(app, id);
} else {
crate::adjust_dialog::open(app, id);
}
Ok(serde_json::Value::Null) Ok(serde_json::Value::Null)
} else { } else {
crate::menus::invoke(app, ctx, id, json!({})) crate::menus::invoke(app, ctx, id, json!({}))
@@ -846,3 +846,117 @@ fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() {
tap(&mut h, egui::Key::Comma, egui::Key::M); tap(&mut h, egui::Key::Comma, egui::Key::M);
assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]); assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]);
} }
/// #8900: ⌥ added to an adjustment's shortcut opens its dialog with the settings last confirmed
/// there (Photoshop's ⌥⌘L for Levels, ⌥⌘M for Curves); the plain shortcut still opens at the
/// defaults, Cancel remembers nothing, each adjustment remembers its own, and a key assigned to
/// another command in Edit › Keyboard Shortcuts stays that command's.
#[test]
fn alt_adjustment_shortcut_reopens_the_last_confirmed_settings() {
use photocraft_doc::ColorMode;
use photocraft_engine::adjust_params::{default_for, from_params, to_params};
let mut h = harness();
put_focus(&mut h, Place::Canvas);
let top = |h: &Harness<'_, PhotocraftApp>| h.state().ui.dialogs.last().map(|d| (d.id, d.fields.clone())).expect("a dialog");
let set = |h: &mut Harness<'_, PhotocraftApp>, id: u64, values: serde_json::Value| {
let d = h.state_mut().ui.dialog_mut(id).unwrap();
d.fields.extend(values.as_object().unwrap().clone());
h.run_steps(2);
};
let levels = to_params(&from_params("levels", &json!({"inBlack": 30, "gamma": 1.3}), None, ColorMode::Rgb).unwrap());
let level_defaults = to_params(&default_for("levels", ColorMode::Rgb).unwrap());
assert_ne!(levels["inBlack"], level_defaults["inBlack"]);
press(&mut h, "Cmd+L");
let (id, _) = top(&h);
set(&mut h, id, json!({"inBlack": 30, "gamma": 1.3}));
crate::dialogs::confirm(h.state_mut(), id).unwrap();
h.run_steps(2);
press(&mut h, "Cmd+L");
let (id, fields) = top(&h);
assert_eq!((&fields["inBlack"], &fields["gamma"]), (&level_defaults["inBlack"], &level_defaults["gamma"]), "⌘L opens at the defaults");
crate::dialogs::cancel(h.state_mut(), id).unwrap();
h.run_steps(2);
press(&mut h, "Cmd+Alt+L");
assert_eq!(logged(&h), ["image.adjustments.levels", "image.adjustments.levels", "image.adjustments.levels"]);
let (id, fields) = top(&h);
assert_eq!((&fields["inBlack"], &fields["gamma"]), (&levels["inBlack"], &levels["gamma"]), "⌥⌘L opens at the last OK'd settings");
set(&mut h, id, json!({"inBlack": 50}));
crate::dialogs::cancel(h.state_mut(), id).unwrap();
h.run_steps(2);
press(&mut h, "Cmd+Alt+L");
let (id, fields) = top(&h);
assert_eq!(fields["inBlack"], levels["inBlack"], "Cancel remembers nothing");
crate::dialogs::cancel(h.state_mut(), id).unwrap();
h.run_steps(2);
// Curves has nothing of its own yet: its defaults, not Levels' settings.
let curve_defaults = to_params(&default_for("curves", ColorMode::Rgb).unwrap());
press(&mut h, "Cmd+Alt+M");
let (id, fields) = top(&h);
assert_eq!(fields["__adjust"], json!("curves"));
assert_eq!(fields["points"], curve_defaults["points"], "⌥⌘M with no Curves confirmed yet");
let curve = json!({"points": [[0, 0], [100, 150], [255, 255]]});
set(&mut h, id, curve.clone());
crate::dialogs::confirm(h.state_mut(), id).unwrap();
h.run_steps(2);
press(&mut h, "Cmd+Alt+M");
let (id, fields) = top(&h);
let curves = to_params(&from_params("curves", &curve, None, ColorMode::Rgb).unwrap());
assert_eq!(fields["points"], curves["points"], "⌥⌘M opens at the last OK'd curve");
crate::dialogs::cancel(h.state_mut(), id).unwrap();
h.run_steps(2);
take_log(&h.ctx);
h.state_mut().session.edit_prefs(|p| {
p.shortcuts.insert("image.adjustments.desaturate".into(), "Cmd+Alt+L".into());
});
press(&mut h, "Cmd+Alt+L");
assert_eq!(logged(&h), ["image.adjustments.desaturate"], "a key assigned to another command is that command's");
assert!(h.state().ui.dialogs.is_empty());
}
/// #8900: in Photoshop ⌥ held while choosing an Image › Adjustments item opens it at its last
/// settings too; a plain click still opens it at the defaults.
#[test]
fn alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings() {
use photocraft_doc::ColorMode;
use photocraft_engine::adjust_params::{default_for, from_params, to_params};
let mut h = harness();
h.state_mut().session.edit_prefs(|p| {
p.dialogs.insert("image.adjustments.levels".into(), json!({"inBlack": 30, "gamma": 1.3}));
});
let levels = to_params(&from_params("levels", &json!({"inBlack": 30, "gamma": 1.3}), None, ColorMode::Rgb).unwrap());
let defaults = to_params(&default_for("levels", ColorMode::Rgb).unwrap());
let choose = |h: &mut Harness<'_, PhotocraftApp>, m: Modifiers| {
h.get_by_label("Image").click();
h.run_steps(3);
// The Image menu's row, not the Adjustments panel's tab in the dock on the right.
let adjustments = h.get_all_by_label_contains("Adjustments").map(|n| n.rect()).min_by(|a, b| a.left().total_cmp(&b.left())).unwrap().center();
h.hover_at(adjustments);
h.run_steps(30);
let at = h.get_by_label_contains("Levels…").rect().center();
h.hover_at(at);
h.run_steps(2);
h.event(egui::Event::ModifiersChanged(m));
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: m });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m });
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
let d = h.state().ui.dialogs.last().expect("Levels opened").clone();
assert_eq!(d.fields["__adjust"], json!("levels"));
crate::dialogs::cancel(h.state_mut(), d.id).unwrap();
h.run_steps(2);
(d.fields["inBlack"].clone(), d.fields["gamma"].clone())
};
assert_eq!(choose(&mut h, Modifiers::NONE), (defaults["inBlack"].clone(), defaults["gamma"].clone()), "a click opens at the defaults");
assert_eq!(choose(&mut h, Modifiers::ALT), (levels["inBlack"].clone(), levels["gamma"].clone()), "⌥-click opens at the last settings");
// The plain click noted Levels already: put another command in front of it first.
crate::palette::note_recent(&h.ctx, "file.new");
choose(&mut h, Modifiers::ALT);
assert_eq!(crate::palette::recent_ids(&h.ctx).first().map(String::as_str), Some("image.adjustments.levels"), "Edit › Search's most recent");
}
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel # UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -207,6 +207,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | |
| Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | |
| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | |
| ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` |
| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) |
## Panels and workspace ## Panels and workspace
@@ -298,3 +299,4 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | | 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) |
| 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) | | 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) |
| 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) | | 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) |
| 2026-10-23 | minor | ⌥ + an adjustment's shortcut (⌥⌘L Levels, ⌥⌘M Curves) opens its dialog at the settings of its last OK, as in Photoshop (#8900); no percentage changed |