diff --git a/ROADMAP.md b/ROADMAP.md index bb426ad9..dacb44f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,6 +112,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options. - **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). @@ -154,7 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 | +| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 78f5d06c..ebf775b9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -120,12 +120,14 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { } return; }; + let current = app.view().map_or(0, |v| v.artboard); let Some(st) = app.session.active() else { return }; let target = match how { "view.fitAll" => { st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)) } - _ => st.doc.artboards.first().map(|a| a.rect), + // The navigator's artboard (the first if it has gone). + _ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect), }; let Some(target) = target else { return }; let Some(v) = app.view_mut() else { return }; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index cb93e998..c002f8c8 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -372,13 +372,25 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }, ); ui.separator(); + // The artboard navigator: first, previous, the current artboard's number, next, last. let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); - for icon in ["chevrons-left", "chevron-left"] { - widgets::icon_button(ui, icon, "", false, 18.0); + let cur = app.view().map_or(0, |v| v.artboard).min(nab.saturating_sub(1)); + let mut go = None; + for (icon, to) in [("chevrons-left", "first"), ("chevron-left", "previous")] { + if widgets::icon_button_enabled(ui, icon, "", false, cur > 0, 18.0).clicked() { + go = Some(to); + } } - ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5)); - for icon in ["chevron-right", "chevrons-right"] { - widgets::icon_button(ui, icon, "", false, 18.0); + ui.label(egui::RichText::new(if nab > 0 { (cur + 1).to_string() } else { "–".into() }).size(11.5)); + for (icon, to) in [("chevron-right", "next"), ("chevrons-right", "last")] { + if widgets::icon_button_enabled(ui, icon, "", false, cur + 1 < nab, 18.0).clicked() { + go = Some(to); + } + } + if let Some(to) = go + && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) + { + app.status(e); } ui.separator(); let tool = vectorcraft_tools::tool_info(app.session.tool_id()) @@ -667,6 +679,62 @@ mod tests { } } + /// One headless frame of the status bar; returns the artboard navigator's buttons, left to + /// right: first, previous, next, last. + fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); + out.textures_delta.clear(); + let size = egui::vec2(18.0, 18.0); + let mut r: Vec = + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r + } + + #[test] + fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() { + use vectorcraft_geom::Point; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + // Three 200 × 100 artboards in a row, centred at x = 100, 320 and 540. + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0))); + let ctx = egui::Context::default(); + let buttons = navigator(&mut app, &ctx, vec![]); + assert_eq!(buttons.len(), 4); + let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| { + let b = |pressed| egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + navigator(app, &ctx, vec![egui::Event::PointerMoved(r.center())]); + navigator(app, &ctx, vec![b(true)]); + navigator(app, &ctx, vec![b(false)]); + }; + let at = |app: &crate::VectorcraftApp| (app.view().unwrap().artboard, app.view().unwrap().center); + click(&mut app, buttons[2]); + assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "next"); + click(&mut app, buttons[3]); + assert_eq!(at(&app), (2, Point::new(540.0, 50.0)), "last"); + click(&mut app, buttons[1]); + assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "previous"); + // Fit Artboard in Window and Actual Size show the navigator's artboard. + app.run("view.setZoom", json!({"zoom": 300, "center": [0, 0]})).unwrap(); + app.run("view.fitArtboard", json!({})).unwrap(); + assert_eq!(at(&app), (1, Point::new(320.0, 50.0))); + app.run("view.actualSize", json!({})).unwrap(); + assert_eq!((at(&app).1, app.view().unwrap().zoom), (Point::new(320.0, 50.0), 1.0)); + click(&mut app, buttons[0]); + assert_eq!(at(&app), (0, Point::new(100.0, 50.0)), "first"); + // The command also takes an index, clamped to the last artboard. + assert_eq!(app.run("view.goToArtboard", json!({"index": 9})), Ok(json!({"index": 2}))); + assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err()); + } + #[test] fn the_tab_title_names_the_opacity_mask_while_it_is_edited() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f34400d1..9cd6fe19 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -988,6 +988,7 @@ Global Process Color, CMYK 整體印刷色,CMYK glyph {id} 字符 {id} Glyphs 字符 + Go to Artboard 前往工作區域 Go To Link 前往連結 GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。 GPU Performance GPU 效能 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1f6d8d44..e1c306ff 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -147,6 +147,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), ("view.actualSize", "Actual Size", "Cmd+1", "{}"), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), + ( + "view.goToArtboard", + "Go to Artboard", + "", + "{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}", + ), ("view.edges", "Hide Edges", "Cmd+H", "{}"), ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), ("view.rulers", "Show Rulers", "Cmd+R", "{}"), @@ -859,6 +865,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option go_to_artboard(app, p), "view.presentation" => { app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 }; Ok(json!(app.ui.screen_mode)) @@ -1118,6 +1125,31 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Result { + let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?; + let last = n.checked_sub(1).ok_or("the document has no artboards")?; + let current = app.view().map_or(0, |v| v.artboard).min(last); + let to = match p.get("index") { + Some(Value::String(s)) => match s.as_str() { + "first" => 0, + "previous" => current.saturating_sub(1), + "next" => current + 1, + "last" => last, + other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")), + }, + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?, + None => return Err("missing index".into()), + } + .min(last); + if let Some(v) = app.view_mut() { + v.artboard = to; + } + crate::canvas::fit(app, "view.fitArtboard"); + Ok(json!({ "index": to })) +} + /// Checked state for toggle items. pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { if id == "app.language" { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index fdece9af..91e106be 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -17,11 +17,15 @@ pub struct View { pub fitted: bool, /// View rotation in degrees (Rotate View tool). pub rotation: f64, + /// The artboard the status bar's navigator is on (an index): Fit Artboard in Window and Actual + /// Size show it. + #[serde(default)] + pub artboard: usize, } impl Default for View { fn default() -> Self { - Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0 } + Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0, artboard: 0 } } } @@ -34,6 +38,7 @@ impl View { center: v.center, fitted: true, rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 }, + artboard: 0, }, _ => Self::default(), }