From 9d6f43c149ec45189b6506b4f7a200ef521fe8a5 Mon Sep 17 00:00:00 2001 From: zakariayacine Date: Sun, 11 Oct 2026 16:41:30 +0100 Subject: [PATCH 1/2] Hand and Zoom cursors drawn like the tool cursors On Windows the Hand tool, Space and the Zoom tool showed the wrong cursors: winit maps egui's Grab and Grabbing to the four-headed move cursor (IDC_SIZEALL) and has nothing for ZoomIn/ZoomOut, so the hand appeared as arrows and the magnifier not at all. Those four were the only tool cursors left to the system. They are now vector glyphs like the others (an open and a closed hand, a magnifier with a plus or a minus), shown as OS cursors on the desktop and painted on the web, and the canvas routes the Space, Hand and Zoom states through the same path as the tool cursors. Tests: the glyphs rasterize at every scale with the others; only the busy states stay system cursors. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/canvas.rs | 16 ++++++---- crates/ui-egui/src/cursors.rs | 56 +++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 10e5d6da..0242b3b3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -442,14 +442,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let space = ui.input(|i| i.key_down(egui::Key::Space)); let panning = matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::Pan { .. })); let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; - let cur = if panning { - egui::CursorIcon::Grabbing + // The hand and the magnifier are drawn like the tool cursors below: Windows has no + // system cursor for them (winit shows the four-headed move cursor for Grab). + let view_cursor = if panning { + Some(Cursor::HandGrab) } else if zooming { - if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } + Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) } else if space || app.session.tool_id() == "hand" { - egui::CursorIcon::Grab - } else if let Some(p) = app.hover_doc { - let c = app.session.cursor(p, mods(m, space), view_info); + Some(Cursor::Hand) + } else { + None + }; + let cur = if let Some(c) = view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))) { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); // The loaded place cursor carries the file's thumbnail. if app.session.tool_id() == "place" diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index f273af20..8166520b 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -257,8 +257,46 @@ fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) { } } +/// The Hand tool and Space: an open hand (palm and four fingers, thumb to the left), or the same +/// hand closed while it drags the view. Windows has no system hand that isn't a pointing finger +/// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the +/// other tool cursors. Hotspot in the palm. +fn hand(p: &mut Ink, o: Pos2, closed: bool) { + // Palm: a rounded block below the hotspot. + let palm = vec![o + vec2(-7.0, -2.0), o + vec2(7.0, -2.0), o + vec2(8.0, 6.0), o + vec2(4.0, 11.0), o + vec2(-5.0, 11.0), o + vec2(-9.0, 5.0)]; + poly(p, palm, HALO, INK); + // Thumb, out to the left. + poly(p, vec![o + vec2(-7.0, 0.0), o + vec2(-13.0, -4.0), o + vec2(-14.0, -1.0), o + vec2(-9.0, 5.0)], HALO, INK); + // Four fingers: up from the palm (open), or folded over it (closed). + for (i, x) in [-5.5f32, -1.5, 2.5, 6.0].into_iter().enumerate() { + let top = if closed { + -5.0 + } else if i == 0 || i == 3 { + -11.0 + } else { + -14.0 + }; + poly(p, vec![o + vec2(x - 1.8, -2.0), o + vec2(x - 1.8, top), o + vec2(x + 1.8, top), o + vec2(x + 1.8, -2.0)], HALO, INK); + } +} + +/// The Zoom tool: a magnifier with a plus in the lens (Alt: a minus), the lens centred on the +/// hotspot, the handle down to the right. +fn magnifier(p: &mut Ink, o: Pos2, out: bool) { + const R: f32 = 7.0; + p.circle_stroke(o, R, Stroke::new(3.0, HALO)); + let d = vec2(R * 0.72, R * 0.72); + line(p, o + d, o + d + vec2(7.0, 7.0)); + p.circle_filled(o, R, HALO); + p.circle_stroke(o, R, Stroke::new(1.2, INK)); + line(p, o - vec2(3.5, 0.0), o + vec2(3.5, 0.0)); + if !out { + line(p, o - vec2(0.0, 3.5), o + vec2(0.0, 3.5)); + } +} + /// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors -/// (hand, zoom, busy states). +/// (the busy states). fn glyph(c: Cursor, p: Pos2) -> Option> { let ink = &mut Ink::default(); match c { @@ -299,6 +337,10 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::TypeWidget => type_widget(ink, p), Cursor::ShapeBuilder => shape_builder(ink, p, false), Cursor::ShapeBuilderErase => shape_builder(ink, p, true), + Cursor::Hand => hand(ink, p, false), + Cursor::HandGrab => hand(ink, p, true), + Cursor::ZoomIn => magnifier(ink, p, false), + Cursor::ZoomOut => magnifier(ink, p, true), _ => return None, } Some(std::mem::take(&mut ink.0)) @@ -437,7 +479,11 @@ mod tests { use crate::VectorcraftApp; /// Every cursor with a glyph. - const GLYPHS: [Cursor; 34] = [ + const GLYPHS: [Cursor; 38] = [ + Cursor::Hand, + Cursor::HandGrab, + Cursor::ZoomIn, + Cursor::ZoomOut, Cursor::Arrow, Cursor::ArrowHollow, Cursor::Move, @@ -492,9 +538,7 @@ mod tests { assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255), "{c:?}: nothing opaque"); } } - for c in [Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, Cursor::ZoomOut, Cursor::NotAllowed] { - assert!(rasterize(c, 1.0).is_none(), "{c:?} stays a system cursor"); - } + assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors"); for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] { assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}"); } @@ -529,7 +573,7 @@ mod tests { let b = images.get(Cursor::Pen, 1.5).unwrap(); assert!(Arc::ptr_eq(&a.rgba, &b.rgba)); assert!(!Arc::ptr_eq(&a.rgba, &images.get(Cursor::Pen, 2.0).unwrap().rgba)); - assert!(images.get(Cursor::Hand, 1.5).is_none()); + assert!(images.get(Cursor::NotAllowed, 1.5).is_none()); assert!(images.get(Cursor::Arrow, f32::NAN).is_none()); assert_eq!(images.0.len(), 2); } From 1c19bf160ad0828369672e1ac6eca77852cb77be Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 00:27:12 +0700 Subject: [PATCH 2/2] Hand and Zoom cursors: draw the hand's parts without halos over their outlines, the plus whole --- crates/ui-egui/src/cursors.rs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 8166520b..9e49e4a2 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -262,13 +262,10 @@ fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) { /// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the /// other tool cursors. Hotspot in the palm. fn hand(p: &mut Ink, o: Pos2, closed: bool) { - // Palm: a rounded block below the hotspot. - let palm = vec![o + vec2(-7.0, -2.0), o + vec2(7.0, -2.0), o + vec2(8.0, 6.0), o + vec2(4.0, 11.0), o + vec2(-5.0, 11.0), o + vec2(-9.0, 5.0)]; - poly(p, palm, HALO, INK); - // Thumb, out to the left. - poly(p, vec![o + vec2(-7.0, 0.0), o + vec2(-13.0, -4.0), o + vec2(-14.0, -1.0), o + vec2(-9.0, 5.0)], HALO, INK); - // Four fingers: up from the palm (open), or folded over it (closed). - for (i, x) in [-5.5f32, -1.5, 2.5, 6.0].into_iter().enumerate() { + // Thumb out to the left, four fingers up from the palm (open) or folded over it (closed), + // then the palm, a rounded block below the hotspot, over their bases. + let mut parts = vec![vec![o + vec2(-7.0, 0.0), o + vec2(-13.0, -4.0), o + vec2(-14.0, -1.0), o + vec2(-9.0, 5.0)]]; + for (i, x) in [-5.5f32, -1.75, 2.0, 5.75].into_iter().enumerate() { let top = if closed { -5.0 } else if i == 0 || i == 3 { @@ -276,7 +273,16 @@ fn hand(p: &mut Ink, o: Pos2, closed: bool) { } else { -14.0 }; - poly(p, vec![o + vec2(x - 1.8, -2.0), o + vec2(x - 1.8, top), o + vec2(x + 1.8, top), o + vec2(x + 1.8, -2.0)], HALO, INK); + parts.push(vec![o + vec2(x - 1.5, 0.0), o + vec2(x - 1.5, top), o + vec2(x + 1.5, top), o + vec2(x + 1.5, 0.0)]); + } + parts.push(vec![o + vec2(-7.0, -2.0), o + vec2(7.25, -2.0), o + vec2(8.0, 6.0), o + vec2(4.0, 11.0), o + vec2(-5.0, 11.0), o + vec2(-9.0, 5.0)]); + // Every halo first, so no part's halo covers another's outline. + for part in &parts { + p.add(Shape::closed_line(part.clone(), Stroke::new(3.0, HALO))); + } + for part in parts { + p.add(Shape::convex_polygon(part.clone(), HALO, Stroke::NONE)); + p.add(Shape::closed_line(part, Stroke::new(1.0, INK))); } } @@ -289,9 +295,11 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { line(p, o + d, o + d + vec2(7.0, 7.0)); p.circle_filled(o, R, HALO); p.circle_stroke(o, R, Stroke::new(1.2, INK)); - line(p, o - vec2(3.5, 0.0), o + vec2(3.5, 0.0)); + // Inside the white lens, without halos: a halo would cut the plus's other bar. + let bar = Stroke::new(1.2, INK); + p.line_segment([o - vec2(3.5, 0.0), o + vec2(3.5, 0.0)], bar); if !out { - line(p, o - vec2(0.0, 3.5), o + vec2(0.0, 3.5)); + p.line_segment([o - vec2(0.0, 3.5), o + vec2(0.0, 3.5)], bar); } }