From 0ca6503f0916e2b2e57e10373f9ef1db1c1c83f7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 05:02:28 +0200 Subject: [PATCH] Canvas: scroll bars along the window's right and bottom edges (#2322) --- crates/ui-egui/src/canvas.rs | 294 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 19 +++ docs/ui-parity.md | 4 +- 3 files changed, 316 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f640a83e7..69bd00c9a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,8 @@ use crate::theme::{self, Tokens}; use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; const RULER: f32 = 16.0; +/// The scroll bars' thickness along the window's right and bottom edges. +const SCROLL_BAR: f32 = 12.0; /// Screen ↔ document mapping for one frame. #[derive(Clone, Copy, Debug)] @@ -204,12 +206,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_filled(full, 0.0, t.panel_darker); return; } + // Normal Screen Mode and Full Screen Mode with Menu Bar keep the scroll bars along the right + // and bottom edges; Full Screen and Presentation Mode have none. + let outer = full; + let bars = app.ui.screen_mode < 2; + let full = if bars { egui::Rect::from_min_max(full.min, full.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { full }; let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; app.canvas_rect = Some(rect); let fitted = app.view().is_some_and(|v| v.fitted); if !fitted { fit(app, "view.fitArtboard"); } + // Before the canvas's own interaction, so the view the canvas draws is the one they moved to. + if bars { + scroll_bars(app, ui, rect, outer, &t); + } let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); // A press on the canvas while the context menu is open only closes it (a drag too, which egui // alone would leave open); the tool doesn't get it. @@ -886,6 +897,142 @@ fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { vm.center += before - Xf::new(rect, vm).to_doc(p); } +/// One scroll bar along its axis, in screen points: the span it scrolls over (`lo..hi`) and the +/// part of it the canvas shows (`v0..v1`). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Span { + lo: f32, + hi: f32, + v0: f32, + v1: f32, +} + +impl Span { + /// The thumb on a track `len` long: where it starts and how long it is. + fn thumb(self, len: f32) -> (f32, f32) { + let total = (self.hi - self.lo).max(1.0); + let size = (len * (self.v1 - self.v0) / total).clamp(MIN_THUMB.min(len), len); + let room = total - (self.v1 - self.v0); + let at = if room > 0.0 { (self.v0 - self.lo) / room * (len - size) } else { 0.0 }; + (at.clamp(0.0, (len - size).max(0.0)), size) + } + /// Screen points the view moves for each point the thumb moves on a track `len` long. + fn ratio(self, len: f32) -> f32 { + let room = (self.hi - self.lo) - (self.v1 - self.v0); + let free = len - self.thumb(len).1; + if room > 0.0 && free > 0.0 { room / free } else { 0.0 } + } +} + +const MIN_THUMB: f32 = 20.0; + +/// The scroll bars' spans, horizontal then vertical: the art and the artboards (`content`) as the +/// view turns them on screen, half a window past each side, and the view itself, so the thumb is +/// always on its track. The canvas has no edge of its own (no drawing area), so the content sets it. +fn scroll_spans(xf: &Xf, content: Option) -> [Span; 2] { + let r = xf.rect; + let around = content.map(|c| egui::Rect::from_points(&xf.quad(c)).expand2(r.size() / 2.0)).filter(|b| b.is_finite()); + let b = around.map_or(r, |b| b.union(r)); + [Span { lo: b.left(), hi: b.right(), v0: r.left(), v1: r.right() }, Span { lo: b.top(), hi: b.bottom(), v0: r.top(), v1: r.bottom() }] +} + +/// The tracks of the horizontal and the vertical scroll bar, along the bottom and the right edge +/// of the window (`outer`), as long as the canvas (`rect`) beside them. +fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; 2] { + let corner = outer.max - vec2(SCROLL_BAR, SCROLL_BAR); + [ + egui::Rect::from_min_max(pos2(rect.left(), corner.y), pos2(corner.x, outer.bottom())), + egui::Rect::from_min_max(pos2(corner.x, rect.top()), pos2(outer.right(), corner.y)), + ] +} + +/// The window's scroll bars ([`scroll_spans`]): a thumb dragged scrolls the view along the +/// screen's axis (a turned view too), a click on the track pages towards it, and the wheel over a +/// bar scrolls along it. +fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { + let content = app.content_bounds(); + let Some(v) = app.view().copied() else { return }; + let xf = Xf::new(rect, &v); + let painter = ui.painter(); + painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); + let hover = ui.input(|i| i.pointer.hover_pos()); + // Screen points the view moves this frame, or the centre a dragged thumb puts it at. + let mut moved = egui::Vec2::ZERO; + let mut center = None; + for (k, (track, span)) in scroll_tracks(rect, outer).into_iter().zip(scroll_spans(&xf, content)).enumerate() { + let horizontal = k == 0; + let along = |p: Pos2| if horizontal { p.x - track.left() } else { p.y - track.top() }; + let axis = if horizontal { vec2(1.0, 0.0) } else { vec2(0.0, 1.0) }; + let len = if horizontal { track.width() } else { track.height() }; + let (at, size) = span.thumb(len); + let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p)); + let id = egui::Id::new(("canvas-scroll-bar", k)); + let resp = ui.interact(track, id, Sense::click_and_drag()); + if resp.drag_started() + && let Some(p) = ui.input(|i| i.pointer.press_origin()) + && on_thumb(p) + { + // The span moves with the view: the drag keeps the one it started with. + let d = ThumbDrag { from: along(p), center: v.center, ratio: span.ratio(len), min: span.lo - span.v0, max: span.hi - span.v1 }; + ui.data_mut(|dd| dd.insert_temp(id, d)); + } + let drag: Option = ui.data(|dd| dd.get_temp(id)); + if let Some(d) = drag { + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + let by = ((along(p) - d.from) * d.ratio).clamp(d.min, d.max); + center = Some(d.center + xf.delta_to_doc(axis * by)); + } + if !resp.dragged() || resp.drag_stopped() { + ui.data_mut(|dd| dd.remove::(id)); + } + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + // A page is most of the window, and never past the end of the span. + let page = 0.9 * (span.v1 - span.v0); + if along(p) < at { + moved -= axis * page.min(span.v0 - span.lo); + } else if along(p) > at + size { + moved += axis * page.min(span.hi - span.v1); + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + moved -= axis * if horizontal { d.x + d.y } else { d.y }; + } + painter.rect_filled(track, 0.0, t.ruler); + let edge = if horizontal { [track.left_top(), track.right_top()] } else { [track.left_top(), track.left_bottom()] }; + painter.line_segment(edge, Stroke::new(1.0, t.border)); + let thumb = if horizontal { + egui::Rect::from_min_size(pos2(track.left() + at, track.top()), vec2(size, track.height())).shrink2(vec2(1.0, 3.0)) + } else { + egui::Rect::from_min_size(pos2(track.left(), track.top() + at), vec2(track.width(), size)).shrink2(vec2(3.0, 1.0)) + }; + let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p)); + painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled }); + } + let Some(vm) = app.view_mut() else { return }; + if let Some(c) = center { + vm.center = c; + } + if moved != egui::Vec2::ZERO { + vm.center += xf.delta_to_doc(moved); + } +} + +/// A scroll bar thumb being dragged: where along the track it was pressed, and the view's centre, +/// the span's travel ratio ([`Span::ratio`]) and how far the view could move either way then. +#[derive(Clone, Copy, Debug)] +struct ThumbDrag { + from: f32, + center: Point, + ratio: f32, + min: f32, + max: f32, +} + /// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white /// artboard and on the grey pasteboard alike (#725). fn marquee(p: &egui::Painter, r: egui::Rect) { @@ -5621,4 +5768,151 @@ mod tests { assert_eq!(app.session.active().unwrap().doc, before); } } + + /// The 800 × 600 window [`frame`] draws the canvas in. + const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } }; + + /// A 400 × 300 pt document at 400%, turned `rotation` degrees, with the Rectangle tool: a press + /// that reached the tool would draw. + fn zoomed_in(rotation: f64) -> (VectorcraftApp, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let v = app.view_mut().unwrap(); + v.zoom = 4.0; + v.rotation = rotation; + frame(&mut app, &ctx, vec![]); + (app, ctx) + } + + /// Where the document's point `p` is on screen. + fn on_screen(app: &VectorcraftApp, p: Point) -> Pos2 { + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(p) + } + + /// Scroll bar `k` (0 horizontal, 1 vertical): its track, its span and the thumb's centre. + fn scroll_bar(app: &mut VectorcraftApp, k: usize) -> (egui::Rect, Span, Pos2) { + let rect = app.canvas_rect.unwrap(); + let track = scroll_tracks(rect, WINDOW)[k]; + let span = scroll_spans(&Xf::new(rect, app.view().unwrap()), app.content_bounds())[k]; + let len = if k == 0 { track.width() } else { track.height() }; + let (at, size) = span.thumb(len); + let c = if k == 0 { pos2(track.left() + at + size / 2.0, track.center().y) } else { pos2(track.center().x, track.top() + at + size / 2.0) }; + (track, span, c) + } + + fn primary(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + /// #2322: the Normal and Full Screen with Menu Bar modes keep the scroll bars along the right + /// and bottom edges, beside the rulers; Full Screen and Presentation Mode give the canvas the + /// whole window. + #[test] + fn scroll_bars_take_the_right_and_bottom_edges_in_the_windowed_screen_modes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.rulers = true; + let ctx = egui::Context::default(); + for (mode, max) in + [(0, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (1, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (2, WINDOW.max), (3, WINDOW.max)] + { + app.ui.screen_mode = mode; + frame(&mut app, &ctx, vec![]); + assert_eq!(app.canvas_rect.unwrap().max, max, "screen mode {mode}"); + } + } + + /// #2322: the thumb is as long, against its track, as the view against the art and the + /// artboards with half a window around them; zoomed in it is shorter. + #[test] + fn a_scroll_bar_thumb_is_as_long_as_the_view_is_against_the_content() { + assert_eq!(Span { lo: 0.0, hi: 100.0, v0: 0.0, v1: 100.0 }.thumb(50.0), (0.0, 50.0), "all in view: the whole track"); + assert_eq!(Span { lo: 0.0, hi: 400.0, v0: 150.0, v1: 250.0 }.thumb(100.0), (37.5, 25.0)); + assert_eq!(Span { lo: 0.0, hi: 1000.0, v0: 450.0, v1: 550.0 }.thumb(100.0), (40.0, 20.0), "never shorter than MIN_THUMB"); + let (mut app, ctx) = zoomed_in(0.0); + let len = |app: &mut VectorcraftApp| { + let (track, span, _) = scroll_bar(app, 0); + span.thumb(track.width()).1 + }; + let at_400 = len(&mut app); + app.view_mut().unwrap().zoom = 8.0; + frame(&mut app, &ctx, vec![]); + assert!(len(&mut app) < at_400, "zoomed in, the thumb is shorter"); + } + + /// #2322: dragging a thumb scrolls the view along the screen's axis, in a turned view too, by + /// as much as the thumb's travel stands for; the drag never reaches the tool under the bar. + #[test] + fn dragging_a_scroll_bar_thumb_scrolls_the_view_and_draws_nothing() { + for rotation in [0.0, 90.0, 30.0] { + for k in 0..2 { + let (mut app, ctx) = zoomed_in(rotation); + let p = Point::new(200.0, 150.0); + let before = on_screen(&app, p); + let (track, span, from) = scroll_bar(&mut app, k); + let len = if k == 0 { track.width() } else { track.height() }; + let step = if k == 0 { vec2(40.0, 0.0) } else { vec2(0.0, 40.0) }; + let moved = (40.0 * span.ratio(len)).clamp(span.lo - span.v0, span.hi - span.v1); + assert!(moved > 40.0, "at 400% the view moves further than the thumb: {moved}"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![primary(from, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step / 2.0)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step)]); + frame(&mut app, &ctx, vec![primary(from + step, false)]); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool under the bar drew nothing"); + let after = on_screen(&app, p); + let want = before - step.normalized() * moved; + assert!((after - want).length() < 0.5, "rotation {rotation}, bar {k}: {before:?} -> {after:?}, want {want:?}"); + } + } + } + + /// #2322: a click on the track beyond the thumb scrolls a page (most of the window) towards it. + #[test] + fn a_click_on_a_scroll_bar_track_scrolls_a_page() { + let (mut app, ctx) = zoomed_in(0.0); + let p = Point::new(200.0, 150.0); + let before = on_screen(&app, p); + let (track, _, _) = scroll_bar(&mut app, 0); + let page = 0.9 * app.canvas_rect.unwrap().width(); + let at = pos2(track.right() - 3.0, track.center().y); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![primary(at, true)]); + frame(&mut app, &ctx, vec![primary(at, false)]); + frame(&mut app, &ctx, vec![]); + let after = on_screen(&app, p); + assert!((after.x - (before.x - page)).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "{before:?} -> {after:?}, page {page}"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); + } + + /// #2322: the wheel over the horizontal bar scrolls sideways, over the vertical one up and + /// down (over the canvas it scrolls up and down). + #[test] + fn the_wheel_over_a_scroll_bar_scrolls_along_it() { + for k in 0..2 { + let (mut app, ctx) = zoomed_in(0.0); + let p = Point::new(200.0, 150.0); + let before = on_screen(&app, p); + let (track, _, _) = scroll_bar(&mut app, k); + let at = track.center(); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -30.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![wheel]); + for _ in 0..30 { + frame(&mut app, &ctx, vec![]); + } + let after = on_screen(&app, p); + let want = if k == 0 { before - vec2(30.0, 0.0) } else { before - vec2(0.0, 30.0) }; + assert!((after - want).length() < 0.5, "bar {k}: {before:?} -> {after:?}, want {want:?}"); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7072640de..bc2aad665 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -300,6 +300,8 @@ pub struct CanvasCache { pub selection_box: Option<((u64, u64, bool), Option)>, /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). pub selection_bounds: Option<((u64, u64), Option)>, + /// [`VectorcraftApp::content_bounds`] for (document uid, revision). + pub content_bounds: Option<((u64, u64), Option)>, /// The tools' cursors as OS cursor bitmaps. pub cursors: cursors::Images, } @@ -497,6 +499,7 @@ impl VectorcraftApp { hidden_chars: Default::default(), selection_box: None, selection_bounds: None, + content_bounds: None, cursors: Default::default(), }, perf: Perf::default(), @@ -1055,6 +1058,22 @@ impl VectorcraftApp { self.canvas.selection_bounds = Some((key, b)); b } + + /// The art and the artboards together, the span of the canvas's scroll bars: measured once + /// per revision, as the canvas reads it every frame. + pub fn content_bounds(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision); + if let Some((k, b)) = self.canvas.content_bounds + && k == key + { + return b; + } + let boards = st.doc.artboards.iter().map(|a| a.rect).reduce(|r, a| r.union(a)); + let b = vectorcraft_geom::union_opt(st.doc.art_bounds(), boards); + self.canvas.content_bounds = Some((key, b)); + b + } } /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5034f1f10..14c5601ed 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -151,6 +151,7 @@ tools, never checked side by side) isn't captured row by row. | Behaviour | Status | Notes | |---|---|---| | Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | | +| Document window scroll bars along the right and bottom edges (Normal and Full Screen Mode with Menu Bar; none in Full Screen and Presentation Mode): a thumb as long as the view against the art and the artboards; dragging it scrolls, a click on the track pages, the wheel over a bar scrolls along it; in a rotated view they follow the screen's axes | partial | #2322. The span is the art and the artboards with half a window around them, not Illustrator's 227 in canvas: VectorCraft has no bounded drawing area (#2284). A track click pages rather than jumping to the click; the page size and the rotated-view behaviour are our own choice (the public documentation gives neither) | | Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | | | Eyedropper reads visible sources while their layer/object is locked or outside isolation | done | Ordinary/Shift/Shift+Alt and gradient-stop reads preserve the source and selection; Alt reverse remains editable-only; hidden art stays excluded (`eyedropper_sources.rs`, #1251) | | Add a visible stroke after drawing with inherited None/zero-width paint | done | Paint and Swap Fill/Stroke restore the existing 1 pt default; positive widths and painted zero widths stay as set; Undo/Redo and targeted appearance items tested (`stroke_visibility.rs`, #1253) | @@ -242,6 +243,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | 2026-10-14 | minor | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); the guide-position row (#1659) is now "not scored": the documentation found doesn't establish it, and it is kept as a convenience pending the owner's decision; scores unchanged | | 2026-10-13 | minor | Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); scores unchanged (estimated) | | 2026-10-13 | minor | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged | +| 2026-10-16 | minor | Document window scroll bars (#2322): thumb drag, track paging and the wheel over a bar; hidden in Full Screen and Presentation Mode; scores unchanged | | 2026-10-13 | minor | Alt during a drag from either ruler swaps the new guide's orientation ([public shortcut reference](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); tool and egui event tests cover preview, snapping, release coordinates, cancellation and one-step Undo/Redo; scores unchanged (estimated). | | 2026-10-13 | minor | G1: create width points through Width Point Edit with optional adjoining adjustment; command/pointer/history regressions and full CI passed; dialog layout reviewed; scores and hours unchanged | | 2026-10-13 | minor | Corner Radius: a link toggle behind the field in the Properties and Transform panels; off, a live rectangle shows a radius field for each corner (Top Left, Top Right, Bottom Right, Bottom Left), each setting its own corner (`singleCornerRadius` preference); split from #1594; score unchanged (estimated) |