macOS: deliver canvas cursors through AppKit pending a winit fix

On macOS the canvas paints tool cursor glyphs into the frame, so they
trail the pointer by a frame or more (#2292). This interim adapter
delivers the same glyphs as native NSCursors, which AppKit moves
independently of canvas redraws.

ui-egui prepares cached 1x/2x RGBA requests from the existing cursor
rasterizer and hotspots (transparent padding only) and keeps returning
a visible system fallback icon. The desktop host builds NSImage/NSCursor
objects with point-sized images and hotspots, owns cursor-rectangle
suspension and restoration, and hands the cursor back on menu tracking,
focus loss, empty passes, cursor overrides and shutdown.

Windows/X11, Wayland and web delivery are unchanged. No artwork, no
production unsafe, no new dependency package: only four AppKit features
are enabled on the already-shipped objc2-app-kit. Remove once the pinned
winit sizes macOS cursor images and hotspots in points.

Addresses #2292.
This commit is contained in:
Ahmed Gomaa
2026-11-09 13:44:32 +02:00
parent 6f5721b0be
commit aec326b6c6
17 changed files with 832 additions and 12 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ vectorcraft-render = { workspace = true }
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSCursor", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
+6
View File
@@ -95,12 +95,18 @@ impl eframe::App for App {
// A ready update installs once the process has exited. The macOS exit below skips
// dropping the app, so the helper is started here.
self.app.start_pending_update();
#[cfg(target_os = "macos")]
super::mac_cursor::release();
end_before_teardown(self.frames);
}
}
impl App {
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
if self.winit_window.as_ref().is_some_and(|w| !w.has_focus() || w.is_visible() == Some(false) || w.is_minimized() == Some(true)) {
super::mac_cursor::release();
}
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
+241
View File
@@ -0,0 +1,241 @@
//! AppKit delivery of the UI's existing cursor artwork, independent of canvas presentation.
#![forbid(unsafe_code)]
// Interim #2292: remove when pinned winit correctly handles macOS cursor image and hotspot sizing.
use egui::{Context, FullOutput, Plugin, ViewportId};
use objc2::{AllocAnyThread, MainThreadMarker, rc::Retained};
use objc2_app_kit::{NSApplication, NSBitmapImageRep, NSCursor, NSImage, NSWindow};
use objc2_foundation::{NSData, NSPoint, NSSize};
use std::cell::RefCell;
use vectorcraft_ui_egui::cursors::native_request::{self, Bitmap, Key, Request};
const CAPACITY: usize = 128;
fn build(bitmap: &Bitmap) -> Option<Retained<NSCursor>> {
let one = &bitmap.representations[0];
let size = NSSize::new(f64::from(one.size[0]), f64::from(one.size[1]));
let image = NSImage::initWithSize(NSImage::alloc(), size);
for bitmap in &bitmap.representations {
let pixels = image::RgbaImage::from_raw(u32::from(bitmap.size[0]), u32::from(bitmap.size[1]), bitmap.rgba.to_vec())?;
let mut png = std::io::Cursor::new(Vec::new());
pixels.write_to(&mut png, image::ImageFormat::Png).ok()?;
let rep = NSBitmapImageRep::imageRepWithData(&NSData::with_bytes(png.get_ref()))?;
rep.setSize(size);
image.addRepresentation(&rep);
}
Some(NSCursor::initWithImage_hotSpot(NSCursor::alloc(), &image, NSPoint::new(f64::from(one.hotspot[0]), f64::from(one.hotspot[1]))))
}
/// The ownership protocol also runs against a headless backend in tests.
trait Backend {
type Window: Clone + PartialEq;
type Cursor: Clone + PartialEq;
fn window(&self) -> Option<Self::Window>;
fn enabled(&self, window: &Self::Window) -> bool;
fn suspend(&mut self, window: &Self::Window);
fn restore(&mut self, window: &Self::Window);
fn build(&mut self, request: &Request) -> Option<Self::Cursor>;
fn current(&self) -> Option<Self::Cursor>;
fn set(&mut self, cursor: &Self::Cursor);
}
struct State<B: Backend> {
backend: B,
candidate: Option<B::Window>,
cache: Vec<(Key, Option<B::Cursor>)>,
// Retain the active object independently of cache eviction.
active: Option<(Key, B::Window, B::Cursor)>,
}
impl<B: Backend> State<B> {
fn new(backend: B) -> Self {
Self { backend, candidate: None, cache: Vec::new(), active: None }
}
fn begin(&mut self) {
self.candidate = self.backend.window();
}
fn release(&mut self) {
self.candidate = None;
if let Some((_, window, _)) = self.active.take() {
self.backend.restore(&window);
}
}
fn finish(&mut self, request: Option<Request>) {
let window = self.candidate.take().filter(|w| self.backend.window().as_ref() == Some(w));
let (Some(request), Some(window)) = (request, window) else {
self.release();
return;
};
if let Some((key, owned, cursor)) = &self.active
&& *key == request.key
&& *owned == window
{
if self.backend.current().as_ref() != Some(cursor) {
self.backend.set(cursor);
}
return;
}
self.release();
// Never take another component's suspension (a sheet or native tracking session).
if !self.backend.enabled(&window) {
return;
}
let cursor = if let Some((_, cursor)) = self.cache.iter().find(|(key, _)| *key == request.key) {
cursor.clone()
} else {
let cursor = self.backend.build(&request);
if self.cache.len() >= CAPACITY {
self.cache.clear();
}
self.cache.push((request.key, cursor.clone()));
cursor
};
if let Some(cursor) = cursor {
self.backend.suspend(&window);
self.backend.set(&cursor);
self.active = Some((request.key, window, cursor));
}
}
}
#[derive(Default)]
struct AppKit {
root: std::sync::Weak<winit::window::Window>,
}
impl Backend for AppKit {
type Window = Retained<NSWindow>;
type Cursor = Retained<NSCursor>;
fn window(&self) -> Option<Self::Window> {
let mtm = MainThreadMarker::new()?;
// Bind the candidate to the host's actual root window without converting raw pointers.
// A floating native panel may be key while egui still has stale root focus input.
let root = self.root.upgrade()?;
if !root.has_focus() || root.is_visible() == Some(false) || root.is_minimized() == Some(true) {
return None;
}
NSApplication::sharedApplication(mtm).keyWindow().filter(|w| w.isVisible() && w.isKeyWindow())
}
fn enabled(&self, window: &Self::Window) -> bool {
window.areCursorRectsEnabled()
}
fn suspend(&mut self, window: &Self::Window) {
window.disableCursorRects();
}
fn restore(&mut self, window: &Self::Window) {
window.enableCursorRects();
window.resetCursorRects();
// egui-winit may skip an unchanged fallback icon. Rebuild its stored rectangle anyway.
if let Some(view) = window.contentView() {
window.invalidateCursorRectsForView(&view);
}
}
fn build(&mut self, request: &Request) -> Option<Self::Cursor> {
build(&request.bitmap)
}
fn current(&self) -> Option<Self::Cursor> {
Some(NSCursor::currentCursor())
}
fn set(&mut self, cursor: &Self::Cursor) {
cursor.set();
}
}
thread_local! { static STATE: RefCell<State<AppKit>> = RefCell::new(State::new(AppKit::default())); }
fn with_state(f: impl FnOnce(&mut State<AppKit>)) {
// All production AppKit access, including restoration, stays on the main thread.
if MainThreadMarker::new().is_some() {
STATE.with(|state| {
if let Ok(mut state) = state.try_borrow_mut() {
f(&mut state);
}
});
}
}
/// Also called synchronously when native menu tracking begins and before process teardown.
pub(crate) fn release() {
with_state(State::release);
}
struct EndedPass {
viewport: ViewportId,
discarded: bool,
request: Option<Request>,
}
struct Delivery {
begin: fn(),
finish: fn(Option<Request>),
release: fn(),
tracking: fn() -> bool,
ended: Vec<EndedPass>,
}
impl Plugin for Delivery {
fn debug_name(&self) -> &'static str {
"macOS canvas cursor"
}
fn on_begin_pass(&mut self, ui: &mut egui::Ui) {
native_request::clear(ui.ctx());
// Floating panels must not acquire or release the main canvas's window.
if ui.ctx().viewport_id() == ViewportId::ROOT {
(self.begin)();
}
}
fn on_end_pass(&mut self, ui: &mut egui::Ui) {
// egui restores the parent viewport before output_hook. Capture this pass here so
// a floating panel's output cannot consume or release its parent's cursor.
self.ended.push(EndedPass { viewport: ui.ctx().viewport_id(), discarded: ui.ctx().will_discard(), request: native_request::take(ui.ctx()) });
}
fn input_hook(&mut self, _: &Context, input: &mut egui::RawInput) {
if input.viewport_id == ViewportId::ROOT
&& (!input.focused || input.viewports.get(&ViewportId::ROOT).is_some_and(|v| v.minimized == Some(true)))
{
(self.release)();
}
}
fn output_hook(&mut self, ctx: &Context, output: &mut FullOutput) {
let Some(ended) = self.ended.pop() else { return };
if ended.viewport != ViewportId::ROOT {
return;
}
if ended.discarded
|| (output.platform_output.requested_discard() && output.platform_output.num_completed_passes < ctx.options(|o| o.max_passes.get()))
{
return;
}
let request = ended.request.filter(|r| {
r.viewport == ended.viewport
&& r.pass.checked_add(1) == Some(ctx.cumulative_pass_nr_for(ended.viewport))
&& ctx.input(|i| i.focused && i.pointer.hover_pos().is_some())
&& output.platform_output.cursor_icon == r.fallback
&& output.platform_output.cursor_image.is_none()
&& !(self.tracking)()
});
// This is BEFORE egui-winit's platform output. In pinned winit, fallback changes update
// the view's stored cursor and invalidate rectangles. Our owned suspension is intended
// to keep that update from replacing the native cursor; native timing needs hand testing.
(self.finish)(request);
}
fn on_exit(&mut self, _: &Context) {
(self.release)();
}
}
pub(crate) fn install(ctx: &Context, window: Option<&std::sync::Arc<winit::window::Window>>) {
with_state(|state| state.backend.root = window.map(std::sync::Arc::downgrade).unwrap_or_default());
ctx.add_plugin(Delivery {
begin: || with_state(State::begin),
finish: |request| with_state(|state| state.finish(request)),
release,
tracking: super::mac_menu::is_tracking,
ended: Vec::new(),
});
}
#[cfg(test)]
mod tests;
+236
View File
@@ -0,0 +1,236 @@
use super::*;
use egui::{CursorIcon, RawInput};
use std::cell::Cell;
#[derive(Default)]
struct Fake {
window: Option<u8>,
suspended: Vec<u8>,
current: Option<Key>,
sets: usize,
builds: usize,
restores: Vec<u8>,
fail: bool,
}
impl Backend for Fake {
type Window = u8;
type Cursor = Key;
fn window(&self) -> Option<u8> {
self.window
}
fn enabled(&self, w: &u8) -> bool {
!self.suspended.contains(w)
}
fn suspend(&mut self, w: &u8) {
self.suspended.push(*w);
}
fn restore(&mut self, w: &u8) {
self.suspended.retain(|v| v != w);
self.restores.push(*w);
self.current = None;
}
fn build(&mut self, r: &Request) -> Option<Key> {
self.builds += 1;
(!self.fail).then_some(r.key)
}
fn current(&self) -> Option<Key> {
self.current
}
fn set(&mut self, cursor: &Key) {
self.current = Some(*cursor);
self.sets += 1;
}
}
fn fake_state() -> State<Fake> {
State::new(Fake { window: Some(1), ..Default::default() })
}
fn raw(id: ViewportId, focused: bool) -> RawInput {
let mut input = RawInput { viewport_id: id, focused, events: vec![egui::Event::PointerMoved(egui::pos2(10.0, 10.0))], ..Default::default() };
input.viewports.entry(id).or_default();
input
}
fn publish(ctx: &Context, scale: f32) {
let mut session = vectorcraft_engine::Session::new();
let cursor = session.cursor(Default::default(), Default::default(), Default::default());
let fallback = native_request::request(ctx, cursor, CursorIcon::Default, true, scale);
ctx.set_cursor_icon(fallback);
}
fn request(scale: f32) -> Request {
let ctx = Context::default();
let _ = headless(&ctx, raw(ViewportId::ROOT, true), |ui| publish(ui.ctx(), scale));
native_request::take(&ctx).unwrap()
}
fn deliver(state: &mut State<Fake>, request: Option<Request>) {
state.begin();
state.finish(request);
}
#[test]
fn mac_cursor_ownership_deduplicates_reasserts_and_restores() {
let req = request(1.0);
let mut state = fake_state();
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.suspended, [1]);
assert_eq!((state.backend.builds, state.backend.sets), (1, 1));
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.sets, 1);
// Simulate a platform fallback replacing the cursor after finalization. The next pass
// reasserts; this proves the protocol, not AppKit timing or absence of transient flicker.
state.backend.current = None;
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.sets, 2);
deliver(&mut state, None);
assert_eq!(state.backend.restores, [1]);
deliver(&mut state, Some(req.clone()));
state.backend.window = None;
deliver(&mut state, Some(req.clone()));
assert!(state.active.is_none());
state.backend.window = Some(1);
deliver(&mut state, Some(req.clone()));
state.begin();
state.backend.window = Some(2);
state.finish(Some(req));
assert!(state.active.is_none(), "never acquire a different key window mid-pass");
assert_eq!(state.backend.restores, [1, 1, 1]);
}
#[test]
fn mac_cursor_respects_other_suspensions_failures_and_cache_eviction() {
let mut state = fake_state();
state.backend.suspended.push(1);
deliver(&mut state, Some(request(1.0)));
assert_eq!(state.backend.builds, 0);
state.release();
assert_eq!(state.backend.suspended, [1]);
assert!(state.backend.restores.is_empty());
state.backend.suspended.clear();
state.backend.fail = true;
for _ in 0..2 {
deliver(&mut state, Some(request(1.0)));
}
assert_eq!(state.backend.builds, 1, "cache failed constructions");
state.backend.fail = false;
for i in 1..140 {
deliver(&mut state, Some(request(1.0 + i as f32 / 1000.0)));
}
assert!(state.cache.len() <= CAPACITY);
let active = state.active.unwrap();
state.cache.clear();
assert_eq!(state.active, Some(active));
state.release();
assert!(state.backend.suspended.is_empty());
}
thread_local! {
static FAKE: RefCell<State<Fake>> = RefCell::new(fake_state());
static TRACKING: Cell<bool> = const { Cell::new(false) };
}
fn install_fake(ctx: &Context) {
ctx.add_plugin(Delivery {
begin: || FAKE.with(|s| s.borrow_mut().begin()),
finish: |r| FAKE.with(|s| s.borrow_mut().finish(r)),
release: || FAKE.with(|s| s.borrow_mut().release()),
tracking: || TRACKING.with(Cell::get),
ended: Vec::new(),
});
}
#[test]
fn mac_cursor_plugin_consumes_passes_and_releases_for_menu_focus_and_overrides() {
FAKE.with(|s| *s.borrow_mut() = fake_state());
TRACKING.with(|t| t.set(false));
let ctx = Context::default();
install_fake(&ctx);
install_fake(&ctx);
let root = ViewportId::ROOT;
let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0));
FAKE.with(|s| assert_eq!(s.borrow().backend.sets, 1, "one installed plugin consumes the request"));
let _ = headless(&ctx, raw(ViewportId::from_hash_of("floating-panel"), true), |_| {});
FAKE.with(|s| assert!(s.borrow().active.is_some()));
let _ = headless(&ctx, raw(root, true), |_| {});
FAKE.with(|s| assert!(s.borrow().active.is_none(), "empty pass hands back even an unchanged Default icon"));
let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0));
let _ = headless(&ctx, raw(root, true), |ui| {
publish(ui.ctx(), 1.0);
ui.ctx().set_cursor_icon(CursorIcon::Text);
});
FAKE.with(|s| assert!(s.borrow().active.is_none(), "changed-icon handback"));
let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0));
// The notification calls release without waiting for another UI pass.
TRACKING.with(|t| t.set(true));
FAKE.with(|s| s.borrow_mut().release());
FAKE.with(|s| assert!(s.borrow().active.is_none()));
let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0));
FAKE.with(|s| assert!(s.borrow().active.is_none()));
TRACKING.with(|t| t.set(false));
let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0));
let _ = headless(&ctx, raw(root, false), |_| {});
FAKE.with(|s| assert!(s.borrow().active.is_none()));
let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0));
FAKE.with(|s| assert!(s.borrow().active.is_some()));
}
#[test]
fn mac_cursor_discarded_pass_does_not_commit_and_installation_does_not_repaint() {
FAKE.with(|s| *s.borrow_mut() = fake_state());
TRACKING.with(|t| t.set(false));
let ctx = Context::default();
install_fake(&ctx);
let mut passes = 0;
let _ = headless(&ctx, raw(ViewportId::ROOT, true), |ui| {
passes += 1;
if passes == 1 {
publish(ui.ctx(), 1.0);
ui.ctx().request_discard("cursor regression");
}
});
assert_eq!(passes, 2);
FAKE.with(|s| assert_eq!(s.borrow().backend.sets, 0));
let idle = || {
let mut input = raw(ViewportId::ROOT, true);
input.events.clear();
input.time = Some(ctx.input(|i| i.time) + 0.1);
input
};
for _ in 0..3 {
let _ = headless(&ctx, idle(), |ui| publish(ui.ctx(), 1.0));
}
let out = headless(&ctx, idle(), |ui| publish(ui.ctx(), 1.0));
assert!(out.viewport_output[&ViewportId::ROOT].repaint_delay > std::time::Duration::ZERO);
}
#[test]
fn mac_cursor_appkit_representations_have_pixel_sizes_and_point_hotspots() {
// Only construct and inspect image/cursor objects: no NSApplication, window or cursor.set().
for scale in [0.75, 1.0, 1.25, 1.5, 2.0] {
let request = request(scale);
let cursor = build(&request.bitmap).unwrap();
let image = cursor.image();
let one = &request.bitmap.representations[0];
let size = NSSize::new(f64::from(one.size[0]), f64::from(one.size[1]));
assert_eq!(image.size(), size);
assert_eq!(cursor.hotSpot(), NSPoint::new(f64::from(one.hotspot[0]), f64::from(one.hotspot[1])));
let reps = image.representations();
assert_eq!(reps.len(), 2);
let mut dimensions: Vec<_> = reps
.iter()
.map(|rep| {
assert_eq!(rep.size(), size);
(rep.pixelsWide(), rep.pixelsHigh())
})
.collect();
dimensions.sort_unstable();
assert_eq!(
dimensions,
[
(isize::try_from(one.size[0]).unwrap(), isize::try_from(one.size[1]).unwrap()),
(isize::try_from(one.size[0]).unwrap() * 2, isize::try_from(one.size[1]).unwrap() * 2)
]
);
}
}
fn headless(ctx: &egui::Context, input: egui::RawInput, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput {
let mut output = ctx.run_ui(input, draw);
output.textures_delta.clear();
output
}
+9 -1
View File
@@ -31,6 +31,10 @@ use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole,
// asynchronous menu presentation). Keep the native tracking state separate from egui popups.
static TRACKING: AtomicBool = AtomicBool::new(false);
pub(crate) fn is_tracking() -> bool {
TRACKING.load(Ordering::Relaxed)
}
fn observe_tracking(ctx: &egui::Context) {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
@@ -41,13 +45,17 @@ fn observe_tracking(ctx: &egui::Context) {
let repaint = ctx.clone();
let block = RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
TRACKING.store(tracking, Ordering::Relaxed);
if tracking {
super::mac_cursor::release();
}
// Apply any deferred menu updates as soon as the tracking session ends.
if !tracking {
repaint.request_repaint();
}
});
// SAFETY: these are AppKit's notification names. The notification center retains
// the observer and block for the application's lifetime; callbacks update an atomic and request repaint.
// the observer and block for the application's lifetime; callbacks update tracking, restore
// owned cursor rectangles and request repaint.
#[allow(unsafe_code)]
unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(name), None, None, &block);
+3
View File
@@ -36,6 +36,8 @@ mod logging;
#[cfg(all(target_os = "macos", feature = "wgpu"))]
mod mac_color_space;
#[cfg(target_os = "macos")]
mod mac_cursor;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
@@ -549,6 +551,7 @@ fn main() -> std::process::ExitCode {
if !in_window_menus {
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
}
mac_cursor::install(&cc.egui_ctx, winit_window.as_ref());
}
#[cfg(not(target_os = "macos"))]
{
+5
View File
@@ -511,6 +511,11 @@ fn canvas_cursor(app: &mut VectorcraftApp, ui: &Ui, view_info: vectorcraft_engin
// (#444). The system cursor stands in for cursors without a glyph. Over a window
// without the focus, macOS shows its own pointer whichever cursor the window sets,
// and the glyph is not painted there.
// Interim #2292: remove when pinned winit correctly handles macOS cursor image and hotspot sizing.
#[cfg(target_os = "macos")]
Some(_) if !app.services.wayland => {
crate::cursors::native_request::request(ui.ctx(), c, cursor_icon(c), custom, ui.ctx().zoom_factor() / ui_scale)
}
Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => {
let ppp = ui.ctx().pixels_per_point() / ui_scale;
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
+71
View File
@@ -10656,3 +10656,74 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() {
assert!(!handle_drawn(&mut app, egui::Modifiers::NONE), "gone once Cmd is released");
assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged");
}
/// The real canvas decision must stay visible without a native host, with no painted glyph.
#[cfg(target_os = "macos")]
#[test]
fn mac_cursor_canvas_without_host_keeps_visible_fallback() {
use crate::cursors::native_request;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
let view = vectorcraft_engine::ViewInfo::default();
for (tool, expected) in [("selection", egui::CursorIcon::Default), ("type", egui::CursorIcon::Text), ("pen", egui::CursorIcon::Crosshair)] {
app.session.select_tool(tool, view).unwrap();
app.hover_doc = Some(Point::new(200.0, 150.0));
let output =
mac_cursor_headless(&ctx, egui::RawInput { events: vec![egui::Event::PointerMoved(pos2(200.0, 150.0))], ..Default::default() }, |ui| {
native_request::clear(ui.ctx());
canvas_cursor(&mut app, ui, view, true);
});
assert_eq!(output.platform_output.cursor_icon, expected, "{tool}: the fallback stays visible without a host");
assert!(output.platform_output.cursor_image.is_none());
assert!(output.shapes.iter().all(|s| matches!(s.shape, egui::Shape::Noop)), "no tool glyph is painted");
assert!(native_request::take(&ctx).is_some(), "{tool}: publish a native request");
}
}
#[cfg(target_os = "macos")]
#[test]
fn mac_cursor_canvas_respects_precise_scaling_focus_and_presentation() {
use crate::cursors::native_request;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let view = vectorcraft_engine::ViewInfo::default();
app.session.select_tool("pen", view).unwrap();
app.hover_doc = Some(Point::new(200.0, 150.0));
let ctx = egui::Context::default();
ctx.set_zoom_factor(1.5);
let frame = |app: &mut VectorcraftApp, focused| {
let _ = mac_cursor_headless(
&ctx,
egui::RawInput { focused, events: vec![egui::Event::PointerMoved(pos2(200.0, 150.0))], ..Default::default() },
|ui| {
native_request::clear(ui.ctx());
canvas_cursor(app, ui, view, true);
},
);
native_request::take(&ctx)
};
let normal = frame(&mut app, true).unwrap();
app.run("prefs.set", json!({"key": "usePreciseCursors", "value": true})).unwrap();
let precise = frame(&mut app, true).unwrap();
assert_ne!(normal.key, precise.key);
let _ = mac_cursor_headless(&ctx, egui::RawInput { events: vec![egui::Event::PointerMoved(pos2(200.0, 150.0))], ..Default::default() }, |ui| {
native_request::request(ui.ctx(), Cursor::Crosshair, egui::CursorIcon::Crosshair, true, 1.0);
});
assert_eq!(native_request::take(&ctx).unwrap().key, precise.key);
app.run("prefs.set", json!({"key": "scaleCursorWithUi", "value": true})).unwrap();
let scaled = frame(&mut app, true).unwrap();
assert_ne!(scaled.key, precise.key);
assert!(scaled.bitmap.representations[0].size[0] > precise.bitmap.representations[0].size[0]);
assert!(frame(&mut app, false).is_none());
assert_eq!(frame(&mut app, true).unwrap().key, scaled.key);
app.ui.screen_mode = 3;
assert!(frame(&mut app, true).is_none());
}
#[cfg(target_os = "macos")]
fn mac_cursor_headless(ctx: &egui::Context, input: egui::RawInput, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput {
let mut output = ctx.run_ui(input, draw);
output.textures_delta.clear();
output
}
+6 -1
View File
@@ -5,6 +5,10 @@
//! 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).
/// Platform-neutral bitmap requests for the interim macOS host adapter.
#[cfg(any(target_os = "macos", test))]
pub mod native_request;
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Painter, Pos2, Shape, Stroke, Vec2, pos2, vec2};
use vectorcraft_tools::Cursor;
@@ -691,7 +695,8 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2, scale: f32) -> bool {
/// 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`]).
/// bitmap cursors there): the web paints them into the window ([`paint`]); the macOS desktop host
/// uses its native request adapter instead.
pub const OS_CURSORS: bool = !cfg!(any(target_os = "macos", target_arch = "wasm32"));
/// Does this window show the glyphs as OS cursors, on a build that can ([`OS_CURSORS`])? Not on
@@ -0,0 +1,136 @@
//! Prepared, platform-neutral requests. The desktop host alone owns native cursor state.
use super::{Cursor, rasterize};
use egui::{Context, CursorIcon, CustomCursorImage, Id, ViewportId};
use std::sync::Arc;
const CAPACITY: usize = 128;
const MAX_PADDED_SIDE: u16 = 258;
/// Opaque identity: the resolved tool cursor and logical scale, independent of display scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Key(Cursor, u32);
/// The same artwork at one and two pixels per logical point, aligned without resampling.
#[derive(Clone, Debug)]
pub struct Bitmap {
/// Straight-alpha RGBA representations; the second size/hotspot is exactly twice the first.
pub representations: [CustomCursorImage; 2],
}
/// One canvas request, valid only for its viewport and pass.
#[derive(Clone, Debug)]
pub struct Request {
/// Identity owned by the UI; the host need not depend on the tools crate.
pub key: Key,
/// Shared prepared pixels, retained independently of cache eviction.
pub bitmap: Arc<Bitmap>,
/// Visible fallback, also used to detect a later UI override.
pub fallback: CursorIcon,
/// Viewport that selected this cursor.
pub viewport: ViewportId,
/// Cumulative pass number when the request was made.
pub pass: u64,
}
#[derive(Clone, Default)]
struct Cache(Vec<(Key, Option<Arc<Bitmap>>)>);
impl Cache {
fn get(&mut self, cursor: Cursor, scale: f32) -> Option<(Key, Arc<Bitmap>)> {
// Bound before tessellation, including malicious/non-finite preference values.
if !scale.is_finite() || !(0.125..=4.0).contains(&scale) {
return None;
}
let key = Key(cursor, scale.to_bits());
if let Some((_, bitmap)) = self.0.iter().find(|(k, _)| *k == key) {
return bitmap.clone().map(|bitmap| (key, bitmap));
}
let bitmap = prepare(cursor, scale).map(Arc::new);
if self.0.len() >= CAPACITY {
self.0.clear();
}
self.0.push((key, bitmap.clone()));
bitmap.map(|bitmap| (key, bitmap))
}
}
fn prepare(cursor: Cursor, scale: f32) -> Option<Bitmap> {
align([rasterize(cursor, scale)?, rasterize(cursor, scale * 2.0)?])
}
fn valid(image: &CustomCursorImage) -> bool {
let [w, h] = image.size;
w > 0 && h > 0 && w <= 256 && h <= 256 && image.hotspot[0] < w && image.hotspot[1] < h && image.rgba.len() == usize::from(w) * usize::from(h) * 4
}
fn align(source: [CustomCursorImage; 2]) -> Option<Bitmap> {
if !source.iter().all(valid) {
return None;
}
let [one, two] = &source;
let mut size = [0; 2];
let mut hotspot = [0; 2];
for axis in 0..2 {
hotspot[axis] = one.hotspot[axis].max(two.hotspot[axis].div_ceil(2));
let after = (one.size[axis] - one.hotspot[axis]).max((two.size[axis] - two.hotspot[axis]).div_ceil(2));
size[axis] = hotspot[axis].checked_add(after)?;
if size[axis].checked_mul(2)? > MAX_PADDED_SIDE {
return None;
}
}
Some(Bitmap { representations: [pad(one, size, hotspot, 1)?, pad(two, size, hotspot, 2)?] })
}
fn pad(source: &CustomCursorImage, size: [u16; 2], hotspot: [u16; 2], factor: u16) -> Option<CustomCursorImage> {
let size = [size[0].checked_mul(factor)?, size[1].checked_mul(factor)?];
let hotspot = [hotspot[0].checked_mul(factor)?, hotspot[1].checked_mul(factor)?];
let x = usize::from(hotspot[0].checked_sub(source.hotspot[0])?);
let y = usize::from(hotspot[1].checked_sub(source.hotspot[1])?);
let stride = usize::from(size[0]) * 4;
let row = usize::from(source.size[0]) * 4;
let mut rgba = vec![0; stride.checked_mul(usize::from(size[1]))?];
for (i, pixels) in source.rgba.chunks_exact(row).enumerate() {
let start = (y + i) * stride + x * 4;
rgba.get_mut(start..start + row)?.copy_from_slice(pixels);
}
Some(CustomCursorImage { rgba: rgba.into(), size, hotspot })
}
fn pending_id(ctx: &Context) -> Id {
Id::unique(("native-canvas-cursor", ctx.viewport_id()))
}
/// Expire the current viewport's request at the start of every host pass.
pub fn clear(ctx: &Context) {
let id = pending_id(ctx);
ctx.data_mut(|data| data.remove::<Request>(id));
}
/// Consume only this viewport's request. The host validates its pass at finalization.
pub fn take(ctx: &Context) -> Option<Request> {
let id = pending_id(ctx);
ctx.data_mut(|data| {
let request = data.get_temp::<Request>(id);
data.remove::<Request>(id);
request
})
}
/// Publish eligible pixels and always return a visible system fallback, even without a host.
pub fn request(ctx: &Context, cursor: Cursor, fallback: CursorIcon, custom: bool, scale: f32) -> CursorIcon {
clear(ctx);
let fallback = if fallback == CursorIcon::None { CursorIcon::Default } else { fallback };
if custom && ctx.input(|i| i.focused && i.pointer.hover_pos().is_some()) {
let prepared = ctx.data_mut(|data| data.get_temp_mut_or_default::<Cache>(Id::unique("native-cursor-bitmaps")).get(cursor, scale));
if let Some((key, bitmap)) = prepared {
let request = Request { key, bitmap, fallback, viewport: ctx.viewport_id(), pass: ctx.cumulative_pass_nr() };
let id = pending_id(ctx);
ctx.data_mut(|data| data.insert_temp(id, request));
}
}
fallback
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,98 @@
use super::*;
#[test]
fn every_glyph_and_rotation_preserves_pixels_and_logical_hotspots() {
let cursors = super::super::GLYPHS.into_iter().chain((0..8).map(Cursor::RotateToward));
for cursor in cursors {
for scale in [0.75, 1.0, 1.25, 1.5, 2.0] {
let bitmap = prepare(cursor, scale).unwrap();
let [one, two] = &bitmap.representations;
assert_eq!(two.size, one.size.map(|x| x * 2));
assert_eq!(two.hotspot, one.hotspot.map(|x| x * 2));
for (i, dest) in bitmap.representations.iter().enumerate() {
let source = rasterize(cursor, scale * (i + 1) as f32).unwrap();
let dx = dest.hotspot[0] - source.hotspot[0];
let dy = dest.hotspot[1] - source.hotspot[1];
for y in 0..dest.size[1] {
for x in 0..dest.size[0] {
let offset = (usize::from(y) * usize::from(dest.size[0]) + usize::from(x)) * 4;
let actual = &dest.rgba[offset..offset + 4];
if x >= dx && x < dx + source.size[0] && y >= dy && y < dy + source.size[1] {
let at = (usize::from(y - dy) * usize::from(source.size[0]) + usize::from(x - dx)) * 4;
assert_eq!(actual, &source.rgba[at..at + 4], "{cursor:?}, {scale}");
} else {
assert_eq!(actual, [0; 4]);
}
}
}
}
}
}
}
#[test]
fn invalid_inputs_and_system_states_are_bounded_and_cached() {
let mut cache = Cache::default();
for scale in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::MAX, 100.0] {
assert!(cache.get(Cursor::Arrow, scale).is_none());
}
for cursor in [Cursor::Busy, Cursor::NotAllowed, Cursor::ScissorsNotAllowed] {
assert!(cache.get(cursor, 1.0).is_none());
assert!(cache.get(cursor, 1.0).is_none());
}
assert_eq!(cache.0.len(), 3);
let good = rasterize(Cursor::Pen, 1.0).unwrap();
for bad in [
CustomCursorImage { rgba: Arc::from([]), ..good.clone() },
CustomCursorImage { size: [u16::MAX, 1], ..good.clone() },
CustomCursorImage { hotspot: good.size, ..good.clone() },
] {
assert!(align([bad, good.clone()]).is_none());
}
let (_, retained) = cache.get(Cursor::Pen, 1.0).unwrap();
assert!(Arc::ptr_eq(&retained, &cache.get(Cursor::Pen, 1.0).unwrap().1));
for i in 0..140 {
cache.get(Cursor::Pen, 1.0 + i as f32 / 1000.0);
}
assert!(cache.0.len() <= CAPACITY);
assert!(valid(&retained.representations[0]));
}
#[test]
fn requests_are_consumed_per_viewport_and_expire_on_empty_passes() {
let ctx = Context::default();
let raw = |id| {
let mut input = egui::RawInput { viewport_id: id, events: vec![egui::Event::PointerMoved(egui::pos2(10.0, 10.0))], ..Default::default() };
input.viewports.entry(id).or_default();
input
};
let root = ViewportId::ROOT;
let panel = ViewportId::from_hash_of("panel");
let _ = headless(&ctx, raw(root), |ui| {
request(ui.ctx(), Cursor::Pen, CursorIcon::Crosshair, true, 1.0);
});
let _ = headless(&ctx, raw(panel), |ui| {
clear(ui.ctx());
assert!(take(ui.ctx()).is_none());
});
assert!(ctx.data(|d| d.get_temp::<Request>(Id::unique(("native-canvas-cursor", root)))).is_some());
let _ = headless(&ctx, raw(root), |ui| {
clear(ui.ctx());
assert!(take(ui.ctx()).is_none());
});
let _ = headless(&ctx, raw(root), |ui| {
request(ui.ctx(), Cursor::Pen, CursorIcon::Crosshair, true, 1.25);
let req = take(ui.ctx()).unwrap();
assert_eq!(req.viewport, root);
assert_eq!(req.pass, ui.ctx().cumulative_pass_nr());
assert!(take(ui.ctx()).is_none());
request(ui.ctx(), Cursor::Pen, CursorIcon::Crosshair, false, 1.0);
assert!(take(ui.ctx()).is_none());
});
}
fn headless(ctx: &egui::Context, input: egui::RawInput, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput {
let mut output = ctx.run_ui(input, draw);
output.textures_delta.clear();
output
}
+3
View File
@@ -349,6 +349,9 @@ fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests(
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 if cfg!(target_os = "macos") {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default);
assert!(out.platform_output.cursor_image.is_none());
} else {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
+4 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -115,6 +115,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G1 Interaction fidelity
macOS cursor delivery (#2292) is narrowed to a native host adapter using the existing artwork and aligned 1×/2× representations. Headless tests cover visible fallbacks, preparation and ownership; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain open validation work (see [hardware parity](hardware-parity.md)). This does not close the interaction-fidelity gap or change its score/estimate.
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
- **Done:** Distance Guides ([public page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html)): with Smart Guides on and something selected, the Selection tool with Alt/Option held over another object shows the horizontal and vertical distances between them, and over an empty part of an artboard the selection's distances from its four edges, labelled in the General unit; Preferences › Smart Guides › Invoke Distance Guides turns them off. Hover only, as documented: not while moving or nudging. No entry closed; estimates unchanged.
@@ -839,6 +841,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-09 | minor | G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. |
| 2026-11-08 | minor | G4 type: the spell check engine is done (`text.spelling.check`, `text.spelling.suggest` on craft-proof, #4936); the Check Spelling dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. |
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Windows: the pen's tilt and barrel rotation from Windows Ink, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Windows: the pen's tilt and barrel rotation from Windows Ink, #372) · **Target:** Adobe Illustrator 2026 (30.x)
What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public
documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code
@@ -26,7 +26,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and
| Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial |
| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h |
| Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done |
| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) |
| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) ; macOS cursors use aligned 1×/2× native image representations (#2292), covered by headless construction tests; mixed-display transitions and physical cursor sizing remain unverified, no additional hardware validated |
| Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) |
| Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h |
| 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) |
@@ -35,6 +35,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-05 | minor | Windows (#372): with Windows Ink, each pen pointer message's `POINTER_PEN_INFO` gives the pen's tilt (`tiltX`, `tiltY`) and barrel rotation where its pen mask says the pen reports them, written into the canvas's pen feed as on macOS and Linux, so a 6D Art Pen's barrel turns a Calligraphic brush's nib. Unit-tested (`windows_stylus::tests::a_pens_tilt_and_rotation_come_where_it_reports_them`); not yet tried with a pen, so the score is unchanged (estimated) |
| 2026-11-01 | patch | Linux (#4967): on a Wayland session, Linux Display Server › Automatic opens the window through XWayland when a pen is attached as the app starts, as X11 (XWayland) When a Pen Is Attached does and as PhotoCraft does; on native Wayland winit 0.30 gets no tablet input and KWin 6.3+ and Mutter emulate no pointer, so the pen moved only the compositor's cursor. `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. Unit-tested (`xwayland::tests`); not tried with a tablet; score unchanged (estimated) |
| 2026-10-30 | patch | Touch (#4198): a second finger landing cancels the tool press the first finger began, also in the same frame (the Pencil's stroke and a Selection move are rolled back, nothing enters the undo history), and a finger landing while another is down presses nothing, so a pinch or two-finger pan only navigates. Headless canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`; not tried on a touch screen; score unchanged (estimated) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -153,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above |
@@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. |
| 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. |
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
+4 -3
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -237,7 +237,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged ; interim macOS cursor delivery (#2292), headless coverage only; native validation pending, score and hours unchanged |
| Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated |
| Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) |
| **Feature depth** | **105** | **~75%** | | **~250–420** | |
@@ -1458,7 +1458,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
anchor or with Alt held over a path (convert) and over another open path's end (join). The Eyedropper has an original pipette glyph with its tip at the hotspot;
Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted
cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests/prefs.rs` cover #1269;
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the tool cursor is painted only while the window has the focus. Over a window behind another app, or under the File › Open… sheet, the system pointer shows alone instead of next to the painted cursor (#2020).
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed.
In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°.
- **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. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502).
Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior.
@@ -1899,6 +1899,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. |
| 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. |
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -316,6 +316,7 @@ tools, never checked side by side) isn't captured row by row.
| Tool double-click opens its options | done | |
| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | |
| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) |
| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. |
| Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass |
## Keyboard shortcuts
@@ -493,6 +494,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |