diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d5b4bdb03..87502be6f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -208,9 +208,10 @@ pub struct Session { /// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]). /// Tests set it. pub search_rules: Option, - /// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared - /// when the preview params change or the interaction ends. - pub(crate) deferred_overlay_cache: Option<(String, Value, Vec)>, + /// Canvas feedback cached for the pending deferred preview ([`Session::overlays`], + /// [`Session::deferred_transform_box`]); cleared when the preview params change or the + /// interaction ends. + pub(crate) deferred_overlay_cache: Option, /// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set /// it. pub search_threads: Option, diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c54bda129..df6b01cc3 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -572,6 +572,11 @@ impl Session { self.tool.transforming() } + /// Is the active tool resizing the selection by a bounding-box handle? + pub fn tool_resizing(&self) -> bool { + self.tool.resizing() + } + /// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)? pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool { self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) @@ -833,25 +838,48 @@ impl Session { Ok(ui) } - /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take - /// outlines from the objects it touched, then put the document back at the interaction snapshot. - fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { - if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) { - return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default(); + /// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the + /// pending preview of a handle resize leaves it, measured once per preview step; `None` when no + /// such preview is pending, or when the preview fails. + pub fn deferred_transform_box(&mut self) -> Option { + if !self.tool.resizing() { + return None; } + let (cmd, p) = self.unapplied_interaction_preview()?; + self.deferred_feedback(&cmd, &p)?.transform_box + } + + fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { + self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default() + } + + /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take + /// outlines from the objects it touched (and, during a handle resize, their transform box), then + /// put the document back at the interaction snapshot. + fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> { + if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) { + let feedback = self.measure_deferred_preview(cmd, p)?; + self.deferred_overlay_cache = Some(feedback); + } + self.deferred_overlay_cache.as_ref() + } + + fn measure_deferred_preview(&mut self, cmd: &str, p: &Value) -> Option { let (snapshot, selection, revision, paint, interaction_fields) = { - let Some(st) = self.active() else { return vec![] }; - let Some(it) = st.interaction.as_ref() else { return vec![] }; + let st = self.active()?; + let it = st.interaction.as_ref()?; if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) { - return vec![]; + return None; } let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone()); (st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields) }; - let overlays = if self.preview(cmd, p).is_ok() { - self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default() + let (overlays, transform_box) = if self.preview(cmd, p).is_ok() { + let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default(); + let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default(); + (overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None }) } else { - vec![] + (vec![], None) }; if let Ok(st) = self.doc_mut() { st.doc = snapshot; @@ -864,11 +892,18 @@ impl Session { } } self.pending_paint = paint; - self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone())); - overlays + Some(DeferredFeedback { cmd: cmd.to_string(), params: p.clone(), overlays, transform_box }) } } +/// What the canvas shows for a deferred preview: see [`Session::deferred_transform_box`]. +pub(crate) struct DeferredFeedback { + cmd: String, + params: Value, + overlays: Vec, + transform_box: Option, +} + fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec { selection .objects diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 67d2f5909..20b4178e1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -139,10 +139,15 @@ pub trait Tool: Send { false } /// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box, - /// so only the art is seen going with the pointer)? + /// so only the art is seen going with the pointer, except during a handle resize)? fn transforming(&self) -> bool { false } + /// Is the transforming drag a resize by a bounding-box handle? The canvas then keeps drawing + /// the bounding box at the changing bounds. + fn resizing(&self) -> bool { + false + } /// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool /// with a stop selected takes Delete and the arrows)? fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index bcbce02ee..0aac591c9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -665,6 +665,10 @@ impl Tool for SelectionTool { self.alt_hover } + fn resizing(&self) -> bool { + matches!(self.state, State::Scaling { .. }) + } + fn transforming(&self) -> bool { match &self.state { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 4f7e83258..b01deee0f 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -91,12 +91,22 @@ fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) { // The Selection tool's bounding box (rotated with the objects after a rotation). It hides while - // a drag moves or resizes the selection, so only the art is seen going with the pointer (#712). + // a drag moves or rotates the selection, so only the art is seen going with the pointer (#712). + // A handle resize keeps it at the changing bounds (#3526). + let resizing = app.session.tool_resizing(); let show_box = app.session.tool_id() == "selection" - && !app.session.tool_transforming() + && (resizing || !app.session.tool_transforming()) && app.ui.view.bounding_box && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); - let bbox = if show_box { app.selection_box() } else { None }; + // With Real-time Drawing off the document stays at the drag's snapshot, so the box is measured + // from the pending preview; otherwise each step's revision measures it once (`selection_box`). + let bbox = if !show_box { + None + } else if let Some(b) = resizing.then(|| app.session.deferred_transform_box()).flatten() { + Some(b) + } else { + app.selection_box() + }; let st = app.session.active(); let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); let big_bounds = if big { app.selection_bounds() } else { None }; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4b59bdb8f..24cf3a6b1 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3638,6 +3638,96 @@ fn hidden_corner_widgets_do_not_answer_the_pointer() { assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app)); } +/// One headless canvas frame → the bounding box drawn: its outline's corners on screen (the +/// closed four-point line in the layer color; `None` when it is hidden) and the number of its +/// handles (white 6 px squares). +fn bounding_box_drawn(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option>, usize) { + let st = app.session.active().unwrap(); + let layer = egui::epaint::ColorMode::Solid(c32(st.doc.layer_color(st.selection.objects[0]))); + let drawn = shapes(app, ctx); + let outline = drawn.iter().find_map(|s| match s { + Shape::Path(ps) if ps.closed && ps.points.len() == 4 && ps.stroke.color == layer => Some(ps.points.clone()), + _ => None, + }); + let handle = |s: &&Shape| matches!(s, Shape::Rect(r) if r.rect.size() == vec2(6.0, 6.0) && r.fill == Color32::WHITE); + (outline, drawn.iter().filter(handle).count()) +} + +/// The selection's bounding box corners on screen, as measured from the document. +fn box_corners(app: &mut VectorcraftApp) -> Vec { + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + app.selection_box().unwrap().corners().iter().map(|q| xf.to_screen(*q)).collect() +} + +/// Do two outlines have the same corners, to 0.01 px? +fn same_corners(a: &[Pos2], b: &[Pos2]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| p.distance(*q) < 0.01) +} + +/// #3526: a resize by a bounding-box handle keeps the bounding box and its handles drawn at the +/// bounds the drag gives, with Real-time Drawing on and off (off, the document stays at the drag's +/// snapshot until the release). +#[test] +fn a_resize_drag_keeps_the_bounding_box_at_the_new_bounds() { + for real_time in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let before = box_corners(&mut app); + let (outline, handles) = bounding_box_drawn(&mut app, &ctx); + assert!(outline.is_some_and(|o| same_corners(&o, &before)) && handles == 8, "real time {real_time}: the box at rest"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 160.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 263.0, 211.0), view).unwrap(); + assert!(app.session.tool_resizing(), "real time {real_time}: the handle was hit"); + let (during, handles) = bounding_box_drawn(&mut app, &ctx); + assert_eq!(handles, 8, "real time {real_time}: the handles show during the resize"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 263.0, 211.0), view).unwrap(); + let after = box_corners(&mut app); + assert!(!same_corners(&after, &before), "real time {real_time}: the drag resized the ellipse"); + assert!(during.as_ref().is_some_and(|o| same_corners(o, &after)), "real time {real_time}: {during:?}, resized to {after:?}"); + let (outline, handles) = bounding_box_drawn(&mut app, &ctx); + assert!(outline.is_some_and(|o| same_corners(&o, &after)) && handles == 8, "real time {real_time}: the box after the release"); + } +} + +/// A move, a rotation and a corner-radius drag keep hiding the bounding box and its handles until +/// the release (#712, #1229). +#[test] +fn a_move_rotate_or_corner_radius_drag_hides_the_bounding_box() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let drag = |app: &mut VectorcraftApp, from: Point, to: Point| { + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), view).unwrap(); + assert!(app.session.tool_transforming() && !app.session.tool_resizing()); + let during = bounding_box_drawn(app, &ctx); + app.session.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), view).unwrap(); + during + }; + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8); + assert_eq!(drag(&mut app, Point::new(150.0, 150.0), Point::new(160.0, 160.0)), (None, 0), "a move"); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the move"); + let offset = 10.0 / app.view_info().zoom; + assert_eq!(drag(&mut app, Point::new(210.0 + offset, 210.0 + offset), Point::new(260.0, 160.0)), (None, 0), "a rotation"); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the rotation"); + let corner = { + let st = app.session.active().unwrap(); + vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap() + }; + assert_eq!(drag(&mut app, corner, corner + vectorcraft_geom::Vec2::new(10.0, 10.0)), (None, 0), "a corner-radius drag"); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the corner-radius drag"); +} + /// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection /// tool then answers neither: a double-click on the widget's place leaves point type as it is, /// and a drag from a corner handle's place does not scale. With edges shown, both answer.