mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
Canvas: scroll bars along the window's right and bottom edges (#2322)
This commit is contained in:
1 parent
917dcdeeef
commit
0ca6503f09
3 files changed
+316
-1
No files matched your search
@@ -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<Rect>) -> [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<ThumbDrag> = 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::<ThumbDrag>(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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,8 @@ pub struct CanvasCache {
|
||||
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
|
||||
/// [`VectorcraftApp::selection_bounds`] for (document uid, revision).
|
||||
pub selection_bounds: Option<((u64, u64), Option<vectorcraft_geom::Rect>)>,
|
||||
/// [`VectorcraftApp::content_bounds`] for (document uid, revision).
|
||||
pub content_bounds: Option<((u64, u64), Option<vectorcraft_geom::Rect>)>,
|
||||
/// 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<vectorcraft_geom::Rect> {
|
||||
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
|
||||
|
||||
+3
-1
@@ -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 Line truncated
|
||||
> **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 toLine truncated
|
||||
|
||||
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) |
|
||||
|
||||
Reference in new issue
Block a user