From e92792e3e89dc7f19c9e2b7f1234770f503de06e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 14:36:39 +0200 Subject: [PATCH] M1: a press on a corner handle resizes instead of picking the corner-radius handle (fixes #292) The corner-radius handle sits 10 px in from each corner and answers within 6 px, so a press on the inner half of the 8 px corner square picked it: the drag rounded the corners or, dragged outward, did nothing. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the constrain-proportions link); further in the radius handle still answers. --- ROADMAP.md | 1 + crates/engine/src/tools.rs | 19 +++++++++++- .../engine/src/transform_behaviour_tests.rs | 29 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..a752a6ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..07f277d4 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -617,6 +617,18 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option< handle_at_corners(&corners, p, zoom, is_line) } +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + /// A box whose sides are both shorter than this on screen (pixels) would be covered by its /// handles: the canvas draws just its outline (the handles still answer just outside it). pub const SMALL_BOX_PX: f64 = 16.0; @@ -982,7 +994,11 @@ impl Session { let mut measure_box = None; let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; self.tool.overlay.arc_hover = arc_hover; - let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; self.tool.overlay.shape_hover = shape_hover; if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { cursor = Cursor::Text; @@ -1090,6 +1106,7 @@ impl Session { return Ok(()); } if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) { self.begin_interaction("Corner radius")?; diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 3fbecb01..330473a4 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -277,6 +277,35 @@ fn constrain_proportions_keeps_the_other_side_in_ratio() { assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale"); } +#[test] +fn a_press_anywhere_on_a_corner_handle_resizes() { + // The canvas draws each corner handle as an 8 px square centred on the corner, and the + // corner-radius handle sits 10 px in along the diagonal: a press on the square's inner half + // resizes (both sides, in ratio with the link on), it doesn't pick the radius handle. + let radii = |s: &Session, r: NodeId| s.doc().unwrap().node(r).unwrap().corners.radii; + for lock in [false, true] { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None); + set(&mut s, &[r], json!({"constrainProportions": lock})); + ex(&mut s, "select.set", json!({"ids": [r]})); + let on_square = Point::new(997.0, 597.0); + s.pointer(&ev(PointerKind::Hover, on_square, NONE)).unwrap(); + assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)), "lock {lock}: {:?}", s.tool.overlay.cursor); + drag(&mut s, on_square, Point::new(1197.0, 637.0), NONE); + let (w, h) = size(&s, r); + let want_h = if lock { 718.2 } else { 637.0 }; + assert!(near(w, 1197.0) && near(h, want_h), "lock {lock}: both sides follow the corner, got {w}×{h}"); + assert_eq!(radii(&s, r), [0.0; 4]); + } + // Further in, past the square, the radius handle still answers. + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None); + ex(&mut s, "select.set", json!({"ids": [r]})); + drag(&mut s, Point::new(993.0, 593.0), Point::new(980.0, 580.0), NONE); + assert_eq!(size(&s, r), (1000.0, 600.0)); + assert!(radii(&s, r)[0] > 0.0, "the radius handle rounds the corners"); +} + #[test] fn frames_resize_with_constraints_and_cmd_ignores_them() { let mut s = session();