From c17cc118f550ed5c15837428833032dc1d892895 Mon Sep 17 00:00:00 2001 From: Fabian Ghenof Date: Sun, 11 Oct 2026 11:11:48 +0300 Subject: [PATCH 01/23] Moving a shape keeps its rendering: Artboards to Files matches Photoshop translate_vectors re-rendered a shape layer on every move. For a shape from a PSD that replaced Photoshop's anti-aliasing with PhotoCraft's, so Artboards to Files (which moves each board to the origin), the Move tool, nudges, layer comps and moving artboards changed how imported shapes look. A whole-pixel move now shifts the rendered pixels, through one shift_shape helper shared with the Move tool's live preview (layer_multi_cmds:: shift_shown), which already did this. The render is cut at the canvas, so a shape reaching past it still renders again, as does a fractional move; the path moves either way, and fit_canvas still re-renders shapes when an artboard move grows the canvas (#2389). Measured on a Steam store asset template with #3218 (the same fix for type): the "How to use" artboard has no pixel off by more than 2 levels from Photoshop's own composite; before, both its shapes differed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 14 +----- crates/engine/src/vector_cmds.rs | 22 +++++++-- crates/engine/src/vector_cmds/tests.rs | 68 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 5db8f52703..75ddffda90 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -345,19 +345,7 @@ fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { *c = translate_surface(c, dx, dy); } } - LayerContent::Shape(sh) => { - // The rendered shape is cut at the canvas: one that reaches past it renders again. - let canvas = doc.bounds(); - let inside = sh.cache.as_ref().is_some_and(|c| { - let b = photocraft_compose::bounds::content_bounds(c); - b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) - }); - crate::vector_cmds::transform_shape(sh, &a); - match &mut sh.cache { - Some(c) if inside => *c = translate_surface(c, dx, dy), - _ => crate::vector_cmds::refresh_shape(doc, sh), - } - } + LayerContent::Shape(sh) => crate::vector_cmds::shift_shape(doc, sh, f64::from(dx), f64::from(dy)), LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy), LayerContent::Group(g) => { if let Some(ab) = &mut g.artboard { diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index fda3ba8b46..6b44efd56a 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -481,10 +481,7 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { vm.path = vm.path.transform(&a); } match &mut l.content { - LayerContent::Shape(sh) => { - transform_shape(sh, &a); - refresh_shape(doc, sh); - } + LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), LayerContent::Group(g) => { for c in &mut g.children { translate_vectors(doc, c, dx, dy); @@ -494,6 +491,23 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { } } +/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a +/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at +/// the canvas, so a shape reaching past it renders again, as does a fractional move. +pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { + transform_shape(sh, &Affine::translate(dx, dy)); + let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); + let canvas = doc.bounds(); + let inside = sh.cache.as_ref().is_some_and(|c| { + let b = photocraft_compose::bounds::content_bounds(c); + b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) + }); + match (&mut sh.cache, whole(dx), whole(dy)) { + (Some(c), Some(dx), Some(dy)) if inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), + _ => refresh_shape(doc, sh), + } +} + /// Applies an affine transform to a shape's path, keeping the live shape when the transform /// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 6f41eb03c1..5346f49dfd 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -666,3 +666,71 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } } + +/// A shape layer as a PSD import leaves it: Photoshop's rendering as the cache (deliberately not +/// what PhotoCraft would draw, a block of another colour, so a re-render shows). +fn add_imported_shape(s: &mut Session, r: Rect) -> LayerId { + s.edit("shape", |doc, active| { + let fmt = doc.pixel_format(); + let path = photocraft_vector::shapes::rect(f64::from(r.x0), f64::from(r.y0), f64::from(r.width()), f64::from(r.height())); + let mut cache = photocraft_raster::Surface::new(fmt); + cache.fill_rect(r, &photocraft_raster::from_rgba(&fmt, [0.9, 0.5, 0.1, 1.0])); + let sh = ShapeLayer { path, fill: Some(photocraft_doc::Fill::Solid(photocraft_color::Color::BLACK)), cache: Some(cache), ..Default::default() }; + let id = doc.insert_above(None, Layer::new("Rectangle 1", LayerContent::Shape(sh))); + *active = Some(id); + Ok(id) + }) + .unwrap() +} + +fn shape_layer(doc: &Document, id: LayerId) -> ShapeLayer { + match &doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape layer: {}", other.kind_name()), + } +} + +fn orange_at(sh: &ShapeLayer, x: i32, y: i32) -> bool { + let c = sh.cache.as_ref().unwrap(); + let px = photocraft_raster::to_rgba(&c.format(), &c.pixel(x, y)); + (px[0] - 0.9).abs() < 0.01 && (px[1] - 0.5).abs() < 0.01 +} + +#[test] +fn moving_and_exporting_imported_shapes_keeps_their_rendering() { + for depth in [8, 16, 32] { + let mut s = session(100, 80, depth); + let id = add_imported_shape(&mut s, Rect::new(30, 20, 60, 40)); + // The Move tool: the same pixels, moved; the path moves with them. + s.execute("layer.translate", json!({"layer": id.0, "dx": 5, "dy": -3})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(35, 17, 65, 37), "depth {depth}"); + assert!(orange_at(&sh, 40, 20), "depth {depth}: not re-rendered"); + assert_eq!(sh.path.control_bounds().map(|b| (b.0.round(), b.1.round())), Some((35.0, 17.0)), "the path moves too"); + // Artboards to Files moves the board to the origin the same way. + s.execute("layer.new.artboard", json!({"rect": [20, 10, 60, 40]})).unwrap(); + let board = doc(&s).artboards()[0].0; + s.edit("into board", |doc, _| { + let l = doc.remove(id).unwrap(); + let Some(LayerContent::Group(g)) = doc.layer_mut(board).map(|b| &mut b.content) else { panic!("artboard group") }; + g.children.push(l); + Ok(()) + }) + .unwrap(); + let one = crate::artboard_cmds::artboard_document(doc(&s), board).unwrap().unwrap(); + let sh = shape_layer(&one, id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(15, 7, 45, 27), "depth {depth}"); + assert!(orange_at(&sh, 20, 10), "depth {depth}: exported as rendered"); + } +} + +#[test] +fn a_shape_reaching_past_the_canvas_renders_again_when_moved() { + let mut s = session(100, 80, 8); + // Cut at the canvas's right edge (100 px), so its pixels can't simply be shifted back in. + let id = add_imported_shape(&mut s, Rect::new(80, 20, 120, 40)); + s.execute("layer.translate", json!({"layer": id.0, "dx": -30, "dy": 0})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert!(!orange_at(&sh, 60, 30), "re-rendered from the path (black fill)"); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(50, 20, 90, 40), "the whole shape shows"); +} From 8744820a279f9df7a8525252661af526212e9b2d Mon Sep 17 00:00:00 2001 From: bazmarth Date: Wed, 14 Oct 2026 22:07:03 +0000 Subject: [PATCH 02/23] Shapes: rounded corners on live triangles, polygons and stars (TOOL-214-12) As Photoshop's rounded triangles and polygons, a live polygon (the Triangle tool draws a 3-sided one, stars included) now has one corner radius: - doc: LiveShape::Polygon gains corner_radius (serde default, so older .pcraft files load sharp); - vector: shapes::fillet and shapes::round_corners round every vertex with a circular arc tangent to both edges, each capped so neighbouring arcs meet at most halfway along an edge; - engine: shape.create / shape.edit take "cornerRadius"; transforms scale it like a rectangle's radii; - ui: the Properties panel shows a Corner radius field for polygons, and the on-canvas corner widgets now cover every selected rectangle, triangle, polygon and star (Path Selection, the Rectangle/Triangle/Polygon tools, Move with Show Transform Controls). Dragging any widget gives every selected shape the same radius; Alt still changes one corner of a rectangle. The widgets take the unsnapped press: snapping used to pull a shape tool's press off the widget, so the tool drew a new shape instead. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/vector.rs | 13 ++ crates/engine/src/vector_cmds.rs | 24 +- crates/engine/src/vector_cmds/tests.rs | 32 +++ crates/io/src/vector_map.rs | 4 +- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/corner_radius.rs | 260 +++++++++++++++------- crates/ui-egui/src/corner_radius_tests.rs | 83 +++++++ crates/ui-egui/src/shape_dialog_tests.rs | 2 +- crates/ui-egui/src/vector_ui.rs | 9 +- crates/vector/src/shapes.rs | 152 ++++++++++++- docs/scorecard.md | 2 +- docs/ui-parity.md | 5 +- scorecard/tools.toml | 2 +- 13 files changed, 496 insertions(+), 98 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f36df185a3..6ccac60741 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -302,10 +302,14 @@ pub enum LiveShape { rect: [f64; 4], }, /// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon). + /// `corner_radius` rounds every vertex (0 = sharp), each within what its edges allow; the + /// Triangle tool draws a 3-sided one. Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, + #[serde(default)] + corner_radius: f64, }, /// Line from `from` to `to` drawn as a filled bar `weight` px thick. Line { @@ -352,6 +356,15 @@ mod tests { assert_eq!(t.control_bounds(), Some((2.0, 3.0, 6.0, 10.0))); } + #[test] + fn a_polygon_saved_before_corner_radii_loads_sharp() { + let old = r#"{"kind":"polygon","rect":[0.0,0.0,10.0,10.0],"sides":3,"star_ratio":1.0}"#; + let back: LiveShape = serde_json::from_str(old).unwrap(); + assert_eq!(back, LiveShape::Polygon { rect: [0.0, 0.0, 10.0, 10.0], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); + let round = LiveShape::Polygon { rect: [0.0; 4], sides: 6, star_ratio: 1.0, corner_radius: 4.5 }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&round).unwrap()).unwrap(), round); + } + #[test] fn serde_roundtrip() { let p = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (3.0, 4.0)]).with_op(PathOp::Exclude)]); diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..77849f7377 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -412,13 +412,19 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) } "ellipse" => LiveShape::Ellipse { rect: rect()? }, "polygon" | "star" => { - let (ps, pr) = match prev { - Some(LiveShape::Polygon { sides, star_ratio, .. }) => (*sides, *star_ratio), - _ => (5, if kind == "star" { 0.5 } else { 1.0 }), + let (ps, pr, pc) = match prev { + Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), + _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), }; + let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); - LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } } + LiveShape::Polygon { + rect: rect()?, + sides, + star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, + corner_radius, + } } "line" => { let (pf, pto, pw) = match prev { @@ -510,7 +516,9 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } } LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, - LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio }, + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } + } LiveShape::Line { from, to, weight } => { // Live-shape edits regenerate the line's filled bar from its parameters. // Keep the weight in sync with a positive scale of the path, or an @@ -704,7 +712,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result { sh.live = None; sh.psd_raw = None; } - let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"]; + let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; if geom_keys.iter().any(|k| p.get(*k).is_some()) { let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; @@ -1683,7 +1691,7 @@ pub fn specs() -> Vec { "New Shape Layer", ["Layer", "New"], leak(format!( - r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## + r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## )), has_doc, shape_create @@ -1693,7 +1701,7 @@ pub fn specs() -> Vec { "Edit Shape", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## + r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## )), has_layer, shape_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..8f619c9ad8 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -948,3 +948,35 @@ fn combine_shapes_fails_gracefully() { assert!(s.execute("layer.combineShapes.unite", json!({})).is_err()); assert_eq!(*doc(&s), before); } + +#[test] +fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() { + let mut s = session(200, 200, 8); + let r = s.execute("shape.create", json!({"kind": "polygon", "rect": [20, 20, 120, 120], "sides": 3, "cornerRadius": 12})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let live = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.live.clone(), + _ => None, + }; + let path_len = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.path.subpaths[0].knots.len(), + _ => 0, + }; + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 3, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 6, "every vertex rounded"); + // Other edits keep the radius; a radius edit regenerates the path; 0 makes it sharp again. + s.execute("shape.edit", json!({"layer": id, "sides": 6})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 6, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 12); + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 0})).unwrap(); + assert_eq!(path_len(&s), 6); + // Hostile values never panic: negative and non-finite radii are clamped or ignored. + for bad in [json!(-5), json!(1e300), json!("x")] { + let _ = s.execute("shape.edit", json!({"layer": id, "cornerRadius": bad})); + } + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if corner_radius.is_finite() && corner_radius >= 0.0)); + // Scaling the layer by half halves the radius, as for a rectangle's radii. + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 10})).unwrap(); + s.execute("shape.edit", json!({"layer": id, "transform": [0.5, 0, 0, 0.5, 0, 0]})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if (corner_radius - 5.0).abs() < 1e-9)); +} diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 3bd127445d..bb830dbd68 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -671,7 +671,7 @@ mod tests { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); } - assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none()); + assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0 }, 72.0).is_none()); } #[test] @@ -699,7 +699,7 @@ mod tests { // Edit: path changes → vmsk regenerated, live dropped when polygon. let mut edited = sh.clone(); edited.path.subpaths.pop(); - edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 }); + edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); shape_blocks(&edited, &mut raw, 64, 64, 72.0); assert!(!raw.iter().any(|(k, _)| k == b"vogk")); let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a9592b692..b41f4f02df 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5005,8 +5005,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs). let ev = crate::move_mods::filter_event(app, ev, mods); - // A live rectangle's corner radius widgets (Path Selection, Rectangle tool). - if crate::corner_radius::pointer(app, ev, mods) { + // A live shape's corner radius widgets (Path Selection, the shape tools, Move with its + // transform controls). Unsnapped, as Free Transform's handles: snapping would pull the press + // off a widget onto a nearby vertex or guide, and the shape tool would draw a new shape. + if crate::corner_radius::pointer(app, raw, mods) { return; } // Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790). diff --git a/crates/ui-egui/src/corner_radius.rs b/crates/ui-egui/src/corner_radius.rs index b02e4d0eea..388ad60a96 100644 --- a/crates/ui-egui/src/corner_radius.rs +++ b/crates/ui-egui/src/corner_radius.rs @@ -1,14 +1,17 @@ -//! On-canvas corner radius widgets for live rectangles (TOOL-214-12), as in Photoshop: with Path -//! Selection, the Rectangle tool, or the Move tool showing its transform controls, every selected -//! shape layer that is a live rectangle shows a round widget inside each corner, at the centre of -//! its arc. Dragging one rounds every corner of every selected rectangle to the same radius; -//! ⌥/Alt changes that corner only, on each of them. The drag previews the new outlines and -//! commits one `shape.edit` with `radii` per changed layer on release, as one history step; a -//! click without movement changes nothing. +//! On-canvas corner radius widgets for live shapes (TOOL-214-12), as in Photoshop: with Path +//! Selection, a shape tool (Rectangle, Triangle, Polygon), or the Move tool showing its transform +//! controls, every selected shape layer that is a live rectangle, triangle, polygon or star shows +//! a round widget inside each corner, at the centre of its arc. Dragging one rounds every corner +//! of every selected shape to the same radius; ⌥/Alt changes that corner only on rectangles +//! (a triangle or polygon has one radius for all its corners, as in Photoshop). The drag previews +//! the new outlines and commits one `shape.edit` per changed layer on release (`radii` for a +//! rectangle, `cornerRadius` for a polygon), as one history step; a click without movement +//! changes nothing. use egui::{Color32, Modifiers, Pos2, Stroke}; use photocraft_doc::LayerContent; use photocraft_doc::vector::LiveShape; +use photocraft_vector::shapes; use crate::PhotocraftApp; use crate::canvas::ToolEvent; @@ -18,24 +21,124 @@ use crate::state::Tool; /// can still be grabbed. const MIN_INSET: f64 = 10.0; -/// A selected live rectangle: its layer, `[x, y, w, h]` with a positive size, and its radii. -#[derive(Clone, Copy, Debug, PartialEq)] +/// The live shape a target's corners belong to. +#[derive(Clone, Debug, PartialEq)] +enum Geom { + /// `[x, y, w, h]` with a positive size. + Rect([f64; 4]), + /// The polygon's parameters and its sharp vertices, in path order. + Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, vertices: Vec<[f64; 2]> }, +} + +/// A selected live shape with corners: its layer, its geometry, and its radii (a rectangle's four +/// `[tl, tr, br, bl]`; a polygon's one radius, repeated). +#[derive(Clone, Debug, PartialEq)] pub struct Target { layer: u64, - rect: [f64; 4], + geom: Geom, radii: [f64; 4], } +/// One corner: its vertex, and how far its arc's centre moves per unit of radius (the centre is +/// `vertex + step * r`: `(±1, ±1)` for a rectangle, `bisector / sin(half the angle)` for a +/// polygon vertex). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Corner { + vertex: [f64; 2], + step: [f64; 2], +} + +impl Target { + fn corners(&self) -> usize { + match &self.geom { + Geom::Rect(_) => 4, + Geom::Polygon { vertices, .. } => vertices.len(), + } + } + + fn is_rect(&self) -> bool { + matches!(self.geom, Geom::Rect(_)) + } + + /// Corner `i`, `None` when it is degenerate. + fn corner(&self, i: usize) -> Option { + match &self.geom { + Geom::Rect([x, y, w, h]) => { + let (vertex, d) = match i { + 0 => ([*x, *y], [1.0, 1.0]), + 1 => ([x + w, *y], [-1.0, 1.0]), + 2 => ([x + w, y + h], [-1.0, -1.0]), + _ => ([*x, y + h], [1.0, -1.0]), + }; + Some(Corner { vertex, step: d }) + } + Geom::Polygon { vertices, .. } => { + let f = self.fillet(i, 1.0)?; + let sin = f.half_angle.sin(); + (sin > 1e-9).then(|| Corner { vertex: vertices.get(i).copied().unwrap_or(f.centre), step: [f.bisector[0] / sin, f.bisector[1] / sin] }) + } + } + } + + /// A polygon vertex's rounding with radius `r`. + fn fillet(&self, i: usize, r: f64) -> Option { + let Geom::Polygon { vertices, .. } = &self.geom else { return None }; + let n = vertices.len(); + let at = |k: usize| vertices.get(k % n).copied(); + shapes::fillet(at(i + n - 1)?, at(i)?, at(i + 1)?, r) + } + + /// The largest radius every corner of the shape can take. + fn max_radius(&self) -> f64 { + match &self.geom { + Geom::Rect(r) => r[2].min(r[3]) / 2.0, + Geom::Polygon { vertices, .. } => (0..vertices.len()).filter_map(|i| self.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min), + } + } + + /// The radius corner `i` is drawn with for `radii`: capped at what the shape allows there. + fn radius_at(&self, radii: [f64; 4], i: usize) -> f64 { + match &self.geom { + Geom::Rect(_) => radii.get(i).copied().unwrap_or(0.0).clamp(0.0, self.max_radius()), + Geom::Polygon { .. } => self.fillet(i, radii[0]).map_or(0.0, |f| f.radius), + } + } + + /// Where corner `i`'s widget is drawn for `radii`: the centre of its arc, or a few screen + /// points inside a sharper corner. + fn widget(&self, radii: [f64; 4], i: usize, zoom: f64) -> Option<[f64; 2]> { + let c = self.corner(i)?; + let most = match &self.geom { + Geom::Rect(_) => self.max_radius(), + Geom::Polygon { .. } => self.fillet(i, 1.0e12).map_or(0.0, |f| f.radius), + }; + // The inset is measured like a rectangle's: 10 points along each axis of a right angle. + let len = c.step[0].hypot(c.step[1]).max(1e-9); + let inset = MIN_INSET / zoom * std::f64::consts::SQRT_2 / len; + let r = self.radius_at(radii, i).max(inset).min(most); + Some([c.vertex[0] + c.step[0] * r, c.vertex[1] + c.step[1] * r]) + } + + /// The outline for `radii`. + fn path(&self, radii: [f64; 4]) -> photocraft_doc::Path { + match &self.geom { + Geom::Rect([x, y, w, h]) => shapes::rounded_rect(*x, *y, *w, *h, radii), + Geom::Polygon { rect, sides, star_ratio, .. } => { + shapes::live_path(&LiveShape::Polygon { rect: *rect, sides: *sides, star_ratio: *star_ratio, corner_radius: radii[0] }) + } + } + } +} + /// A corner radius drag in progress. #[derive(Clone, Debug, PartialEq)] pub struct Drag { - /// Every selected live rectangle, as at the press. + /// Every selected live shape with corners, as at the press. targets: Vec, - /// The rectangle whose widget was pressed (an index into `targets`) and its corner, - /// `[tl, tr, br, bl]` order. + /// The shape whose widget was pressed (an index into `targets`) and its corner. grabbed: usize, corner: usize, - /// Every corner (no ⌥) or the dragged one only. + /// Every corner (no ⌥) or the dragged one only (rectangles). all: bool, /// Where the press was. start: [f64; 2], @@ -47,13 +150,39 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && match app.ui.tool { - Tool::PathSelection | Tool::Rectangle => true, + Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true, Tool::Move => app.ui.tool_options.move_show_transform, _ => false, } } -/// The selected layers that are live rectangles, front first. +/// A shape layer's live shape as a target, if it has corners. +fn target(layer: u64, live: &LiveShape) -> Option { + let finite = |v: &[f64]| v.iter().all(|v| v.is_finite()); + let positive = |[x, y, w, h]: [f64; 4]| { + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; + (w > 0.0 && h > 0.0 && finite(&[x, y, w, h])).then_some([x, y, w, h]) + }; + match *live { + LiveShape::Rect { rect, radii } => { + let rect = positive(rect)?; + finite(&radii).then_some(Target { layer, geom: Geom::Rect(rect), radii }) + } + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + positive(rect)?; + if !finite(&[star_ratio, corner_radius]) { + return None; + } + let sharp = shapes::polygon(rect[0], rect[1], rect[2], rect[3], sides, star_ratio); + let vertices = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices }, radii: [corner_radius.max(0.0); 4] }) + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => None, + } +} + +/// The selected layers that are live shapes with corners, front first. fn targets(app: &PhotocraftApp) -> Vec { if !shows(app) { return Vec::new(); @@ -65,10 +194,7 @@ fn targets(app: &PhotocraftApp) -> Vec { .filter_map(|id| { let l = st.doc.layer(id)?; let LayerContent::Shape(sh) = &l.content else { return None }; - let Some(LiveShape::Rect { rect: [x, y, w, h], radii }) = sh.live else { return None }; - let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; - let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; - (w > 0.0 && h > 0.0 && [x, y, w, h].iter().chain(&radii).all(|v| v.is_finite())).then_some(Target { layer: l.id.0, rect: [x, y, w, h], radii }) + target(l.id.0, sh.live.as_ref()?) }) .collect(); // `selected_layers` lists bottom to top; the topmost widget wins a press. @@ -76,58 +202,19 @@ fn targets(app: &PhotocraftApp) -> Vec { out } -/// The largest radius a corner of `rect` takes. -fn max_radius(rect: [f64; 4]) -> f64 { - rect[2].min(rect[3]) / 2.0 -} - -/// Corner `i` of `rect` and the unit-step direction pointing inside from it. -fn corner(rect: [f64; 4], i: usize) -> ([f64; 2], [f64; 2]) { - let [x, y, w, h] = rect; - match i { - 0 => ([x, y], [1.0, 1.0]), - 1 => ([x + w, y], [-1.0, 1.0]), - 2 => ([x + w, y + h], [-1.0, -1.0]), - _ => ([x, y + h], [1.0, -1.0]), - } -} - -/// Where corner `i`'s widget is drawn: the centre of its arc, or a few screen points inside a -/// sharper corner. -fn widget(rect: [f64; 4], radii: [f64; 4], i: usize, zoom: f64) -> [f64; 2] { - let (c, d) = corner(rect, i); - let r = radii.get(i).copied().unwrap_or(0.0).max(MIN_INSET / zoom).min(max_radius(rect)); - [c[0] + d[0] * r, c[1] + d[1] * r] -} - -/// The radius the pressed corner `i` asks for when the pointer has moved from `start` to `p`: its -/// radius at the press plus the move projected onto the corner's diagonal (inward rounds more), -/// at least 0. Relative, so a click on a sharp corner's inset widget changes nothing. Each -/// rectangle then caps it at half its own shorter side. -fn radius_after(rect: [f64; 4], i: usize, from: f64, start: [f64; 2], p: [f64; 2]) -> f64 { - let (_, d) = corner(rect, i); - let r = from + ((p[0] - start[0]) * d[0] + (p[1] - start[1]) * d[1]) / 2.0; - if r.is_finite() { r.max(0.0) } else { from } -} - fn zoom(app: &PhotocraftApp) -> f64 { f64::from(app.point_zoom().max(0.01)) } -/// The widget of `targets` within grabbing distance of document point `p`: the rectangle (an -/// index into `targets`) and its corner, the nearest when several are close. +/// The widget of `targets` within grabbing distance of document point `p`: the shape (an index +/// into `targets`) and its corner, the nearest when several are close. fn hit(app: &PhotocraftApp, targets: &[Target], p: [f64; 2]) -> Option<(usize, usize)> { let z = zoom(app); let tol = 7.0 / z; targets .iter() .enumerate() - .flat_map(|(t, g)| { - (0..4).map(move |i| { - let w = widget(g.rect, g.radii, i, z); - (t, i, (w[0] - p[0]).hypot(w[1] - p[1])) - }) - }) + .flat_map(|(t, g)| (0..g.corners()).filter_map(move |i| g.widget(g.radii, i, z).map(|w| (t, i, (w[0] - p[0]).hypot(w[1] - p[1]))))) .filter(|(.., d)| *d <= tol) .min_by(|a, b| a.2.total_cmp(&b.2)) .map(|(t, i, _)| (t, i)) @@ -159,12 +246,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Move { x, y, .. } => { let Some(d) = app.ui.corner_radius.as_mut() else { return false }; let Some(g) = d.targets.get(d.grabbed) else { return true }; - let from = g.radii.get(d.corner).copied().unwrap_or(0.0).clamp(0.0, max_radius(g.rect)); - let r = radius_after(g.rect, d.corner, from, d.start, [x, y]); - // Every selected rectangle takes the same radius, within its own size. + let Some(c) = g.corner(d.corner) else { return true }; + // The radius at the press plus the move projected onto the corner's step (the way its + // arc's centre moves as the radius grows). Relative, so a click on a sharp corner's + // inset widget changes nothing. + let from = g.radius_at(g.radii, d.corner); + let len2 = c.step[0] * c.step[0] + c.step[1] * c.step[1]; + let r = from + ((x - d.start[0]) * c.step[0] + (y - d.start[1]) * c.step[1]) / len2.max(1e-12); + let r = if r.is_finite() { r.max(0.0) } else { from }; + // Every selected shape takes the same radius, within its own size. for (t, now) in d.targets.iter().zip(d.now.iter_mut()) { - let r = r.min(max_radius(t.rect)); - if d.all { + let r = r.min(t.max_radius()); + if d.all || !t.is_rect() { *now = [r; 4]; } else { *now = t.radii; @@ -184,9 +277,15 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool // One history step for every layer changed. let key = format!("corner-radius-{}", app.ui.alloc_id()); for (t, now) in d.targets.iter().zip(&d.now) { - if *now != t.radii - && let Err(e) = app.run("shape.edit", serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key})) - { + if *now == t.radii { + continue; + } + let params = if t.is_rect() { + serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key}) + } else { + serde_json::json!({"layer": t.layer, "cornerRadius": now[0], "coalesce": key}) + }; + if let Err(e) = app.run("shape.edit", params) { app.ui.status = e; app.ui.status_error = true; break; @@ -199,11 +298,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// The radius the dragged corner shows in the readout. fn dragged_radius(d: &Drag) -> Option { - d.now.get(d.grabbed).and_then(|r| r.get(d.corner)).copied() + let g = d.targets.get(d.grabbed)?; + let now = d.now.get(d.grabbed)?; + if g.is_rect() { now.get(d.corner).copied() } else { Some(g.radius_at(*now, d.corner)) } } -/// The widgets of every selected live rectangle; while one is dragged, the new outlines and the -/// radius beside the pointer (`R: 25.0 px`), as in Photoshop. +/// The widgets of every selected live shape with corners; while one is dragged, the new outlines +/// and the radius beside the pointer (`R: 25.0 px`), as in Photoshop. pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { let drag = app.ui.corner_radius.as_ref(); if let Some(r) = drag.and_then(dragged_radius) @@ -216,13 +317,14 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; let now = drag.and_then(|d| d.targets.iter().position(|g| g.layer == t.layer).and_then(|i| d.now.get(i))); let radii = now.copied().unwrap_or(t.radii); if now.is_some() { - let path = photocraft_vector::shapes::rounded_rect(t.rect[0], t.rect[1], t.rect[2], t.rect[3], radii); - crate::vector_ui::draw_outline(painter, &path, to_scr, accent); + crate::vector_ui::draw_outline(painter, &t.path(radii), to_scr, accent); } - for i in 0..4 { - let p = to_scr(widget(t.rect, radii, i, z)); - painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); - painter.circle_filled(p, 2.5, accent); + for i in 0..t.corners() { + if let Some(w) = t.widget(radii, i, z) { + let p = to_scr(w); + painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); + painter.circle_filled(p, 2.5, accent); + } } } } diff --git a/crates/ui-egui/src/corner_radius_tests.rs b/crates/ui-egui/src/corner_radius_tests.rs index b0b47c45be..252e9ce587 100644 --- a/crates/ui-egui/src/corner_radius_tests.rs +++ b/crates/ui-egui/src/corner_radius_tests.rs @@ -184,3 +184,86 @@ fn the_cursor_and_readout_follow_the_widgets() { tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 62.0 }, Modifiers::NONE); assert!(!super::hovers(&app, [100.0, 80.0])); } + +/// The live polygon of layer `id`: its corner radius. +fn corner_radius_of(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> f64 { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Polygon { corner_radius, .. }) => corner_radius, + _ => panic!("live polygon"), + }, + _ => panic!("shape"), + } +} + +/// A live triangle (a 3-sided polygon) in (40, 40) 120 × 120, as the Triangle tool draws it. +fn triangle(tool: Tool, radius: f64) -> (PhotocraftApp, photocraft_doc::LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.run("shape.create", json!({"kind": "polygon", "rect": [40, 40, 120, 120], "sides": 3, "cornerRadius": radius})).unwrap(); + app.ui.tool = tool; + let id = app.session.active().unwrap().active_layer.unwrap(); + (app, id) +} + +/// Where the widget of corner `i` of the first target is, and the way its arc's centre moves. +fn widget_of(app: &PhotocraftApp, i: usize) -> ([f64; 2], [f64; 2]) { + let t = &super::targets(app)[0]; + let z = f64::from(app.point_zoom()); + (t.widget(t.radii, i, z).unwrap(), t.corner(i).unwrap().step) +} + +#[test] +fn a_triangle_rounds_all_its_corners_from_any_widget_in_one_step() { + for tool in [Tool::Triangle, Tool::Polygon, Tool::PathSelection] { + let (mut app, id) = triangle(tool, 6.0); + assert_eq!(super::targets(&app)[0].corners(), 3, "{tool:?}"); + let n = steps(&app); + // Drag the bottom-right widget inwards by 4 radius units along its step: 6 + 4 = 10. + let (w, step) = widget_of(&app, 1); + let to = [w[0] + step[0] * 4.0, w[1] + step[1] * 4.0]; + drag(&mut app, w, to, Modifiers::NONE); + assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9, "{tool:?}: {}", corner_radius_of(&app, id)); + assert_eq!(steps(&app), n + 1, "one history step"); + // ⌥ makes no difference: a triangle has one radius for all its corners. + let (w, step) = widget_of(&app, 0); + drag(&mut app, w, [w[0] + step[0] * 2.0, w[1] + step[1] * 2.0], Modifiers::ALT); + assert!((corner_radius_of(&app, id) - 12.0).abs() < 1e-9); + assert!(app.session.undo()); + assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9); + } +} + +#[test] +fn a_triangle_radius_is_capped_and_a_click_on_a_sharp_corner_changes_nothing() { + let (mut app, id) = triangle(Tool::PathSelection, 0.0); + let n = steps(&app); + let (w, step) = widget_of(&app, 2); + drag(&mut app, w, w, Modifiers::NONE); + assert_eq!((corner_radius_of(&app, id), steps(&app)), (0.0, n), "a click commits nothing"); + // Dragged far in: the radius stops where the arcs meet halfway along the edges. + drag(&mut app, w, [w[0] + step[0] * 1000.0, w[1] + step[1] * 1000.0], Modifiers::NONE); + let max = super::targets(&app)[0].max_radius(); + assert!(max > 0.0 && (corner_radius_of(&app, id) - max).abs() < 1e-9, "{} vs {max}", corner_radius_of(&app, id)); +} + +#[test] +fn rectangles_and_triangles_selected_together_take_the_same_radius() { + let (mut app, tri) = triangle(Tool::PathSelection, 0.0); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 170, 100, 20], "radii": 0})).unwrap(); + let rect = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": tri.0, "mode": "add"})).unwrap(); + // The rectangle's top-left widget (10 px inside, at (20, 180)), 4 px in each direction: 4. + drag(&mut app, [20.0, 180.0], [24.0, 184.0], Modifiers::NONE); + assert_eq!(radii(&app), None, "the active layer is the triangle"); + let rr = match &app.session.active().unwrap().doc.layer(rect).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => radii, + _ => panic!(), + }, + _ => panic!(), + }; + assert_eq!(rr, [4.0; 4]); + assert!((corner_radius_of(&app, tri) - 4.0).abs() < 1e-9); +} diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index a0b5c61596..d5dc01ebfc 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -76,7 +76,7 @@ fn from_center_and_the_other_tools() { let id = only_dialog(&app); set(&mut app, id, json!({"width": 50, "height": 60, "sides": 7, "starRatio": 50})); crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5 }); + assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5, corner_radius: 0.0 }); press(&mut app, Tool::Triangle, [5.0, 6.0], [5.0, 6.0]); let id = only_dialog(&app); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index fe6db74079..86b10dd929 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1555,7 +1555,14 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }); } } - photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, .. } => { + photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, corner_radius, .. } => { + // One radius rounds every vertex of a triangle, polygon or star, as in Photoshop. + ui.horizontal(|ui| { + let mut r = *corner_radius as f32; + if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") { + edit = Some(json!({"cornerRadius": r, "coalesce": key("corner-radius")})); + } + }); ui.horizontal(|ui| { let mut n = *sides as f32; if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index b154a47325..dfeae26e81 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -111,6 +111,101 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P Path::new(vec![Subpath::polygon(&pts)]) } +/// A rounded vertex: see [`fillet`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Fillet { + /// The arc's centre and its tangent points on the edge to the previous and the next vertex. + pub centre: [f64; 2], + pub from: [f64; 2], + pub to: [f64; 2], + /// The radius used (the one asked for, capped at what the edges allow). + pub radius: f64, + /// Unit direction from the vertex towards the arc's centre, and half the vertex's angle. + pub bisector: [f64; 2], + pub half_angle: f64, +} + +/// Where a vertex's rounding sits: the vertex `v` between neighbours `prev` and `next`, rounded +/// with radius `r`, capped so its tangent points stay within half of each edge (two neighbouring +/// roundings never overlap). `None` for `r <= 0` or a degenerate vertex (a zero-length edge, or +/// a straight or folded angle). +pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option { + let unit = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy); + (l > 1e-9).then(|| ([dx / l, dy / l], l)) + }; + if r.is_nan() || r <= 0.0 { + return None; + } + let (u1, l1) = unit(v, prev)?; + let (u2, l2) = unit(v, next)?; + let cos = (u1[0] * u2[0] + u1[1] * u2[1]).clamp(-1.0, 1.0); + let half = cos.acos() / 2.0; + if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-6 { + return None; + } + let r = r.min(l1.min(l2) / 2.0 * half.tan()); + let t = r / half.tan(); + let (bx, by) = (u1[0] + u2[0], u1[1] + u2[1]); + let bl = bx.hypot(by); + if bl < 1e-9 { + return None; + } + let bisector = [bx / bl, by / bl]; + let c = r / half.sin(); + Some(Fillet { + centre: [v[0] + bisector[0] * c, v[1] + bisector[1] * c], + from: [v[0] + u1[0] * t, v[1] + u1[1] * t], + to: [v[0] + u2[0] * t, v[1] + u2[1] * t], + radius: r, + bisector, + half_angle: half, + }) +} + +/// `path` (closed subpaths of corner knots, as [`polygon`] makes) with every vertex rounded by a +/// circular arc of radius `r` tangent to both its edges, each capped as [`fillet`] does, like +/// Photoshop's rounded triangles and polygons. `r <= 0` leaves it sharp. +pub fn round_corners(path: Path, r: f64) -> Path { + if r.is_nan() || r <= 0.0 { + return path; + } + let subpaths = path + .subpaths + .into_iter() + .map(|sp| { + let n = sp.knots.len(); + if n < 3 || !sp.closed { + return sp; + } + let pt = |i: usize| sp.knots.get(i % n).map_or([0.0; 2], |k| [k.anchor.x, k.anchor.y]); + let mut knots = Vec::with_capacity(n * 2); + for i in 0..n { + let (prev, v, next) = (pt(i + n - 1), pt(i), pt(i + 1)); + let Some(f) = fillet(prev, v, next, r) else { + knots.push(Knot::corner(v[0], v[1])); + continue; + }; + // The arc as one cubic: handles 4/3 tan(sweep/4) r long, along the tangents + // towards the vertex; the sweep is pi minus the vertex's angle. + let sweep = std::f64::consts::PI - 2.0 * f.half_angle; + let h = 4.0 / 3.0 * (sweep / 4.0).tan() * f.radius; + let toward = |a: [f64; 2]| { + let (dx, dy) = (v[0] - a[0], v[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + Point::new(a[0] + dx / l * h, a[1] + dy / l * h) + }; + let (from, to) = (Point::new(f.from[0], f.from[1]), Point::new(f.to[0], f.to[1])); + knots.push(Knot { anchor: from, in_ctrl: from, out_ctrl: toward(f.from), smooth: false }); + knots.push(Knot { anchor: to, in_ctrl: toward(f.to), out_ctrl: to, smooth: false }); + } + Subpath { knots, ..sp } + }) + .collect(); + Path { subpaths, ..path } +} + /// A line drawn as a filled bar of `weight` px from `a` to `b`. pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path { let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -125,7 +220,9 @@ pub fn live_path(s: &LiveShape) -> Path { match s { LiveShape::Rect { rect: [x, y, w, h], radii } => rounded_rect(*x, *y, *w, *h, *radii), LiveShape::Ellipse { rect: [x, y, w, h] } => ellipse(*x, *y, *w, *h), - LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio } => polygon(*x, *y, *w, *h, *sides, *star_ratio), + LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio, corner_radius } => { + round_corners(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius) + } LiveShape::Line { from, to, weight } => line((from[0], from[1]), (to[0], to[1]), *weight), } } @@ -134,6 +231,59 @@ pub fn live_path(s: &LiveShape) -> Path { mod tests { use super::*; + /// The point at `t` of the cubic leaving `a` towards `b`. + fn at(a: &Knot, b: &Knot, t: f64) -> [f64; 2] { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + let p = [a.anchor, a.out_ctrl, b.in_ctrl, b.anchor]; + [p.iter().zip(w).map(|(p, w)| p.x * w).sum(), p.iter().zip(w).map(|(p, w)| p.y * w).sum()] + } + + #[test] + fn a_rounded_triangle_has_one_arc_per_vertex_on_a_true_circle() { + let sharp = polygon(0.0, 0.0, 300.0, 300.0, 3, 1.0); + assert_eq!(sharp.subpaths[0].knots.len(), 3); + let p = round_corners(sharp.clone(), 20.0); + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 6, "two knots per rounded vertex"); + // Each arc stays 20 px from its centre along its length (one cubic, within 0.1 px). + let verts: Vec<[f64; 2]> = sharp.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + for i in 0..3 { + let f = fillet(verts[(i + 2) % 3], verts[i], verts[(i + 1) % 3], 20.0).unwrap(); + assert!((f.radius - 20.0).abs() < 1e-9); + assert_eq!([k[2 * i].anchor.x, k[2 * i].anchor.y], f.from); + for t in [0.25, 0.5, 0.75] { + let q = at(&k[2 * i], &k[2 * i + 1], t); + let d = (q[0] - f.centre[0]).hypot(q[1] - f.centre[1]); + assert!((d - 20.0).abs() < 0.1, "vertex {i} t {t}: {d}"); + } + } + // The edges between the arcs stay straight. + assert_eq!((k[1].out_ctrl, k[2].in_ctrl), (k[1].anchor, k[2].anchor)); + } + + #[test] + fn huge_radii_are_capped_and_zero_stays_sharp() { + let sharp = polygon(0.0, 0.0, 100.0, 100.0, 6, 1.0); + assert_eq!(round_corners(sharp.clone(), 0.0), sharp); + assert_eq!(round_corners(sharp.clone(), f64::NAN), sharp); + let p = round_corners(sharp, 1.0e9); + // Capped: the tangent points of neighbouring vertices meet halfway along each edge. + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 12); + for w in k.windows(2).skip(1).step_by(2) { + let d = (w[0].anchor.x - w[1].anchor.x).hypot(w[0].anchor.y - w[1].anchor.y); + assert!(d < 1e-6, "no straight left between arcs: {d}"); + } + assert!(k.iter().all(|k| k.anchor.x.is_finite() && k.anchor.y.is_finite())); + } + + #[test] + fn a_star_rounds_its_inner_vertices_too() { + let p = live_path(&LiveShape::Polygon { rect: [0.0, 0.0, 200.0, 200.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 }); + assert_eq!(p.subpaths[0].knots.len(), 20); + } + #[test] fn rounded_rect_knots() { let p = rounded_rect(0.0, 0.0, 100.0, 50.0, [10.0; 4]); diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..174ecf8100 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -156,7 +156,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7fbe8932cc..23ef1452d0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (rounded triangle, polygon and star corners; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -123,7 +123,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | -| On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | +| On-canvas corner-radius handles on live shapes | done: every selected live rectangle, triangle, polygon and star shows a widget at each arc centre with Path Selection, the Rectangle, Triangle and Polygon tools and Move with Show Transform Controls; a drag rounds every corner of every selected shape to the same radius, ⌥ one corner of a rectangle (a triangle or polygon has one radius), with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -253,3 +253,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Pen ⇧ 45° done: ⇧-click puts the next anchor on the nearest 45° ray, ⇧-drag turns its handles in 45° steps (TOOL-214-10 done, #3751) | | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | +| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..9ed74a2fcc 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -192,7 +192,7 @@ id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" status = "partial" issue = 214 -note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" +note = "On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" [[item]] id = "TOOL-214-13" From d7828bda6f25f163e99758711fea1c123166fc8d Mon Sep 17 00:00:00 2001 From: EuanTop Date: Fri, 16 Oct 2026 00:01:16 +0800 Subject: [PATCH 03/23] Add hex input to shape Fill and Stroke popovers --- crates/ui-egui/src/vector_paint_tests.rs | 78 ++++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 31 ++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index c62277fb1e..f5c7f23cdb 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -94,6 +94,84 @@ fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_pain } } +#[test] +fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + let original = [0.123456, 0.345678, 0.567891, 0.372819]; + app.run("tools.setColors", json!({"foreground": original, "background": original})).unwrap(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + for ch in "12AB".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + assert_eq!(h.get_by_label("#").value().as_deref(), Some("12AB")); + assert_eq!(h.state().session.tools.foreground, original); + assert_eq!(h.state().session.tools.background, original); + for ch in "EF".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, original[3]]; + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + assert_eq!(if label == "Fill color" { tools.background } else { tools.foreground }, original); + // An invalid/multi-byte value leaves the selected paint untouched. + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Text("颜色".into())); + h.run_steps(2); + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + h.key_press(Key::Escape); + h.run_steps(2); + finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let drawn = shape(h.state()); + let paint = if label == "Fill color" { drawn.fill.as_ref().and_then(color_of) } else { drawn.stroke.as_ref().and_then(|s| color_of(&s.paint)) }; + assert_eq!(paint, Some(want), "the next shape takes the typed colour"); + } +} + +#[test] +fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + app.run("tools.setColors", json!({"foreground": [0.2, 0.3, 0.4, 0.6], "background": [0.5, 0.6, 0.7, 0.8]})).unwrap(); + app.ui.tool_options.stroke_width = 3.0; + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let original = shape(&app).clone(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Paste("#12ABEF".into())); + h.run_steps(2); + let edited = shape(h.state()); + let (paint, untouched) = if label == "Fill color" { + (edited.fill.as_ref().and_then(color_of), edited.stroke == original.stroke) + } else { + (edited.stroke.as_ref().and_then(|s| color_of(&s.paint)), edited.fill == original.fill) + }; + let alpha = if label == "Fill color" { 0.6 } else { 0.8 }; + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, alpha]; + assert_eq!(paint, Some(want)); + assert!(untouched, "the other paint stays unchanged"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + } +} + #[test] fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() { let mut app = app("rgb", 32); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 01bb3c133b..572a3f3dbc 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1431,6 +1431,37 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 out = Some(PaintChange::Color(color)); } } + ui.horizontal(|ui| { + let label = ui.label(egui::RichText::new("#").color(t.text_dim)); + let id = resp.id.with("shape-paint-hex"); + let selected = match out { + Some(PaintChange::Color(color)) => color, + _ => original, + }; + // Keep incomplete typing across frames; deriving the field from the colour each + // frame would discard every keystroke until all six digits were present. + let mut text = ui + .data(|d| d.get_temp::<([f32; 4], String)>(id)) + .filter(|(previous, _)| *previous == selected) + .map_or_else(|| hex(selected).trim_start_matches('#').to_ascii_uppercase(), |(_, text)| text); + let response = crate::widgets::text_field(ui, id, &mut text, "RRGGBB", 90.0, true).labelled_by(label.id); + if response.changed() + && let Some(rgb) = crate::color_picker_ui::parse_hex(&text) + { + let color = [rgb[0], rgb[1], rgb[2], selected[3]]; + out = Some(PaintChange::Color(color)); + hsva = picker_color(color); + } + if response.has_focus() { + let color = match out { + Some(PaintChange::Color(color)) => color, + _ => selected, + }; + ui.data_mut(|d| d.insert_temp(id, (color, text))); + } else { + ui.data_mut(|d| d.remove::<([f32; 4], String)>(id)); + } + }); if let Some(rgb) = crate::screen_picker::button(ui, screen_id) { out = Some(sampled(rgb)); hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6edff953df..3e111a4ec1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (shape Fill/Stroke popovers accept hex RGB values, #6176; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -126,6 +126,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | +| Shape options-bar Fill/Stroke hex input | done | #6176; `vector_ui.rs`, `vector_paint_tests.rs`: typed and pasted hex, partial/invalid input, alpha retention, next-shape defaults and selected-shape Undo | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Shape Fill/Stroke compact pickers accept six-digit hex RGB, including pasted values with `#`, retaining alpha and high-depth paint until an edit; typed/pasted colour and history regressions (#6176). Scores unchanged. | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From a1502432e5257b7bed9d926fbb3560a3050065dc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 19:47:53 +0200 Subject: [PATCH 04/23] Paths panel: rename a path in place, and name the Work Path when saving it (part of #6238) --- crates/ui-egui/src/i18n/cs.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/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/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/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/vector_ui.rs | 167 ++++++++++++++++++++++++---- 21 files changed, 168 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6c8f21418a..6bb36f9b71 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2862,6 +2862,7 @@ cameraData Lens Objektiv Duplicate Layer Comp Duplikovat kompozici vrstev Duplicate Layers Duplikovat vrstvy Duplicate Path Duplikovat cestu + Rename Path… Přejmenovat cestu… Duplication Duplikace Duration Doba trvání Dust & Scratches Prach a škrábance diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2bb1c57f19..25c5b8ff63 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2245,6 +2245,7 @@ Fill Path Pfad füllen Stroke Path Pfad nachziehen Delete Path Pfad löschen + Rename Path… Pfad umbenennen… W B H H Ruler Lineal diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 48d23d13b9..33f2f4f023 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2348,6 +2348,7 @@ Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας Dual Brush Διπλό πινέλο Duplicate Path Διπλασιασμός διαδρομής + Rename Path… Μετονομασία διαδρομής… Duplicate style Διπλασιασμός στυλ Dust & Scratches Σκόνη και γρατζουνιές Dust & Scratches… Σκόνη και γρατζουνιές… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a7338795aa..97d051088a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2936,6 +2936,7 @@ curves Smooth Suavizar Clipping Path Trazado de recorte New Path Nuevo trazado Duplicate Path Duplicar trazado + Rename Path… Cambiar nombre del trazado… Auto-Hide Ocultar automáticamente X X Y Y diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba1de7b706..918c6dea3a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2366,6 +2366,7 @@ Clipping Path Tracé de détourage New Path Nouveau tracé Duplicate Path Dupliquer le tracé + Rename Path… Renommer le tracé… Delete Path Supprimer le tracé Auto-Hide Masquage automatique W L diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e26f3ec3b8..620702ac8e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3193,6 +3193,7 @@ cameraRaw Yellow Sárga Drop an image or PSD anywhere to open it. Húzz ide bárhová egy képet vagy PSD-fájlt a megnyitásához. Duplicate Layer Comp Rétegkompozíció kettőzése Duplicate Path Útvonal kettőzése + Rename Path… Útvonal átnevezése… Duplication Kettőzés East asian Kelet-ázsiai Edge Extension Szélek kiterjesztése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d13db9f0cf..86e920709c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2357,6 +2357,7 @@ Clipping Path Clipping Path New Path Path Baru Duplicate Path Duplikat Path + Rename Path… Ganti Nama Path… Delete Path Hapus Path Auto-Hide Sembunyi Otomatis W W diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1779c1259e..6e89a84655 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2928,6 +2928,7 @@ curves Smooth Leviga Clipping Path Tracciato di ritaglio New Path Nuovo tracciato Duplicate Path Duplica tracciato + Rename Path… Rinomina tracciato… Auto-Hide Nascondi automaticamente X X Y Y diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd0cca727f..33e2ea9a24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2872,6 +2872,7 @@ fontWeight Italic 斜体 Duplicate Layer Comp レイヤーカンプを複製 Duplicate Layers レイヤーを複製 Duplicate Path パスを複製 + Rename Path… パス名を変更… Dust & Scratches ダスト&スクラッチ EXIF EXIF Export As 書き出し形式を指定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f5c243d1e5..5eea8a2983 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1390,6 +1390,7 @@ Rename 이름 바꾸기 Rename Group… 그룹 이름 바꾸기… Rename Layer… 레이어 이름 바꾸기… + Rename Path… 패스 이름 바꾸기… Rename Tool Preset… 도구 사전 설정 이름 바꾸기… Rename… 이름 바꾸기… Resample 리샘플링 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index aea44757ca..161ba124f2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3347,6 +3347,7 @@ cameraRaw Color Kleur Do you want to save you made to “{name}” before quitting? Wil je de wijzigingen in “{name}” opslaan voordat je afsluit? Dodge/Burn Tegenhouden/doordrukken Duplicate Path Pad dupliceren + Rename Path… Pad hernoemen… Dust & Scratches Stof en krassen Dynamics Dynamiek Edit Plane Vlak bewerken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17feb459aa..c7891648fe 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2389,6 +2389,7 @@ Clipping Path Ścieżka przycinająca New Path Nowa ścieżka Duplicate Path Duplikuj ścieżkę + Rename Path… Zmień nazwę ścieżki… Delete Path Usuń ścieżkę Auto-Hide Ukrywanie automatyczne W Sz. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e9559c80e..b42fc3710c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2361,6 +2361,7 @@ Clipping Path Demarcador de corte New Path Novo demarcador Duplicate Path Duplicar demarcador + Rename Path… Renomear demarcador… Delete Path Excluir demarcador Auto-Hide Ocultar automaticamente W L diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..b602fa9c26 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1190,6 +1190,7 @@ Clipping Path Обтравочный контур New Path Новый контур Duplicate Path Дублировать контур + Rename Path… Переименовать контур… Delete Path Удалить контур Current Path Текущий контур Vector Mask from Current Path Векторная маска из текущего контура diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4a8552fb0..ecfc33a541 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2343,6 +2343,7 @@ Clipping Path Putanja isecanja New Path Nova putanja Duplicate Path Dupliraj putanju + Rename Path… Preimenuj putanju… Delete Path Obriši putanju Auto-Hide Automatski sakrij W Š diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b11f51a807..bdd8a2f8e1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3319,6 +3319,7 @@ cameraRaw Color Renk Do you want to save you made to “{name}” before quitting? Çıkmadan önce yaptığınız işi “{name}” konumuna kaydetmek istiyor musunuz? Dodge/Burn Soldurma/Yakma Duplicate Path Yinelenen Yol + Rename Path… Yolu Yeniden Adlandır… Dust & Scratches Toz ve Çizikler Dynamics Dinamik Edit Plane Düzlemi Düzenle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e563354fdf..79daa22097 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1165,6 +1165,7 @@ Clipping Path Відсічний контур New Path Новий контур Duplicate Path Дублювати контур + Rename Path… Перейменувати контур… Delete Path Видалити контур Current Path Поточний контур Vector Mask from Current Path Векторна маска з поточного контуру diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6907be96aa..97ea1d458e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1272,6 +1272,7 @@ Stroke Path Đi nét đường dẫn Save Path Lưu đường dẫn Duplicate Path Nhân bản đường dẫn + Rename Path… Đổi tên đường dẫn… Delete Path Xóa đường dẫn Delete Vector Mask Xóa mặt nạ vector Draw with the Pen tool (P) or make a work path from a selection. Vẽ bằng công cụ Bút (P) hoặc tạo đường dẫn làm việc từ vùng chọn. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3962f46d65..3b3b5098ff 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1430,6 +1430,7 @@ Rename 重命名 Rename Group… 重命名组… Rename Layer… 重命名图层… + Rename Path… 重命名路径… Rename Tool Preset… 重命名工具预设… Rename… 重命名… Resample 重新采样 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 005c992e23..5d96e20346 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1427,6 +1427,7 @@ Rename 重新命名 Rename Group… 重新命名群組… Rename Layer… 重新命名圖層… + Rename Path… 重新命名路徑… Rename Tool Preset… 重新命名工具預設集… Rename… 重新命名… Resample 重新取樣 diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 01bb3c133b..b6aff620a3 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1690,6 +1690,32 @@ pub fn paths_footer(ctx: &egui::Context) -> Option { ctx.data(|d| d.get_temp(footer_id())) } +/// Photoshop's default name for a saved path: the first free "Path N", counting from the number of +/// saved paths. +fn free_path_name(doc: &Document) -> String { + let mut n = doc.paths.len().saturating_add(1); + while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { + n = n.saturating_add(1); + if n == usize::MAX { + break; + } + } + format!("Path {n}") +} + +/// A Paths panel name being typed in place: the row's key ("work" or the saved path's name) and +/// the text so far. It lives in egui's temp data, like the Channels panel's rename field. +#[derive(Clone)] +struct PathRename { + key: String, + text: String, + focused: bool, +} + +fn rename_id(doc: &Document) -> egui::Id { + egui::Id::new(("path-rename", doc.id.0)) +} + /// Commands offered by a Paths row's context menu. The row's path is passed explicitly so a /// right-click acts on that row even when another path is selected in the panel. fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, &'static str, Value)> { @@ -1704,14 +1730,7 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, ("Stroke Path…", "path.stroke", json!({"name": key})), ]; if entry.kind == PathRow::Work { - let mut n = doc.paths.len().saturating_add(1); - while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { - n = n.saturating_add(1); - if n == usize::MAX { - break; - } - } - actions.push(("Save Path", "path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + actions.push(("Save Path", "path.rename", json!({"name": "work", "to": free_path_name(doc)}))); } else { let mut n = 1usize; while doc.paths.iter().any(|p| p.name == format!("{} copy {n}", entry.name)) { @@ -1723,6 +1742,10 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, let copy_name = format!("{} copy {n}", entry.name); actions.push(("Duplicate Path", "path.set", json!({"name": copy_name, "path": photocraft_engine::vector_cmds::path_json(&entry.path)}))); } + if entry.kind == PathRow::Saved { + // No `to`: the panel asks for the new name in place. + actions.push(("Rename Path…", "path.rename", json!({"name": key}))); + } if entry.kind != PathRow::Layer { actions.push(("Delete Path", "path.delete", json!({"name": key}))); } else if entry.name.ends_with(" Vector Mask") { @@ -1742,6 +1765,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; let mut stroke_dialog: Option = None; + let rename_id = rename_id(&doc); // The buttons sit in a footer at the panel's bottom, like Photoshop's. let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; @@ -1777,8 +1801,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 0.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics, ..Default::default() }, ); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + let renaming = ui.ctx().data(|d| d.get_temp::(rename_id)).filter(|e| e.key == key); + if let Some(mut edit) = renaming { + let field = Rect::from_min_max(pos2(r.left() + 43.0, r.center().y - 11.0), pos2(r.right() - 8.0, r.center().y + 11.0)); + let id = rename_id.with("field"); + let te = ui.put(field, egui::TextEdit::singleline(&mut edit.text).id(id).font(egui::FontId::proportional(12.0))); + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if !edit.focused { + te.request_focus(); + // Like Photoshop, the whole name starts selected so typing replaces it. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(edit.text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + edit.focused = true; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } else if esc { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + } else if enter || te.lost_focus() || !te.has_focus() { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + let to = edit.text.trim(); + // The Work Path is saved even under its proposed name; an unchanged saved name is left alone. + if !to.is_empty() && (*kind == PathRow::Work || to != name) { + action = Some(("path.rename", json!({"name": key, "to": to}))); + } + } else { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } + } else { + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + } // Ctrl/Cmd-click loads only the thumbnail's path, without changing the panel target. let modifiers = ui.input(|i| i.modifiers); let load = resp.clicked() && modifiers.command && resp.interact_pointer_pos().is_some_and(|p| thumbnail.contains(p)); @@ -1791,10 +1845,18 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.clicked() { app.ui.selected_path = Some(key.clone()); } - // Double-clicking the Work Path saves it (Photoshop's "Save Path"). - if !load && resp.double_clicked() && *kind == PathRow::Work { - let n = doc.paths.len() + 1; - action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + // As in Photoshop, double-clicking a saved path's name renames it, and double-clicking + // the Work Path asks for the name to save it under (#6238). + let proposed = match kind { + PathRow::Work => Some(free_path_name(&doc)), + PathRow::Saved => Some(name.clone()), + PathRow::Layer => None, + }; + if !load + && resp.double_clicked() + && let Some(text) = proposed + { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, PathRename { key: key.clone(), text, focused: false })); } resp.context_menu(|ui| { crate::widgets::menu_scroll(ui, |ui| { @@ -1807,6 +1869,9 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if cmd == "path.stroke" { // Stroke Path… asks for the tool first, as in Photoshop (#2978). stroke_dialog = Some(params.get("name").and_then(Value::as_str).unwrap_or("work").to_string()); + } else if cmd == "path.rename" && params.get("to").is_none() { + let edit = PathRename { key: key.clone(), text: name.clone(), focused: false }; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); } else { action = Some((cmd, params)); } @@ -1869,11 +1934,20 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(name) = stroke_dialog { crate::canvas_tool_menu::stroke_path_dialog(app, &name); } - if let Some((cmd, p)) = action - && let Err(e) = app.run(cmd, p) - { - app.ui.status = e; - app.ui.status_error = true; + if let Some((cmd, p)) = action { + let renamed = if cmd == "path.rename" { p.get("to").and_then(Value::as_str).map(str::to_string) } else { None }; + match app.run(cmd, p) { + // The renamed (or just saved) path stays the panel's target. + Ok(_) => { + if renamed.is_some() { + app.ui.selected_path = renamed; + } + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } } } @@ -2017,6 +2091,61 @@ mod tests { assert!(!entries.iter().any(|(_, id, _)| *id == "path.delete")); } + /// #6238: double-clicking a saved path's name renames it in place, and double-clicking the + /// Work Path saves it under the name typed (it used to save it as "Path N" straight away). + #[test] + fn double_clicking_a_path_name_renames_it_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"name": "First", "path": path})).unwrap(); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| paths_panel(app, ui), app); + h.run_steps(3); + let rename = |h: &mut Harness<'_, PhotocraftApp>, row: &str, text: &str| { + let name = h.get_by_label(row).rect().left_center() + vec2(60.0, 0.0); + h.event(egui::Event::PointerMoved(name)); + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.paths.iter().map(|p| p.name.clone()).collect::>(); + rename(&mut h, "First", "Outline"); + assert_eq!(names(&h), ["Outline"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_some(), "renaming a saved path leaves the work path alone"); + rename(&mut h, "Work Path", "Circle"); + assert_eq!(names(&h), ["Outline", "Circle"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_none()); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Circle")); + } + + /// #6238: a saved path's context menu offers Rename Path…, as the Layers panel's does. + #[test] + fn saved_path_context_menu_offers_rename() { + let mut app = app(); + app.run("path.set", json!({"name": "First", "path": {"subpaths": []}})).unwrap(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": []}})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let rename = |kind| { + let entry = path_rows(doc, None).into_iter().find(|r| r.kind == kind).unwrap(); + path_context_actions(&entry, doc).into_iter().find(|(label, _, _)| *label == "Rename Path…").map(|(_, id, p)| (id, p)) + }; + assert_eq!(rename(PathRow::Saved), Some(("path.rename", json!({"name": "First"})))); + assert_eq!(rename(PathRow::Work), None, "the work path is saved, not renamed"); + } + /// How many shapes the path overlay paints for `app` this frame. fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize { let doc = app.session.active().unwrap().doc.clone(); From 4e131ecf2d37fc75259187b9372af7c062de96f3 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Wed, 14 Oct 2026 12:45:33 +0700 Subject: [PATCH 05/23] fix(pen): an unclosed path stays with its document (#5373) Leaving a path open with the Pen and creating a new document showed that path on the new document: `UiState::pen` had no document owner, so it survived the switch. Follow-up to #5015, which did the same for the Polygonal Lasso. The path now remembers the document it was started on (`UiState::pen_doc`, set at its first anchor by `pen_down` and `curvature_down`) and is dropped when another document becomes active, as the Polygonal Lasso and the Crop tool already do. `cancel_stale` also runs at the top of `pen_commit`: the control channel is drained after `sync_views` (`logic` calls them in that order), so a document switch in the same frame would otherwise commit the path into the wrong document. The Crop tool guards its commit path the same way. Adds a regression test that File > New drops the path, that a switch before the commit writes it to neither document, and that it still commits to the document it was drawn on. --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/src/vector_ui.rs | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a981c41f3..152328656f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1191,6 +1191,8 @@ impl PhotocraftApp { // ... and to the document it was started on. crate::canvas::polygon_cancel_stale(self); crate::crop_ui::cancel_stale(self); + // A pen path under construction belongs to the document it was started on too. + vector_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. let now = |i: usize| match self.view_docs.get(i) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..5d690beba1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1150,6 +1150,10 @@ pub struct UiState { /// Pen path under construction. #[serde(default)] pub pen: Option, + /// The document the pen path is drawn on (set at its first anchor): another document becoming + /// active drops it (`vector_ui::cancel_stale`). + #[serde(default)] + pub pen_doc: Option, #[serde(default)] pub stroke_editor: Option, /// Direct Selection tool: selected anchors and the drag in progress (#790). @@ -1346,6 +1350,7 @@ impl Default for UiState { layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), pen: None, + pen_doc: None, stroke_editor: None, direct_selection: Default::default(), corner_radius: None, diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..e89a9c7faa 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -223,6 +223,9 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) @@ -423,8 +426,26 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } +/// The path in progress belongs to the document it was started on (`UiState::pen_doc`): once +/// another document becomes active it is dropped, so it doesn't show on (or get committed to) the +/// other document. Switching tools is handled in `PhotocraftApp::logic`, which commits the path +/// to its own document. Like the Polygonal Lasso's ([`crate::canvas::polygon_cancel_stale`]) and +/// the Crop tool's ([`crate::crop_ui::cancel_stale`]). +pub fn cancel_stale(app: &mut PhotocraftApp) { + let active = app.session.active().map(|st| st.doc.id); + if app.ui.pen.is_none() { + app.ui.pen_doc = None; + } else if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { + app.ui.pen = None; + app.ui.pen_doc = None; + } +} + /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { + // The control channel is drained after `sync_views`, so a document switch in this frame has + // not been seen yet: never commit the path into the document it was not drawn on. + cancel_stale(app); let Some(mut pen) = app.ui.pen.take() else { return }; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. @@ -600,6 +621,9 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { pen_commit(app, true); return; } + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); if hit.is_none() { pen.knots.push([[x, y]; 3]); @@ -2236,6 +2260,39 @@ mod tests { let k = app.ui.pen.as_ref().unwrap().knots[2]; assert!(k.iter().all(|p| *p == [200.0, 100.0]), "{k:?}"); pen_up(&mut app); + + /// #5373: an unclosed path belongs to the document it was drawn on. File › New opened a blank + /// document that showed the first document's path; a document switch between `sync_views` and + /// the commit also committed it into the wrong document. Like the Polygonal Lasso's #5015. + #[test] + fn an_unclosed_path_stays_with_its_document() { + let open_path = |app: &mut PhotocraftApp| { + app.ui.tool = Tool::Pen; + for (x, y) in [(20.0, 20.0), (120.0, 60.0)] { + pen_down(app, x, y, false); + pen_up(app); + } + }; + let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); + // File › New drops the first document's path instead of carrying it over. + let mut app = app(); + open_path(&mut app); + assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "the path is claimed by its document"); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "the first document's path is gone"); + assert_eq!(work(&app, 1), None, "the new document is blank"); + // The control channel is drained after `sync_views`, so a switch in the same frame reaches + // the commit unseen: it must not write the path into the other document. + open_path(&mut app); + assert!(app.session.set_active(0)); + pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert_eq!((work(&app, 0), work(&app, 1)), (None, None), "neither document took it"); + // Staying on its own document, the path still commits there. + open_path(&mut app); + pen_commit(&mut app, false); + assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); + assert_eq!(work(&app, 1), None); } /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new From 590787b84825b5bae41c69fa3212ca9249c8def6 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Wed, 14 Oct 2026 15:28:29 +0700 Subject: [PATCH 06/23] fix(pen): drop the Direct Selection with the stale path `pen_commit` resets `ui.direct_selection` when it was targeting the Pen's path; `cancel_stale` dropped the path without it, leaving the target pointing at anchors that no longer exist. Spotted by @Daktyl198 on #5412. The test now drives the Curvature Pen as well as the Pen through File > New, and covers the Direct Selection reset. --- crates/ui-egui/src/vector_ui.rs | 36 ++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index e89a9c7faa..0f190fb036 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -438,6 +438,10 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { } else if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { app.ui.pen = None; app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + // Its anchors went with it, as on commit. + app.ui.direct_selection = Default::default(); + } } } @@ -2266,21 +2270,33 @@ mod tests { /// the commit also committed it into the wrong document. Like the Polygonal Lasso's #5015. #[test] fn an_unclosed_path_stays_with_its_document() { + // Both pens claim the document at their first anchor. let open_path = |app: &mut PhotocraftApp| { - app.ui.tool = Tool::Pen; for (x, y) in [(20.0, 20.0), (120.0, 60.0)] { - pen_down(app, x, y, false); - pen_up(app); + if app.ui.tool == Tool::CurvaturePen { + curvature_down(app, x, y); + curvature_up(app); + } else { + pen_down(app, x, y, false); + pen_up(app); + } } }; let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); // File › New drops the first document's path instead of carrying it over. + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + open_path(&mut app); + assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document"); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone"); + assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank"); + } + // Two documents; the path below is drawn on the second (active) one. let mut app = app(); - open_path(&mut app); - assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "the path is claimed by its document"); + app.ui.tool = Tool::Pen; app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); - assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "the first document's path is gone"); - assert_eq!(work(&app, 1), None, "the new document is blank"); // The control channel is drained after `sync_views`, so a switch in the same frame reaches // the commit unseen: it must not write the path into the other document. open_path(&mut app); @@ -2293,6 +2309,12 @@ mod tests { pen_commit(&mut app, false); assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); assert_eq!(work(&app, 1), None); + // A Direct Selection on the path goes with it, as on commit: no target left dangling. + open_path(&mut app); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + app.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path"); } /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new From a528b9993e3d8e1ffc4908b06c0491f51f4390a4 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Thu, 15 Oct 2026 08:26:23 +0700 Subject: [PATCH 07/23] fix(pen): guard every entry point against a stale path Review on #5412: `sync_views` and `pen_commit` left three ways for a control batch to reach another document's path in the same frame, before the next sync. `cancel_stale` now also runs at the top of `pen_down`, `curvature_down` and `pen_undo_last_point`, and `draw_overlay` draws the path only on the document it was started on, as #5427 does. The regression test covers all three entry points after a document switch and the overlay, comparing the same view with and without the path so the count measures the path alone. Co-authored-by: Daktyl198 <2475672+Daktyl198@users.noreply.github.com> Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> --- crates/ui-egui/src/vector_ui.rs | 59 ++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 0f190fb036..80f45e5d4d 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -222,6 +222,7 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { + cancel_stale(app); let tol = 6.0 / app.point_zoom().max(0.01) as f64; if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); @@ -288,6 +289,7 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { + cancel_stale(app); let Some(pen) = app.ui.pen.as_mut() else { return false }; if pen.knots.pop().is_none() { return false; @@ -617,6 +619,7 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { /// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor /// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { + cancel_stale(app); if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { pen_commit(app, false); } @@ -1049,7 +1052,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While // ⌘/Ctrl drags it, Direct Selection draws its preview instead. let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. })); - if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited) { + // Only on the document it is drawn on: a view of another document never shows it (#5373). + let active = app.session.active().map(|st| st.doc.id); + if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| Some(d) == active)) { let mut pts = Vec::new(); // A close-drag shows the first segment bending with the handle being pulled out. let mut knots = pen.knots.clone(); @@ -2264,6 +2269,7 @@ mod tests { let k = app.ui.pen.as_ref().unwrap().knots[2]; assert!(k.iter().all(|p| *p == [200.0, 100.0]), "{k:?}"); pen_up(&mut app); + } /// #5373: an unclosed path belongs to the document it was drawn on. File › New opened a blank /// document that showed the first document's path; a document switch between `sync_views` and @@ -2309,6 +2315,57 @@ mod tests { pen_commit(&mut app, false); assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); assert_eq!(work(&app, 1), None); + // A control batch can switch documents and act on the path in the same frame, before the + // next `sync_views`: no entry point extends, undoes or keeps a stale path (#5427). + for act in [0, 1, 2] { + open_path(&mut app); + assert!(app.session.set_active(1)); + match act { + 0 => pen_down(&mut app, 50.0, 50.0, false), + 1 => curvature_down(&mut app, 50.0, 50.0), + _ => assert!(!pen_undo_last_point(&mut app), "nothing of this document's to undo"), + } + let knots = app.ui.pen.as_ref().map_or(0, |p| p.knots.len()); + assert!(knots <= 1, "act {act}: a new path, not the other document's (knots {knots})"); + // The two drawing entry points start a path of their own here; undo has none to take. + let expected = if act == 2 { None } else { app.session.active().map(|st| st.doc.id) }; + assert_eq!(app.ui.pen_doc, expected, "act {act}"); + app.ui.pen = None; + app.ui.pen_doc = None; + assert!(app.session.set_active(0)); + } + // The overlay draws the path only on its own document, so a view of the other one in the + // same frame shows nothing (#5427). + open_path(&mut app); + let shapes = |app: &PhotocraftApp| { + let ctx = egui::Context::default(); + let doc = app.session.active().unwrap().doc.clone(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 75.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes.len() + }; + // Same view, pen present vs absent: the difference is the path itself, nothing else. + let drawn = |app: &mut PhotocraftApp| { + let with_pen = shapes(app); + let pen = app.ui.pen.take(); + let without = shapes(app); + app.ui.pen = pen; + with_pen - without + }; + assert!(drawn(&mut app) > 0, "its own document draws the path"); + assert!(app.session.set_active(1)); + assert_eq!(drawn(&mut app), 0, "the other document draws nothing of it"); + assert!(app.session.set_active(0)); + app.ui.pen = None; + app.ui.pen_doc = None; // A Direct Selection on the path goes with it, as on commit: no target left dangling. open_path(&mut app); app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); From 89d158c98ac465dca0dddaaf6584447dcba737e1 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Fri, 16 Oct 2026 05:23:26 +0700 Subject: [PATCH 08/23] fix(pen): preserve stale drafts in their owning Work Path --- crates/ui-egui/src/direct_select.rs | 9 +- crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/state.rs | 3 +- crates/ui-egui/src/vector_ui.rs | 266 ++++++++++++++++++++++++---- 4 files changed, 251 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index b27d9ea466..71fccc3870 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -174,7 +174,14 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { /// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then /// every visible shape layer. pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { - let pen = app.ui.pen.as_ref().filter(|p| !p.dragging && !p.knots.is_empty()).map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p))); + let active = app.session.active().map(|st| st.doc.id); + let pen = app + .ui + .pen + .as_ref() + .filter(|_| app.ui.pen_doc.is_none_or(|owner| Some(owner) == active)) + .filter(|p| !p.dragging && !p.knots.is_empty()) + .map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p))); let mut out: Vec<_> = pen.into_iter().chain(active_paths(app)).collect(); if let Some(st) = app.session.active() { for (path, _, l) in st.doc.walk().iter().rev() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 152328656f..fd922edeb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1035,6 +1035,9 @@ impl PhotocraftApp { } fn run_command(&mut self, id: &str, params: Value) -> Result { + if !vector_ui::cancel_stale(self) { + return Err(self.ui.status.clone()); + } if matches!(id, "paint.setSymmetry" | "paint.symmetryFromPath" | "paint.symmetryDisable") { self.ui.symmetry_transform = None; } @@ -1086,10 +1089,6 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; - // A draft Pen path belongs to the current document, not the one about to be created. - if id == "file.new" && self.ui.pen.is_some() { - vector_ui::pen_commit(self, false); - } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1147,18 +1146,18 @@ impl PhotocraftApp { self.perf.command_ms = gpu_canvas::now_ms() - t0; match &r { Ok(_) => { + if self.ui.status_error { + self.ui.status.clear(); + self.ui.status_error = false; + } self.sync_views(); let removed = self.session.take_samplers_removed(); - if removed > 0 { + if removed > 0 && !self.ui.status_error { // A canvas-size change (crop, Canvas Size, Image Size, rotation) dropped Color // Sampler points that fell outside the new canvas (#1046). self.ui.status = crate::i18n::trn(crate::i18n::current(), removed as u64, "{n} sample point removed", "{n} sample points removed"); self.ui.status_error = false; } - if self.ui.status_error { - self.ui.status.clear(); - self.ui.status_error = false; - } } Err(e) => { self.ui.status = e.clone(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5d690beba1..347078b0cf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1150,8 +1150,7 @@ pub struct UiState { /// Pen path under construction. #[serde(default)] pub pen: Option, - /// The document the pen path is drawn on (set at its first anchor): another document becoming - /// active drops it (`vector_ui::cancel_stale`). + /// The document the pen path is drawn on; switching away commits it to that Work Path. #[serde(default)] pub pen_doc: Option, #[serde(default)] diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 80f45e5d4d..f6b65260a6 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -222,7 +222,9 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { - cancel_stale(app); + if !cancel_stale(app) { + return; + } let tol = 6.0 / app.point_zoom().max(0.01) as f64; if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); @@ -289,7 +291,9 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { - cancel_stale(app); + if !cancel_stale(app) { + return false; + } let Some(pen) = app.ui.pen.as_mut() else { return false }; if pen.knots.pop().is_none() { return false; @@ -428,31 +432,50 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } -/// The path in progress belongs to the document it was started on (`UiState::pen_doc`): once -/// another document becomes active it is dropped, so it doesn't show on (or get committed to) the -/// other document. Switching tools is handled in `PhotocraftApp::logic`, which commits the path -/// to its own document. Like the Polygonal Lasso's ([`crate::canvas::polygon_cancel_stale`]) and -/// the Crop tool's ([`crate::crop_ui::cancel_stale`]). -pub fn cancel_stale(app: &mut PhotocraftApp) { +/// Preserve a foreign document's draft as its Work Path before another document can edit it. +/// A failed commit retains the draft and blocks the caller until it can be retried. +pub fn cancel_stale(app: &mut PhotocraftApp) -> bool { let active = app.session.active().map(|st| st.doc.id); if app.ui.pen.is_none() { app.ui.pen_doc = None; - } else if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { - app.ui.pen = None; - app.ui.pen_doc = None; - if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { - // Its anchors went with it, as on commit. - app.ui.direct_selection = Default::default(); + return true; + } + let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true }; + if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) { + let Some(mut pen) = app.ui.pen.clone() else { return true }; + if pen.curvature { + curvature_refresh(&mut pen, false); + } + let active = app.session.active_index(); + app.session.set_active(index); + // Bypass shell targets and sync_views: both belong to the newly active document. + let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)})); + if let Some(index) = active { + app.session.set_active(index); + } + if let Err(e) = result { + app.ui.status = e.to_string(); + app.ui.status_error = true; + return false; } } + 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(); + } + true } /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { // The control channel is drained after `sync_views`, so a document switch in this frame has // not been seen yet: never commit the path into the document it was not drawn on. - cancel_stale(app); + if !cancel_stale(app) { + return; + } let Some(mut pen) = app.ui.pen.take() else { return }; + app.ui.pen_doc = None; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. app.ui.direct_selection = Default::default(); @@ -619,7 +642,9 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { /// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor /// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { - cancel_stale(app); + if !cancel_stale(app) { + return; + } if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { pen_commit(app, false); } @@ -1025,7 +1050,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays. // Free Transform Path draws the path through its box instead (transform_tool). let paths = app.ui.view.shows(app.ui.view.show.target_path) && app.ui.transform.as_ref().is_none_or(|t| t.path.is_none()); - if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { + if app.session.active().is_some_and(|st| st.doc.id == doc.id) && crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { // Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790). crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths); if paths { @@ -1053,8 +1078,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // ⌘/Ctrl drags it, Direct Selection draws its preview instead. let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. })); // Only on the document it is drawn on: a view of another document never shows it (#5373). - let active = app.session.active().map(|st| st.doc.id); - if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| Some(d) == active)) { + if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| d == doc.id)) { let mut pts = Vec::new(); // A close-drag shows the first segment bending with the handle being pulled out. let mut knots = pen.knots.clone(); @@ -2271,9 +2295,7 @@ mod tests { pen_up(&mut app); } - /// #5373: an unclosed path belongs to the document it was drawn on. File › New opened a blank - /// document that showed the first document's path; a document switch between `sync_views` and - /// the commit also committed it into the wrong document. Like the Polygonal Lasso's #5015. + /// #5373: an unfinished path stays with its own document through same-frame switches. #[test] fn an_unclosed_path_stays_with_its_document() { // Both pens claim the document at their first anchor. @@ -2289,7 +2311,7 @@ mod tests { } }; let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); - // File › New drops the first document's path instead of carrying it over. + // File › New commits the path to its original document. for tool in [Tool::Pen, Tool::CurvaturePen] { let mut app = app(); app.ui.tool = tool; @@ -2297,26 +2319,24 @@ mod tests { assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document"); app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone"); + assert!(work(&app, 0).is_some(), "{tool:?}: the original keeps its Work Path"); assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank"); } // Two documents; the path below is drawn on the second (active) one. let mut app = app(); app.ui.tool = Tool::Pen; app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); - // The control channel is drained after `sync_views`, so a switch in the same frame reaches - // the commit unseen: it must not write the path into the other document. open_path(&mut app); assert!(app.session.set_active(0)); pen_commit(&mut app, false); assert!(app.ui.pen.is_none()); - assert_eq!((work(&app, 0), work(&app, 1)), (None, None), "neither document took it"); + assert!(work(&app, 0).is_none(), "the active document stays untouched"); + assert!(work(&app, 1).is_some(), "the owner keeps its Work Path"); // Staying on its own document, the path still commits there. open_path(&mut app); pen_commit(&mut app, false); assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); - assert_eq!(work(&app, 1), None); - // A control batch can switch documents and act on the path in the same frame, before the - // next `sync_views`: no entry point extends, undoes or keeps a stale path (#5427). + assert!(work(&app, 1).is_some()); for act in [0, 1, 2] { open_path(&mut app); assert!(app.session.set_active(1)); @@ -2334,8 +2354,6 @@ mod tests { app.ui.pen_doc = None; assert!(app.session.set_active(0)); } - // The overlay draws the path only on its own document, so a view of the other one in the - // same frame shows nothing (#5427). open_path(&mut app); let shapes = |app: &PhotocraftApp| { let ctx = egui::Context::default(); @@ -2366,7 +2384,6 @@ mod tests { assert!(app.session.set_active(0)); app.ui.pen = None; app.ui.pen_doc = None; - // A Direct Selection on the path goes with it, as on commit: no target left dangling. open_path(&mut app); app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); app.ui.direct_selection.anchors = vec![[0, 1]]; @@ -2374,6 +2391,191 @@ mod tests { assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path"); } + #[test] + fn pen_overlay_uses_the_view_document_even_during_direct_selection() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let owner = app.session.active().unwrap().doc.clone(); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let other = app.session.active().unwrap().doc.clone(); + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let shapes = |app: &PhotocraftApp, doc: &Document, modifiers| { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }, |ui| { + draw_overlay(app, ui.painter(), &xf, doc) + }); + output.textures_delta.clear(); + output.shapes.len() + }; + for active in [0, 1] { + assert!(app.session.set_active(active)); + for tool in [Tool::Pen, Tool::DirectSelection] { + app.ui.tool = tool; + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::COMMAND] { + for (doc, visible) in [(&owner, true), (&other, false)] { + let with_pen = shapes(&app, doc, modifiers); + let pen = app.ui.pen.take(); + let without = shapes(&app, doc, modifiers); + app.ui.pen = pen; + assert_eq!(with_pen > without, visible, "active {active}, {tool:?}, {modifiers:?}"); + } + } + } + } + } + + #[test] + fn failed_stale_pen_commit_preserves_the_draft_until_retry() { + for action in ["pen", "curvature", "undo", "commit", "command"] { + let mut app = app(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[5, 5], [10, 10]]}]}})).unwrap(); + let original = app.session.active().unwrap().doc.work_path.clone().unwrap(); + app.ui.tool = Tool::Pen; + for (x, y) in [(20.0, 20.0), (80.0, 60.0)] { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } + let draft = app.ui.pen.clone(); + let owner = app.ui.pen_doc; + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.selected_path = Some("foreign named path".into()); + app.session.authorize = Some(|_, _| Err(photocraft_engine::EngineError::Other("test rejection".into()))); + match action { + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => { + assert!(app.run("file.close", json!({})).is_err()); + } + } + assert_eq!(app.ui.pen, draft, "{action}: failed save retains all anchors"); + assert_eq!(app.ui.pen_doc, owner); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&original)); + assert!(app.session.documents()[1].doc.work_path.is_none()); + assert!(app.ui.status_error); + assert!(!crate::direct_select::candidates(&app).iter().any(|(target, _)| *target == crate::direct_select::PathRef::Pen)); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.ui.selected_path.as_deref(), Some("foreign named path")); + let saved = app.session.documents()[0].doc.work_path.clone().unwrap(); + assert_eq!(saved.subpaths.len(), original.subpaths.len() + 1); + assert_eq!(saved.subpaths[0], original.subpaths[0]); + app.sync_views(); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&saved), "retry never duplicates the path"); + assert!(app.session.set_active(0)); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.work_path.as_ref(), Some(&original)); + } + } + + #[test] + fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() { + let mut app = app(); + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let draft = app.ui.pen.clone(); + app.session.authorize = Some(|id, _| if id == "path.set" { Err(photocraft_engine::EngineError::Other("test rejection".into())) } else { Ok(()) }); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("test rejection")); + assert_eq!(app.ui.pen, draft); + assert_eq!(app.session.active_index(), Some(1)); + assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none())); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert!(app.session.documents()[0].doc.work_path.is_some()); + assert!(app.session.documents()[1].doc.work_path.is_none()); + } + + #[test] + fn new_document_preserves_a_single_pen_anchor_as_a_work_path() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + if tool == Tool::Pen { + pen_down(&mut app, 20.0, 30.0, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, 20.0, 30.0); + curvature_up(&mut app); + } + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.pen.is_none()); + let path = app.session.documents()[0].doc.work_path.as_ref().unwrap(); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 30.0)); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + } + } + + #[test] + fn stale_pen_commits_to_its_owner_across_document_changes() { + use photocraft_doc::{ColorMode, SampleType, Size}; + for tool in [Tool::Pen, Tool::CurvaturePen] { + for action in ["switch", "open", "closeOther", "pen", "curvature", "undo", "commit"] { + let mut app = app(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = tool; + for (x, y) in [(20.0, 20.0), (80.0, 60.0), (120.0, 30.0)] { + if tool == Tool::Pen { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, x, y); + curvature_up(&mut app); + } + } + let owner = app.ui.pen_doc.unwrap(); + let expected = photocraft_engine::vector_cmds::parse_path(&pen_to_json(app.ui.pen.as_ref().unwrap(), false)).unwrap(); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + assert!(app.session.set_active(0)); + match action { + "open" => { + app.services.import = + Some(Box::new(|name, _, _| Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())))); + app.open_file("synthetic.png", &[]).unwrap(); + } + "closeOther" => { + app.run("file.close", json!({"document": 0})).unwrap(); + } + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => app.sync_views(), + } + let source = app.session.documents().iter().find(|d| d.doc.id == owner).unwrap(); + let path = source.doc.work_path.as_ref().expect("the owner keeps the unfinished path"); + assert_eq!(path, &expected, "{tool:?} {action}"); + assert!(app.session.documents().iter().filter(|d| d.doc.id != owner).all(|d| d.doc.work_path.is_none())); + if action != "closeOther" { + assert_ne!(app.session.active().unwrap().doc.id, owner, "the active document is restored"); + } + assert_eq!(app.ui.direct_selection, Default::default()); + let index = app.session.documents().iter().position(|d| d.doc.id == owner).unwrap(); + assert!(app.session.set_active(index)); + assert!(app.session.undo()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert!(app.session.redo()); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + } + } + } + /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new /// subpath, instead of replacing it; a saved path that is selected takes it instead; with no /// path selected, the new path replaces the Work Path, as in Photoshop. From 942a581c0ca833939fc651b04fae6204f5b3eb46 Mon Sep 17 00:00:00 2001 From: jackn Date: Thu, 15 Oct 2026 23:51:39 -0600 Subject: [PATCH 09/23] Shapes: the Color panel, Swatches and Gradients fill the selected shape (#5568) With a shape layer selected, picking a colour in the Color panel, clicking a swatch or confirming the foreground Color Picker only changed the foreground colour: the panels recoloured selected type and nothing else, so the shape kept its fill. A Gradients panel click recoloured a selected Gradient Fill layer but ignored shapes. As in Photoshop, these now fill the selected shape layer. A new foreground fills the shape with a solid colour, keeping the fill's own opacity; a Color panel drag is one undo step (edits coalesce on the press time), each click its own. A gradient preset fills the shape with the gradient, keeping an existing gradient fill's angle, style, scale and reverse. Locked shapes are left alone: the Color panel only changes the foreground, and gradient.presets.select refuses before it changes the Gradient tool's gradient. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/presets/gradients.rs | 40 +++++++++++++++++++++--- crates/engine/src/presets/tests.rs | 42 ++++++++++++++++++++++++++ crates/ui-egui/src/color_picker_ui.rs | 1 + crates/ui-egui/src/panels.rs | 30 ++++++++++++++++++ crates/ui-egui/src/shape_fill_ui.rs | 35 +++++++++++++++++++++ crates/ui-egui/src/swatches_ui.rs | 32 ++++++++++++++++++-- 6 files changed, 174 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..31e7561c35 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -441,15 +441,47 @@ fn active_gradient_fill(s: &Session) -> Option { matches!(d.doc.layer(id)?.content, LayerContent::Fill(Fill::Gradient { .. })).then_some(id) } +fn active_shape(s: &Session) -> Option { + let d = s.active()?; + let id = d.active_layer?; + matches!(d.doc.layer(id)?.content, LayerContent::Shape(_)).then_some(id) +} + fn select(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD_SELECT)?; + let apply = p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true); + let shape = active_shape(s).filter(|_| apply); + // A locked shape refuses before anything changes, the Gradient tool's gradient included. + if let (Some(id), Some(d)) = (shape, s.active()) { + let locks = d.doc.effective_locks(id); + if locks.all || locks.pixels { + return Err(bad(CMD_SELECT, "the selected shape layer is locked")); + } + } s.presets.gradient = g.clone(); s.presets_changed(); let mut out = json!({"current": g.to_json()}); + // Clicking a preset with a shape layer selected fills the shape with it (Photoshop, #5568); + // a gradient fill keeps its angle, style, scale and reverse. + if let Some(id) = shape { + let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); + crate::vector_cmds::with_shape(s, id, "Edit Shape", |sh, _| { + let (angle, scale, style, reverse) = match &sh.fill { + Some(Fill::Gradient { angle, scale, style, reverse, .. }) => (*angle, *scale, *style, *reverse), + _ => (90.0, 1.0, photocraft_doc::GradientStyle::Linear, false), + }; + let mut fill = Fill::gradient(stops_new, angle, scale, style, reverse); + if let Fill::Gradient { opacity_stops, .. } = &mut fill { + *opacity_stops = opacity_new; + } + sh.fill = Some(fill); + Ok(()) + })?; + out["layer"] = json!(id.0); + return Ok(out); + } // Clicking a preset with a Gradient Fill layer selected changes that layer (Photoshop). - if p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true) - && let Some(id) = active_gradient_fill(s) - { + if apply && let Some(id) = active_gradient_fill(s) { let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); s.edit("Change Gradient Fill", |doc, _| { let mode = doc.mode; @@ -674,7 +706,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."## + r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer, or fills a selected shape layer with it)}} → {{current,layer?}}. The Gradient tool paints with it."## )), enabled: always, run: select, diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..0b714245dc 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -748,3 +748,45 @@ fn clone_source_errors_and_overlay() { assert_eq!(v["width"], json!(1000.0)); assert_eq!(v["rotation"], json!(-90.0)); } + +fn shape_fill(s: &Session, id: LayerId) -> Option { + match &s.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + } +} + +/// #5568: clicking a gradient with a shape layer selected fills the shape with it in one undo +/// step; a gradient fill keeps its angle, style, scale and reverse. +#[test] +fn gradient_preset_click_fills_the_selected_shape_layer() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let before = shape_fill(&s, id); + let r = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap(); + assert_eq!(r["layer"], json!(id.0)); + let Some(Fill::Gradient { stops, angle, style, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + // Red 01 starts at #7f0000; a new gradient fill is linear at 90°. + assert_eq!(stops[0].1.to_rgba8(), [0x7f, 0, 0, 0xff]); + assert_eq!((angle, style), (90.0, photocraft_doc::GradientStyle::Linear)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(shape_fill(&s, id), before); + + s.execute("shape.edit", json!({"layer": id.0, "fill": {"gradient": {"angle": 30, "style": "radial", "scale": 80, "reverse": true}}})).unwrap(); + s.execute("gradient.presets.select", json!({"preset": "Green 01"})).unwrap(); + let Some(Fill::Gradient { angle, style, scale, reverse, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + assert_eq!((angle, style, scale, reverse), (30.0, photocraft_doc::GradientStyle::Radial, 0.8, true)); +} + +/// A locked shape refuses the click before anything changes, the Gradient tool's gradient included. +#[test] +fn gradient_preset_click_on_a_locked_shape_changes_nothing() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let (before, gradient) = (shape_fill(&s, id), s.presets.gradient.name.clone()); + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + let err = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + assert_eq!(shape_fill(&s, id), before); + assert_eq!(s.presets.gradient.name, gradient); +} diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 09a3ce8d4c..9d61e3b984 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -825,6 +825,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(h.state()); + let min = h.ctx.input(|i| i.viewport_rect()).min; + // The field's top-left: no saturation, full brightness. + click(&mut h, min + vec2(60.0, 10.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 1); + drag(&mut h, min + vec2(120.0, 40.0), min + vec2(250.0, 120.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 2, "the whole drag is one undo step"); + } + /// Press at `from`, move to `to` in six steps and release, as egui sees a drag. fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); diff --git a/crates/ui-egui/src/shape_fill_ui.rs b/crates/ui-egui/src/shape_fill_ui.rs index 37731ce243..20d297d725 100644 --- a/crates/ui-egui/src/shape_fill_ui.rs +++ b/crates/ui-egui/src/shape_fill_ui.rs @@ -82,6 +82,41 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]})) } +/// A new foreground colour from the Color panel, a swatch or the Color Picker fills the selected +/// shape layer, keeping the fill's own opacity, as in Photoshop (#5568). Edits sharing `coalesce` +/// (one Color panel drag) are one undo step. Locked shapes and other layers are left alone. +pub(crate) fn foreground_changed(app: &mut PhotocraftApp, coalesce: Option) { + let Some(st) = app.session.active() else { return }; + let Some(layer) = st.active_layer else { return }; + let Some(LayerContent::Shape(sh)) = st.doc.layer(layer).map(|l| &l.content) else { return }; + let locks = st.doc.effective_locks(layer); + if locks.all || locks.pixels { + return; + } + let fg = app.session.tools.foreground; + let alpha = match &sh.fill { + Some(Fill::Solid(c)) => c.alpha, + _ => 1.0, + }; + if sh.fill == Some(Fill::Solid(Color::rgba(fg[0], fg[1], fg[2], alpha))) { + return; + } + let mut p = json!({"layer": layer.0, "fill": [fg[0], fg[1], fg[2], alpha]}); + if let Some(key) = coalesce { + p["coalesce"] = json!(key); + } + if let Err(e) = app.run(COMMAND, p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// The coalesce key of the pointer gesture under way (its press time), so one Color panel drag +/// is one undo step; `None` for clicks and typed values. +pub(crate) fn gesture(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.press_start_time()).map(|t| format!("color-panel-fill:{}", t.to_bits())) +} + /// The canvas preview while the picker is open: the document with the shape re-rendered in the /// picked colour, cached per dialog, revision and colour. pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..d1fe7ef7c0 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -2,7 +2,8 @@ //! preset browser: groups with disclosure triangles, a colour grid with name tooltips, a context //! menu (Rename, Delete, Move to), and the footer (size · New Group · New Swatch · Delete). //! -//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type); +//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type or +//! fills the selected shape layer); //! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The //! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`, //! `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a @@ -51,8 +52,10 @@ fn colors(app: &PhotocraftApp, ctx: &egui::Context) -> Arc>> { pub fn use_swatch(app: &mut PhotocraftApp, name: &str, background: bool) { let target = if background { "background" } else { "foreground" }; if run(app, "swatches.use", json!({"swatch": name, "target": target})).is_some() && !background { - // Clicking a swatch while characters are selected recolours them (#364). + // Clicking a swatch while characters are selected recolours them (#364), and with a + // shape layer selected fills the shape (#5568). crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, None); } } @@ -273,6 +276,31 @@ mod tests { use egui::{Modifiers, vec2}; use egui_kittest::Harness; + /// #5568: clicking a swatch with a shape layer selected fills the shape with it, one undo + /// step each, a gradient fill included. + #[test] + fn swatch_click_fills_the_selected_shape() { + use photocraft_doc::{Fill, LayerContent, LayerId}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": {"gradient": {}}})).unwrap()["layer"].as_u64().unwrap()); + let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(&app); + let names: Vec = app.session.presets.swatches[0].items.iter().take(2).map(|s| s.name.clone()).collect(); + use_swatch(&mut app, &names[0], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[0]); + assert_eq!(steps(&app), before + 1); + use_swatch(&mut app, &names[1], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[1]); + assert_eq!(steps(&app), before + 2, "each swatch click is its own undo step"); + } + fn click(h: &mut Harness<'_, PhotocraftApp>, p: egui::Pos2, modifiers: Modifiers) { h.hover_at(p); // Held modifiers, as when the user holds Alt while clicking. From e5a4af76487551bb7e23e98319807b4c4f20977c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:44:00 +0000 Subject: [PATCH 10/23] Find small objects in large documents with the Object Finder Fixes #6576 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/segment/grabcut.rs | 29 ++++++++++++++++++++++++++++- crates/algo/src/segment/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..9e53325edb 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -144,6 +144,10 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. +/// +/// When the whole-canvas pass finds nothing in a large document (an object only a few working +/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of +/// the same working size around the point, down to full resolution. pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { return None; @@ -152,7 +156,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u if !canvas.contains(px, py) { return None; } - let step = super::scale_for(canvas, work_px.max(1024)); + let work_px = work_px.max(1024); + let mut step = super::scale_for(canvas, work_px); + if let Some(r) = object_in(sampler, canvas, px, py, step) { + return Some(r); + } + // Side (working pixels) of the square windows of the finer passes. + let side = (work_px as f64).sqrt() as usize; + while step > 1 { + step = (step / 4).max(1); + let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32; + let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas); + if window.width() < 4 || window.height() < 4 { + continue; + } + if let Some(r) = object_in(sampler, window, px, py, step) { + return Some(r); + } + } + None +} + +/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`; +/// `(px, py)` lies inside `canvas`. +fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option { let img = sampler.rgb_scaled(canvas, step); let (w, h) = (img.w, img.h); if w < 2 || h < 2 { diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..46dda6cb0f 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -762,6 +762,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } +/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is). +#[test] +fn object_at_finds_small_object_in_large_canvas() { + let (w, h) = (4000usize, 3000usize); + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] { + let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh; + let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let truth: Vec = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32); + let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX); + let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found")); + let v = iou(®, &truth, w, h); + assert!(v > 0.8, "{ow}×{oh}: IoU {v}"); + } + // The flat background around the small object is still nothing. + let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + // ---------- Select Subject on the cases reported in #2788 ---------- /// A figure whose upper part is close to the background colour (a cream shirt on a near-white From 4c730d3889cfe4499077aa21e6a64bde1dc2e1a5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:06:52 +0200 Subject: [PATCH 11/23] Object Finder: hovering with an empty active layer shows the status-bar hint (#6549) --- crates/engine/src/smartselect_cmds.rs | 18 +++++++++---- crates/ui-egui/src/i18n/cs.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/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/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/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/object_finder.rs | 38 +++++++++++++++++++++++++-- docs/ui-parity.md | 2 +- 23 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..a4152abfdb 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -221,12 +221,20 @@ impl ObjectFinderCache { /// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer /// above the photo first). fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { - let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; - if surf.content_bounds().intersect(&area).is_empty() { - let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; - return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + match empty_sampled_area(doc, layer, all_layers, area) { + Some(msg) => Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))), + None => Ok(()), } - Ok(()) +} + +/// Why sampling `layer` (unless `all_layers`) can find nothing in `area`: the layer is empty, or has no pixels there. +/// `None` = there is something to sample. The click path reports it as an error, the Object Finder hover as a hint. +pub fn empty_sampled_area(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Option<&'static str> { + let surf = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface)?; + if !surf.content_bounds().intersect(&area).is_empty() { + return None; + } + Some(if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }) } /// `select.object` with `"point"`: the Object Finder's object under the point (cached). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..ab954fdf5f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3720,6 +3720,7 @@ navigator Zoom Zvětšení Medium Blue Středně modrá Object Finder Hledání objektů No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivní vrstva tu nemá žádné pixely — zapněte Vzorkovat všechny vrstvy nebo vyberte vrstvu s pixely Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..ff7028d50c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3110,6 +3110,7 @@ curves Smooth Glätten Fuzziness Toleranz Object Finder Objektsucher No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Die aktive Ebene hat hier keine Pixel — aktivieren Sie „Alle Ebenen einbeziehen“ oder wählen Sie eine Ebene mit Pixeln Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..b5542f7861 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3810,6 +3810,7 @@ curves Smooth Εξομάλυνση Light Gray Ανοιχτό γκρι Object Finder Εύρεση αντικειμένων No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Το ενεργό επίπεδο δεν έχει εικονοστοιχεία εδώ — ενεργοποιήστε τη Δειγματοληψία όλων των επιπέδων ή επιλέξτε ένα επίπεδο με εικονοστοιχεία Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..674e777ed3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2798,6 +2798,7 @@ curves Smooth Suavizar Light Gray Gris claro Object Finder Buscador de objetos No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels La capa activa no tiene píxeles aquí; activa Muestrear todas las capas o selecciona una capa con píxeles Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..080b15a7c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3115,6 +3115,7 @@ curves Smooth Lisser Light Gray Gris clair Object Finder Outil de recherche d’objets No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Le calque actif n’a pas de pixels ici — activez Échantillonner tous les calques ou sélectionnez un calque contenant des pixels Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..7a1bfdaa8a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4047,6 +4047,7 @@ cameraRaw Yellow Sárga @plural {n} action|{n} actions {n} művelet Object Finder Objektumkereső No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Az aktív rétegnek itt nincsenek képpontjai – kapcsold be a Mintavétel minden rétegből beállítást, vagy válassz képpontokat tartalmazó réteget Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..be7899aeb9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3102,6 +3102,7 @@ curves Smooth Haluskan Light Gray Abu-abu Muda Object Finder Pencari Objek No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Layer aktif tidak memiliki piksel di sini — aktifkan Sampel Semua Layer atau pilih layer yang berisi piksel Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..a2b316e0a1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4076,6 +4076,7 @@ navigator Zoom Zoom {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. Object Finder Ricerca oggetti No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Il livello attivo non ha pixel qui: attiva Campiona tutti i livelli o seleziona un livello con pixel Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..c17f887a04 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3723,6 +3723,7 @@ curves Smooth スムーズ Light Gray ライトグレー Object Finder オブジェクトファインダー No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels アクティブなレイヤーのここにはピクセルがありません — 「すべてのレイヤーをサンプル」をオンにするか、ピクセルのあるレイヤーを選択してください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..94df2a9df8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3287,6 +3287,7 @@ curves Smooth 매끄럽게 Light Gray 연한 회색 Object Finder 개체 찾기 No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 활성 레이어의 이 위치에는 픽셀이 없습니다 — 모든 레이어 샘플링을 켜거나 픽셀이 있는 레이어를 선택하세요 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..7bac8575cf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3872,6 +3872,7 @@ curves Smooth Vloeiend maken Light Gray Lichtgrijs Object Finder Objectzoeker No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels De actieve laag heeft hier geen pixels — zet Alle lagen samplen aan of selecteer een laag met pixels Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..2edeffd236 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3710,6 +3710,7 @@ curves Smooth Wygładź Light Gray Jasnoszary Object Finder Wyszukiwanie obiektów No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktywna warstwa nie ma tu pikseli — włącz Próbkuj wszystkie warstwy albo wybierz warstwę z pikselami Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..d932d08e09 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3902,6 +3902,7 @@ layerLabel No Color Sem cor Light Gray Cinza-claro Object Finder Localizador de objetos No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels A camada ativa não tem pixels aqui — ative Usar amostra de todas as camadas ou selecione uma camada com pixels Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..cfe75a9e39 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3060,6 +3060,7 @@ curves Smooth Сгладить Light Gray Светло-серый Object Finder Поиск объектов No object found there — drag a rectangle around it Объект не найден — обведите его прямоугольником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активном слое здесь нет пикселей — включите «По всем слоям» или выберите слой с пикселями Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..6cf369deef 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4181,6 +4181,7 @@ shape Appearance Izgled Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje Object Finder Pronalazač objekata No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivni sloj ovde nema piksela — uključite Uzorkuj sve slojeve ili izaberite sloj sa pikselima Clear Brush Controls Obriši kontrole četkice Reset All Locked Settings Vrati sva zaključana podešavanja Copy Texture to Other Tools Kopiraj teksturu u druge alatke diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..05297f933a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3858,6 +3858,7 @@ cameraData Lens Lens @plural {n} action|{n} actions {n} eylem Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Etkin katmanda burada piksel yok — Tüm Katmanları Örnekle seçeneğini açın veya piksel içeren bir katman seçin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın Gradient Preset Options Gradyan Ön Ayarı Seçenekleri Import .GRD .GRD İçe Aktar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..e42e6450e3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3811,6 +3811,7 @@ curves Smooth Згладити Light Gray Світло-сірий Object Finder Пошук об’єктів No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активному шарі тут немає пікселів — увімкніть «Зразок з усіх шарів» або виберіть шар із пікселями Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..a0a1cd7679 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3594,6 +3594,7 @@ curves Smooth Làm mượt Response curve Đường cong phản hồi Object Finder Trình tìm đối tượng No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Lớp đang hoạt động không có điểm ảnh ở đây — hãy bật Lấy mẫu mọi lớp hoặc chọn một lớp có điểm ảnh Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..3074fb8627 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2723,6 +2723,7 @@ curves Smooth 平滑 Light Gray 浅灰色 Object Finder 对象查找程序 No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 当前图层在此处没有像素 — 请打开“对所有图层采样”或选择有像素的图层 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..5261e93b53 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2708,6 +2708,7 @@ curves Smooth 平滑 Light Gray 淺灰色 Object Finder 物件尋找器 No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 作用中圖層在此處沒有像素 — 請開啟「取樣全部圖層」或選取有像素的圖層 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index e219b0ddff..bc2b47048e 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use egui::{Color32, TextureOptions, pos2}; use photocraft_algo::selection::Region; use photocraft_doc::{DocId, Document, LayerId}; -use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; +use photocraft_engine::smartselect_cmds::{empty_sampled_area, find_object, sampled_target}; use crate::PhotocraftApp; use crate::canvas::ViewXform; @@ -57,6 +57,13 @@ pub(crate) struct Update { pub shown: Option>, /// A lookup is pending or running: repaint soon to pick it up. pub busy: bool, + /// Why nothing can be found here: [`empty_layer_hint`] when the sampled layer has no pixels under the pointer. + pub hint: Option<&'static str>, +} + +/// The status-bar hint while hovering where the sampled layer has no pixels (the click reports the same as an error). +fn empty_layer_hint() -> &'static str { + tl!("The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels") } /// Whether the preview applies now (`pressed`: a pointer button is down). @@ -87,6 +94,11 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); } let doc_arc = Arc::clone(&st.doc); + // The click reports an empty sampled layer; the hover says so too instead of showing nothing. + let here = photocraft_geom::Rect::from_xywh(px[0], px[1], 1, 1); + if doc_arc.bounds().contains(px[0], px[1]) && empty_sampled_area(&doc_arc, layer, all_layers, here).is_some() { + return Update { shown: None, busy: false, hint: Some(empty_layer_hint()) }; + } let Some(f) = app.object_finder.as_mut() else { return Update::default() }; if f.at != px { f.at = px; @@ -121,7 +133,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); } let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); - Update { shown: f.shown.clone(), busy } + Update { shown: f.shown.clone(), busy, hint: None } } fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { @@ -193,6 +205,15 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf if up.busy { ctx.request_repaint_after(std::time::Duration::from_millis(40)); } + match up.hint { + Some(h) if app.ui.status != h => { + app.ui.status = h.to_owned(); + app.ui.status_error = false; + } + // The pointer moved on: drop our hint, never another message. + None if app.ui.status == empty_layer_hint() => app.ui.status.clear(), + _ => {} + } let Some(r) = up.shown else { return }; let Some(f) = app.object_finder.as_mut() else { return }; let key = Arc::as_ptr(&r) as usize; @@ -271,6 +292,19 @@ mod tests { assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); } + #[test] + fn hovering_an_empty_active_layer_gives_a_hint_not_silence() { + let mut app = disc_app(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 100.0); + assert!(u.shown.is_none() && !u.busy); + assert_eq!(u.hint, Some(empty_layer_hint())); + // Sample All Layers on: no hint, the object is looked up. + app.ui.tool_options.sample_all_layers = true; + assert!(update(&mut app, Some([100.0, 80.0]), false, 100.0).hint.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + } + #[test] fn preview_clears_on_leave_tool_switch_button_option_and_edits() { let mut app = disc_app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..ee51b1e45a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Where the active layer has no pixels under the pointer (Sample All Layers off), the status bar says so instead of showing nothing (#6549). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | From d7fabb52404c6bddb39a5fd438f97327ad180435 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:03:19 +0900 Subject: [PATCH 12/23] wip: fix Object Finder foreground classification (#6548) Use canvas-boundary coverage instead of a total-area cutoff so dominant enclosed foregrounds reach GrabCut while their surrounding backdrop stays unselected. Verify independent masks, 8/16/32-bit commands and undo, and 24 MP release timing. Closes #6548 Co-Authored-By: OpenAI Codex --- crates/algo/src/segment/grabcut.rs | 9 ++++--- crates/algo/src/segment/tests.rs | 32 +++++++++++++++++++++++ crates/algo/src/segment/workflow_tests.rs | 24 +++++++++++++++++ crates/engine/src/smartselect_cmds.rs | 16 ++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..41ee22fcd3 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -139,8 +139,8 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the /// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both /// by the distance to the seed's running mean and by the local edge step), so it stops at the -/// object's outline. A region that grows over most of the canvas is the background: nothing is -/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// object's outline. A region covering most of the canvas boundary is treated as background. +/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. @@ -164,8 +164,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let sy = (((py - canvas.y0) as usize) / step).min(h - 1); let grown = grow_similar(&lab, w, h, sx, sy); let count = grown.iter().filter(|g| **g).count(); - // Background (or a flat image): no single object under the cursor. - if count * 10 > w * h * 6 || count < 4 { + let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count(); + // shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788). + if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 { return None; } let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..19726e118d 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() { assert!(v > 0.9, "IoU {v}"); } +#[test] +fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() { + let (w, h) = (400, 300); + for margin in [30, 20, 8] { + let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin; + let img = RgbImage::from_fn(w, h, |x, y| { + let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0; + if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] } + }); + let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let sampler = ImageSampler { img: &img, origin: (-40, 25) }; + let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25); + let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX) + .unwrap_or_else(|| panic!("missing large foreground with margin {margin}")); + let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask }; + assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}"); + assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}"); + } +} + +#[test] +fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() { + let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap(); + assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9); + assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1); + let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]); + assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + #[test] fn object_at_on_the_background_or_off_canvas_finds_nothing() { let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); diff --git a/crates/algo/src/segment/workflow_tests.rs b/crates/algo/src/segment/workflow_tests.rs index ca3be5b40f..96c6caeb8f 100644 --- a/crates/algo/src/segment/workflow_tests.rs +++ b/crates/algo/src/segment/workflow_tests.rs @@ -1,4 +1,28 @@ use super::*; + +#[test] +#[ignore = "release Object Finder timing on a 24 MP document"] +#[allow(clippy::assertions_on_constants)] +fn object_finder_large_foreground_release_timing() { + assert!(!cfg!(debug_assertions), "run with --release"); + let (w, h) = (6000, 4000); + let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] { + let start = std::time::Instant::now(); + let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX); + println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0); + if name == "foreground" { + let region = region.unwrap(); + assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9); + assert!(region.at(10, 10) < 0.1); + } else { + assert!(region.is_none()); + } + } +} + #[test] #[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"] #[allow(clippy::assertions_on_constants)] diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..a7b55131e2 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -696,6 +696,22 @@ mod tests { assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); } + #[test] + fn object_selection_finds_a_large_foreground_at_each_depth_and_undoes() { + for depth in [8, 16, 32] { + let inside = |x: i32, y: i32| (10..190).contains(&x) && (8..152).contains(&y); + let mut s = session_with(200, 160, depth, |x, y| if inside(x as i32, y as i32) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + assert!(sel_iou(&s, 200, 160, inside) > 0.95, "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + let r = s.execute("select.object", json!({"point": [1, 1]})).unwrap(); + assert_eq!(r["changed"], false, "background at depth {depth}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + } + #[test] fn object_selection_point_honours_the_modes() { let (mut s, _) = disc(8); From f58107b50574e7d7ffc13cced453f4bdfea89479 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:13:21 +0900 Subject: [PATCH 13/23] docs: record Object Finder foreground detection fix Document the boundary-based classification change and keep semantic segmentation explicitly open so the parity notes do not imply Photoshop-level recognition. Closes #6548 Co-Authored-By: OpenAI Codex --- ROADMAP.md | 4 +++- docs/gaps.md | 4 +++- docs/ui-parity.md | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..a4e7ac1b00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548)) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | | 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar | @@ -135,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) | | 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..8117b3a985 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Classical Object Finder improvement; learned segmentation remains open) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -141,6 +141,7 @@ what an agent can't finish alone. exist (GrabCut/max-flow selection, Remove tool by non-local patch completion). - **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682). +- **Progress (2026-10-17):** classical Object Finder no longer rejects a foreground solely for covering over 60% of the canvas (#6548); canvas-boundary coverage distinguishes it from the surrounding backdrop. This does not add semantic recognition or close #2788. - **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote). ### G16. Plug-in compatibility @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Classical Object Finder improvement; learned segmentation remains open | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..813be83193 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder foreground/background discrimination (#6548)) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Dominant enclosed objects are retained; regions covering most of the canvas boundary are treated as background (#6548). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | @@ -227,6 +227,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From db2b55f66e1bef035893043397289c608e6cda41 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:02:35 +0300 Subject: [PATCH 14/23] fix(ui): Create Rectangle/Ellipse/Triangle/Polygon: right-click Width or Height to pick their unit (#7786) --- crates/ui-egui/src/shape_dialog.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/shape_dialog.rs b/crates/ui-egui/src/shape_dialog.rs index f643c210cd..517fa90248 100644 --- a/crates/ui-egui/src/shape_dialog.rs +++ b/crates/ui-egui/src/shape_dialog.rs @@ -2,11 +2,14 @@ //! opens Photoshop's creation dialog instead of drawing. OK runs the same `shape.create` command as //! a drag, with the options bar's fill and stroke, at the clicked point (or centred on it). //! Width and height are stored in document pixels (so automation can set them with -//! `ui.dialog.set`) and shown in the ruler unit. +//! `ui.dialog.set`) and shown in the ruler unit, or in one picked from the Width and Height +//! fields' right-click menu (remembered with the other values). use egui::{Align2, Sense, pos2, vec2}; use serde_json::{Map, Value, json}; +use photocraft_engine::prefs::Unit; + use crate::PhotocraftApp; use crate::state::{DialogKind, Tool}; use crate::theme::Tokens; @@ -15,7 +18,7 @@ const MARK: &str = "__createShape"; /// Largest side the dialog accepts, in pixels (Photoshop's document limit). const MAX_PX: f64 = 300_000.0; /// Remembered values (document pixels; sides and radii come from the options bar). -const KEYS: [&str; 4] = ["width", "height", "fromCenter", "starRatio"]; +const KEYS: [&str; 5] = ["width", "height", "fromCenter", "starRatio", "unit"]; pub fn owns(fields: &Map) -> bool { fields.contains_key(MARK) @@ -40,6 +43,7 @@ fn valid(key: &str, v: &Value) -> bool { match key { "width" | "height" => v.as_f64().is_some_and(|x| x.is_finite() && (1.0..=MAX_PX).contains(&x)), "starRatio" => v.as_f64().is_some_and(|x| x.is_finite() && (1.0..=100.0).contains(&x)), + "unit" => v.as_str().is_some_and(|u| Unit::parse(u).is_some()), _ => v.is_boolean(), } } @@ -68,6 +72,8 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, at: [f64; 2]) -> Option { f.insert("width".into(), json!(100.0)); f.insert("height".into(), json!(100.0)); f.insert("fromCenter".into(), json!(false)); + // The unit Width and Height show (the ruler unit until one is picked with a right-click). + f.insert("unit".into(), json!(app.session.prefs().units_and_rulers.rulers.name())); match kind { "rectangle" => { f.insert("radii".into(), json!([r, r, r, r])); @@ -160,9 +166,11 @@ fn label(ui: &mut egui::Ui, text: &str) { pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { ui.spacing_mut().item_spacing.y = 6.0; let kind = f.get(MARK).and_then(Value::as_str).unwrap_or("").to_string(); - // Width and height in the ruler unit (percent of the document's side). + // Width and height in the dialog's unit (percent of the document's side): the ruler unit, or + // the one picked from the fields' right-click menu, as in Photoshop (#7786). let ur = &app.session.prefs().units_and_rulers; - let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); + let unit = f.get("unit").and_then(Value::as_str).and_then(Unit::parse).unwrap_or(ur.rulers); + let ppi = ur.point_size.per_inch(); let (dpi, size) = app .session .active() @@ -174,10 +182,17 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32; let mut v = to_unit(px); let places = unit.decimals() as u32; - if crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places).changed() { + let resp = crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places); + if resp.changed() { let px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, MAX_PX); f.insert(key.into(), json!((px * 1000.0).round() / 1000.0)); } + // Right-click: the length units, for both fields (the sizes stay in pixels). + resp.context_menu(|ui| { + if let Some(name) = crate::rulers::unit_menu(ui, unit) { + f.insert("unit".into(), json!(name)); + } + }); }); } match kind.as_str() { From 295901cf3fcb1cba76ca3ae017026a8fdcda67b4 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:02:35 +0300 Subject: [PATCH 15/23] test(ui): the shape dialog shows Width and Height in the picked unit and remembers it (#7786) --- crates/ui-egui/src/shape_dialog_tests.rs | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index a0b5c61596..db45a004a7 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -248,3 +248,57 @@ fn the_dialog_body_renders_for_every_tool_and_unit() { app.ui.close_dialog(id); } } + +/// The texts a dialog body draws. +fn body_texts(app: &PhotocraftApp, fields: &mut serde_json::Map) -> Vec { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| body(app, ui, fields)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for clip in &out.shapes { + if let egui::Shape::Text(t) = &clip.shape { + texts.push(t.galley.text().to_string()); + } + } + texts +} + +/// #7786: Width and Height show in the unit picked from their right-click menu (the ruler unit +/// until then), the sizes stay in pixels, and OK remembers the unit with the other values. +#[test] +fn width_and_height_show_in_the_picked_unit_and_remember_it() { + let mut app = app(); + press(&mut app, Tool::Rectangle, [10.0, 10.0], [10.0, 10.0]); + let id = only_dialog(&app); + let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); + assert_eq!(f["unit"], json!("pixels"), "the ruler unit at first"); + // Each of Width and Height: its label, its value, its unit. + let sides = |texts: &[String]| -> Vec<(String, String)> { + ["Width:", "Height:"] + .iter() + .filter_map(|l| texts.iter().position(|t| t == l)) + .filter_map(|i| Some((texts.get(i + 1)?.clone(), texts.get(i + 2)?.clone()))) + .collect() + }; + let px = ("100".to_string(), "px".to_string()); + assert_eq!(sides(&body_texts(&app, &mut f)), [px.clone(), px], "shown in pixels"); + // What the menu's Millimeters sets: 100 px at the document's 72 ppi is 35.28 mm. + f.insert("unit".into(), json!("mm")); + let mm = ("35.28".to_string(), "mm".to_string()); + assert_eq!(sides(&body_texts(&app, &mut f)), [mm.clone(), mm]); + assert_eq!(f["width"].as_f64(), Some(100.0), "the size stays in pixels"); + set(&mut app, id, json!({"unit": "mm", "width": 50})); + crate::dialogs::confirm(&mut app, id).unwrap(); + // The next Create Rectangle opens in millimetres, with the remembered size. + press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]); + let id = only_dialog(&app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["unit"].as_str(), f["width"].as_f64()), (Some("mm"), Some(50.0))); + // A remembered unit that isn't one falls back to the ruler unit. + app.ui.close_dialog(id); + app.session.prefs.edit(|p| { + p.dialogs.insert("shape.create.rectangle".into(), json!({"unit": "furlongs"})); + }); + press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]); + assert_eq!(app.ui.dialogs[0].fields["unit"], json!("pixels")); +} From 1ee1354530ab41522ee53114b2a6617415c59c37 Mon Sep 17 00:00:00 2001 From: VishnuByrraju Date: Wed, 21 Oct 2026 11:10:19 +0530 Subject: [PATCH 16/23] fix(ui): grey out Select and Mask in the options bar without a selection (#7520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Select and Mask… buttons of the marquee, lasso and Magic Wand options bars were always live, though Select > Select and Mask… is disabled until there is a selection or a layer mask. They now follow the same enabled state. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 22 +++++---- .../src/select_and_mask_button_tests.rs | 46 +++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/select_and_mask_button_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8bf2be90d7..6c800afdaf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3482,3 +3482,5 @@ mod channel_tint_tests { #[cfg(test)] mod options_hit_area_tests; +#[cfg(test)] +mod select_and_mask_button_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 18cef34c98..f47e3c8a57 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1193,9 +1193,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut o.marquee_height, range, unit, 62.0); }); widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); } Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso if t.pro => { selection_mode_buttons(app, ui); @@ -1220,9 +1218,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } widgets::vline(ui, 22.0); } - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); if crate::lasso_ui::active(app) || (app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty()) { hint( ui, @@ -1257,9 +1253,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let r = app.run("select.subject", json!({})); crate::retouch_ui::report_smart_result(app, r, tl!("No subject found — try Object Selection or the Lasso")); } - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); } Tool::PaintBucket if t.pro => { opt_label(ui, tl!("Fill")); @@ -4493,6 +4487,16 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi } } +/// The options bar's Select and Mask… button: greyed out, as the menu item is, until there is a +/// selection (or a layer mask) to refine (#7520). +fn select_and_mask_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let enabled = crate::menus::is_enabled(app, "select.selectAndMask"); + let clicked = ui.add_enabled_ui(enabled, |ui| widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked()).inner; + if clicked { + let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); + } +} + /// New / add / subtract / intersect selection buttons (shared by selection tools). fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; diff --git a/crates/ui-egui/src/select_and_mask_button_tests.rs b/crates/ui-egui/src/select_and_mask_button_tests.rs new file mode 100644 index 0000000000..5426b778ac --- /dev/null +++ b/crates/ui-egui/src/select_and_mask_button_tests.rs @@ -0,0 +1,46 @@ +//! #7520: the options bar's Select and Mask… button is greyed out while nothing is selected. +use crate::PhotocraftApp; +use crate::state::Tool; +use egui::vec2; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use serde_json::json; + +fn harness(tool: Tool, select: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + if select { + app.run("select.all", json!({})).unwrap(); + } + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + h +} + +#[test] +fn select_and_mask_is_disabled_without_a_selection() { + for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] { + let h = harness(tool, false); + assert!(h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be greyed out with no selection"); + } +} + +#[test] +fn select_and_mask_is_enabled_with_a_selection() { + for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] { + let h = harness(tool, true); + assert!(!h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be live with a selection"); + } +} From 81d278eaad8662479c9bfc5475f73b9fb84b4feb Mon Sep 17 00:00:00 2001 From: VishnuByrraju Date: Wed, 21 Oct 2026 12:21:40 +0530 Subject: [PATCH 17/23] fix(paths): the Work Path is drawn only while selected or a path tool is active (#6551) --- crates/ui-egui/src/path_selection_tests.rs | 4 +- crates/ui-egui/src/vector_ui.rs | 48 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 12b1670863..a5d4ee035a 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -248,7 +248,8 @@ fn clicking_shape_with_path_selection_selects_its_layer() { assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } -/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// A work path selected in the Paths panel remains visible (outline) regardless of the active tool +/// (one that is not selected is hidden, #6551). Before the fix, /// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, /// etc. This test renders the canvas with a Brush tool active and a work path present, then /// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. @@ -259,6 +260,7 @@ fn path_overlay_drawn_for_non_vector_tools() { app.sync_views(); // Place a work path whose top edge runs exactly along y=10 in document space. app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + app.ui.selected_path = Some("work".into()); // Switch to a non-vector tool — before the fix this hid the path outline. app.ui.tool = Tool::Brush; diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 205e47aaf9..104f514d61 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1009,10 +1009,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - // Paths remain visible as outlines with any tool. Path Selection and the Pen tools add - // anchor squares; Direct Selection has its own overlay above. + // The shape's or vector mask's path stays visible as an outline with any tool. Path + // Selection and the Pen tools add anchor squares; Direct Selection has its own overlay + // above. The Work Path is drawn only while it is the path selected in the Paths panel or + // a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived + // with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551). let anchors = shows_anchors(tool); - if let Some(wp) = &doc.work_path { + if let Some(wp) = &doc.work_path + && (anchors || app.ui.selected_path.as_deref() == Some("work")) + { draw_path(wp, anchors); } let st = app.session.active(); @@ -2069,6 +2074,43 @@ mod tests { assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move)); } + #[test] + fn an_unselected_work_path_is_not_drawn_over_the_canvas() { + // #6551: Ctrl-click Load as Selection leaves the Paths panel's selection alone, yet the Work + // Path stayed drawn over the new selection's edge. Photoshop draws it only while selected + // in the Paths panel (or a path tool is active). + let ctx = egui::Context::default(); + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [20, 20], "in": [20, 20], "out": [20, 20]}, + {"anchor": [90, 20], "in": [90, 20], "out": [90, 20]}, + {"anchor": [90, 90], "in": [90, 90], "out": [90, 90]} + ]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.run("path.toSelection", json!({"name": "work", "mode": "replace"})).unwrap(); + assert!(app.ui.selected_path.is_none()); + for tool in [Tool::Move, Tool::Brush, Tool::Rectangle, Tool::Zoom] { + app.ui.tool = tool; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: an unselected Work Path stays off the canvas"); + } + // Selected in the Paths panel: drawn with any tool. + app.ui.selected_path = Some("work".into()); + for tool in [Tool::Move, Tool::Brush] { + app.ui.tool = tool; + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the selected Work Path is drawn"); + } + // Another path selected: the Work Path is not the target path. + app.ui.selected_path = Some("Saved".into()); + app.ui.tool = Tool::Move; + assert_eq!(overlay_shapes(&app, &ctx), 0); + // A path tool shows the Work Path it edits even when the panel selection was cleared. + app.ui.selected_path = None; + for tool in [Tool::Pen, Tool::PathSelection] { + app.ui.tool = tool; + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: path tools draw the Work Path"); + } + } + #[test] fn show_target_path_and_extras_hide_the_path_outlines() { // #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the From 4e89f04d050fbf65fa93ff06e26bed9b60f23053 Mon Sep 17 00:00:00 2001 From: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:08:27 +0530 Subject: [PATCH 18/23] fix(pen): Alt-drag grabs the last anchor's incoming handle too (#6077) While drawing, the Pen shows both handles of the last anchor, but an Alt-press only grabbed the outgoing one; on the incoming handle it added a new anchor instead. Alt now grabs whichever shown handle is under the pointer and turns it alone, as Photoshop's Alt (Convert Point) does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_draw_tests.rs | 29 +++++++++++++++++++++++++++- crates/ui-egui/src/vector_ui.rs | 28 +++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 57be940bd6..e11bd4abf2 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -1,5 +1,5 @@ //! The Pen's modifiers while drawing, through the real canvas (#3306): ⌥/Alt breaks the handle -//! being pulled out and grabs the last anchor's outgoing handle alone, and dragging on the first +//! being pulled out and grabs either of the last anchor's handles alone, and dragging on the first //! anchor closes the path with that anchor's handles pulled out. use egui::{Key, Modifiers, PointerButton, Pos2, vec2}; @@ -122,6 +122,33 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0])); } +/// #6077: both handles of the last anchor are drawn, so ⌥/Alt grabs either one, as in Photoshop. +/// Dragging the incoming handle turns it alone; the outgoing handle and the anchors stay put. +#[test] +fn alt_drag_turns_the_last_anchors_incoming_handle_alone() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + drag(&mut h, &[[100.0, 20.0], [120.0, 40.0]], None); + let k = h.state().ui.pen.clone().unwrap().knots[1]; + assert!(near(k[1], [80.0, 0.0]) && near(k[2], [120.0, 40.0]), "smooth anchor pulled out: {k:?}"); + + drag(&mut h, &[[80.0, 0.0], [60.0, 30.0]], Some(0)); + let pen = h.state().ui.pen.clone().unwrap(); + assert_eq!(pen.knots.len(), 2, "no new anchor on the handle"); + let k = pen.knots[1]; + assert!(near(k[0], [100.0, 20.0]), "the anchor stayed put: {k:?}"); + assert!(near(k[1], [60.0, 30.0]), "the incoming handle followed the pointer: {k:?}"); + assert!(near(k[2], [120.0, 40.0]), "the outgoing handle stayed put: {k:?}"); + + click(&mut h, [150.0, 120.0]); + h.key_press(Key::Enter); + h.run_steps(2); + let doc = &h.state().session.active().unwrap().doc; + let k = &doc.work_path.as_ref().unwrap().subpaths[0].knots[1]; + assert!(!k.smooth, "the broken handle is a cusp in the work path"); + assert!(near([k.in_ctrl.x, k.in_ctrl.y], [60.0, 30.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [120.0, 40.0])); +} + #[test] fn opening_a_new_document_finishes_the_previous_pen_path() { let mut h = harness(); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 205e47aaf9..ae91497d12 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -26,6 +26,9 @@ pub struct PenPath { /// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put. #[serde(skip)] pub adjusting_last: bool, + /// ⌥/Alt-dragging the final anchor's incoming handle: it turns alone (#6077). + #[serde(skip)] + pub adjusting_in: bool, /// Drawn with the Curvature Pen: handles follow the anchors (`curvature_refresh`) and /// `unlinked` lists its corner points. #[serde(default)] @@ -224,6 +227,7 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; let pen = app.ui.pen.get_or_insert_with(PenPath::default); + pen.adjusting_in = false; if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) { @@ -263,10 +267,14 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { - let adjusting_last = pen.adjusting_last; + let (adjusting_last, adjusting_in) = (pen.adjusting_last, pen.adjusting_in); if let Some(k) = pen.knots.last_mut() { let a = k[0]; let [x, y] = shift_45(a, [x, y], shift); + if adjusting_in { + k[1] = [x, y]; + return; + } k[2] = [x, y]; if !adjusting_last { k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y]; @@ -313,6 +321,7 @@ pub fn pen_up(app: &mut PhotocraftApp) { let Some(pen) = app.ui.pen.as_mut() else { return }; pen.dragging = false; pen.adjusting_last = false; + pen.adjusting_in = false; if closing(pen) { // A click on the first anchor closes the path and leaves that anchor's handles alone. if let Some(k) = pen.knots.last_mut() @@ -336,21 +345,31 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) { pen.adjusting_last = true; } -/// ⌥/Alt-press on the last anchor's outgoing handle while drawing grabs that handle alone: the -/// drag turns it and the incoming curve stays put (#3306). False when the press misses it. +/// ⌥/Alt-press on one of the last anchor's handles while drawing grabs that handle alone: the +/// drag turns it and the other handle stays put (#3306, #6077). False when the press misses both. pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); let Some(pen) = app.ui.pen.as_mut() else { return false }; let Some((i, k)) = pen.knots.len().checked_sub(1).zip(pen.knots.last()) else { return false }; let dist = |p: [f64; 2]| (p[0] - x).hypot(p[1] - y); - if k[2] == k[0] || dist(k[2]) >= tol || dist(k[2]) >= dist(k[0]) { + // Both handles show only while the outgoing one is pulled out (`draw_overlay`). + if k[2] == k[0] { return false; } + let grabbed = |h: [f64; 2]| h != k[0] && dist(h) < tol && dist(h) < dist(k[0]); + let incoming = if grabbed(k[2]) && dist(k[2]) <= dist(k[1]) { + false + } else if grabbed(k[1]) { + true + } else { + return false; + }; if !pen.unlinked.contains(&i) { pen.unlinked.push(i); } pen.dragging = true; pen.adjusting_last = true; + pen.adjusting_in = incoming; true } @@ -414,6 +433,7 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { pen.unlinked = knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect(); pen.dragging = false; pen.adjusting_last = false; + pen.adjusting_in = false; pen.moving = None; if pen.curvature { curvature_refresh(pen, false); From 5bcf78416d65729a81c9b77c622ef9b101dd9dc4 Mon Sep 17 00:00:00 2001 From: Ebenezer-03 <187195646+Ebenezer-03@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:11:07 +0530 Subject: [PATCH 19/23] Object Selection: preserve modifiers from mouse-down Capture Shift and Alt at the initial press so early key release does not turn Add or Subtract into Replace. Cover real canvas clicks and rectangle drags, including a subsequent unmodified click. --- crates/ui-egui/src/canvas.rs | 12 ++++-- crates/ui-egui/src/marquee_tests.rs | 57 ++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2e535f3f6..e1f66fb89f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3633,9 +3633,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) { ctx.request_repaint(); } - // Capture intent at the press, before egui's drag threshold: Type transforms keep - // Command, and marquee add/subtract must not become a live Shift/Alt constraint. - let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee); + // Capture intent at the press, before egui's drag threshold or click replay on release: + // Type transforms keep Command; selections keep Add/Subtract if Shift/Alt is released. + let capture_press = tool.is_type() + || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee | Tool::ObjectSelection); let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx)); if capture_press && let Some((p, press_mods)) = ui.input(|i| { @@ -3866,6 +3867,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary }; let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods); + let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary { + ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods) + } else { + click_mods + }; let d = xf.to_doc(p); match tool { Tool::Zoom if !transform_owns_pointer => { diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 321a31e73a..f49b830105 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -13,12 +13,16 @@ use crate::canvas::ViewXform; use crate::state::Tool; fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + harness_with_step_dt(tool, 0.25) +} + +fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.sync_views(); app.ui.extras.rulers = false; app.ui.tool = tool; - let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(step_dt).build_ui_state( |ui, app: &mut PhotocraftApp| { let ctx = ui.ctx().clone(); if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -147,6 +151,57 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +#[test] +fn object_selection_keeps_the_mode_from_the_mouse_press() { + for dragged in [false, true] { + for held in [false, true] { + for at_press in [Modifiers::SHIFT, Modifiers::ALT] { + let mut h = harness_with_step_dt(Tool::ObjectSelection, 1.0 / 60.0); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("paint.pencil", json!({"points": [[220, 150]], "size": 60, "color": "#ff0000"})).unwrap(); + let st = h.state().session.active().unwrap(); + let doc = st.doc.clone(); + let target = st.active_layer; + let region = photocraft_engine::smartselect_cmds::find_object(&doc, target, false, 220.0, 150.0).expect("the red disc is detectable"); + h.state_mut().session.object_finder.insert(&doc, target, std::sync::Arc::new(region)); + if at_press.alt { + h.state_mut().run("select.all", json!({})).unwrap(); + } else { + h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } + let start_doc = if dragged { [180.0, 110.0] } else { [220.0, 150.0] }; + mods(&mut h, at_press); + move_to(&mut h, start_doc[0], start_doc[1]); + let start = screen(&h, start_doc[0], start_doc[1]); + button(&mut h, start, true, at_press); + assert!(h.state().drag.is_none(), "the press is still below egui's drag threshold"); + let during = if held { at_press } else { Modifiers::NONE }; + mods(&mut h, during); + let end_doc = if dragged { [260.0, 190.0] } else { start_doc }; + release_at(&mut h, end_doc[0], end_doc[1], during); + let app = h.state(); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (command, params) = app.session.journal.last().expect("the gesture dispatched a command"); + assert_eq!(command, "select.object", "the canvas dispatched Object Selection"); + let expected = if at_press.alt { "subtract" } else { "add" }; + assert_eq!(params["mode"], expected, "dragged={dragged}, held={held}"); + let sel = app.session.active().unwrap().doc.selection.as_ref().expect("the old selection survives outside the object"); + assert!(sel.sample_channel(20, 20, 0) > 0.99, "the gesture must preserve selection outside the object"); + assert_eq!(sel.sample_channel(220, 150, 0) > 0.5, !at_press.alt, "the disc follows the requested mode"); + mods(&mut h, Modifiers::NONE); + move_to(&mut h, 220.0, 150.0); + let next = screen(&h, 220.0, 150.0); + button(&mut h, next, true, Modifiers::NONE); + release_at(&mut h, 220.0, 150.0, Modifiers::NONE); + let (_, params) = h.state().session.journal.last().unwrap(); + assert_eq!(params["mode"], "replace", "the next unmodified click must not reuse the previous mode"); + let sel = h.state().session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(sel.sample_channel(20, 20, 0) < 0.01 && sel.sample_channel(220, 150, 0) > 0.99); + } + } + } +} + /// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis /// snapped to the pixel grid on its own. The readout and the selection are square, and the /// Elliptical Marquee's circle too. From 1c2ed13dd328b01742fbed4afcc5fd9995f38875 Mon Sep 17 00:00:00 2001 From: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:13:08 +0530 Subject: [PATCH 20/23] fix(engine): Remove Background looks for the subject in the layer, not the canvas (#8235) On a pixel layer that doesn't fill the canvas (a placed photo, or the canvas grown), Select Subject ran over the whole canvas, so the photo's rectangle stood out from the empty canvas around it as the subject. The mask then revealed the rectangle, which a Stroke layer style outlines. Search and refine the edge within the layer's own pixels instead. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cutout_cmds.rs | 62 ++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 2f6e2b70b5..cceb60036a 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -6,6 +6,7 @@ use photocraft_algo::matting::{self, RefineParams}; use photocraft_algo::segment::subject; use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; @@ -35,21 +36,32 @@ fn enabled(s: &Session) -> std::result::Result<(), String> { check(&d.doc, crate::active_layer_of(s)?) } +/// Where the subject is looked for: the layer's own pixels within the canvas. A photo that +/// doesn't fill the canvas (placed, or the canvas grown) would otherwise stand out from the +/// empty canvas around it as the subject, and the mask would reveal its whole rectangle, which a +/// Stroke then outlines (#8235). The whole canvas when sampling all layers. +fn subject_area(l: &Layer, canvas: Rect, sample_all: bool) -> Rect { + let Some(s) = l.surface().filter(|_| !sample_all) else { return canvas }; + let empty_around = s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0); + let b = s.content_bounds().intersect(&canvas); + if empty_around && !b.is_empty() { b } else { canvas } +} + fn run(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?; + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + check(doc, layer).map_err(EngineError::Other)?; let sample_all = p.get("sampleAllLayers").and_then(Value::as_bool).unwrap_or(false); let refine = p.get("refine").and_then(Value::as_bool).unwrap_or(true); + let area = subject_area(layer, doc.bounds(), sample_all); crate::jobs::edit_job( s, "Remove Background", move |doc, _, ctx| { // Cancel reaches the min cuts inside Select Subject, so Esc doesn't wait for them. let found = ctx - .stage(0.1, 0.6, "Finding subject", |ctl| { - with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject_with(smp, d.bounds(), ctl)) - }) + .stage(0.1, 0.6, "Finding subject", |ctl| with_doc_sampler(doc, Some(id), sample_all, |smp, _| subject::select_subject_with(smp, area, ctl))) .map_err(|_| EngineError::Cancelled)?; let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; if ctx.cancelled() { @@ -57,8 +69,8 @@ fn run(s: &mut Session, p: &Value) -> Result { } if refine { ctx.progress(0.6, "Refining edge"); - let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, d| { - matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, d.bounds(), &REFINE) + let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| { + matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, area, &REFINE) }); region = refined.unwrap_or(region); if ctx.cancelled() { @@ -94,7 +106,6 @@ mod tests { use super::*; use photocraft_algo::segment::Rng; use photocraft_doc::LayerId; - use photocraft_geom::Rect; fn session_with(w: u32, h: u32, depth: u32, px: impl Fn(u32, u32) -> [f32; 3]) -> Session { let mut s = Session::new(); @@ -287,4 +298,41 @@ mod tests { let masks = s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| l.mask.is_some()).count(); assert_eq!(masks, 1); } + + #[test] + fn remove_background_on_a_layer_smaller_than_the_canvas_masks_the_subject_not_the_layer() { + // The photo doesn't fill the canvas (a placed photo, or the canvas grown): the empty + // canvas around it is not the background, and a Stroke must not outline the photo's + // rectangle (#8235). + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + s.execute("image.canvasSize", json!({"width": 260, "height": 220, "anchor": "center"})).unwrap(); + let id = active_id(&s); + s.execute(CMD, json!({})).unwrap(); + // The photo now sits at (30, 30). + let v = mask_iou(&s, 260, 220, |x, y| inside(x - 30, y - 30)); + assert!(v >= 0.75, "depth {depth}: IoU {v}"); + for (x, y) in [(31, 31), (228, 31), (31, 188), (228, 188), (40, 110)] { + assert!(mask_cov(&s, x, y) < 0.2, "depth {depth}: photo background at ({x}, {y}) revealed"); + } + s.edit("stroke", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.effects.items = vec![photocraft_doc::Effect::Stroke(photocraft_doc::StrokeFx { + common: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + size: 3.0, + position: photocraft_doc::StrokePosition::Outside, + paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + })]; + Ok(()) + }) + .unwrap(); + let img = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 260, 220)); + // Just outside the photo's left edge, and just inside it: no stroke. + for x in [27, 28, 29, 30, 31] { + let a = img.px[110 * 260 + x][3]; + assert!(a < 0.05, "depth {depth}: stroke along the layer's edge at x = {x} (alpha {a})"); + } + } + } } From 4f3de938520bd82f9001255e0a48210633d81de5 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:08:51 +0200 Subject: [PATCH 21/23] Marquee: Fixed Size selects exactly the entered width and height (#6015) A press between pixels snapped both edges outwards, so 10 x 10 selected 11 x 11 and 1 x 1 selected 2 x 2. The near edge now goes to the closest pixel edge and the far one is the size away. A click without a drag places the Fixed Size selection, as in Photoshop. --- crates/ui-egui/src/canvas.rs | 31 ++++++++++++++++++++++++++--- crates/ui-egui/src/marquee_tests.rs | 29 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7b05bffce8..e2332755d9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,14 +244,16 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) - /// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None` /// while the gesture is still a click (it never left the press point by more than a few screen /// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then -/// deselects. A one-pixel row or column is a selection (#2391). +/// deselects. A one-pixel row or column is a selection (#2391). With the Fixed Size style a +/// click places the selection, as in Photoshop. pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> { - if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) { + let fixed = o.marquee_style == "fixedSize"; + if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || (!fixed && crate::shape_dialog::is_click(d.start, &d.points, zoom)) { return None; } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let mut r = marquee_px(a, b); + let mut r = if fixed { fixed_size_px(a, b, o.marquee_width, o.marquee_height) } else { marquee_px(a, b) }; if marquee_is_square(o, d) { r = square_px(r, d.start, last); } @@ -263,6 +265,16 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// The pixel rectangle of a Fixed Size marquee between the corners `a` and `b`: exactly `w` × `h` +/// whole pixels. Snapping both edges outwards (`marquee_px`) added a pixel to each side whenever +/// the press point sat between pixels, so 10 × 10 selected 11 × 11 (#6015). The near edge goes +/// to the closest pixel edge and the far one is the size away. +pub fn fixed_size_px(a: [f64; 2], b: [f64; 2], w: f32, h: f32) -> [f64; 4] { + let (w, h) = (f64::from(w).round().max(1.0), f64::from(h).round().max(1.0)); + let (x0, y0) = (a[0].min(b[0]).round(), a[1].min(b[1]).round()); + [x0, y0, x0 + w, y0 + h] +} + /// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with /// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { @@ -6324,6 +6336,19 @@ mod tests { assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); } + /// #6015: a Fixed Size marquee pressed between pixels selected one pixel more on each side. + #[test] + fn fixed_size_px_is_exactly_the_size() { + use super::fixed_size_px; + // Down-right from (10.4, 20.6): 10 × 10 from the closest pixel edges. + assert_eq!(fixed_size_px([10.4, 20.6], [20.4, 30.6], 10.0, 10.0), [10.0, 21.0, 20.0, 31.0]); + // Up-left: the far corner is the low one; still exactly the size. + assert_eq!(fixed_size_px([10.4, 20.6], [-53.6, -11.4], 64.0, 32.0), [-54.0, -11.0, 10.0, 21.0]); + // 1 × 1 is one pixel, and a fractional size rounds to whole pixels. + assert_eq!(fixed_size_px([3.5, 3.5], [4.5, 4.5], 1.0, 1.0), [4.0, 4.0, 5.0, 5.0]); + assert_eq!(fixed_size_px([0.0, 0.0], [2.6, 2.4], 2.6, 2.4), [0.0, 0.0, 3.0, 2.0]); + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 321a31e73a..400353d321 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -164,6 +164,35 @@ fn shift_square_pressed_between_pixels_has_equal_sides() { } } +/// #6015: Fixed Size pressed between pixels selected one pixel more on each side (10 × 10 became +/// 11 × 11, 1 × 1 became 2 × 2). The readout and the selection are the entered size, and a click +/// without a drag places it too, as in Photoshop. +#[test] +fn fixed_size_selects_exactly_the_entered_size() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (w, ht) in [(10, 10), (1, 1), (64, 32)] { + let mut h = harness(tool); + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (w as f32, ht as f32); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + assert!(readout(&h, w, ht), "{tool:?} {w} × {ht}: the readout shows the entered size"); + release_at(&mut h, 90.0, 90.0, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(50, 61, 50 + w, 61 + ht), "{tool:?} {w} × {ht}: dragged"); + + // A click: the button goes down and up at one point. + h.state_mut().run("select.deselect", json!({})).unwrap(); + let p = screen(&h, 120.6, 100.4); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(&mut h, p, true, Modifiers::NONE); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(121, 100, 121 + w, 100 + ht), "{tool:?} {w} × {ht}: clicked"); + } + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { From 5f13491411c0f0eb76d37192c77efd46841d916b Mon Sep 17 00:00:00 2001 From: Bob Sabiston Date: Wed, 21 Oct 2026 11:43:15 -0500 Subject: [PATCH 22/23] Fix Fixed Size marquee positioning while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Position a fixed-size marquee at the current pointer throughout creation, including when crossing the initial press. Preserve Alt centering and completed-selection movement. Add real-canvas regression coverage at normal and atlas zoom levels. Builds on itsF4LCON’s exact-size and single-click correction from #6026, preserved as a separate preceding commit. --- crates/ui-egui/src/canvas.rs | 13 +++-- crates/ui-egui/src/marquee_tests.rs | 74 ++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e2332755d9..e4d36ad133 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -230,14 +230,21 @@ impl Drag { /// style (fixed ratio / fixed size), ⇧ for a square or circle, ⌥ to draw from the centre. pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -> ([f64; 2], [f64; 2]) { let (shift, alt) = d.marquee_mods(); + if o.marquee_style == "fixedSize" { + // A fixed box is positioned, not sized, by the drag. Using the press plus the drag's + // sign pinned it there and flipped it by its full width/height when the pointer crossed + // the press. The preview and release both follow the current pointer instead. + let (w, h) = (f64::from(o.marquee_width).max(1.0), f64::from(o.marquee_height).max(1.0)); + let a = if alt { [end[0] - w / 2.0, end[1] - h / 2.0] } else { end }; + return (a, [a[0] + w, a[1] + h]); + } let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end); if !alt { return (d.start, e); } let (dx, dy) = (e[0] - d.start[0], e[1] - d.start[1]); - // A fixed size is centred on the press point; otherwise the drag is the half extent. - let (hx, hy) = if o.marquee_style == "fixedSize" { (dx / 2.0, dy / 2.0) } else { (dx, dy) }; - ([d.start[0] - hx, d.start[1] - hy], [d.start[0] + hx, d.start[1] + hy]) + // For a sized marquee the drag is the half extent when drawing from the centre. + ([d.start[0] - dx, d.start[1] - dy], [d.start[0] + dx, d.start[1] + dy]) } /// The pixel rectangle a Rectangular/Elliptical Marquee drag selects so far: shown live (ants diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 400353d321..3a27897931 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -178,7 +178,7 @@ fn fixed_size_selects_exactly_the_entered_size() { press_at(&mut h, 50.4, 60.6, Modifiers::NONE); assert!(readout(&h, w, ht), "{tool:?} {w} × {ht}: the readout shows the entered size"); release_at(&mut h, 90.0, 90.0, Modifiers::NONE); - assert_eq!(selection(&h), Rect::new(50, 61, 50 + w, 61 + ht), "{tool:?} {w} × {ht}: dragged"); + assert_eq!(selection(&h), Rect::new(90, 90, 90 + w, 90 + ht), "{tool:?} {w} × {ht}: dragged to the release point"); // A click: the button goes down and up at one point. h.state_mut().run("select.deselect", json!({})).unwrap(); @@ -193,6 +193,78 @@ fn fixed_size_selects_exactly_the_entered_size() { } } +/// Fixed Size places a box rather than sizing it: the pointer positions it in every direction, +/// including crossing the press point, without flipping by the full width or height. +#[test] +fn fixed_size_drag_positions_the_box_in_every_direction() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for zoom in [1.0, 16.0] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].center = [60.0, 70.0]; + h.run_steps(2); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + for (x, y) in [(58.4, 66.6), (49.4, 59.6), (49.4, 66.6), (58.4, 59.6), (59.4, 60.6)] { + move_to(&mut h, x, y); + let app = h.state(); + let r = crate::canvas::marquee_preview_px(&app.ui.tool_options, app.drag.as_ref().unwrap(), zoom).unwrap(); + let (x, y) = (f64::from(x).round(), f64::from(y).round()); + assert_eq!(r, [x, y, x + 11.0, y + 11.0], "{tool:?} at zoom {zoom}: follows the pointer"); + assert!(readout(&h, 11, 11), "the live dimensions stay 11 × 11"); + } + // Commit the release position even if there was no move frame at this point. + let p = screen(&h, 60.4, 61.6); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(60, 62, 71, 73), "{tool:?}: release commits the final position"); + } + } +} + +#[test] +fn fixed_size_drag_inside_a_completed_box_still_moves_its_outline() { + for snap in [false, true] { + for zoom in [1.0, 26.730814] { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = snap; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].center = [112.0, 110.0]; + h.run_steps(2); + press_at(&mut h, 100.4, 100.6, Modifiers::NONE); + release_at(&mut h, 108.4, 106.6, Modifiers::NONE); + let r = selection(&h); + let (x, y) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0); + press_at(&mut h, x, y, Modifiers::NONE); + release_at(&mut h, x + 2.0, y + 1.0, Modifiers::NONE); + assert_eq!(selection(&h), r.translate(2, 1), "zoom {zoom}, snap {snap}"); + } + } +} + +#[test] +fn fixed_size_alt_centers_the_box_at_the_current_pointer() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + mods(&mut h, Modifiers::ALT); + move_to(&mut h, 100.4, 100.6); + assert!(readout(&h, 11, 11)); + release_at(&mut h, 110.4, 90.6, Modifiers::ALT); + assert_eq!(selection(&h), Rect::new(105, 85, 116, 96), "{tool:?}: Alt centres the moving box"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { From d1cd1387a1c26133b8eef7d8e22b37002c5057b0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:45:19 +0000 Subject: [PATCH 23/23] Integration: fix the two merged tests that no longer compile against main (StrokeFx overprint, per-tool Sample All Layers) --- crates/engine/src/cutout_cmds.rs | 1 + crates/ui-egui/src/object_finder.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index cceb60036a..7e4fafbbe5 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -323,6 +323,7 @@ mod tests { size: 3.0, position: photocraft_doc::StrokePosition::Outside, paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + overprint: false, })]; Ok(()) }) diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index 559c6fbf51..267c326775 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -300,7 +300,7 @@ mod tests { assert!(u.shown.is_none() && !u.busy); assert_eq!(u.hint, Some(empty_layer_hint())); // Sample All Layers on: no hint, the object is looked up. - app.ui.tool_options.sample_all_layers = true; + app.ui.tool_options.sample_all.object_selection = true; assert!(update(&mut app, Some([100.0, 80.0]), false, 100.0).hint.is_none()); assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); }