Merge tool-cursors: Canvas: tools show their own cursors (Direct Selection, Pen states)

Conflict: crates/tools/src/tests.rs: both sides' tests (thread ports, pen add/delete)
Conflict: crates/ui-egui/src/canvas.rs: the branch's cursors::show for the icon, then next's loaded-text/thread badge; next's cursor_icon dropped for cursors::system
Conflict: crates/ui-egui/src/lib.rs: DesignApp fields from both sides
This commit is contained in:
aiongg
2026-10-11 05:17:24 +00:00
7 changed files with 541 additions and 57 deletions
+22 -16
View File
@@ -34,6 +34,16 @@ fn targets(cx: &ToolContext, p: Point) -> Vec<ItemId> {
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<ItemId> {
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 {
+8
View File
@@ -358,6 +358,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,
+61 -15
View File
@@ -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<Action> {
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 {
+48
View File
@@ -1205,4 +1205,52 @@ fn live_corner_diamonds_follow_a_rotated_frame() {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
let got = corner_preview(&a);
assert!(got.iter().all(|(shape, size)| shape == "rounded" && (size - 25.0).abs() < 1e-6), "{got:?}");
#[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<dyn Tool>, 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);
}
+29 -26
View File
@@ -371,7 +371,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));
let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p);
ui.ctx().set_cursor_icon(icon);
draw_cursor_badge(&painter, p, c, &t);
}
}
@@ -405,31 +406,6 @@ fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) {
}
}
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::ThreadLink => C::Alias,
Cursor::Unthread => C::NoDrop,
Cursor::NotAllowed => C::NotAllowed,
}
}
/// Keep the canvas texture current. Panning inside the overscanned region just shifts the
/// texture; zooming shows the old texture scaled until the background render arrives; document
/// edits render synchronously so editing never lags.
@@ -2850,6 +2826,33 @@ mod tests {
assert_eq!(spread_items(&h), [a]);
}
#[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()));
+369
View File
@@ -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<Shape>,
ink: Vec<Shape>,
}
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<Pos2>, 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<Shape> {
let mut out = self.halo;
out.extend(self.ink);
out
}
}
fn at(o: Pos2, pts: &[(f32, f32)]) -> Vec<Pos2> {
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::<Vec<_>>();
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<Pos2> = (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<Vec<Shape>> {
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<CustomCursorImage> {
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<CustomCursorImage> {
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<u8> = 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());
}
}
+4
View File
@@ -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;
@@ -551,6 +552,8 @@ pub struct DesignApp {
/// The canvas asked for the system IME in the last frame (the web build's text input has the
/// keyboard then).
pub(crate) canvas_ime: bool,
/// The tool cursor bitmaps handed to the OS.
pub cursors: cursors::Images,
}
impl DesignApp {
@@ -596,6 +599,7 @@ impl DesignApp {
ime_marked: None,
ime_discard: false,
canvas_ime: false,
cursors: cursors::Images::default(),
}
}