Browse / github.com/storytold/photocraft / patch
fix(selection): preserve fixed-size marquees beyond canvas edges (Photoshop parity)
fix(selection): preserve fixed-size marquees beyond canvas edges (Photoshop parity) ## Motivation PhotoCraft clips a Fixed Size marquee to the canvas when the selection is first committed. The part outside the image is lost, so moving the outline back inside cannot recover the requested dimensions. Photoshop retains the complete Fixed Size selection. For example, on a 100 × 100 image, place a 100 × 50 marquee at (50, 25), then move its outline 50 pixels left. PhotoCraft currently leaves a 50 × 50 selection; this PR retains the requested 100 × 50 selection. This branch is based on upstream main and contains no Qwen Edit fork changes. ## Changes - Add an optional `clipToCa
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- david419kr (imported from pull request #9878, open)
- Base
07bd794686ea4db0b557c978d58173a62760e4ca- Size
- +141 −13 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
01dcc316bb9f2f220eff6328
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd.rs crates/engine/src/cmd/selection_cmds.rs crates/engine/src/cmd/selection_cmds/tests.rs crates/ui-egui/src/canvas/gesture.rs crates/ui-egui/src/marquee_tests.rs docs/ui-parity.md
@@ -378,11 +378,16 @@ fn build() -> Vec<CommandSpec> { "Rectangular Selection", [], None,- r##"{"x":i32,"y":i32,"width":u32,"height":u32,"mode":"replace|add|subtract|intersect"="replace","ellipse":bool=false,"line":"row|column"?,"antiAlias":bool=true,"feather":px=0}"##,+ r##"{"x":i32,"y":i32,"width":u32,"height":u32,"mode":"replace|add|subtract|intersect"="replace","ellipse":bool=false,"clipToCanvas":bool=true (false keeps the full Fixed Size marquee outside the canvas),"line":"row|column"?,"antiAlias":bool=true,"feather":px=0}"##, has_doc, |s, p| { let get = |k: &str| int_i32("select.rect", p, k).and_then(|v| v.ok_or_else(|| bad("select.rect", format!("missing `{k}`"))));- let r = Rect::from_xywh(get("x")?, get("y")?, get("width")?.max(0) as u32, get("height")?.max(0) as u32);+ let (x, y, w, h) = (get("x")?, get("y")?, get("width")?.max(0) as u32, get("height")?.max(0) as u32);+ let clip = p.get("clipToCanvas").and_then(Value::as_bool).unwrap_or(true);+ if !clip && (x.checked_add_unsigned(w).is_none() || y.checked_add_unsigned(h).is_none()) {+ return Err(bad("select.rect", "the full marquee exceeds the pixel coordinate range"));+ }+ let r = Rect::from_xywh(x, y, w, h); let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace").to_string(); let ellipse = p.get("ellipse").and_then(Value::as_bool).unwrap_or(false); // The History step is named after the tool: the Single Row / Single Column@@ -398,9 +403,9 @@ fn build() -> Vec<CommandSpec> { let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); let feather = p.get("feather").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1000.0) as f32; s.edit(step, |doc, _| {- let area = doc.bounds();- // A marquee dragged past the canvas stops at its edge (Photoshop); the ellipse- // keeps the dragged shape and is only cut there.+ // Fixed Size keeps the entire shape so moving its outline can recover the+ // part initially outside the canvas. Normal marquees retain their clipping.+ let area = if clip { doc.bounds() } else { selection_cmds::unclipped_marquee_area(r, feather)? }; use photocraft_algo::selection as sel; let shape = if ellipse { sel::ellipse_surface(r, area, aa)@@ -417,14 +422,14 @@ fn build() -> Vec<CommandSpec> { } let old = doc.selection.take(); let combined = match (mode.as_str(), old) {- ("add", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.max(b)),- ("subtract", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a * (1.0 - b)),+ ("add", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a.max(b)),+ ("subtract", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a * (1.0 - b)), ("intersect", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.min(b)), // With no existing selection, neither operation can select new pixels. ("subtract" | "intersect", None) => Surface::new(photocraft_color::PixelFormat::GRAY8), _ => shape, };- // Nothing selected on the canvas (e.g. dragged entirely outside it) deselects.+ // An empty mask deselects; an unclipped marquee can live entirely outside the canvas. doc.selection = (!combined.content_bounds().is_empty()).then_some(combined); Ok(()) })?;@@ -1,6 +1,6 @@ //! Select menu and selection tools: Magic Wand, Color Range, Modify //! (expand/contract/border/smooth/feather), Grow, Similar, Lasso.-//! Selections are grayscale coverage surfaces clipped to the canvas.+//! Selections are grayscale coverage surfaces; Fixed Size marquees may extend past the canvas. use photocraft_algo::selection::{self as sel, SelectionMode}; use photocraft_doc::Document;@@ -668,6 +668,31 @@ pub(crate) fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: S (s.tile_count() > 0).then_some(s) } +/// Fixed Size has no canvas clipping to bound its allocation. Include the feather halo and+/// charge both tiles and dense blur buffers against Memory Usage before building anything.+pub(crate) fn unclipped_marquee_area(bounds: Rect, feather: f32) -> Result<Rect> {+ if bounds.is_empty() {+ return Ok(Rect::EMPTY);+ }+ let reach = i32::try_from(sel::feather_reach(feather)).unwrap_or(i32::MAX);+ let area = bounds.inflate(reach.saturating_add(1));+ let tile = i64::from(photocraft_geom::TILE_SIZE);+ let bytes = (u64::from(area.width()) + 2 * tile as u64).saturating_mul(u64::from(area.height()) + 2 * tile as u64).saturating_mul(if feather > 0.0 {+ 32+ } else {+ 1+ });+ // Tile rectangles and feather halos must remain representable at either coordinate end.+ let safe = |v: i32| i64::from(v) > i64::from(i32::MIN) + tile && i64::from(v) < i64::from(i32::MAX) - tile;+ if ![area.x0, area.y0, area.x1, area.y1].into_iter().all(safe) || bytes > crate::transform_cmds::budget() {+ return Err(EngineError::BadParams {+ cmd: "select.rect".into(),+ msg: "the full marquee is too large or outside the pixel coordinate range; reduce its size or increase Memory Usage in Preferences".into(),+ });+ }+ Ok(area)+}+ /// Keep dense feather masks local to the drawn shape. Clip before inflating: pixels outside /// the canvas never contribute to the original full-canvas blur, even near a canvas edge. pub(crate) fn feather_window(bounds: Rect, canvas: Rect, feather: f32) -> Rect {@@ -835,3 +835,67 @@ fn small_feathered_tools_work_on_large_sparse_documents_at_every_depth() { assert!(coverage(&s, 3025, 2025) > 0.95); } }++/// Fixed Size preserves the entire shape, including pixels initially beyond any canvas edge.+#[test]+fn unclipped_marquee_keeps_its_size_through_moves_and_history() {+ for (x, y, w, h) in [(50, 25, 100, 50), (-20, 25, 100, 50), (20, -20, 50, 100), (20, 80, 50, 100), (120, 120, 150, 120)] {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();+ s.execute("select.rect", json!({"x": x, "y": y, "width": w, "height": h, "clipToCanvas": false})).unwrap();+ let original = Rect::from_xywh(x, y, w, h);+ let bounds = |s: &Session| s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds();+ assert_eq!(bounds(&s), original);+ s.execute("select.transformSelection", json!({"dx": -x, "dy": -y})).unwrap();+ assert_eq!(bounds(&s), Rect::from_xywh(0, 0, w, h));+ // Moving completely off-canvas and back must not destroy the recovered pixels either.+ s.execute("select.transformSelection", json!({"dx": 200, "dy": -200})).unwrap();+ s.execute("select.transformSelection", json!({"dx": -200, "dy": 200})).unwrap();+ assert_eq!(bounds(&s), Rect::from_xywh(0, 0, w, h));+ assert!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, w, h)).iter().all(|v| *v == 1.0));+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(bounds(&s), Rect::from_xywh(200, -200, w, h));+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(bounds(&s), Rect::from_xywh(0, 0, w, h));+ }+}++#[test]+fn unclipped_marquee_combines_the_full_selection() {+ for clip in [true, false] {+ for (mode, expected) in [("add", Rect::new(0, 25, 150, 75)), ("subtract", Rect::new(80, 25, 150, 75)), ("intersect", Rect::new(50, 25, 80, 75))] {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();+ s.execute("select.rect", json!({"x": 50, "y": 25, "width": 100, "height": 50, "clipToCanvas": false})).unwrap();+ s.execute("select.rect", json!({"x": 0, "y": 25, "width": 80, "height": 50, "clipToCanvas": clip, "mode": mode})).unwrap();+ assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), expected, "{mode}, clip={clip}");+ }+ }+}++#[test]+fn unclipped_marquee_feather_and_ellipse_match_a_larger_canvas() {+ for ellipse in [false, true] {+ for feather in [0.0, 2.0] {+ let make = |size| {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": size, "height": size})).unwrap();+ s.execute("select.rect", json!({"x": 50, "y": 25, "width": 100, "height": 50, "ellipse": ellipse, "feather": feather, "clipToCanvas": false}))+ .unwrap();+ s.active().unwrap().doc.selection.clone().unwrap()+ };+ assert_eq!(make(100), make(200), "ellipse={ellipse}, feather={feather}");+ }+ }+}++#[test]+fn unclipped_marquee_rejects_unbounded_sizes_without_changing_selection() {+ let mut s = session();+ s.execute("select.rect", json!({"x": 1, "y": 2, "width": 10, "height": 10})).unwrap();+ let before = s.active().unwrap().doc.selection.clone();+ for (x, y, w, h) in [(0, 0, i32::MAX, i32::MAX), (i32::MAX - 5, 0, 100, 50), (i32::MIN, 0, 100, 50), (0, i32::MAX - 5, 100, 50)] {+ assert!(s.execute("select.rect", json!({"x": x, "y": y, "width": w, "height": h, "clipToCanvas": false, "feather": 2})).is_err());+ assert_eq!(s.active().unwrap().doc.selection, before);+ }+}@@ -97,7 +97,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { }; let mode = selection_mode(app, d.modifiers); let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather);- let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather}));+ let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "clipToCanvas": app.ui.tool_options.marquee_style != "fixedSize", "antiAlias": aa, "feather": feather})); } // A click (or the end of a drag) selects the line under the pointer, never deselects. Tool::SingleRowMarquee | Tool::SingleColumnMarquee => {@@ -17,8 +17,12 @@ fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { } fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, PhotocraftApp> {+ harness_with_canvas(tool, step_dt, [400, 300])+}++fn harness_with_canvas(tool: Tool, step_dt: f32, [width, height]: [u32; 2]) -> 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.run("file.new", json!({"width": width, "height": height})).unwrap(); app.sync_views(); app.ui.extras.rulers = false; app.ui.tool = tool;@@ -38,7 +42,7 @@ fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, Photocraft // 100 %: one document pixel per point, so drags land on whole pixels. let v = &mut h.state_mut().ui.views[0]; v.zoom = 1.0;- v.center = [200.0, 150.0];+ v.center = [width as f32 / 2.0, height as f32 / 2.0]; v.fit_pending = false; h.run_steps(2); h@@ -303,6 +307,34 @@ fn fixed_size_drag_inside_a_completed_box_still_moves_its_outline() { } } +/// A 100 × 50 box clicked halfway across a 100² document stays 100 × 50 when moved back.+#[test]+fn fixed_size_keeps_the_off_canvas_part_when_its_outline_moves() {+ for width in [100.0, 160.0] {+ let mut h = harness_with_canvas(Tool::RectMarquee, 0.25, [100, 100]);+ 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) = (width, 50.0);+ move_to(&mut h, 50.0, 25.0);+ let p = screen(&h, 50.0, 25.0);+ button(&mut h, p, true, Modifiers::NONE);+ button(&mut h, p, false, Modifiers::NONE);+ assert_eq!(selection(&h), Rect::from_xywh(50, 25, width as u32, 50));+ press_at(&mut h, 75.0, 40.0, Modifiers::NONE);+ release_at(&mut h, 25.0, 40.0, Modifiers::NONE);+ assert_eq!(selection(&h), Rect::from_xywh(0, 25, width as u32, 50));+ // Cross the left/top edges and move back again, without rebuilding from the options.+ h.state_mut().ui.tool_options.marquee_width = 11.0;+ press_at(&mut h, 25.0, 40.0, Modifiers::NONE);+ release_at(&mut h, 5.0, 5.0, Modifiers::NONE);+ assert_eq!(selection(&h), Rect::from_xywh(-20, -10, width as u32, 50));+ press_at(&mut h, 10.0, 10.0, Modifiers::NONE);+ release_at(&mut h, 30.0, 45.0, Modifiers::NONE);+ assert_eq!(selection(&h), Rect::from_xywh(0, 25, width as u32, 50));+ }+}+ #[test] fn fixed_size_alt_centers_the_box_at_the_current_pointer() { for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {@@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Fixed Size marquees retain their complete shape beyond the canvas and recover it when moved; 12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **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,@@ -110,6 +110,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | ⇧ square/circle, ⌥ from centre, live W × H readout (marquee) | done | `marquee_tests.rs` |+| Fixed Size marquee retains its dimensions past canvas edges, including after moving the outline | done | `marquee_tests.rs::fixed_size_keeps_the_off_canvas_part_when_its_outline_moves`; `selection_cmds/tests.rs::unclipped_marquee_keeps_its_size_through_moves_and_history` | | ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | done | tested for the marquee (add, subtract, ⇧⌥ intersect: `marquee_tests.rs`), Lasso (all three: `lasso_ui/tests.rs`) and Polygonal Lasso (`polygon_lasso_tests.rs`) (BUG-208-4) | | Space repositions the marquee while dragging | done | `hold_keys.rs` | | Polygonal Lasso ⇧ snaps to 45° | missing | #2352 |@@ -246,6 +247,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---|+| 2026-10-28 | minor | Fixed Size marquees keep the complete selection mask beyond the canvas, including for sizes larger than the image; outline moves and undo/redo preserve it. Normal marquees keep their existing clipping. Scores unchanged. | | 2026-10-25 | minor | Every preference takes effect: 12 of 178 do nothing, the scorecard's count (it still said 46 of 149) | | 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) | | 2026-10-24 | minor | Swatches: a click recolours the selected type layers and shape fills whatever the tool, as Photoshop 2025 does (measured with the Move and Brush tools); the Color panel still recolours them only with a Type, shape or Pen tool (#9238). Scores unchanged. |