diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 4febf31d..c15cce78 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -425,6 +425,11 @@ pub enum Cursor { ResizeNwSe, ResizeNeSw, Rotate, + /// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off + /// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps + /// the corner it is at, so at the top right it opens toward the bottom left. Make it with + /// [`Cursor::rotate_about`]. + RotateToward(u8), /// Over a Live Corners widget (drag to round the corners). CornerRadius, Pen, @@ -476,6 +481,18 @@ pub enum Cursor { } impl Cursor { + /// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`] + /// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself. + pub fn rotate_about(center: Point, p: Point) -> Self { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return Cursor::Rotate; + } + // y points down the page: atan2(x, -y) is 0 straight up and grows clockwise. + let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64; + Cursor::RotateToward(octant.rem_euclid(8) as u8) + } + /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the /// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. pub fn precise(self) -> Self { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 4a3a4dca..21934d78 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -444,7 +444,7 @@ impl Tool for SelectionTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.state { - State::Rotating { .. } => return Cursor::Rotate, + State::Rotating { center, .. } => return Cursor::rotate_about(center, p), State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle), State::Moving { began: true, .. } => return Cursor::Arrow, State::Corner(_) => return Cursor::CornerRadius, @@ -468,7 +468,7 @@ impl Tool for SelectionTool { { match box_hit(cx, &bx, p) { Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle), - Some(BoxHit::Rotate) => return Cursor::Rotate, + Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p), None => {} } } @@ -588,6 +588,24 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty()); } + /// Just outside each corner of the box the rotate cursor bows toward that corner, its arc + /// opening toward the box: at the top right it opens to the bottom left. + #[test] + fn the_rotate_cursor_wraps_the_corner_it_is_at() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let t = SelectionTool::default(); + // The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally. + for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] { + assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})"); + } + assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre"); + assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up"); + } + #[test] fn drag_moves_with_single_undo() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 73d631f0..54099c81 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -313,7 +313,7 @@ impl Tool for FreeTransformTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { let Some(r) = selection_bounds(cx) else { return Cursor::Arrow }; match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { - Some(Op::Rotate) => Cursor::Rotate, + Some(Op::Rotate) => Cursor::rotate_about(r.center(), p), Some(Op::Move) => Cursor::Move, Some(Op::Scale(h) | Op::Shear(h)) => match h { Handle::Top | Handle::Bottom => Cursor::ResizeV, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8cfcfe58..16fc26a5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -487,7 +487,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeV => C::ResizeVertical, Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, - Cursor::Rotate => C::Alias, + Cursor::Rotate | Cursor::RotateToward(_) => C::Alias, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => { C::Crosshair } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index d98a2d2e..f273af20 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -113,17 +113,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) { } /// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along -/// the arc (#701). -fn rotate(p: &mut Ink, o: Pos2) { +/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a +/// bounding box it wraps the corner the pointer is at. +fn rotate(p: &mut Ink, o: Pos2, octant: u8) { const R: f32 = 8.0; let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88); - let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R); + // Clockwise on screen (y down) by whole octants. + let (s, c) = (f32::from(octant % 8) * std::f32::consts::FRAC_PI_4).sin_cos(); + let turn = |v: Vec2| vec2(v.x * c - v.y * s, v.x * s + v.y * c); + let at = |a: f32| o + turn(vec2(a.cos() * R, -a.sin() * R)); let pts: Vec = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect(); p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO))); p.add(Shape::line(pts, Stroke::new(1.3, INK))); // Each head points away from the arc, along its tangent there. for (a, away) in [(a0, -1.0f32), (a1, 1.0)] { - let t = vec2(-a.sin(), -a.cos()) * away; + let t = turn(vec2(-a.sin(), -a.cos()) * away); let n = vec2(-t.y, t.x); let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5); poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK); @@ -269,7 +273,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)), Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)), Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)), - Cursor::Rotate => rotate(ink, p), + Cursor::Rotate => rotate(ink, p, 0), + Cursor::RotateToward(octant) => rotate(ink, p, octant), Cursor::CornerRadius => corner_radius(ink, p), Cursor::Pen => pen(ink, p, "*"), Cursor::PenAdd => pen(ink, p, "+"), @@ -432,7 +437,7 @@ mod tests { use crate::VectorcraftApp; /// Every cursor with a glyph. - const GLYPHS: [Cursor; 33] = [ + const GLYPHS: [Cursor; 34] = [ Cursor::Arrow, Cursor::ArrowHollow, Cursor::Move, @@ -442,6 +447,7 @@ mod tests { Cursor::ResizeNwSe, Cursor::ResizeNeSw, Cursor::Rotate, + Cursor::RotateToward(1), Cursor::CornerRadius, Cursor::Pen, Cursor::PenAdd,