Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 f46527e0bd Fix #444: tool cursors are OS cursors, so the pointer no longer trails over the canvas
Over the canvas the window hid the system cursor and painted the tool's
cursor glyph into its own frame. That glyph reached the screen only after
the frame was rendered, queued (two frames by default) and composited, so
on Windows it visibly trailed the real pointer, on any GPU: the "sluggish
mouse" of the report. Frame times were not the cause (0.4 ms empty,
1.5 ms median on a 20 000-path document, and no repaints while idle).

- cursors: the glyphs are collected as shapes, tessellated by egui and
  rasterized into straight-alpha RGBA bitmaps (anti-aliased, hotspot on a
  pixel corner), cached per cursor and display scale so the same Arc is
  handed over every frame and egui-winit keeps its OS cursor.
- canvas: on Windows and Linux the tool cursor goes to the OS through
  ctx.set_cursor_image; the system moves it itself. macOS (bitmaps sized
  in points) and the web (no bitmap cursors in eframe) keep painting it.
- app: the wgpu surface queues one frame instead of two (LOW_LATENCY):
  the GPU only composites the CPU-rendered canvas, so everything drawn in
  the window follows the pointer a frame sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:41:22 +07:00
BFlat 1ce3a5c908 Merge pull request #448 from storytold/fix/issue-442
Fix #442: alike circular corners on rectangles that aren't square
2026-10-08 14:40:03 +07:00
BFlat 4254bf915e Merge pull request #441 from DvloperKing/es-errores
i18n: Spanish translations for error and status-bar messages
2026-10-08 14:39:46 +07:00
5 changed files with 354 additions and 55 deletions

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -155,7 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369).
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web).
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
+3
View File
@@ -358,6 +358,9 @@ fn main() -> eframe::Result {
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.power_preference = power;
}
+7
View File
@@ -388,6 +388,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
match ui.input(|i| i.pointer.hover_pos()) {
// An OS cursor: the system moves it at once, where a painted one trails the pointer
// (#444). The system cursor stands in for cursors without a glyph.
Some(_) if custom && crate::cursors::OS_CURSORS => {
let ppp = ui.ctx().native_pixels_per_point().unwrap_or_else(|| ui.ctx().pixels_per_point());
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
cursor_icon(c)
}
Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None,
_ => cursor_icon(c),
}
+339 -53
View File
@@ -1,21 +1,44 @@
//! Tool cursors drawn as vector glyphs (egui only offers system cursors). Black shapes with a white
//! halo, hotspot at `p`, Illustrator's visual grammar: solid arrow (Selection), hollow arrow (Direct
//! Selection), pen nib with state badges, crosshair for drawing tools, curved arrows for rotate.
//!
//! The desktop app shows them as OS cursors ([`Images`]): the system moves those itself, at once,
//! while a cursor painted into the window follows the pointer a few frames late (#444).
use egui::{Color32, Painter, Pos2, Shape, Stroke, pos2, vec2};
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Painter, Pos2, Shape, Stroke, Vec2, pos2, vec2};
use vectorcraft_tools::Cursor;
const INK: Color32 = Color32::BLACK;
const HALO: Color32 = Color32::WHITE;
fn poly(p: &Painter, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
/// The shapes of one cursor glyph.
#[derive(Default)]
struct Ink(Vec<Shape>);
impl Ink {
fn add(&mut self, s: Shape) {
self.0.push(s);
}
fn line_segment(&mut self, pts: [Pos2; 2], stroke: Stroke) {
self.add(Shape::line_segment(pts, stroke));
}
fn circle_stroke(&mut self, c: Pos2, r: f32, stroke: Stroke) {
self.add(Shape::circle_stroke(c, r, stroke));
}
fn circle_filled(&mut self, c: Pos2, r: f32, fill: Color32) {
self.add(Shape::circle_filled(c, r, fill));
}
}
fn poly(p: &mut Ink, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
// Halo first (thicker white outline), then the glyph.
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
p.add(Shape::closed_line(pts, Stroke::new(1.0, stroke)));
}
fn line(p: &Painter, a: Pos2, b: Pos2) {
fn line(p: &mut Ink, a: Pos2, b: Pos2) {
p.line_segment([a, b], Stroke::new(3.0, HALO));
p.line_segment([a, b], Stroke::new(1.2, INK));
}
@@ -25,7 +48,7 @@ fn arrow_points(o: Pos2) -> Vec<Pos2> {
[(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)].iter().map(|(x, y)| o + vec2(*x, *y)).collect()
}
fn arrow(p: &Painter, o: Pos2, hollow: bool) {
fn arrow(p: &mut Ink, o: Pos2, hollow: bool) {
let pts = arrow_points(o);
// The arrow is concave: draw as a filled mesh of two convex parts, then outline.
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
@@ -35,7 +58,7 @@ fn arrow(p: &Painter, o: Pos2, hollow: bool) {
p.add(Shape::closed_line(pts, Stroke::new(1.0, INK)));
}
fn crosshair(p: &Painter, o: Pos2) {
fn crosshair(p: &mut Ink, o: Pos2) {
for (a, b) in
[(vec2(-9.0, 0.0), vec2(-2.0, 0.0)), (vec2(2.0, 0.0), vec2(9.0, 0.0)), (vec2(0.0, -9.0), vec2(0.0, -2.0)), (vec2(0.0, 2.0), vec2(0.0, 9.0))]
{
@@ -43,7 +66,7 @@ fn crosshair(p: &Painter, o: Pos2) {
}
}
fn pen(p: &Painter, o: Pos2, badge: &str) {
fn pen(p: &mut Ink, o: Pos2, badge: &str) {
// Nib pointing to the top-left hotspot.
let pts = vec![o, o + vec2(4.0, 12.0), o + vec2(8.0, 16.0), o + vec2(16.0, 8.0), o + vec2(12.0, 4.0)];
poly(p, pts, INK, INK);
@@ -74,7 +97,7 @@ fn pen(p: &Painter, o: Pos2, badge: &str) {
}
}
fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
let d = dir.normalized() * 8.0;
let n = vec2(-d.y, d.x) * 0.45;
line(p, o - d, o + d);
@@ -83,7 +106,7 @@ fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
}
}
fn rotate(p: &Painter, o: Pos2) {
fn rotate(p: &mut Ink, o: Pos2) {
let pts: Vec<Pos2> = (0..=10)
.map(|i| {
let a = std::f32::consts::PI * (0.15 + 0.7 * i as f32 / 10.0);
@@ -98,7 +121,7 @@ fn rotate(p: &Painter, o: Pos2) {
}
/// Live Corners: the hollow arrow with a rounded-corner badge.
fn corner_radius(p: &Painter, o: Pos2) {
fn corner_radius(p: &mut Ink, o: Pos2) {
arrow(p, o, true);
let b = o + vec2(12.0, 13.0);
let mut pts = vec![b + vec2(0.0, 10.0)];
@@ -113,7 +136,7 @@ fn corner_radius(p: &Painter, o: Pos2) {
/// Over a bracket of type on a path: the arrow with a bracket (a stem standing on a baseline,
/// with a foot) below right.
fn path_bracket(p: &Painter, o: Pos2) {
fn path_bracket(p: &mut Ink, o: Pos2) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
let stem = [b + vec2(3.0, 0.0), b + vec2(3.0, 10.0)];
@@ -128,7 +151,7 @@ fn path_bracket(p: &Painter, o: Pos2) {
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
/// badge.
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
fn stop_badge(p: &mut Ink, o: Pos2, add: bool) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
line(p, b, b + vec2(6.0, 0.0));
@@ -138,14 +161,14 @@ fn stop_badge(p: &Painter, o: Pos2, add: bool) {
}
/// A slice badge: a small rectangle cut by a line, at `b` (its top left).
fn slice_badge(p: &Painter, b: Pos2) {
fn slice_badge(p: &mut Ink, b: Pos2) {
let pts = vec![b, b + vec2(8.0, 0.0), b + vec2(8.0, 6.0), b + vec2(0.0, 6.0)];
poly(p, pts, HALO, INK);
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 6.0));
}
/// The Slice tool: a crosshair with a blade below right of the hotspot.
fn slice(p: &Painter, o: Pos2) {
fn slice(p: &mut Ink, o: Pos2) {
crosshair(p, o);
let b = o + vec2(7.0, 7.0);
poly(p, vec![b, b + vec2(9.0, 4.0), b + vec2(10.0, 7.0), b + vec2(3.0, 6.0)], HALO, INK);
@@ -155,7 +178,7 @@ fn slice(p: &Painter, o: Pos2) {
/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point),
/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point
/// (a drag moves or widens it).
fn width(p: &Painter, o: Pos2, badge: &str) {
fn width(p: &mut Ink, o: Pos2, badge: &str) {
arrow(p, o, true);
let b = o + vec2(11.0, 18.0);
let top: Vec<Pos2> = (0..=8)
@@ -179,7 +202,7 @@ fn width(p: &Painter, o: Pos2, badge: &str) {
}
}
fn ibeam(p: &Painter, o: Pos2) {
fn ibeam(p: &mut Ink, o: Pos2) {
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
line(p, o + vec2(-3.0, 8.0), o + vec2(3.0, 8.0));
@@ -188,7 +211,7 @@ fn ibeam(p: &Painter, o: Pos2) {
/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art,
/// filled over an object; over an anchor point a ringed dot (the blend starts there).
fn blend(p: &Painter, o: Pos2, badge: Cursor) {
fn blend(p: &mut Ink, o: Pos2, badge: Cursor) {
crosshair(p, o);
let b = o + vec2(9.0, 9.0);
if badge == Cursor::BlendAnchor {
@@ -203,7 +226,7 @@ fn blend(p: &Painter, o: Pos2, badge: Cursor) {
/// The Shape Builder: a crosshair with a plus badge below right of the hotspot (merge mode), or a
/// minus (erase mode).
fn shape_builder(p: &Painter, o: Pos2, erase: bool) {
fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) {
crosshair(p, o);
let c = o + vec2(12.0, 12.0);
line(p, c - vec2(3.5, 0.0), c + vec2(3.5, 0.0));
@@ -212,53 +235,316 @@ fn shape_builder(p: &Painter, o: Pos2, erase: bool) {
}
}
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
/// stay system cursors (hand, zoom, busy states).
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
/// (hand, zoom, busy states).
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
let ink = &mut Ink::default();
match c {
Cursor::Arrow => arrow(painter, p, false),
Cursor::ArrowHollow => arrow(painter, p, true),
Cursor::Arrow => arrow(ink, p, false),
Cursor::ArrowHollow => arrow(ink, p, true),
Cursor::Move => {
arrow(painter, p, false);
double_arrow(painter, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
arrow(ink, p, false);
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
}
Cursor::Crosshair | Cursor::Eyedropper => crosshair(painter, p),
Cursor::ResizeH => double_arrow(painter, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(painter, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)),
Cursor::Rotate => rotate(painter, p),
Cursor::CornerRadius => corner_radius(painter, p),
Cursor::Pen => pen(painter, p, "*"),
Cursor::PenAdd => pen(painter, p, "+"),
Cursor::PenDelete => pen(painter, p, "-"),
Cursor::PenClose => pen(painter, p, "o"),
Cursor::PenContinue => pen(painter, p, "/"),
Cursor::PenConvert => pen(painter, p, "^"),
Cursor::Text => ibeam(painter, p),
Cursor::AddStop => stop_badge(painter, p, true),
Cursor::RemoveStop => stop_badge(painter, p, false),
Cursor::Slice => slice(painter, p),
Cursor::Crosshair | Cursor::Eyedropper => crosshair(ink, p),
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
Cursor::Rotate => rotate(ink, p),
Cursor::CornerRadius => corner_radius(ink, p),
Cursor::Pen => pen(ink, p, "*"),
Cursor::PenAdd => pen(ink, p, "+"),
Cursor::PenDelete => pen(ink, p, "-"),
Cursor::PenClose => pen(ink, p, "o"),
Cursor::PenContinue => pen(ink, p, "/"),
Cursor::PenConvert => pen(ink, p, "^"),
Cursor::Text => ibeam(ink, p),
Cursor::AddStop => stop_badge(ink, p, true),
Cursor::RemoveStop => stop_badge(ink, p, false),
Cursor::Slice => slice(ink, p),
Cursor::SliceSelect => {
arrow(painter, p, false);
slice_badge(painter, p + vec2(11.0, 14.0));
arrow(ink, p, false);
slice_badge(ink, p + vec2(11.0, 14.0));
}
Cursor::Width => width(ink, p, ""),
Cursor::WidthAdd => width(ink, p, "+"),
Cursor::WidthPoint => width(ink, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(ink, p, c),
Cursor::PathBracket => path_bracket(ink, p),
Cursor::ShapeBuilder => shape_builder(ink, p, false),
Cursor::ShapeBuilderErase => shape_builder(ink, p, true),
_ => return None,
}
Some(std::mem::take(&mut ink.0))
}
/// Paint cursor `c` at `p` on the given (foreground) painter, where the window can't show it as an
/// OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay system cursors.
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
glyph(c, p).map(|shapes| painter.extend(shapes)).is_some()
}
/// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor
/// bitmap in points (a Retina one would show twice as big, or blurred), nor on the web (eframe has no
/// bitmap cursors there): those paint them into the window ([`paint`]).
pub const OS_CURSORS: bool = !cfg!(any(target_os = "macos", target_arch = "wasm32"));
/// The cursor bitmaps handed to the OS, by (cursor, pixels per point): each glyph is rasterized
/// once, and handing the same `Arc` every frame keeps the window's OS cursor instead of making a
/// new one.
#[derive(Default)]
pub struct Images(Vec<(Cursor, f32, CustomCursorImage)>);
impl Images {
/// Cursor `c` for a display of `ppp` physical pixels per point; `None` for cursors that stay
/// system cursors.
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 dozen 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 50 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.
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 tri in mesh.indices.chunks_exact(3) {
let &[i, j, k] = tri else { continue };
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 = vec2(x as f32 + 0.5, y as f32 + 0.5);
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);
}
Cursor::Width => width(painter, p, ""),
Cursor::WidthAdd => width(painter, p, "+"),
Cursor::WidthPoint => width(painter, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
Cursor::PathBracket => path_bracket(painter, p),
Cursor::ShapeBuilder => shape_builder(painter, p, false),
Cursor::ShapeBuilderErase => shape_builder(painter, p, true),
_ => return false,
}
let _ = pos2;
true
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
/// Every cursor with a glyph.
const GLYPHS: [Cursor; 31] = [
Cursor::Arrow,
Cursor::ArrowHollow,
Cursor::Move,
Cursor::Crosshair,
Cursor::ResizeH,
Cursor::ResizeV,
Cursor::ResizeNwSe,
Cursor::ResizeNeSw,
Cursor::Rotate,
Cursor::CornerRadius,
Cursor::Pen,
Cursor::PenAdd,
Cursor::PenDelete,
Cursor::PenClose,
Cursor::PenContinue,
Cursor::PenConvert,
Cursor::Text,
Cursor::Eyedropper,
Cursor::AddStop,
Cursor::RemoveStop,
Cursor::Slice,
Cursor::SliceSelect,
Cursor::Width,
Cursor::WidthAdd,
Cursor::WidthPoint,
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::PathBracket,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
];
/// The straight RGBA of pixel (x, y).
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
let i = (y * img.size[0] as usize + x) * 4;
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
}
#[test]
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
for c in GLYPHS {
let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}"));
let [w, h] = img.size.map(usize::from);
assert_eq!(img.rgba.len(), w * h * 4, "{c:?}");
assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}");
assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}");
assert!(img.rgba.chunks_exact(4).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");
}
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
}
}
#[test]
fn the_arrow_bitmap_is_black_in_a_white_halo_with_the_tip_on_the_hotspot() {
let img = rasterize(Cursor::Arrow, 1.0).unwrap();
let [hx, hy] = img.hotspot.map(usize::from);
// The tip covers the hotspot's pixel; the body is solid black, the outline's halo white.
assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy));
let body = pixel(&img, hx + 2, hy + 8);
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo");
// Nothing left of or above the tip but the halo.
assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128));
// At twice the pixels per point, twice the pixels.
let big = rasterize(Cursor::Arrow, 2.0).unwrap();
for i in 0..2 {
let ratio = f32::from(big.size[i]) / f32::from(img.size[i]);
assert!((1.8..=2.2).contains(&ratio), "{:?} vs {:?}", big.size, img.size);
}
let [bx, by] = big.hotspot.map(usize::from);
let body = pixel(&big, bx + 4, by + 16);
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
}
#[test]
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
let mut images = Images::default();
let a = images.get(Cursor::Pen, 1.5).unwrap();
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::Arrow, f32::NAN).is_none());
assert_eq!(images.0.len(), 2);
}
/// One headless 800×600 frame of the whole window, a tenth of a second after the last.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> egui::FullOutput {
let time = Some(ctx.input(|i| i.time) + 0.1);
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
#[test]
fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
let canvas = app.canvas_rect.unwrap();
let over = canvas.center();
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
if OS_CURSORS {
let img = out.platform_output.cursor_image.as_ref().expect("the Selection tool's arrow as an OS cursor");
assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap());
// Where the OS can't show the bitmap, its system cursor.
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default);
} else {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
// The same bitmap while the pointer moves (the OS keeps its cursor), and an idle window
// once it rests: no frame is asked for.
let next = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over + vec2(5.0, 3.0))]);
if OS_CURSORS {
assert!(Arc::ptr_eq(&next.platform_output.cursor_image.unwrap().rgba, &out.platform_output.cursor_image.unwrap().rgba));
}
let mut idle = frame(&mut app, &ctx, vec![]);
for _ in 0..3 {
idle = frame(&mut app, &ctx, vec![]);
}
let delay = idle.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay).unwrap();
assert!(delay > std::time::Duration::from_millis(100), "{delay:?} {:?}", ctx.repaint_causes());
// Off the canvas, the panels' cursors.
let off = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(canvas.left() - 20.0, canvas.center().y))]);
assert!(off.platform_output.cursor_image.is_none());
}
#[test]
fn arrow_hotspot_is_tip() {
+3
View File
@@ -234,6 +234,8 @@ pub struct CanvasCache {
pub print_tiling: Option<PrintTilingCache>,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
/// The tools' cursors as OS cursor bitmaps.
pub cursors: cursors::Images,
}
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
@@ -367,6 +369,7 @@ impl VectorcraftApp {
slices: None,
print_tiling: None,
selection_box: None,
cursors: Default::default(),
},
perf: Perf::default(),
integrated_titlebar: false,