From 5750ed40f94c5b6239a857e51df92dd79279a03b Mon Sep 17 00:00:00 2001 From: kotulsky Date: Fri, 23 Oct 2026 23:54:26 +0200 Subject: [PATCH 1/7] Fix pasted shape group bounds and effects across documents (#8992) * fix(clipboard): render copied shapes in destination canvas space When a copied shape group is placed beyond its source canvas, rebuild its vector caches against the destination canvas and pixel format. Previously a centered paste into a larger document could clip the caches to an empty source-sized frame, leaving no transform bounds and incorrect effects until a point edit refreshed each shape. Restore missing shape caches and refresh source-clipped caches when canvas dimensions change. Cover Paste and Copy to Document, nested group contents, 8/16-bit destinations, rendered cache equality, transform bounds and movement Undo. * fix(clipboard): measure shape placement before destination clipping Restore missing source-space shape caches before measuring copy bounds, then adapt and translate for the destination. Re-render empty caches when shapes move back onto the canvas instead of shifting an empty raster. Add focused integration coverage for 32 combinations of partial and full destination clipping, grouped shapes, missing caches, 8/16-bit output, centered and point placement, and Undo/Redo. Clippy and WASM checks pass. --------- Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com> Co-authored-by: Brandon Thomas --- crates/engine/src/edit_cmds.rs | 74 ++++++++++++++++++- crates/engine/src/layer_copy_cmds.rs | 33 +++++++-- crates/engine/src/vector_cmds.rs | 3 +- .../engine/tests/it/copy_shape_placement.rs | 74 +++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 5 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/it/copy_shape_placement.rs diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 29ce479c51..1ae19fcdad 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -416,7 +416,7 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< *l = l.duplicate(); } 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(); // A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584). 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"); } + #[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 /// takes them whole and Paste recreates them, in the same or another document, in one step. #[test] diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index d08955fdf6..7995ff6c56 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; use photocraft_color::ColorMode; -use photocraft_doc::{Document, LayerContent, LayerId, SmartContentsId}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId}; use photocraft_geom::Rect; 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] } +/// 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 /// 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`, @@ -107,13 +118,20 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document) *surf = surf.convert(f); }); } + let new_canvas = copies.bounds() != dest.bounds(); + prepare_shapes(&mut copies.layers, dest, new_canvas); Ok(()) } /// 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 { - 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 { crate::commands::translate_layer(&snapshot, l, dx, 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 { 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(); } + // 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)?; // 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). - 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 (c, d) = (center(content), center(ddoc.bounds())); (d[0] - c[0], d[1] - c[1]) @@ -187,7 +208,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { 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); - 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(); s.set_active(dest); let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" }; diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 4a94c1e9ab..ccfc3a472a 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -506,7 +506,8 @@ pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) let canvas = doc.bounds(); let inside = sh.cache.as_ref().is_some_and(|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 stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { diff --git a/crates/engine/tests/it/copy_shape_placement.rs b/crates/engine/tests/it/copy_shape_placement.rs new file mode 100644 index 0000000000..ee6d016984 --- /dev/null +++ b/crates/engine/tests/it/copy_shape_placement.rs @@ -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); + } + } + } + } + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index cd44a7a946..1e31899a35 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -14,6 +14,7 @@ mod clipping_semantics; mod close_document; mod command_lookup; mod convert_keeps_references; +mod copy_shape_placement; mod corpus_atn; mod crop_content_aware; mod current_affinity_artboards; From 3190b4ea4f6852bba610f41987a935125bf297a0 Mon Sep 17 00:00:00 2001 From: Roni Balthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:54:39 +0100 Subject: [PATCH 2/7] fix(io): preserve unsupported custom-domain Color Lookup LUTs (#8879) Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/io/src/adjust_map.rs | 46 ++++++++++++ crates/io/tests/it/adjust_warnings.rs | 101 ++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 4afc831d48..e3c5195aa0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -252,5 +252,8 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" +[people.ronibalthazar] +sync_github_name = true + [people.drunkeninja42] real_name = "Kushagra Sharma" diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 9cd7479b81..053a38b99b 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -450,6 +450,12 @@ fn read_lookup(d: &[u8]) -> Result { _ => ("x.cube", "CUBE"), }; 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(); Ok(Adjustment::ColorLookup { name, @@ -1112,6 +1118,46 @@ mod tests { assert!(matches!(parse(b"selc", &[0, 1, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); } + fn cube_lookup_block(cube: &[u8]) -> Vec { + 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 { + 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] fn malformed_falls_back_to_unsupported() { assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); diff --git a/crates/io/tests/it/adjust_warnings.rs b/crates/io/tests/it/adjust_warnings.rs index c5bba01827..e3778b0b48 100644 --- a/crates/io/tests/it/adjust_warnings.rs +++ b/crates/io/tests/it/adjust_warnings.rs @@ -6,6 +6,7 @@ use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_io::*; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen}; use photocraft_raster::Surface; fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec) { @@ -48,6 +49,10 @@ fn readable_adjustments_open_without_warnings() { /// cyan, so the table is not the identity). fn photoshop_clr_l_block() -> Vec { 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 { 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 desc = Descriptor::new("null") @@ -61,6 +66,46 @@ fn photoshop_clr_l_block() -> Vec { raw } +fn identity_cube(domain: &str) -> Vec { + 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 { + 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) { + 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] 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 @@ -109,3 +154,59 @@ fn short_color_lookup_table_warns_on_open() { let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default()); 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); + } +} From 9fe6be6d86c910358e96150c6bb36125ec58ec3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Batuhan=20G=C3=B6zeg=C3=BC?= <112725779+batuhangozegu@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:54:52 +0300 Subject: [PATCH 3/7] i18n(tr): fix mistranslations, inconsistencies and untranslated rows (#8871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Color Decontaminate: "Renkli" (colourful) -> "Renkleri Arındır" - Warp split commands and "Cancel warp": "Çözgü" (textile warp) -> "Çarpıtma" - Constraint hint: "Shift" was translated as "kaydırma"; keep the key name - Highlights (tonal range) -> "Parlak Tonlar", as in the other rows - Luminance -> "Lüminans" (was "parlaklık", which is Brightness) - Indexed -> "İndekslenmiş", Trilinear -> "Üç Doğrusal" - Levels In/Out Black/White: distinct "Giriş/Çıkış Siyahı/Beyazı" - Layer Knocks Out Drop Shadow now says "Alt Gölge" like Drop Shadow - Contact Sheet II -> "Kontak Sayfası II" - Fix word order of the clone-source hints, "After (X)", "On Black/White" - Translate "Preview size" and "Print preview image" - Keep theme names Solarized and Breeze; Pro Light -> "Pro Açık" - Add contributor entry for the author Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 ++ crates/ui-egui/src/i18n/tr.tsv | 70 +++++++++++++++++----------------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index e3c5195aa0..4be7a88031 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -252,6 +252,10 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" +[people.batuhangozegu] +real_name = "Batuhan Gözegü" +sync_github_name = false + [people.ronibalthazar] sync_github_name = true diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 90fe0c3eef..a1eb6ae039 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -89,7 +89,7 @@ Batch… Toplu… Create Droplet… Damlacık Oluştur… 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… Fit Image… Görüntüyü Sığdır… Lens Correction… Mercek Düzeltme… @@ -247,7 +247,7 @@ Threshold… Eşik… Gradient Map… Degrade Eşlemesi… Selective Color… Seçici Renk… - Shadows/Highlights… Gölgeler/Vurgular… + Shadows/Highlights… Gölgeler/Parlak Tonlar… HDR Toning… HDR Tonlama… Desaturate Doygunluğu Azalt Match Color… Rengi Eşleştir… @@ -410,7 +410,7 @@ Stamp Down Aşağıya Damgala Flatten Image Görüntüyü Düzleştir Matting Matlama - Color Decontaminate… Renkli Dekontamine… + Color Decontaminate… Renkleri Arındır… Remove Black Matte Siyah Matı Kaldır Remove White Matte Beyaz Matı Kaldır Defringe… Kenar Halesini Kaldır… @@ -438,7 +438,7 @@ Right Edges Sağ Kenarlar Distribute Dağıt Vertically Dikey Olarak - Horizontally Yatay olarak + Horizontally Yatay Olarak Layer Edges Katman Kenarları Isolate Layers Katmanları Yalıt Layer Content Options… Katman İçerik Seçenekleri… @@ -777,9 +777,9 @@ Other… Diğer… Remove Warp Split Çarpıtma Bölünmesini Kaldır Shape Şekil - Split Warp Crosswise Çözgüyü Çapraz Olarak Böl - Split Warp Horizontally Çözgüyü Yatay Olarak Böl - Split Warp Vertically Çözgüyü Dikey Olarak Böl + Split Warp Crosswise Çarpıtmayı Çapraz Olarak Böl + Split Warp Horizontally Çarpıtmayı Yatay Olarak Böl + Split Warp Vertically Çarpıtmayı Dikey Olarak Böl Statistics… İstatistikler… Refine Strokes Fırça Vuruşlarını Hassaslaştır Edits Düzenlemeler @@ -889,7 +889,7 @@ 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 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 extension color: Tuval uzatma rengi: Capture Yakala @@ -946,7 +946,7 @@ Commit warp ({key}) Çarpıtmayı onayla ({key}) Constant Sabit Constrain proportions Oranları koru - Contact Sheet II İletişim Sayfası II + Contact Sheet II Kontak Sayfası II Content-Aware İçeriğe Duyarlı Contiguous Bitişik Contrast Kontrast @@ -1056,7 +1056,7 @@ Dots Noktalar 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 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 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. @@ -1147,7 +1147,7 @@ Hide Properties Özellikleri Gizle Highlight Compression Parlak Ton Sıkıştırma Highlight Priority Parlak Ton Önceliği - Highlights Öne Çıkanlar + Highlights Parlak Tonlar Home Ana sayfa Horizontal scale Yatay ölçek Hue Ton @@ -1183,7 +1183,7 @@ Kerning Karakter Aralığı Keyboard Shortcuts Klavye Kısayolları Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler - Kind tür + Kind Tür Label Size Etiket Boyutu Labels Etiketler Lanczos (sharp) Lanczos (keskin) @@ -1219,7 +1219,7 @@ Lock: Kilit: Logical Length: Mantıksal Uzunluk: Logical Units: Mantıksal Birimler: - Luminance parlaklık + Luminance Lüminans Luminance Detail Lüminans Ayrıntısı Magenta Macenta Magenta · Green Macenta · Yeşil @@ -1521,7 +1521,7 @@ Tracking (1/1000 em) İzleme (1/1000 em) Transparency Saydamlık Transparent Saydam - Trilinear Üç çizgili + Trilinear Üç Doğrusal Type Styles Metin Stilleri Type: Tür: Underline Altı çizili @@ -1644,7 +1644,7 @@ ExtraBold Ekstra Kalın ExtraLight Ekstra Hafif Eyedropper Tool Damlalık Aracı - Fade Solmaya + Fade Solma Fill Opacity Dolgu Opaklığı Film & Video Film ve Video From Şuradan @@ -1657,14 +1657,14 @@ Hard Sert Hard Mix Sert Karışım 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ı Horizontal Type Tool Yatay Metin Aracı Horizontal Type Mask Tool Yatay Metin Maskesi Aracı Vertical Type Mask Tool Dikey Metin Maskesi Aracı Image Size: Görüntü Boyutu: Inches İnç - Indexed Dizine eklendi + Indexed İndekslenmiş Initial Direction Başlangıç Yönü Inner Bevel İç Eğim Inner Glow İç Işıma @@ -1676,7 +1676,7 @@ Lasso Kement Lasso Tool Kement Aracı 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 Letter Letter Light Hafif @@ -2037,7 +2037,7 @@ Expand 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) - 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) Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr) Move Group Down Grubu Aşağı Taşı @@ -2059,10 +2059,10 @@ Reset GPU Backend GPU Arka Ucunu Sıfırla 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 - {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 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ı Type Layer Metin Katmanı Shape Layer Şekil Katmanı @@ -2464,7 +2464,7 @@ cameraRaw Blue Mavi cameraRaw Purple Mor cameraRaw Magenta Macenta cameraRaw Blending Karıştırma -cameraRaw Luminance Parlaklık +cameraRaw Luminance Lüminans cameraRaw Temperature Sıcaklık cameraRaw Color Renk Patch Tool Yama Aracı @@ -2933,9 +2933,9 @@ cameraRaw Color Renk Gray Value (Median) Gri Değeri (Medyan) Integrated Density Entegre Yoğunluk Length Uzunluk - In Black Siyah - In White Beyaz - Out Black Siyah + In Black Giriş Siyahı + In White Giriş Beyazı + Out Black Çıkış Siyahı Out White Çıkış Beyazı Tint Color Ton Rengi Stops Duraklar @@ -3276,7 +3276,7 @@ cameraRaw Color Renk 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ı… 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. Alternates for Current Selection Geçerli Seçimin Alternatifleri Apply Data Set Veri Kümesini Uygula @@ -3310,7 +3310,7 @@ cameraRaw Color Renk Channel4 Kanal 4 Clipping Path Kırpma Yolu Clone Klon - Color Decontaminate Renkli Dekontaminasyon + Color Decontaminate Renkleri Arındır Color Halftone Renk Yarım Tonu Color Lookup Tables Renk Arama Tabloları Color Settings Renk Ayarları @@ -3486,7 +3486,7 @@ cameraRaw Color Renk Select and Mask Seç ve Maskele Semi Bold Yarı Kalın 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ığı Shear Kesme Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar) @@ -3788,8 +3788,8 @@ cameraData Lens Lens Loading fonts… Yazı tipleri yükleniyor… Onion Skin Soğan Kabuğu Marching Ants Yürüyen Karıncalar - On Black Siyah - On White Beyaz + On Black Siyah Üzerinde + On White Beyaz Üzerinde On Layers Katmanlarda Show Edge Kenarı Göster Show Original Orijinali Göster @@ -4072,7 +4072,7 @@ cameraData Lens Lens Next Brush Sonraki fırça First Brush İlk fırça Last Brush Son fırça - Preview size Preview size + Preview size Önizleme Boyutu Memory Usage Bellek kullanımı 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}% @@ -4096,10 +4096,10 @@ cameraData Lens Lens # 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 Pro Medium Gray Pro Orta Gri - Solarized Solarize - Breeze Esinti + Solarized Solarized + Breeze Breeze 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 EXR EXR @@ -4111,4 +4111,4 @@ cameraData Lens Lens 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 Print preview image + Print preview image Baskı Önizleme Görüntüsü From 3a870ca3ce410e82cec624ff46197b426ab34a6d Mon Sep 17 00:00:00 2001 From: Roni Balthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:55:05 +0100 Subject: [PATCH 4/7] fix(ipp): preserve printer group metadata (#8828) Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/ipp/src/lib.rs | 185 +++++++++++++++++++++++---------------- crates/ipp/src/tests.rs | 155 ++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 76 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 4be7a88031..2e2f945188 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -252,6 +252,9 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" +[people.ronibalthazar] +sync_github_name = true + [people.batuhangozegu] real_name = "Batuhan Gözegü" sync_github_name = false diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs index bbf6efffec..7fc6532862 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -323,56 +323,31 @@ pub struct Reply { pub attributes: Vec, } +/// 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, +} + impl Reply { /// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag. pub fn parse(b: &[u8]) -> Result { - 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). fn parse_at(b: &[u8]) -> Result<(Reply, usize)> { - 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 attributes: Vec = 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")); - } - } + parse_message(b).map(|parsed| (parsed.reply, parsed.end)) } /// 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. -fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { +/// Parses one IPP message while preserving each group instance for private consumers that need +/// object boundaries. The public reply remains a flat list of attributes. +fn parse_message(b: &[u8]) -> Result { + 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 = Vec::new(); + let mut groups: Vec = 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 { let mut stream = endpoint.connect()?; 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", @@ -417,9 +456,9 @@ fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { })); } 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)?; - if reply.status >= 0x0400 { - return Err(Error::Status(reply.status, reply.status_message())); + let reply = parse_message(&body)?; + if reply.reply.status >= 0x0400 { + return Err(Error::Status(reply.reply.status, reply.reply.status_message())); } Ok(reply) } @@ -563,41 +602,35 @@ pub fn printers(endpoint: &Endpoint) -> Result> { fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result> { let mut r = Request::new(op, 1); 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, // CUPS answers "not found" ("No destinations added.") when no printer is set up. Err(Error::Status(0x0406, _)) => return Ok(Vec::new()), Err(e) => return Err(e), }; let mut out = Vec::new(); - let mut current: Option = None; - for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) { - match a.name.as_str() { - "printer-name" => { - if let Some(p) = current.take() { - out.push(p); - } - current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class }); + let attributes = &parsed.reply.attributes; + for group in parsed.groups.iter().filter(|group| group.tag == TAG_PRINTER_GROUP) { + let Some(group_attributes) = attributes.get(group.start..group.end) else { + return Err(Error::Protocol("invalid printer attribute group range".into())); + }; + let mut name = None; + let mut info = String::new(); + 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 { - p.info = a.text(); - } - } - "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)); - } - } - _ => {} + } + if let Some(name) = name.filter(|name| !name.is_empty()) { + out.push(Printer { name, info, state, accepting_jobs, is_default: false, is_class }); } } - out.extend(current); Ok(out) } @@ -606,7 +639,7 @@ pub fn default_printer(endpoint: &Endpoint) -> Result> { let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2); r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]); 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. Err(Error::Status(0x0406, _)) => Ok(None), Err(e) => Err(e), @@ -642,7 +675,7 @@ pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result { } let mut body = r.end(); body.extend_from_slice(job.document); - let reply = post(endpoint, &path, &body)?; + let reply = post(endpoint, &path, &body)?.reply; reply .attributes .iter() diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index 518c9dfa39..14ddf43846 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -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::>(); + 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] 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); From 2b83be6c813a4740fe69ed0bf1e548b39f3cb9bf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:55:18 +0200 Subject: [PATCH 5/7] =?UTF-8?q?Levels,=20Curves=20and=20the=20other=20adju?= =?UTF-8?q?stments:=20=E2=8C=A5=20plus=20the=20shortcut=20opens=20the=20di?= =?UTF-8?q?alog=20at=20its=20last=20settings=20(#8900)=20(#8969)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Levels, Curves and the other adjustments: ⌥ plus the shortcut opens the dialog at its last settings (#8900) * Image › Adjustments: ⌥ while choosing an item opens it at its last settings (#8900) * Tests: the ⌥-click test picks the Image menu's Adjustments row, not the panel tab (#8900) * Image › Adjustments: an item opened with ⌥ lands in Edit › Search's recents too (#8900) --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/adjust_dialog.rs | 27 +++++ crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/menus.rs | 15 ++- crates/ui-egui/src/shortcut_dispatch.rs | 25 +++- crates/ui-egui/src/shortcut_dispatch/tests.rs | 114 ++++++++++++++++++ docs/ui-parity.md | 4 +- 6 files changed, 183 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 3bbf36b3c2..acb56b46aa 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -279,6 +279,33 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { 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 { + 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) { + 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 /// selection — both ways of selecting a mask must give the same dropdown (#5335). fn isolated(app: &PhotocraftApp) -> Option { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4a7f5634f8..3191d64392 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -855,7 +855,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { app.ui.view.guide_layout = params; } 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. crate::actions::resume(app, &d.fields)?; } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e9721b765d..68c64cffb0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1293,8 +1293,19 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { } if let Some(id) = clicked { let ctx = ui.ctx().clone(); - let id = alt_click(id, ctx.input(|i| i.modifiers.alt)); - if let Err(e) = invoke(app, &ctx, &id, json!({})) { + let alt = ctx.input(|i| i.modifiers.alt); + // ⌥ 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_error = true; } diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 0f47282213..e7d8b3c36d 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -239,7 +239,15 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc let by_position = physical_key .filter(|p| POSITIONAL.contains(p)) .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, }; @@ -250,7 +258,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc i.events.remove(at); Some(hit) }); - let Some((id, tab)) = next else { break }; + let Some((id, tab, last)) = next else { break }; if tab { // 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. @@ -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 owner = key_owner(app, ctx); - dispatch(app, ctx, &id); + dispatch_as(app, ctx, &id, last); ran = true; if key_owner(app, ctx) != owner { 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. 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 command_id = if action.is_some() { "actions.play" } else { 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) } }) } 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) } else { crate::menus::invoke(app, ctx, id, json!({})) diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index df66185db1..b602a5a2b0 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -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); 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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f1ed065ced..73bc39a53b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # 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; 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; 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 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) | | | 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_*`) | | +| ⌥ + 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) | ## Panels and workspace @@ -293,3 +294,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-16 | minor | View › Extras (⌘H) hides the Move tool's transform controls and their handles stop responding; ⌘H again restores them, and Show Transform Controls keeps its setting (#6699) | | 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) | | 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | +| 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 | From fc0b40dae50301c08a6f302f32ecc10a39d20158 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:55:54 +0100 Subject: [PATCH 6/7] fix(atn): keep Photoshop's other transform interpolation modes on import (#8698) The .atn importer kept only bicubic, bilinear and nearest from a transform's Intr, so a recorded Bicubic Automatic, Sharper or Smoother Transform Selection replayed with the command's default (bilinear) and re-exported without its choice. The bicubic variants are now kept (the transforms already render them as Bicubic), Automatic becomes Bicubic Automatic and Preserve Details Bicubic, and both transform commands list the variants (#8697). Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 10 +++++++++- crates/engine/src/atn_cmds/tests.rs | 22 ++++++++++++++++++++++ crates/engine/src/select_extra_cmds.rs | 22 +++++++++++++++++++++- crates/engine/src/transform_cmds.rs | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 30a05192ec..cedfa5aeed 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1949,7 +1949,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 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). - 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)) { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => { if let Some(i) = interpolation { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 02474b8858..cca5491979 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2053,6 +2053,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 /// (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. diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index b3fae926eb..8ca012266d 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { label: "Transform Selection", menu: &["Select"], 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, run: transform_selection, journal: true, @@ -297,6 +297,26 @@ mod tests { 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::>()) + }; + 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] fn sky_selects_the_sky_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index fce8c1be49..a1b1aac379 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -859,7 +859,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], 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, journal: true, run: transform, From 29e4039c7206dad579d9ac9a3e52edf5f55c436f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 18:19:36 -0400 Subject: [PATCH 7/7] fix(contributors): drop a duplicate people.toml table (#9009) Two PRs that landed together each added [people.ronibalthazar], and a duplicate table makes the file invalid TOML (ui-egui's build script warns that it can't parse it, so the About window's names are lost). Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 2e2f945188..e258d32e21 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -259,8 +259,5 @@ sync_github_name = true real_name = "Batuhan Gözegü" sync_github_name = false -[people.ronibalthazar] -sync_github_name = true - [people.drunkeninja42] real_name = "Kushagra Sharma"