diff --git a/ROADMAP.md b/ROADMAP.md index fa806707..f014330a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -118,7 +118,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **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. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. - **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. +- **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. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. - **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). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index afb4d87e..d55ffe88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -614,6 +614,14 @@ fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { let r = app.session.pointer(ev, view); + // The artboard the Artboard tool makes active is the active one (the navigator's, the + // Artboards panel's). + if app.session.tool_id() == "artboard" + && let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()) + && let Some(v) = app.view_mut() + { + v.artboard = i; + } apply_requests(app, r); } diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 69dee346..2d0d906e 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -1,4 +1,7 @@ -//! Artboards panel: numbered list with inline rename (double-click), move up / down, new, delete. +//! Artboards panel: numbered list with inline rename (double-click the name), move up / down, new, +//! delete. The highlighted row is the active artboard, the one the status bar's navigator shows and +//! Fit Artboard in Window fits: clicking a row makes it active, double-clicking its number also +//! fits it in the window. use egui::{Sense, Ui, pos2, vec2}; use serde_json::json; @@ -8,8 +11,20 @@ use crate::theme::Tokens; use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; -fn selected(ui: &Ui, n: usize) -> usize { - pstate::(ui.ctx(), "ab-sel").min(n.saturating_sub(1)) +/// The active artboard (of `n`). +fn selected(app: &VectorcraftApp, n: usize) -> usize { + app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1)) +} + +/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it +/// is the tool, the Artboard tool's. +fn select(app: &mut VectorcraftApp, i: usize) { + if let Some(v) = app.view_mut() { + v.artboard = i; + } + if app.session.tool_id() == "artboard" { + app.session.set_tool_option("active", &json!(i)); + } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -19,7 +34,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { super::empty_state(ui, "dc-artboards", tl!("No document"), tl!("Open a document to see its artboards.")); return; } - let sel = selected(ui, abs.len()); + let sel = selected(app, abs.len()); let editing: Option = pstate(ui.ctx(), "ab-edit"); widgets::list_box(ui, |ui| { ui.set_min_height(110.0); @@ -62,13 +77,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let oresp = ui.interact(opt, ui.id().with(("ab-opt", i)), Sense::click()); icons::paint(ui, "dc-artboard-options", opt, if oresp.hovered() { t.text_strong } else { t.icon }); if oresp.on_hover_text(tl!("Artboard Options: edit with the Artboard tool")).clicked() { - set_pstate(ui.ctx(), "ab-sel", i); app.select_tool("artboard"); + select(app, i); } if resp.clicked() { - set_pstate(ui.ctx(), "ab-sel", i); + select(app, i); } - if resp.double_clicked() { + // Double-clicking the number goes to the artboard (fits it in the window); the name + // renames it. + let on_number = resp.interact_pointer_pos().is_some_and(|p| p.x < name_rect.left()); + if resp.double_clicked() && on_number { + app.run("view.goToArtboard", json!({ "index": i })).ok(); + } else if resp.double_clicked() { set_pstate(ui.ctx(), "ab-edit", Some(i)); } } @@ -81,15 +101,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button_enabled(ui, "dc-arrow-up", tl!("Move Up"), false, sel > 0, 24.0).clicked() && app.run("artboard.reorder", json!({"index": sel, "to": sel - 1})).is_ok() { - set_pstate(ui.ctx(), "ab-sel", sel - 1); + select(app, sel - 1); } if widgets::icon_button_enabled(ui, "dc-arrow-down", tl!("Move Down"), false, sel + 1 < n, 24.0).clicked() && app.run("artboard.reorder", json!({"index": sel, "to": sel + 1})).is_ok() { - set_pstate(ui.ctx(), "ab-sel", sel + 1); + select(app, sel + 1); } if widgets::icon_button(ui, "dc-new-item", tl!("New Artboard"), false, 24.0).clicked() && app.run("artboard.new", json!({})).is_ok() { - set_pstate(ui.ctx(), "ab-sel", n); + select(app, n); } if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() { app.run("artboard.delete", json!({"index": sel})).ok(); @@ -99,7 +119,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); - let sel = selected(ui, n); + let sel = selected(app, n); if menu_item(ui, tl!("New Artboard"), n > 0, false) { app.run("artboard.new", json!({})).ok(); } @@ -124,3 +144,100 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("artboard.fitToArt", json!({"index": sel})).ok(); } } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + /// Clicking a row makes its artboard the active one (the navigator's, Fit Artboard in Window's). + #[test] + fn clicking_a_row_makes_its_artboard_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 200, "height": 150})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // Where the second row's name is painted. + let at = frame(&mut app, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "Artboard 2" => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(at), button(true), button(false)] { + frame(&mut app, vec![e]); + } + assert_eq!(app.view().unwrap().artboard, 1, "Artboard 2 is active"); + // The navigator moves it on; the panel follows. + crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"})); + assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator"); + } + /// Double-clicking a row's number goes to its artboard and its name renames it; a row's + /// Options button edits that artboard with the Artboard tool, and the tool's artboard is the + /// active one. + #[test] + fn double_clicks_go_to_or_rename_and_options_edits_the_rows_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 200, "height": 150})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let shapes = frame(&mut app, 0.0, vec![]); + let name = shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "Artboard 2" => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap(); + // The highlighted row (Artboard 1's), for its Options button at the right end. + let row = shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect), + _ => None, + }) + .unwrap(); + let events = |at: egui::Pos2, clicks: usize| { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + std::iter::once(egui::Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect::>() + }; + // Artboard 2's number: it is active, no rename starts. + frame(&mut app, 1.0, events(name - vec2(16.0, 0.0), 2)); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(pstate::>(&ctx, "ab-edit"), None); + // Its name: a rename. + frame(&mut app, 2.0, events(name, 2)); + assert_eq!(pstate::>(&ctx, "ab-edit"), Some(1)); + set_pstate::>(&ctx, "ab-edit", None); + frame(&mut app, 3.0, vec![]); + // Artboard 1's Options button: the Artboard tool, editing Artboard 1. + frame(&mut app, 4.0, events(egui::pos2(row.right() - 14.0, row.center().y), 1)); + assert_eq!(app.session.tool_id(), "artboard"); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["active"].clone()), (0, json!(0))); + // While the Artboard tool is chosen, clicking a row moves the tool to that artboard too. + frame(&mut app, 5.0, events(name, 1)); + assert_eq!(app.session.tool_options()["active"], json!(1)); + // And clicking an artboard with the tool makes it the active one, highlighted in the panel. + let view = app.view_info(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(kind, 100.0, 100.0), view); + } + assert_eq!((app.session.tool_options()["active"].clone(), selected(&app, 2)), (json!(0), 0)); + } +}