From 56c8ed96673efaf553d44ac0f9f0ad6cc72182c3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 04:12:43 +0000 Subject: [PATCH] Canvas: tools show their own cursors (Direct Selection, Pen states) Tool cursors are glyphs drawn in code (black ink, white halo): the filled arrow for Selection, the hollow arrow for Direct Selection, the pen nib with an add, delete, close or convert badge, and a curved double arrow for rotating. Linux and Windows show them as OS cursor bitmaps, rasterized per pixels-per-point and cached; macOS paints them at the pointer, since it sizes cursor bitmaps in points; the web build keeps the system cursors. Text, hand, zoom and resize cursors stay system cursors. The Pen tool reports what a click does: over the first anchor of the path being drawn it closes the path, and between paths a click on a selected path's anchor deletes it and a click on its outline adds one. The Add, Delete and Convert Direction Point tools show the matching pen badges. The glyph geometry and the rasterizer are adapted from VectorCraft's code-drawn tool cursors (same organisation, original work). Fixes #438 --- crates/tools/src/anchors.rs | 38 ++-- crates/tools/src/lib.rs | 8 + crates/tools/src/pen.rs | 76 +++++-- crates/tools/src/tests.rs | 50 +++++ crates/ui-egui/src/canvas.rs | 53 ++--- crates/ui-egui/src/cursors.rs | 369 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + 7 files changed, 543 insertions(+), 55 deletions(-) create mode 100644 crates/ui-egui/src/cursors.rs diff --git a/crates/tools/src/anchors.rs b/crates/tools/src/anchors.rs index bdcf3b27..3ce10b15 100644 --- a/crates/tools/src/anchors.rs +++ b/crates/tools/src/anchors.rs @@ -34,6 +34,16 @@ fn targets(cx: &ToolContext, p: Point) -> Vec { if cx.selection.items.is_empty() { cx.hit(p).map(|(_, id)| vec![id]).unwrap_or_default() } else { cx.selection.items.clone() } } +/// The first of `ids` whose outline passes within 5 screen pixels of canvas point `p`. +pub(crate) fn segment_at(cx: &ToolContext, ids: &[ItemId], p: Point) -> Option { + ids.iter().copied().find(|&id| { + let (Some(it), Some(xf)) = (cx.doc.item(id), cx.item_canvas_xf(id)) else { return false }; + let inner = (xf * it.xf).inverse() * p; + let scale = (xf * it.xf).inverse().determinant().abs().sqrt(); + it.path.nearest(inner).is_some_and(|n| n.4 <= cx.tol(5.0) * scale) + }) +} + impl Tool for AnchorTool { fn id(&self) -> &'static str { match self.kind { @@ -49,21 +59,12 @@ impl Tool for AnchorTool { match (self.kind, ev.kind) { (Kind::Add | Kind::Scissors, PointerKind::Down) => { let Some((_, sp)) = cx.layout.spread_at(p) else { return vec![] }; - // The first target whose outline passes near the pointer. - for id in targets(cx, p) { - let Some(it) = cx.doc.item(id) else { continue }; - let Some(xf) = cx.item_canvas_xf(id) else { continue }; - let inner = (xf * it.xf).inverse() * p; - let scale = (xf * it.xf).inverse().determinant().abs().sqrt(); - if it.path.nearest(inner).is_some_and(|n| n.4 <= cx.tol(5.0) * scale) { - return vec![if self.kind == Kind::Scissors { - Action::Exec("path.split".into(), json!({"id": id.0, "at": [sp.x, sp.y]})) - } else { - Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)})) - }]; - } - } - vec![] + let Some(id) = segment_at(cx, &targets(cx, p), p) else { return vec![] }; + vec![if self.kind == Kind::Scissors { + Action::Exec("path.split".into(), json!({"id": id.0, "at": [sp.x, sp.y]})) + } else { + Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)})) + }] } (Kind::Delete, PointerKind::Down) => match anchor_at_in(cx, &targets(cx, p), p) { Some((id, si, ai, None)) => vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))], @@ -100,7 +101,12 @@ impl Tool for AnchorTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - if self.kind == Kind::Scissors { Cursor::Crosshair } else { Cursor::Pen } + match self.kind { + Kind::Add => Cursor::PenAdd, + Kind::Scissors => Cursor::Crosshair, + Kind::Delete => Cursor::PenDelete, + Kind::Convert => Cursor::PenConvert, + } } fn busy(&self) -> bool { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f8..0fd5cf51 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -335,6 +335,14 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The pen over a segment of a selected path: a click adds an anchor there. + PenAdd, + /// The pen over an anchor of a selected path: a click deletes it. + PenDelete, + /// The pen over the first anchor of the path it is drawing: a click closes it. + PenClose, + /// Convert Direction Point. + PenConvert, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 3c75e51c..e6a98c52 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,11 +1,14 @@ //! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, //! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in -//! InDesign. +//! InDesign. Between paths, a click on a selected path's anchor deletes it and a click on its +//! outline adds an anchor; the cursor shows which. -use designcraft_doc::SpreadRef; +use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; +use crate::anchors::segment_at; +use crate::select::anchor_at_in; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, spread_json}; #[derive(Default)] @@ -25,7 +28,40 @@ fn anchor_json(a: &(Point, Point, Point)) -> Value { json!({"p": [a.0.x, a.0.y], "in": [a.1.x, a.1.y], "out": [a.2.x, a.2.y]}) } +/// What a pen click at the pointer does instead of placing an anchor. +#[derive(Debug, PartialEq)] +enum Hover { + Place, + /// Close the path being drawn (the pointer is on its first anchor). + Close, + /// Delete anchor (subpath, anchor) of a selected path. + Delete(u64, usize, usize), + /// Add an anchor on a selected path's outline. + Add(ItemId), +} + impl PenTool { + /// What a click at canvas point `p` does. + fn hover_at(&self, cx: &ToolContext, p: Point) -> Hover { + if let Some(sr) = self.spread { + if self.anchors.len() >= 2 + && let Some(first) = self.anchors.first() + && (first.0 - cx.layout.to_spread(sr, p)).hypot() <= cx.tol(6.0) + { + return Hover::Close; + } + return Hover::Place; + } + if !self.anchors.is_empty() { + return Hover::Place; + } + let ids = &cx.selection.items; + if let Some((id, si, ai, None)) = anchor_at_in(cx, ids, p) { + return Hover::Delete(id, si, ai); + } + segment_at(cx, ids, p).map_or(Hover::Place, Hover::Add) + } + fn finish(&mut self) -> Vec { self.path = None; self.spread = None; @@ -87,17 +123,22 @@ impl Tool for PenTool { vec![] } PointerKind::Down => { - // Close on the first anchor. - if self.anchors.len() >= 2 - && let Some(first) = self.anchors.first() - && (first.0 - sp).hypot() <= cx.tol(6.0) - { - let out = match self.path { - Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], - None => vec![], - }; - self.finish(); - return out; + match self.hover_at(cx, ev.pos) { + Hover::Close => { + let out = match self.path { + Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], + None => vec![], + }; + self.finish(); + return out; + } + Hover::Delete(id, si, ai) => { + return vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))]; + } + Hover::Add(id) => { + return vec![Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)}))]; + } + Hover::Place => {} } let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { @@ -165,8 +206,13 @@ impl Tool for PenTool { out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Pen + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + match self.hover_at(cx, p) { + Hover::Place => Cursor::Pen, + Hover::Close => Cursor::PenClose, + Hover::Delete(..) => Cursor::PenDelete, + Hover::Add(_) => Cursor::PenAdd, + } } fn busy(&self) -> bool { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da..9c5f6bb0 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,53 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn pen_cursor_shows_add_delete_and_close() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let selected = Selection::items(vec![fid]); + let none = Selection::default(); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); + let mut t = create("pen"); + let m = Mods::default(); + + let cx = ctx(&d, &selected, &c, &l); + assert_eq!(t.cursor(&cx, at(200.0, 100.0), m), Cursor::PenDelete, "on a selected path's anchor"); + assert_eq!(t.cursor(&cx, at(150.0, 101.0), m), Cursor::PenAdd, "on a selected path's outline"); + assert_eq!(t.cursor(&cx, at(150.0, 150.0), m), Cursor::Pen, "inside, away from the outline"); + let click = |t: &mut Box, cx: &ToolContext, p: Point| { + let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + let up = t.pointer(cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)); + (down, up) + }; + let (down, up) = click(&mut t, &cx, at(200.0, 100.0)); + assert_eq!(down, vec![Action::Exec("path.deleteAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 1}))]); + assert!(up.is_empty() && !t.busy(), "deleting starts no path"); + let (down, _) = click(&mut t, &cx, at(150.0, 101.0)); + assert!(matches!(&down[..], [Action::Exec(cmd, p)] if cmd == "path.addAnchor" && p["id"] == fid.0), "{down:?}"); + + // Unselected paths are drawn over; the first anchor of the path being drawn closes it. + let cx = ctx(&d, &none, &c, &l); + assert_eq!(t.cursor(&cx, at(200.0, 100.0), m), Cursor::Pen, "unselected path"); + click(&mut t, &cx, at(300.0, 300.0)); + assert_eq!(t.cursor(&cx, at(300.0, 300.0), m), Cursor::Pen, "one anchor cannot close yet"); + click(&mut t, &cx, at(400.0, 300.0)); + assert_eq!(t.cursor(&cx, at(301.0, 301.0), m), Cursor::PenClose, "on the first anchor"); + assert_eq!(t.cursor(&cx, at(350.0, 350.0), m), Cursor::Pen, "elsewhere while drawing"); +} + +#[test] +fn anchor_tools_show_pen_state_cursors() { + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = Point::new(10.0, 10.0); + let cursor = |id: &str| create(id).cursor(&cx, p, Mods::default()); + assert_eq!(cursor("addAnchor"), Cursor::PenAdd); + assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete); + assert_eq!(cursor("convertDirection"), Cursor::PenConvert); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e6..d8888885 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -337,30 +337,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; - ui.ctx().set_cursor_icon(cursor_icon(c)); - } -} - -fn cursor_icon(c: Cursor) -> egui::CursorIcon { - use egui::CursorIcon as C; - match c { - Cursor::Arrow | Cursor::ArrowHollow => C::Default, - Cursor::Move => C::Move, - Cursor::Crosshair => C::Crosshair, - Cursor::ResizeH => C::ResizeHorizontal, - Cursor::ResizeV => C::ResizeVertical, - Cursor::ResizeNwSe => C::ResizeNwSe, - Cursor::ResizeNeSw => C::ResizeNeSw, - Cursor::Rotate => C::Alias, - Cursor::Pen => C::Crosshair, - Cursor::Text => C::Text, - Cursor::Hand => C::Grab, - Cursor::HandGrab => C::Grabbing, - Cursor::ZoomIn => C::ZoomIn, - Cursor::ZoomOut => C::ZoomOut, - Cursor::Eyedropper => C::Crosshair, - Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy, - Cursor::NotAllowed => C::NotAllowed, + let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); + ui.ctx().set_cursor_icon(icon); } } @@ -1798,6 +1776,33 @@ mod tests { } } + #[test] + fn tools_show_their_own_cursors_over_the_page() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let p = h.state().canvas_rect.unwrap().center(); + let mut shown = |tool: &str| { + h.state_mut().run("tool.select", json!({"tool": tool})).unwrap(); + h.hover_at(p); + h.run_steps(2); + let out = &h.output().platform_output; + (out.cursor_icon, out.cursor_image.clone()) + }; + let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type")); + let ppp = h.ctx.pixels_per_point(); + let images = &mut h.state_mut().cursors; + match crate::cursors::MODE { + crate::cursors::Mode::Os => { + assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen"); + assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection"); + assert_eq!(text, (egui::CursorIcon::Text, None), "the I-beam stays a system cursor"); + } + crate::cursors::Mode::Painted => assert_eq!((pen.0, direct.0), (egui::CursorIcon::None, egui::CursorIcon::None)), + crate::cursors::Mode::System => assert_eq!(pen, (egui::CursorIcon::Crosshair, None)), + } + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs new file mode 100644 index 00000000..f3cbf95e --- /dev/null +++ b/crates/ui-egui/src/cursors.rs @@ -0,0 +1,369 @@ +//! Tool cursors drawn in code: black glyphs with a white halo, so they read on any page colour. +//! The Selection tool's filled arrow, Direct Selection's hollow arrow, the pen nib with a badge for +//! what a click does (add, delete, close, convert), and a curved double arrow for rotating. +//! Cursors without a glyph stay system cursors. +//! +//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so +//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show +//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The +//! web build keeps the system cursors. + +use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex}; +use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2}; + +use designcraft_tools::Cursor; + +const INK: Color32 = Color32::BLACK; +const HALO: Color32 = Color32::WHITE; + +/// How the window shows a cursor glyph. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + /// As an OS cursor bitmap ([`Images`]). + Os, + /// Painted into the window at the pointer, the OS cursor hidden. + Painted, + /// Not at all: the system cursor stands in. + System, +} + +pub const MODE: Mode = if cfg!(target_arch = "wasm32") { + Mode::System +} else if cfg!(target_os = "macos") { + Mode::Painted +} else { + Mode::Os +}; + +/// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts +/// through another part's ink. +#[derive(Default)] +struct Ink { + halo: Vec, + ink: Vec, +} + +impl Ink { + fn halo(&mut self, s: Shape) { + self.halo.push(s); + } + + fn add(&mut self, s: Shape) { + self.ink.push(s); + } + + /// A filled outline with a halo around it. + fn poly(&mut self, pts: Vec, fill: Color32) { + self.halo(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO))); + self.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE)); + self.add(Shape::closed_line(pts, Stroke::new(1.0, INK))); + } + + /// A haloed stroke. + fn line(&mut self, a: Pos2, b: Pos2) { + self.halo(Shape::line_segment([a, b], Stroke::new(3.0, HALO))); + self.add(Shape::line_segment([a, b], Stroke::new(1.2, INK))); + } + + fn into_shapes(self) -> Vec { + let mut out = self.halo; + out.extend(self.ink); + out + } +} + +fn at(o: Pos2, pts: &[(f32, f32)]) -> Vec { + pts.iter().map(|&(x, y)| o + vec2(x, y)).collect() +} + +/// The pointer arrow, tip at `o`: ink-filled (Selection) or hollow (Direct Selection). +fn arrow(ink: &mut Ink, o: Pos2, hollow: bool) { + let pts = at(o, &[(0.0, 0.0), (0.0, 15.0), (3.8, 11.4), (6.4, 17.0), (8.6, 16.0), (6.1, 10.6), (11.0, 10.6)]); + ink.halo(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO))); + // The arrow is concave: fill it as two convex parts, then outline it. + let fill = if hollow { HALO } else { INK }; + let part = |idx: &[usize]| idx.iter().filter_map(|&i| pts.get(i).copied()).collect::>(); + ink.add(Shape::convex_polygon(part(&[0, 1, 2, 5, 6]), fill, Stroke::NONE)); + ink.add(Shape::convex_polygon(part(&[2, 3, 4, 5]), fill, Stroke::NONE)); + ink.add(Shape::closed_line(pts, Stroke::new(1.0, INK))); +} + +/// What the pen badge says a click does. +#[derive(Clone, Copy)] +enum Badge { + None, + Add, + Delete, + Close, + Convert, +} + +/// The pen nib, tip at `o` (top left), with a badge below right. +fn pen(ink: &mut Ink, o: Pos2, badge: Badge) { + ink.poly(at(o, &[(0.0, 0.0), (4.0, 12.0), (8.0, 16.0), (16.0, 8.0), (12.0, 4.0)]), INK); + // The nib's slit and breather hole. + ink.add(Shape::line_segment([o + vec2(1.0, 1.0), o + vec2(5.5, 5.5)], Stroke::new(1.0, HALO))); + ink.add(Shape::circle_filled(o + vec2(6.5, 6.5), 1.4, HALO)); + ink.line(o + vec2(10.0, 14.0), o + vec2(14.0, 18.0)); + let b = o + vec2(16.0, 14.0); + match badge { + Badge::None => {} + Badge::Add => { + ink.line(b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)); + ink.line(b + vec2(3.0, 0.0), b + vec2(3.0, 6.0)); + } + Badge::Delete => ink.line(b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)), + Badge::Close => { + ink.halo(Shape::circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(3.0, HALO))); + ink.add(Shape::circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(1.2, INK))); + } + Badge::Convert => { + ink.line(b + vec2(0.0, 6.0), b + vec2(3.0, 0.0)); + ink.line(b + vec2(3.0, 0.0), b + vec2(6.0, 6.0)); + } + } +} + +/// A curved double arrow over the hotspot: an arc bowing up, an arrowhead at each end pointing on +/// along the arc. +fn rotate(ink: &mut Ink, o: Pos2) { + const R: f32 = 8.0; + let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88); + let on = |a: f32| o + vec2(a.cos() * R, -a.sin() * R); + let pts: Vec = (0..=12).map(|i| on(a0 + (a1 - a0) * i as f32 / 12.0)).collect(); + ink.halo(Shape::line(pts.clone(), Stroke::new(3.2, HALO))); + ink.add(Shape::line(pts, Stroke::new(1.3, INK))); + for (a, away) in [(a0, -1.0f32), (a1, 1.0)] { + let t = vec2(-a.sin(), -a.cos()) * away; + let n = vec2(-t.y, t.x); + let (end, tip) = (on(a) - t * 1.5, on(a) + t * 4.5); + ink.poly(vec![tip, end + n * 1.8, end - n * 1.8], INK); + } +} + +/// The shapes of cursor `c` with its hotspot at `p`, in points; `None` for system cursors. +fn glyph(c: Cursor, p: Pos2) -> Option> { + let mut ink = Ink::default(); + match c { + Cursor::Arrow => arrow(&mut ink, p, false), + Cursor::ArrowHollow => arrow(&mut ink, p, true), + Cursor::Pen => pen(&mut ink, p, Badge::None), + Cursor::PenAdd => pen(&mut ink, p, Badge::Add), + Cursor::PenDelete => pen(&mut ink, p, Badge::Delete), + Cursor::PenClose => pen(&mut ink, p, Badge::Close), + Cursor::PenConvert => pen(&mut ink, p, Badge::Convert), + Cursor::Rotate => rotate(&mut ink, p), + _ => return None, + } + Some(ink.into_shapes()) +} + +/// The cursor bitmaps handed to the OS, by (cursor, pixels per point). Each glyph is rasterized +/// once; handing the same `Arc` every frame lets the integration keep its OS cursor. +#[derive(Default)] +pub struct Images(Vec<(Cursor, f32, CustomCursorImage)>); + +impl Images { + /// Cursor `c` for `ppp` physical pixels per point; `None` for cursors without a glyph. + pub fn get(&mut self, c: Cursor, ppp: f32) -> Option { + if let Some((.., image)) = self.0.iter().find(|(k, s, _)| *k == c && *s == ppp) { + return Some(image.clone()); + } + let image = rasterize(c, ppp)?; + // A few glyphs at the scales of the displays the window visited. + if self.0.len() >= 128 { + self.0.clear(); + } + self.0.push((c, ppp, image.clone())); + Some(image) + } +} + +/// The largest side of a cursor bitmap, in pixels (the glyphs span under 30 points). +const MAX_SIDE: f32 = 256.0; + +/// Cursor `c` as a straight-alpha RGBA bitmap at `ppp` pixels per point, tessellated +/// (anti-aliased) as egui would paint it, the hotspot on a pixel corner. +pub(crate) fn rasterize(c: Cursor, ppp: f32) -> Option { + if !(ppp.is_finite() && ppp > 0.0) { + return None; + } + let shapes = glyph(c, Pos2::ZERO)?; + let mut tessellator = Tessellator::new(ppp, TessellationOptions::default(), [1, 1], vec![]); + let mut mesh = Mesh::default(); + for shape in shapes { + tessellator.tessellate_shape(shape, &mut mesh); + } + let bounds = mesh.calc_bounds(); + // The hotspot must lie inside the bitmap. + let min = (bounds.min.to_vec2() * ppp).floor().min(Vec2::ZERO); + let max = (bounds.max.to_vec2() * ppp).ceil().max(Vec2::splat(1.0)); + let size = max - min; + if !(size.x <= MAX_SIDE && size.y <= MAX_SIDE) { + return None; + } + let (w, h) = (size.x as usize, size.y as usize); + let mut px = vec![[0.0; 4]; w * h]; + let corner = |v: &Vertex| (v.pos.to_vec2() * ppp - min, v.color); + for &[i, j, k] in mesh.indices.as_chunks::<3>().0 { + if let (Some(a), Some(b), Some(c)) = (mesh.vertices.get(i as usize), mesh.vertices.get(j as usize), mesh.vertices.get(k as usize)) { + fill_triangle(&mut px, w, [corner(a), corner(b), corner(c)]); + } + } + let rgba: Vec = px + .iter() + .flat_map(|&[r, g, b, a]| { + let k = if a > 0.0 { 1.0 / a } else { 0.0 }; + [r * k, g * k, b * k, a].map(|x| (x * 255.0).round().clamp(0.0, 255.0) as u8) + }) + .collect(); + Some(CustomCursorImage { rgba: rgba.into(), size: [w as u16, h as u16], hotspot: [-min.x as u16, -min.y as u16] }) +} + +/// Blend triangle `t` (pixel positions and premultiplied colours, interpolated across it) over `px` +/// (premultiplied RGBA, `w` pixels a row) at the pixel centres it covers, as the GPU blends egui's +/// meshes. A centre on an edge goes to one of the two triangles sharing it, so seams aren't doubled. +fn fill_triangle(px: &mut [[f32; 4]], w: usize, t: [(Vec2, Color32); 3]) { + let edge = |a: Vec2, b: Vec2, p: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + let [a, mut b, mut c] = t; + let mut area = edge(a.0, b.0, c.0); + if area < 0.0 { + std::mem::swap(&mut b, &mut c); + area = -area; + } + if area.is_nan() || area <= 1e-6 || w == 0 { + return; + } + // Of the two triangles sharing an edge (walking it in opposite directions), one owns it. + let owns = |u: Vec2, v: Vec2| v.y > u.y || (v.y == u.y && v.x > u.x); + let h = px.len() / w; + let lo = a.0.min(b.0).min(c.0).max(Vec2::ZERO); + let hi = a.0.max(b.0).max(c.0).min(vec2(w as f32, h as f32)); + for y in lo.y.floor() as usize..hi.y.ceil() as usize { + for x in lo.x.floor() as usize..hi.x.ceil() as usize { + let p = pos2(x as f32 + 0.5, y as f32 + 0.5).to_vec2(); + let weights = [(b.0, c.0), (c.0, a.0), (a.0, b.0)].map(|(u, v)| (edge(u, v, p), owns(u, v))); + if !weights.iter().all(|&(e, own)| e > 0.0 || (e == 0.0 && own)) { + continue; + } + let Some(dst) = px.get_mut(y * w + x) else { continue }; + let [wa, wb, wc] = weights.map(|(e, _)| e / area); + let src: [f32; 4] = std::array::from_fn(|i| { + let channel = |col: Color32| f32::from(col.to_array()[i]); + (wa * channel(a.1) + wb * channel(b.1) + wc * channel(c.1)) / 255.0 + }); + let keep = 1.0 - src[3]; + *dst = std::array::from_fn(|i| src[i] + dst[i] * keep); + } + } +} + +/// The system cursor for `c`: shown where the window has no glyph for it, or no bitmap cursors. +pub fn system(c: Cursor) -> egui::CursorIcon { + use egui::CursorIcon as C; + match c { + Cursor::Arrow | Cursor::ArrowHollow => C::Default, + Cursor::Move => C::Move, + Cursor::Crosshair => C::Crosshair, + Cursor::ResizeH => C::ResizeHorizontal, + Cursor::ResizeV => C::ResizeVertical, + Cursor::ResizeNwSe => C::ResizeNwSe, + Cursor::ResizeNeSw => C::ResizeNeSw, + Cursor::Rotate => C::Alias, + Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenConvert => C::Crosshair, + Cursor::Text => C::Text, + Cursor::Hand => C::Grab, + Cursor::HandGrab => C::Grabbing, + Cursor::ZoomIn => C::ZoomIn, + Cursor::ZoomOut => C::ZoomOut, + Cursor::Eyedropper => C::Crosshair, + Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy, + Cursor::NotAllowed => C::NotAllowed, + } +} + +/// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint +/// the glyph per [`MODE`], and return the cursor icon to set. +pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon { + match MODE { + Mode::Os => { + // The glyphs keep the system cursors' size whatever the interface scale. + let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01); + ctx.set_cursor_image(images.get(c, ppp)); + system(c) + } + Mode::Painted => { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")); + // Divide by the zoom factor so the glyph keeps its size in physical pixels. + let scale = 1.0 / ctx.zoom_factor().max(0.01); + let painter = ctx.layer_painter(layer); + match glyph(c, Pos2::ZERO) { + Some(shapes) => { + let place = egui::emath::TSTransform::new(hover.to_vec2(), scale); + painter.extend(shapes.into_iter().map(|mut s| { + s.transform(place); + s + })); + egui::CursorIcon::None + } + None => system(c), + } + } + Mode::System => system(c), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every cursor with a glyph. + const GLYPHS: [Cursor; 8] = + [Cursor::Arrow, Cursor::ArrowHollow, Cursor::Pen, Cursor::PenAdd, Cursor::PenDelete, Cursor::PenClose, Cursor::PenConvert, Cursor::Rotate]; + + fn alpha(img: &CustomCursorImage, x: usize, y: usize) -> u8 { + img.rgba.get((y * img.size[0] as usize + x) * 4 + 3).copied().unwrap_or(0) + } + + #[test] + fn every_glyph_rasterizes_with_its_hotspot_inside() { + for c in GLYPHS { + for ppp in [1.0, 2.0] { + let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} has no bitmap")); + let [w, h] = img.size.map(usize::from); + assert_eq!(img.rgba.len(), w * h * 4, "{c:?}"); + let [hx, hy] = img.hotspot.map(usize::from); + assert!(hx < w && hy < h, "{c:?} hotspot {:?} outside {:?}", img.hotspot, img.size); + let inked = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).filter(|&(x, y)| alpha(&img, x, y) > 128).count(); + assert!(inked > 20, "{c:?} at {ppp}× is nearly empty ({inked} pixels)"); + } + } + for c in [Cursor::Text, Cursor::Hand, Cursor::ZoomIn, Cursor::Crosshair] { + assert!(rasterize(c, 1.0).is_none(), "{c:?} stays a system cursor"); + } + assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none()); + } + + #[test] + fn the_selection_arrows_differ_in_fill() { + // A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct. + let centre = |c| { + let img = rasterize(c, 2.0).unwrap_or_else(|| panic!("{c:?}")); + let [hx, hy] = img.hotspot.map(usize::from); + let i = ((hy + 16) * img.size[0] as usize + hx + 4) * 4; + img.rgba.get(i..i + 4).map(|p| p.to_vec()).unwrap_or_default() + }; + assert_eq!(centre(Cursor::Arrow), vec![0, 0, 0, 255], "Selection is filled"); + assert_eq!(centre(Cursor::ArrowHollow), vec![255, 255, 255, 255], "Direct Selection is hollow"); + } + + #[test] + fn images_are_cached_by_cursor_and_scale() { + let mut images = Images::default(); + let a = images.get(Cursor::Pen, 2.0).unwrap_or_else(|| panic!("pen")); + let b = images.get(Cursor::Pen, 2.0).unwrap_or_else(|| panic!("pen")); + assert!(std::sync::Arc::ptr_eq(&a.rgba, &b.rgba), "the same bitmap every frame"); + assert_ne!(images.get(Cursor::Pen, 1.0).map(|i| i.size), Some(a.size)); + assert!(images.get(Cursor::Text, 2.0).is_none()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c003154a..824cbf52 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ pub mod canvas; pub mod chrome; pub mod control; pub mod credits; +pub mod cursors; pub mod dialogs; pub mod dock; pub mod i18n; @@ -510,6 +511,8 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, + /// The tool cursor bitmaps handed to the OS. + pub cursors: cursors::Images, } impl DesignApp { @@ -547,6 +550,7 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + cursors: cursors::Images::default(), } }