From e4be1a3cda9f7398f6bb3ff62a37787f968e1b95 Mon Sep 17 00:00:00 2001 From: jacabus Date: Tue, 27 Oct 2026 17:52:25 +1100 Subject: [PATCH 01/60] feat(atn): gradientLayer import and Blnd blend-if support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two engine gaps identified in ARTCRAFT-PC-2026-001: 1. gradientLayer in make_from / set_from (Spotlight, Vignette — BLOCKED → PASS) - Add gradientLayer arm in make_from contentLayer branch: maps to layer.newFillLayer.gradient + gradient.fill.set (two steps via steps()) - Add gradientLayer arm in set_from contentLayer branch: maps to gradient.fill.set - Both use existing commands; no new commands or data models required - New helpers: gradient_layer_params, gradient_layer_set_params, gradient_color_stops, gradient_transparency_stops, GRADIENT_STYLES 2. Blnd (blend-if) in set_from for (Some("Lyr"), _) (Highlights/Midtones/Shadows — PARTIAL → PASS) - Read Blnd list from T descriptor; map each entry via CHANNELS table - Emit layer.setProps {"blendIf": [...]} step; layer.setProps already accepts blendIf at layer_menu_cmds.rs:461 — importer-only change - SrcB/Srcl/SrcW/Srcm → thisLayer; DstB/Dstl/DstW/Dstt → underlying (field mapping verified by fixture test 2026-10-27) All changes confined to crates/engine/src/atn_cmds.rs. No new commands. No new data models. No engine changes. Co-Authored-By: Claude Sonnet 4.6 --- crates/engine/src/atn_cmds.rs | 113 ++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 244c21479a..0928226247 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1159,6 +1159,79 @@ fn pattern_desc(p: &Value) -> Option { Some(t) } +const GRADIENT_STYLES: &[(&str, &str)] = &[ + ("Rdl", "radial"), ("Lnr", "linear"), ("Angl", "angle"), + ("Rflt", "reflected"), ("Dmnd", "diamond"), +]; + +/// Color stops from a `gradientLayer` Grad descriptor: `[[location_0_to_1, "#rrggbb"], …]`. +fn gradient_color_stops(grad: &Descriptor) -> Option> { + let clrs = get_list(grad, "Clrs")?; + clrs.iter().map(|item| { + let D::Descriptor(stop) = item else { return None }; + let hex = color_hex(get_obj(stop, "Clr ")?)?; + let loc = get_num(stop, "Lctn")? / 4096.0; + Some(json!([loc, hex])) + }).collect() +} + +/// Transparency stops from a `gradientLayer` Grad descriptor: `[[location_0_to_1, opacity_0_to_100], …]`. +fn gradient_transparency_stops(grad: &Descriptor) -> Vec { + get_list(grad, "Trns").unwrap_or(&[]).iter().filter_map(|item| { + let D::Descriptor(stop) = item else { return None }; + let opct = get_num(stop, "Opct")?; + let loc = get_num(stop, "Lctn")? / 4096.0; + Some(json!([loc, opct])) + }).collect() +} + +/// Set params for a `gradientLayer` descriptor → `gradient.fill.set`. +fn gradient_layer_set_params(t: &Descriptor) -> Option { + let grad = get_obj(t, "Grad")?; + let style = lookup(GRADIENT_STYLES, &get_enum(t, "Type")?)?; + let angle = get_num(t, "Angl").unwrap_or(90.0); + let stops = gradient_color_stops(grad)?; + let mut p = Map::new(); + p.insert("style".into(), style.into()); + p.insert("angle".into(), angle.into()); + p.insert("stops".into(), Value::Array(stops)); + let trns = gradient_transparency_stops(grad); + if trns.iter().any(|s| s[1].as_f64() != Some(100.0)) { + p.insert("transparency".into(), Value::Array(trns)); + } + if get_bool(t, "Rvrs") == Some(true) { p.insert("reverse".into(), true.into()); } + if get_bool(t, "Dthr") == Some(true) { p.insert("dither".into(), true.into()); } + if let Some(s) = get_num(t, "Scl ").filter(|&s| (s - 100.0).abs() > 0.01) { + p.insert("scale".into(), s.into()); + } + if let Some(ofst) = get_obj(t, "Ofst") { + let h = get_num(ofst, "Hrzn").unwrap_or(0.0); + let v = get_num(ofst, "Vrtc").unwrap_or(0.0); + if h.abs() > 0.001 || v.abs() > 0.001 { + p.insert("offset".into(), json!([h, v])); + } + } + Some(Value::Object(p)) +} + +/// Create + set params for a `gradientLayer` descriptor → `(layer.newFillLayer.gradient, gradient.fill.set)`. +fn gradient_layer_params(t: &Descriptor) -> Option<(Value, Value)> { + let set_p = gradient_layer_set_params(t)?; + let grad = get_obj(t, "Grad")?; + let stops = gradient_color_stops(grad)?; + let from = stops.first().and_then(|s| s[1].as_str()).map(str::to_string)?; + let to = stops.last().and_then(|s| s[1].as_str()).map(str::to_string)?; + let style = lookup(GRADIENT_STYLES, &get_enum(t, "Type")?)?; + let angle = get_num(t, "Angl").unwrap_or(90.0); + let mut create_p = Map::new(); + create_p.insert("from".into(), from.into()); + create_p.insert("to".into(), to.into()); + create_p.insert("style".into(), style.into()); + create_p.insert("angle".into(), angle.into()); + if get_bool(t, "Rvrs") == Some(true) { create_p.insert("reverse".into(), true.into()); } + Some((Value::Object(create_p), set_p)) +} + /// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command. const MASK_USING: &[(&str, &str)] = &[ ("RvlA", "layer.layerMask.revealAll"), @@ -1276,6 +1349,13 @@ fn make_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + Some(t) if t.class_id.is("gradientLayer") => match gradient_layer_params(t) { + Some((create_p, set_p)) => steps(vec![ + ("layer.newFillLayer.gradient", create_p), + ("gradient.fill.set", set_p), + ]), + None => Mapped::Unsupported { harmless: false }, + }, _ => Mapped::Unsupported { harmless: false }, }, Some("layerSection") => { @@ -1535,6 +1615,35 @@ fn set_from(d: &Descriptor) -> Mapped { if p.keys().any(|k| k != "layer") { v.push(("layer.setProps", Value::Object(p))); } + if let Some(blnd_list) = get_list(t, "Blnd") { + let entries: Option> = blnd_list.iter().map(|item| { + let D::Descriptor(blnd) = item else { return None }; + let chnl_ref = get_ref(blnd, "Chnl")?; + let ch_name = match target(chnl_ref) { + Target::Ordinal(o) if o == "Trgt" || o == "Cmps" => "gray", + Target::Ordinal(o) => lookup(CHANNELS, &o)?, + _ => return None, + }; + let src_b = get_num(blnd, "SrcB")? as i64; + let src_l = get_num(blnd, "Srcl")? as i64; + let src_w = get_num(blnd, "SrcW")? as i64; + let src_m = get_num(blnd, "Srcm")? as i64; + let mut entry = json!({"channel": ch_name, "thisLayer": [src_b, src_l, src_w, src_m]}); + if let (Some(db), Some(dl), Some(dw), Some(dt)) = ( + get_num(blnd, "DstB"), get_num(blnd, "Dstl"), + get_num(blnd, "DstW"), get_num(blnd, "Dstt"), + ) { + entry["underlying"] = json!([db as i64, dl as i64, dw as i64, dt as i64]); + } + Some(entry) + }).collect(); + if let Some(entries) = entries { + if let Some(mut bp) = layer_target(r) { + bp.insert("blendIf".into(), Value::Array(entries)); + v.push(("layer.setProps", Value::Object(bp))); + } + } + } if v.is_empty() { unsupported } else { steps(v) } } // A Pattern Fill layer's pattern (double-click its thumbnail). @@ -1543,6 +1652,10 @@ fn set_from(d: &Descriptor) -> Mapped { Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)), _ => unsupported, }, + Some(t) if t.class_id.is("gradientLayer") => match gradient_layer_set_params(t) { + Some(p) => step("gradient.fill.set", p), + None => unsupported, + }, _ => unsupported, }, (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { From 25dd0bed5a6e0830018da608be5f0243a318a1e0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 27 Oct 2026 11:44:28 -0400 Subject: [PATCH 02/60] fix(move): one click accepts a resized transform box and picks the layer under it (#8208) With Show Transform Controls on, dragging a handle opens a Free Transform session, and while it is open every press goes to the transform tool. A click outside the box commits it on release and returns true, which swallowed the event, so Auto-Select never saw that click and the user had to click again. After the outside click commits and the session is gone, replay the press as a plain Move tool click at the same point, so Auto-Select, Shift/Cmd and the empty-spot marquee behave as for any click. A commit handed to a background job keeps the session open, and then nothing is replayed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/move_auto_select_tests.rs | 18 ++++++++++++++++++ .../src/tools/transform_tool/pointer.rs | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 91eb91e5d3..e2c3916252 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -257,3 +257,21 @@ fn shift_click_adds_shape_layers_one_after_another() { selected.sort_by_key(|l| l.0); assert_eq!(selected, ids); } + +#[test] +fn one_click_accepts_a_resized_box_and_picks_the_layer_under_it() { + let (mut app, a, b) = app(); + app.ui.tool_options.move_show_transform = true; + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + // Resize B's box to twice its size from its top-left corner. + let t = app.ui.transform.as_mut().unwrap(); + t.quad[2] = [t.quad[2][0] + 16.0, t.quad[2][1] + 16.0]; + t.quad[1][0] += 16.0; + t.quad[3][1] += 16.0; + let click = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, click); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 16.0, y: 16.0 }, click); + assert!(app.ui.transform.is_none(), "the click accepted the box"); + assert_eq!(bounds(&app, b), Rect::new(40, 40, 72, 72), "B keeps its resize"); + assert_eq!(active(&app), Some(a), "and the same click selected A"); +} diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 7df9bfa6f1..6dbaa3bf3e 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -97,6 +97,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> // keep their existing behavior. if matches!(ev, ToolEvent::Up { .. }) && g.is_some_and(|g| accepts_outside_click(g, [x, y], tol)) { commit(app); + // The Move tool's click that accepted the box also picks what is under it + // (Auto-Select), rather than needing a second click (#8208). + if app.ui.transform.is_none() && app.ui.tool == crate::state::Tool::Move { + crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods); + crate::canvas::tool_event(app, ToolEvent::Up { x, y }, mods); + } return true; } let type_layer = moves_type(app, &t); From 1c4864cb37d2a777fd239e99a61f6043cca3a706 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 27 Oct 2026 12:33:55 -0400 Subject: [PATCH 03/60] docs(move): document the one-click transform regression test (#8208) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/move_auto_select_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index e2c3916252..79eb579ae9 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -258,6 +258,8 @@ fn shift_click_adds_shape_layers_one_after_another() { assert_eq!(selected, ids); } +/// #8208: with Show Transform Controls on, one click outside a resized box both commits the +/// resize and Auto-Selects the layer under the pointer; it no longer takes a second click. #[test] fn one_click_accepts_a_resized_box_and_picks_the_layer_under_it() { let (mut app, a, b) = app(); From 9ef07f4bd9df910aa62fcae3fb513b69d1d457d5 Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Tue, 27 Oct 2026 17:42:43 +0100 Subject: [PATCH 04/60] Document attribution for guide unit screenshots --- ATTRIBUTION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fdec613767..478fb049a7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -89,6 +89,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/new-guide-units-*.png` | New Guide position unit selector, before and after (no artwork) | PhotoCraft contributors | Original PhotoCraft screenshot and offscreen `snapshot` example | MIT OR Apache-2.0 | | `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | | `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` | From 04bcb7e302d1eefe218306a4f4eb404ba5a30c65 Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Tue, 27 Oct 2026 17:43:27 +0100 Subject: [PATCH 05/60] Document selectable guide position units --- docs/ui-dialogs.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ui-dialogs.md b/docs/ui-dialogs.md index 634c7711a0..06cc4fee14 100644 --- a/docs/ui-dialogs.md +++ b/docs/ui-dialogs.md @@ -45,6 +45,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor | Match | Module | Role | |---|---|---| | `__guideLayout` | `guide_layout_ui.rs` | View › New Guide Layout… | +| `__newGuide` | `new_guide_ui.rs` | New Guide / Edit Selected Guides; position with a unit dropdown | | `__fill` | `fill_ui.rs` | Edit › Fill… | | `__patternFill` | `pattern_fill_ui.rs` | Pattern Fill layer / New Fill Layer › Pattern… | | `__stroke` | `stroke_ui.rs` | Edit › Stroke… | @@ -74,6 +75,13 @@ actions with their marker already set. ### Shared private keys +New Guide and Edit Selected Guides open in the ruler unit and offer Pixels, Millimeters, Inches, +Centimeters, Points, Picas and Percent beside Position. Selecting a unit converts the draft using +the active document's resolution without moving the guide. Bare numbers use the selected unit +(`__unit`, a suffix such as `px`, `mm` or `in`); a typed suffix overrides it. Decimal commas are +accepted. Percent uses width for vertical guides and height for horizontal guides. OK dispatches +the existing guide command; Cancel discards the draft. + | Key | Meaning | |---|---| | `__command` | Engine command id run on OK (bare confirm, filters, sizing, …) | @@ -97,6 +105,7 @@ actions with their marker already set. | Key | Module | |---|---| | `__guideLayout` | `guide_layout_ui` | +| `__newGuide` | `new_guide_ui` | | `__fill` | `fill_ui` | | `__patternFill` | `pattern_fill_ui` | | `__stroke` | `stroke_ui` | @@ -216,6 +225,7 @@ Same hub entry as standalone pickers; `color_picker_ui::confirm` branches on ext | Date | Change | |---|---| +| 2026-10-27 | New Guide / Edit Selected Guides: visible position unit dropdown and resolution-aware conversion | | 2026-10-11 | Initial doc from dialog codebase audit | | 2026-10-11 | Document all `DialogKind` values and `Command` routing markers | | 2026-10-11 | Alt+Cancel → Reset hub (`dialog_alt_reset`); opt-in markers | From 95cba18ba5e10c40623487dad6f9eb5df0316598 Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Tue, 27 Oct 2026 17:44:07 +0100 Subject: [PATCH 06/60] Add before and after screenshots of guide unit selection --- docs/images/new-guide-units-after.png | Bin 0 -> 49831 bytes docs/images/new-guide-units-before.png | Bin 0 -> 56638 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/new-guide-units-after.png create mode 100644 docs/images/new-guide-units-before.png diff --git a/docs/images/new-guide-units-after.png b/docs/images/new-guide-units-after.png new file mode 100644 index 0000000000000000000000000000000000000000..ef9409d83d1ed09ebbe5058466548718829134aa GIT binary patch literal 49831 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

os61p-8J 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 B++Y9z literal 0 HcmV?d00001 diff --git a/docs/images/new-guide-units-before.png b/docs/images/new-guide-units-before.png new file mode 100644 index 0000000000000000000000000000000000000000..cc050a575208b61bb8dc8d03f978aaee99144f11 GIT binary patch literal 56638 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

iS4YF2v>gS) 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 Date: Tue, 27 Oct 2026 17:44:21 +0100 Subject: [PATCH 07/60] Pass document context to the guide dialog --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c9d6f46d68..07328d610d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -501,7 +501,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { first_field_focused(ui, id, |ui| body(ui, &mut fields)); } } - DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(ui, &mut fields), + DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields), DialogKind::Command => {} DialogKind::LayerStyle => crate::layer_style::body(app, ui, &mut fields), From 9e5e685ccd35a7b691b8332e04acc68727e0c4aa Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Tue, 27 Oct 2026 17:46:22 +0100 Subject: [PATCH 08/60] Add selectable units to guide position input --- crates/ui-egui/src/dialogs/new_guide_ui.rs | 68 +++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index 8657576b57..0f9e19aca8 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -2,9 +2,10 @@ //! Photoshop. //! //! The position opens in the ruler unit ("0 cm") and takes any unit Photoshop's field takes: -//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". A bare number is in the ruler unit, as in -//! Photoshop. OK runs `view.newGuide` with the text, so the engine converts it with the document's -//! resolution. A position the engine refuses reopens the dialog with what was typed and the +//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". The unit dropdown converts the current +//! position without moving the guide; a bare number uses the selected unit. An explicit suffix +//! overrides that selection. OK runs `view.newGuide` with the text, so the engine converts it with +//! the document's resolution. A position the engine refuses reopens the dialog with what was typed and the //! engine's message, as Photoshop keeps New Guide open on a bad position. The //! dialog is an ordinary one in the UI state, so `ui.inspect`, `ui.dialog.confirm` and //! `ui.dialog.cancel` drive it. The last successful guide's orientation and colour open next @@ -36,6 +37,17 @@ const INITIAL_COLOR: &str = "__initialColor"; const CUSTOM: &str = "custom"; /// New Guide's colour when nothing was remembered, as in Photoshop. const DEFAULT_COLOR: &str = "cyan"; +const UNIT: &str = "__unit"; + +const UNITS: [(Unit, &str); 7] = [ + (Unit::Pixels, "Pixels"), + (Unit::Millimeters, "Millimeters"), + (Unit::Inches, "Inches"), + (Unit::Centimeters, "Centimeters"), + (Unit::Points, "Points"), + (Unit::Picas, "Picas"), + (Unit::Percent, "Percent"), +]; /// A colour's label in the list (the catalogue holds each one). pub(crate) fn color_label(name: &str) -> &'static str { @@ -73,6 +85,27 @@ fn ruler_unit(app: &PhotocraftApp) -> Unit { app.session.prefs().units_and_rulers.rulers } +fn selected_unit(app: &PhotocraftApp, f: &Map) -> Unit { + f.get(UNIT).and_then(Value::as_str).and_then(Unit::from_suffix).unwrap_or_else(|| ruler_unit(app)) +} + +/// Keep subpixel positions when switching units: ruler readout rounding would move the guide. +/// Invalid drafts stay untouched for the engine to reject on OK. +fn convert_position(app: &PhotocraftApp, position: &str, from: Unit, to: Unit, vertical: bool) -> Option { + let (value, suffix) = Unit::parse_length(position)?; + let doc = &app.session.active()?.doc; + let dpi = f64::from(doc.resolution_dpi); + let extent = f64::from(if vertical { doc.size.width } else { doc.size.height }); + let ppi = app.session.prefs().units_and_rulers.point_size.per_inch(); + let px = suffix.unwrap_or(from).to_px(value, dpi, extent, ppi); + let converted = to.from_px(px, dpi, extent, ppi); + if !px.is_finite() || !converted.is_finite() { + return None; + } + let number = format!("{converted:.12}"); + Some(with_unit(number.trim_end_matches('0').trim_end_matches('.'), to)) +} + /// "0 cm", "0 px", "0 %": a value in `unit` as the field shows it, with a space before the unit /// as in Photoshop. fn with_unit(value: &str, unit: Unit) -> String { @@ -105,6 +138,7 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { f.insert("__label".into(), json!("New Guide…")); f.insert("orientation".into(), json!(orientation)); f.insert("position".into(), json!(position)); + f.insert(UNIT.into(), json!(ruler_unit(app).suffix())); f.insert("color".into(), json!(color)); f.insert("customColor".into(), json!(custom)); app.ui.open_dialog(DialogKind::Command, f) @@ -142,15 +176,17 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result { f.insert(EDIT.into(), json!({"orientation": orientation, "index": index})); f.insert("orientation".into(), json!(orientation)); f.insert("position".into(), json!(in_ruler_unit(app, px, vertical))); + f.insert(UNIT.into(), json!(ruler_unit(app).suffix())); f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() })); f.insert("color".into(), json!(color)); f.insert("customColor".into(), json!(custom)); Ok(app.ui.open_dialog(DialogKind::Command, f)) } -pub fn body(ui: &mut egui::Ui, f: &mut Map) { +pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string(); let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string(); + let mut unit = selected_unit(app, f); let mut color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR).to_string(); if let Some(error) = f.get("__error").and_then(Value::as_str) { ui.colored_label(ui.visuals().error_fg_color, error); @@ -164,7 +200,24 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { }); ui.end_row(); let position_label = ui.label(tl!("Position")); - ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); + ui.horizontal(|ui| { + let response = ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); + if ui.is_enabled() { + crate::field_tab::register(ui.ctx(), response.id); + } + if response.gained_focus() { + crate::field_tab::select_all(ui.ctx(), response.id, &position); + } + if let Some((_, Some(typed_unit))) = Unit::parse_length(&position) { + unit = typed_unit; + } + let previous = unit; + if crate::widgets::dropdown(ui, "new-guide-unit", tl!("Units"), &mut unit, &UNITS, 110.0) + && let Some(converted) = convert_position(app, &position, previous, unit, orientation == "vertical") + { + position = converted; + } + }); ui.end_row(); ui.label(tl!("Color")); ui.horizontal(|ui| { @@ -186,16 +239,17 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { }); f.insert("orientation".into(), json!(orientation)); f.insert("position".into(), json!(position)); + f.insert(UNIT.into(), json!(unit.suffix())); f.insert("color".into(), json!(color)); } -/// The command's parameters: a bare number gets the ruler unit; anything else goes as typed, for +/// The command's parameters: a bare number gets the selected unit; anything else goes as typed, for /// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". pub fn params(app: &PhotocraftApp, f: &Map) -> Value { let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); let position = match Unit::parse_length(typed) { - Some((_, None)) => with_unit(typed, ruler_unit(app)), + Some((_, None)) => with_unit(typed, selected_unit(app, f)), _ => typed.to_string(), }; let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); From 4f1ab3a2a418d340b69d7d33a7b988448e175197 Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Tue, 27 Oct 2026 17:46:44 +0100 Subject: [PATCH 09/60] Test guide position units and keyboard interaction --- .../ui-egui/src/dialogs/new_guide_ui/tests.rs | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index 4bf4fa80d6..eaf8bc178f 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -5,8 +5,12 @@ use crate::menus::invoke as menu; /// One 4 × 2 inch document at 100 ppi, with the rulers in `unit`. fn app(unit: &str) -> (PhotocraftApp, egui::Context) { + app_at_resolution(unit, 100) +} + +fn app_at_resolution(unit: &str, dpi: u32) -> (PhotocraftApp, egui::Context) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + app.run("file.new", json!({"width": 400, "height": 200, "resolution": dpi})).unwrap(); app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap(); app.sync_views(); (app, egui::Context::default()) @@ -142,3 +146,81 @@ fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() { crate::dialogs::cancel(&mut app, d).unwrap(); } } + +#[test] +fn bare_positions_use_the_selected_unit_at_the_document_resolution() { + for (unit, position, expected) in [("px", "12,5", 12.5), ("mm", "25,4", 300.0), ("in", "1,5", 450.0)] { + let (mut app, ctx) = app_at_resolution("pixels", 300); + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", position); + app.ui.dialog_mut(d).unwrap().fields.insert(UNIT.into(), json!(unit)); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(guides(&app), (vec![], vec![expected]), "{position} {unit}"); + } +} + +#[test] +fn unit_conversion_preserves_fractional_negative_positions_and_percent_extents() { + let (app, _) = app_at_resolution("pixels", 300); + let mut position = "-123.456789 px".to_string(); + let mut from = Unit::Pixels; + for to in [Unit::Millimeters, Unit::Inches, Unit::Pixels] { + position = convert_position(&app, &position, from, to, true).unwrap(); + from = to; + } + assert!((Unit::parse_length(&position).unwrap().0 + 123.456789).abs() < 1e-8, "{position}"); + assert_eq!(convert_position(&app, "50%", Unit::Pixels, Unit::Pixels, true).unwrap(), "200 px"); + assert_eq!(convert_position(&app, "50%", Unit::Pixels, Unit::Pixels, false).unwrap(), "100 px"); + for invalid in ["", "-", "12 furlongs", "NaN", "inf"] { + assert!(convert_position(&app, invalid, Unit::Pixels, Unit::Inches, true).is_none()); + } +} + +#[test] +fn editing_an_existing_guide_accepts_the_selected_unit_and_undo_restores_it() { + let (mut app, _) = app("pixels"); + app.run(COMMAND, json!({"orientation": "horizontal", "position": 10})).unwrap(); + crate::guide_select::select(&mut app, false, 0); + let d = open_edit(&mut app).unwrap(); + set(&mut app, d, "vertical", "25,4"); + app.ui.dialog_mut(d).unwrap().fields.insert(UNIT.into(), json!("mm")); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(guides(&app), (vec![], vec![100.0])); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(guides(&app), (vec![10.0], vec![])); +} + +#[test] +fn unit_dropdown_converts_the_draft_and_bare_typing_uses_its_selection() { + use egui_kittest::{Harness, kittest::Queryable}; + + let (app, ctx) = app_at_resolution("pixels", 300); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = open(h.state_mut(), &ctx); + set(h.state_mut(), d, "vertical", "300 px"); + h.run_steps(3); + for (label, position, suffix) in [("Millimeters", "25.4 mm", "mm"), ("Inches", "1 in", "in"), ("Pixels", "300 px", "px")] { + h.get_by_label("Units").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); + let fields = &h.state().ui.dialogs.iter().find(|dialog| dialog.id == d).unwrap().fields; + assert_eq!(fields["position"], position); + assert_eq!(fields[UNIT], suffix); + assert_eq!(guides(h.state()), (vec![], vec![]), "changing units only edits the draft"); + } + // An explicit typed suffix also updates the dropdown; Tab returns to the position field. + set(h.state_mut(), d, "vertical", "25.4 mm"); + h.run_steps(3); + assert_eq!(h.get_by_label("Units").value().as_deref(), Some("Millimeters")); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert!(h.get_by_label("Position").is_focused()); + h.event(egui::Event::Text("12,7".into())); + h.run_steps(3); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(guides(h.state()), (vec![], vec![150.0])); + assert!(h.state().ui.dialogs.is_empty()); +} From 4afbfef2ae7303c5067d1bee6a355ecb2d553b9b Mon Sep 17 00:00:00 2001 From: jacabus Date: Wed, 28 Oct 2026 15:56:22 +1100 Subject: [PATCH 10/60] =?UTF-8?q?ARTCRAFT-PC-2026-001:=20gradient=20layer?= =?UTF-8?q?=20import=20=E2=80=94=20one-stop=20guard=20and=20explicit=20def?= =?UTF-8?q?aults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 2: gradient_color_stops now returns None when fewer than 2 color stops are present, preventing a partial broken gradient layer from being created from a malformed descriptor. Fix 3: gradient_layer_set_params now always writes explicit values for reverse (true/false), dither (true/false), scale (value or 100.0), offset ([h, v] always, defaulting to [0, 0]), and transparency stops (always included, even if all opacities are 100). Fix 1 (midpoints) not applied: gradient.fill.set does not accept midpoints as a third element on individual stops. It accepts them via a separate "midpoints" array parameter — that conversion is deferred. Co-Authored-By: Claude Sonnet 4.6 --- crates/engine/src/atn_cmds.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 0928226247..006a89aedb 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1165,14 +1165,18 @@ const GRADIENT_STYLES: &[(&str, &str)] = &[ ]; /// Color stops from a `gradientLayer` Grad descriptor: `[[location_0_to_1, "#rrggbb"], …]`. +/// Returns `None` if the descriptor is malformed or has fewer than 2 stops. fn gradient_color_stops(grad: &Descriptor) -> Option> { let clrs = get_list(grad, "Clrs")?; - clrs.iter().map(|item| { + let stops: Option> = clrs.iter().map(|item| { let D::Descriptor(stop) = item else { return None }; let hex = color_hex(get_obj(stop, "Clr ")?)?; let loc = get_num(stop, "Lctn")? / 4096.0; Some(json!([loc, hex])) - }).collect() + }).collect(); + let stops = stops?; + if stops.len() < 2 { return None; } + Some(stops) } /// Transparency stops from a `gradientLayer` Grad descriptor: `[[location_0_to_1, opacity_0_to_100], …]`. @@ -1195,22 +1199,14 @@ fn gradient_layer_set_params(t: &Descriptor) -> Option { p.insert("style".into(), style.into()); p.insert("angle".into(), angle.into()); p.insert("stops".into(), Value::Array(stops)); - let trns = gradient_transparency_stops(grad); - if trns.iter().any(|s| s[1].as_f64() != Some(100.0)) { - p.insert("transparency".into(), Value::Array(trns)); - } - if get_bool(t, "Rvrs") == Some(true) { p.insert("reverse".into(), true.into()); } - if get_bool(t, "Dthr") == Some(true) { p.insert("dither".into(), true.into()); } - if let Some(s) = get_num(t, "Scl ").filter(|&s| (s - 100.0).abs() > 0.01) { - p.insert("scale".into(), s.into()); - } - if let Some(ofst) = get_obj(t, "Ofst") { - let h = get_num(ofst, "Hrzn").unwrap_or(0.0); - let v = get_num(ofst, "Vrtc").unwrap_or(0.0); - if h.abs() > 0.001 || v.abs() > 0.001 { - p.insert("offset".into(), json!([h, v])); - } - } + p.insert("transparency".into(), Value::Array(gradient_transparency_stops(grad))); + p.insert("reverse".into(), get_bool(t, "Rvrs").unwrap_or(false).into()); + p.insert("dither".into(), get_bool(t, "Dthr").unwrap_or(false).into()); + p.insert("scale".into(), get_num(t, "Scl ").unwrap_or(100.0).into()); + let (h, v) = get_obj(t, "Ofst") + .map(|ofst| (get_num(ofst, "Hrzn").unwrap_or(0.0), get_num(ofst, "Vrtc").unwrap_or(0.0))) + .unwrap_or((0.0, 0.0)); + p.insert("offset".into(), json!([h, v])); Some(Value::Object(p)) } From d719adb48e36553f7165b0e9f4966f9b7f490c3c Mon Sep 17 00:00:00 2001 From: jacabus Date: Wed, 28 Oct 2026 16:00:10 +1100 Subject: [PATCH 11/60] =?UTF-8?q?ARTCRAFT-PC-2026-001:=20gradient=20layer?= =?UTF-8?q?=20import=20=E2=80=94=20midpoint=20passthrough=20(Fix=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add gradient_midpoints() which reads Mdpn (0–100) from each color stop descriptor, converts to the engine's 0..1 range, and returns one value per segment (n-1 for n stops; the last stop's Mdpn is irrelevant). Returns None when all segments are at the default (0.5) so the key is omitted. gradient_layer_set_params now inserts "midpoints" when any segment differs from the default, passing it to gradient.fill.set which accepts a flat float array at that key. Co-Authored-By: Claude Sonnet 4.6 --- crates/engine/src/atn_cmds.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 006a89aedb..6c56d21af7 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1179,6 +1179,21 @@ fn gradient_color_stops(grad: &Descriptor) -> Option> { Some(stops) } +/// Per-segment midpoints from a `gradientLayer` Grad descriptor, in 0..1 range (Photoshop +/// stores 0–100 in `Mdpn`; 50 is the default). Returns `None` when all segments use the +/// default so the caller can omit the `"midpoints"` key. One value per segment (one fewer +/// than stops); `Mdpn` on the last stop describes no segment and is ignored. +fn gradient_midpoints(grad: &Descriptor) -> Option> { + let clrs = get_list(grad, "Clrs")?; + let n = clrs.len().saturating_sub(1); + let mids: Vec = clrs.iter().take(n).map(|item| { + let D::Descriptor(stop) = item else { return 0.5 }; + get_num(stop, "Mdpn").map(|m| m / 100.0).unwrap_or(0.5) + }).collect(); + if mids.iter().all(|&m| (m - 0.5).abs() < 0.001) { return None; } + Some(mids) +} + /// Transparency stops from a `gradientLayer` Grad descriptor: `[[location_0_to_1, opacity_0_to_100], …]`. fn gradient_transparency_stops(grad: &Descriptor) -> Vec { get_list(grad, "Trns").unwrap_or(&[]).iter().filter_map(|item| { @@ -1200,6 +1215,9 @@ fn gradient_layer_set_params(t: &Descriptor) -> Option { p.insert("angle".into(), angle.into()); p.insert("stops".into(), Value::Array(stops)); p.insert("transparency".into(), Value::Array(gradient_transparency_stops(grad))); + if let Some(mids) = gradient_midpoints(grad) { + p.insert("midpoints".into(), Value::Array(mids.into_iter().map(Value::from).collect())); + } p.insert("reverse".into(), get_bool(t, "Rvrs").unwrap_or(false).into()); p.insert("dither".into(), get_bool(t, "Dthr").unwrap_or(false).into()); p.insert("scale".into(), get_num(t, "Scl ").unwrap_or(100.0).into()); From 321c25ce4757401471f2c9aee6252757c85bb950 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Fri, 30 Oct 2026 23:59:37 -0600 Subject: [PATCH 12/60] Move tool: an arrow nudge is one screen pixel, as in Photoshop (#9511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain arrow nudge moved layers 1 document pixel at any zoom. Photoshop moves one screen pixel: floor(1 / zoom) document pixels, at least 1 (4 at 25 %, 2 at 50 %, 1 at 66.7 % and above), while ⇧ stays 10 px. Fixes #9511 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/move_mods.rs | 17 +++++++++++++++-- crates/ui-egui/src/tools/move_mods/tests.rs | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/move_mods.rs b/crates/ui-egui/src/tools/move_mods.rs index 4c96702694..c311ac2ce8 100644 --- a/crates/ui-egui/src/tools/move_mods.rs +++ b/crates/ui-egui/src/tools/move_mods.rs @@ -198,7 +198,20 @@ pub fn abandon(app: &mut PhotocraftApp) { } } -/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px; +/// The document pixels one plain arrow press moves layers by with the Move tool: one screen +/// pixel, as in Photoshop (#9511). Zoomed out that is several document pixels (2 at 50 %, 4 at +/// 25 %; 1 at 66.7 %: whole pixels only), zoomed in still one. ⇧'s 10 px don't scale. Free +/// Transform keeps 1 px. +fn screen_pixel_step(app: &PhotocraftApp) -> f64 { + let zoom = f64::from(app.current_zoom()); + if app.ui.transform.is_some() || !zoom.is_finite() || zoom <= 0.0 { + return 1.0; + } + (1.0 / zoom + 1e-6).floor().clamp(1.0, 1000.0) +} + +/// Arrow keys with the Move tool (or while Free Transform is active): nudge one screen pixel +/// (1 px at 100 %, [`screen_pixel_step`]), ⇧ 10 px; /// ⌥ duplicates the layers first. ⌘-arrows (Ctrl off the Mac) do the same from most other tools, /// where ⌘ is the temporary Move tool (`hold_keys::cmd_nudges`, #2474). Returns true when a key /// was used. @@ -218,7 +231,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { // Most specific first: `consume_key` ignores extra ⇧ / ⌥ (`matches_logically`). for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] { if ctx.input_mut(|i| i.consume_key(base | mods, key)) { - let k = if mods.shift { 10.0 } else { 1.0 }; + let k = if mods.shift { 10.0 } else { screen_pixel_step(app) }; app.nudge_bundle.now = ctx.input(|i| i.time); nudge(app, ux * k, uy * k, mods.alt); return true; diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index e42e24875e..82b6b3e792 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -523,3 +523,19 @@ fn arrow_keys_ignored_during_text_edit() { assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT)); assert_eq!(bounds(&app, id), b0, "arrow keys do not nudge while editing text"); } + +/// #9511: as in Photoshop, a plain arrow nudge is one screen pixel, so zoomed out it moves the +/// layer several document pixels (floor of 1 / zoom: 4 at 25 %, 2 at 50 %, 1 at 66.7 %, 1 at +/// 100 % and above); ⇧ is 10 document pixels at any zoom. +#[test] +fn a_nudge_is_one_screen_pixel() { + for (zoom, step) in [(0.25, 4), (0.5, 2), (2.0 / 3.0, 1), (1.0, 1), (2.0, 1)] { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.views[0].zoom = zoom; + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(left_edge(&app, id), 8 + step, "→ at {zoom}"); + assert!(press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::SHIFT)); + assert_eq!(left_edge(&app, id), 8 + step - 10, "⇧← at {zoom}"); + } +} From 6d9ddc66e1112eb0c72c9cdab80fffeb6175f8fb Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 00:26:13 -0600 Subject: [PATCH 13/60] Move tool: no 1000 px cap on the screen-pixel nudge (CodeRabbit review on crates/ui-egui/src/tools/move_mods.rs:210) A large document zooms out until its shorter side is 2 px on screen (zoom_levels::min), below 0.1 %, so one screen pixel can be more than 1000 document pixels (6000 at a 12,000 x 12,000 px document's lowest zoom). The step is now floor(1 / zoom), at least 1, with no upper cap; the zoom's own floor bounds it at half the shorter side. The rounding margin is now relative: an f32 zoom rung is a hair off its value (0.1 % is 1 / 999.99995), so the old absolute 1e-6 gave 999 at 0.1 % and 499 at 0.2 %. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/move_mods.rs | 8 +++++--- crates/ui-egui/src/tools/move_mods/tests.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/move_mods.rs b/crates/ui-egui/src/tools/move_mods.rs index c311ac2ce8..ee48a7da2a 100644 --- a/crates/ui-egui/src/tools/move_mods.rs +++ b/crates/ui-egui/src/tools/move_mods.rs @@ -200,14 +200,16 @@ pub fn abandon(app: &mut PhotocraftApp) { /// The document pixels one plain arrow press moves layers by with the Move tool: one screen /// pixel, as in Photoshop (#9511). Zoomed out that is several document pixels (2 at 50 %, 4 at -/// 25 %; 1 at 66.7 %: whole pixels only), zoomed in still one. ⇧'s 10 px don't scale. Free -/// Transform keeps 1 px. +/// 25 %; 1 at 66.7 %: whole pixels only), zoomed in still one. No cap: the zoom bottoms out +/// with the document's shorter side 2 px on screen (`zoom_levels::min`), so a step is at most +/// half that side. ⇧'s 10 px don't scale. Free Transform keeps 1 px. fn screen_pixel_step(app: &PhotocraftApp) -> f64 { let zoom = f64::from(app.current_zoom()); if app.ui.transform.is_some() || !zoom.is_finite() || zoom <= 0.0 { return 1.0; } - (1.0 / zoom + 1e-6).floor().clamp(1.0, 1000.0) + // A relative margin: the f32 zoom is a hair off its rung (0.1 % is 1 / 999.99995). + (1.0 / zoom * (1.0 + 1e-6)).floor().max(1.0) } /// Arrow keys with the Move tool (or while Free Transform is active): nudge one screen pixel diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index 82b6b3e792..61fd9876cc 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -539,3 +539,19 @@ fn a_nudge_is_one_screen_pixel() { assert_eq!(left_edge(&app, id), 8 + step - 10, "⇧← at {zoom}"); } } + +/// #9511: below 0.1 % (a large document zooms out until its shorter side is 2 px) the nudge is +/// still one screen pixel: 6000 document pixels at a 12,000 × 12,000 px document's lowest zoom, +/// not capped at 1000. The ladder's f32 rungs (0.1 %, 0.2 %) still give whole steps (1000, +/// not 999). +#[test] +fn a_nudge_is_one_screen_pixel_below_a_tenth_of_a_percent() { + let lowest = crate::zoom_levels::min([12_000, 12_000]); + for (zoom, step) in [(0.002, 500), (0.001, 1000), (0.0005, 2000), (lowest, 6000)] { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.views[0].zoom = zoom; + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(left_edge(&app, id), 8 + step, "→ at {zoom}"); + } +} From ba94972ffa7b81a4b28ba4cc6c40c82b7fe9ea63 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 00:51:22 -0600 Subject: [PATCH 14/60] Move tool: snap the nudge only within f32 precision of a whole step (CodeRabbit review on move_mods.rs:212) Test: a_nudge_is_one_screen_pixel_below_a_tenth_of_a_percent gains 0.3333334 -> 2 (was 3) and the f32 third -> 3. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/move_mods.rs | 7 +++++-- crates/ui-egui/src/tools/move_mods/tests.rs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/move_mods.rs b/crates/ui-egui/src/tools/move_mods.rs index ee48a7da2a..4acbb80558 100644 --- a/crates/ui-egui/src/tools/move_mods.rs +++ b/crates/ui-egui/src/tools/move_mods.rs @@ -208,8 +208,11 @@ fn screen_pixel_step(app: &PhotocraftApp) -> f64 { if app.ui.transform.is_some() || !zoom.is_finite() || zoom <= 0.0 { return 1.0; } - // A relative margin: the f32 zoom is a hair off its rung (0.1 % is 1 / 999.99995). - (1.0 / zoom * (1.0 + 1e-6)).floor().max(1.0) + // A ladder rung is a hair off in f32 (0.1 % is 1 / 999.99995): snap a reciprocal within f32 + // precision of a whole number to it, and floor any other zoom (0.3333334 is 2, not 3). + let (r, whole) = (1.0 / zoom, (1.0 / zoom).round()); + let steps = if (r - whole).abs() <= r * f64::from(f32::EPSILON) { whole } else { r.floor() }; + steps.max(1.0) } /// Arrow keys with the Move tool (or while Free Transform is active): nudge one screen pixel diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index 61fd9876cc..39ed988c91 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -547,7 +547,9 @@ fn a_nudge_is_one_screen_pixel() { #[test] fn a_nudge_is_one_screen_pixel_below_a_tenth_of_a_percent() { let lowest = crate::zoom_levels::min([12_000, 12_000]); - for (zoom, step) in [(0.002, 500), (0.001, 1000), (0.0005, 2000), (lowest, 6000)] { + // 0.3333334 is just above a third, off the ladder (pinch, Zoom tool drag): 2.9999994 screen + // pixels' worth floors to 2, while the ladder's own f32 third still gives 3 (#10438 review). + for (zoom, step) in [(0.002, 500), (0.001, 1000), (0.0005, 2000), (lowest, 6000), (0.333_333_4, 2), (1.0 / 3.0, 3)] { let mut app = app_with_layer(); let id = app.session.active().unwrap().active_layer.unwrap(); app.ui.views[0].zoom = zoom; From f2fe5d94c3fab92b3f81457bf257063c49d16801 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 13:42:51 -0600 Subject: [PATCH 15/60] Select and Mask: resize the brush with Alt+right-drag Alt+right-drag, or Control+Alt-drag, on the image now changes the current brush tool's size (and the Brush tool's hardness) with the circle anchored where the drag began and a Diameter/Hardness readout, as on the main canvas and in Liquify. It no longer starts a selection stroke; Alt+left-drag still subtracts. Fixes #10497 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 58 ++++++++++++++--- .../src/dialogs/select_mask_ui/tests.rs | 63 +++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index ebef97f740..c54af2e9af 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -426,6 +426,9 @@ pub struct SelectMaskWorkspace { /// Document px per reduced-copy px (High Quality Preview uses 1). k: u32, pressed: bool, + /// An on-canvas brush resize in progress (#10497): where it began on screen, and the size and + /// hardness then. + resize: Option<(Pos2, f64, f32)>, /// The device's texture side limit, as the context reports it each frame. texture_limit: usize, image_tex: Option, @@ -761,6 +764,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { hide_view: false, k, pressed: false, + resize: None, texture_limit: 2048, image_tex: None, zoom: 0.0, @@ -1747,13 +1751,27 @@ fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::P ctx.set_cursor_icon(egui::CursorIcon::Grab); } else if w.tool.brushes() { let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); - clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); - clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); - let add = w.add != alt; - for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { - clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); - if add { - clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + // While its size is dragged the circle stays where the drag began, with the + // Diameter/Hardness readout beside the pointer, as on the main canvas (#10497). + let center = w.resize.map_or(hp, |(anchor, ..)| anchor); + clip.circle_stroke(center, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(center, r, Stroke::new(0.75, Color32::WHITE)); + if w.resize.is_some() { + let hardness = if w.tool == SmTool::Brush { format!("{}%", w.hardness.round() as i64) } else { "—".into() }; + crate::canvas::draw_readout( + ctx, + "select-mask-brush-resize-readout", + hp, + ["Diameter:", "Hardness:"], + [format!("{} px", w.size().round() as i64), hardness], + ); + } else { + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } } } ctx.set_cursor_icon(egui::CursorIcon::None); @@ -1780,6 +1798,32 @@ fn canvas_input( events: &mut Vec, mods: &mut egui::Modifiers, ) { + // Alt+right-drag, or Control+Alt-drag, resizes the brush instead of selecting (#10497), from + // the press on, so its first frame never starts a stroke. Alt+left-drag still subtracts. + let (secondary, now) = ui.input(|i| (i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); + let gesture = if secondary { crate::brush_resize::is_right_gesture(now) } else { crate::brush_resize::is_gesture(now) }; + if !pan && w.tool.brushes() && !w.pressed && (w.resize.is_some() || (resp.is_pointer_button_down_on() && gesture)) { + if w.resize.is_none() + && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) + { + w.resize = Some((p, w.size(), w.hardness)); + } + if let (Some((anchor, size0, hardness0)), Some(p)) = (w.resize, resp.interact_pointer_pos()) { + let d = p - anchor; + let zoom = if w.zoom.is_finite() && w.zoom > 0.0 { w.zoom } else { 1.0 }; + let (size, hardness) = crate::brush_resize::resized((size0 as f32, hardness0 / 100.0), f64::from(d.x / zoom), f64::from(d.y / zoom), zoom); + w.set_size(f64::from(size)); + // Only the Brush tool has a hardness; Quick Selection and Refine Edge change size only. + if w.tool == SmTool::Brush { + w.hardness = (hardness * 100.0).round(); + } + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + w.resize = None; + } + ctx.request_repaint(); + return; + } if pan && resp.dragged() { let dl = resp.drag_delta(); w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 95a3a2ace9..b2212dcaec 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -808,3 +808,66 @@ fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { assert_eq!(selected_pixels(Some(&inverted), smaller), 999_999); assert_eq!(selected_pixels(None, area), 0); } + +/// Alt+right-drag (and Control+Alt-drag) on the image resizes the current brush tool instead of +/// selecting, as on the main canvas (#10497): only that tool's size changes, the Brush's hardness +/// follows vertical motion, nothing is painted, and the next Alt-drag still subtracts. +#[test] +fn alt_right_drag_resizes_the_workspace_brush() { + use egui::{PointerButton, Pos2}; + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": "brush"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::distort_ui::keys(app, &ctx); + keys(app, &ctx); + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let over = Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0); + let drag = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, button: PointerButton, modifiers: Modifiers, by: egui::Vec2| { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: over, button, pressed: true, modifiers }); + h.run_steps(1); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(over + by * (k as f32 / 4.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: over + by, button, pressed: false, modifiers }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + drag(&mut h, PointerButton::Secondary, Modifiers::ALT, vec2(40.0, -60.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + assert_eq!(w.brush_size, want, "horizontal motion sets the diameter (zoom {zoom})"); + assert_eq!(w.hardness, 70.0, "upward motion softens the brush, 200 points for 100 %"); + assert_eq!((w.quick_size, w.refine_size), (30.0, 30.0), "the other tools keep their sizes"); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "resizing selected nothing"); + assert!(w.resize.is_none(), "the gesture ends with the button"); + // Alt with the left button is still the subtracting stroke. + drag(&mut h, PointerButton::Primary, Modifiers::ALT, vec2(30.0, 0.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + // A Brush stroke is an edit (Quick Selection's are strokes). + assert_eq!(w.edits.len(), 1, "Alt+left-drag selects"); + assert_eq!(w.edits[0]["mode"], json!("subtract")); + assert_eq!(w.brush_size, want, "and doesn't resize"); + // Control+Alt-drag (the macOS gesture) resizes Quick Selection on its own. + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick); + drag(&mut h, PointerButton::Primary, Modifiers::CTRL | Modifiers::ALT, vec2(-10.0, 0.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!(w.quick_size, (30.0 - 2.0 * f64::from(10.0 / zoom)).round().max(1.0)); + assert_eq!((w.brush_size, w.hardness), (want, 70.0), "the Brush keeps its size and hardness"); + assert!(w.strokes.is_empty() && w.edits.len() == 1, "resizing Quick Selection selected nothing"); +} From 51275cc5bbb853cf698737810d82e09909f0d6fe Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Sat, 31 Oct 2026 21:02:36 +0100 Subject: [PATCH 16/60] Preserve precision when converting guide position units Serialize converted guide positions with f64 Display instead of fixed twelve-place rounding. Keep decimal text accepted by Unit::parse_length, including small nonzero values. --- crates/ui-egui/src/dialogs/new_guide_ui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index 0f9e19aca8..b9e9c0c683 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -102,8 +102,8 @@ fn convert_position(app: &PhotocraftApp, position: &str, from: Unit, to: Unit, v if !px.is_finite() || !converted.is_finite() { return None; } - let number = format!("{converted:.12}"); - Some(with_unit(number.trim_end_matches('0').trim_end_matches('.'), to)) + // Display preserves the f64 value without exponent notation, which parse_length rejects. + Some(with_unit(&converted.to_string(), to)) } /// "0 cm", "0 px", "0 %": a value in `unit` as the field shows it, with a space before the unit From b47531717326bcc8a832e0b87a66c99b4921a8fc Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Sat, 31 Oct 2026 21:03:21 +0100 Subject: [PATCH 17/60] Test guide unit switches preserve small and precise positions Exercise the unit dropdown through inches, millimeters and pixels, then confirm the guide. Cover tiny positive and negative positions, high-precision fractions, zero and integers. The regression fails with fixed twelve-place serialization. --- .../ui-egui/src/dialogs/new_guide_ui/tests.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index eaf8bc178f..d3a426d031 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -176,6 +176,34 @@ fn unit_conversion_preserves_fractional_negative_positions_and_percent_extents() } } +#[test] +fn switching_units_and_confirming_preserves_high_precision_positions() { + use egui_kittest::{Harness, kittest::Queryable}; + + for original in [-1e-10_f64, 1e-10, -1e-100, 1e-100, -123.45678901234567, 0.0, 100.0, 123.45678901234567] { + let (app, ctx) = app_at_resolution("pixels", 300); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = open(h.state_mut(), &ctx); + set(h.state_mut(), d, "vertical", &format!("{original} px")); + h.run_steps(3); + for label in ["Inches", "Millimeters", "Pixels"] { + h.get_by_label("Units").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); + } + crate::dialogs::confirm(h.state_mut(), d).unwrap(); + let (horizontal, vertical) = guides(h.state()); + assert!(horizontal.is_empty()); + assert_eq!(vertical.len(), 1); + let actual = vertical[0]; + // Allow floating-point conversion error, but no loss from decimal-place rounding. + let tolerance = original.abs() * f64::EPSILON * 8.0; + assert!((actual - original).abs() <= tolerance, "{original} px became {actual} px"); + } +} + #[test] fn editing_an_existing_guide_accepts_the_selected_unit_and_undo_restores_it() { let (mut app, _) = app("pixels"); From 84f7ebe8c2c4122b961cedb14d5fee04ab664c69 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 14:31:36 -0600 Subject: [PATCH 18/60] Select and Mask: end a brush resize released while Space pans (CodeRabbit review on crates/ui-egui/src/dialogs/select_mask_ui.rs:1805) Releasing the button of an Alt+right-drag resize while Space (or the middle button) was down left the resize pending, so a later Space-drag that went on after Space came up resized the brush from the old anchor instead of painting. Letting go of the button now ends the resize whether or not a pan is under way. The resize test's harness and drag steps move into helpers shared by the new test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 4 + .../src/dialogs/select_mask_ui/tests.rs | 100 +++++++++++++----- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index c54af2e9af..4cb3e64edc 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -1802,6 +1802,10 @@ fn canvas_input( // the press on, so its first frame never starts a stroke. Alt+left-drag still subtracts. let (secondary, now) = ui.input(|i| (i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); let gesture = if secondary { crate::brush_resize::is_right_gesture(now) } else { crate::brush_resize::is_gesture(now) }; + // Space or the middle button may pan mid-resize; letting go of the button still ends it. + if pan && !ui.input(|i| i.pointer.any_down()) { + w.resize = None; + } if !pan && w.tool.brushes() && !w.pressed && (w.resize.is_some() || (resp.is_pointer_button_down_on() && gesture)) { if w.resize.is_none() && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index b2212dcaec..8beb63e158 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -809,15 +809,11 @@ fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { assert_eq!(selected_pixels(None, area), 0); } -/// Alt+right-drag (and Control+Alt-drag) on the image resizes the current brush tool instead of -/// selecting, as on the main canvas (#10497): only that tool's size changes, the Brush's hardness -/// follows vertical motion, nothing is painted, and the next Alt-drag still subtracts. -#[test] -fn alt_right_drag_resizes_the_workspace_brush() { - use egui::{PointerButton, Pos2}; +/// The workspace in a harness on `tool`, and a point over the image. +fn resize_harness(tool: &str) -> (egui_kittest::Harness<'static, PhotocraftApp>, egui::Pos2) { let mut app = app_with_selection(); open(&mut app).unwrap(); - ui(&mut app, json!({"tool": "brush"})); + ui(&mut app, json!({"tool": tool})); let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { let ctx = ui.ctx().clone(); @@ -832,23 +828,49 @@ fn alt_right_drag_resizes_the_workspace_brush() { ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); h.run_steps(4); - let over = Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0); - let drag = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, button: PointerButton, modifiers: Modifiers, by: egui::Vec2| { - h.event(egui::Event::ModifiersChanged(modifiers)); - h.event(egui::Event::PointerMoved(over)); + (h, egui::Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0)) +} + +/// Presses `button` at `at` with `modifiers` held. +fn pointer_down(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2, button: egui::PointerButton, modifiers: Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.run_steps(1); +} + +/// Moves the held pointer from `from` by `by`, in four steps. +fn pointer_move(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, by: egui::Vec2) { + for k in 1..=4 { + h.event(egui::Event::PointerMoved(from + by * (k as f32 / 4.0))); h.run_steps(1); - h.event(egui::Event::PointerButton { pos: over, button, pressed: true, modifiers }); - h.run_steps(1); - for k in 1..=4 { - h.event(egui::Event::PointerMoved(over + by * (k as f32 / 4.0))); - h.run_steps(1); - } - h.event(egui::Event::PointerButton { pos: over + by, button, pressed: false, modifiers }); - h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); - h.run_steps(2); - }; + } +} + +fn tap_key(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, key: Key, pressed: bool) { + h.event(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// A whole drag of `button` from `at` by `by` with `modifiers` held. +fn pointer_drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2, button: egui::PointerButton, modifiers: Modifiers, by: egui::Vec2) { + pointer_down(h, at, button, modifiers); + pointer_move(h, at, by); + h.event(egui::Event::PointerButton { pos: at + by, button, pressed: false, modifiers }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// Alt+right-drag (and Control+Alt-drag) on the image resizes the current brush tool instead of +/// selecting, as on the main canvas (#10497): only that tool's size changes, the Brush's hardness +/// follows vertical motion, nothing is painted, and the next Alt-drag still subtracts. +#[test] +fn alt_right_drag_resizes_the_workspace_brush() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; - drag(&mut h, PointerButton::Secondary, Modifiers::ALT, vec2(40.0, -60.0)); + pointer_drag(&mut h, over, PointerButton::Secondary, Modifiers::ALT, vec2(40.0, -60.0)); let w = h.state().distort.select_mask.as_ref().unwrap(); let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); assert_eq!(w.brush_size, want, "horizontal motion sets the diameter (zoom {zoom})"); @@ -857,7 +879,7 @@ fn alt_right_drag_resizes_the_workspace_brush() { assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "resizing selected nothing"); assert!(w.resize.is_none(), "the gesture ends with the button"); // Alt with the left button is still the subtracting stroke. - drag(&mut h, PointerButton::Primary, Modifiers::ALT, vec2(30.0, 0.0)); + pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::ALT, vec2(30.0, 0.0)); let w = h.state().distort.select_mask.as_ref().unwrap(); // A Brush stroke is an edit (Quick Selection's are strokes). assert_eq!(w.edits.len(), 1, "Alt+left-drag selects"); @@ -865,9 +887,39 @@ fn alt_right_drag_resizes_the_workspace_brush() { assert_eq!(w.brush_size, want, "and doesn't resize"); // Control+Alt-drag (the macOS gesture) resizes Quick Selection on its own. h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick); - drag(&mut h, PointerButton::Primary, Modifiers::CTRL | Modifiers::ALT, vec2(-10.0, 0.0)); + pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::CTRL | Modifiers::ALT, vec2(-10.0, 0.0)); let w = h.state().distort.select_mask.as_ref().unwrap(); assert_eq!(w.quick_size, (30.0 - 2.0 * f64::from(10.0 / zoom)).round().max(1.0)); assert_eq!((w.brush_size, w.hardness), (want, 70.0), "the Brush keeps its size and hardness"); assert!(w.strokes.is_empty() && w.edits.len() == 1, "resizing Quick Selection selected nothing"); } + +/// Space pressed mid-resize pans, and the button released before Space still ends the resize +/// (CodeRabbit on #10559): a Space-drag that goes on after Space comes up paints, as any +/// Space-drag does, instead of resizing the brush from the old anchor. +#[test] +fn a_resize_released_while_space_pans_lets_the_next_drag_paint() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, 0.0)); + tap_key(&mut h, Key::Space, true); + h.event(egui::Event::PointerButton { pos: over + vec2(40.0, 0.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().brush_size, want); + // Space+drag pans; Space comes up mid-drag and the drag goes on. + pointer_down(&mut h, over, PointerButton::Primary, Modifiers::NONE); + pointer_move(&mut h, over, vec2(0.0, 20.0)); + tap_key(&mut h, Key::Space, false); + pointer_move(&mut h, over + vec2(0.0, 20.0), vec2(30.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(30.0, 20.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!(w.brush_size, want, "the drag doesn't resize the brush"); + assert_eq!(w.edits.len(), 1, "the drag's rest paints"); + assert_eq!(w.edits[0]["mode"], json!("add")); + assert!(w.resize.is_none()); +} From 1c07f00188b600e22698c949b77e371e6bdb2f14 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 14:33:44 -0600 Subject: [PATCH 19/60] Select and Mask: keep a brush resize on the tool it began on (CodeRabbit review on crates/ui-egui/src/dialogs/select_mask_ui.rs:1809) Picking another tool by key mid-resize (let go of Alt, press W) applied the Brush's starting size to Quick Selection, and picking a non-brush tool (L) let the rest of the held drag draw a lasso selection. The resize now records its tool; when the tool changes, the rest of the drag is consumed until the button is let go, and the anchored circle and readout show only for that tool. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 31 ++++++++++----- .../src/dialogs/select_mask_ui/tests.rs | 39 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 4cb3e64edc..95dd00cc91 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -426,9 +426,9 @@ pub struct SelectMaskWorkspace { /// Document px per reduced-copy px (High Quality Preview uses 1). k: u32, pressed: bool, - /// An on-canvas brush resize in progress (#10497): where it began on screen, and the size and - /// hardness then. - resize: Option<(Pos2, f64, f32)>, + /// An on-canvas brush resize in progress (#10497): the tool it resizes, where it began on + /// screen, and the size and hardness then. + resize: Option<(SmTool, Pos2, f64, f32)>, /// The device's texture side limit, as the context reports it each frame. texture_limit: usize, image_tex: Option, @@ -1753,10 +1753,11 @@ fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::P let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); // While its size is dragged the circle stays where the drag began, with the // Diameter/Hardness readout beside the pointer, as on the main canvas (#10497). - let center = w.resize.map_or(hp, |(anchor, ..)| anchor); + let resize = w.resize.filter(|&(tool, ..)| tool == w.tool); + let center = resize.map_or(hp, |(_, anchor, ..)| anchor); clip.circle_stroke(center, r, Stroke::new(1.5, Color32::BLACK)); clip.circle_stroke(center, r, Stroke::new(0.75, Color32::WHITE)); - if w.resize.is_some() { + if resize.is_some() { let hardness = if w.tool == SmTool::Brush { format!("{}%", w.hardness.round() as i64) } else { "—".into() }; crate::canvas::draw_readout( ctx, @@ -1802,17 +1803,27 @@ fn canvas_input( // the press on, so its first frame never starts a stroke. Alt+left-drag still subtracts. let (secondary, now) = ui.input(|i| (i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); let gesture = if secondary { crate::brush_resize::is_right_gesture(now) } else { crate::brush_resize::is_gesture(now) }; - // Space or the middle button may pan mid-resize; letting go of the button still ends it. - if pan && !ui.input(|i| i.pointer.any_down()) { - w.resize = None; + // A resize keeps its drag until the button is let go: Space or the middle button may pan + // meanwhile, and if another tool is picked mid-drag the rest of it neither resizes nor + // selects with that tool. + if let Some((tool, ..)) = w.resize + && (pan || tool != w.tool) + { + if !ui.input(|i| i.pointer.any_down()) { + w.resize = None; + } + if !pan { + ctx.request_repaint(); + return; + } } if !pan && w.tool.brushes() && !w.pressed && (w.resize.is_some() || (resp.is_pointer_button_down_on() && gesture)) { if w.resize.is_none() && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { - w.resize = Some((p, w.size(), w.hardness)); + w.resize = Some((w.tool, p, w.size(), w.hardness)); } - if let (Some((anchor, size0, hardness0)), Some(p)) = (w.resize, resp.interact_pointer_pos()) { + if let (Some((_, anchor, size0, hardness0)), Some(p)) = (w.resize, resp.interact_pointer_pos()) { let d = p - anchor; let zoom = if w.zoom.is_finite() && w.zoom > 0.0 { w.zoom } else { 1.0 }; let (size, hardness) = crate::brush_resize::resized((size0 as f32, hardness0 / 100.0), f64::from(d.x / zoom), f64::from(d.y / zoom), zoom); diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 8beb63e158..a698cb8bab 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -923,3 +923,42 @@ fn a_resize_released_while_space_pans_lets_the_next_drag_paint() { assert_eq!(w.edits[0]["mode"], json!("add")); assert!(w.resize.is_none()); } + +/// A resize belongs to the tool it began on (CodeRabbit on #10559): if another tool is picked +/// mid-drag, the rest of the drag neither resizes that tool nor selects with it. +#[test] +fn picking_another_tool_mid_resize_neither_resizes_nor_selects() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + // Alt+right-drag the Brush larger, let go of Alt, press W (Quick Selection) and drag on. + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, 0.0)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + tap_key(&mut h, Key::W, true); + tap_key(&mut h, Key::W, false); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().tool, SmTool::Quick); + pointer_move(&mut h, over + vec2(40.0, 0.0), vec2(40.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(80.0, 0.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.quick_size), (want, 30.0), "Quick Selection keeps its size"); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "nothing was selected"); + // The same with the Lasso (not a brush) picked mid-drag: the rest of the drag draws no lasso. + tap_key(&mut h, Key::B, true); + tap_key(&mut h, Key::B, false); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(20.0, 0.0)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + tap_key(&mut h, Key::L, true); + tap_key(&mut h, Key::L, false); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().tool, SmTool::Lasso); + pointer_move(&mut h, over + vec2(20.0, 0.0), vec2(0.0, 60.0)); + pointer_move(&mut h, over + vec2(20.0, 60.0), vec2(-60.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(-40.0, 60.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "the Lasso selected nothing"); + assert!(w.resize.is_none(), "the gesture ends with the button"); +} From c171557ff8da6415724d1ba3379dd36148948b8f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 14:59:36 -0600 Subject: [PATCH 20/60] Select and Mask: end a brush resize when its own button comes up (CodeRabbit review on crates/ui-egui/src/dialogs/select_mask_ui.rs:1829) A resize ended only once no button at all was down. Releasing the right button while the middle button panned kept it pending, so a left-drag begun during that pan resized the brush from the old anchor on the frame the middle button came up. The resize now records the button that drags it and ends as soon as that button is let go. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 18 +++++----- .../src/dialogs/select_mask_ui/tests.rs | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 95dd00cc91..2ccb39e143 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -427,8 +427,8 @@ pub struct SelectMaskWorkspace { k: u32, pressed: bool, /// An on-canvas brush resize in progress (#10497): the tool it resizes, where it began on - /// screen, and the size and hardness then. - resize: Option<(SmTool, Pos2, f64, f32)>, + /// screen, the size and hardness then, and the button that drags it. + resize: Option<(SmTool, Pos2, f64, f32, egui::PointerButton)>, /// The device's texture side limit, as the context reports it each frame. texture_limit: usize, image_tex: Option, @@ -1803,13 +1803,14 @@ fn canvas_input( // the press on, so its first frame never starts a stroke. Alt+left-drag still subtracts. let (secondary, now) = ui.input(|i| (i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); let gesture = if secondary { crate::brush_resize::is_right_gesture(now) } else { crate::brush_resize::is_gesture(now) }; - // A resize keeps its drag until the button is let go: Space or the middle button may pan + // A resize keeps its drag until its button is let go: Space or the middle button may pan // meanwhile, and if another tool is picked mid-drag the rest of it neither resizes nor // selects with that tool. - if let Some((tool, ..)) = w.resize + let held = |button| ui.input(|i| i.pointer.button_down(button)); + if let Some((tool, .., button)) = w.resize && (pan || tool != w.tool) { - if !ui.input(|i| i.pointer.any_down()) { + if !held(button) { w.resize = None; } if !pan { @@ -1821,9 +1822,10 @@ fn canvas_input( if w.resize.is_none() && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { - w.resize = Some((w.tool, p, w.size(), w.hardness)); + let button = if secondary { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; + w.resize = Some((w.tool, p, w.size(), w.hardness, button)); } - if let (Some((_, anchor, size0, hardness0)), Some(p)) = (w.resize, resp.interact_pointer_pos()) { + if let (Some((_, anchor, size0, hardness0, _)), Some(p)) = (w.resize, resp.interact_pointer_pos()) { let d = p - anchor; let zoom = if w.zoom.is_finite() && w.zoom > 0.0 { w.zoom } else { 1.0 }; let (size, hardness) = crate::brush_resize::resized((size0 as f32, hardness0 / 100.0), f64::from(d.x / zoom), f64::from(d.y / zoom), zoom); @@ -1833,7 +1835,7 @@ fn canvas_input( w.hardness = (hardness * 100.0).round(); } } - if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + if resp.drag_stopped() || w.resize.is_some_and(|(.., button)| !held(button)) { w.resize = None; } ctx.request_repaint(); diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index a698cb8bab..10183a99c1 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -962,3 +962,39 @@ fn picking_another_tool_mid_resize_neither_resizes_nor_selects() { assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "the Lasso selected nothing"); assert!(w.resize.is_none(), "the gesture ends with the button"); } + +/// A resize ends when its own button comes up, even while the middle button pans (CodeRabbit on +/// #10559): a left-drag begun before the middle button is let go doesn't resize the brush from +/// the old anchor when the pan ends. +#[test] +fn a_resize_released_while_the_middle_button_pans_ends_with_its_button() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, 0.0)); + let at = over + vec2(40.0, 0.0); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().brush_size, want); + // The left button goes down while the middle one still pans, then the middle one comes up + // and the left drag goes on. + pointer_move(&mut h, at, vec2(0.0, 20.0)); + let at = at + vec2(0.0, 20.0); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + pointer_move(&mut h, at, vec2(30.0, -20.0)); + let at = at + vec2(30.0, -20.0); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + pointer_move(&mut h, at, vec2(60.0, -40.0)); + h.event(egui::Event::PointerButton { pos: at + vec2(60.0, -40.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.hardness), (want, 100.0), "the later drag doesn't resize the brush"); + assert!(w.resize.is_none(), "the gesture ended with its button"); +} From 0d235ddcf61d80303a2cb5dc81b6ada6e8e4acc1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 15:47:16 -0600 Subject: [PATCH 21/60] Re-run CI The gate failed in photocraft-compose's proptest the_split_route_agrees_with_the_exact_path (flaky on main, #10386), which this PR doesn't touch. Co-Authored-By: Claude Opus 5.5 From 342b659d2a562550e833ceb977ebd033ff12e976 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:08:37 -0600 Subject: [PATCH 22/60] Tests: nudge at 1600 %, a zoomed-out nudge run as one step, Free Transform keeps 1 px (FredApps review) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/move_mods/tests.rs | 39 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index 39ed988c91..3b27032dfa 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -335,6 +335,28 @@ fn a_run_of_arrow_nudges_is_one_history_step() { assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34), "and one Redo does it again"); } +/// #9511: zoomed out, where each press moves several document pixels, a run of nudges is still +/// one History state. +#[test] +fn a_run_of_arrow_nudges_is_one_history_step_zoomed_out() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.views[0].zoom = 0.5; + let h0 = history(&app); + // Twelve taps of 2 px each at 50 %, then ⇧ for ten more px: still the same run. + for i in 0..12 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 5.0 + 0.2 * f64::from(i))); + } + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34)); + assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move")); + assert!(app.session.undo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); + assert!(app.session.redo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34), "and one Redo does it again"); +} + /// A pause longer than Nudge Bundle Pause starts a new run; a longer pause setting keeps it. #[test] fn a_pause_ends_the_run() { @@ -500,6 +522,19 @@ fn free_transform_nudges_bundle_into_one_undo_step() { assert_eq!(quad(&app), moved(2.0, 0.0)); } +/// #9511: Free Transform keeps Photoshop's 1 document pixel per arrow press at any zoom; only +/// the Move tool's nudge is one screen pixel. +#[test] +fn free_transform_nudges_one_document_pixel_zoomed_out() { + let mut app = app_with_layer(); + app.ui.views[0].zoom = 0.5; + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0.map(|q| [q[0] + 1.0, q[1]]), "→ at 50 % moves the box 1 px"); +} + /// Arrow keys while editing text must not nudge layers, leaving the keys to the type tool (#5355). #[test] fn arrow_keys_ignored_during_text_edit() { @@ -526,10 +561,10 @@ fn arrow_keys_ignored_during_text_edit() { /// #9511: as in Photoshop, a plain arrow nudge is one screen pixel, so zoomed out it moves the /// layer several document pixels (floor of 1 / zoom: 4 at 25 %, 2 at 50 %, 1 at 66.7 %, 1 at -/// 100 % and above); ⇧ is 10 document pixels at any zoom. +/// 100 % and above, up to 1600 %); ⇧ is 10 document pixels at any zoom. #[test] fn a_nudge_is_one_screen_pixel() { - for (zoom, step) in [(0.25, 4), (0.5, 2), (2.0 / 3.0, 1), (1.0, 1), (2.0, 1)] { + for (zoom, step) in [(0.25, 4), (0.5, 2), (2.0 / 3.0, 1), (1.0, 1), (2.0, 1), (16.0, 1)] { let mut app = app_with_layer(); let id = app.session.active().unwrap().active_layer.unwrap(); app.ui.views[0].zoom = zoom; From d17d8950a8cd5ebe15bff60998627b383a91a1e1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 00:04:29 -0600 Subject: [PATCH 23/60] Select and Mask: Esc mid brush resize restores the brush and keeps the workspace (FredApps review) Esc during an Alt+right-drag or Control+Alt-drag resize closed the whole workspace. As in Photoshop it now puts the size and hardness back to where the drag began and ends only the resize; egui drops the drag on Esc, so the rest of it does nothing. Esc with Control+Alt still held (the macOS gesture) reaches it too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/distort_ui.rs | 6 ++- crates/ui-egui/src/dialogs/select_mask_ui.rs | 21 +++++++- .../src/dialogs/select_mask_ui/tests.rs | 54 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/distort_ui.rs b/crates/ui-egui/src/dialogs/distort_ui.rs index aa5c8fe4d9..1f8e05d214 100644 --- a/crates/ui-egui/src/dialogs/distort_ui.rs +++ b/crates/ui-egui/src/dialogs/distort_ui.rs @@ -361,10 +361,12 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { return true; } if app.distort.select_mask.is_some() { - // Enter closes a Polygonal Lasso polygon, else OK; Esc drops one, else Cancel. + // Enter closes a Polygonal Lasso polygon, else OK; Esc ends a brush resize (with + // Control+Alt, its macOS gesture, still held too), else drops a polygon, else Cancel. + let resize_esc = |ctx: &egui::Context| ctx.input_mut(|i| i.consume_key(Modifiers::CTRL | Modifiers::ALT, Key::Escape)); if enter(ctx) { crate::select_mask_ui::enter(app); - } else if esc(ctx) { + } else if esc(ctx) || (crate::select_mask_ui::resizing(app) && resize_esc(ctx)) { crate::select_mask_ui::escape(app); } return true; diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 2ccb39e143..d16acd4b7b 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -485,6 +485,18 @@ impl SelectMaskWorkspace { } } + /// Esc mid-resize: the brush goes back to its size and hardness when the drag began, as in + /// Photoshop. egui drops the drag on Esc, so the rest of it does nothing. + fn escape_resize(&mut self) { + let Some((tool, _, size, hardness, _)) = self.resize.take() else { return }; + match tool { + SmTool::Quick => self.quick_size = size, + SmTool::RefineEdge => self.refine_size = size, + _ => self.brush_size = size, + } + self.hardness = hardness; + } + fn pick(&mut self, t: SmTool) { self.tool = t; match t { @@ -881,9 +893,16 @@ pub fn enter(app: &mut PhotocraftApp) -> bool { false } -/// Esc: drops a Polygonal Lasso polygon being drawn, else Cancel. +/// A brush resize is in progress (Esc then ends it, with Control+Alt still held too). +pub fn resizing(app: &PhotocraftApp) -> bool { + app.distort.select_mask.as_ref().is_some_and(|w| w.resize.is_some()) +} + +/// Esc: ends a brush resize as it began, else drops a Polygonal Lasso polygon being drawn, else +/// Cancel. pub fn escape(app: &mut PhotocraftApp) { match app.distort.select_mask.as_mut() { + Some(w) if w.resize.is_some() => w.escape_resize(), Some(w) if w.poly.is_some() => w.poly = None, _ => cancel(app), } diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 10183a99c1..eb3d4a0f3a 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -998,3 +998,57 @@ fn a_resize_released_while_the_middle_button_pans_ends_with_its_button() { assert_eq!((w.brush_size, w.hardness), (want, 100.0), "the later drag doesn't resize the brush"); assert!(w.resize.is_none(), "the gesture ended with its button"); } + +/// Esc mid-resize puts the brush back and ends only the resize, as in Photoshop (FredApps on +/// #10559): the workspace stays open, the rest of that drag neither resizes nor selects, and the +/// next drag selects as usual. +#[test] +fn escape_mid_resize_restores_the_brush_and_keeps_the_workspace() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, -60.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.resize.is_some() && w.brush_size > 30.0 && w.hardness < 100.0, "the drag resizes"); + // Esc with Alt and the right button still held. + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::ALT }); + h.run_steps(1); + let w = h.state().distort.select_mask.as_ref().expect("Esc ends the resize, not the workspace"); + assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the brush is as it was"); + assert!(w.resize.is_none()); + // The drag goes on with Alt and the right button held: it does nothing. + pointer_move(&mut h, over + vec2(40.0, -60.0), vec2(40.0, 40.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(80.0, -20.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the rest of the drag doesn't resize"); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "nor select"); + assert!(w.resize.is_none()); + // The next drag selects with the brush as it was. + pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::NONE, vec2(30.0, 0.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the next drag doesn't resize"); + assert_eq!(w.edits.len(), 1, "it selects"); + assert_eq!(w.edits[0]["mode"], json!("add")); + // Control+Alt-drag (the macOS gesture) on Quick Selection, Esc with both still held. + let ctrl_alt = Modifiers::CTRL | Modifiers::ALT; + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick); + pointer_down(&mut h, over, PointerButton::Primary, ctrl_alt); + pointer_move(&mut h, over, vec2(30.0, 0.0)); + assert!(h.state().distort.select_mask.as_ref().unwrap().quick_size > 30.0, "the drag resizes"); + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: ctrl_alt }); + h.run_steps(1); + let w = h.state().distort.select_mask.as_ref().expect("the workspace stays open"); + assert_eq!(w.quick_size, 30.0, "Quick Selection's size is as it was"); + assert!(w.resize.is_none()); + pointer_move(&mut h, over + vec2(30.0, 0.0), vec2(30.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(60.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: ctrl_alt }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!(w.quick_size, 30.0, "the rest of the drag doesn't resize"); + assert!(w.strokes.is_empty() && w.edits.len() == 1, "nor select"); +} From a78c313fb0d517f2ef6083d0d25a705e74da4c01 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 00:07:02 -0600 Subject: [PATCH 24/60] Select and Mask: test the brush resize circle and readout (FredApps review) While the brush is resized its circle stays at the drag's start at the new size and the Diameter/Hardness readout follows the pointer; after the drag the circle follows the pointer again and the readout is gone. Co-Authored-By: Claude Opus 5.5 --- .../src/dialogs/select_mask_ui/tests.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index eb3d4a0f3a..e993830507 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -1052,3 +1052,48 @@ fn escape_mid_resize_restores_the_brush_and_keeps_the_workspace() { assert_eq!(w.quick_size, 30.0, "the rest of the drag doesn't resize"); assert!(w.strokes.is_empty() && w.edits.len() == 1, "nor select"); } + +/// The brush circles drawn last frame (the black outline's centre and radius). +fn brush_circles(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec<(egui::Pos2, f32)> { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Circle(s) if s.stroke == Stroke::new(1.5, Color32::BLACK) => Some((s.center, s.radius)), + _ => None, + }) + .collect() +} + +/// While the brush is resized its circle stays where the drag began, at the new size, and the +/// Diameter/Hardness readout follows the pointer, as on the main canvas (FredApps on #10559). +#[test] +fn the_resize_circle_stays_put_while_the_readout_follows_the_pointer() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + let readout = || egui::Id::unique("select-mask-brush-resize-readout"); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(brush_circles(&h), [(over, 15.0 * zoom)], "hovering, the circle is at the pointer"); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + for by in [vec2(40.0, -60.0), vec2(60.0, -60.0)] { + pointer_move(&mut h, over, by); + // The cursor is drawn before the frame's input, so it shows the new size a frame later. + h.run_steps(1); + let at = over + by; + let w = h.state().distort.select_mask.as_ref().unwrap(); + let (size, hardness) = (w.brush_size, w.hardness); + assert_eq!(brush_circles(&h), [(over, size as f32 * 0.5 * zoom)], "the circle stays at the anchor at the new size ({by:?})"); + let rect = h.ctx.memory(|m| m.area_rect(readout())).expect("the readout shows"); + assert_eq!(rect.min, at + vec2(16.0, 18.0), "the readout is beside the pointer"); + assert!(h.query_by_label(&format!("{} px", size.round() as i64)).is_some(), "it shows the diameter"); + assert!(h.query_by_label(&format!("{}%", hardness.round() as i64)).is_some(), "and the hardness"); + } + h.event(egui::Event::PointerButton { pos: over + vec2(60.0, -60.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let size = h.state().distort.select_mask.as_ref().unwrap().brush_size; + assert_eq!(brush_circles(&h), [(over + vec2(60.0, -60.0), size as f32 * 0.5 * zoom)], "after the drag the circle follows the pointer again"); + assert!(h.query_by_label("Diameter:").is_none(), "and the readout is gone"); +} From 638697199ce7d57dbdbeefd6b6e435424ea128fc Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 25 Oct 2026 07:47:48 -0600 Subject: [PATCH 25/60] feat(ui): Path mode for the Shape tools (part of #9453) The Shape tools' mode dropdown offered only Shape, so a rectangle or an ellipse could only become a shape layer. Photoshop's Shape tools have Shape, Path and Pixels modes; Path adds the outline to the Work Path and makes no layer. The dropdown now offers Shape and Path (a tool option of its own, `shape_mode`, Shape by default; the Pens keep their own mode). In Path mode a drag adds its outline, custom shapes included, to the work path as one more subpath, the preview shows the outline only, and the fill/stroke options are hidden. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/state/tool_options.rs | 4 ++ crates/ui-egui/src/tools/tool_reset.rs | 4 +- crates/ui-egui/src/tools/vector_ui/options.rs | 68 +++++++++++-------- crates/ui-egui/src/tools/vector_ui/shapes.rs | 42 +++++++++--- crates/ui-egui/src/tools/vector_ui/tests.rs | 27 ++++++++ 5 files changed, 102 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index bf5303e0d5..9363c5340f 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -96,6 +96,9 @@ pub struct ToolOptions { /// draws it as "combine"), "combine", "subtract", "intersect" or "exclude". #[serde(default = "default_pen_path_op")] pub pen_path_op: String, + /// Shape tools: "shape" (a shape layer) or "path" (the work path), as Photoshop's tool mode + /// (#9453). Shape by default, where the Pen's own mode defaults to Path. + pub shape_mode: String, /// Shape tools: fill with the foreground colour, stroke width (0 = none), rectangle corner /// radius, polygon sides, line weight. pub shape_fill: bool, @@ -421,6 +424,7 @@ impl Default for ToolOptions { enhance_edge: false, vector_mode: "path".into(), pen_path_op: default_pen_path_op(), + shape_mode: "shape".into(), shape_fill: true, stroke_width: 0.0, shape_stroke: Default::default(), diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 4a6ee2c2d8..ed94dcefb8 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -212,7 +212,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::CurvaturePen => reset!(vector_mode, pen_path_op), Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit), Tool::Line => reset!( - vector_mode, + shape_mode, shape_fill, stroke_width, shape_stroke, @@ -228,7 +228,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { arrow_concavity, ), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { - reset!(vector_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) + reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler | Tool::Ruler diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index cd68a6bcb6..ac84db952f 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -111,41 +111,49 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } return true; } - let mut mode = "shape".to_string(); - crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0); + // Shape draws a shape layer, Path adds the outline to the work path (#9453). + let opts = [("shape".to_string(), tl!("Shape")), ("path".to_string(), tl!("Path"))]; + crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut app.ui.tool_options.shape_mode, &opts, 80.0); crate::widgets::vline(ui, 22.0); - crate::shape_options::options_bar(app, ui, tool); - // Photoshop-style arrowhead settings at the right of Line options. - if tool == Tool::Line { - crate::widgets::vline(ui, 22.0); - ui.menu_image_button(crate::icons::image("settings", 18.0, t.icon), |ui| { - let o = &mut app.ui.tool_options; - ui.set_min_width(170.0); - ui.label(tl!("Arrowheads")); - ui.separator(); - ui.horizontal(|ui| { - ui.checkbox(&mut o.arrow_start, tl!("Start")); - ui.checkbox(&mut o.arrow_end, tl!("End")); - }); - ui.horizontal(|ui| { - ui.label(tl!("Width")); - ui.add(egui::DragValue::new(&mut o.arrow_width).range(1.0..=4096.0).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label(tl!("Length")); - ui.add(egui::DragValue::new(&mut o.arrow_length).range(1.0..=4096.0).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label(tl!("Concavity")); - ui.add(egui::DragValue::new(&mut o.arrow_concavity).range(0.0..=100.0).suffix(" %")); - }); - }) - .response - .on_hover_text(tl!("Arrowhead options")); + if !draws_path(&app.ui.tool_options) { + crate::shape_options::options_bar(app, ui, tool); + // Photoshop-style arrowhead settings at the right of Line options. + if tool == Tool::Line { + crate::widgets::vline(ui, 22.0); + ui.menu_image_button(crate::icons::image("settings", 18.0, t.icon), |ui| { + let o = &mut app.ui.tool_options; + ui.set_min_width(170.0); + ui.label(tl!("Arrowheads")); + ui.separator(); + ui.horizontal(|ui| { + ui.checkbox(&mut o.arrow_start, tl!("Start")); + ui.checkbox(&mut o.arrow_end, tl!("End")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Width")); + ui.add(egui::DragValue::new(&mut o.arrow_width).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Length")); + ui.add(egui::DragValue::new(&mut o.arrow_length).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Concavity")); + ui.add(egui::DragValue::new(&mut o.arrow_concavity).range(0.0..=100.0).suffix(" %")); + }); + }) + .response + .on_hover_text(tl!("Arrowhead options")); + } } true } +/// Whether the Shape tools are in Path mode: a drag adds its outline to the work path. +pub(crate) fn draws_path(o: &crate::state::ToolOptions) -> bool { + o.shape_mode == "path" +} + /// Whether the active layer supplies the selected-shape options. pub(super) fn selected_shape(session: &photocraft_engine::Session) -> Option { let st = session.active()?; diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index fe96e7b177..5a9955c558 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -1,4 +1,4 @@ -use super::options::solid_paint; +use super::options::{draws_path, solid_paint}; use super::overlay::path_lines; use super::{Affine, Color32, PhotocraftApp, Tokens, Tool, ToolOptions, Value, ViewXform, json, paint_param}; @@ -100,6 +100,19 @@ pub fn shape_bounds(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], /// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre. pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { + if draws_path(&app.ui.tool_options) { + // Path mode: the outline joins the work path (one more subpath), and no layer is made. + let Some(path) = shape_outline(app, tool, start, end, mods) else { return }; + let r = app.run("path.set", json!({"name": "work", "op": "combine", "path": photocraft_engine::vector_cmds::path_json(&path)})); + match r { + Ok(_) => app.ui.selected_path = Some("work".into()), + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } + return; + } let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return }; if tool == Tool::CustomShape { // ⇧ keeps the shape's proportions (the rect is already squared). @@ -127,22 +140,29 @@ pub fn create_shape(app: &mut PhotocraftApp, mut geometry: Value) -> Result Option { - let o = &app.ui.tool_options; - let geometry = shape_geometry(o, tool, start, end, mods)?; - let path = if tool == Tool::CustomShape { +/// The outline a Shape-tool drag draws, as a path (a custom shape fitted to the drag's box). +fn shape_outline(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { + let geometry = shape_geometry(&app.ui.tool_options, tool, start, end, mods)?; + if tool == Tool::CustomShape { let rect = geometry.get("rect").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok())?; let groups = photocraft_engine::presets::shapes::all_groups(&app.session); let shape = groups.iter().flat_map(|g| &g.items).find(|s| s.name == app.ui.presets_ui.shape())?; - photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift) + Some(photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift)) } else { - photocraft_engine::vector_cmds::shape_path(&geometry).ok()? - }; + photocraft_engine::vector_cmds::shape_path(&geometry).ok() + } +} + +/// The preview and command both use the full float tool colours, including transparent paint. +/// In Path mode only the outline shows, as the drag draws no layer. +pub(super) fn shape_preview(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { + let o = &app.ui.tool_options; + let path = shape_outline(app, tool, start, end, mods)?; + let paint = !draws_path(o); Some(photocraft_doc::ShapeLayer { path, - fill: o.shape_fill.then(|| tool_paint(app, false)), - stroke: (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, tool_paint(app, true))), + fill: (paint && o.shape_fill).then(|| tool_paint(app, false)), + stroke: (paint && o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, tool_paint(app, true))), ..Default::default() }) } diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index 6f7438b340..de3b1a4680 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -500,6 +500,33 @@ fn shape_drag_creates_layers_with_modifiers() { assert_eq!(app.session.active().unwrap().doc.layers.len(), 4); } +/// #9453: in Path mode a Shape-tool drag adds its outline to the work path, one subpath per +/// drag, and makes no layer, as Photoshop's Path tool mode does. Shape stays the default. +#[test] +fn path_mode_shape_drags_add_to_the_work_path() { + let mut app = app(); + assert_eq!(app.ui.tool_options.shape_mode, "shape", "Shape by default, as in Photoshop"); + let layers = app.session.active().unwrap().doc.layers.len(); + app.ui.tool_options.shape_mode = "path".into(); + finish_shape(&mut app, Tool::EllipseShape, [20.0, 20.0], [70.0, 50.0], egui::Modifiers::NONE); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layers.len(), layers, "no layer"); + let work = doc.work_path.as_ref().expect("a work path"); + assert_eq!(work.subpaths.len(), 1); + assert!(work.subpaths[0].closed); + let b = work.control_bounds().unwrap(); + assert!((b.0 - 20.0).abs() < 0.5 && (b.1 - 20.0).abs() < 0.5 && (b.2 - 70.0).abs() < 0.5 && (b.3 - 50.0).abs() < 0.5, "{b:?}"); + assert_eq!(app.ui.selected_path.as_deref(), Some("work")); + finish_shape(&mut app, Tool::Rectangle, [100.0, 100.0], [140.0, 130.0], egui::Modifiers::NONE); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.work_path.as_ref().unwrap().subpaths.len(), 2, "a second drag adds a subpath"); + assert_eq!(doc.layers.len(), layers); + // Back in Shape mode a drag makes a shape layer again. + app.ui.tool_options.shape_mode = "shape".into(); + finish_shape(&mut app, Tool::Rectangle, [10.0, 150.0], [60.0, 180.0], egui::Modifiers::NONE); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); +} + #[test] fn shift_pressed_during_a_shape_drag_constrains_it() { // #668: ⇧ only counted when held at the press; Photoshop constrains when it is pressed From 10ac3e37539b65ea0e1e0036156a2b1a966d3b90 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 25 Oct 2026 08:06:48 -0600 Subject: [PATCH 26/60] Keep the Shape tools' geometry options in Path mode CodeRabbit on #9456: Path mode hid the whole shape options bar, including Rectangle Radius, Polygon Sides, Line Weight and the Custom Shape picker. Path mode now hides only Fill and Stroke, as Photoshop's Path tool mode does. Test: path_mode_keeps_the_geometry_options_and_hides_paint Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/shape_options.rs | 65 ++++++++++--------- crates/ui-egui/src/tools/vector_ui/options.rs | 61 ++++++++--------- .../src/tools/vector_ui/paint_tests.rs | 16 +++++ 3 files changed, 82 insertions(+), 60 deletions(-) diff --git a/crates/ui-egui/src/tools/shape_options.rs b/crates/ui-egui/src/tools/shape_options.rs index f7ca997539..0a25de2387 100644 --- a/crates/ui-egui/src/tools/shape_options.rs +++ b/crates/ui-egui/src/tools/shape_options.rs @@ -32,7 +32,9 @@ fn selected_shape(session: &photocraft_engine::Session) -> Option } } -pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) { +/// The Shape tools' options. `paint` shows Fill and Stroke: off in Path mode, where a drag draws +/// only an outline, but the tool's geometry (radius, sides, weight, custom shape) stays. +pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool, paint: bool) { let t = Tokens::get(ui.ctx()); let lbl = |ui: &mut egui::Ui, s: &str| { let label = ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0)); @@ -61,40 +63,43 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool let gesture = paint_gesture(ui, gesture_key); let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}:{}:{gesture}", s.doc, s.layer.0, gesture_key.value())); let mut edit: Option = None; - lbl(ui, tl!("Fill:")); - if crate::widgets::checkbox(ui, &mut has_fill, tl!("Fill")).changed() { - o.shape_fill = has_fill; - edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }})); - } - let fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); - lbl(ui, tl!("Stroke:")); - let stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); - if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() { - o.stroke_width = width; - // A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke. - if stroke.is_some() || width > 0.0 { - let mut p = json!({"width": width}); + let (mut fill_color, mut stroke_color) = (None, None); + if paint { + lbl(ui, tl!("Fill:")); + if crate::widgets::checkbox(ui, &mut has_fill, tl!("Fill")).changed() { + o.shape_fill = has_fill; + edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }})); + } + fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); + lbl(ui, tl!("Stroke:")); + stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); + if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() { + o.stroke_width = width; + // A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke. + if stroke.is_some() || width > 0.0 { + let mut p = json!({"width": width}); + if stroke.is_none() { + p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param); + } + edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")})); + } + } + let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer)); + if let Some(change) = + crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled) + { + let mut p = change.options.params(); + if !stroke_enabled { + p["width"] = json!(3.0); + } if stroke.is_none() { p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param); } - edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")})); + edit = Some(json!({"stroke": p, "coalesce": change.coalesce})); + o.shape_stroke = change.options; + o.stroke_width = enabled_stroke_width(o.stroke_width); } } - let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer)); - if let Some(change) = - crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled) - { - let mut p = change.options.params(); - if !stroke_enabled { - p["width"] = json!(3.0); - } - if stroke.is_none() { - p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param); - } - edit = Some(json!({"stroke": p, "coalesce": change.coalesce})); - o.shape_stroke = change.options; - o.stroke_width = enabled_stroke_width(o.stroke_width); - } match tool { Tool::Rectangle => { crate::widgets::vline(ui, 22.0); diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index ac84db952f..7290d90734 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -115,36 +115,37 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo let opts = [("shape".to_string(), tl!("Shape")), ("path".to_string(), tl!("Path"))]; crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut app.ui.tool_options.shape_mode, &opts, 80.0); crate::widgets::vline(ui, 22.0); - if !draws_path(&app.ui.tool_options) { - crate::shape_options::options_bar(app, ui, tool); - // Photoshop-style arrowhead settings at the right of Line options. - if tool == Tool::Line { - crate::widgets::vline(ui, 22.0); - ui.menu_image_button(crate::icons::image("settings", 18.0, t.icon), |ui| { - let o = &mut app.ui.tool_options; - ui.set_min_width(170.0); - ui.label(tl!("Arrowheads")); - ui.separator(); - ui.horizontal(|ui| { - ui.checkbox(&mut o.arrow_start, tl!("Start")); - ui.checkbox(&mut o.arrow_end, tl!("End")); - }); - ui.horizontal(|ui| { - ui.label(tl!("Width")); - ui.add(egui::DragValue::new(&mut o.arrow_width).range(1.0..=4096.0).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label(tl!("Length")); - ui.add(egui::DragValue::new(&mut o.arrow_length).range(1.0..=4096.0).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label(tl!("Concavity")); - ui.add(egui::DragValue::new(&mut o.arrow_concavity).range(0.0..=100.0).suffix(" %")); - }); - }) - .response - .on_hover_text(tl!("Arrowhead options")); - } + // Path mode hides Fill and Stroke (an outline has no paint); the tool's geometry, + // the Line's arrowheads included, stays. + let paint = !draws_path(&app.ui.tool_options); + crate::shape_options::options_bar(app, ui, tool, paint); + // Photoshop-style arrowhead settings at the right of Line options. + if tool == Tool::Line { + crate::widgets::vline(ui, 22.0); + ui.menu_image_button(crate::icons::image("settings", 18.0, t.icon), |ui| { + let o = &mut app.ui.tool_options; + ui.set_min_width(170.0); + ui.label(tl!("Arrowheads")); + ui.separator(); + ui.horizontal(|ui| { + ui.checkbox(&mut o.arrow_start, tl!("Start")); + ui.checkbox(&mut o.arrow_end, tl!("End")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Width")); + ui.add(egui::DragValue::new(&mut o.arrow_width).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Length")); + ui.add(egui::DragValue::new(&mut o.arrow_length).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Concavity")); + ui.add(egui::DragValue::new(&mut o.arrow_concavity).range(0.0..=100.0).suffix(" %")); + }); + }) + .response + .on_hover_text(tl!("Arrowhead options")); } true } diff --git a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs index 4f20f228c6..1dfbe44a54 100644 --- a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs @@ -993,3 +993,19 @@ fn a_refused_popover_swatch_is_not_a_recently_used_colour() { assert_eq!(recent, ["#00ff00"], "properties={properties}"); } } + +/// CodeRabbit on #9456: Path mode hides Fill and Stroke (a path has no paint), but each tool's +/// geometry stays in the options bar, as in Photoshop's Path tool mode. +#[test] +fn path_mode_keeps_the_geometry_options_and_hides_paint() { + for (tool, geometry) in [(Tool::Rectangle, "Radius:"), (Tool::Polygon, "Sides:"), (Tool::Line, "Weight:"), (Tool::CustomShape, "Shape:")] { + let mut app = app("rgb", 8); + app.ui.tool = tool; + app.ui.tool_options.shape_mode = "path".into(); + let h = harness(app, false); + assert!(h.query_by_label(geometry).is_some(), "{tool:?}: {geometry} stays in Path mode"); + for paint in ["Fill:", "Stroke:", "Fill color", "Stroke color", "Stroke Options"] { + assert!(h.query_by_label(paint).is_none(), "{tool:?}: {paint} is hidden in Path mode"); + } + } +} From a8a3c39ea69ac6af241a3dbca1888f4a8c8f39d8 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 25 Oct 2026 08:23:41 -0600 Subject: [PATCH 27/60] Path mode's Radius sets the next outline only CodeRabbit on #9456: with a shape layer active, the Radius field in Path mode read and edited that layer. In Path mode the options bar now has no selected-shape target, so Radius only sets the next outline's radius. Test: path_mode_radius_leaves_the_active_shape_layer_alone Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/shape_options.rs | 3 ++- .../src/tools/vector_ui/paint_tests.rs | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/shape_options.rs b/crates/ui-egui/src/tools/shape_options.rs index 0a25de2387..8aabe45922 100644 --- a/crates/ui-egui/src/tools/shape_options.rs +++ b/crates/ui-egui/src/tools/shape_options.rs @@ -42,7 +42,8 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool crate::widgets::scrub_option_label(ui, &label); }; let o = &mut app.ui.tool_options; - let target = selected_shape(&app.session); + // Path mode edits only the next outline: an active shape layer is not the target. + let target = if paint { selected_shape(&app.session) } else { None }; // Fill and Stroke edit the selected shape layer too (#3347), and still set the next shape's. let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); let (mut has_fill, fill, stroke) = match &target { diff --git a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs index 1dfbe44a54..7d99cda456 100644 --- a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs @@ -1009,3 +1009,29 @@ fn path_mode_keeps_the_geometry_options_and_hides_paint() { } } } + +/// CodeRabbit on #9456: in Path mode the Radius field sets the next outline's radius only; it +/// must not edit a shape layer that happens to be active. +#[test] +fn path_mode_radius_leaves_the_active_shape_layer_alone() { + let mut app = app("rgb", 8); + app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 40], "radii": 10})).unwrap(); + let radii = |app: &PhotocraftApp| match &shape(app).live { + Some(photocraft_doc::LiveShape::Rect { radii, .. }) => *radii, + other => panic!("expected a live rectangle, got {other:?}"), + }; + assert_eq!(radii(&app), [10.0; 4]); + app.ui.tool_options.corner_radius = 3.0; + app.ui.tool_options.shape_mode = "path".into(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, false); + h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the radius").click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text("20".into())); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.corner_radius, 20.0, "the next outline's radius"); + assert_eq!(radii(h.state()), [10.0; 4], "the shape layer keeps its radius"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history, "no edit"); +} From 9a0dd2a554534de7066d671b7c74a780d104095d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 25 Oct 2026 20:13:00 -0600 Subject: [PATCH 28/60] Path mode belongs to the Shape tools only CodeRabbit on #9456 (inline 4372132651): with a shape layer selected, Path Selection and Direct Selection reach the same options bar, and in Path mode they lost Fill and Stroke and stopped editing the shape's radius. They now always show their paint controls and keep main's fixed Shape menu; only the six Shape tools follow Path mode. Test: selection_tools_ignore_the_shape_tools_path_mode Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/vector_ui/options.rs | 19 +++++++++++++------ .../src/tools/vector_ui/paint_tests.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index 7290d90734..6cd9cc325f 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -111,13 +111,20 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } return true; } - // Shape draws a shape layer, Path adds the outline to the work path (#9453). - let opts = [("shape".to_string(), tl!("Shape")), ("path".to_string(), tl!("Path"))]; - crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut app.ui.tool_options.shape_mode, &opts, 80.0); + if is_shape_tool(tool) { + // Shape draws a shape layer, Path adds the outline to the work path (#9453). + let opts = [("shape".to_string(), tl!("Shape")), ("path".to_string(), tl!("Path"))]; + crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut app.ui.tool_options.shape_mode, &opts, 80.0); + } else { + // Path Selection and Direct Selection edit the selected shape layer: the Shape tools' + // Path mode isn't theirs to show or change. + let mut mode = "shape".to_string(); + crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0); + } crate::widgets::vline(ui, 22.0); - // Path mode hides Fill and Stroke (an outline has no paint); the tool's geometry, - // the Line's arrowheads included, stays. - let paint = !draws_path(&app.ui.tool_options); + // The Shape tools' Path mode hides Fill and Stroke (an outline has no paint); the tool's + // geometry, the Line's arrowheads included, stays. + let paint = !(is_shape_tool(tool) && draws_path(&app.ui.tool_options)); crate::shape_options::options_bar(app, ui, tool, paint); // Photoshop-style arrowhead settings at the right of Line options. if tool == Tool::Line { diff --git a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs index 7d99cda456..ee30c004cb 100644 --- a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs @@ -1035,3 +1035,19 @@ fn path_mode_radius_leaves_the_active_shape_layer_alone() { assert_eq!(radii(h.state()), [10.0; 4], "the shape layer keeps its radius"); assert_eq!(h.state().session.active().unwrap().history.past_len(), history, "no edit"); } + +/// CodeRabbit on #9456: the Shape tools' Path mode is theirs alone. With a shape selected, Path +/// Selection and Direct Selection keep their Fill and Stroke controls whatever that mode is. +#[test] +fn selection_tools_ignore_the_shape_tools_path_mode() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = app("rgb", 8); + app.run("shape.create", json!({"rect": [20, 20, 60, 40]})).unwrap(); + app.ui.tool = tool; + app.ui.tool_options.shape_mode = "path".into(); + let h = harness(app, false); + for label in ["Fill color", "Stroke color"] { + assert!(h.query_by_label(label).is_some(), "{tool:?}: {label} stays"); + } + } +} From e81e7cd1dc55b001e2f5d65d2f423a84cb56b72a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 25 Oct 2026 21:23:49 -0600 Subject: [PATCH 29/60] Path mode: a click opens no Create dialog CodeRabbit on #9456 (outside-diff comment, canvas.rs 6814): in Path mode a click on Rectangle, Ellipse, Triangle or Polygon still opened the Create dialog, whose OK makes a shape layer; Path mode never draws one. The dialog now opens in Shape mode only. Test: a_path_mode_click_opens_no_create_dialog Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/gesture.rs | 4 +++- crates/ui-egui/src/dialogs/shape_dialog/tests.rs | 14 ++++++++++++++ crates/ui-egui/src/tools/vector_ui.rs | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index 6e4e015d9f..eb6dc447e2 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -86,8 +86,10 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { crate::vector_ui::freeform_finish_owned(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight()), owner); } } - // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. + // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. Not in + // Path mode: the dialog makes a shape layer, and Path mode never draws one. t if crate::vector_ui::is_shape_tool(t) + && !crate::vector_ui::draws_path(&app.ui.tool_options) && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) && crate::shape_dialog::open(app, t, d.start).is_some() => {} t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live), diff --git a/crates/ui-egui/src/dialogs/shape_dialog/tests.rs b/crates/ui-egui/src/dialogs/shape_dialog/tests.rs index 7af252f087..9f02db8bb6 100644 --- a/crates/ui-egui/src/dialogs/shape_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/shape_dialog/tests.rs @@ -354,3 +354,17 @@ fn right_click_picks_the_unit_of_that_field_only_and_it_is_not_remembered() { let s = sides(&frame(&egui::Context::default(), &app, &mut f, vec![])); assert_eq!(shown(&s), [("1.76".to_string(), "cm".to_string()), ("3.53".to_string(), "cm".to_string())]); } + +/// CodeRabbit on #9456: in Path mode a click opens no Create dialog, whose OK would make a shape +/// layer; Path mode never draws one (a drag adds its outline to the work path). +#[test] +fn a_path_mode_click_opens_no_create_dialog() { + for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon] { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + let before = layers(&app); + press(&mut app, tool, [20.4, 30.2], [20.4, 30.2]); + assert!(app.ui.dialogs.is_empty(), "{tool:?}: no Create dialog in Path mode"); + assert_eq!(layers(&app), before, "{tool:?}: and no layer"); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index 0749b87434..2d8e51b44e 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -30,7 +30,7 @@ pub(crate) use curvature::curvature_anchor_at; pub use curvature::{curvature_down, curvature_move, curvature_toggle, curvature_up}; #[cfg(test)] use options::{corner_radii_patch, solid_paint}; -pub(crate) use options::{enabled_stroke_width, paint_gesture}; +pub(crate) use options::{draws_path, enabled_stroke_width, paint_gesture}; pub use options::{options_bar, shape_properties}; pub use overlay::draw_overlay; pub(crate) use overlay::selected_saved_path_index; From ed7581a609160493656dd53d1011d0e91325416e Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 13:47:24 -0600 Subject: [PATCH 30/60] Path mode: a click that moved a little adds no subpath (CodeRabbit review on gesture.rs:92) A Path-mode click skipped the Create dialog but fell through to finish_shape, so a press and release a fraction of a pixel apart (still a click) rounded to a 1 x 1 px box and added a speck of a subpath to the work path. A click in Path mode now ends there, for every Shape tool. Test: a_path_mode_click_that_moved_a_little_adds_no_subpath Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/gesture.rs | 8 ++++---- .../ui-egui/src/dialogs/shape_dialog/tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index eb6dc447e2..0e67031929 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -86,12 +86,12 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { crate::vector_ui::freeform_finish_owned(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight()), owner); } } - // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. Not in - // Path mode: the dialog makes a shape layer, and Path mode never draws one. + // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. In Path + // mode a click adds nothing: the dialog makes a shape layer, which Path mode never draws, + // and the click's few pixels of travel would add a speck of a subpath to the work path. t if crate::vector_ui::is_shape_tool(t) - && !crate::vector_ui::draws_path(&app.ui.tool_options) && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) - && crate::shape_dialog::open(app, t, d.start).is_some() => {} + && (crate::vector_ui::draws_path(&app.ui.tool_options) || crate::shape_dialog::open(app, t, d.start).is_some()) => {} t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live), Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]), Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]), diff --git a/crates/ui-egui/src/dialogs/shape_dialog/tests.rs b/crates/ui-egui/src/dialogs/shape_dialog/tests.rs index 9f02db8bb6..1ca5de28f7 100644 --- a/crates/ui-egui/src/dialogs/shape_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/shape_dialog/tests.rs @@ -368,3 +368,21 @@ fn a_path_mode_click_opens_no_create_dialog() { assert_eq!(layers(&app), before, "{tool:?}: and no layer"); } } + +/// CodeRabbit on #9456: a Path-mode click that moved a little, still within the click slop, adds +/// nothing to the work path either. Its box would round to a 1 × 1 or 2 × 2 px outline. +#[test] +fn a_path_mode_click_that_moved_a_little_adds_no_subpath() { + let work = |app: &PhotocraftApp| app.session.active().unwrap().doc.work_path.as_ref().map_or(0, |p| p.subpaths.len()); + for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line] { + for end in [[20.8, 30.8], [21.8, 31.9]] { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + let before = layers(&app); + press(&mut app, tool, [20.2, 30.2], end); + assert_eq!(work(&app), 0, "{tool:?} to {end:?}: no subpath"); + assert!(app.ui.dialogs.is_empty(), "{tool:?} to {end:?}: no Create dialog"); + assert_eq!(layers(&app), before, "{tool:?} to {end:?}: and no layer"); + } + } +} From 28a563d3f398b576427f3a5082dd6678280705de Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 14:51:32 -0600 Subject: [PATCH 31/60] Re-run CI windows-latest unit failed in photocraft-compose's proptest the_split_route_agrees_with_the_exact_path, which this PR doesn't touch; main's CI is green. Co-Authored-By: Claude Opus 5.5 From 8eeca2301faa462d23bba1c70d9470a8841cf7f5 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 15:18:05 -0600 Subject: [PATCH 32/60] Path mode: a custom shape keeps its holes in the work path (CodeRabbit review on shapes.rs:100) Path mode appended the outline with path.set's "op": "combine", which sets every appended subpath's operation, so a Custom Shape's subtracted subpaths (the Ring's hole, the Fish's eye) became combined ones. The outline is now appended to the work path as it is and the whole path set in one step, so each subpath keeps its own operation, as in Photoshop. Dropping "op" alone, as suggested, would replace the work path instead of adding to it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/vector_ui/shapes.rs | 8 ++++++-- crates/ui-egui/src/tools/vector_ui/tests.rs | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index 5a9955c558..a00109ee94 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -102,8 +102,12 @@ pub fn shape_bounds(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { if draws_path(&app.ui.tool_options) { // Path mode: the outline joins the work path (one more subpath), and no layer is made. - let Some(path) = shape_outline(app, tool, start, end, mods) else { return }; - let r = app.run("path.set", json!({"name": "work", "op": "combine", "path": photocraft_engine::vector_cmds::path_json(&path)})); + let Some(outline) = shape_outline(app, tool, start, end, mods) else { return }; + // Appended as it is rather than with `path.set`'s `op`, which would turn a custom + // shape's subtracted subpaths (holes) into combined ones. + let mut path = app.session.active().and_then(|st| st.doc.work_path.clone()).unwrap_or_default(); + path.subpaths.extend(outline.subpaths); + let r = app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&path)})); match r { Ok(_) => app.ui.selected_path = Some("work".into()), Err(e) => { diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index de3b1a4680..e2a99a6b8f 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -527,6 +527,20 @@ fn path_mode_shape_drags_add_to_the_work_path() { assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); } +/// CodeRabbit on #9456: a Custom Shape's own subtracted subpaths (the Ring's hole) keep their +/// operation when Path mode adds the shape to the work path, as in Photoshop. +#[test] +fn path_mode_custom_shape_keeps_its_subtracted_subpaths() { + use photocraft_doc::PathOp; + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + finish_shape(&mut app, Tool::Rectangle, [100.0, 100.0], [140.0, 130.0], egui::Modifiers::NONE); + app.ui.presets_ui.custom_shape = "Ring".into(); + finish_shape(&mut app, Tool::CustomShape, [20.0, 20.0], [70.0, 70.0], egui::Modifiers::NONE); + let ops: Vec = app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths.iter().map(|s| s.op).collect(); + assert_eq!(ops, [PathOp::Combine, PathOp::Combine, PathOp::Subtract], "the rectangle, the ring and its hole"); +} + #[test] fn shift_pressed_during_a_shape_drag_constrains_it() { // #668: ⇧ only counted when held at the press; Photoshop constrains when it is pressed From 3a0647bb67bc017557c7c7062233b7115cdb246f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 16:13:08 -0600 Subject: [PATCH 33/60] Path mode: a new work path takes the custom shape's fill rule (CodeRabbit review on shapes.rs:106) With no work path yet, Path mode started one with the default non-zero rule and appended the outline's subpaths, so an even-odd custom shape (shape.presets.new reads fillRule) lost its rule: a pentagram's middle filled. The new work path now takes the outline's fill rule. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/vector_ui/shapes.rs | 9 ++++-- crates/ui-egui/src/tools/vector_ui/tests.rs | 30 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index a00109ee94..a1a8e0e6ef 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -104,8 +104,13 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ // Path mode: the outline joins the work path (one more subpath), and no layer is made. let Some(outline) = shape_outline(app, tool, start, end, mods) else { return }; // Appended as it is rather than with `path.set`'s `op`, which would turn a custom - // shape's subtracted subpaths (holes) into combined ones. - let mut path = app.session.active().and_then(|st| st.doc.work_path.clone()).unwrap_or_default(); + // shape's subtracted subpaths (holes) into combined ones. A new work path takes the + // outline's fill rule. + let mut path = app + .session + .active() + .and_then(|st| st.doc.work_path.clone()) + .unwrap_or_else(|| photocraft_doc::vector::Path { fill_rule: outline.fill_rule, ..Default::default() }); path.subpaths.extend(outline.subpaths); let r = app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&path)})); match r { diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index e2a99a6b8f..fb9565291a 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -541,6 +541,36 @@ fn path_mode_custom_shape_keeps_its_subtracted_subpaths() { assert_eq!(ops, [PathOp::Combine, PathOp::Combine, PathOp::Subtract], "the rectangle, the ring and its hole"); } +/// Coverage of the work path at pixel `(x, y)`. +fn work_covered(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + let path = app.session.active().unwrap().doc.work_path.clone().expect("a work path"); + photocraft_vector::fill_rasterizer(&path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0] +} + +/// CodeRabbit on #9456: a custom shape that starts the work path brings its fill rule, so an +/// even-odd star keeps its empty centre. +#[test] +fn path_mode_custom_shape_starts_the_work_path_with_its_fill_rule() { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + // A pentagram drawn in one stroke: even-odd leaves the pentagon in the middle empty. + let star: Vec<[f64; 2]> = (0..5) + .map(|k| { + let a = std::f64::consts::TAU * (k * 2) as f64 / 5.0 - std::f64::consts::FRAC_PI_2; + [50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin()] + }) + .collect(); + let r = app.run("shape.presets.new", json!({"name": "Even-Odd Star", "path": {"fillRule": "evenodd", "subpaths": [{"knots": star}]}})); + assert!(r.is_ok(), "{r:?}"); + app.ui.presets_ui.custom_shape = "Even-Odd Star".into(); + finish_shape(&mut app, Tool::CustomShape, [20.0, 20.0], [120.0, 120.0], egui::Modifiers::NONE); + let work = app.session.active().unwrap().doc.work_path.clone().expect("a work path"); + assert_eq!(work.fill_rule, photocraft_doc::vector::FillRule::EvenOdd); + let b = work.control_bounds().unwrap(); + let (cx, cy) = (((b.0 + b.2) / 2.0) as i32, ((b.1 + b.3) / 2.0) as i32); + assert_eq!(work_covered(&app, cx, cy), 0.0, "the star's centre stays empty"); +} + #[test] fn shift_pressed_during_a_shape_drag_constrains_it() { // #668: ⇧ only counted when held at the press; Photoshop constrains when it is pressed From c91fde619156ab0e4272de31c38893cbe2c3cdc8 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 16:15:50 -0600 Subject: [PATCH 34/60] Path mode: a custom shape's holes cut only the shape (CodeRabbit review on shapes.rs:106) The work path folds each subpath into everything before it, so a Ring drawn over a rectangle in Path mode kept its hole as a subtracted subpath and cut the rectangle where the hole overlapped it. Photoshop 2025 adds a custom shape as one shape component: a ring placed with Combine over a rectangle leaves the rectangle whole under the hole (checked by making a selection from the work path). The subpaths after a custom shape's first now join it, with holes wound against it so the fill rule cuts them. Where the joined form would cover a different area (an intersect or exclude subpath, or a cut reaching past the shape, like the Crescent Moon's) the shape is appended as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/vector_ui/shapes.rs | 49 ++++++++++++++++++-- crates/ui-egui/src/tools/vector_ui/tests.rs | 48 +++++++++++++++++-- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index a1a8e0e6ef..7b67544721 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -103,14 +103,15 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ if draws_path(&app.ui.tool_options) { // Path mode: the outline joins the work path (one more subpath), and no layer is made. let Some(outline) = shape_outline(app, tool, start, end, mods) else { return }; - // Appended as it is rather than with `path.set`'s `op`, which would turn a custom - // shape's subtracted subpaths (holes) into combined ones. A new work path takes the - // outline's fill rule. + // Appended here rather than with `path.set`'s `op`, which would turn a custom shape's + // subtracted subpaths (holes) into combined ones; the shape goes in as one component. + // A new work path takes the outline's fill rule. let mut path = app .session .active() .and_then(|st| st.doc.work_path.clone()) .unwrap_or_else(|| photocraft_doc::vector::Path { fill_rule: outline.fill_rule, ..Default::default() }); + let outline = as_one_component(&outline, path.fill_rule).unwrap_or(outline); path.subpaths.extend(outline.subpaths); let r = app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&path)})); match r { @@ -149,6 +150,48 @@ pub fn create_shape(app: &mut PhotocraftApp, mut geometry: Value) -> Result Option { + use photocraft_doc::vector::{Path, PathOp, Subpath}; + // Clockwise on screen (y down) or not, from the flattened outline's signed area. + let clockwise = |s: &Subpath| { + let pts = photocraft_vector::flatten_subpath(s, 0.25).pts; + let twice_area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); + twice_area > 0.0 + }; + let (first, rest) = outline.subpaths.split_first()?; + if rest.is_empty() { + return None; + } + let outer = clockwise(first); + let mut joined = Path { fill_rule: rule, ..outline.clone() }; + for s in &mut joined.subpaths[1..] { + let want = match s.op { + PathOp::Join => continue, + PathOp::Combine => outer, + PathOp::Subtract => !outer, + PathOp::Intersect | PathOp::Exclude => return None, + }; + if clockwise(s) != want { + s.knots.reverse(); + for k in &mut s.knots { + std::mem::swap(&mut k.in_ctrl, &mut k.out_ctrl); + } + } + s.op = PathOp::Join; + } + // Compare the two areas at a small fixed size. + const SIDE: f64 = 128.0; + let rect = photocraft_geom::Rect::from_xywh(0, 0, SIDE as u32, SIDE as u32); + let cover = |p: &Path| photocraft_vector::path_coverage(&photocraft_engine::presets::shapes::fit(p, [0.0, 0.0, SIDE, SIDE], true), rect); + let same = cover(outline).iter().zip(cover(&joined)).all(|(a, b)| (a - b).abs() <= 0.5); + same.then_some(joined) +} + /// The outline a Shape-tool drag draws, as a path (a custom shape fitted to the drag's box). fn shape_outline(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { let geometry = shape_geometry(&app.ui.tool_options, tool, start, end, mods)?; diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index fb9565291a..45ec1e9d87 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -527,10 +527,11 @@ fn path_mode_shape_drags_add_to_the_work_path() { assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); } -/// CodeRabbit on #9456: a Custom Shape's own subtracted subpaths (the Ring's hole) keep their -/// operation when Path mode adds the shape to the work path, as in Photoshop. +/// CodeRabbit on #9456: Path mode adds a Custom Shape to the work path as one shape component, +/// its hole joined to it, as Photoshop does (the Ring's hole no longer turns into a combined +/// subpath, nor stays a subtracted one that would cut the work path under it). #[test] -fn path_mode_custom_shape_keeps_its_subtracted_subpaths() { +fn path_mode_custom_shape_joins_its_hole_to_it() { use photocraft_doc::PathOp; let mut app = app(); app.ui.tool_options.shape_mode = "path".into(); @@ -538,7 +539,46 @@ fn path_mode_custom_shape_keeps_its_subtracted_subpaths() { app.ui.presets_ui.custom_shape = "Ring".into(); finish_shape(&mut app, Tool::CustomShape, [20.0, 20.0], [70.0, 70.0], egui::Modifiers::NONE); let ops: Vec = app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths.iter().map(|s| s.op).collect(); - assert_eq!(ops, [PathOp::Combine, PathOp::Combine, PathOp::Subtract], "the rectangle, the ring and its hole"); + assert_eq!(ops, [PathOp::Combine, PathOp::Combine, PathOp::Join], "the rectangle, the ring and its hole"); + assert_eq!(work_covered(&app, 45, 45), 0.0, "the ring's hole"); + assert_eq!(work_covered(&app, 22, 45), 1.0, "the ring"); +} + +/// CodeRabbit on #9456, checked in Photoshop 2025: a Ring drawn in Path mode over a rectangle +/// cuts its hole in the ring only; the rectangle stays whole where the hole overlaps it. +#[test] +fn path_mode_custom_shape_hole_leaves_the_work_path_under_it() { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [120.0, 120.0], egui::Modifiers::NONE); + app.ui.presets_ui.custom_shape = "Ring".into(); + finish_shape(&mut app, Tool::CustomShape, [80.0, 80.0], [180.0, 180.0], egui::Modifiers::NONE); + assert_eq!(work_covered(&app, 50, 50), 1.0, "the rectangle"); + assert_eq!(work_covered(&app, 110, 110), 1.0, "the rectangle under the ring's hole"); + assert_eq!(work_covered(&app, 130, 130), 0.0, "the ring's hole"); + assert_eq!(work_covered(&app, 170, 130), 1.0, "the ring"); +} + +/// Every built-in custom shape drawn alone in Path mode covers what the shape does, joined into +/// one component or, where that would change its area (the Crescent Moon's cut reaches past +/// the disc), kept as it is. +#[test] +fn path_mode_custom_shapes_keep_their_area() { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + let rect = photocraft_geom::Rect::from_xywh(0, 0, 200, 200); + for g in photocraft_engine::presets::shapes::builtin() { + for shape in g.items { + app.run("path.delete", json!({"name": "work"})).ok(); + app.ui.presets_ui.custom_shape = shape.name.clone(); + finish_shape(&mut app, Tool::CustomShape, [10.0, 10.0], [190.0, 190.0], egui::Modifiers::NONE); + let work = app.session.active().unwrap().doc.work_path.clone().expect("a work path"); + let want = photocraft_engine::presets::shapes::fit(&shape.path, [10.0, 10.0, 180.0, 180.0], false); + let (a, b) = (photocraft_vector::path_coverage(&work, rect), photocraft_vector::path_coverage(&want, rect)); + let off = a.iter().zip(&b).filter(|(a, b)| (*a - *b).abs() > 0.5).count(); + assert_eq!(off, 0, "{}: {off} pixels differ", shape.name); + } + } } /// Coverage of the work path at pixel `(x, y)`. From 80e32f8ef7e7d437586a4e72f54703e87dcc89ba Mon Sep 17 00:00:00 2001 From: DingusMcRinkyDinky <127614779+DingusMcRinkyDinky@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:24:33 +0100 Subject: [PATCH 35/60] Pen: Make Shape turns the path into a shape layer (#10934) The Pen options bar in Path mode only offered Make Selection, so a drawn path could not become a shape layer. Make Shape now does that, next to Make Selection, with the shape tools' fill and stroke, as one History step. A path still being drawn is finished first. The Work Path becomes the shape's path and is removed; a saved path stays. New engine command path.toShape, so scripts and agents can do the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZbgszHyuEuWAzxaamteuU --- crates/engine/src/cmd/vector_cmds.rs | 12 +++- crates/engine/src/cmd/vector_cmds/shape.rs | 54 +++++++++++++---- crates/engine/src/cmd/vector_cmds/tests.rs | 52 ++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/tools/vector_ui.rs | 4 +- crates/ui-egui/src/tools/vector_ui/options.rs | 7 +++ .../ui-egui/src/tools/vector_ui/path_ops.rs | 19 ++++++ .../src/tools/vector_ui/pen_path_ops_tests.rs | 59 +++++++++++++++++++ 32 files changed, 220 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index f331a30335..21bfbfddba 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -80,7 +80,7 @@ pub(crate) use paths::{is_work, path_mut}; pub(crate) use shape::{bake_path, map_path, map_shape, shift_shape, with_shape}; use shape::{ can_paste_shape_fill, can_paste_shape_stroke, combine_shapes, copy_shape_fill, copy_shape_stroke, has_shape_fill, has_shape_stroke, merge_components, - paste_shape_fill, paste_shape_stroke, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize, + paste_shape_fill, paste_shape_stroke, path_to_shape, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize, }; pub use shape::{preview_shape_stroke, refresh_shape, shape_path, transform_shape, translate_vectors}; use vector_mask::{toggle_vector_mask, vector_mask_add, vector_mask_delete, vector_mask_edit, vector_mask_info, vector_mask_rasterize, with}; @@ -230,6 +230,16 @@ pub fn specs() -> Vec { run: |s, p| path_to_selection_with_mode(s, p, "subtract"), journal: true, }, + spec!( + "path.toShape", + "Make Shape from Path", + [], + leak(format!( + r##"{{"name":str|"work"|"layer"="work" (the Work Path becomes the shape's path; a saved path stays),{fill}=foreground,{stroke}=none,"layerName":str?}} → shape.info"## + )), + has_doc, + path_to_shape + ), spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), spec!( "path.fill", diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 33e1bfe3cf..432b8b47f2 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -424,16 +424,7 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { let l = live_from_params(CMD, kind, p, None)?; (vector::shapes::live_path(&l), Some(l)) }; - let fg = s.tools.foreground; - let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); - let fill = match p.get("fill") { - Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?, - None => Some(Fill::Solid(fgc)), - }; - let stroke = match p.get("stroke") { - Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?, - None => None, - }; + let (fill, stroke) = new_fill_stroke(s, CMD, p)?; vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; // Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode). if let Some(target) = p.get("addTo").and_then(Value::as_u64) { @@ -463,6 +454,49 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { shape_info(s, id) } +/// A new shape layer's `fill` (default the foreground colour) and `stroke` (default none). +fn new_fill_stroke(s: &Session, cmd: &str, p: &Value) -> Result<(Option, Option)> { + let fg = s.tools.foreground; + let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); + let fill = match p.get("fill") { + Some(v) => parse_fill(v).map_err(|e| bad(cmd, e))?, + None => Some(Fill::Solid(fgc)), + }; + let stroke = match p.get("stroke") { + Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(cmd, e))?, + None => None, + }; + Ok((fill, stroke)) +} + +/// The Pen options bar's Make: Shape: a new shape layer from the work path or a saved path, as one +/// History step. The Work Path becomes the shape's path, so it is gone afterwards; a saved path +/// stays. With no work path, `"work"` takes the active layer's path, as the other path commands do. +pub(super) fn path_to_shape(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.toShape"; + let name = p.get("name").and_then(Value::as_str); + let path = super::resolve_path(s, name)?; + if path.subpaths.iter().all(|sp| sp.knots.is_empty()) { + return Err(bad(CMD, "the path is empty")); + } + let (fill, stroke) = new_fill_stroke(s, CMD, p)?; + vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; + let layer_name = p.get("layerName").and_then(Value::as_str).map(str::to_string); + let consume_work = super::is_work(name); + let mut sh = ShapeLayer { path, fill, stroke, ..Default::default() }; + let id = s.edit("New Shape Layer", |doc, active| { + refresh_shape(doc, &mut sh); + let name = layer_name.unwrap_or_else(|| doc.next_layer_name(default_name("path"))); + let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable); + if consume_work { + doc.work_path = None; + } + *active = Some(id); + Ok(id) + })?; + shape_info(s, id) +} + pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result { const CMD: &str = "shape.edit"; let id = layer_id(s, p)?; diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index b659cbd656..ef48efe5a8 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -403,6 +403,58 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } +#[test] +fn path_to_shape_makes_a_shape_layer_from_the_work_path_in_one_step() { + let mut s = session(80, 80, 8); + let square = json!({"subpaths": [{"knots": [[10, 10], [50, 10], [50, 50], [10, 50]]}]}); + s.execute("path.set", json!({"path": square})).unwrap(); + let history = s.active().unwrap().history.past_len(); + let info = s.execute("path.toShape", json!({"fill": "#ff0000"})).unwrap(); + let id = LayerId(info["layer"].as_u64().unwrap()); + let d = doc(&s); + let LayerContent::Shape(sh) = &d.layer(id).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.subpaths[0].knots.len(), 4); + assert_eq!(sh.fill, Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)))); + // The Work Path became the shape's path. + assert!(d.work_path.is_none()); + assert_eq!(s.active().unwrap().active_layer, Some(id)); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + s.undo(); + assert!(doc(&s).layer(id).is_none()); + assert!(doc(&s).work_path.is_some()); +} + +#[test] +fn path_to_shape_keeps_a_saved_path() { + let mut s = session(80, 80, 8); + let tri = json!({"subpaths": [{"knots": [[10, 10], [50, 10], [30, 50]]}]}); + s.execute("path.set", json!({"name": "Tri", "path": tri})).unwrap(); + let info = s.execute("path.toShape", json!({"name": "Tri", "layerName": "Roof"})).unwrap(); + assert_eq!(info["name"], "Roof"); + assert_eq!(doc(&s).paths[0].name, "Tri"); +} + +#[test] +fn path_to_shape_rejects_bad_input_without_changing_history() { + let mut s = session(80, 80, 8); + assert!(s.execute("path.toShape", json!({})).is_err(), "no path"); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap(); + let before = doc(&s).clone(); + let history = s.active().unwrap().history.past_len(); + for p in [ + json!({"name": "missing"}), + json!({"fill": "not a colour"}), + json!({"fill": 42}), + json!({"stroke": {"width": -1}}), + json!({"stroke": {"width": 1e100}}), + json!({"stroke": true}), + ] { + assert!(s.execute("path.toShape", p.clone()).is_err(), "accepted {p}"); + assert_eq!(*doc(&s), before, "{p} modified the document"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{p} changed history"); + } +} + #[test] fn path_make_selection_rejects_invalid_options_without_changing_history() { let mut s = session(80, 80, 8); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0b09aae6e1..bb1e223191 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2290,6 +2290,7 @@ Punctuation علامات الترقيم Make Work Path إنشاء مسار عمل Make Selection إنشاء تحديد + Make Shape إنشاء شكل Fill Path تعبئة المسار Stroke Path رسم حدود للمسار Delete Path حذف المسار diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6b84a31cbf..3d4f24dcf8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3224,6 +3224,7 @@ cameraData Lens Objektiv Magnetic Lasso Magnetické laso Make Výrobce Make Selection Vytvořit výběr + Make Shape Vytvořit tvar Make Work Path Vytvořit pracovní cestu Manual Ručně Map Document Dokument mapy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00cfb09d84..72538cd966 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1209,6 +1209,7 @@ Path to Illustrator… Sti til Illustrator… Make Work Path Opret arbejdssti Make Selection Opret markering + Make Shape Opret form Fill Path Udfyld sti Stroke Path Lav kontur på sti Clipping Path Udklipningssti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c68e8e098d..e79622fc4a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2364,6 +2364,7 @@ Punctuation Zeichensetzung Make Work Path Arbeitspfad erzeugen Make Selection Auswahl erzeugen + Make Shape Form erzeugen Fill Path Pfad füllen Stroke Path Pfad nachziehen Delete Path Pfad löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e40e24e130..d0fef9fbc0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2781,6 +2781,7 @@ Make Κατασκευαστής Make Default Ορισμός ως προεπιλογής Make Selection Δημιουργία επιλογής + Make Shape Δημιουργία σχήματος Make Work Path Δημιουργία διαδρομής εργασίας Marker Size Μέγεθος δείκτη Master Κύριο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4b039176f4..5346fff77f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2339,6 +2339,7 @@ Punctuation Puntuación Make Work Path Crear trazado de trabajo Make Selection Crear selección + Make Shape Crear forma Fill Path Rellenar trazado Stroke Path Contornear trazado Delete Path Eliminar trazado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1cacf9ffea..924e0d13f1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1210,6 +1210,7 @@ Path to Illustrator… Polku Illustratoriin… Make Work Path Luo työpolku Make Selection Luo valinta + Make Shape Luo muoto Fill Path Täytä polku Stroke Path Piirrä polun reunaviiva Clipping Path Leikkauspolku diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c9e3bf5d3d..79efc2cc04 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2479,6 +2479,7 @@ Path to Illustrator… Tracé vers Illustrator… Make Work Path Créer un tracé de travail Make Selection Créer une sélection + Make Shape Créer une forme Fill Path Remplir le tracé Stroke Path Contourner le tracé Clipping Path Tracé de détourage diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 37e07e1690..a2b7e0bb2a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3431,6 +3431,7 @@ cameraRaw Yellow Sárga Magnetic Lasso Mágneses lasszó Magnetic Lasso Tool Mágneses lasszó Make Selection Kijelölés készítése + Make Shape Alakzat készítése Make work path from selection Munkaútvonal készítése a kijelölésből Manual Kézi manual kézi diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5d1a13dcc8..7fc324ecf5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2484,6 +2484,7 @@ Path to Illustrator… Path ke Illustrator… Make Work Path Buat Work Path Make Selection Buat Seleksi + Make Shape Buat Bentuk Fill Path Isi Path Stroke Path Stroke Path Clipping Path Clipping Path diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6c2d6c18cd..332da8afed 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2386,6 +2386,7 @@ Punctuation Punteggiatura Make Work Path Crea tracciato di lavoro Make Selection Crea selezione + Make Shape Crea forma Fill Path Riempi tracciato Stroke Path Traccia tracciato Delete Path Elimina tracciato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0d3714c9e2..27b4696295 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3024,6 +3024,7 @@ fontWeight Italic 斜体 Magnetic Lasso マグネット投げ縄 Make 作成 Make Selection 選択範囲を作成 + Make Shape シェイプを作成 Make Work Path 作業用パスを作成 Match Color カラーを一致 Mezzotint メゾティント diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c52d0c31ca..c4f301a7a9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3564,6 +3564,7 @@ curves Smooth 매끄럽게 Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 Make Selection 선택 영역 만들기 + Make Shape 모양 만들기 Bicubic Automatic 쌍입방 자동 Bicubic Smoother 쌍입방 더 부드럽게 Bicubic Sharper 쌍입방 더 선명하게 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 85f9a0441a..cfd0dec77c 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1209,6 +1209,7 @@ Path to Illustrator… Bane til Illustrator… Make Work Path Opprett arbeidsbane Make Selection Opprett utvalg + Make Shape Opprett form Fill Path Fyll bane Stroke Path Strek opp bane Clipping Path Klippebane diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c80142366a..cc7bb15a56 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2307,6 +2307,7 @@ Punctuation Interpunctie Make Work Path Werkpad maken Make Selection Selectie maken + Make Shape Vorm maken Fill Path Pad vullen Stroke Path Pad omlijnen Delete Path Pad verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1b5c9e6878..f908d78aab 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2508,6 +2508,7 @@ Path to Illustrator… Ścieżka do Illustratora… Make Work Path Utwórz ścieżkę roboczą Make Selection Utwórz zaznaczenie + Make Shape Utwórz kształt Fill Path Wypełnij ścieżkę Stroke Path Obrysuj ścieżkę Clipping Path Ścieżka przycinająca diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7b50d472fe..ace1b8fea0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2473,6 +2473,7 @@ Path to Illustrator… Demarcador para o Illustrator… Make Work Path Criar demarcador de trabalho Make Selection Fazer seleção + Make Shape Fazer forma Fill Path Preencher demarcador Stroke Path Traçar demarcador Clipping Path Demarcador de corte diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 487a6fc066..582b98556c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1341,6 +1341,7 @@ Path to Illustrator… Контур в Illustrator… Make Work Path Создать рабочий контур Make Selection Создать выделение + Make Shape Создать фигуру Fill Path Залить контур Stroke Path Обвести контур Clipping Path Обтравочный контур diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 0152204d90..742d4cb6e9 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2494,6 +2494,7 @@ Path to Illustrator… Putanja u Illustrator… Make Work Path Napravi radnu putanju Make Selection Napravi selekciju + Make Shape Napravi oblik Fill Path Popuni putanju Stroke Path Ocrtaj putanju Clipping Path Putanja isecanja diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 64191012c4..ba789d05dc 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1207,6 +1207,7 @@ Path to Illustrator… Bana till Illustrator… Make Work Path Skapa arbetsbana Make Selection Skapa markering + Make Shape Skapa form Fill Path Fyll bana Stroke Path Linje på bana Clipping Path Urklippsbana diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a45309f6b2..e9601ea02b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2260,6 +2260,7 @@ Punctuation Noktalama işaretleri Make Work Path Çalışma Yolu Oluştur Make Selection Seçim Yap + Make Shape Şekil Yap Fill Path Yolu Doldur Stroke Path Kontur Yolu Delete Path Yolu Sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index eec0b45c31..3459064c16 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1308,6 +1308,7 @@ Path to Illustrator… Контур до Illustrator… Make Work Path Створити робочий контур Make Selection Створити виділення + Make Shape Створити фігуру Fill Path Залити контур Stroke Path Обвести контур Clipping Path Відсічний контур diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 768d1dd2ad..570e33cb62 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1388,6 +1388,7 @@ ✓ Color Indicates Selected Areas ✓ Màu chỉ vùng được chọn Work Path Đường dẫn làm việc Make Selection Tạo vùng chọn + Make Shape Tạo hình Fill Path Tô đường dẫn Stroke Path Đi nét đường dẫn Save Path Lưu đường dẫn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2a203c08c1..e158ad8bf7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3015,6 +3015,7 @@ curves Smooth 平滑 Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 Make Selection 建立选区 + Make Shape 建立形状 Bicubic Automatic 双三次(自动) Bicubic Smoother 双三次(较平滑) Bicubic Sharper 双三次(较锐利) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6452c838bd..b92581c0b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2976,6 +2976,7 @@ curves Smooth 平滑 Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 Make Selection 建立選取範圍 + Make Shape 建立形狀 Bicubic Automatic 雙三次 (自動) Bicubic Smoother 雙三次 (較平滑) Bicubic Sharper 雙三次 (較銳利) diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index 0749b87434..aef9749948 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -41,7 +41,9 @@ pub(crate) use overlay::{curve_points, path_lines, selected_saved_path, shows_an pub(crate) use overlay::{draw_anchor, draw_handle, draw_outline}; #[cfg(test)] use path_ops::target_path; -pub use path_ops::{active_path_name, path_selection_finish, path_to_selection, pick_shape_at, selected_document_path, takes_path_mask, with_active_path}; +pub use path_ops::{ + active_path_name, make_shape, path_selection_finish, path_to_selection, pick_shape_at, selected_document_path, takes_path_mask, with_active_path, +}; pub(crate) use path_ops::{ active_shape_path, active_type_path, delete_key_deletes_work_path, deselect_loaded_path, free_transform_path, targeted_vector_mask, work_path_shown, }; diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index cd68a6bcb6..fb2528a65f 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -108,6 +108,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo app.ui.status = e; app.ui.status_error = true; } + // Make: Shape turns the path into a shape layer with the shape fill and stroke (#10934). + if ui.add_enabled(has_path, egui::Button::new(tl!("Make Shape"))).clicked() + && let Err(e) = super::make_shape(app) + { + app.ui.status = e; + app.ui.status_error = true; + } } return true; } diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index e6c3a1a6cc..f484725192 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -73,6 +73,25 @@ pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result Result { + let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + // A path being drawn into a targeted vector mask is that layer's path afterwards. + let pending_layer = pending && super::targeted_vector_mask(app).is_some(); + if pending && !pen_commit(app, false) { + return Err(app.ui.status.clone()); + } + let name = if pending_layer { Some("layer".to_string()) } else { active_path_name(app) } + .ok_or_else(|| tl!("No path to make a shape from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; + let r = app.run("path.toShape", json!({"name": name, "fill": super::shapes::fill_param(app), "stroke": super::shapes::stroke_param(app)}))?; + if name == "work" && app.ui.selected_path.as_deref() == Some("work") { + app.ui.selected_path = None; + } + Ok(r) +} + /// After the Pen or Path Selection tool made a selection from the path `name` (⌘↩, the canvas /// menu's Make Selection…), Photoshop deselects it in the Paths panel and stops drawing it; the /// path itself stays (#6079). The Work Path is hidden too when a saved path was loaded, so no diff --git a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs index 25ae41650b..8e713c098a 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs @@ -348,3 +348,62 @@ fn freeform_refuses_a_locked_shape_before_starting_a_stroke() { assert!(app.drag.is_some(), "{lock}: unlocked stroke can start"); } } + +/// #10934: the options bar's Make Shape (Path mode) turns the Work Path into a shape layer filled +/// with the foreground colour, as one History step; the Work Path is the shape's path afterwards. +/// A path still being drawn is finished first. With no path the button is disabled. +#[test] +fn make_shape_turns_the_work_path_into_a_shape_layer() { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::options_bar(app, ui, Tool::Pen)); + } + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + assert!(h.get_by_label("Make Shape").accesskit_node().is_disabled(), "no path yet"); + square(h.state_mut(), 20.0, 20.0, 100.0); + let drawn = work(h.state()); + let (count, steps) = (layers(h.state()), h.state().session.active().unwrap().history.past_len()); + h.run_steps(2); + h.get_by_label("Make Shape").click(); + h.run_steps(3); + let app = h.state(); + assert_eq!(layers(app), count + 1); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "one History step"); + let (id, path) = shape_path(app); + assert_eq!(path, drawn); + let st = app.session.active().unwrap(); + assert!(st.doc.work_path.is_none()); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("not a shape layer") }; + let fg = app.session.tools.foreground; + assert_eq!(sh.fill, Some(photocraft_doc::Fill::Solid(photocraft_color::Color::rgba(fg[0], fg[1], fg[2], fg[3])))); + // An open path still being drawn is finished into the Work Path, then made a shape. + for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] { + pen_down(h.state_mut(), x, y, false); + pen_up(h.state_mut()); + } + h.run_steps(2); + h.get_by_label("Make Shape").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.ui.pen.is_none()); + assert_eq!(layers(app), count + 2); + assert_eq!(shape_path(app).1.subpaths[0].knots.len(), 3); +} + +/// Make Shape on a saved path selected in the Paths panel keeps that path. +#[test] +fn make_shape_keeps_a_saved_path() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 50.0); + app.run("path.rename", json!({"name": "work", "to": "Box"})).unwrap(); + app.ui.selected_path = Some("Box".into()); + super::make_shape(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.paths.first().map(|p| p.name.as_str()), Some("Box")); + assert_eq!(shape_path(&app).1, st.doc.paths[0].path); +} From 818d3a026e2f51eb5bcbc506c0d5460962357456 Mon Sep 17 00:00:00 2001 From: DingusMcRinkyDinky <127614779+DingusMcRinkyDinky@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:30:24 +0100 Subject: [PATCH 36/60] path.toShape: refuse a name that isn't text A non-string `name` such as 42 was read as no name, so the command turned the Work Path into a shape instead of reporting the mistake. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZbgszHyuEuWAzxaamteuU --- crates/engine/src/cmd/vector_cmds/shape.rs | 6 +++++- crates/engine/src/cmd/vector_cmds/tests.rs | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 432b8b47f2..d688b6bd88 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -474,7 +474,11 @@ fn new_fill_stroke(s: &Session, cmd: &str, p: &Value) -> Result<(Option, O /// stays. With no work path, `"work"` takes the active layer's path, as the other path commands do. pub(super) fn path_to_shape(s: &mut Session, p: &Value) -> Result { const CMD: &str = "path.toShape"; - let name = p.get("name").and_then(Value::as_str); + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) => Some(n.as_str()), + Some(_) => return Err(bad(CMD, "`name` must be a path name: \"work\", \"layer\" or a saved path's name")), + }; let path = super::resolve_path(s, name)?; if path.subpaths.iter().all(|sp| sp.knots.is_empty()) { return Err(bad(CMD, "the path is empty")); diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index ef48efe5a8..55c70e5ab3 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -443,6 +443,8 @@ fn path_to_shape_rejects_bad_input_without_changing_history() { let history = s.active().unwrap().history.past_len(); for p in [ json!({"name": "missing"}), + json!({"name": 42}), + json!({"name": ["work"]}), json!({"fill": "not a colour"}), json!({"fill": 42}), json!({"stroke": {"width": -1}}), From 74ce42807ecfb0be135281331a09f915fe921dfd Mon Sep 17 00:00:00 2001 From: nurana23799 Date: Tue, 3 Nov 2026 20:03:17 +0400 Subject: [PATCH 37/60] Layers filter: Group filter (#10883) --- crates/ui-egui/src/panels/layer_filter_ui.rs | 36 +++++- .../src/panels/layer_filter_ui/group.rs | 25 ++++ .../src/panels/layer_filter_ui/group_tests.rs | 118 ++++++++++++++++++ crates/ui-egui/src/panels/layer_tree_ui.rs | 2 +- crates/ui-egui/src/panels/layers.rs | 3 +- docs/control-protocol.md | 3 +- 6 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/group.rs create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index fd035f3284..eaa086d29a 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -3,6 +3,8 @@ //! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel //! can read and set them. +use std::collections::HashSet; + use photocraft_color::BlendMode; use photocraft_doc::{Layer, LayerContent}; use serde::{Deserialize, Serialize}; @@ -18,6 +20,7 @@ pub enum FilterBy { Kind, Name, Mode, + Group, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -42,6 +45,7 @@ impl Filter { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), FilterBy::Mode => true, + FilterBy::Group => true, } } @@ -54,8 +58,26 @@ impl Filter { FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), FilterBy::Mode => l.blend == self.mode, + FilterBy::Group => true, } } + + pub fn show_all(&self) -> bool { + self.active() && self.by != FilterBy::Group + } + + /// The layers the Group filter lists, worked out once per frame (empty for other filters). + pub fn group_ids(&self, doc: &photocraft_doc::Document) -> HashSet { + if self.by == FilterBy::Group { group::listed(doc) } else { HashSet::new() } + } + + /// Like [`Filter::shows`], with the Group filter's precomputed ids from [`Filter::group_ids`]. + pub fn shows_in(&self, groups: &HashSet, l: &Layer) -> bool { + if self.by == FilterBy::Group { + return groups.contains(&l.id.0); + } + self.shows(l) + } } /// The kinds the Kind filter toggles. @@ -72,7 +94,7 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result &'static str { pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode")), (FilterBy::Group, tl!("Group"))]; widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); match app.ui.layer_filter_by { @@ -153,9 +175,19 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let width = ui.available_width().max(96.0); widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width); } + FilterBy::Group => {} } }); } +mod group; + #[cfg(test)] mod tests; +mod group; + +#[cfg(test)] +mod group_tests; + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group.rs b/crates/ui-egui/src/panels/layer_filter_ui/group.rs new file mode 100644 index 0000000000..8af26b6ad5 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group.rs @@ -0,0 +1,25 @@ +//! The Layers panel's Group filter (#10883): every group with its whole subtree; layers in no +//! group are hidden. An artboard row is not a group here, but the groups inside it are. + +use std::collections::HashSet; + +use photocraft_doc::{Document, Layer, LayerContent}; + +/// The ids of the layers the Group filter lists. +pub fn listed(doc: &Document) -> HashSet { + fn rec(layers: &[Layer], inside: bool, out: &mut HashSet) { + for l in layers { + let artboard = matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some()); + let inside = inside || (l.is_group() && !artboard); + if inside { + out.insert(l.id.0); + } + if let Some(children) = l.children() { + rec(children, inside, out); + } + } + } + let mut out = HashSet::new(); + rec(&doc.layers, false, &mut out); + out +} \ No newline at end of file diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs new file mode 100644 index 0000000000..b37b534c68 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs @@ -0,0 +1,118 @@ +//! #10883: the Layers panel's Group filter. + +use photocraft_doc::Document; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::state::UiState; + +fn group_filter() -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Group, ..UiState::default() }) +} + +fn new_session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +fn id(doc: &Document, name: &str) -> u64 { + doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0).unwrap_or_else(|| panic!("no layer {name}")) +} + +fn select(s: &mut photocraft_engine::Session, name: &str) { + let layer = id(&s.active().unwrap().doc, name); + s.execute("layer.select", json!({"layer": layer})).unwrap(); +} + +/// A new layer above the one selected (inside its group, if it is in one). +fn add(s: &mut photocraft_engine::Session, name: &str) { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); +} + +/// Groups the layer `name`. +fn group(s: &mut photocraft_engine::Session, name: &str, group_name: &str) { + select(s, name); + s.execute("layer.groupLayers", json!({"name": group_name})).unwrap(); +} + +fn set_expanded(s: &mut photocraft_engine::Session, name: &str, expanded: bool) { + let layer = id(&s.active().unwrap().doc, name); + s.execute("layer.setExpanded", json!({"layer": layer, "expanded": expanded})).unwrap(); +} + +/// The rows the panel draws under the Group filter, top to bottom, two spaces per level. +fn listed(doc: &Document) -> Vec { + let f = group_filter(); + let ids = f.group_ids(doc); + crate::layer_tree_ui::display_rows(doc, f.show_all()) + .into_iter() + .filter(|(_, l)| f.shows_in(&ids, l)) + .map(|(depth, l)| format!("{}{}", " ".repeat(depth), l.name)) + .collect() +} + +/// Top Pixel · Outer { Inner { Deep } } · Background. +fn nested() -> photocraft_engine::Session { + let mut s = new_session(); + add(&mut s, "Top Pixel"); + add(&mut s, "Deep"); + group(&mut s, "Deep", "Inner"); + group(&mut s, "Inner", "Outer"); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + s +} + +#[test] +fn lists_nested_groups_with_their_contents_and_hides_loose_layers() { + let s = nested(); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer", " Inner", " Deep"]); +} + +#[test] +fn collapsed_groups_stay_collapsed() { + let mut s = nested(); + set_expanded(&mut s, "Inner", false); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer", " Inner"], "a collapsed nested group hides its children"); + set_expanded(&mut s, "Outer", false); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer"], "a collapsed group hides everything inside"); + set_expanded(&mut s, "Outer", true); + assert_eq!(listed(&s.active().unwrap().doc), ["Outer", " Inner"], "each group keeps its own state"); +} + +#[test] +fn an_artboard_is_not_listed_but_its_groups_are() { + let mut s = new_session(); + add(&mut s, "G child"); + group(&mut s, "G child", "Plain G"); + select(&mut s, "Background"); + add(&mut s, "A child"); + s.execute("layer.new.artboardFromLayers", json!({"name": "Artboard 1"})).unwrap(); + select(&mut s, "A child"); + add(&mut s, "GA child"); + group(&mut s, "GA child", "G in AB"); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + assert_eq!(listed(&s.active().unwrap().doc), [" G in AB", " GA child", "Plain G", " G child"]); + set_expanded(&mut s, "Artboard 1", false); + assert_eq!(listed(&s.active().unwrap().doc), ["Plain G", " G child"], "a collapsed artboard hides the groups inside it"); +} + +#[test] +fn the_selection_does_not_change_the_list_or_the_selection() { + let mut s = nested(); + select(&mut s, "Deep"); + let doc_selected = listed(&s.active().unwrap().doc); + let before = s.active().unwrap().active_layer; + select(&mut s, "Top Pixel"); + assert_eq!(listed(&s.active().unwrap().doc), doc_selected, "same list whatever is selected"); + select(&mut s, "Deep"); + assert_eq!(s.active().unwrap().active_layer, before, "listing never selects or deselects"); +} + +#[test] +fn group_is_set_by_name_through_the_control_channel() { + let ui = UiState::default(); + let f = super::patched(&ui, &json!({"by": "group"})).unwrap(); + assert_eq!(f.by, FilterBy::Group); + assert!(super::patched(&ui, &json!({"by": "nope"})).is_err()); +} \ No newline at end of file diff --git a/crates/ui-egui/src/panels/layer_tree_ui.rs b/crates/ui-egui/src/panels/layer_tree_ui.rs index 858fe432fc..32d401b3f1 100644 --- a/crates/ui-egui/src/panels/layer_tree_ui.rs +++ b/crates/ui-egui/src/panels/layer_tree_ui.rs @@ -39,7 +39,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> { /// Mask session's working text, which is no layer to the user: Photoshop shows only the mask /// overlay while you type (#3036). pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> { - let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).active()); + let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).show_all()); if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) { rows.retain(|(_, l)| l.id.0 != working); } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 176ed271e1..da98160cc8 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -271,6 +271,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.request_repaint(); } let filter = crate::layer_filter_ui::Filter::of(&app.ui); + let group_ids = filter.group_ids(&doc); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last @@ -281,7 +282,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { continue; } - if !filter.shows(l) { + if !filter.shows_in(&group_ids, l) { continue; } let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7c5f4f2c24..5117c7f870 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root -- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name`, `mode` or `group` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. +Group lists every group that is not an artboard with everything inside it, and hides layers that are in no group; it has no further controls and filters at once, and each group keeps its own open or closed state. Fields left out keep their value; a bad one is an error and changes nothing. - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. From 3dbf843df08a81b9bc23bc4b78fb0593a958d165 Mon Sep 17 00:00:00 2001 From: nurana23799 Date: Tue, 3 Nov 2026 20:04:40 +0400 Subject: [PATCH 38/60] Test empty groups in Group filter --- .../src/panels/layer_filter_ui/group_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs index b37b534c68..4dbc2a794a 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs @@ -115,4 +115,17 @@ fn group_is_set_by_name_through_the_control_channel() { let f = super::patched(&ui, &json!({"by": "group"})).unwrap(); assert_eq!(f.by, FilterBy::Group); assert!(super::patched(&ui, &json!({"by": "nope"})).is_err()); +} + +#[test] +fn lists_empty_groups() { + let mut s = new_session(); + + s.execute("layer.new.group", json!({"name": "Empty Group"})).unwrap(); + + assert_eq!( + listed(&s.active().unwrap().doc), + ["Empty Group"], + "empty groups should appear in the Group filter" + ); } \ No newline at end of file From 10876cb20db4e1c591de5645a9e31d6e4ebdf8bc Mon Sep 17 00:00:00 2001 From: NaufalDzz Date: Wed, 4 Nov 2026 10:54:58 +0700 Subject: [PATCH 39/60] feat(atn): load and save Blend If and Gradient Fill layers Blend If in a recorded layer Set loads as layer.layerStyle.blendingOptions and plays (it was dropped without a word). The Blending Options dialog's Set (opacity, fill, mode, Blend If, Advanced) stays one step and saves back as one Set; with other keys those go through layer.setProps. Gradient Fill Make and Set load as layer.newFillLayer.gradient and gradient.fill.set with stops, transparency, midpoints, scale, offset, dither and align. Midpoint k is the Mdpn of stop k + 1, as the PSD reader reads it. A loaded step saves back as its recorded descriptor until it is edited; a Noise gradient, smoothness below 100 %, a non-classic interpolation or a transparency midpoint stay Photoshop steps. Fixes #10771 --- crates/engine/src/cmd.rs | 19 +- crates/engine/src/cmd/atn_cmds/events.rs | 2 + crates/engine/src/cmd/atn_cmds/steps_make.rs | 378 ++++++++++++++++++- crates/engine/src/cmd/atn_cmds/tests.rs | 314 +++++++++++++++ docs/atn.md | 14 +- 5 files changed, 706 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index df40333b90..0933f77763 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -870,7 +870,7 @@ fn build() -> Vec { "Gradient…", ["Layer", "New Fill Layer"], None, - r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, + r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask),"stops"?,"transparency"?,"midpoints"?,"scale"?,"offset"?,"dither"?,"align"? (as gradient.fill.set; stops replace from/to)}"##, has_doc, |s, p| { let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; @@ -879,15 +879,26 @@ fn build() -> Vec { let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); + // The rest of a recorded Gradient Fill (a loaded action's Make): applied as + // `gradient.fill.set` would, in the same step. + let rest: serde_json::Map = ["stops", "transparency", "midpoints", "scale", "offset", "dither", "align"] + .iter() + .filter_map(|k| p.get(*k).map(|v| ((*k).to_string(), v.clone()))) + .collect(); + let (fg, bg) = (s.tools.foreground, s.tools.background); let id = s.edit("New Gradient Fill Layer", |doc, active| { - let fill = Fill::gradient( + let mut fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], angle, 1.0, style, reverse, - ) - .in_mode(doc.mode); + ); + if !rest.is_empty() { + let l = Layer::new(String::new(), LayerContent::Fill(fill.clone())); + fill = crate::gradient_fill_cmds::apply_set(&l, &fill, doc.bounds(), &Value::Object(rest.clone()), fg, bg)?; + } + let fill = fill.in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index b611026809..66cb22e972 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -808,6 +808,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.new.layerViaCut", "layer.newFillLayer.solidColor", "layer.newFillLayer.pattern", + "layer.newFillLayer.gradient", "layer.newAdjustmentLayer.levels", "layer.newAdjustmentLayer.curves", "layer.newAdjustmentLayer.hueSaturation", @@ -852,6 +853,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.setLabelColor", "layer.setAdjustment", "layer.fill.pattern.set", + "gradient.fill.set", "layer.new.layerFromBackground", "layer.layerStyle.blendingOptions", "layer.smartFilter.blendingOptions", diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index b45ceddabf..7939523bc3 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1,11 +1,11 @@ use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps, levels_complete}; use super::descriptors::{ - PCT, PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, - jnum, jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, + PCT, PX, Target, ang, boolean, class, class_ref, code, color_desc, color_hex, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj, + get_ref, get_text, jbool, jnum, jstr, layer_ref, layer_target, long, name_ref, num, pct, px, ref_class, target, target_ref, text, trimmed, }; use super::fields::{Mapped, none, step, steps}; use super::steps_layer::quick_mask_ref; -use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_back}; +use super::tables::{CHANNELS, LABEL_COLORS, MODES, blend_label, blend_ps, channel_of, channel_ref, lookup, lookup_back}; use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json}; /// A `patternLayer` descriptor's pattern, scale, angle and link as the params of @@ -62,6 +62,330 @@ fn pattern_desc(p: &Value) -> Option { Some(t) } +/// Gradient Fill layers: `GrdT` ↔ `style`. +const GRADIENT_STYLES: &[(&str, &str)] = &[("Lnr", "linear"), ("Rdl", "radial"), ("Angl", "angle"), ("Rflt", "reflected"), ("Dmnd", "diamond")]; + +/// The `gradientLayer` keys [`gradient_params`] reads; a descriptor with any other key stays a +/// kept Photoshop step. +const GRADIENT_LAYER_KEYS: &[&str] = &["Grad", "Type", "Angl", "Rvrs", "Dthr", "Algn", "Scl ", "Ofst", "gradientsInterpolationMethod"]; + +/// The hidden param an imported Gradient Fill step keeps its recorded `gradientLayer` descriptor +/// in, so Save Actions writes it back unchanged while its params are the ones it loaded with. +const GRADIENT_DESCRIPTOR: &str = "__atnDescriptor"; + +/// A `gradientLayer` descriptor as the params of `layer.newFillLayer.gradient` / +/// `gradient.fill.set`. Midpoint k belongs to the segment that ends at stop k + 1, as the PSD +/// reader reads it; all-default midpoints give `[]`, which clears custom ones on a Set. None for +/// what the fill does not play: a Noise gradient, a smoothness below 100 %, a non-classic +/// interpolation, a transparency midpoint or stops out of order. +fn gradient_params(t: &Descriptor) -> Option> { + if t.items.iter().any(|(k, _)| !GRADIENT_LAYER_KEYS.iter().any(|g| k.is(g))) { + return None; + } + let mut p = Map::new(); + if let Some(s) = t.get("Type") { + p.insert("style".into(), lookup(GRADIENT_STYLES, &enumerated(s)?)?.into()); + } + if let Some(a) = t.get("Angl") { + p.insert("angle".into(), num(a)?.into()); + } + for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither"), ("Algn", "align")] { + if let Some(v) = t.get(key) { + p.insert(param.into(), boolean(v)?.into()); + } + } + if let Some(s) = t.get("Scl ") { + p.insert("scale".into(), num(s)?.into()); + } + if let Some(o) = t.get("Ofst") { + let D::Descriptor(o) = o else { return None }; + p.insert("offset".into(), json!([get_num(o, "Hrzn")?, get_num(o, "Vrtc")?])); + } + if t.get("gradientsInterpolationMethod").is_some_and(|m| enumerated(m).as_deref() != Some("Gcls")) { + return None; + } + let g = get_obj(t, "Grad")?; + if get_enum(g, "GrdF").as_deref() != Some("CstS") || g.get("Intr").is_some_and(|i| num(i) != Some(4096.0)) { + return None; + } + let (mut stops, mut mids, mut previous) = (Vec::new(), Vec::new(), 0.0); + for (i, s) in get_list(g, "Clrs").filter(|c| (2..=1024).contains(&c.len()))?.iter().enumerate() { + let D::Descriptor(s) = s else { return None }; + let color = match get_enum(s, "Type").as_deref() { + None | Some("UsrS") => json!(color_hex(get_obj(s, "Clr ")?)?), + Some("FrgC") => json!("foreground"), + Some("BckC") => json!("background"), + Some(_) => return None, + }; + let at = get_num(s, "Lctn")?; + if !(previous..=4096.0).contains(&at) { + return None; + } + previous = at; + stops.push(json!([at / 4096.0, color])); + if i > 0 { + mids.push(get_num(s, "Mdpn").unwrap_or(50.0) / 100.0); + } + } + p.insert("stops".into(), Value::Array(stops)); + if let Some(trns) = get_list(g, "Trns") { + let mut out = Vec::new(); + for s in trns { + let D::Descriptor(s) = s else { return None }; + if get_num(s, "Mdpn").is_some_and(|m| m != 50.0) { + return None; + } + out.push(json!([get_num(s, "Lctn")? / 4096.0, get_num(s, "Opct")?])); + } + p.insert("transparency".into(), Value::Array(out)); + } + if mids.iter().all(|m| *m == 0.5) { + mids.clear(); + } + p.insert("midpoints".into(), json!(mids)); + Some(p) +} + +/// [`gradient_params`] plus the recorded descriptor, for Save Actions. +fn gradient_step(t: &Descriptor) -> Option { + let mut p = gradient_params(t)?; + p.insert(GRADIENT_DESCRIPTOR.into(), photocraft_paint::tile::b64_encode(&t.to_bytes()).into()); + Some(Value::Object(p)) +} + +/// The `gradientLayer` descriptor for a Gradient Fill step: the recorded one while the params are +/// still what it imports as, else one written from the params. That needs every stop as +/// `#rrggbb` on a 1/4096 location, whole-percent midpoints and, for a Set (`set`), the +/// transparency stops: Photoshop's Set replaces the whole gradient, where the command keeps the +/// layer's own transparency. +fn gradient_desc(p: &Value, set: bool) -> Option { + if let Some(t) = jstr(p, GRADIENT_DESCRIPTOR).and_then(photocraft_paint::tile::b64_decode).and_then(|b| Descriptor::from_bytes(&b).ok()) { + let (mut a, mut b) = (Value::Object(gradient_params(&t)?), p.clone()); + b.as_object_mut()?.remove(GRADIENT_DESCRIPTOR); + super::convert::normalise(&mut a); + super::convert::normalise(&mut b); + if a == b { + return Some(t); + } + } + const KEYS: &[&str] = &["style", "angle", "reverse", "dither", "align", "scale", "offset", "stops", "transparency", "midpoints", GRADIENT_DESCRIPTOR]; + if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !KEYS.contains(&k.as_str())) || (set && p.get("transparency").is_none()) { + return None; + } + let location = |v: &Value| { + let t = v.as_f64()? * 4096.0; + ((0.0..=4096.0).contains(&t) && t.fract() == 0.0).then(|| long(t)) + }; + let stops = p.get("stops")?.as_array().filter(|s| (2..=1024).contains(&s.len()))?; + let mids: Vec = match p.get("midpoints") { + None | Some(Value::Null) => Vec::new(), + Some(m) => m.as_array()?.iter().map(Value::as_f64).collect::>()?, + }; + if mids.len() >= stops.len() { + return None; + } + let (mut clrs, mut previous) = (Vec::new(), 0.0); + for (i, s) in stops.iter().enumerate() { + let [at, color] = s.as_array()?.as_slice() else { return None }; + if at.as_f64()? < previous { + return None; + } + previous = at.as_f64()?; + // A foreground or background stop is left out: Photoshop records the colour it had too. + let color = color_desc(color.as_str()?)?; + let mid = if i == 0 { 50.0 } else { mids.get(i - 1).copied().unwrap_or(0.5) * 100.0 }; + if (mid - mid.round()).abs() > 1e-9 || !(5.0..=95.0).contains(&mid.round()) { + return None; + } + clrs.push(D::Descriptor( + desc("Clrt").with("Clr ", D::Descriptor(color)).with("Type", enm("Clry", "UsrS")).with("Lctn", location(at)?).with("Mdpn", long(mid)), + )); + } + let default_trns = vec![json!([0, 100]), json!([1, 100])]; + let trns: Option> = p + .get("transparency") + .and_then(Value::as_array) + .unwrap_or(&default_trns) + .iter() + .map(|s| { + let [at, o] = s.as_array()?.as_slice() else { return None }; + Some(D::Descriptor(desc("TrnS").with("Opct", pct(o.as_f64()?)).with("Lctn", location(at)?).with("Mdpn", long(50.0)))) + }) + .collect(); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(clrs)) + .with("Trns", D::List(trns?)); + let mut t = desc("gradientLayer"); + if let Some(a) = jnum(p, "angle") { + t = t.with("Angl", ang(a)); + } + if let Some(s) = jstr(p, "style") { + t = t.with("Type", enm("GrdT", lookup_back(GRADIENT_STYLES, s)?)); + } + for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr"), ("align", "Algn")] { + if let Some(v) = p.get(param).filter(|v| !v.is_null()) { + t = t.with(key, D::Boolean(v.as_bool()?)); + } + } + if let Some(s) = jnum(p, "scale") { + t = t.with("Scl ", pct(s)); + } + if let Some(o) = p.get("offset").filter(|v| !v.is_null()) { + let [x, y] = o.as_array()?.as_slice() else { return None }; + t = t.with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(x.as_f64()?)).with("Vrtc", pct(y.as_f64()?)))); + } + Some(t.with("Grad", D::Descriptor(grad))) +} + +/// `layer.newFillLayer.gradient` as a Make of a `contentLayer`. The two-colour form (`from`, +/// `to`) becomes the stops it plays as; without them it plays the toolbox colours, which +/// Photoshop records with the colour they had. +fn gradient_make_to(p: &Value) -> Option { + let mut p = p.clone(); + let o = p.as_object_mut()?; + if o.remove("path").is_some_and(|v| !v.is_null()) { + return None; + } + match (o.remove("from").filter(|v| !v.is_null()), o.remove("to").filter(|v| !v.is_null())) { + (None, None) => {} + (Some(a), Some(b)) if !o.contains_key("stops") => { + o.insert("stops".into(), json!([[0, a], [1, b]])); + } + _ => return None, + } + let t = gradient_desc(&p, false)?; + Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))) +} + +/// `layer.layerStyle.blendingOptions` as a Set of the target layer. +fn blending_options_to(p: &Value) -> Option { + let o = p.as_object()?; + let blend_if = p.get("blendIf"); + let mut t = desc("Lyr "); + if blend_if.is_some() { + // With Blend If, the dialog's opacities and mode are in the same `set`; without it, + // Photoshop's `set` of them loads as `layer.setProps`. + for (param, key) in [("opacity", "Opct"), ("fillOpacity", "fillOpacity")] { + if let Some(v) = jnum(p, param) { + t = t.with(key, pct(v)); + } + } + if let Some(m) = jstr(p, "blend") { + t = t.with("Md ", enm("BlnM", blend_ps(m)?)); + } + } + for (key, param) in ADVANCED_BLENDING { + if let Some(b) = jbool(p, param) { + t = t.with(key, D::Boolean(b)); + } + } + if let Some(b) = blend_if { + t = t.with("Blnd", blend_if_to(b)?); + } + // Only these export: Photoshop records the dialog's other options differently. + if t.items.is_empty() || t.items.len() != o.len() { + return None; + } + Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))) +} + +/// Blend If: a `Blnd` list's `Chnl` ↔ `blendIf` channel (`Gry` is the composite). +fn blend_if_channel(r: &[ReferenceItem]) -> Option<&'static str> { + match target(r) { + Target::Ordinal(o) if o == "Cmps" => Some("gray"), + Target::Ordinal(o) => channel_of(&o), + _ => None, + } +} + +/// The slider keys of one `Blnd` entry: This Layer, then Underlying Layer, each black low and +/// high, white low and high. +const BLEND_IF_KEYS: [[&str; 4]; 2] = [["SrcB", "Srcl", "SrcW", "Srcm"], ["DstB", "Dstl", "DstW", "Dstt"]]; + +/// A `Blnd` list as `layer.layerStyle.blendingOptions`' `blendIf`. Each entry's sliders are +/// whole levels 0..255 (also in a 16-bit document); Underlying Layer may be missing. None for +/// anything else, so the step stays a kept Photoshop step. +fn blend_if_from(list: &[D]) -> Option { + let mut out = Vec::with_capacity(list.len()); + for e in list { + let D::Descriptor(e) = e else { return None }; + if e.items.iter().any(|(k, _)| !k.is("Chnl") && !BLEND_IF_KEYS.iter().flatten().any(|b| k.is(b))) { + return None; + } + let channel = blend_if_channel(get_ref(e, "Chnl")?)?; + let range = |keys: &[&str; 4]| -> Option> { + let v: Vec> = keys + .iter() + .map(|k| match e.get(k) { + Some(D::Integer(i)) if (0..=255).contains(i) => Some(*i), + _ => None, + }) + .collect(); + match v.iter().flatten().count() { + 0 if keys.iter().all(|k| e.get(k).is_none()) => Some(None), + 4 => Some(Some(json!(v.into_iter().flatten().collect::>()))), + _ => None, + } + }; + let mut m = json!({"channel": channel, "thisLayer": range(&BLEND_IF_KEYS[0])??}); + if let Some(u) = range(&BLEND_IF_KEYS[1])? { + m["underlying"] = u; + } + out.push(m); + } + (!out.is_empty()).then_some(Value::Array(out)) +} + +/// `blendIf` back as a `Blnd` list: named channels and whole levels only (an index or `null` +/// reset has no Photoshop form). +fn blend_if_to(v: &Value) -> Option { + let entries = match v { + Value::Array(a) => a.iter().collect::>(), + Value::Object(_) => vec![v], + _ => return None, + }; + let mut list = Vec::with_capacity(entries.len()); + for e in entries { + let o = e.as_object()?; + if o.keys().any(|k| !matches!(k.as_str(), "channel" | "thisLayer" | "underlying")) { + return None; + } + let channel = match e.get("channel") { + None | Some(Value::Null) => "gray", + Some(Value::String(c)) if c == "grey" => "gray", + Some(Value::String(c)) => c.as_str(), + _ => return None, + }; + lookup_back(CHANNELS, channel)?; + let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel))); + for (param, keys) in [("thisLayer", &BLEND_IF_KEYS[0]), ("underlying", &BLEND_IF_KEYS[1])] { + let Some(r) = e.get(param) else { + if param == "thisLayer" { + return None; + } + continue; + }; + let r: Vec = r.as_array()?.iter().map(Value::as_f64).collect::>()?; + let q = match r.as_slice() { + [b, w] => [*b, *b, *w, *w], + [a, b, c, w] => [*a, *b, *c, *w], + _ => return None, + }; + if !q.iter().all(|x| (0.0..=255.0).contains(x) && x.fract() == 0.0) || !q.windows(2).all(|w| w[0] <= w[1]) { + return None; + } + for (k, x) in keys.iter().zip(q) { + d = d.with(k, long(x)); + } + } + list.push(D::Descriptor(d)); + } + Some(D::List(list)) +} + /// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command. const MASK_USING: &[(&str, &str)] = &[ ("RvlA", "layer.layerMask.revealAll"), @@ -259,6 +583,10 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) { + Some(p) => step("layer.newFillLayer.gradient", p), + None => Mapped::Unsupported { harmless: false }, + }, _ => Mapped::Unsupported { harmless: false }, }, Some("layerSection") => { @@ -382,6 +710,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.newFillLayer.gradient" => gradient_make_to(p)?, "layer.duplicate" => { // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). // Anything else falls through to the `Dplc` event below. @@ -573,6 +902,10 @@ const LOCKS: &[(&str, &str)] = &[ ("protectAll", "all"), ]; +/// The Blending Options keys besides the Advanced checkboxes that `layer.layerStyle.blendingOptions` +/// takes from a `set` of the layer: opacity, fill opacity, mode and Blend If. +const BLENDING_DIALOG: &[&str] = &["Opct", "fillOpacity", "Md ", "Blnd"]; + /// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params. const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")]; @@ -654,6 +987,24 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { advanced.insert((*param).into(), b.into()); } } + if let Some(list) = get_list(t, "Blnd") { + let Some(b) = blend_if_from(list) else { return unsupported }; + advanced.insert("blendIf".into(), b); + // The Blending Options dialog's OK: with nothing but its own options, the opacities and + // the mode go with Blend If into one step, saved back as the one `set`. + if t.items.iter().all(|(k, _)| BLENDING_DIALOG.iter().any(|b| k.is(b)) || ADVANCED_BLENDING.iter().any(|(a, _)| k.is(a))) { + for (key, param) in [("Opct", "opacity"), ("fillOpacity", "fillOpacity")] { + if let Some(v) = get_num(t, key) { + advanced.insert(param.into(), v.into()); + } + } + p.remove("opacity"); + p.remove("fill"); + if let Some(b) = p.remove("blend") { + advanced.insert("blend".into(), b); + } + } + } if !advanced.is_empty() { if !targeted { return unsupported; @@ -723,6 +1074,10 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)), _ => unsupported, }, + Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) { + Some(p) => step("gradient.fill.set", p), + None => unsupported, + }, _ => unsupported, }, (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { @@ -869,21 +1224,14 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?)) } - "layer.layerStyle.blendingOptions" => { - let o = p.as_object()?; - let mut t = desc("Lyr "); - for (key, param) in ADVANCED_BLENDING { - if let Some(b) = jbool(p, param) { - t = t.with(key, D::Boolean(b)); - } - } - // Only the Advanced checkboxes export: Photoshop records the dialog's other options - // differently. - if t.items.is_empty() || t.items.len() != o.len() { + "gradient.fill.set" => { + // A named layer or dragged handles have no place in the event. + if ["layer", "from", "to"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return None; } - desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)) + desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(gradient_desc(p, true)?)) } + "layer.layerStyle.blendingOptions" => blending_options_to(p)?, "layer.smartFilter.blendingOptions" => { if p.get("layer").is_some() { return None; diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 2b32e02220..c041d33bda 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -845,6 +845,11 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), ("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})), ("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})), + ("layer.layerStyle.blendingOptions", json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [0, 0, 255, 255]}]})), + ( + "layer.layerStyle.blendingOptions", + json!({"opacity": 40, "fillOpacity": 70, "blend": "Multiply", "blendIf": [{"channel": "blue", "thisLayer": [5, 5, 250, 250]}]}), + ), ("layer.createClippingMask", json!({})), ("layer.lockLayers", json!({"transparency": true})), ("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})), @@ -980,6 +985,9 @@ fn every_mapped_command_exports_and_imports_back() { "filter.sharpen.sharpenMore", // Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged). "filter.cameraRaw", + // Load with the recorded descriptor riding along (gradient_fill_steps_export_and_import_back). + "gradient.fill.set", + "layer.newFillLayer.gradient", ]; let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect(); assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}"); @@ -6063,3 +6071,309 @@ fn shear_nonfinite_coordinates_stay_kept() { } } } + +/// #10771: a Gradient Fill as Photoshop records it: three stops, a transparency stop at 40 % and +/// one midpoint per stop (`Mdpn` of stop k is the segment that ends at it). +fn recorded_gradient(mids: [i32; 3]) -> Descriptor { + let stop = |hex: &str, at: i32, mid: i32| { + D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(hex).unwrap())) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(at)) + .with("Mdpn", D::Integer(mid)), + ) + }; + let trn = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![stop("#ff0000", 0, mids[0]), stop("#00ff00", 2048, mids[1]), stop("#0000ff", 4096, mids[2])])) + .with("Trns", D::List(vec![trn(0, 100.0), trn(4096, 40.0)])); + desc("gradientLayer").with("Angl", ang(30.0)).with("Type", enm("GrdT", "Rdl ")).with("Grad", D::Descriptor(grad)) +} + +fn gradient_set(t: Descriptor) -> AtnItem { + item("setd", "Set", Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(t)))) +} + +fn gradient_make(t: Descriptor) -> AtnItem { + let d = desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))); + item("Mk ", "Make", Some(d)) +} + +/// The first saved step of `action` (strict save). +fn saved_first(s: &mut Session) -> (AtnEvent, Option) { + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let i = &saved.actions[0].items[0]; + (i.event.clone(), i.descriptor.clone()) +} + +fn without_hidden(p: &Value) -> Value { + let mut p = p.clone(); + p.as_object_mut().unwrap().remove("__atnDescriptor"); + normalise(&mut p); + p +} + +fn active_fill(s: &Session) -> photocraft_doc::Fill { + let st = s.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + photocraft_doc::LayerContent::Fill(f) => f.clone(), + c => panic!("not a fill layer: {}", c.kind_name()), + } +} + +/// #10771: a recorded Set of a Gradient Fill loads as `gradient.fill.set` with its midpoints on +/// the segment they belong to, plays them, and saves back as the recorded descriptor. +#[test] +fn gradient_fill_set_imports_midpoints_plays_and_saves_back_unchanged() { + let recorded = recorded_gradient([50, 25, 70]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded.clone())])).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let (id, p) = s.actions.list[0].steps[0].clone(); + assert_eq!(id, "gradient.fill.set"); + let mut expected = json!({ + "style": "radial", "angle": 30, + "stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]], + "transparency": [[0, 100], [1, 40]], + "midpoints": [0.25, 0.7] + }); + normalise(&mut expected); + assert_eq!(without_hidden(&p), expected); + assert_eq!( + saved_first(&mut s), + (AtnEvent::Code(*b"setd"), Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(recorded)))) + ); + // It plays on a Gradient Fill layer. + s.execute("file.new", json!({"width": 16, "height": 12})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; + assert_eq!(stops.len(), 3); + assert_eq!(midpoints, vec![0.25, 0.7]); + assert_eq!(opacity_stops.len(), 2); + // Default midpoints load as `[]`, which clears the custom ones when played. + let mut s2 = Session::new(); + s2.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 50, 50]))])).unwrap())})).unwrap(); + assert_eq!(s2.actions.list[0].steps[0].1["midpoints"], json!([])); + s.actions = s2.actions; + s.execute("actions.play", json!({"action": "A"})).unwrap(); + let photocraft_doc::Fill::Gradient { midpoints, .. } = active_fill(&s) else { panic!("not a gradient") }; + assert!(midpoints.is_empty() || midpoints.iter().all(|m| *m == 0.5), "{midpoints:?}"); +} + +/// #10771: an edited loaded step writes a fresh descriptor from its params, midpoints included. +#[test] +fn edited_gradient_fill_set_writes_a_fresh_descriptor() { + let mut s = Session::new(); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 25, 70]))])).unwrap())})).unwrap(); + s.actions.list[0].steps[0].1["midpoints"] = json!([0.4, 0.6]); + let (_, d) = saved_first(&mut s); + let t = get_obj(d.as_ref().unwrap(), "T ").unwrap(); + let clrs = get_list(get_obj(t, "Grad").unwrap(), "Clrs").unwrap(); + let mids: Vec = clrs.iter().map(|c| if let D::Descriptor(c) = c { get_num(c, "Mdpn").unwrap() } else { panic!() }).collect(); + assert_eq!(mids, vec![50.0, 40.0, 60.0]); + // Its own import is the edited params. + let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"setd")).unwrap().from)(d.as_ref().unwrap()) else { panic!("did not import") }; + let mut edited = without_hidden(&s.actions.list[0].steps[0].1); + edited["midpoints"] = json!([0.4, 0.6]); + normalise(&mut edited); + assert_eq!(without_hidden(&steps[0].1), edited); +} + +/// #10771: a recorded Make of a Gradient Fill layer plays its stops, transparency and midpoints +/// and saves back unchanged. +#[test] +fn gradient_fill_make_imports_plays_and_saves_back_unchanged() { + let recorded = recorded_gradient([50, 25, 70]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_make(recorded.clone())])).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + assert_eq!(s.actions.list[0].steps[0].0, "layer.newFillLayer.gradient"); + let (event, d) = saved_first(&mut s); + assert_eq!(event, AtnEvent::Code(*b"Mk ")); + assert_eq!(get_obj(get_obj(d.as_ref().unwrap(), "Usng").unwrap(), "Type"), Some(&recorded)); + for depth in [8, 16] { + s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; + assert_eq!((stops.len(), midpoints, opacity_stops.len()), (3, vec![0.25, 0.7], 2)); + // One undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert!(!matches!(s.active().unwrap().doc.layers.last().unwrap().content, photocraft_doc::LayerContent::Fill(_))); + } +} + +/// #10771: PhotoCraft's own Gradient Fill steps export and load back as the same fill. +#[test] +fn gradient_fill_steps_export_and_import_back() { + let set = json!({"style": "linear", "angle": 45, "stops": [[0, "#ff0000"], [0.25, "#00ff00"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 50]], "midpoints": [0.3, 0.6]}); + let item = export_item("gradient.fill.set", &set, "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") }; + let mut expected = set.clone(); + normalise(&mut expected); + assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("gradient.fill.set", expected)); + // The two-colour Make plays as the same two stops at full opacity. + let item = export_item("layer.newFillLayer.gradient", &json!({"from": "#ff0000", "to": "#0000ff", "angle": 45}), "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") }; + let mut expected = json!({"angle": 45, "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": []}); + normalise(&mut expected); + assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("layer.newFillLayer.gradient", expected)); + // What the event cannot hold is refused: a Set without its transparency, toolbox colours, + // dragged handles, a path, a midpoint off a whole percent. + for (id, p) in [ + ("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]]})), + ("gradient.fill.set", json!({"stops": [[0, "foreground"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})), + ("gradient.fill.set", json!({"from": [0, 0], "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})), + ("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": [0.333]})), + ("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "path": "work"})), + ("layer.newFillLayer.gradient", json!({"from": "#ff0000"})), + ] { + assert!(export_item(id, &p, "S").is_none(), "{id}: {p}"); + } +} + +/// #10771: a Noise gradient, smoothness below 100 % or a transparency midpoint stay Photoshop +/// steps rather than playing as a different fill. +#[test] +fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() { + // `with` appends; these replace the recorded value. + fn replaced(mut d: Descriptor, key: &str, v: D) -> Descriptor { + d.items.retain(|(k, _)| !k.is(key)); + d.with(key, v) + } + let base = recorded_gradient([50, 50, 50]); + let with_grad = |f: &dyn Fn(Descriptor) -> Descriptor| { + let g = get_obj(&base, "Grad").unwrap().clone(); + replaced(base.clone(), "Grad", D::Descriptor(f(g))) + }; + let trns_mid = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(0)).with("Mdpn", D::Integer(30)); + let trns_end = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(4096)).with("Mdpn", D::Integer(50)); + for t in [ + with_grad(&|g| replaced(g, "GrdF", enm("GrdF", "ClNs"))), + with_grad(&|g| replaced(g, "Intr", D::Double(2048.0))), + with_grad(&|g| replaced(g, "Trns", D::List(vec![D::Descriptor(trns_mid.clone()), D::Descriptor(trns_end.clone())]))), + base.clone().with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Perc")), + ] { + let imp = import(&atn::write(&one_action(vec![gradient_set(t)])).unwrap()).unwrap(); + assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP); + } +} + +fn blend_if_entry(channel: &str, this: [i32; 4], under: Option<[i32; 4]>) -> D { + let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel))); + for (k, v) in ["SrcB", "Srcl", "SrcW", "Srcm"].into_iter().zip(this) { + d = d.with(k, D::Integer(v)); + } + if let Some(u) = under { + for (k, v) in ["DstB", "Dstl", "DstW", "Dstt"].into_iter().zip(u) { + d = d.with(k, D::Integer(v)); + } + } + D::Descriptor(d) +} + +fn layer_set(t: Descriptor) -> AtnItem { + item("setd", "Set", Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)))) +} + +fn blend_layers(depth: u8) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s +} + +fn active_info(s: &Session) -> Value { + let st = s.active().unwrap(); + crate::inspect::layer(st.doc.layer(st.active_layer.unwrap()).unwrap()) +} + +/// #10771: Blend If loads as `layer.layerStyle.blendingOptions` and plays (it used to be a +/// silent no-op), on 8- and 16-bit documents, Gray and a colour channel, split sliders. +#[test] +fn blend_if_imports_and_plays() { + let blnd = D::List(vec![blend_if_entry("gray", [0, 30, 200, 255], Some([10, 10, 240, 250])), blend_if_entry("red", [20, 40, 255, 255], None)]); + for depth in [8, 16] { + let mut s = blend_layers(depth); + let loaded = s + .execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", blnd.clone()))])).unwrap())})) + .unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let mut expected = json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}, {"channel": "red", "thisLayer": [20, 40, 255, 255]}]}); + normalise(&mut expected); + let (id, p) = &s.actions.list[0].steps[0]; + assert_eq!((id.as_str(), p), ("layer.layerStyle.blendingOptions", &expected)); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let b = active_info(&s)["blendIf"].clone(); + assert_eq!(b[0], json!({"channel": 0, "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}), "{b}"); + assert_eq!(b[1], json!({"channel": 1, "thisLayer": [20, 40, 255, 255], "underlying": [0, 0, 255, 255]}), "{b}"); + // It saves back as the same `Blnd`. + let (_, d) = saved_first(&mut s); + assert_eq!(get_obj(d.as_ref().unwrap(), "T ").unwrap().get("Blnd"), Some(&blnd)); + } +} + +/// #10771: the Blending Options dialog's OK (opacity, fill, mode and Blend If in one Set) is one +/// step that applies them all; a Set that also renames splits into setProps and Blend If. +#[test] +fn blend_if_with_the_dialog_options_is_one_step_and_a_rename_splits() { + let blnd = D::List(vec![blend_if_entry("gray", [0, 0, 128, 255], None)]); + let dialog = desc("Lyr ").with("Opct", pct(40.0)).with("fillOpacity", pct(70.0)).with("Md ", enm("BlnM", "Mltp")).with("Blnd", blnd.clone()); + let mut s = blend_layers(8); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap(); + let steps = &s.actions.list[0].steps; + assert_eq!(steps.len(), 1, "{steps:?}"); + assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions"); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = active_info(&s); + assert!((info["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-3, "{info}"); + assert_eq!(info["blend"], json!("Multiply"), "{info}"); + assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}"); + let (_, d) = saved_first(&mut s); + assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog)); + // With a rename the Set is not the dialog's: the name and opacity go through setProps. + let mut s = blend_layers(8); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Nm ", text("Renamed")).with("Opct", pct(40.0)).with("Blnd", blnd))])).unwrap())})) + .unwrap(); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert!(ids.contains(&"layer.setProps") && ids.contains(&"layer.layerStyle.blendingOptions"), "{ids:?}"); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = active_info(&s); + assert_eq!(info["name"], json!("Renamed")); + assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}"); +} + +/// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying +/// sliders or an unknown key stay Photoshop steps. +#[test] +fn blend_if_the_command_cannot_play_stays_a_photoshop_step() { + let with = |key: &str| match blend_if_entry("gray", [0, 0, 255, 255], None) { + D::Descriptor(d) => D::Descriptor(d.with(key, D::Integer(3))), + e => e, + }; + let (half, unknown) = (with("DstB"), with("Zzzz")); + for e in [blend_if_entry("gray", [0, 0, 300, 255], None), half, unknown] { + let imp = import(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", D::List(vec![e])))])).unwrap()).unwrap(); + assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP); + } + for p in [ + json!({"blendIf": null}), + json!({"blendIf": {"channel": 2, "thisLayer": [0, 255]}}), + json!({"blendIf": {"channel": "gray", "thisLayer": [200, 100]}}), + json!({"blendIf": {"channel": "gray", "underlying": [0, 255]}}), + ] { + assert!(export_item("layer.layerStyle.blendingOptions", &p, "S").is_none(), "{p}"); + } +} diff --git a/docs/atn.md b/docs/atn.md index 64c6033bf5..dba55c40bb 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -99,7 +99,7 @@ the reference application. | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | -| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` | +| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newFillLayer.gradient`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` | | (string ID only) | animationFramesFromLayers | Make Frames from Layers | | | (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | @@ -142,7 +142,7 @@ the reference application. | (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | | (string ID only) | autoCutout | Select Subject | `select.subject` | | `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` | -| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | +| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `gradient.fill.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | | (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | | `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | | `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` | @@ -438,6 +438,16 @@ differences that do not change what a step does. `forceNotify`) and default flags (`MkVs` false, `Adjs` false) are not written back. - An adjustment layer's values equal to the defaults are not written back (other values are). - Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom". +- A loaded Gradient Fill Make or Set (`gradientLayer`) saves back as the recorded descriptor until + it is edited. Otherwise it is written from the params: `#rrggbb` stops on 1/4096 locations and + whole-percent midpoints; a Set also needs its transparency stops, as the event replaces the whole + gradient. Midpoint k is the `Mdpn` of stop k + 1 (the first stop's is 50). A Noise gradient, + smoothness below 100 %, a non-classic interpolation or a transparency midpoint stay Photoshop steps. +- Blend If (`Blnd` in a layer Set) loads as `layer.layerStyle.blendingOptions`. With only the + Blending Options dialog's keys (`Opct`, `fillOpacity`, `Md `, `Blnd`, the Advanced checkboxes) the + Set is one step and saves back as one Set; with any other key, those go through `layer.setProps`. + Slider values are whole levels 0..255, `SrcB`/`Srcl`/`SrcW`/`Srcm` for This Layer and + `DstB`/`Dstl`/`DstW`/`Dstt` for Underlying Layer (black low, black high, white low, white high). - Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543). ## Reference forms From 33ed06270a6ce54bffcc3c6b7ef0285b7f161471 Mon Sep 17 00:00:00 2001 From: DingusMcRinkyDinky <127614779+DingusMcRinkyDinky@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:26:32 +0100 Subject: [PATCH 40/60] Make Shape: a path being drawn becomes the shape in one step While drawing, Make Shape no longer commits the path first: the shape takes what the commit would make, as one History step. With a vector mask targeted the shape is the drawn path alone, and the mask stays as it was. A saved path being drawn into stays as it was. path.toShape takes an inline `path` (with `consumeWorkPath`), no longer copies the active shape or vector mask when there is no Work Path, refuses a path without a segment, and refuses a `layerName` that isn't a name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZbgszHyuEuWAzxaamteuU --- crates/engine/src/cmd/vector_cmds.rs | 2 +- crates/engine/src/cmd/vector_cmds/shape.rs | 35 +++++++++--- crates/engine/src/cmd/vector_cmds/tests.rs | 54 +++++++++++++++++++ .../ui-egui/src/tools/vector_ui/path_ops.rs | 36 +++++++++---- crates/ui-egui/src/tools/vector_ui/pen.rs | 42 +++++++++++++-- .../src/tools/vector_ui/pen_path_ops_tests.rs | 47 +++++++++++++++- 6 files changed, 193 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 21bfbfddba..32114036fc 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -235,7 +235,7 @@ pub fn specs() -> Vec { "Make Shape from Path", [], leak(format!( - r##"{{"name":str|"work"|"layer"="work" (the Work Path becomes the shape's path; a saved path stays),{fill}=foreground,{stroke}=none,"layerName":str?}} → shape.info"## + r##"{{"name":str|"work"|"layer"="work" (the Work Path becomes the shape's path; a saved path stays) | "path":{{subpaths}} (inline; not with name),"consumeWorkPath":bool=false (with path: it was drawn into the Work Path, which goes),{fill}=foreground,{stroke}=none,"layerName":str?}} → shape.info"## )), has_doc, path_to_shape diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index d688b6bd88..db54321cb5 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -469,9 +469,12 @@ fn new_fill_stroke(s: &Session, cmd: &str, p: &Value) -> Result<(Option, O Ok((fill, stroke)) } -/// The Pen options bar's Make: Shape: a new shape layer from the work path or a saved path, as one -/// History step. The Work Path becomes the shape's path, so it is gone afterwards; a saved path -/// stays. With no work path, `"work"` takes the active layer's path, as the other path commands do. +/// The Pen options bar's Make: Shape: a new shape layer, as one History step, from the work path +/// or a saved path (`name`), or from an inline `path` (a Pen path still being drawn). The Work +/// Path becomes the shape's path, so it is gone afterwards; a saved path stays. An inline `path` +/// leaves the document's paths alone unless `consumeWorkPath` says it was drawn into the Work +/// Path. Unlike the other path commands, `"work"` never falls back to the active layer's path: +/// that would duplicate the selected shape or vector mask. pub(super) fn path_to_shape(s: &mut Session, p: &Value) -> Result { const CMD: &str = "path.toShape"; let name = match p.get("name") { @@ -479,14 +482,30 @@ pub(super) fn path_to_shape(s: &mut Session, p: &Value) -> Result { Some(Value::String(n)) => Some(n.as_str()), Some(_) => return Err(bad(CMD, "`name` must be a path name: \"work\", \"layer\" or a saved path's name")), }; - let path = super::resolve_path(s, name)?; - if path.subpaths.iter().all(|sp| sp.knots.is_empty()) { - return Err(bad(CMD, "the path is empty")); + let consume_flag = match p.get("consumeWorkPath") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(CMD, "`consumeWorkPath` must be true or false")), + }; + let (path, consume_work) = match p.get("path").filter(|v| !v.is_null()) { + Some(_) if name.is_some() => return Err(bad(CMD, "give `name` or `path`, not both")), + Some(v) => (parse_path(v).map_err(|e| bad(CMD, e))?, consume_flag), + None if super::is_work(name) => { + let d = s.active().ok_or(EngineError::NoDocument)?; + (d.doc.work_path.clone().ok_or_else(|| bad(CMD, "there is no Work Path"))?, true) + } + None => (super::resolve_path(s, name)?, false), + }; + if !path.subpaths.iter().any(|sp| sp.knots.len() >= 2) { + return Err(bad(CMD, "the path has no segment to make a shape from")); } let (fill, stroke) = new_fill_stroke(s, CMD, p)?; vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; - let layer_name = p.get("layerName").and_then(Value::as_str).map(str::to_string); - let consume_work = super::is_work(name); + let layer_name = match p.get("layerName") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if !n.trim().is_empty() => Some(n.clone()), + Some(_) => return Err(bad(CMD, "`layerName` must be a name that isn't empty")), + }; let mut sh = ShapeLayer { path, fill, stroke, ..Default::default() }; let id = s.edit("New Shape Layer", |doc, active| { refresh_shape(doc, &mut sh); diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index 55c70e5ab3..47394823d6 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -457,6 +457,60 @@ fn path_to_shape_rejects_bad_input_without_changing_history() { } } +/// With no Work Path, `path.toShape` doesn't take the active shape layer's path: that would only +/// duplicate the shape. +#[test] +fn path_to_shape_without_a_work_path_does_not_copy_the_active_shape() { + let mut s = session(80, 80, 8); + s.execute("shape.create", json!({"rect": [10, 10, 30, 30]})).unwrap(); + let layers = doc(&s).layers.len(); + for p in [json!({}), json!({"name": "work"})] { + assert!(s.execute("path.toShape", p.clone()).is_err(), "{p} made a shape"); + } + assert_eq!(doc(&s).layers.len(), layers); +} + +/// An inline `path` (a Pen path still being drawn) makes the shape and leaves the Work Path alone, +/// unless it was drawn into the Work Path (`consumeWorkPath`). +#[test] +fn path_to_shape_takes_an_inline_path() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap(); + let drawn = json!({"subpaths": [{"knots": [[5, 5], [30, 5], [30, 30]]}]}); + let history = s.active().unwrap().history.past_len(); + let info = s.execute("path.toShape", json!({"path": drawn})).unwrap(); + let id = LayerId(info["layer"].as_u64().unwrap()); + let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.subpaths[0].knots[0].anchor, Point::new(5.0, 5.0)); + assert!(doc(&s).work_path.is_some(), "the Work Path stays"); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + s.execute("path.toShape", json!({"path": drawn, "consumeWorkPath": true})).unwrap(); + assert!(doc(&s).work_path.is_none()); +} + +#[test] +fn path_to_shape_rejects_paths_and_names_it_cannot_use() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap(); + let before = doc(&s).clone(); + let drawn = json!({"subpaths": [{"knots": [[5, 5], [30, 5], [30, 30]]}]}); + for p in [ + json!({"path": {"subpaths": [{"knots": [[5, 5]]}]}}), + json!({"path": {"subpaths": []}}), + json!({"path": 42}), + json!({"path": drawn, "name": "work"}), + json!({"path": drawn, "consumeWorkPath": "yes"}), + json!({"layerName": 5}), + json!({"layerName": ""}), + json!({"layerName": " "}), + ] { + assert!(s.execute("path.toShape", p.clone()).is_err(), "accepted {p}"); + assert_eq!(*doc(&s), before, "{p} modified the document"); + } + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12]]}]}})).unwrap(); + assert!(s.execute("path.toShape", json!({})).is_err(), "a one-anchor Work Path"); +} + #[test] fn path_make_selection_rejects_invalid_options_without_changing_history() { let mut s = session(80, 80, 8); diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index f484725192..c24f64a279 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -74,17 +74,35 @@ pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result Result { - let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); - // A path being drawn into a targeted vector mask is that layer's path afterwards. - let pending_layer = pending && super::targeted_vector_mask(app).is_some(); - if pending && !pen_commit(app, false) { - return Err(app.ui.status.clone()); + super::cancel_stale_pen(app); + if let Some(mut pen) = app.ui.pen.clone().filter(|p| p.knots.len() >= 2) { + // A path still being drawn becomes the shape in one step, without being committed first: + // into a targeted vector mask that would change the mask. + if pen.curvature { + super::curvature::curvature_refresh(&mut pen, false); + } + let (path, consume) = super::pen::draft_shape_path(app, &pen).ok_or_else(|| tl!("No document").to_string())?; + let r = app.run( + "path.toShape", + json!({"path": path, "consumeWorkPath": consume, "fill": super::shapes::fill_param(app), "stroke": super::shapes::stroke_param(app)}), + )?; + app.ui.pen = None; + app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + if consume && app.ui.selected_path.as_deref() == Some("work") { + app.ui.selected_path = None; + } + return Ok(r); } - let name = if pending_layer { Some("layer".to_string()) } else { active_path_name(app) } - .ok_or_else(|| tl!("No path to make a shape from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; + let name = + active_path_name(app).ok_or_else(|| tl!("No path to make a shape from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; let r = app.run("path.toShape", json!({"name": name, "fill": super::shapes::fill_param(app), "stroke": super::shapes::stroke_param(app)}))?; if name == "work" && app.ui.selected_path.as_deref() == Some("work") { app.ui.selected_path = None; diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index baa536f3b8..ce1e90cc12 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -445,10 +445,7 @@ fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> // path selected, a new Work Path replaces the old one. The drawn path ends up selected. // A resumed draft whose subpath changed meanwhile joins the path it came from, as a new // subpath, even with nothing selected in the Paths panel. - let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); - let resumed_from = - pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); - let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)); + let target = draft_target(app, pen); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); if target.is_some() { @@ -464,6 +461,43 @@ fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> true } +/// The document path a Path-mode draft joins when committed (not into a vector mask): the path it +/// resumed, else the path selected in the Paths panel. `None`: it becomes a new Work Path. +fn draft_target(app: &PhotocraftApp, pen: &PenPath) -> Option { + let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); + let resumed_from = + pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); + resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)) +} + +/// What Make Shape turns a Path-mode draft into, without committing it: the path the commit would +/// leave (the draft joined to the path it goes into, with the Path Operation), and whether that is +/// the Work Path, which the shape then takes. With a vector mask targeted, the draft alone: the +/// mask stays as it was. `None` with no document. +pub(super) fn draft_shape_path(app: &PhotocraftApp, pen: &PenPath) -> Option<(Value, bool)> { + let drawn = pen_to_json(pen, false); + let doc = &app.session.active()?.doc; + if pen.resumes.as_ref().is_some_and(|r| r.layer.is_some()) || targeted_vector_mask(app).is_some() { + return Some((drawn, false)); + } + if let Some(r) = pen.resumes.as_ref().filter(|r| r.doc == doc.id.0) + && let Some(p) = with_resumed_subpath(doc, r, &drawn) + { + return Some((p, r.path == "work")); + } + let Some(name) = draft_target(app, pen) else { return Some((drawn, false)) }; + let existing = doc_path(doc, &name)?; + let mut p = photocraft_engine::vector_cmds::path_json(existing); + let op = pen_path_op(&app.ui.tool_options).unwrap_or("combine"); + if let (Some(subs), Some(new)) = (p.get_mut("subpaths").and_then(Value::as_array_mut), drawn.get("subpaths").and_then(Value::as_array)) { + subs.extend(new.iter().cloned().map(|mut sp| { + sp["op"] = json!(op); + sp + })); + } + Some((p, name == "work")) +} + /// Does `doc` have the path the Paths panel calls `name`: "work", or a saved path's name? fn path_exists(doc: &Document, name: &str) -> bool { doc_path(doc, name).is_some() diff --git a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs index 8e713c098a..e09cc9e82a 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs @@ -381,11 +381,12 @@ fn make_shape_turns_the_work_path_into_a_shape_layer() { let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("not a shape layer") }; let fg = app.session.tools.foreground; assert_eq!(sh.fill, Some(photocraft_doc::Fill::Solid(photocraft_color::Color::rgba(fg[0], fg[1], fg[2], fg[3])))); - // An open path still being drawn is finished into the Work Path, then made a shape. + // An open path still being drawn becomes the shape too, still in one History step. for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] { pen_down(h.state_mut(), x, y, false); pen_up(h.state_mut()); } + let steps = h.state().session.active().unwrap().history.past_len(); h.run_steps(2); h.get_by_label("Make Shape").click(); h.run_steps(3); @@ -393,6 +394,50 @@ fn make_shape_turns_the_work_path_into_a_shape_layer() { assert!(app.ui.pen.is_none()); assert_eq!(layers(app), count + 2); assert_eq!(shape_path(app).1.subpaths[0].knots.len(), 3); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "one History step while drawing"); + assert!(app.session.active().unwrap().doc.work_path.is_none()); +} + +/// Make Shape while drawing with a vector mask targeted makes a shape of the drawn path alone and +/// leaves the mask as it was: the draft isn't committed into the mask first. +#[test] +fn make_shape_while_drawing_leaves_a_targeted_vector_mask_alone() { + let mut app = app(); + let id = app.session.active().unwrap().active_layer.unwrap().0; + app.run("layer.vectorMask.add", json!({"layer": id, "path": {"subpaths": [{"closed": true, "knots": [[0, 0], [100, 0], [100, 100], [0, 100]]}]}})).unwrap(); + app.ui.vector_mask_target = true; + let mask = app.session.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone(); + for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } + let steps = app.session.active().unwrap().history.past_len(); + super::make_shape(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(LayerId(id)).unwrap().vector_mask, mask, "the mask changed"); + assert_eq!(st.history.past_len(), steps + 1); + let (_, path) = shape_path(&app); + assert_eq!(path.subpaths.len(), 1); + assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(10.0, 150.0)); +} + +/// Make Shape while drawing into a selected saved path: the shape is the saved path plus the +/// drawing, as the commit would have made it, and the saved path stays as it was. +#[test] +fn make_shape_while_drawing_into_a_saved_path_keeps_it() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 50.0); + app.run("path.rename", json!({"name": "work", "to": "Box"})).unwrap(); + app.ui.selected_path = Some("Box".into()); + let saved = app.session.active().unwrap().doc.paths[0].path.clone(); + for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } + super::make_shape(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.paths[0].path, saved); + assert_eq!(shape_path(&app).1.subpaths.len(), saved.subpaths.len() + 1); } /// Make Shape on a saved path selected in the Paths panel keeps that path. From 4e4cb7d8e3521ae482544ff047e4e4882bd9a542 Mon Sep 17 00:00:00 2001 From: DingusMcRinkyDinky <127614779+DingusMcRinkyDinky@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:18:50 +0100 Subject: [PATCH 41/60] Make Shape: use the draft's own target; never copy a layer's path - A path being drawn becomes the shape worked out with the target and settings captured when drawing began, as the commit does. - With nothing selected, the drawing replaces an old Work Path. - A draft begun in Shape mode is committed, so a resumed subpath keeps its layer's other subpaths. - Without a draft, only the Work Path or a saved path is used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZbgszHyuEuWAzxaamteuU --- crates/ui-egui/src/tools/vector_ui/options.rs | 3 +- .../ui-egui/src/tools/vector_ui/path_ops.rs | 29 +++--- crates/ui-egui/src/tools/vector_ui/pen.rs | 63 ++++++++++--- .../src/tools/vector_ui/pen_path_ops_tests.rs | 90 +++++++++++++++++++ 4 files changed, 159 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index fb2528a65f..d5941af320 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -109,7 +109,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo app.ui.status_error = true; } // Make: Shape turns the path into a shape layer with the shape fill and stroke (#10934). - if ui.add_enabled(has_path, egui::Button::new(tl!("Make Shape"))).clicked() + let has_shape_path = super::path_ops::shape_source_path(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + if ui.add_enabled(has_shape_path, egui::Button::new(tl!("Make Shape"))).clicked() && let Err(e) = super::make_shape(app) { app.ui.status = e; diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index c24f64a279..3edef2ad86 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -74,35 +74,28 @@ pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result Result { super::cancel_stale_pen(app); if let Some(mut pen) = app.ui.pen.clone().filter(|p| p.knots.len() >= 2) { - // A path still being drawn becomes the shape in one step, without being committed first: - // into a targeted vector mask that would change the mask. if pen.curvature { super::curvature::curvature_refresh(&mut pen, false); } - let (path, consume) = super::pen::draft_shape_path(app, &pen).ok_or_else(|| tl!("No document").to_string())?; - let r = app.run( - "path.toShape", - json!({"path": path, "consumeWorkPath": consume, "fill": super::shapes::fill_param(app), "stroke": super::shapes::stroke_param(app)}), - )?; + let r = super::pen::make_shape_from_draft(app, &pen)?; app.ui.pen = None; app.ui.pen_doc = None; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { app.ui.direct_selection = Default::default(); } - if consume && app.ui.selected_path.as_deref() == Some("work") { - app.ui.selected_path = None; - } return Ok(r); } let name = - active_path_name(app).ok_or_else(|| tl!("No path to make a shape from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; + shape_source_path(app).ok_or_else(|| tl!("No path to make a shape from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; let r = app.run("path.toShape", json!({"name": name, "fill": super::shapes::fill_param(app), "stroke": super::shapes::stroke_param(app)}))?; if name == "work" && app.ui.selected_path.as_deref() == Some("work") { app.ui.selected_path = None; @@ -110,6 +103,12 @@ pub fn make_shape(app: &mut PhotocraftApp) -> Result { Ok(r) } +/// The path Make Shape makes a shape from without a draft: [`active_path_name`] when it is the +/// Work Path or a saved path, never a layer's own path (that would duplicate the shape or mask). +pub fn shape_source_path(app: &PhotocraftApp) -> Option { + active_path_name(app).filter(|n| n != "layer") +} + /// After the Pen or Path Selection tool made a selection from the path `name` (⌘↩, the canvas /// menu's Make Selection…), Photoshop deselects it in the Paths panel and stops drawing it; the /// path itself stays (#6079). The Work Path is hidden too when a saved path was loaded, so no diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index ce1e90cc12..b162619bba 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -471,9 +471,9 @@ fn draft_target(app: &PhotocraftApp, pen: &PenPath) -> Option { } /// What Make Shape turns a Path-mode draft into, without committing it: the path the commit would -/// leave (the draft joined to the path it goes into, with the Path Operation), and whether that is -/// the Work Path, which the shape then takes. With a vector mask targeted, the draft alone: the -/// mask stays as it was. `None` with no document. +/// leave (the draft joined to the path it goes into, with the Path Operation, or alone as a new +/// Work Path), and whether that is the Work Path, which the shape then takes. With a vector mask +/// targeted, the draft alone: the mask stays as it was. `None` with no document. pub(super) fn draft_shape_path(app: &PhotocraftApp, pen: &PenPath) -> Option<(Value, bool)> { let drawn = pen_to_json(pen, false); let doc = &app.session.active()?.doc; @@ -485,7 +485,8 @@ pub(super) fn draft_shape_path(app: &PhotocraftApp, pen: &PenPath) -> Option<(Va { return Some((p, r.path == "work")); } - let Some(name) = draft_target(app, pen) else { return Some((drawn, false)) }; + // With nothing to join, the commit's new Work Path would replace the old one. + let Some(name) = draft_target(app, pen) else { return Some((drawn, true)) }; let existing = doc_path(doc, &name)?; let mut p = photocraft_engine::vector_cmds::path_json(existing); let op = pen_path_op(&app.ui.tool_options).unwrap_or("combine"); @@ -814,7 +815,21 @@ fn fit_with_straight_segments(trace: &[[f64; 2]], straight: &[usize], fit: f64, use super::{PenOwner, cancel_stale_pen, capture_pen_owner}; fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(), String> { - let Some(owner) = pen.owner.as_ref().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) }; + let Some(owner) = pen.owner.clone().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) }; + let result = as_owner(app, &owner, |app| { + let result = commit_path_current(app, pen, closed); + if result && matches!(owner.target, super::PenTarget::Shape { .. } | super::PenTarget::VectorMask(_)) { + app.ui.selected_path = Some("layer".into()); + } + result + }); + if result { Ok(()) } else { Err(app.ui.status.clone()) } +} + +/// Run `f` with the options, colours, active layer, Paths panel selection and vector mask target +/// the draft's `owner` captured when drawing began, then put the current ones back (the Paths +/// panel selection stays as `f` left it). +fn as_owner(app: &mut PhotocraftApp, owner: &PenOwner, f: impl FnOnce(&mut PhotocraftApp) -> R) -> R { let options = std::mem::replace(&mut app.ui.tool_options, owner.options.clone()); let vector_mask = std::mem::replace(&mut app.ui.vector_mask_target, owner.vector_mask); app.ui.selected_path = match &owner.target { @@ -834,10 +849,7 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<( photocraft_engine::channel_cmds::ChannelTarget::Composite }; } - let result = commit_path_current(app, pen, closed); - if result && matches!(owner.target, super::PenTarget::Shape { .. } | super::PenTarget::VectorMask(_)) { - app.ui.selected_path = Some("layer".into()); - } + let result = f(app); if let (Some(st), Some(channel)) = (app.session.active_mut(), channel) { st.channel_view.target = channel; } @@ -845,5 +857,36 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<( app.session.tools.background = background; app.ui.tool_options = options; app.ui.vector_mask_target = vector_mask; - if result { Ok(()) } else { Err(app.ui.status.clone()) } + result +} + +/// Make Shape on a Pen draft (#10934), as one History step: a draft begun in Shape mode is +/// committed (it is a shape already, and a resumed subpath keeps its layer's other subpaths); +/// otherwise a new shape layer, with the shape tools' fill and stroke, of what the commit would +/// have made, worked out as the commit does, with the settings drawing began with. +pub(super) fn make_shape_from_draft(app: &mut PhotocraftApp, pen: &PenPath) -> Result { + let owner = pen.owner.clone(); + if owner.as_ref().is_some_and(|o| !o.valid(app)) { + return Err("Pen draft target is stale; no path was changed".into()); + } + if owner.as_ref().is_some_and(|o| matches!(o.target, super::PenTarget::Shape { .. })) { + return try_pen_commit(app, false).map(|()| Value::Null); + } + let selected = app.ui.selected_path.clone(); + let params = |app: &mut PhotocraftApp| { + let (path, consume) = draft_shape_path(app, pen).ok_or_else(|| tl!("No document").to_string())?; + let params = json!({"path": path, "consumeWorkPath": consume, "fill": fill_param(app), "stroke": stroke_param(app)}); + Ok::<_, String>((params, consume)) + }; + let made = match &owner { + Some(o) => as_owner(app, o, params), + None => params(app), + }; + app.ui.selected_path = selected; + let (params, consume) = made?; + let r = app.run("path.toShape", params); + if r.is_ok() && consume && app.ui.selected_path.as_deref() == Some("work") { + app.ui.selected_path = None; + } + r } diff --git a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs index e09cc9e82a..f20b688cfc 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs @@ -452,3 +452,93 @@ fn make_shape_keeps_a_saved_path() { assert_eq!(st.doc.paths.first().map(|p| p.name.as_str()), Some("Box")); assert_eq!(shape_path(&app).1, st.doc.paths[0].path); } + +/// Clicks an open path with the Pen through `points`, without finishing it. +fn draw(app: &mut PhotocraftApp, points: &[(f64, f64)]) { + for &(x, y) in points { + pen_down(app, x, y, false); + pen_up(app); + } +} + +/// Make Shape while drawing into a saved path uses the path drawing began with, as the commit +/// does, not one selected in the Paths panel since. +#[test] +fn make_shape_while_drawing_uses_the_path_drawing_began_with() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 50.0); + app.run("path.rename", json!({"name": "work", "to": "Box"})).unwrap(); + app.run("path.set", json!({"name": "Other", "path": {"subpaths": [{"closed": true, "knots": [[100, 100], [150, 100], [150, 150]]}]}})).unwrap(); + app.ui.selected_path = Some("Box".into()); + let doc_paths = app.session.active().unwrap().doc.paths.clone(); + draw(&mut app, &[(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)]); + app.ui.selected_path = Some("Other".into()); + super::make_shape(&mut app).unwrap(); + let box_path = &doc_paths.iter().find(|p| p.name == "Box").unwrap().path; + let shape = shape_path(&app).1; + assert_eq!(shape.subpaths.len(), box_path.subpaths.len() + 1, "Box and the drawing"); + assert_eq!(shape.subpaths[0], box_path.subpaths[0]); + assert_eq!(app.session.active().unwrap().doc.paths, doc_paths, "the saved paths stay as they were"); + assert_eq!(app.ui.selected_path.as_deref(), Some("Other"), "the Paths panel selection stays"); +} + +/// With no Work Path and no saved path selected, Make Shape has nothing to make a shape from: the +/// selected shape layer's own path isn't copied into a second shape layer. +#[test] +fn make_shape_doesnt_copy_a_shape_layers_own_path() { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + square(&mut app, 20.0, 20.0, 50.0); + app.ui.tool_options.vector_mode = "path".into(); + assert_eq!(super::active_path_name(&app).as_deref(), Some("layer")); + let count = layers(&app); + let steps = app.session.active().unwrap().history.past_len(); + assert!(super::make_shape(&mut app).is_err()); + assert_eq!(layers(&app), count); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +/// Make Shape while drawing a new path with an unselected Work Path: the commit would have +/// replaced the Work Path with the drawing, so the shape takes the drawing and no Work Path is +/// left behind. +#[test] +fn make_shape_while_drawing_a_new_work_path_replaces_the_old_one() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 50.0); + app.ui.selected_path = None; + draw(&mut app, &[(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)]); + super::make_shape(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.doc.work_path.is_none(), "the old Work Path is gone, as after a commit and Make Shape"); + let shape = shape_path(&app).1; + assert_eq!(shape.subpaths.len(), 1); + assert_eq!(shape.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(10.0, 150.0)); +} + +/// A draft begun in Shape mode that carries on an open subpath of a shape layer, with the mode +/// switched to Path before Make Shape: the layer keeps its other subpaths, extended in one step, +/// as the commit does, and no second layer copies part of it. +#[test] +fn make_shape_on_a_resumed_shape_layer_draft_keeps_its_other_subpaths() { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + draw(&mut app, &[(20.0, 20.0), (80.0, 20.0)]); + assert!(super::pen_commit(&mut app, false)); + app.ui.tool_options.pen_path_op = "combine".into(); + square(&mut app, 100.0, 100.0, 40.0); + let (id, before) = shape_path(&app); + assert_eq!(before.subpaths.len(), 2); + let count = layers(&app); + let steps = app.session.active().unwrap().history.past_len(); + draw(&mut app, &[(80.0, 20.0), (80.0, 80.0)]); + assert!(app.ui.pen.as_ref().is_some_and(|p| p.resumes.is_some()), "drawing carries on the open subpath"); + app.ui.tool_options.vector_mode = "path".into(); + super::make_shape(&mut app).unwrap(); + assert_eq!(layers(&app), count, "no second layer"); + let (now, after) = shape_path(&app); + assert_eq!(now, id); + assert_eq!(after.subpaths.len(), 2, "the square stays"); + assert_eq!(after.subpaths[0].knots.len(), 3, "the open subpath, extended"); + assert_eq!(after.subpaths[1], before.subpaths[1]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); +} From 7d446b507271d4c98e3440d667a3c15c1ce4bd55 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 5 Nov 2026 10:29:18 -0500 Subject: [PATCH 42/60] fix(transform): a click on another layer's pixels commits the box; the next click picks it (#8208, #11100) Measured in the reference application (#9862, #11100): with a box open, a click outside it on pixels of a layer the box doesn't move commits one Free Transform step and leaves the selection alone; a second click picks that layer. Drop the one-click Auto-Select replay, which went further than the reference, and add that commit path next to #11165's pasteboard rule. Where only the Background has pixels the box stays open. Unmeasured cases (Transform Selection, path or mask targets, hidden, locked or grouped layers) keep the box open as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/move_auto_select_tests.rs | 45 ++++++++++++++----- .../src/tools/transform_tool/pointer.rs | 28 ++++++++++-- .../src/transform_commit_click_tests.rs | 30 +++++++++++++ 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 6c8cddf48d..568a3e6aa5 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -258,22 +258,47 @@ fn shift_click_adds_shape_layers_one_after_another() { assert_eq!(selected, ids); } -/// #8208: with Show Transform Controls on, one click outside a resized box both commits the -/// resize and Auto-Selects the layer under the pointer; it no longer takes a second click. -#[test] -fn one_click_accepts_a_resized_box_and_picks_the_layer_under_it() { +/// Opens B's transform controls (Show Transform Controls on) and resizes the box to twice B's +/// size from its top-left corner, so it covers (40..72)². +fn resized_box() -> (PhotocraftApp, LayerId, LayerId) { let (mut app, a, b) = app(); app.ui.tool_options.move_show_transform = true; crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); - // Resize B's box to twice its size from its top-left corner. let t = app.ui.transform.as_mut().unwrap(); t.quad[2] = [t.quad[2][0] + 16.0, t.quad[2][1] + 16.0]; t.quad[1][0] += 16.0; t.quad[3][1] += 16.0; - let click = egui::Modifiers::NONE; - crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, click); - crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 16.0, y: 16.0 }, click); - assert!(app.ui.transform.is_none(), "the click accepted the box"); + (app, a, b) +} + +/// A plain click (press and release without moving) at document point `p`. +fn click(app: &mut PhotocraftApp, p: [f64; 2]) { + crate::canvas::tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::NONE); + crate::canvas::tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, egui::Modifiers::NONE); +} + +/// #8208, #11100, as measured in the reference application: a click outside a resized box on +/// another layer's pixels commits the resize as one Free Transform step and keeps the transformed +/// layer selected; the second click on that layer is the one that picks it. +#[test] +fn a_click_on_another_layer_accepts_a_resized_box_and_the_next_one_picks_it() { + let (mut app, a, b) = resized_box(); + click(&mut app, [16.0, 16.0]); + assert!(app.ui.transform.is_none(), "the click on A accepted the box"); assert_eq!(bounds(&app, b), Rect::new(40, 40, 72, 72), "B keeps its resize"); - assert_eq!(active(&app), Some(a), "and the same click selected A"); + let last = app.session.active().unwrap().history.entries().last().map(ToString::to_string); + assert_eq!(last.as_deref(), Some("Free Transform")); + assert_eq!(active(&app), Some(b), "the accepting click leaves B selected"); + click(&mut app, [16.0, 16.0]); + assert_eq!(active(&app), Some(a), "the second click picks A"); +} + +/// #11100: a click outside the box where only the Background has pixels keeps it open, B still +/// selected. +#[test] +fn a_click_on_the_background_alone_keeps_the_resized_box_open() { + let (mut app, _, b) = resized_box(); + click(&mut app, [30.0, 30.0]); + assert!(app.ui.transform.is_some(), "the box stays open"); + assert_eq!(active(&app), Some(b)); } diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 3aebfd81f5..db041c469f 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -66,6 +66,24 @@ fn on_pasteboard(app: &PhotocraftApp, p: [f64; 2]) -> bool { app.session.active().is_some_and(|st| p[0] < 0.0 || p[1] < 0.0 || p[0] >= f64::from(st.doc.size.width) || p[1] >= f64::from(st.doc.size.height)) } +/// Whether a document point lands on pixels of a layer the box doesn't move: a visible, +/// top-level layer that is not the Background and not fully locked. As measured in the reference +/// application (#11100, #8208), a click there commits a layer transform and leaves the selection +/// as it was; a second click picks that layer. Not measured, so the box stays open as before: +/// Transform Selection, a path or mask target, and hidden, locked or grouped layers. +fn on_other_layer(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> bool { + if t.selection || t.path.is_some() || t.target.is_some() || !p[0].is_finite() || !p[1].is_finite() { + return false; + } + let Some(st) = app.session.active() else { return false }; + let doc = &st.doc; + let moved = |id: photocraft_doc::LayerId| id.0 == t.layer || t.layers.contains(&id.0); + // `as` saturates out-of-range floats; `layers_at` returns nothing off the canvas. + photocraft_engine::pick_cmds::layers_at(doc, p[0].floor() as i32, p[1].floor() as i32) + .into_iter() + .any(|id| !moved(id) && !doc.is_background(id) && !doc.effective_locks(id).all && doc.layers.iter().any(|l| l.id == id && l.artboard().is_none())) +} + /// Pointer input while transforming. Returns false when no transform is active. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { let Some(t) = app.ui.transform.clone() else { return false }; @@ -100,11 +118,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> if matches!(ev, ToolEvent::Up { .. }) { pv.gesture = None; } - // Clicking the pasteboard (outside the document) commits; a click outside the box on the - // image does nothing and the box stays open (#11100). Dragging the rotation zone and - // Option-clicking to move the pivot keep their existing behavior. + // Clicking the pasteboard (outside the document) or another layer's pixels commits; a + // click outside the box where only the Background has pixels does nothing and the box + // stays open (#11100). The commit doesn't pick the layer under the click: as in the + // reference application, that takes a second click (#8208). Dragging the rotation + // zone and Option-clicking to move the pivot keep their existing behavior. if matches!(ev, ToolEvent::Up { .. }) && g.is_some_and(|g| accepts_outside_click(g, [x, y], tol)) { - if on_pasteboard(app, [x, y]) { + if on_pasteboard(app, [x, y]) || g.is_some_and(|g| on_other_layer(app, &t, g.start)) { commit(app); } else if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) { // Jitter before the release may have turned the box a little: put it back. diff --git a/crates/ui-egui/src/transform_commit_click_tests.rs b/crates/ui-egui/src/transform_commit_click_tests.rs index 1d43c501f7..169f1d5187 100644 --- a/crates/ui-egui/src/transform_commit_click_tests.rs +++ b/crates/ui-egui/src/transform_commit_click_tests.rs @@ -72,6 +72,36 @@ fn a_click_on_the_pasteboard_commits_one_free_transform_step() { } } +/// #11100, measured in the reference application: a click outside the box on another layer's +/// pixels commits one Free Transform step, and the transformed layer stays the only selected one +/// (the click doesn't pick the layer under it). +#[test] +fn a_click_on_another_layers_pixels_commits_and_keeps_the_selection() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.extras.snap = false; + // "Other" has a blue block at x 120..160, y 120..160; "Paint" a red one at x 20..60, y 20..60. + for (name, at, color) in [("Other", 120, "#0000ff"), ("Paint", 20, "#ff0000")] { + let id = app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": id})).unwrap(); + app.run("select.rect", json!({"x": at, "y": at, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + } + let paint = app.session.active().unwrap().active_layer.unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + let before = steps(&app); + click(&mut app, [140.0, 140.0]); + assert!(app.ui.transform.is_none(), "committed"); + assert_eq!(steps(&app), before + 1); + assert_eq!(app.session.active().unwrap().history.entries().last().map(ToString::to_string).as_deref(), Some("Free Transform")); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![paint], "the selection doesn't change"); +} + #[test] fn a_drag_outside_the_box_still_rotates() { let mut app = app(); From acd8c0e04a4b7534c7cedad956b625abeb4c5fcb Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Thu, 5 Nov 2026 17:39:56 +0100 Subject: [PATCH 43/60] Preserve exact guide positions across unit changes --- crates/ui-egui/src/dialogs/new_guide_ui.rs | 46 ++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index d6236809c7..f58c812a77 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -38,6 +38,8 @@ const CUSTOM: &str = "custom"; /// New Guide's colour when nothing was remembered, as in Photoshop. const DEFAULT_COLOR: &str = "cyan"; const UNIT: &str = "__unit"; +/// Exact pixels behind an unedited ruler readout, also retained across unit changes. +const EXACT_POSITION: &str = "__exactPosition"; const UNITS: [(Unit, &str); 7] = [ (Unit::Pixels, "Pixels"), @@ -92,6 +94,16 @@ fn selected_unit(app: &PhotocraftApp, f: &Map) -> Unit { f.get(UNIT).and_then(Value::as_str).and_then(Unit::from_suffix).unwrap_or_else(|| ruler_unit(app)) } +/// Only a text edit releases the exact position. Turning the guide still uses the usual +/// orientation-dependent interpretation of its displayed text (notably for percent). +fn exact_position(f: &Map, position: &str, orientation: &str) -> Option { + let saved = f.get(EXACT_POSITION)?; + if saved.get("text")?.as_str()? != position || f.get(EDIT)?.get("orientation")?.as_str()? != orientation { + return None; + } + saved.get("pixels")?.as_f64().filter(|px| px.is_finite()) +} + /// Keep subpixel positions when switching units: ruler readout rounding would move the guide. /// Invalid drafts stay untouched for the engine to reject on OK. fn convert_position(app: &PhotocraftApp, position: &str, from: Unit, to: Unit, vertical: bool) -> Option { @@ -178,7 +190,9 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result { f.insert("__label".into(), json!("Edit Selected Guides")); f.insert(EDIT.into(), json!({"orientation": orientation, "index": index})); f.insert("orientation".into(), json!(orientation)); - f.insert("position".into(), json!(in_ruler_unit(app, px, vertical))); + let position = in_ruler_unit(app, px, vertical); + f.insert("position".into(), json!(position)); + f.insert(EXACT_POSITION.into(), json!({"text": position, "pixels": px})); f.insert(UNIT.into(), json!(ruler_unit(app).suffix())); f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() })); f.insert("color".into(), json!(color)); @@ -217,14 +231,28 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) if response.gained_focus() { crate::field_tab::select_all(ui.ctx(), response.id, &position); } + if f.contains_key(EDIT) + && (response.changed() + || f.get(EXACT_POSITION).and_then(|saved| saved.get("text")).and_then(Value::as_str).is_some_and(|text| text != position)) + { + f.remove(EXACT_POSITION); + f.insert("__positionEdited".into(), json!(true)); + } if let Some((_, Some(typed_unit))) = Unit::parse_length(&position) { unit = typed_unit; } let previous = unit; - if crate::widgets::dropdown(ui, "new-guide-unit", tl!("Units"), &mut unit, &UNITS, 110.0) - && let Some(converted) = convert_position(app, &position, previous, unit, orientation == "vertical") - { - position = converted; + if crate::widgets::dropdown(ui, "new-guide-unit", tl!("Units"), &mut unit, &UNITS, 110.0) { + let exact = exact_position(f, &position, &orientation); + let source = exact.map(|px| with_unit(&px.to_string(), Unit::Pixels)); + if let Some(converted) = convert_position(app, source.as_deref().unwrap_or(&position), previous, unit, orientation == "vertical") { + if exact.is_some() + && let Some(saved) = f.get_mut(EXACT_POSITION).and_then(Value::as_object_mut) + { + saved.insert("text".into(), json!(converted)); + } + position = converted; + } } }); ui.end_row(); @@ -253,14 +281,18 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) } /// The command's parameters: a bare number gets the selected unit; anything else goes as typed, for -/// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". +/// the engine to read (or refuse, keeping the dialog open). An unedited guide keeps its exact +/// pixels behind the readout. Custom… sends its "#rrggbb". pub fn params(app: &PhotocraftApp, f: &Map) -> Value { let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); - let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); + let raw = f.get("position").and_then(Value::as_str).unwrap_or_default(); + let typed = raw.trim(); let position = match Unit::parse_length(typed) { Some((_, None)) => with_unit(typed, selected_unit(app, f)), _ => typed.to_string(), }; + // Sending the retained pixels also avoids a round-trip conversion consuming Redo on OK. + let position = exact_position(f, raw, orientation).map_or_else(|| json!(position), |px| json!(px)); let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; match f.get(EDIT) { From 426ac67593a3a418f47a65cefb135346fe53ce10 Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Thu, 5 Nov 2026 17:40:59 +0100 Subject: [PATCH 44/60] Honor edited guide positions matching the initial readout --- crates/ui-egui/src/dialogs/guide_edit.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/guide_edit.rs b/crates/ui-egui/src/dialogs/guide_edit.rs index 70a2606999..b058ed208e 100644 --- a/crates/ui-egui/src/dialogs/guide_edit.rs +++ b/crates/ui-egui/src/dialogs/guide_edit.rs @@ -104,7 +104,9 @@ fn prepare(app: &PhotocraftApp, draft: &mut Draft, fields: &Map) let mut params = crate::new_guide_ui::params(app, fields); // Opening a fractional position in cm/percent rounds its display. Leaving the text and // orientation alone must not move the guide or consume Redo through that rounding. - if fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position) + // Once the user edits Position, even a value equal to the original readout is intentional. + if !fields.get("__positionEdited").and_then(Value::as_bool).unwrap_or(false) + && fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position) && (params.get("newOrientation").and_then(Value::as_str) == Some("vertical")) == draft.edit.original().vertical { params["position"] = serde_json::json!(draft.edit.original().position); From 657182ac6f31332cd4215f6e429bbef202b9d2d7 Mon Sep 17 00:00:00 2001 From: SvenCarstensen Date: Thu, 5 Nov 2026 17:42:20 +0100 Subject: [PATCH 45/60] Test exact guide positions, unit changes and subsequent text edits --- .../ui-egui/src/dialogs/new_guide_ui/tests.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index c60c9a4607..1103bf099b 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -234,6 +234,75 @@ fn editing_an_existing_guide_accepts_the_selected_unit_and_undo_restores_it() { assert_eq!(guides(&app), (vec![10.0], vec![])); } +#[test] +fn edit_unit_changes_preserve_the_exact_position_preview_and_redo() { + use egui_kittest::{Harness, kittest::Queryable}; + + for (rulers, original) in [("pixels", 12.5), ("cm", 123.45678901234567), ("percent", -1e-100)] { + let (mut app, _) = app(rulers); + app.run(COMMAND, json!({"orientation": "vertical", "position": original})).unwrap(); + app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": original + 1.0})).unwrap(); + assert!(app.session.undo()); + crate::guide_select::select(&mut app, true, 0); + let history = app.session.active().unwrap().history.past_len(); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = open_edit(h.state_mut()).unwrap(); + if rulers == "pixels" { + assert_eq!(h.state_mut().ui.dialog_mut(d).unwrap().fields["position"], "13 px"); + } + h.run_steps(3); + for label in ["Millimeters", "Inches", "Percent", "Pixels", "Millimeters"] { + h.get_by_label("Units").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); + crate::guide_edit::sync(h.state_mut()); + let preview = edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap(); + assert_eq!(preview.vertical, vec![original], "{rulers} to {label}"); + assert_eq!(guides(h.state()).1, vec![original]); + } + crate::dialogs::confirm(h.state_mut(), d).unwrap(); + assert_eq!(guides(h.state()).1, vec![original]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert!(h.state().session.active().unwrap().history.can_redo(), "unit changes must remain a no-op"); + } +} + +#[test] +fn editing_the_position_after_a_unit_change_releases_the_exact_original() { + use egui_kittest::{Harness, kittest::Queryable}; + + let (mut app, _) = app("pixels"); + app.run(COMMAND, json!({"orientation": "vertical", "position": 12.5})).unwrap(); + crate::guide_select::select(&mut app, true, 0); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = open_edit(h.state_mut()).unwrap(); + h.run_steps(3); + h.get_by_label("Units").click(); + h.run_steps(3); + h.get_by_label("Millimeters").click(); + h.run_steps(3); + h.get_by_label("Position").click(); + h.run_steps(3); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("13 px".into())); + h.run_steps(3); + crate::guide_edit::sync(h.state_mut()); + assert_eq!(edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap().vertical, vec![13.0]); + for label in ["Inches", "Pixels"] { + h.get_by_label("Units").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); + } + crate::dialogs::confirm(h.state_mut(), d).unwrap(); + assert_eq!(guides(h.state()).1, vec![13.0]); + assert!(h.state_mut().session.undo()); + assert_eq!(guides(h.state()).1, vec![12.5]); +} + #[test] fn unit_dropdown_converts_the_draft_and_bare_typing_uses_its_selection() { use egui_kittest::{Harness, kittest::Queryable}; From b3f3cabf44b00d69529571260960d8325e59ad3d Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 14:54:56 -0500 Subject: [PATCH 46/60] =?UTF-8?q?Right-click=20on=20the=20toolbar's=20"?= =?UTF-8?q?=E2=80=A6"=20button=20opens=20a=20flyout=20with=20one=20row,=20?= =?UTF-8?q?Edit=20Toolbar=E2=80=A6=20(#11378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flyout is drawn like a tool flyout, beside the button. Its row runs edit.toolbar and closes it; Escape or a press outside closes it without running anything. A left click on the button still opens Edit Toolbar directly. Press-and-hold on the button is not measured and is not added. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/panels/toolbar.rs | 51 ++++++++- .../src/toolbar_more_slot_flyout_tests.rs | 107 ++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 54edda6264..4b86099145 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -792,6 +792,8 @@ mod pencil_tests; mod tool_options_theme_tests; #[cfg(test)] mod tool_shortcut_tests; +#[cfg(test)] +mod toolbar_more_slot_flyout_tests; #[cfg(test)] mod pixel_aspect_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ab5475ff9b..c872d7f69d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -60,7 +60,7 @@ pub mod swatches_ui; pub(super) mod timeline_ui; pub(crate) mod title_bar; pub(super) mod titlebar; -mod toolbar; +pub(crate) mod toolbar; pub mod type_panels_ui; pub(super) mod variables_ui; diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index c8f62a602e..40ce398041 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -6,7 +6,7 @@ use super::{Align2, Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, StrokeKin /// Tool groups separated by hairlines, Photoshop order. /// Photoshop toolbar: divider-separated sections of slots; each slot is a flyout group whose button /// shows the group's last-used tool. -pub(super) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ +pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Move, Tool::Artboard]], &[ &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee], @@ -87,6 +87,51 @@ pub(super) fn tool_flyout_release(row: Rect, pointer: Option, released: bo released && from_button && pointer.is_some_and(|p| row.contains(p)) } +/// Right-click on the "…" button opens a flyout with one row, "Edit Toolbar…", drawn like a tool +/// flyout. The row runs `edit.toolbar` and closes the flyout; Escape or a press outside closes it +/// without running anything. +fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::Response, t: &Tokens) { + let open_id = egui::Id::unique("toolbar-more-flyout-open"); + let open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); + if button.secondary_clicked() { + ui.data_mut(|d| d.insert_temp(open_id, !open)); + ui.ctx().request_repaint(); + return; + } + if !open { + return; + } + let (pivot, pos) = if crate::direction::is_rtl() { + (egui::Align2::RIGHT_BOTTOM, button.rect.left_bottom() - vec2(6.0, 0.0)) + } else { + (egui::Align2::LEFT_BOTTOM, button.rect.right_bottom() + vec2(6.0, 0.0)) + }; + let area = egui::Area::new(egui::Id::unique("toolbar-more-flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + let label = tl!("Edit Toolbar…"); + let label_w = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), t.text).size().x; + let (r, row) = ui.allocate_exact_size(vec2((label_w + 24.0).max(180.0), 26.0), Sense::click()); + row.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + if row.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text(pos2(r.left() + 12.0, r.center().y), Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); + row.clicked() + }) + }); + if area.inner.inner { + ui.data_mut(|d| d.remove::(open_id)); + let ctx = ui.ctx().clone(); + let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); + return; + } + let pressed_outside = + ui.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !button.rect.contains(p))); + if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::(open_id)); + } +} + pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; @@ -345,9 +390,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } } - if icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { + let more = icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")); + if more.clicked() { let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); } + more_slot_flyout(app, ui, &more, &t); reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); diff --git a/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs b/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs new file mode 100644 index 0000000000..99c4dbebb6 --- /dev/null +++ b/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs @@ -0,0 +1,107 @@ +//! Right-click on the toolbar's "…" button opens a one-row flyout, "Edit Toolbar…"; the row runs +//! `edit.toolbar`, Escape or a press outside closes it, and a left click still opens Edit Toolbar. + +use egui::{Event, Pos2, Rect, vec2}; + +use crate::PhotocraftApp; +use crate::panels::toolbar::{TOOL_SECTIONS, toolbar}; +use crate::theme::Tokens; + +fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) { + let mut out = ctx.run_ui( + egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); +} + +fn setup() -> (PhotocraftApp, egui::Context, Pos2) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(bx, bx) && w.sense.senses_click()).map(|w| w.rect).collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + // The "…" button sits right below the last tool slot. + let slots = TOOL_SECTIONS.iter().map(|s| s.len()).sum::(); + let at = buttons[slots].center(); + (app, ctx, at) +} + +fn button(pos: Pos2, which: egui::PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos, button: which, pressed, modifiers: Default::default() } +} + +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, at: Pos2, t: f64) { + frame(app, ctx, t, vec![Event::PointerMoved(at), button(at, egui::PointerButton::Secondary, true)]); + frame(app, ctx, t + 0.05, vec![button(at, egui::PointerButton::Secondary, false)]); + frame(app, ctx, t + 0.1, vec![]); +} + +fn open(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(egui::Id::unique("toolbar-more-flyout-open"))).unwrap_or(false) +} + +fn flyout(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-more-flyout"))) +} + +#[test] +fn right_click_on_more_opens_a_flyout_with_one_row() { + let (mut app, ctx, at) = setup(); + assert!(!open(&ctx)); + right_click(&mut app, &ctx, at, 1.0); + assert!(open(&ctx)); + let rect = flyout(&ctx).expect("the flyout is drawn"); + assert!(rect.height() < 50.0, "one 26 point row, not a list: {rect:?}"); + assert!(app.ui.dialogs.is_empty(), "opening the flyout runs nothing"); +} + +#[test] +fn choosing_edit_toolbar_runs_it_and_closes_the_flyout() { + let (mut app, ctx, at) = setup(); + right_click(&mut app, &ctx, at, 1.0); + let row = flyout(&ctx).unwrap().center(); + frame(&mut app, &ctx, 1.95, vec![Event::PointerMoved(row)]); + frame(&mut app, &ctx, 2.0, vec![button(row, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 2.05, vec![]); + frame(&mut app, &ctx, 2.1, vec![button(row, egui::PointerButton::Primary, false)]); + frame(&mut app, &ctx, 2.15, vec![]); + assert_eq!(app.ui.dialogs.len(), 1, "edit.toolbar opened its dialog"); + assert!(!open(&ctx)); +} + +#[test] +fn escape_closes_the_flyout_without_running_anything() { + let (mut app, ctx, at) = setup(); + right_click(&mut app, &ctx, at, 1.0); + let key = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, &ctx, 2.0, vec![key]); + assert!(!open(&ctx)); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn a_press_outside_closes_the_flyout_without_running_anything() { + let (mut app, ctx, at) = setup(); + right_click(&mut app, &ctx, at, 1.0); + let away = Pos2::new(900.0, 900.0); + frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(away), button(away, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 2.05, vec![button(away, egui::PointerButton::Primary, false)]); + assert!(!open(&ctx)); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn a_left_click_still_opens_edit_toolbar_directly() { + let (mut app, ctx, at) = setup(); + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), button(at, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 1.05, vec![button(at, egui::PointerButton::Primary, false)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert_eq!(app.ui.dialogs.len(), 1); + assert!(!open(&ctx), "no flyout for a left click"); +} From 178bf4506258d8ac1cb872e702d9aa613039f44d Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 5 Nov 2026 19:52:44 -0500 Subject: [PATCH 47/60] fix(transform): keep main's Distort comment; only the frontmost hit commits the box Restore main's comment that Distort moves corners and edges (#10085), which the merge with main had reverted. on_other_layer now checks the frontmost hit under the click (skipping the moved and fully locked layers, as Auto-Select does), so a grouped layer covering a top-level layer keeps the box open instead of committing through the covered layer. Co-Authored-By: Claude Opus 5.5 --- .../src/tools/transform_tool/pointer.rs | 17 +++++---- .../src/transform_commit_click_tests.rs | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 5656b7a90b..325d0b02ba 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -83,11 +83,13 @@ fn on_pasteboard(app: &PhotocraftApp, p: [f64; 2]) -> bool { app.session.active().is_some_and(|st| p[0] < 0.0 || p[1] < 0.0 || p[0] >= f64::from(st.doc.size.width) || p[1] >= f64::from(st.doc.size.height)) } -/// Whether a document point lands on pixels of a layer the box doesn't move: a visible, -/// top-level layer that is not the Background and not fully locked. As measured in the reference -/// application (#11100, #8208), a click there commits a layer transform and leaves the selection -/// as it was; a second click picks that layer. Not measured, so the box stays open as before: -/// Transform Selection, a path or mask target, and hidden, locked or grouped layers. +/// Whether a document point lands on pixels of a layer the box doesn't move: the frontmost hit +/// there, skipping the moved layers and fully locked ones as Auto-Select does, is a visible +/// top-level layer that is not the Background. As measured in the reference application +/// (#11100, #8208), a click there commits a layer transform and leaves the selection as it was; +/// a second click picks that layer. Not measured, so the box stays open as before: Transform +/// Selection, a path or mask target, and hidden, locked or grouped layers (also when a grouped +/// layer covers a top-level one at the click). fn on_other_layer(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> bool { if t.selection || t.path.is_some() || t.target.is_some() || !p[0].is_finite() || !p[1].is_finite() { return false; @@ -98,7 +100,8 @@ fn on_other_layer(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> boo // `as` saturates out-of-range floats; `layers_at` returns nothing off the canvas. photocraft_engine::pick_cmds::layers_at(doc, p[0].floor() as i32, p[1].floor() as i32) .into_iter() - .any(|id| !moved(id) && !doc.is_background(id) && !doc.effective_locks(id).all && doc.layers.iter().any(|l| l.id == id && l.artboard().is_none())) + .find(|id| !moved(*id) && !doc.effective_locks(*id).all) + .is_some_and(|id| !doc.is_background(id) && doc.layers.iter().any(|l| l.id == id && l.artboard().is_none())) } /// Pointer input while transforming. Returns false when no transform is active. @@ -125,7 +128,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> s.pivot = [x, y]; h = Hit::Pivot; } - // Distort only moves corners (and the whole box from inside): no rotating, no edges. + // Distort moves corners and edges (and the whole box from inside), but doesn't rotate. // Even in Distort mode, remember an outside press so a plain click can // accept the transform. A drag outside remains inert in that mode, in Skew, and in // Content-Aware Scale, which only scales and moves. diff --git a/crates/ui-egui/src/transform_commit_click_tests.rs b/crates/ui-egui/src/transform_commit_click_tests.rs index 0a16f1f7fe..0ddfeeb056 100644 --- a/crates/ui-egui/src/transform_commit_click_tests.rs +++ b/crates/ui-egui/src/transform_commit_click_tests.rs @@ -102,6 +102,41 @@ fn a_click_on_another_layers_pixels_commits_and_keeps_the_selection() { assert_eq!(app.session.active().unwrap().selected_layers(), vec![paint], "the selection doesn't change"); } +/// Only the frontmost layer under the click counts: where a grouped layer (not measured, so the +/// box stays open there) covers a top-level layer's pixels, the click doesn't commit through the +/// covered layer. +#[test] +fn a_click_on_a_grouped_layer_covering_another_layer_keeps_the_box_open() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.extras.snap = false; + // "Paint" has a red block at x 20..60, y 20..60; "Other" a blue one at x 120..160, y 120..160, + // covered by a green one on "Cover", which then goes into a group. + let mut ids = Vec::new(); + for (name, at, color) in [("Paint", 20, "#ff0000"), ("Other", 120, "#0000ff"), ("Cover", 120, "#00ff00")] { + let id = app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": id})).unwrap(); + app.run("select.rect", json!({"x": at, "y": at, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + ids.push(id); + } + app.run("layer.groupLayers", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert!(doc.layers.iter().all(|l| l.id.0 != ids[2]), "Cover is grouped"); + assert!(doc.layers.iter().any(|l| l.id.0 == ids[1]), "Other is top-level"); + app.run("layer.select", json!({"layer": ids[0]})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + let before = steps(&app); + click(&mut app, [140.0, 140.0]); + assert!(app.ui.transform.is_some(), "the box stays open"); + assert_eq!(steps(&app), before); +} + /// The press decides where a click lands: one on the image that jitters past the canvas edge /// before the release keeps the box open, one on the pasteboard that jitters onto the image commits. #[test] From 823848f8e6ac203046d226eed0e8a96a6bfb7476 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 07:59:02 -0500 Subject: [PATCH 48/60] i18n and Layers empty-area menu follow-ups to integration batch 18 (#11494, #11503, #11455, #11500) Add the missing Hindi rows for Camera Raw... and Reveal in Explorer/Finder. Make Working CMY Plates plural and drop the document wording from New Window in the catalogs that kept them. Skip re-persisting an unchanged thumbnail size in the Layers empty-area menu. Tighten two tests there and add a test for the Hindi rows. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/i18n/ar.tsv | 4 +-- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/da.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 4 +-- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hi.tsv | 3 +++ crates/ui-egui/src/i18n/id.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nb.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 4 +-- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tests.rs | 10 +++++++ crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 4 +-- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- .../src/layers_empty_area_menu_tests.rs | 26 ++++++++++++++++++- .../ui-egui/src/panels/layers_empty_menu.rs | 6 +++-- 28 files changed, 70 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 80c41fe82b..ae456edd71 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -680,7 +680,7 @@ Working Magenta Plate لوح الأرجواني العامل Working Yellow Plate لوح الأصفر العامل Working Black Plate لوح الأسود العامل - Working CMY Plates لوح CMY العامل + Working CMY Plates ألواح CMY العاملة Legacy Macintosh RGB (Gamma 1.8) RGB القديم لنظام Macintosh (Gamma 1.8) Internet Standard RGB (sRGB) RGB القياسي للإنترنت (sRGB) Monitor RGB RGB الشاشة @@ -764,7 +764,7 @@ Consolidate All to Tabs توحيد الكل في علامات تبويب Float in Window تعويم في نافذة Float All in Windows تعويم الكل في نوافذ - New Window نافذة جديدة للمستند + New Window نافذة جديدة Workspace مساحة العمل Essentials الأساسيات Graphic and Web الرسومات والويب diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6fc8c62e6a..00c584321d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -623,7 +623,7 @@ Working Magenta Plate Radna magenta ploča Working Yellow Plate Radna žuta ploča Working Black Plate Radna crna ploča - Working CMY Plates Radna CMY ploča + Working CMY Plates Radne CMY ploče Legacy Macintosh RGB (Gamma 1.8) Naslijeđeni Macintosh RGB (Gamma 1.8) Internet Standard RGB (sRGB) Internet standardni RGB (sRGB) Monitor RGB RGB monitora diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a23681f867..65fe0bd232 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -876,7 +876,7 @@ Consolidate All to Tabs Sloučit vše do záložek Float in Window Uvolnit do plovoucího okna Float All in Windows Uvolnit vše do plovoucích oken - New Window Nové okno dokumentu + New Window Nové okno Workspace Pracovní prostor Essentials Základy Graphic and Web Grafika a web diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 89deab4287..626110de8e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2061,7 +2061,7 @@ Vertical Centers Lodrette midtpunkter Video Frames to Layers… Videobilleder til lag… WIA Support… WIA-support… - Working CMY Plates Arbejds-CMY-plade + Working CMY Plates Arbejds-CMY-plader # tl! literals (third pass) Absolute Absolut diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 39147f17c9..36cb7a3f98 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -755,7 +755,7 @@ Working Magenta Plate Arbeits-Magenta-Druckplatte Working Yellow Plate Arbeits-Gelb-Druckplatte Working Black Plate Arbeits-Schwarz-Druckplatte - Working CMY Plates Arbeits-CMY-Druckplatte + Working CMY Plates Arbeits-CMY-Druckplatten Legacy Macintosh RGB (Gamma 1.8) Legacy-Macintosh-RGB (Gamma 1.8) Internet Standard RGB (sRGB) Internet-Standard-RGB (sRGB) Monitor RGB Monitor-RGB @@ -839,7 +839,7 @@ Consolidate All to Tabs Alle in Registerkarten zusammenfassen Float in Window In Fenster schweben lassen Float All in Windows Alle in Fenstern schweben lassen - New Window Neues Fenster für Dokument + New Window Neues Fenster Workspace Arbeitsbereich Essentials Essentials Graphic and Web Grafik und Web diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8f5c57e3bd..597d542660 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -720,7 +720,7 @@ Working Magenta Plate Placa magenta de trabajo Working Yellow Plate Placa amarilla de trabajo Working Black Plate Placa negra de trabajo - Working CMY Plates Placa CMY de trabajo + Working CMY Plates Placas CMY de trabajo Legacy Macintosh RGB (Gamma 1.8) RGB heredado de Macintosh (Gamma 1.8) Internet Standard RGB (sRGB) RGB estándar de Internet (sRGB) Monitor RGB RGB del monitor diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d89ea90673..84e974854f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2062,7 +2062,7 @@ Vertical Centers Pystykeskikohdat Video Frames to Layers… Videoruudut tasoiksi… WIA Support… WIA-tuki… - Working CMY Plates Työ-CMY-levy + Working CMY Plates Työ-CMY-levyt # tl! literals (third pass) Absolute Absoluuttinen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 050f58d308..dfdc7dbdce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -736,7 +736,7 @@ Working Magenta Plate Plaque magenta de travail Working Yellow Plate Plaque jaune de travail Working Black Plate Plaque noire de travail - Working CMY Plates Plaque CMJ de travail + Working CMY Plates Plaques CMJ de travail Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh (ancien) (Gamma 1.8) Internet Standard RGB (sRGB) RVB standard d'Internet (sRGB) Monitor RGB RVB du moniteur diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aa79f03063..76ac78893c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -160,6 +160,8 @@ Layer Mask लेयर मास्क Hide All सभी छिपाएँ Reveal Selection चयन दिखाएँ + Reveal in Finder Finder में दिखाएँ + Reveal in Explorer Explorer में दिखाएँ Hide Selection चयन छिपाएँ From Transparency पारदर्शिता से Apply लागू करें @@ -350,6 +352,7 @@ Aligned संरेखित Fit in View दृश्य में फ़िट करें Camera Raw Camera Raw + Camera Raw… Camera Raw… cameraRaw Amount मात्रा Amount मात्रा cameraRaw Aqua एक्वा diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b2f51040e8..d7ad8195ee 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -825,7 +825,7 @@ Consolidate All to Tabs Gabungkan Semua ke Tab Float in Window Mengambang di Jendela Float All in Windows Mengambangkan Semua di Jendela - New Window Jendela Baru untuk Dokumen + New Window Jendela Baru Workspace Ruang Kerja Essentials Esensial Graphic and Web Grafis dan Web diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a42efb170f..309652bb8b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -763,7 +763,7 @@ Working Magenta Plate Lastra magenta di lavoro Working Yellow Plate Lastra gialla di lavoro Working Black Plate Lastra nera di lavoro - Working CMY Plates Lastra CMY di lavoro + Working CMY Plates Lastre CMY di lavoro Legacy Macintosh RGB (Gamma 1.8) RGB legacy Macintosh (Gamma 1.8) Internet Standard RGB (sRGB) RGB standard Internet (sRGB) Monitor RGB RGB monitor diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 919d1f0977..4b74dbf411 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -870,7 +870,7 @@ Consolidate All to Tabs すべてをタブに統合 Float in Window ウィンドウ内に浮動 Float All in Windows すべてをウィンドウ内に浮動 - New Window ドキュメントの新規ウィンドウ + New Window 新規ウィンドウ Workspace ワークスペース Essentials 基本 Graphic and Web グラフィックと Web diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b7f16a941e..b239871b11 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -825,7 +825,7 @@ Consolidate All to Tabs 모두 탭으로 통합 Float in Window 별도 창으로 띄우기 Float All in Windows 모두 별도 창으로 띄우기 - New Window 문서의 새 창 + New Window 새 창 Workspace 작업 영역 Essentials 필수 Graphic and Web 그래픽 및 웹 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 73f521675a..f16521e45d 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2061,7 +2061,7 @@ Vertical Centers Loddrette sentre Video Frames to Layers… Videorammer til lag… WIA Support… WIA-støtte… - Working CMY Plates Arbeidsplate for CMY + Working CMY Plates Arbeidsplater for CMY # tl! literals (third pass) Absolute Absolutt diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 169db2173c..7458039ded 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -707,7 +707,7 @@ Working Magenta Plate Werkplaat magenta Working Yellow Plate Werkplaat geel Working Black Plate Werkplaat zwart - Working CMY Plates Werkplaat CMY + Working CMY Plates CMY-werkplaten Legacy Macintosh RGB (Gamma 1.8) Verouderde Macintosh RGB (Gamma 1.8) Internet Standard RGB (sRGB) Internetstandaard RGB (sRGB) Monitor RGB Monitor-RGB diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6aae9b3d84..ba6b406c28 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -752,7 +752,7 @@ Working Magenta Plate Robocza płyta magenty Working Yellow Plate Robocza płyta żółci Working Black Plate Robocza płyta czerni - Working CMY Plates Robocza płyta CMY + Working CMY Plates Robocze płyty CMY Legacy Macintosh RGB (Gamma 1.8) Starszy Macintosh RGB (Gamma 1.8) Internet Standard RGB (sRGB) Standardowy RGB dla Internetu (sRGB) Monitor RGB RGB monitora @@ -846,7 +846,7 @@ Consolidate All to Tabs Połącz wszystko w karty Float in Window Odłącz do okna Float All in Windows Odłącz wszystko do okien - New Window Nowe okno dla dokumentu + New Window Nowe okno Workspace Obszar roboczy Essentials Podstawowe Graphic and Web Grafika i Internet diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bee618bcbd..17272f3b2d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -730,7 +730,7 @@ Working Magenta Plate Chapa magenta de trabalho Working Yellow Plate Chapa amarela de trabalho Working Black Plate Chapa preta de trabalho - Working CMY Plates Chapa CMY de trabalho + Working CMY Plates Chapas CMY de trabalho Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh legado (Gamma 1.8) Internet Standard RGB (sRGB) RGB padrão da Internet (sRGB) Monitor RGB RGB do monitor diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a9daa9c8f4..89359cfe0e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2159,7 +2159,7 @@ Vertical Centers Центры по вертикали Video Frames to Layers… Видеокадры в слои… WIA Support… Поддержка WIA… - Working CMY Plates Рабочая голубая форма + Working CMY Plates Рабочие формы CMY Absolute Абсолютный Action Операция Adaptive Адаптивный diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4ff32db75a..bdb5087db2 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -756,7 +756,7 @@ Working Magenta Plate Radna magenta ploča Working Yellow Plate Radna žuta ploča Working Black Plate Radna crna ploča - Working CMY Plates Radna CMY ploča + Working CMY Plates Radne CMY ploče Legacy Macintosh RGB (Gamma 1.8) Stari Macintosh RGB (Gamma 1.8) Internet Standard RGB (sRGB) Internet standard RGB (sRGB) Monitor RGB RGB monitora diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4c9e6a12f6..81954c8edf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2059,7 +2059,7 @@ Vertical Centers Lodräta mittpunkter Video Frames to Layers… Videobildrutor till lager… WIA Support… WIA-stöd… - Working CMY Plates CMY-arbetsplåt + Working CMY Plates CMY-arbetsplåtar # tl! literals (third pass) Absolute Absolut diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e29a3f6899..e323150b87 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -8,6 +8,16 @@ const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered"); const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered"); const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered"); +#[test] +fn hindi_has_the_reveal_and_camera_raw_rows() { + assert_eq!(tr(HI(), "Reveal in Explorer"), "Explorer में दिखाएँ"); + assert_eq!(tr(HI(), "Reveal in Finder"), "Finder में दिखाएँ"); + assert_eq!(tr(HI(), "Camera Raw…"), "Camera Raw…"); + let (rows, errors) = parse_entries(include_str!("hi.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + assert!(rows.iter().any(|(ctx, src, _)| ctx.is_empty() && src == "Camera Raw…"), "Camera Raw… is a catalog row, not a fallback"); +} + #[test] fn hindi_tags_resolve() { for tag in ["hi", "hi-IN", "hi_IN", "hi_IN.UTF-8"] { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7b7108878d..3aaa236ab0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -672,7 +672,7 @@ Working Magenta Plate Çalışma Macenta Plakası Working Yellow Plate Çalışma Sarı Plakası Working Black Plate Çalışma Siyah Plakası - Working CMY Plates Çalışma CMY Plakası + Working CMY Plates Çalışma CMY Plakaları Legacy Macintosh RGB (Gamma 1.8) Eski Macintosh RGB (Gamma 1.8) Internet Standard RGB (sRGB) İnternet Standardı RGB (sRGB) Monitor RGB Monitör RGB diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 327d20fb7f..69818ae56e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -651,7 +651,7 @@ Match Location Узгодити розташування Match Rotation Узгодити поворот Match All Узгодити все - New Window Нове вікно для документа + New Window Нове вікно Workspace Робочий простір Extensions Розширення Panels Панелі @@ -2129,7 +2129,7 @@ Vertical Centers Вертикальні центри Video Frames to Layers… Відеокадри в шари… WIA Support… Підтримка WIA… - Working CMY Plates Робоча форма CMY + Working CMY Plates Робочі форми CMY Absolute Абсолютний Action Дія Adaptive Адаптивний diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9890267d85..549a7506bd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -823,7 +823,7 @@ Modifier Keys Phím bổ trợ Motion Chuyển động Navigator Điều hướng - New Window Cửa sổ mới cho tài liệu + New Window Cửa sổ mới New Workspace… Không gian làm việc mới… Next Theme Chủ đề tiếp theo Options Thanh tùy chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e1039a735d..a062e08e84 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -854,7 +854,7 @@ Consolidate All to Tabs 将所有窗口合并为选项卡 Float in Window 在窗口中浮动 Float All in Windows 使所有文档在窗口中浮动 - New Window 为文档新建窗口 + New Window 新建窗口 Workspace 工作区 Essentials 基本功能 Graphic and Web 图形与 Web diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0630f30f06..132712819e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -853,7 +853,7 @@ Consolidate All to Tabs 全部合併至標籤頁 Float in Window 在視窗中浮動 Float All in Windows 全部在視窗中浮動 - New Window 為文件新增視窗 + New Window 新增視窗 Workspace 工作區 Essentials 基本功能 Graphic and Web 繪圖和網頁 diff --git a/crates/ui-egui/src/layers_empty_area_menu_tests.rs b/crates/ui-egui/src/layers_empty_area_menu_tests.rs index 10b0c52212..b871b78f35 100644 --- a/crates/ui-egui/src/layers_empty_area_menu_tests.rs +++ b/crates/ui-egui/src/layers_empty_area_menu_tests.rs @@ -122,9 +122,29 @@ fn each_size_pick_sets_the_panel_option_and_saves_it() { } } +/// Picking the size that is already checked closes the menu and leaves the saved options as they +/// were. The harness cannot observe a skipped write directly, so this asserts the saved value is +/// unchanged. +#[test] +fn picking_the_checked_size_closes_the_menu_and_changes_nothing() { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + h.run_steps(2); + let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + let before = saved(&h); + open_menu(&mut h); + assert!(checked(&h, "Large Thumbnails")); + pick(&mut h, "Large Thumbnails"); + assert!(!menu_open(&h), "the menu closes"); + assert_eq!(size(&h), LayerThumbnailSize::Large); + assert_eq!(saved(&h), before, "the saved options are unchanged"); +} + #[test] fn each_clip_pick_sets_the_preference() { let mut h = panel(true); + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": false})).unwrap(); + h.run_steps(2); assert!(!clip(&h)); open_menu(&mut h); pick(&mut h, "Clip Thumbnails to Layer Bounds"); @@ -155,8 +175,12 @@ fn a_left_click_on_the_empty_area_still_deselects_and_a_right_click_does_not() { #[test] fn with_no_document_nothing_opens() { + // The Layers panel is not drawn without a document, so take its rect from the same layout + // with one open. + let rect = crate::panel_docking::probe::panel_rect(&panel(true).ctx, "layers").expect("the Layers panel is drawn"); let mut h = panel(false); - let p = pos2(1100.0, 400.0); + let p = rect.center(); + assert!(rect.contains(p)); click(&mut h, p, PointerButton::Secondary); assert!(!menu_open(&h)); } diff --git a/crates/ui-egui/src/panels/layers_empty_menu.rs b/crates/ui-egui/src/panels/layers_empty_menu.rs index 17c1c0d6e0..ffe6ba9e57 100644 --- a/crates/ui-egui/src/panels/layers_empty_menu.rs +++ b/crates/ui-egui/src/panels/layers_empty_menu.rs @@ -21,8 +21,10 @@ pub(super) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (size, label) in sizes() { let mut on = current == size; if crate::widgets::checkbox(ui, &mut on, label).clicked() { - app.ui.layers_panel_options.thumbnail_size = size; - super::layers_panel_ui::persist_options(app); + if current != size { + app.ui.layers_panel_options.thumbnail_size = size; + super::layers_panel_ui::persist_options(app); + } ui.close(); } } From e5e7848143e8de48bd9d7300c14ed2feeb0c1b54 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 08:00:12 -0500 Subject: [PATCH 49/60] Sort the toolbar test mods after the merge Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5ac59a4412..a952269f71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -811,9 +811,9 @@ mod tool_shift_cycle_order_tests; #[cfg(test)] mod tool_shortcut_tests; #[cfg(test)] -mod toolbar_more_slot_flyout_tests; -#[cfg(test)] mod toolbar_group_order_tests; +#[cfg(test)] +mod toolbar_more_slot_flyout_tests; #[cfg(test)] mod pixel_aspect_tests; From 13a1f852aa8fc2961cdb061831dde09b106b106d Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 08:44:57 -0500 Subject: [PATCH 50/60] Layers empty-area menu: a pick of the checked size restores a missing saved entry persist_options now skips the write when the saved entry already equals the options, so the menu can persist on every pick. A pick of the checked row writes nothing unless the saved entry is missing or stale. Adds a test for the missing-entry case and saves an entry first in the unchanged-value test. Co-Authored-By: Claude Fable 5.1 --- .../src/layers_empty_area_menu_tests.rs | 22 +++++++++++++++++++ .../ui-egui/src/panels/layers_empty_menu.rs | 6 ++--- crates/ui-egui/src/panels/layers_panel_ui.rs | 6 ++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/layers_empty_area_menu_tests.rs b/crates/ui-egui/src/layers_empty_area_menu_tests.rs index b871b78f35..c426a7e2c8 100644 --- a/crates/ui-egui/src/layers_empty_area_menu_tests.rs +++ b/crates/ui-egui/src/layers_empty_area_menu_tests.rs @@ -130,8 +130,10 @@ fn picking_the_checked_size_closes_the_menu_and_changes_nothing() { let mut h = panel(true); h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; h.run_steps(2); + crate::panels::layers_panel_ui::persist_options(h.state_mut()); let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); let before = saved(&h); + assert!(before.is_some(), "an entry is saved to begin with"); open_menu(&mut h); assert!(checked(&h, "Large Thumbnails")); pick(&mut h, "Large Thumbnails"); @@ -140,6 +142,26 @@ fn picking_the_checked_size_closes_the_menu_and_changes_nothing() { assert_eq!(saved(&h), before, "the saved options are unchanged"); } +/// The saved entry can be missing (a preferences reset, an older file) while the panel still +/// shows the size; picking the checked row writes it back. +#[test] +fn picking_the_checked_size_restores_a_missing_saved_entry() { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + h.run_steps(2); + h.state_mut().session.prefs.edit(|p| { + p.dialogs.remove("ui.layersPanelOptions"); + }); + let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + assert!(saved(&h).is_none(), "the saved entry is gone"); + open_menu(&mut h); + assert!(checked(&h, "Large Thumbnails")); + pick(&mut h, "Large Thumbnails"); + assert!(!menu_open(&h), "the menu closes"); + let restored = saved(&h).expect("the pick writes the entry back"); + assert_eq!(restored, serde_json::to_value(&h.state().ui.layers_panel_options).unwrap()); +} + #[test] fn each_clip_pick_sets_the_preference() { let mut h = panel(true); diff --git a/crates/ui-egui/src/panels/layers_empty_menu.rs b/crates/ui-egui/src/panels/layers_empty_menu.rs index ffe6ba9e57..17c1c0d6e0 100644 --- a/crates/ui-egui/src/panels/layers_empty_menu.rs +++ b/crates/ui-egui/src/panels/layers_empty_menu.rs @@ -21,10 +21,8 @@ pub(super) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (size, label) in sizes() { let mut on = current == size; if crate::widgets::checkbox(ui, &mut on, label).clicked() { - if current != size { - app.ui.layers_panel_options.thumbnail_size = size; - super::layers_panel_ui::persist_options(app); - } + app.ui.layers_panel_options.thumbnail_size = size; + super::layers_panel_ui::persist_options(app); ui.close(); } } diff --git a/crates/ui-egui/src/panels/layers_panel_ui.rs b/crates/ui-egui/src/panels/layers_panel_ui.rs index 5bf63ac814..a7dc5f9fb2 100644 --- a/crates/ui-egui/src/panels/layers_panel_ui.rs +++ b/crates/ui-egui/src/panels/layers_panel_ui.rs @@ -9,9 +9,13 @@ use crate::state::{LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog const OPTIONS_PREF: &str = "ui.layersPanelOptions"; -/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. +/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. A pick of the +/// current value writes nothing unless the saved entry is missing or stale. pub fn persist_options(app: &mut PhotocraftApp) { if let Ok(value) = serde_json::to_value(&app.ui.layers_panel_options) { + if app.session.prefs().dialogs.get(OPTIONS_PREF) == Some(&value) { + return; + } app.session.prefs.edit(|p| { p.dialogs.insert(OPTIONS_PREF.into(), value); }); From 92de311b2b96b49b4629b2e22a23a044fe483b65 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 15:07:27 +0100 Subject: [PATCH 51/60] Actions: keep more Blend If and Gradient Fill forms exact (#11249) Review fix-ups on #11249 after merging main: - A midpoint outside 5..95 %, transparency stops out of range or out of order, and a Gradient Fill Set without transparency stops stay reference-application steps instead of playing clamped or keeping the layer's own transparency. - An inverted Blend If range stays a reference-application step; the command rejects it at play. - The Layer Style dialog's OK records `Lefx` = {Scl} beside Blend If on a layer without effects (measured). That Set is still the dialog's one step and saves back with the `Lefx` it was recorded with. Co-Authored-By: NaufalDzz <90562913+NaufalDzz@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_make.rs | 61 ++++++++++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 40 ++++++++++++- docs/atn.md | 15 +++-- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index e824aec942..88b24c8a53 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -93,7 +93,7 @@ const GRADIENT_DESCRIPTOR: &str = "__atnDescriptor"; /// `gradient.fill.set`. Midpoint k belongs to the segment that ends at stop k + 1, as the PSD /// reader reads it; all-default midpoints give `[]`, which clears custom ones on a Set. None for /// what the fill does not play: a Noise gradient, a smoothness below 100 %, a non-classic -/// interpolation, a transparency midpoint or stops out of order. +/// interpolation, a transparency midpoint, stops out of order or values out of range. fn gradient_params(t: &Descriptor) -> Option> { if t.items.iter().any(|(k, _)| !GRADIENT_LAYER_KEYS.iter().any(|g| k.is(g))) { return None; @@ -140,18 +140,29 @@ fn gradient_params(t: &Descriptor) -> Option> { previous = at; stops.push(json!([at / 4096.0, color])); if i > 0 { - mids.push(get_num(s, "Mdpn").unwrap_or(50.0) / 100.0); + // The dialog's midpoints are 5..95 %; playback would clamp anything else. + let mid = get_num(s, "Mdpn").unwrap_or(50.0); + if !(5.0..=95.0).contains(&mid) { + return None; + } + mids.push(mid / 100.0); } } p.insert("stops".into(), Value::Array(stops)); if let Some(trns) = get_list(g, "Trns") { - let mut out = Vec::new(); + let (mut out, mut previous) = (Vec::new(), 0.0); for s in trns { let D::Descriptor(s) = s else { return None }; if get_num(s, "Mdpn").is_some_and(|m| m != 50.0) { return None; } - out.push(json!([get_num(s, "Lctn")? / 4096.0, get_num(s, "Opct")?])); + // Playback would clamp and sort what is out of range or out of order. + let (at, opacity) = (get_num(s, "Lctn")?, get_num(s, "Opct")?); + if !(previous..=4096.0).contains(&at) || !(0.0..=100.0).contains(&opacity) { + return None; + } + previous = at; + out.push(json!([at / 4096.0, opacity])); } p.insert("transparency".into(), Value::Array(out)); } @@ -300,6 +311,13 @@ fn blending_options_to(p: &Value) -> Option { } if let Some(b) = blend_if { t = t.with("Blnd", blend_if_to(b)?); + if let Some(fx) = jstr(p, EFFECTS_SCALE) { + let fx = photocraft_paint::tile::b64_decode(fx).and_then(|b| Descriptor::from_bytes(&b).ok())?; + if !scale_only(&fx) { + return None; + } + t = t.with("Lefx", D::Descriptor(fx)); + } } // Only these export: Photoshop records the dialog's other options differently. if t.items.is_empty() || t.items.len() != o.len() { @@ -342,7 +360,11 @@ fn blend_if_from(list: &[D]) -> Option { .collect(); match v.iter().flatten().count() { 0 if keys.iter().all(|k| e.get(k).is_none()) => Some(None), - 4 => Some(Some(json!(v.into_iter().flatten().collect::>()))), + // In order, as the command takes them; an inverted range stays a Photoshop step. + 4 => { + let q: Vec = v.into_iter().flatten().collect(); + q.windows(2).all(|w| w[0] <= w[1]).then(|| Some(json!(q))) + } _ => None, } }; @@ -991,6 +1013,21 @@ const LOCKS: &[(&str, &str)] = &[ /// takes from a `set` of the layer: opacity, fill opacity, mode and Blend If. const BLENDING_DIALOG: &[&str] = &["Opct", "fillOpacity", "Md ", "Blnd"]; +/// The hidden `layer.layerStyle.blendingOptions` param that keeps the `Lefx` = {Scl} the Layer +/// Style dialog's OK records beside Blend If, so Save Actions writes the Set back unchanged. +const EFFECTS_SCALE: &str = "__atnEffectsScale"; + +/// A `Lefx` holding only the effects' scale: what the dialog's OK records on a layer without +/// effects. It changes nothing. +fn scale_only(fx: &Descriptor) -> bool { + fx.items.iter().all(|(k, _)| k.is("Scl ")) +} + +/// A `T` key that is such a `Lefx`. +fn is_scale_only(k: &Id, v: &D) -> bool { + matches!(v, D::Descriptor(fx) if k.is("Lefx") && scale_only(fx)) +} + /// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params. const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")]; @@ -1077,7 +1114,10 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { advanced.insert("blendIf".into(), b); // The Blending Options dialog's OK: with nothing but its own options, the opacities and // the mode go with Blend If into one step, saved back as the one `set`. - if t.items.iter().all(|(k, _)| BLENDING_DIALOG.iter().any(|b| k.is(b)) || ADVANCED_BLENDING.iter().any(|(a, _)| k.is(a))) { + if t.items.iter().all(|(k, v)| BLENDING_DIALOG.iter().any(|b| k.is(b)) || ADVANCED_BLENDING.iter().any(|(a, _)| k.is(a)) || is_scale_only(k, v)) { + if let Some(D::Descriptor(fx)) = t.get("Lefx") { + advanced.insert(EFFECTS_SCALE.into(), photocraft_paint::tile::b64_encode(&fx.to_bytes()).into()); + } for (key, param) in [("Opct", "opacity"), ("fillOpacity", "fillOpacity")] { if let Some(v) = get_num(t, key) { advanced.insert(param.into(), v.into()); @@ -1102,10 +1142,7 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { // The Layer Style dialog's OK on a layer without effects records only the effects' // scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes // the document. - let no_effect = t.items.iter().all(|(k, val)| match val { - D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")), - _ => false, - }); + let no_effect = t.items.iter().all(|(k, val)| is_scale_only(k, val)); if !v.is_empty() { steps(v) } else if no_effect { @@ -1159,7 +1196,9 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)), _ => unsupported, }, - Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) { + // Photoshop's Set replaces the whole gradient; one without transparency stops would + // keep the layer's own on play, and could not be written back once edited. + Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t).filter(|p| p.get("transparency").is_some()) { Some(p) => step("gradient.fill.set", p), None => unsupported, }, diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 4e919bdcdd..10b158f64c 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -6476,7 +6476,20 @@ fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() { }; let trns_mid = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(0)).with("Mdpn", D::Integer(30)); let trns_end = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(4096)).with("Mdpn", D::Integer(50)); + let trns = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); for t in [ + // A midpoint outside the dialog's 5..95 % would play clamped. + recorded_gradient([50, 2, 50]), + recorded_gradient([50, 50, 97]), + // Transparency stops out of range or out of order would play clamped and sorted. + with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 100.0), trns(5000, 40.0)]))), + with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 150.0), trns(4096, 40.0)]))), + with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(4096, 100.0), trns(0, 40.0)]))), + // A Set without transparency stops would keep the layer's own. + with_grad(&|mut g| { + g.items.retain(|(k, _)| !k.is("Trns")); + g + }), with_grad(&|g| replaced(g, "GrdF", enm("GrdF", "ClNs"))), with_grad(&|g| replaced(g, "Intr", D::Double(2048.0))), with_grad(&|g| replaced(g, "Trns", D::List(vec![D::Descriptor(trns_mid.clone()), D::Descriptor(trns_end.clone())]))), @@ -6575,6 +6588,30 @@ fn blend_if_with_the_dialog_options_is_one_step_and_a_rename_splits() { assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}"); } +/// #10771: the Layer Style dialog's OK on a layer without effects also records the effects' +/// scale (`Lefx` = {Scl}). With Blend If it is still the dialog's one step and saves back with it. +#[test] +fn blend_if_with_the_recorded_effects_scale_saves_back_unchanged() { + let blnd = D::List(vec![blend_if_entry("gray", [10, 20, 200, 230], Some([0, 0, 255, 255]))]); + let dialog = desc("Lyr ").with("Opct", pct(80.0)).with("Blnd", blnd.clone()).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)))); + let mut s = blend_layers(8); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap(); + let steps = &s.actions.list[0].steps; + assert_eq!(steps.len(), 1, "{steps:?}"); + assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions"); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = active_info(&s); + assert!((info["opacity"].as_f64().unwrap() - 0.8).abs() < 1e-3, "{info}"); + assert_eq!(info["blendIf"][0]["thisLayer"], json!([10, 20, 200, 230]), "{info}"); + let (_, d) = saved_first(&mut s); + assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog)); + // A `Lefx` with an effect in it is not the scale alone: the step is not the dialog's. + let with_fx = desc("Lyr ").with("Blnd", blnd).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)).with("DrSh", D::Descriptor(desc("DrSh"))))); + let imp = import(&atn::write(&one_action(vec![layer_set(with_fx)])).unwrap()).unwrap(); + assert!(imp.actions[0].steps.iter().all(|(_, p)| p.get("__atnEffectsScale").is_none()), "{:?}", imp.actions[0].steps); +} + /// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying /// sliders or an unknown key stay Photoshop steps. #[test] @@ -6584,7 +6621,8 @@ fn blend_if_the_command_cannot_play_stays_a_photoshop_step() { e => e, }; let (half, unknown) = (with("DstB"), with("Zzzz")); - for e in [blend_if_entry("gray", [0, 0, 300, 255], None), half, unknown] { + let inverted = blend_if_entry("gray", [0, 200, 100, 255], None); + for e in [blend_if_entry("gray", [0, 0, 300, 255], None), inverted, half, unknown] { let imp = import(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", D::List(vec![e])))])).unwrap()).unwrap(); assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP); } diff --git a/docs/atn.md b/docs/atn.md index 33a2fc9312..19f2611293 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -756,12 +756,17 @@ differences that do not change what a step does. it is edited. Otherwise it is written from the params: `#rrggbb` stops on 1/4096 locations and whole-percent midpoints; a Set also needs its transparency stops, as the event replaces the whole gradient. Midpoint k is the `Mdpn` of stop k + 1 (the first stop's is 50). A Noise gradient, - smoothness below 100 %, a non-classic interpolation or a transparency midpoint stay Photoshop steps. + smoothness below 100 %, a non-classic interpolation, a transparency midpoint, a midpoint outside + 5..95 %, transparency stops out of range or out of order, and a Set without transparency stops stay + Photoshop steps. - Blend If (`Blnd` in a layer Set) loads as `layer.layerStyle.blendingOptions`. With only the - Blending Options dialog's keys (`Opct`, `fillOpacity`, `Md `, `Blnd`, the Advanced checkboxes) the - Set is one step and saves back as one Set; with any other key, those go through `layer.setProps`. - Slider values are whole levels 0..255, `SrcB`/`Srcl`/`SrcW`/`Srcm` for This Layer and - `DstB`/`Dstl`/`DstW`/`Dstt` for Underlying Layer (black low, black high, white low, white high). + Blending Options dialog's keys (`Opct`, `fillOpacity`, `Md `, `Blnd`, the Advanced checkboxes, and + the `Lefx` = {`Scl `} its OK records on a layer without effects) the Set is one step and saves back + as one Set; with any other key, those go through `layer.setProps`. Slider values are whole levels + 0..255 in order, `SrcB`/`Srcl`/`SrcW`/`Srcm` for This Layer and `DstB`/`Dstl`/`DstW`/`Dstt` for + Underlying Layer (black low, black high, white low, white high; measured in the dialog). The gray + channel saves as `Chnl.Gry`; a `Chnl.Cmps` entry loads as gray and saves as `Gry`, as the + application's own dialog writes it back. - Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543). ### Classic Gradient recordings From 0217be25b42406d26e36b92126a564fdbbbec44e Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 15:10:15 +0100 Subject: [PATCH 52/60] Batch fix-up: #10438's zoomed-out nudge test expects "Nudge" Main's #11383 renamed an arrow-key run's History step to "Nudge", as the reference application names it; #10438's new zoomed-out test still expected "Move". Co-Authored-By: hectorviov Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/move_mods/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index db541ff882..c7591abf9e 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -350,7 +350,7 @@ fn a_run_of_arrow_nudges_is_one_history_step_zoomed_out() { assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34)); assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); - assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move")); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge")); assert!(app.session.undo()); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); assert!(app.session.redo()); From 665170d1f8b33af114b2e71ebba0a2bfd164bfda Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 09:47:35 -0500 Subject: [PATCH 53/60] =?UTF-8?q?Toolbar=20flyout:=20a=20failed=20Edit=20T?= =?UTF-8?q?oolbar=E2=80=A6=20reports=20its=20error=20in=20the=20status=20b?= =?UTF-8?q?ar=20(#11378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flyout row discarded the result of edit.toolbar, so a rejected command (for example by an automation policy) just closed the flyout silently. It now sets the status bar error like other panels. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/panels/toolbar.rs | 5 ++++- .../src/toolbar_more_slot_flyout_tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index 87ef482461..dc05b75d74 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -128,7 +128,10 @@ fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::R if area.inner.inner { ui.data_mut(|d| d.remove::(open_id)); let ctx = ui.ctx().clone(); - let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); + if let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } return; } let pressed_outside = diff --git a/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs b/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs index 99c4dbebb6..f34623ea97 100644 --- a/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs +++ b/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs @@ -105,3 +105,21 @@ fn a_left_click_still_opens_edit_toolbar_directly() { assert_eq!(app.ui.dialogs.len(), 1); assert!(!open(&ctx), "no flyout for a left click"); } + +#[test] +fn a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar() { + let (mut app, ctx, at) = setup(); + app.automation_input = true; + app.services.automation_command = Some(Box::new(|id, _| if id == "edit.toolbar" { Err("toolbar edits denied".into()) } else { Ok(()) })); + right_click(&mut app, &ctx, at, 1.0); + let row = flyout(&ctx).unwrap().center(); + frame(&mut app, &ctx, 1.95, vec![Event::PointerMoved(row)]); + frame(&mut app, &ctx, 2.0, vec![button(row, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 2.05, vec![]); + frame(&mut app, &ctx, 2.1, vec![button(row, egui::PointerButton::Primary, false)]); + frame(&mut app, &ctx, 2.15, vec![]); + assert!(app.ui.dialogs.is_empty(), "the denied command opened nothing"); + assert!(!open(&ctx)); + assert_eq!(app.ui.status, "toolbar edits denied"); + assert!(app.ui.status_error); +} From 32ca273fe6d200e792ae13a0ceb4b664bc0dda6c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 15:49:53 +0100 Subject: [PATCH 54/60] PR #11092: the Artboard filter control test expects the error to list group Co-Authored-By: Nurana100 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d190d110b9..e2079c3005 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -890,7 +890,7 @@ fn ui_set_drives_the_layers_panel_artboard_filter() { assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard); let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}})); assert_eq!(bad["ok"], false, "{bad}"); - assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}"); + assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject, artboard or group"), "{bad}"); assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing"); } From 9d6cdca3fc9870f27834d5a372ed02faac5cf8b6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 15:58:57 +0100 Subject: [PATCH 55/60] Actions: a Gradient Fill Make exports with stops, keeps extra layer props (#11249) Two CodeRabbit findings on #11249: - A layer.newFillLayer.gradient step with from, to and stops plays the stops, but the ATN export refused it. from/to are now left out beside stops. - A recorded Make whose Usng also held a name, opacity, mode or clipping loaded as the plain command, which plays none of them and saved back without them. It now stays the recorded step. Each has a test that fails without the fix. Co-Authored-By: NaufalDzz <90562913+NaufalDzz@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_make.rs | 9 ++++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 88b24c8a53..bd21ddc1a5 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -276,9 +276,11 @@ fn gradient_make_to(p: &Value) -> Option { if o.remove("path").is_some_and(|v| !v.is_null()) { return None; } + // Stops replace `from` and `to` when the step plays, so those two are left out beside them. match (o.remove("from").filter(|v| !v.is_null()), o.remove("to").filter(|v| !v.is_null())) { + _ if o.contains_key("stops") => {} (None, None) => {} - (Some(a), Some(b)) if !o.contains_key("stops") => { + (Some(a), Some(b)) => { o.insert("stops".into(), json!([[0, a], [1, b]])); } _ => return None, @@ -649,9 +651,10 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + // A name, opacity, mode or clipping beside `Type` would neither play nor save back. Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) { - Some(p) => step("layer.newFillLayer.gradient", p), - None => Mapped::Unsupported { harmless: false }, + Some(p) if using.is_some_and(|u| u.items.iter().all(|(k, _)| k.is("Type"))) => step("layer.newFillLayer.gradient", p), + _ => Mapped::Unsupported { harmless: false }, }, _ => Mapped::Unsupported { harmless: false }, }, diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 10b158f64c..568f421be1 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -6446,6 +6446,11 @@ fn gradient_fill_steps_export_and_import_back() { let mut expected = json!({"angle": 45, "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": []}); normalise(&mut expected); assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("layer.newFillLayer.gradient", expected)); + // Stops replace `from` and `to` when it plays, so a Make with all three exports its stops. + let p = json!({"from": "#ff0000", "to": "#0000ff", "stops": [[0, "#00ff00"], [1, "#ffffff"]], "transparency": [[0, 100], [1, 100]]}); + let item = export_item("layer.newFillLayer.gradient", &p, "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") }; + assert_eq!(steps[0].1["stops"], json!([[0.0, "#00ff00"], [1.0, "#ffffff"]])); // What the event cannot hold is refused: a Set without its transparency, toolbox colours, // dragged handles, a path, a midpoint off a whole percent. for (id, p) in [ @@ -6498,6 +6503,14 @@ fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() { let imp = import(&atn::write(&one_action(vec![gradient_set(t)])).unwrap()).unwrap(); assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP); } + // A Make whose new layer also gets a name, opacity, mode or clipping: the command plays none + // of them, so the step stays as recorded. + for (key, v) in [("Nm ", text("Sky")), ("Opct", pct(50.0)), ("Md ", enm("BlnM", "Mltp")), ("Grup", D::Boolean(true))] { + let using = desc("contentLayer").with("Type", D::Descriptor(base.clone())).with(key, v); + let make = item("Mk ", "Make", Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(using)))); + let imp = import(&atn::write(&one_action(vec![make])).unwrap()).unwrap(); + assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP, "{key}"); + } } fn blend_if_entry(channel: &str, this: [i32; 4], under: Option<[i32; 4]>) -> D { From 812b25fa100933cd8d0c8245e058018dbbf29b71 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 18:35:38 +0100 Subject: [PATCH 56/60] Batch fix-up: CodeRabbit findings on #11249, #11541 and #9862 - #11249: a recorded Blend If Set that also carries real layer effects (a Lefx with more than the scale) stays the recorded step instead of playing and saving back without the effects. - #11541: the toolbar "..." flyout owns Escape, so Escape with it open no longer deselects or leaves full screen; a failed Edit Toolbar from a left click on "..." reports its error in the status bar like the flyout row. - #9862: the click-on-another-layer check uses the commit's targets (the link group as it is now), so a layer linked after the box opened is not "another layer". Each has a test that fails without the fix. Co-Authored-By: NaufalDzz Co-Authored-By: TrevorBrown-dev Co-Authored-By: Higgs32584 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_make.rs | 5 +++ crates/engine/src/cmd/atn_cmds/tests.rs | 2 ++ crates/ui-egui/src/input/shortcuts.rs | 5 +-- crates/ui-egui/src/input_tests.rs | 14 ++++++++ crates/ui-egui/src/panels/toolbar.rs | 18 ++++++++-- .../src/tools/transform_tool/pointer.rs | 5 ++- .../src/transform_commit_click_tests.rs | 34 +++++++++++++++++++ 7 files changed, 77 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index bd21ddc1a5..7e26df4c16 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1113,6 +1113,11 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { } } if let Some(list) = get_list(t, "Blnd") { + // Effects beside Blend If have no step here: keep the recorded step rather than play + // and save it back without them. + if t.items.iter().any(|(k, v)| k.is("Lefx") && !is_scale_only(k, v)) { + return unsupported; + } let Some(b) = blend_if_from(list) else { return unsupported }; advanced.insert("blendIf".into(), b); // The Blending Options dialog's OK: with nothing but its own options, the opacities and diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 568f421be1..90b965d939 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -6623,6 +6623,8 @@ fn blend_if_with_the_recorded_effects_scale_saves_back_unchanged() { let with_fx = desc("Lyr ").with("Blnd", blnd).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)).with("DrSh", D::Descriptor(desc("DrSh"))))); let imp = import(&atn::write(&one_action(vec![layer_set(with_fx)])).unwrap()).unwrap(); assert!(imp.actions[0].steps.iter().all(|(_, p)| p.get("__atnEffectsScale").is_none()), "{:?}", imp.actions[0].steps); + // Nor can it play without the shadow: it stays the recorded step. + assert_eq!(imp.actions[0].steps.iter().map(|(c, _)| c.as_str()).collect::>(), [PHOTOSHOP_STEP], "{:?}", imp.actions[0].steps); } /// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index b0304b8237..0abd096530 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -851,7 +851,7 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { // and inline type returned above, transform, pen, lasso and crop consumed it, and these own // theirs later in the frame: the palette, the canvas and layer menus, the brush picker, // Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any open egui popup - // and the toolbar tool flyout. A canvas drag in progress (a marquee being drawn) keeps it too. + // and the toolbar tool and "…" flyouts. A canvas drag in progress (a marquee being drawn) keeps it too. let esc_taken = app.drag.is_some() || app.ui.palette_open || app.ui.canvas_tool_menu.is_some() @@ -863,7 +863,8 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { || app.smart_svg.modal_open() || app.distort.gallery.is_some() || ctx.any_popup_open() - || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some()); + || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some()) + || crate::panels::toolbar::more_flyout_open(ctx); // Both full screen modes (with and without the menu bar) return to Standard, as in Photoshop // (#4116), through the menu command so the window and the Screen Mode check follow. if !esc_taken && app.ui.view.screen_mode != "standard" && pressed(Key::Escape) { diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 139804eb86..e70f05d257 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1275,6 +1275,20 @@ fn escape_with_the_palette_open_does_not_deselect() { assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the palette"); } +/// The toolbar's "…" flyout owns Esc (it closes there, `toolbar_more_slot_flyout_tests`), so the +/// selection stays. +#[test] +fn escape_with_the_toolbar_more_flyout_open_does_not_deselect() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("toolbar-more-flyout-open"), true)); + h.run_steps(1); + assert!(crate::panels::toolbar::more_flyout_open(&h.ctx)); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the flyout"); +} + /// Targa Options owns Esc (it cancels the save), so the selection stays. #[test] fn escape_with_targa_options_open_does_not_deselect() { diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index dc05b75d74..a81ca7653a 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -93,11 +93,20 @@ pub(super) fn tool_flyout_release(row: Rect, pointer: Option, released: bo released && from_button && pointer.is_some_and(|p| row.contains(p)) } +fn more_flyout_id() -> egui::Id { + egui::Id::unique("toolbar-more-flyout-open") +} + +/// Whether the "…" button's flyout is open: it owns Escape then (`shortcuts::handle`). +pub(crate) fn more_flyout_open(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(more_flyout_id())).unwrap_or(false) +} + /// Right-click on the "…" button opens a flyout with one row, "Edit Toolbar…", drawn like a tool /// flyout. The row runs `edit.toolbar` and closes the flyout; Escape or a press outside closes it /// without running anything. fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::Response, t: &Tokens) { - let open_id = egui::Id::unique("toolbar-more-flyout-open"); + let open_id = more_flyout_id(); let open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); if button.secondary_clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); @@ -400,8 +409,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } let more = icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")); - if more.clicked() { - let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); + if more.clicked() + && let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) + { + app.ui.status = e; + app.ui.status_error = true; } more_slot_flyout(app, ui, &more, &t); reveal_focused_control(ui); diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 325d0b02ba..2cd5ba41a8 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -96,7 +96,10 @@ fn on_other_layer(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> boo } let Some(st) = app.session.active() else { return false }; let doc = &st.doc; - let moved = |id: photocraft_doc::LayerId| id.0 == t.layer || t.layers.contains(&id.0); + // The layers the commit moves: `edit.transform` takes the link group along as it is then, so a + // layer linked after the box opened counts as moved too. + let moved = photocraft_engine::transform_cmds::box_targets(doc, &super::begin::moving_layers(t)); + let moved = |id: photocraft_doc::LayerId| id.0 == t.layer || moved.contains(&id); // `as` saturates out-of-range floats; `layers_at` returns nothing off the canvas. photocraft_engine::pick_cmds::layers_at(doc, p[0].floor() as i32, p[1].floor() as i32) .into_iter() diff --git a/crates/ui-egui/src/transform_commit_click_tests.rs b/crates/ui-egui/src/transform_commit_click_tests.rs index 0ddfeeb056..bd5a36f498 100644 --- a/crates/ui-egui/src/transform_commit_click_tests.rs +++ b/crates/ui-egui/src/transform_commit_click_tests.rs @@ -102,6 +102,40 @@ fn a_click_on_another_layers_pixels_commits_and_keeps_the_selection() { assert_eq!(app.session.active().unwrap().selected_layers(), vec![paint], "the selection doesn't change"); } +/// A layer linked to the transformed one after the box opened moves with the commit +/// (`edit.transform` takes the link group along), so a click on its pixels is not a click on +/// another layer: the box stays open. +#[test] +fn a_click_on_a_layer_linked_after_the_box_opened_keeps_it_open() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.extras.snap = false; + let mut ids = Vec::new(); + for (name, at, color) in [("Other", 120, "#0000ff"), ("Paint", 20, "#ff0000")] { + let id = app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": id})).unwrap(); + app.run("select.rect", json!({"x": at, "y": at, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + ids.push(LayerId(id)); + } + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + app.session + .edit("Link Layers", |doc, _| { + for id in &ids { + doc.layer_mut(*id).unwrap().link_group = Some(7); + } + Ok(()) + }) + .unwrap(); + click(&mut app, [140.0, 140.0]); + assert!(app.ui.transform.is_some(), "the linked layer moves with the box: the click doesn't commit"); +} + /// Only the frontmost layer under the click counts: where a grouped layer (not measured, so the /// box stays open there) covers a top-level layer's pixels, the click doesn't commit through the /// covered layer. From af0ae88bad247d71fd36069ef1ba83f26d599858 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 18:54:34 +0100 Subject: [PATCH 57/60] Batch: drop #9876 (New Guide unit dropdown) from the batch The reference application's New Guide dialog has Orientation, Position (a text field that takes typed units such as "2 cm") and Color, but no unit dropdown; measured on 2026-11-06. Typed units already work on main, so the PR only adds something the reference lacks. This reverts merge 22e03d0bcb. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 - crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/guide_edit.rs | 4 +- crates/ui-egui/src/dialogs/new_guide_ui.rs | 106 +--------- .../ui-egui/src/dialogs/new_guide_ui/tests.rs | 186 +----------------- docs/images/new-guide-units-after.png | Bin 49831 -> 0 bytes docs/images/new-guide-units-before.png | Bin 56638 -> 0 bytes docs/ui-dialogs.md | 10 - 8 files changed, 13 insertions(+), 296 deletions(-) delete mode 100644 docs/images/new-guide-units-after.png delete mode 100644 docs/images/new-guide-units-before.png diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 56458d2f30..4ff9772412 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -99,7 +99,6 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | -| `docs/images/new-guide-units-*.png` | New Guide position unit selector, before and after (no artwork) | PhotoCraft contributors | Original PhotoCraft screenshot and offscreen `snapshot` example | MIT OR Apache-2.0 | | `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 | | `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 | | `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1b5492c24d..0cfeb7fdc1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -335,7 +335,7 @@ fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fie _ if crate::adjust_dialog::owns(fields) => crate::adjust_dialog::body(app, ui, fields), _ if crate::blur_pins::owns(fields) => crate::blur_pins::body(ui, fields), _ if fields.contains_key("__filter") => filter_body(app, ui, d, fields, id), - _ if crate::new_guide_ui::owns(fields) => crate::new_guide_ui::body(app, ui, fields), + _ if crate::new_guide_ui::owns(fields) => crate::new_guide_ui::body(ui, fields), _ if fields.contains_key("__form") => { if fields.get("__command").and_then(Value::as_str) == Some("file.fileInfo") { let room = (ui.ctx().content_rect().height() - DIALOG_CHROME).max(120.0); diff --git a/crates/ui-egui/src/dialogs/guide_edit.rs b/crates/ui-egui/src/dialogs/guide_edit.rs index b058ed208e..70a2606999 100644 --- a/crates/ui-egui/src/dialogs/guide_edit.rs +++ b/crates/ui-egui/src/dialogs/guide_edit.rs @@ -104,9 +104,7 @@ fn prepare(app: &PhotocraftApp, draft: &mut Draft, fields: &Map) let mut params = crate::new_guide_ui::params(app, fields); // Opening a fractional position in cm/percent rounds its display. Leaving the text and // orientation alone must not move the guide or consume Redo through that rounding. - // Once the user edits Position, even a value equal to the original readout is intentional. - if !fields.get("__positionEdited").and_then(Value::as_bool).unwrap_or(false) - && fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position) + if fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position) && (params.get("newOrientation").and_then(Value::as_str) == Some("vertical")) == draft.edit.original().vertical { params["position"] = serde_json::json!(draft.edit.original().position); diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index f58c812a77..25b058ea87 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -2,10 +2,9 @@ //! Photoshop. //! //! The position opens in the ruler unit ("0 cm") and takes any unit Photoshop's field takes: -//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". The unit dropdown converts the current -//! position without moving the guide; a bare number uses the selected unit. An explicit suffix -//! overrides that selection. OK runs `view.newGuide` with the text, so the engine converts it with -//! the document's resolution. A position the engine refuses reopens the dialog with what was typed and the +//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". A bare number is in the ruler unit, as in +//! Photoshop. OK runs `view.newGuide` with the text, so the engine converts it with the document's +//! resolution. A position the engine refuses reopens the dialog with what was typed and the //! engine's message, as Photoshop keeps New Guide open on a bad position. The //! dialog is an ordinary one in the UI state, so `ui.inspect`, `ui.dialog.confirm` and //! `ui.dialog.cancel` drive it. The last successful guide's orientation and colour open next @@ -37,19 +36,6 @@ const INITIAL_COLOR: &str = "__initialColor"; const CUSTOM: &str = "custom"; /// New Guide's colour when nothing was remembered, as in Photoshop. const DEFAULT_COLOR: &str = "cyan"; -const UNIT: &str = "__unit"; -/// Exact pixels behind an unedited ruler readout, also retained across unit changes. -const EXACT_POSITION: &str = "__exactPosition"; - -const UNITS: [(Unit, &str); 7] = [ - (Unit::Pixels, "Pixels"), - (Unit::Millimeters, "Millimeters"), - (Unit::Inches, "Inches"), - (Unit::Centimeters, "Centimeters"), - (Unit::Points, "Points"), - (Unit::Picas, "Picas"), - (Unit::Percent, "Percent"), -]; /// A colour's label in the list (the catalogue holds each one). pub(crate) fn color_label(name: &str) -> &'static str { @@ -90,37 +76,6 @@ fn ruler_unit(app: &PhotocraftApp) -> Unit { app.session.prefs().units_and_rulers.rulers } -fn selected_unit(app: &PhotocraftApp, f: &Map) -> Unit { - f.get(UNIT).and_then(Value::as_str).and_then(Unit::from_suffix).unwrap_or_else(|| ruler_unit(app)) -} - -/// Only a text edit releases the exact position. Turning the guide still uses the usual -/// orientation-dependent interpretation of its displayed text (notably for percent). -fn exact_position(f: &Map, position: &str, orientation: &str) -> Option { - let saved = f.get(EXACT_POSITION)?; - if saved.get("text")?.as_str()? != position || f.get(EDIT)?.get("orientation")?.as_str()? != orientation { - return None; - } - saved.get("pixels")?.as_f64().filter(|px| px.is_finite()) -} - -/// Keep subpixel positions when switching units: ruler readout rounding would move the guide. -/// Invalid drafts stay untouched for the engine to reject on OK. -fn convert_position(app: &PhotocraftApp, position: &str, from: Unit, to: Unit, vertical: bool) -> Option { - let (value, suffix) = Unit::parse_length(position)?; - let doc = &app.session.active()?.doc; - let dpi = f64::from(doc.resolution_dpi); - let extent = f64::from(if vertical { doc.size.width } else { doc.size.height }); - let ppi = app.session.prefs().units_and_rulers.point_size.per_inch(); - let px = suffix.unwrap_or(from).to_px(value, dpi, extent, ppi); - let converted = to.from_px(px, dpi, extent, ppi); - if !px.is_finite() || !converted.is_finite() { - return None; - } - // Display preserves the f64 value without exponent notation, which parse_length rejects. - Some(with_unit(&converted.to_string(), to)) -} - /// "0 cm", "0 px", "0 %": a value in `unit` as the field shows it, with a space before the unit /// as in Photoshop. fn with_unit(value: &str, unit: Unit) -> String { @@ -153,7 +108,6 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { f.insert("__label".into(), json!("New Guide…")); f.insert("orientation".into(), json!(orientation)); f.insert("position".into(), json!(position)); - f.insert(UNIT.into(), json!(ruler_unit(app).suffix())); f.insert("color".into(), json!(color)); f.insert("customColor".into(), json!(custom)); app.ui.open_dialog(DialogKind::Command, f) @@ -190,10 +144,7 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result { f.insert("__label".into(), json!("Edit Selected Guides")); f.insert(EDIT.into(), json!({"orientation": orientation, "index": index})); f.insert("orientation".into(), json!(orientation)); - let position = in_ruler_unit(app, px, vertical); - f.insert("position".into(), json!(position)); - f.insert(EXACT_POSITION.into(), json!({"text": position, "pixels": px})); - f.insert(UNIT.into(), json!(ruler_unit(app).suffix())); + f.insert("position".into(), json!(in_ruler_unit(app, px, vertical))); f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() })); f.insert("color".into(), json!(color)); f.insert("customColor".into(), json!(custom)); @@ -206,10 +157,9 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result { } /// Edit the dialog fields and show validation errors without changing the live guide. -pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string(); let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string(); - let mut unit = selected_unit(app, f); let mut color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR).to_string(); if let Some(error) = f.get("__error").and_then(Value::as_str) { ui.colored_label(ui.visuals().error_fg_color, error); @@ -223,38 +173,7 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) }); ui.end_row(); let position_label = ui.label(tl!("Position")); - ui.horizontal(|ui| { - let response = ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); - if ui.is_enabled() { - crate::field_tab::register(ui.ctx(), response.id); - } - if response.gained_focus() { - crate::field_tab::select_all(ui.ctx(), response.id, &position); - } - if f.contains_key(EDIT) - && (response.changed() - || f.get(EXACT_POSITION).and_then(|saved| saved.get("text")).and_then(Value::as_str).is_some_and(|text| text != position)) - { - f.remove(EXACT_POSITION); - f.insert("__positionEdited".into(), json!(true)); - } - if let Some((_, Some(typed_unit))) = Unit::parse_length(&position) { - unit = typed_unit; - } - let previous = unit; - if crate::widgets::dropdown(ui, "new-guide-unit", tl!("Units"), &mut unit, &UNITS, 110.0) { - let exact = exact_position(f, &position, &orientation); - let source = exact.map(|px| with_unit(&px.to_string(), Unit::Pixels)); - if let Some(converted) = convert_position(app, source.as_deref().unwrap_or(&position), previous, unit, orientation == "vertical") { - if exact.is_some() - && let Some(saved) = f.get_mut(EXACT_POSITION).and_then(Value::as_object_mut) - { - saved.insert("text".into(), json!(converted)); - } - position = converted; - } - } - }); + ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); ui.end_row(); ui.label(tl!("Color")); ui.horizontal(|ui| { @@ -276,23 +195,18 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) }); f.insert("orientation".into(), json!(orientation)); f.insert("position".into(), json!(position)); - f.insert(UNIT.into(), json!(unit.suffix())); f.insert("color".into(), json!(color)); } -/// The command's parameters: a bare number gets the selected unit; anything else goes as typed, for -/// the engine to read (or refuse, keeping the dialog open). An unedited guide keeps its exact -/// pixels behind the readout. Custom… sends its "#rrggbb". +/// The command's parameters: a bare number gets the ruler unit; anything else goes as typed, for +/// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". pub fn params(app: &PhotocraftApp, f: &Map) -> Value { let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); - let raw = f.get("position").and_then(Value::as_str).unwrap_or_default(); - let typed = raw.trim(); + let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); let position = match Unit::parse_length(typed) { - Some((_, None)) => with_unit(typed, selected_unit(app, f)), + Some((_, None)) => with_unit(typed, ruler_unit(app)), _ => typed.to_string(), }; - // Sending the retained pixels also avoids a round-trip conversion consuming Redo on OK. - let position = exact_position(f, raw, orientation).map_or_else(|| json!(position), |px| json!(px)); let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; match f.get(EDIT) { diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index 1103bf099b..c08b6d4948 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -5,12 +5,8 @@ use crate::menus::invoke as menu; /// One 4 × 2 inch document at 100 ppi, with the rulers in `unit`. fn app(unit: &str) -> (PhotocraftApp, egui::Context) { - app_at_resolution(unit, 100) -} - -fn app_at_resolution(unit: &str, dpi: u32) -> (PhotocraftApp, egui::Context) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 400, "height": 200, "resolution": dpi})).unwrap(); + app.run("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap(); app.sync_views(); (app, egui::Context::default()) @@ -158,186 +154,6 @@ fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() { } } -#[test] -fn bare_positions_use_the_selected_unit_at_the_document_resolution() { - for (unit, position, expected) in [("px", "12,5", 12.5), ("mm", "25,4", 300.0), ("in", "1,5", 450.0)] { - let (mut app, ctx) = app_at_resolution("pixels", 300); - let d = open(&mut app, &ctx); - set(&mut app, d, "vertical", position); - app.ui.dialog_mut(d).unwrap().fields.insert(UNIT.into(), json!(unit)); - crate::dialogs::confirm(&mut app, d).unwrap(); - assert_eq!(guides(&app), (vec![], vec![expected]), "{position} {unit}"); - } -} - -#[test] -fn unit_conversion_preserves_fractional_negative_positions_and_percent_extents() { - let (app, _) = app_at_resolution("pixels", 300); - let mut position = "-123.456789 px".to_string(); - let mut from = Unit::Pixels; - for to in [Unit::Millimeters, Unit::Inches, Unit::Pixels] { - position = convert_position(&app, &position, from, to, true).unwrap(); - from = to; - } - assert!((Unit::parse_length(&position).unwrap().0 + 123.456789).abs() < 1e-8, "{position}"); - assert_eq!(convert_position(&app, "50%", Unit::Pixels, Unit::Pixels, true).unwrap(), "200 px"); - assert_eq!(convert_position(&app, "50%", Unit::Pixels, Unit::Pixels, false).unwrap(), "100 px"); - for invalid in ["", "-", "12 furlongs", "NaN", "inf"] { - assert!(convert_position(&app, invalid, Unit::Pixels, Unit::Inches, true).is_none()); - } -} - -#[test] -fn switching_units_and_confirming_preserves_high_precision_positions() { - use egui_kittest::{Harness, kittest::Queryable}; - - for original in [-1e-10_f64, 1e-10, -1e-100, 1e-100, -123.45678901234567, 0.0, 100.0, 123.45678901234567] { - let (app, ctx) = app_at_resolution("pixels", 300); - let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - let d = open(h.state_mut(), &ctx); - set(h.state_mut(), d, "vertical", &format!("{original} px")); - h.run_steps(3); - for label in ["Inches", "Millimeters", "Pixels"] { - h.get_by_label("Units").click(); - h.run_steps(3); - h.get_by_label(label).click(); - h.run_steps(3); - } - crate::dialogs::confirm(h.state_mut(), d).unwrap(); - let (horizontal, vertical) = guides(h.state()); - assert!(horizontal.is_empty()); - assert_eq!(vertical.len(), 1); - let actual = vertical[0]; - // Allow floating-point conversion error, but no loss from decimal-place rounding. - let tolerance = original.abs() * f64::EPSILON * 8.0; - assert!((actual - original).abs() <= tolerance, "{original} px became {actual} px"); - } -} - -#[test] -fn editing_an_existing_guide_accepts_the_selected_unit_and_undo_restores_it() { - let (mut app, _) = app("pixels"); - app.run(COMMAND, json!({"orientation": "horizontal", "position": 10})).unwrap(); - crate::guide_select::select(&mut app, false, 0); - let d = open_edit(&mut app).unwrap(); - set(&mut app, d, "vertical", "25,4"); - app.ui.dialog_mut(d).unwrap().fields.insert(UNIT.into(), json!("mm")); - crate::guide_edit::sync(&mut app); - let preview = edit_preview(&app, &app.session.active().unwrap().doc).unwrap(); - assert!(preview.horizontal.is_empty()); - assert_eq!(preview.vertical, vec![100.0]); - assert_eq!(guides(&app), (vec![10.0], vec![]), "the selected unit changes only the preview until OK"); - crate::dialogs::confirm(&mut app, d).unwrap(); - assert_eq!(guides(&app), (vec![], vec![100.0])); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(guides(&app), (vec![10.0], vec![])); -} - -#[test] -fn edit_unit_changes_preserve_the_exact_position_preview_and_redo() { - use egui_kittest::{Harness, kittest::Queryable}; - - for (rulers, original) in [("pixels", 12.5), ("cm", 123.45678901234567), ("percent", -1e-100)] { - let (mut app, _) = app(rulers); - app.run(COMMAND, json!({"orientation": "vertical", "position": original})).unwrap(); - app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": original + 1.0})).unwrap(); - assert!(app.session.undo()); - crate::guide_select::select(&mut app, true, 0); - let history = app.session.active().unwrap().history.past_len(); - let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - let d = open_edit(h.state_mut()).unwrap(); - if rulers == "pixels" { - assert_eq!(h.state_mut().ui.dialog_mut(d).unwrap().fields["position"], "13 px"); - } - h.run_steps(3); - for label in ["Millimeters", "Inches", "Percent", "Pixels", "Millimeters"] { - h.get_by_label("Units").click(); - h.run_steps(3); - h.get_by_label(label).click(); - h.run_steps(3); - crate::guide_edit::sync(h.state_mut()); - let preview = edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap(); - assert_eq!(preview.vertical, vec![original], "{rulers} to {label}"); - assert_eq!(guides(h.state()).1, vec![original]); - } - crate::dialogs::confirm(h.state_mut(), d).unwrap(); - assert_eq!(guides(h.state()).1, vec![original]); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - assert!(h.state().session.active().unwrap().history.can_redo(), "unit changes must remain a no-op"); - } -} - -#[test] -fn editing_the_position_after_a_unit_change_releases_the_exact_original() { - use egui_kittest::{Harness, kittest::Queryable}; - - let (mut app, _) = app("pixels"); - app.run(COMMAND, json!({"orientation": "vertical", "position": 12.5})).unwrap(); - crate::guide_select::select(&mut app, true, 0); - let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - let d = open_edit(h.state_mut()).unwrap(); - h.run_steps(3); - h.get_by_label("Units").click(); - h.run_steps(3); - h.get_by_label("Millimeters").click(); - h.run_steps(3); - h.get_by_label("Position").click(); - h.run_steps(3); - h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - h.event(egui::Event::Text("13 px".into())); - h.run_steps(3); - crate::guide_edit::sync(h.state_mut()); - assert_eq!(edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap().vertical, vec![13.0]); - for label in ["Inches", "Pixels"] { - h.get_by_label("Units").click(); - h.run_steps(3); - h.get_by_label(label).click(); - h.run_steps(3); - } - crate::dialogs::confirm(h.state_mut(), d).unwrap(); - assert_eq!(guides(h.state()).1, vec![13.0]); - assert!(h.state_mut().session.undo()); - assert_eq!(guides(h.state()).1, vec![12.5]); -} - -#[test] -fn unit_dropdown_converts_the_draft_and_bare_typing_uses_its_selection() { - use egui_kittest::{Harness, kittest::Queryable}; - - let (app, ctx) = app_at_resolution("pixels", 300); - let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - let d = open(h.state_mut(), &ctx); - set(h.state_mut(), d, "vertical", "300 px"); - h.run_steps(3); - for (label, position, suffix) in [("Millimeters", "25.4 mm", "mm"), ("Inches", "1 in", "in"), ("Pixels", "300 px", "px")] { - h.get_by_label("Units").click(); - h.run_steps(3); - h.get_by_label(label).click(); - h.run_steps(3); - let fields = &h.state().ui.dialogs.iter().find(|dialog| dialog.id == d).unwrap().fields; - assert_eq!(fields["position"], position); - assert_eq!(fields[UNIT], suffix); - assert_eq!(guides(h.state()), (vec![], vec![]), "changing units only edits the draft"); - } - // An explicit typed suffix also updates the dropdown; Tab returns to the position field. - set(h.state_mut(), d, "vertical", "25.4 mm"); - h.run_steps(3); - assert_eq!(h.get_by_label("Units").value().as_deref(), Some("Millimeters")); - h.key_press(egui::Key::Tab); - h.run_steps(3); - assert!(h.get_by_label("Position").is_focused()); - h.event(egui::Event::Text("12,7".into())); - h.run_steps(3); - h.key_press(egui::Key::Enter); - h.run_steps(3); - assert_eq!(guides(h.state()), (vec![], vec![150.0])); - assert!(h.state().ui.dialogs.is_empty()); -} - // These regressions exercise the ordinary dialog handlers, not a separate model. /// Open a draft on the first of two guides and propose moving it to 30 px. fn edit_fixture() -> (PhotocraftApp, u64) { diff --git a/docs/images/new-guide-units-after.png b/docs/images/new-guide-units-after.png deleted file mode 100644 index ef9409d83d1ed09ebbe5058466548718829134aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49831 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

os61p-8J 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 B++Y9z diff --git a/docs/images/new-guide-units-before.png b/docs/images/new-guide-units-before.png deleted file mode 100644 index cc050a575208b61bb8dc8d03f978aaee99144f11..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56638 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

iS4YF2v>gS) zzQLzV7l(nFFTej5s&mOjS$9bCU5FX}gtSkvi?Cu`cG+yR5W3w_t+7)k!nI#)TFD*d zLiV1@;igO7PXnE0DG6*x!&R2LvbnJLVVE4Qe<<;J)Id7u6M!a2s^TmPrH&7d3QTh5 zw|o?O3}^gjio8TCM*ZQ6`m?-pOJ?bTQ=e7F<;`+L(wK3=p0ZaZOlb7JGAO8x{X0jR zd)mIVbH+o*P{cS@A*_iU_VUjMI1heR%~T(^{z!X3ZPNt#r*DY4g{?7+dY$a?(dV5i8l z#!vBktteY?4+=z?FJY#2qJadH&ICOa`Rr1B`{WfAx&S}e3TlW55s*fg@uZES6{A87 zunnJThcyz<27Zi6>a}A_)_Cf{H*lw}aR($Yg7h zPb$MuO_ITB`=mr|aicc;Iw5(}8_;%IN~})O%3N23y$uVvNNfk5c*OK-H*- zbxUfCy6FV!t_BCiD)(r6AyAnleG{#E_FKSkXu2_hMEW3u{FHxJ`;2aFK) zq!j}h=s{G(dj74X4%AH-uffuF_rXHFQqBLV(7$5|h5z^9AOY#$m>opEY%f?fGe-A6 z#swY)6kSpODAVL~V^+OqliIiO*WVP$T={dF+>w{1Y!!eJV(rVky(u5`9nT;!LKXn&hcs{%q?eKdgA3Cc99D|>8z{Nhmj^}Lz&)>LF*>J8GofT4W ztNWQ}Q0<8?+JEXmc7FV0}}Q{1y@x;g-HqZ!!M% zwpGKoG%x@W)z*$2{lW3428#u@0SFp%r3=ffla}~F^Si$dpw58I7Al^|m>K@LN%r3Y zPIu54oHFGeESl^qtpQ>D9nHLr)#-D{`EC>X$HJQ;2kAM$U4%tcmKA-8v@|$tQjLtwvQ;eID3A&lOwMIEiuZ64dY+nKZ?&L;z6qQIcIP}ffB!~{Adot+G&CgF8p>b> z{=L@2X|VnR?P(q_5D8sLD_yLYtcDUvo4;L+AHpe|gxjfg|AqDXu%BO~Rryt7Vf~ox z-y47^2nq#|la#g3bh+ekE3VRf65>Ha;KhJGYoxTp5=$KQeW4fJVH6_gv`t8a##}L; zLEX+f`KGLlmwIr&vu=Y62I1681^V;BzxN8YJ|f<}17%IaisPX7B(r8{U2O;LZ`j$P zINgb>*|sReTjKtcl`a`RJQNN$H#Z0U2^6TX#0^ejK&}&KBSH6%QK~k4nd+<6#i+=p zM)NdJ*Jp}JEF*@P^7hixGL<=FFTzhSdU_TZFyod+9aD{y1J~Ea=_=*JQ%3|6#D{P_ zpNp<{f(b!?*s(uPW`o;D#h(}{bp*$%LIvMRm?ZJW>mw+!h%Vo_L+@E9zyBT_pZE3g%2bC}H`-1AKYk|)Y7UFoZ>x zzVYtcBn__D2v#8~4%@e+^RG&n>25zcf{OO4;y8#FD)B)Lhd%Y6n|z}Xe_iqz{Ygr+ z)37xd$vpwBTA2nJVoE@_3YwSsphw$GZi}yDh!C}8`pVyYbg0KC5fc|-P{5t}?7@hL zT0z~abKY^-m+FZ>SMf)F(c?@|7p8|7`2P736k7V}*x||5iiGzLz9kDNF0zS)#)lPn z+iz4AP33OtsM)^@65n-N2bUP?jPlJXYw#sW@b?oc?=?&H?RUg-&Mv{joIvCP={tzQ z%P``-@y)f4CCmp=o8@&fwOivuX>u6aWpaMsQK-PfNfDf}tg)B^--J*dwooQc%}r1@ zGGvsk_FfS?zQ;HeXMO6*6E>KXQ)fdDO@3}X_N0Qdo3eG)Tb~azt?Oxi+xY5b`Rm53 zb|b%~-rqfRoFV7x!Y?E!R8-x{zVMz)HUh}xOgz{5o8u(+^=fSqORIfjr(ZeE#Daa+ z*USsfN7<{q1$|TXSeU_?RLTO%Z>P0wzMiV~6I*`YZQ6gC?M1wUhITXQ2TgtXH}vkxkHxEO?ne@;SX;A6f_oos#!Lr zJ`BY{flJ~G&y;)+5m~;7M@=dauJa2)k3=T$dO@Oe3OQP{>R2Ov_8oSHHkM-c=sZ9I z?FT}~LQDsR;+3-;#SVjUU}VHKToBjE4#n!NKw0pB~boO=mjL@%OS|5 z-+)W?)K5^S&F{=T5=YA61xl>n9$!b4Ct1T!XlqJ~W7-+cI?N(NnU~Lt>CM%`Lt7n3 z_L5IrpioNQEmY=xHKE17nW}3_#0qXLkyFClU?k7}(p_C&YM$D5^JrbXwR zm9$_1{=hPhg?S>{yPO$+njB=kSmiVd41-mUYpEk3N-^$9dlC0vO~QRwq!EKc91_>N zYsETyqtQ1e2e!a4;ACn^#kBt1@u^gaN!`Is4PBaUx24AW-}T1im6yT%k15o-Y`ZOJ zpZ)Cq;>|X}SsD;<35L&yo7nF#qmw-dXB5&J;6m<(GK=}_r}?ABukkkktrfi)`R(I< zUTG@MvaS9(;^|BSJ#~!^&r|wJuTw@-H0|6^vxPD9gUg#1_MHEBc3bCuhpw-R`OGbB z_&0-Tg)=~qC#tcaNjnegMUZO^G#k}1o8Oau5I%nUd1q-NF6c>++bC0x^zE4|-)T$4 zY!XnDaj{@R_AtPtSb=8}pGc~54SaIC7ot6^m%WoNXbanf@Ut^XJM#~o8#=AovtD^j zEVea2z6mD`1T9)d%jXysQlu9Vb@4WgqyJ1B%a7hfbnnM-zgbsY5tf?ukK_`SGAs4g zdWR>h<3SL=_6ACtpiWc!B&-?1_Z|~kR18t0H#ZuHq+hH=BS)gOH_Cnb)1rBs29^%6dg;3thpi`1Q$!l*Jm?`&=$ z5vP%^bQG>xcpK>0XezE+hJP2>LG+z&ckRKM@#|?8w((#`>ERGk>$tDhZwTOAcXPNR zaCq_A9n?Zob{rI?-l$ZFX6d|I)YQnXle9lJLlY1(*1#AU{4Tsn?!W^C8rr}N}SZr?J*(G_6B-~FGiCfQ!>5%ppP77$xI!{ z155u+CUNcI(sx1%+0^p=eXt+;1-_;8vVh~2<+IfbYLc4#HUw!dt{Su$^*N2)wNPEl zXPWAwaSRZ{+iZhQ{my)hOB>34YI44sB*+=DVCtsGW&M6?_c(hRX|&UiXWbbZG5#qx zDU;#+39aBjp@G-$z#>KZ)agpX-?A}gT5oD`x50qH%zWH@Iz`i5x^oVs{?vvA8$`zE zVTK<8c}0s|NfogKJEfetWdl196V~TEC}m4PWjO9h%h4B4Zn_rbZ-d8`<`2R+z4JEs zh06D?)I*r0TrNaMdk`iKT}45WblBoMH+CgRRH%kK;bS|`J;fg;8!@QzFCt!=@nojc zZxz%(wVO?PU?@5fy~n4=Je-=sJ&3SBeIQO%=MM^nPuVk^1*Kt9)Z58yxwMX>n{e&u|8mfq ziE7UUadK_Sk14gAGp7D+h%WcLfJQc(XQD=MGO@Vj}Q%-|k9J7ngcX&!Gs zTQqKDkTvl&K%bUha^5g~=fd9AkmH5Sx}s5Axh+zJc>E2w7lSiaI{b_4V|u-@UAQ2z z-LZy+cyB0%>^!{aI^K`@!x!B{R6SSs(^woq0zjf7Z*`GbHoVSw9k?c6>EG)~6>`Vl zQyc=^PpWwz+*s2#bKgF@5uu4`7@U71=mWNPSC5x*r3EwV4VvUXzye%MiNl@ZZP~{4 ztQb;73>D^D8Qtx5iUHZ=a6R%36@H&G2 zBY~8%)lXA2tx{@i>aCBO?q#_*U(bF;yd}$Xm1})Nm#-zJOtMWQ1wYlFk1^0~UBA7d zVnrYJW>LBby#r>N>HN0GVYJSZNyi_89p1|SU|N{ zM{ez|GWqfv@iR>=FxR@gCO24{uZVpPiTnkj3s+n7)$gb&^sBsD4<$gXNb=-0x9nHC z(ch2ik*Va_X?CpV#&HM}4^s9A!b_2NR7-+?6z8MSS28t!-9B+5giL=LqTZC18E4Kn zCzH6H>jSU6@MMoM^PA9}H{*G4oBx$k_e(FG&d-X!EMw@}%TmvDv0yiGRC9>jpU)w& z_Axr^BPcGX_*clhZL|D1a)|%DLb6jxBm{7{BP`!}vYTnJ^d-l-4zE@XaN~3t7k;KCUDbB!L?9MpwnIL(9Y971JyQfga}P!C}D0aF?@gmrG$Yt>=;T7QGcVF9#bGz$lIeu*OfGKT(SiUUw9&YK0V-!q~elxrx{CxKidu2Na zsM-PN?1p0I;=mUOwsu_;$~m_l952k+fZK#7A#Y+c1_EhG+Za}N5^Z%y&W#Hn%4stu z3VW4xc@|dGD_ErFoN9&d31DO<;}~{KwiWGO0b@8S+wq2Fq$qx%xufWv8GC&4gq;as z9Q(_e&}oJ2d-E$mI>oMOJo3*Ok3X^g>(*Od7bI~mVWlHGxDgm-0OFlK{*Y~id^K0Y z)Uu+iDe^6g!;C2BN})6+BjvaYl(*nR>~AU6qj1Xp+NVw(^RL7W-*G~OB3z&>vKpzf za1~P$*EibWSF=|kvvLo2p?Xs)SPzkb$)IKaaW6BJ0=acc ztfqmIEda^5ZXy}H#svGYZ{vBW=~V-<>6R(QEZ=7O!o{;gV&`U)ko1;qqLEwYoun!R zR5O}^{}Ycv0Ht@<<38>!sNFs2Qna_{QuOV_@76>!QOEk|)XOTGX=UdK^(_oWn6CjbDAfG(YS<~q@>D{lFw1zv2F1wA3bwD`3MLIaNB5^ z_jVN5F-=E+V{T6eDVEoiEht>;G8kK2P>3s$?Mr|GQdf?#SCW-mH=QVR*~km3PM{*? z!AK50q1A{^+Na!?{S<$55HA&nzET<7Vhe;!Bn#5OC;XjSOwU3xSPYUIJ#qeOBk7KB zi`Kw*3cVwr8^)>IDPPH6SFrlnQ*biMNU(@PqSBQ~rqXqZu`3!D@T!}^#6QT7EXBOn zU&lhF`w*<#z-l9qVxI4+5W7<~0Ud9Zb2 zqX^U}=FO@wg^_>4o^_8%WhxI%`3U2=b~c?eG;jx7<^ zwYC^<`mnE9t>TxcGxB*ct%1{T6dP&{@e9Q}{-*M+M0}(#-TU$s5fMKbk9kynp+7N( zbV3J(WUGaMUR7^zEL9&9U;msjR#|y?vq&@ER;Vn4I{Zja&3YDh|C-T5)@M@0-Sm1lnw z79T?(FxK%Ao{6xcu*H_YM68AUt#u=vBCj+OIi)8t&h);3%xm5&3n@JWyLQeEl|jp$ zIC1!HE7WGIMJ@e9BE-0N9Z}+x1k)W1-w_lBG27@3aO4229|UMtSSppu9HYY24(GEn zmDnkfBwhznYF?vTS0IRaZ8))C&I-VaYHcSU7>+D5BSnT!;)2A4v^_k&U9R;3@akf7 zwcR0RcLaBe83x4s)T~w|%P&z;QHRy|@}CNih@B>jew5ALLuXnu4Xu=UYsaw5^Kp>VdmRx5mFCC84 zSA@_ZENC>T^~RVEcGl-9oHec-VHrh#bVp-GY6G3`8Xqk!WH|0p9_-1iCurYxq6Z&F z$d$b1MVI}^_0ba1K?avY3~C;-t|WqorMUfFt^@^}ur9+U%r-cHdO}w8Q{0+BdfMDC z4v$X=;Agt_PrqC^SN?3dXat`_D~nNNcYUNzKJ01tqfvUn+eZ)GL?$Fqt9z?pHBVJ~ ztCqJnbQN=}Fde-i#U*J_?zUkTM7iWWv!v|}HPb^$zGwd+w@hFIAp68Xu5iRQf$-{I zaKDe|_+Y!v4xM-xVsZT6Dc;z>;6M(N?rnIeBbJ_)1)df{P4YE485@CEHel!e>-S4U zc&?2_SUq#%81a3FP*qDS-+-$6pWn{XEANm{%y*YN9)bFuxkt5~aZCRa=OiNd&AJ~P z?h=3Drho-g@Um7RyPUHOa=d7!UaO?N!rt?z@x?KKc%xbS1Sl+RG-y-V<2`Ft^yl~o zdj$s0;LKGj5nlVFfm_QFxEN-Q3l*|b>zs!1L_$qpPv`RJCeHfO<5&%)Jy1P(f`9B! z=w)HGb5_xFORh;-B1>H%(VWcEoWhDCiEN&DJNMF`O-D{3B)@O(xIz}@Xy16c`&m2@ z^Cu-bQQ-Vi>Xj3$@OytAa-8;z$n(I&C;$P=X>bL{;L|;6{pO9;su^%7JLaK7eV{J; zeIOt4ItmwGrg~PHweCPZ_d8E+gL08C@`&3@X)l**ORmp3hpn0UUoZ2Kbx0r%1FI~=rz|Nc$qTJMn46UQz)gC^ zD6gP}$cdvxEGqQi*fuv-lK*4$`hm3rFRB+oFWva# zGkFLWsF3AeM(~hh*i2Oxr#KSfGecp z&?2SeM(Y)k@gHJRTwuZ1XOwmSbAIkUgBXoO0pFAJ9G{H{T&UdHsLfuw1<_sQIs2Zw zdFf{(p?oNZoKapSQs&F(?^%x9JxIFqG%2>?tgI6y|J44a7psf^9UPeok>*S=Jek(K ze+_Ulgf}rHiItAjypRu#Y>wb({}v@&uym{u|LhU3P*Q4sGqtyu?n-47=^I?p3LGH+ z_UmBt9KU`rDT1FhiZcC|e5vMhd*)#eu}t7?o^0trbiz=?bL^ z?`~8IPu_3SzfS|#f^Bu)WP8#v_q(jfk~Z=2v>d(Syf~U=KiPH(%;|18eudj@-=(Dr z76N?bC%wo!wT24i3?1;0%jKWjRh zpeFeeq9OFUK~TE8lBwkQe(NUb;g|H^f-}Lu7wgl9Y{2Q7OAF*+g(EiStGj~iJ;6t5 z&qcwTrF)K{%uhlX2_+Wv-z)9ERSN@hM>Lvr*&^me7z0^2HT5|e2n(pd)lgi~IIxEZ z!d%`9*(9(Q03@m9lilZNBm^n`*@>)u8kwd0yD>q_^`?A{Cf^eN3oc2S%E%4Ntjt{q zme$U@2cGb1g8zE>bF2R2!3y$#?Qc$I@friS9Lq4A*Ub^Y_cW3(Zz>7oUpR_`TS`4g zQRXA@3W6YMmx2lQ{R=-g7{(=+23#9ChVupT_m8tWXa3<;?zrFn)?TV2@Bu{+A8|VTngQUK=U_y2w)ert1uV~27D)WCEeBjoA62Y$!sfp z`}6S7!h;WQ9GDBH!g@K=Y^h0Z8B&{>i#dLANZVV#f!zMJ)yy#*gPjL)@zn6B_ezzP z$x+hh;BuVo|B!hwD>qT5@KF=jqE(gIY0kmJXJs9JaeN&*OsTd<{aQZiF>4oNK6W+u z*B0&I`VB3J>8g+{vqFwdoqH0yF1H1`8Hvi&BU7*zGeZ5^Ab_6w0HYduoe@1u>>_TaQ8NXz^T~RPW~Pu!q1>GYHL!J^Ns|th zPCgGqG&6jSVe>Xhink9M*yDt#f3-j~#~athaRz&GSI^+~ujYb|rE8eJKhw*jeihdoYeLXW8F&fUb2~2WOPi z2)%d7ZGCr~iqlA4B{89jX9**7$;?;31ttJ*` z?%E+fBXULir?`1%+B|&U6~qYMMzw86HdYC|3Ii08<`suh`scVS59NvlT?C61B^|C|e5a0r$43vg6AMgrE%{7) zXi!)zHlSeYS3)+Dq~$gmbusmoV{&jyqgf{6WP*sv4a+1cnQ0>A)LQaJk+yE$<|^X@CRDiPFvzPGDffXp82_w`;ZOO1}suWERJfh=ec5avJPkvtDo z_asP?TNHhLkIcl^vjR%eFASzaV~BOh4w_;zrW?3*UQMqN-~9=a4g|{_Sjl+EVuXG04P0Wq^P}C#(#|5=*o*L=7-(h}aOcIZcY4$%YlAj*)hTG?m|NH& z-)o3bTE4b^jfp9kfpZT1!F_pAToRRwTyI0s2+ z-yGC|syC_R)@ip<*u>+R_6&uz}Et(q-n zq^>HSM$O1$@8^_hkfR}h-f<8l=(o)xJ?X$~MKiclycf;EhfNO?YbSTukrbKw7+2qw zGHvGws;004CLFKbbBuLJPmj0Tr!&MR_Ja5bz*}IDVE_~#to`4vl{+xvihOQ_WOYAO zcyLdSdh`K0Q6XC)rG3%VVbjCxbw;{WpDOw15Z3N^CX2C*0^N*cRx2Zb01}X&B@CvBx)4!!Ym3JTDDs|7&sIMS?7z6 z<@HbHPmtnu$*#0=faxW}Zhesy=W~aSN;jWF%ONG7o*d)zrcsfbIA(BH3?r@Vf}15u zqpqXH&wOpmaESzrSx5ZjCA4YC4} z=`Sh_fg+24r?HL+>6f^GTNR75X#Tg1jzB!UiYk*QBAnesg+}@8bv584LAvZVj!ehc zw6(!fL>IFPr$+GGT%C&+^=>s_ck*h7>SwFZF-xW4>00qe<8RMdr}|@nea9a zXbOK+?{P<7$~8aY6NvfE`s~As$N+;!@qA3_c5*Aw6r~@~?LgB|y{@^9gLym;(i;K7-S`R=&L744A#*hz-fyTE zMA5!;G~2t$Sf4#(X%z{3HjGa7-VhYXjf^&pDZU)hr~&GQ9m%d|wUQ0>dFQU*1>xr~ zkn#kGF66C{gW2M>N|2z?2>|`jVN{t)P-r>i`1e^qWqfSb?!%ZkXc zZ3tUPAVN{d9QUmKek+ir{igY#jz`88-NZ(pP1eXRAchwoagDf$a_a?kF3GpdP=E8R z&6TBkPj1gWiY|078$$pkmfrCZ8q1E2O=OuN z6JlIBvuVB}86>;dr96GqWHn&!MriEx;}60KTQuJwM2X*a;_X)u^0@w{@h4nJ{llPY z)1AplOqkOV;|?$0UTn}zO=2{FK+w8BVKBOJ2i%k1SS9|HN$OcdkWjKI#NbIX29II6 zTewk3{(?Q&R;|aYQ<{b;cgNLzSIl{2)8a4`@v4h~BQ&=5f$X)|A_)3_Exn9Ywbi!VOpF6%Wo^2a5WykxMh#JStRS-=sK z@B+WkD2u-x;lO;s|LrUpl`CDiVA{_%hhkz zJ7?RRtbA~?L8~^+D`&FSSLe) z@B1K;#6vPj6-}!!ix?Z7NCe>ioS_j`;5nV!krTQS@PLrDp?+FO`&rQFRkSyHj(0wN00p|v|@9E7(ci+gj2sbx9`dIJyMedPxI@= zXCp=hiFe(Q#5;KDEHv>H7Ue}hW~l?F%5cfF|+;$&Adw{PS>wt=w2ACXPhB()wEN$>*Q3%RD{=egS^;Tf;gtVGV%eN?R^SEHe>7_wf=#oi$ZIabo_2wFW3;o}x>@|&cob2F~W?|xc=)g)sj;hi2I={Yc3 z7J4GVj}vAeskUXW0f5b!1`x&SgmbE{RYCCa@Ec8y#Bmu$5*(|eCq)VSEXy5hXpEVE z3o6lUZ>Q#`o#BzhkgmMDj)QdU;)+VQqTLqjMl-gY7l*YV@!f&9+YJA7AgQ)zg`++XAFP(G%*TS~rJ6iz z53x)m0%6{vs12{lC(3y_o-xp82x&`q)3SRNd_Z_7Y}|{TX>kBRLTEwP3h`y)eb8YM z932t(E=1P?G5wr^$QsdqYC9WHSp|8HKP0z{|4D9QjAJz`+XL#FmsB{oBHV3s2lmLl zQ7~z1fOUee#ArR0EkW^_XQv2rm6FEV>u=SQ%{z*(Od7Dj@3HW_0aV7F1(22yI_$yl z0vx=5<+l4^N3r+vE{+oY!%F?Y`~a(dQ-+XP8WPli=o~+juLK%Kp;Xq6?f~<62y>iD zke|8cZMh+J9AVQk0C``Gk2KB*QDV>0w#F@;k9m*&(BsTKZI;m_mSPyN8L9D&j*h+r$?>I!vwaz(m9c1b6x!vOB+r84;~kb#>r%cNRM?MU{ls$XWslJih3bI@$QW z2pHZ!k&obR-q>dmq8#^8zrm@wU!nqs!^bET10~Qv$fQAYgMEM``?)=_mY& z$7$1_XRZxSkvM*uXwd~M@P+5DYv*yE9q}TQ)^njzNs=Fk_7{9{C=9?vi1HN-cQ8#zdjiOdK#^6z=eH~ z>(AyPAg}*-B(q?;0Fy5q-I~v1UQKQFoi$Tr^Dc_ldjA|lH+b;-JsHe>0$7Yq?*S!{0c&EA5@&9kgoNo|dqM3#wwDVO zT{=g&r7A!ZJIdkJHu~}Z{ml^8xUmdtloVpl;`N0F$;aOh&xaj`83AhRe|-HuW)B$c zC;M9R0kVT^iFF97G0@GXvasy_jX^rNr=j=EKlk+Ye*y3|7|z`I*WOUD6k^XHyQdlP zn18(hjl?3XftVpGgf0uE(d{n}VgADvc?4G|239f<)@bipUjWt-X&lzVG4z141_pB1 z&yVbn0sZKQFr44(YpbAkn0H8QpNpm?v*ZQ?|NlO-|Hri{bi2B^=p4^9mc9u1d2fHV zVS|*2KYddBs0e_rk^rX9W^w;90$-sBwv_U-K+h^BU5^~SpGHSEHj!K8HIlgUM zyfNfxI1&rM?kZ6L z`p-QAsJ#NL5PAD|nT|-F0o^zL`;)i9LDITv3-@T3Zutme{R=q?0^kcS@fjNYu^pa6 z6t^E5d=50O_>UW6ulh6o;c5N*b9}Giw#{ViL$`1a{&NR_6tTYik3T$#EDXNk{2ojH zoL((`AXF)5T{1U}j>2Of{f_|;0qVFA2$pk$Hdt7593!k<@X&<7ZPCmle8r!Apca5m z4Pxm{;*5cf2uP*&JOWkPa{}YAe{Z8pN&XT&Y~zy4?nOYs>Vxz|M(&~j>dWT>fX8#b zDC#snrkhtMk#p$rchA!%0E8{CVssuz%hBnk$%L!RE*;ntrU`f!tcqVK4>!k;)?stbi zOe%kL-zhnM`?=^Q93OTDke2Vl7^GnBC_Q#1|C8aK$@3}Pkb&H&aL1gU} zU0~tnSs2)OY`hRSm`(9r8)$FO+58-#)t2+&WJF;_Y(J-NKF61wZX4v^L5PD}8a_w7 zbr?X{lXxLk-V^)~V$9O;F<}T%gp?fkNQ3J|Gk;?KWQwKEe&KHhYxD`>v#%6-nMx*c zQRWfcr`qi5uK?*}DXQz7llK@A-8AVaZh5i3VMz-UkmPV&`%5 z!L%g{^nQMy?$>4rP`aOQB^m=4P*wDew6QNPoaj=gyw#JyuU)dAXc=wuU0>XHH`c!U zKAe|<8UGQW<5j?NjHDh9F}O5|xjXB!<)iuJ)&;eSNsDsaPCb!S=HadiEd*ufso~k)2?_y{g{#{K=wBQO0lXjk_4E zxKQUV?v0(TluJ{SgSlyO=t_kTr?BjyzaT6ep|NFWhp-p&gCIOz)0iME(-5byanI#{ z=7n#`X~^r?6Y#nDAVR6%%ev4R5Ld2B$_T9Jvf(4Z|8HIwK}UN4FyPCcS;}M5mcn^$ zl9%5#j;X4O09#U_s#4d~KSjxA_hk z1u@mXo|S}fR<4K`8vir)j2mJ&jpIDeQ?H&A!uQ}I|@(n{hqDXirRoDOx8LHMyn*w_XaW|h;BIb;X{jG=#EmCN$=gPAru1Fy7S0T`K-}Vyz)5=ObC11I{ zD|u(Bk&_1drT0ggfQBZBVj@7cIPOb_V>MCw;Qrw;BSYG4mYQCLbByj~fOHV=PSeBk z>bM%AA5k;2k`r9JF+0DEABu-BWQhQsk>^p7PDu3FHVoK4l$8c6%$E^`_yi2{? zm#!@5UT1dDB5N|uZ5Y?>>+@3%qT|rT2ZG_1sHeGy7oz<+FR(*q4WJhmpy`D|Tx!IQ zTak|S{*)9702=vjfcRd&YK|5ac3UeMZ1k;KPOl(C)9_plyKh5^A5W%bw~&^#DtbOq zy+aMIHF*b)!E!E6D??w3`LTvDKPQ4BDzg|(-JM|ieYsFXllbXFk(*FxB~mlFFYR+f z?pmXY75B$Syc8K@xwj(l7_Qnr;Gq+3K(7g!7{0BT#a7F!4s_IC%d*1%e*Mn!>x$z{ zNjn*d`Q@*W&>{m7vBBE@0arh>aOi}Y^Pqrbkmr`_3z5UKs|4Q-=zqd$KN6**?lXB^ zPmn{ik+hy^R8JfErmCBr=M}_?)IHB-efcw_& z(&aop{d5I>oFK$>B|(V5RY|GYtk%p(QVAMf*+u*8nq_Kv4}2@|b3c5|;>b!wNr05g z;#R%8Y8Tuyx68PQblD&}BU0!#0sT>?ho7B~zA)ZSZm`wcRrTc=d)Rt3pe4h=mCNYl zJU4t{X<$=}Q6USOTW(h(L0Yp!E~h;zey=^>8Ygr9SOwDT7b=K)f&)2d%H-azBsS+g z7?d8E9I&E3DVDu6bjfto^O8wpo9xXexuYSZiOtR|@7!yHtsCw)@c2C(zZ@t3BR{)t z)%aU-_v#6XE_`t@`|H&6X4vqhFu%W=BB>(GADki?3_V4$>#Rd9GXAP~Yk#JD0}7uN z0_X#97Vmx-ivY==aLZq3t- zC^!+km_|zY6JbGpk=kMHM$J_Jf;s=n%=~Hr_NTQ;kyE13KLyiS?N*Y5Xip1D$2jXG zkpkCwAi&F6=-=k-e!e^4GG|g(?qArxQ0-c!L_$k*(%5&>F3hi*JA&aSmtssw>Bw7U zmSl8M&`p$>qlr@C;#1|jPjM?wQgZ6P`ukymdxk~F#$KS?&*Dp)$qiFyK2pt>ADG`D z0jovAh#UVfNGU;YQ`{^qaF`$16L}#uvLgP_Lz&7?=1lUV(0FBP=!9#J6oIBBh9~LR=lTF#WEz;iu>S1o7iLtdS+}+rY#=VW71G9G^8ecV@_(rJ_wS>lQ=){T zvegkz*^)KHsZNW%$XEuGB@$yP+r*%f7BNLewkTU=%oxUuoruZ4Wt*{&vG2QKxL;E` zpYQ2>?#F%q0pFkUD8@AJ>vg@Z>v}Gip_yA<(7aymZ<00vGmjR^pCdAKSA|m#yT`Q| z@XadeM3t_6d6L!sgpDKJRA(2Xsgkq&t9h!^vMl$n0tTFso12vdCsH_k{lQQpSHPv# zly~`W`W@yB*yWM`7ZQ4^ORWxVo>KBN_am%_8yYaE)vO}hE36gJyMopEfwvH=7q}~)-@xly zx_5_l2ja?)R7>992?#wEz&SkX`3T?=a7A0*(Vn)Wg6bhs{rW^%#4(w(eRim`HApqE z!sX=h3I5SbJB#F~rjurI*FXEeW`nJz6|wZVEL*KeJ*nvsO}C!niueu3k=P42woiE3#Q}>KN zCbsgr;){VmF;a5D3>pgdhD|2PP2A3?74m%gNgb?v&)Q^6NZP@`&~Dx?)HMV>@-cMv zaymUt$VV3xC5JxRO3SPAM5A@@h;`bqRf&Rx|fZvkz$YLdGnJETH#|B}_=mT9V&g zW@W3!v?SunhrUu_8rVU^4cxU8O*7gxmy&~f=8(C+rS9mtp&udq*w=s1_cPDsEzTL2mK>=gYvx;C+ z>t4P&aOP5XvP$Afg4bAby5&LLas_P$wCb%_BU2-P9U3^^Fd z&hxH@XM2y$##hm{N8g7LU?_ZBfF#43M}~mfFWXCQQu-WjLL=ozPhz)wbk5E=O%K>x z0+dTap$xIl6$tn_$wE2vYXoKEk2KN1jk)T*Ij`OTq<0mBVCK;6WBhvc@Am;B@Ri-b zFp8%tOi%#qv@`C#v_!$#a@KcSM_}cmVw>BI?$sLwg4GCm(-p~!tVJd%dSr;`*URu6 zupJ34TAEh$S6eOSO)J*?E@y0JyF4(qDV|3Oe2gqg1dAuyF&$j1-T8Mb-d{_O*wiL0 zJq$Wz-lVq*h<`V0CGg=N3R|hsH>WNjTNP*>AP?QiXC!+w!ks>`TT1=1*y5KgKXJHw zob=j^wTr17tc$VfLRYbL2Q^xMjLO@`-gk3oqLCL5WxH}Gb~{~JErb3hd1zS9oM(Bp zv^S?KYPFj&c8xAbOhD8Q&eoP@T3iYk!Kad66c@a8Emtn^Brjy>_-Kf3jNj~A(D5tb|2f_3SnzJ) z|30H`I>Sz|gx0=0XyI|;OBd$O4Z8zz&WASB<(>D_VM`N78wa#+% z$d6&S%8|`MNaV4*>Yyq+dr^Q8^Pv|5mIW~b`*KN+( zK?3H7sdy5_vPx#->ppf^|U< zJO$=gwuk({ zjzX2&`DULaYe`*{BP>@>^^I_Y-RRu>0;^>hU>Pp$%aC*Wm?P(;#b`zuRVwq>T@2+H zpC4Nnt}O~Am@AOBMzppLmw*e6Q6Y&~g0-Buk3Qxst+a3nnxsF#- zmSF490MN|qw9IMUn*lWQmzIr8z_m+}E|^~xX^#S13lN>=$a%>a@MlssfeNgc&1UvJF``}Ya ziN`H#NunOHhtuF&53_8;-qv^DJhtN1a7?TmJ%QaAn4s*6eq^lcqlFWXpMzCYK+`cB z%_D{=IZpDHLz_1~2*y|uA9$gHi?<;$Mf~XH?jw;I)A={(pQSh!i;Dih`1vd+3!lyS zj-{yUm(YmIq#jY6`C1wvf^y#l72i%O7@1jm6rJ7Ciy_4_w;S1K7n8JhT6VnAy>xJ~ z=hBX}!B_9_ zJQdX5WnxPHbodrHH=o>MG16J0I*TF!lXjv~q^CPKSgsFuoc_$ue$^!49#%i3^#+Ne zDye6R3Qsa`g#{&=@Xng3useHig=UxD)#FA_1p=;eBg>+nI`MWHB^-)>&(ZJhUL4`|tVg?MSJyb-1pV?G%XOj1w&X91U;Y5c z`el;#{2=H8fLKw;6?nJg6~u>&D+F|#DUeVrP)+v}9!=+VJz_CqJ+8G8IB;lPDpw{p zfjB|WnC@@;4gXd573)sL5kJ^p*W&$S1LTo?EXjK$0edm9-q|`b#hEF&=}{4&I%_L3 zqgz`l{&~;b-iblwdIL~Pb^X=4veMSVyLgt7#m=k&UsU5{&y{ZUrVdb5WKW77eZyw@ z_KkQzjaO)Sbg!l&;!R%7w6CrC>LkuN5}7-b*7Q+)6u>YNozJEYHa>%bNg|6{11Cc1 zB@Mu*tY@*=4zqnqP1pzHvYf`|`3yDiAG0e@SY>3&-P%)-A6@z@$>EY$g*4}Q-xR6O zwd;8JPPGZwvDh&Z(hAtqRVU{V9$q5hF1phuy8XN~#gZ@Mos=yUnqr?quB^ZO-A+K; zM5Dxl-4b=a5C}aZ;J}`*Zpgv8Eu5z#@lMT&0YLrLf9CR=hHjZx-~CSyO=ZOzbjz0P z9?Bp`BwXeSJGvLIsdID8^f}P|)N!7C#Iw%D98?z293|CJ5krzb*%J(>ryd%&lI~~7 zOn;pacr+nmVR5g0wcmY_3dvM!68oa)XE3UNE0+vC3j}bD|I(_A;c&a(<&)ip5 zs4uaKPDXc<72p{4u!)$lrC?wGD2Gw0`XI7Ocxwy7ie&wvQCWm}<(=Ti6NHQ)5<}iT zM;CPr`o-=`c@?-OnS@W|7s6KylGpAoK&;Tba_6*0!Yc?jX z+BvKZl(~n8Z&O!td$k}LXy%pq4X;7`jb7l2ey*|EfOF2p&p*=xMnpxMlc}b>-7Kp# z#6}Eb30PN!uq@OwLl4%voFJ8KA%QEEAv~QP|8eEmG%Qtg_eqXCho@f2iaXEJHUQOi zuCTEVTAJffwr&Tgku{D!Q7k?E!|C4Mx>F-wGy4|WyE0-5r6)@^XRDiSKG$f^x>xr_ zhbXvO=f-QmufmXyPF;+uUn4&X@Jn;gY+RceL68;t?{S$6RdI`6y?4Cp%e>K|me$f8 zRmQWO9+6l>l>c(bDaKRX@xdR24ojzy?s0WM%1E38P!Kdgq(kA?e|BUq{B_F@9)BEr zu|e$fd5n0Y1tXq+QQW}R8P>khL$LewJW&jsT~t>Kjd;L64h)w{FBD9P_4bmUqF~zY zxuS)f1;mc9krsOdppkPhvNK}bv@;&9GM0Rw=Cw&d;=sJ*F^i~+I-jGBbo-``rkQwEMU*{}}^8jW$Bh`=bGaT(5HdEC#7r5}%VLeZb01p2sKSQJOy z)WT%wlbyyl147IjRXcJ%E~Og2rQ9vcfX~cSiHK_Hmf1Z;*WK1GDhV}jdXACZb+ySy zwMWQs=JA8zYbq(m`{mAxZ2)f=?Rf^M4HmR2)ce*Nj?W;fm>G%}L^*x^`INV4kEfHc z>L19*N?!H?i0lCtXbQu3U`4qMG}ngai+6dRdBEtY%W@eVn(c-yHJ?hh>Z?c>yu(olCqsLqm$9ZO9_fPPb< z|D)-X=y!7MMvHfccUA`Pmtal}opNtpEV$~+*3q{!t};ATO@>8p=?S==nW5mN=*m#) zXP}w^v6lQGEU077wRu~9+H{kh<>rwza#d>dn#Db9PpJwPEn2zTy<%v;g5%wj;e=m) zkr-yJCTie%|xmdu%J&``-KSdS9lhuS8kK5!R=Z#?QG1d$ zl9y2j_?dprsi_MHxpN&>D{!UY)rqst7hlf}8&}j;w8Ja0VCcKI3dbX>>gju^XgI^$ z>c}T4ah^a%D*dT@sdrsaOYu&~Ufpc}(Z*p(-ja`=H^;;6E=3W|yYeD}P=f7z*v--R z;B@2Uj2qIk&^n=aXyO7yUZImT&dlRGL$}k#0MjAF2|XGvRIb=@mhzL5Vu} z@c31ggE{J+P-GE%u^g3528rGq18OQ+|J4dnS-Eu5ylVSt*ClN&#n0!su44}~bp`-P zu`fL_;2v(=f*}Aj?(1_UE5&lR5dM%Rqv=}NLX0V&U!oFvb*hZ#_!b@I#4vKAC(L=S;=bsClIfpp9c`u zb|E(0x~2WE+p~Z2{@15(>NL^5902elA8^W>A-K2zSwEa5RkG+Ub?Ec5^3G*d#UVf& zw4|K_AOKnOOBg-r*Ss!0_o1~UNy9hsPx3_E(r(gu|B`{Nyp5p(07V#@Ge3i9^ebl> z_x`r`$g6G>%f4n{IGH;1n0xDWZeA&)#c%f0u3b-y9O{;KLa5`M*#WOuUcQ6F+G--O z)WZz3GA38v)K!&;)}C%9C{C`EaqiEVILMLFwF!&ZnG5Q&An=9&@)_y5#PV3&Eb!6_ zDH)D_-g~_6Uh4>>06}&otqA`12<4)ss(0kfQN&lXF~vQhx;`fptu5{ZOeg4m)VU+0 z+qBoZ=zx{ag}}>BCm7|aW43No&pR4#f%xJRxR2Wee<|%R z--+M*M`d@+^)z`Q@#xbehJ#Db#n;Wtl5EJXEwBVq@KU(tiO-pN>-nO68(w|Z1%iwk z!HG4s`TbuagV1lBIqtz8uz(9-3a*+F)^^aS%Z|k|U>P7Z966diX0f9n1mH71hE;NJS!NA)ZRG7H+(M!s>wTe{39;{R zVnN+f7-Zv9CljS86FzsX3ityn{VEogrWXgeJYZU%w($JY_AkO7Pa;2@0#+2&on8v2 zR-9P%P`;yA?ROO96l0mhxCJY(Hj@t>wIti;oDx$)*4?uYGdq>mga6e38+B63)d%}j z0naHsI?3v0d8^K2gh1EpPIY(eH7|;wjB_`f$ID6eQjvMs#w zzA|NnO7&keLkv^eukF4M;XSnVrORe?A<$`bKZ`ra5@i5Mbk>CSo=mQ@sUxDwOK(;v zR%d@m{H6K=e(>A=;J?pueVqiJJ9GmWt4{{-j`JDA9Xl=_O&UQ?!*AeXW*>{(`{S0f|EC8#g_R(` z8C&jPyu5;HVu1s-4__<8EwU#!fGyo0(YY6F&9%#6-K$TsJdZ03Ow9&(>WC^XoLz2S zC@+(!A$9)4lH-kdXT$`?xr9mF?VIkucxc!5Q(R*wT))aI0^`HZ9A=SMBBCL~JSdZ_-BX~46i?v!tT~D;l8sF(y4H?u6I<-lCM|eomFxAQ0&a*MmI|K z!Xby)ATmmLVVlbU&^e$@)O<4lNx-3iC53GTek)~^%nimmwaQp``7_Jc`+%l59XL&C?VM{xtwqf?B{18-qsi6tkWZ~D-D)NzWC zpNS1XQ!mP?yu*6z)w>`3)%*8$kDeKbcvBrIxk7OP5=fizuOF%>nlFpO`(nt}#S+HH zcUU~d&s7ONX0f!MUPg)ZWUK=tR>J_Un)Ne%#cIuupTFVL+)|q10w_C?8=9j{;{BEy zZd1JWYK;^gDZR}iB(ps7w|QyBNn`}j^(5T;x^0(ybU$C`y@^ad9#LSgWEr2bla5%nXKP+?JQ`O?sE;m-4Rw6*Ov)*@gyM>#rCuI1_hd1lB(my@*|_AX8(l@G zvK47(?@*SO+`7wvINJH33-HkZWM^QupwJms18g+P%&XJBA1Aim!6%HY#U?dv}dRG{*&Bv{UBRt%CEQ_d-cl1sApM96#kgKwxpnbaCtD z{bx`^zC$LDPqS%(JNANft4D#Hk+mvYhp;f5UmfixrErGSStFIp1he*ps|L_Z#64Ok zzTe9%N^C+|Fb5ytqyck_Y=2d29Sy*|8&^-dXv${>)aPpvJFN;>oblth+9MA2v29T< z=)DO-{HPucX;BU3A`N%X6C!-p*EDYFnUz$%z}mN*cqlSkGkEI8n66FO&Aa7Cbaik> z8aKv5ZN5yd>!1$A_0Cj02e{q4DaR5SoSz!u^x~^v#6<<}8z3vFH;~742I00WLS&E4 zeH`m3o3-qiNWjXrHUT20Od8* zL*oK=g9SkZv+d{|H@?sO5z5;HQQ-f;H_P&)e(9?1efFKz2YANiK3?FWyZpOIw4Xdl zP4pX(aR|tmH4k=beNQ|;*unR0_n%_Zn)}RGJ1DH1n<`@vcX%ad;5K!<$%qYglhWv7 z$~$XD$z>jPjOPJ{1VWckBEl9(-Bn11!!}9gZxZm)rbG8x%#Sm7hl7t@2nhxVDp9&0 ze>Kh*8V?cBT`Rl)>)p9}U=AtWXZ_Lnr5knlU8w9H!?R1*=il!?ckBoG@o(De3UAX8 zR%B-$!1o>l;XMX40}=Ld!RwtQQ)I>kU3SaTE7y75`6g7uTLTJNt8@pZ3ug=hdJd_1 z-pW8^1^yj)+pl=bZimG&mS;>BXeB}baVn>SD=&InRW!ZTzNpRTPk|Qn?mX{!i8c@O zRD!@PmhyNVX_B>lSDr_p~S-JfR}=JlK22< zNNedk)--y531|DygZcgebg>UbTX@T^i74K>stTBF<#`@7UN*p={j4T~y`DB-1hF9L zmE|5k%0Fh7NApZomgs9=243~hJdl$4`X3~#_l{vlA5``<=NLk6|HQmM5Ei6AUbEM$ zha_Y?U(8}DW)dkUy_TV;UC#p}n5gkRa{c%27F)*YnLeW}TbD(3=&iCU+BAa%3&P#U zn?JF`gMf_`%i=kUEaDd|Z&&rou|)qp&1_6nG}JdfoT}KAS)dFDeDr%ZrDj+1?8b-$ zf$6;Nj>PVYsr(n;u9{Sg_iz@@tk&4ex`6@tCDo@dlEO&^5 zx9ehxZ;{U@m`HMvqAB$JcLl)gSA2&=n`ZbU3#UZVvXd9DUxrFG$s3KbOQl6KQi~qg z(q%q7Ns0ZqSpPm)Ep%WN}u z@$yK_KxN_iWe&P~)&JGSnpjK?szvEWWLi5#!bPD8wu!{kKnygei@aEJ@&1qV0WvD< z%cL20y;}##a(r%4<`($B@{EK+36v{ukS=gNjLb5WI-n9KDJ2rA%GAC!H zyK0n%S*WB8H&-!YyfE*G^5Ic~7)YTj6+p`z^nfV`!?9HbpQ;S&ddPo&ySLGNif7}hI%M{H& zzFmHSOGdzbuLQpf-qOt6+KBH`>EJ`)6O$n-KclAVrv{iIAh5W$G~s-+M3?89M256xE^%&sdCftn zf-$%PM`XCA+nM*BtTadF!jHgP!!0p}%9li;$o9^hahE^@;X^L2y@4@om(e(Ww-@d& ziqr(uAY!vJ0@W4n$XvmgJ0AhV5Uw@M`e~IgwxkY7r@IB!-!6o zW!ohMlTn$P&XeGAmugVHQcRs4kqh9*m(I*6m!LEg+f2caOU%r5&SBiZ`*c|BngLdN z;WqFbiefS@K-6197$&Ek{lK;uQ#b!iyeev5qLZI&E1aH-l%{+PUC^26{PyweZLqf} zhmTpI&Q71mn10S^`ZEL9BtEI5gP*+!)`R6s);Rtf)f@(-{ z;zbSgQZdehha7)7k@XjjJ^s>mFZg{Fq8-A{hGj%4O&m%n5T$+X$TQAYRKs2(kyaiu zF|kh+Byy{=Ch83|29x|#qqT|SGH|?hUZn>j_Ev_b?1~fLvROF|>_}=bDHBiO9oE?) zdyLoxc7dQx;}pljYbRBowOPyQa$@NwX*ckOl2IEhm$Dy$@$E|`o+sknEfQUn`>8U@ z`OApN8H_4Fe2V;+x-6o)Mu&FV&MFiESee#5wZHS`ov>Zk|jkY9kp3V%$K&DxrXc z_5T?*)LU^yWEXPI=6dINPF0kNe`;cKHH?mb_u&w1aH!^PYu|DzIAl0=<cvnM$M%5d#{JfB1p-3$wihn@_ zvuULeW^8eIGP~>MT=hqVc7oGBn8)?3&X$_>Xl;^ho4K_UpG#C~YGz$6kzyL*F-0o9 zP`W$sH~71FVC)v3kstu4D$9~ihle2{AEi>p?$<*n(a;mv zMIAo3$XQk8E{M4WMK_P$)@yG$=DnLK(SM|f{R|q;Hx76q+Jv^X&T*OcI2Wf%391fN zo~Ft_t(u@XEc#RI?tdTpr}O~jR72cuDF{MnoiKkd#uGYO8>P~$ycTZPM$#QMWfKSn zR;1OMZZ|zJo)((=C2a=h@YTLTl2N!#J31td@BB?o;=Rg}hIw-@1o1y9>4@q7GbMrG zuvou6HJJx;3TAVtr)s5ng!%zj1^%3eB$_5p<$-7m=mgPL?N{fmF;U7&QkLIyB*xB+ zdWrfiRgsrisY>$yovLzhL^*XVEB`TQ+xgENPVuN~mt?(k$zj?&NWR}+Sn;j~QEmb% z1g<6vc#E|}>&F~|ZKBHBG9J#yNy?Q!K?)#T=$_djyBqHb#GZ~(F=kTXFkZ(D1w65{ zW@_)%{GF*w4&<8<0dfLGLc|W%l2DV{?4eXpTr4o@*yh;w`i~G@H37S?(wf9lLY_H! z^eZz(J|i9TclruqrfluKud5b)uR+k6+VOhjDD!L(_!0HSBlOniQ*8oMlKX1n%%p@% zF+eD1OTC1M99Mv-)Iq}O3}XzODnob_#QAKfgOm6< z-zbIx))Fs7r9h_!O*cZ`$NyAT0g1Te{!m=%LC0dfJQ)a86>cl6>yzqPSVMY33=7Io zkZ5*l5w6WkWD=+JzR2J@TPUUDG&3)L=}a>>Zy~7Q_n^upd{{cv-mpBzy5|hliF35s zHO!nD6{9?*>M#+x)JKN&J@;5cuKTyk|Mv`r31G#__eUBBVko1r-i3ymSwJjRR1Klr zSoiFv+4en~O(D=;XBxH#g7lOBK+dxx=ahg$xG zyncF^WaG-wP%9ZP#B%Nx@<}7u6jIhGG4!kHs(0_!ncVom<&qU8Ed5YVXPO|{W#p=e zT_f!fB0OCy{3cm1bO02U+>FqK@QfGy8PZeYcu0P|b)08LT?@Y>FrPj1)!-)lME?%^ zcfctc9~Bn_wZ0=%o5zuhf~))S*?tIbg&1=Ed>7{Oy-=5KlB^IsR-iTp(-VwCBexhN}ma0hktpvHX&Z*wn zHPSR_(a7NpJ28AWA;9$$NnW-}ZlWjyRBKb28WS72OdsbTKV_dg1}RZ?l|QWsKEH{Z z*_mcjgGF*VF?ZChc?O9B?hg;i$*p8hEjSHkD(~Tsb7y_B8G3Hs@uo4{Q!Qy$3>$y7 zOsxdvH=DkRldq9s2<{df^TCf3KDt~#e);8!9nugysnxZ(sf+wln_UhreVY%AMfAniT;styeI(6Q|#YmVeK|g7n)tMft zv-yLM@uL#DugP<2uDuFP>^yFg6ohSTT~5$2y{(^L{s;gL-#TEhaQ9Q_j*QPTD({fY-F2N{^%- zO4jk<7obXH=!0Gge|VRuqX!-w%y0iHOzm8isx+tJf2xz6;k4}&Y!1IY^*A>N$>(yLmJ_}%Sb+GYA!BxdTm!>1O0Xg+Fbm{g!ILeBE=qTvfX>JTM;vsOz! zm3d?j8UMj$O)Jn^X$NxmM>JG6;4iHy79evN*M^v8eXX{u$^n~c9=WGimlNbB84XzU z0`7}{n-3>C+%C1%T7n`$Lz9qR=ev1!xVi-osZNQXGtE*(-_nJ;tpEP6z8N$ukEqKx zZc4OEP@GP?QavrYukD!biznP1vnI&6;rn(WevX0nsGw>haefS7cGRyvDYm)Ox7bj9 z&nz5E?RYx0lZsF@CPS@OAUABO)Cl8Y0!|u6`6AtxNt6elXhv9%+sJ}B75dLBIXwox zF$bCw(RSqweTDVg>)AB$&D0NCfAL#>+drJW3%uk-$V7aP1u#ye! z^AFkgb4p(W?z9Vb^)LT5WeWucu2vhv3Z6el(iaq1LMiGTzU|@upA2uM_Y2$1pi1J~ zpN6kNe+Gd6;)dDXpS{HQkJ^IwD^R+|CH(X3X{qFu0081ANSL+=| zfBqa_hyt%0t#+ZGyw-nVFL(`nS}WZ&FZ}N#`A>xX?<4u&Q?flB{eH*)? Date: Fri, 6 Nov 2026 19:39:16 +0100 Subject: [PATCH 58/60] Batch fix-up: path.toShape (#11005) takes a document vector path, not a file The automation registry check reads a `path` param as a host filesystem path unless the command is listed as taking a document vector path, as path.set is. #11005's new path.toShape takes an inline vector path, so it joins that list; CI failed registry_filesystem_path_params_are_classified. Co-Authored-By: DingusMcRinkyDinky Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/workspace/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 1750623eae..7ac1a591e8 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -733,6 +733,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.addToSelection" | "path.subtractFromSelection" | "path.set" + | "path.toShape" | "path.transform" | "path.moveAnchors" | "path.moveHandle" From 0b66e8f00cc6ce5c5853f4251ffe5b82b9676b4f Mon Sep 17 00:00:00 2001 From: DingusMcRinkyDinky <127614779+DingusMcRinkyDinky@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:55:12 +0100 Subject: [PATCH 59/60] path.toShape: its `path` is a vector path, not a host file The automation registry check takes a `path` parameter for a host file unless the command is listed as taking a document vector path, as the other path commands are. path.toShape's `path` is the inline path being drawn, so it belongs in that list; integration batch #11643 failed on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZbgszHyuEuWAzxaamteuU --- crates/automation/src/workspace/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index e96ccb369d..5ef5bf4675 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -685,6 +685,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.clippingPath.set" | "path.rename" | "path.stroke" + | "path.toShape" | "type.attachToPath" | "type.fillPath" | "type.detachFromPath" From e339ecead24815543ca066dee0789cdedbb83794 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 21:44:32 +0100 Subject: [PATCH 60/60] Batch fix-up: keep one path.toShape entry after merging #11005's 0b66e8f00c #11005's new head adds path.toShape to the vector-path list next to path.stroke; the batch's 95c7a5d17e had added it next to path.set. Both merged cleanly into a duplicate or-pattern; keep the contributor's. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/workspace/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 640d34d5ca..3e0f5e7d82 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -733,7 +733,6 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.addToSelection" | "path.subtractFromSelection" | "path.set" - | "path.toShape" | "path.transform" | "path.moveAnchors" | "path.moveHandle"