mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
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:
+22
-16
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user