diff --git a/ROADMAP.md b/ROADMAP.md index 6b3f8261..c3b699ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ _Last updated: 2026-10-02._ - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - - Native macOS menu bar, vector tool cursors, a ⌘K command palette. + - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. - Four brightness themes, persistent preferences. - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. @@ -68,7 +68,7 @@ _Last updated: 2026-10-02._ | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; 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 done, Trim View; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 | +| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 | | 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 done; accessibility, Windows/Linux packaging pending | 20–30 | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1327b5be..9fd87fa8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -63,9 +63,11 @@ impl Xf { #[derive(Clone, Copy, Debug, PartialEq)] enum Drag { Tool, + /// Hand tool / Space drag, or a middle-button drag with any tool (`middle`). Pan { start: Pos2, center: Point, + middle: bool, }, ZoomBox { start: Pos2, @@ -291,8 +293,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if resp.hovered() { let m = ui.input(|i| i.modifiers); let space = ui.input(|i| i.key_down(egui::Key::Space)); - let cur = if space || app.session.tool_id() == "hand" { - if ui.input(|i| i.pointer.primary_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } + let panning = matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::Pan { .. })); + let cur = if panning { + egui::CursorIcon::Grabbing + } else if space || app.session.tool_id() == "hand" { + egui::CursorIcon::Grab } else if app.session.tool_id() == "zoom" { if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } } else if let Some(p) = app.hover_doc { @@ -368,12 +373,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let tool = app.session.tool_id(); let pan_mode = space || tool == "hand"; - if pointer.primary_pressed() && resp.hovered() { + let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); + if pointer.primary_pressed() && resp.hovered() && !middle_pan { ui.ctx().memory_mut(|mem| mem.stop_text_input()); app.ui.flyout = None; let p = hover.unwrap_or(rect.center()); let d = if pan_mode { - Drag::Pan { start: p, center: v.center } + Drag::Pan { start: p, center: v.center, middle: false } } else if tool == "zoom" { Drag::ZoomBox { start: p } } else if tool == "rotateView" { @@ -392,11 +398,16 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: kind }; ui.data_mut(|dd| dd.insert_temp(drag_id(), d)); + } else if drag.is_none() && resp.hovered() && pointer.button_pressed(egui::PointerButton::Middle) { + // Middle-button drag pans the view whatever the tool. + let start = hover.unwrap_or(rect.center()); + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true })); } else if let Some(d) = drag { let p = pointer.interact_pos().unwrap_or(rect.center()); - if pointer.primary_down() { + let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() }; + if held { match d { - Drag::Pan { start, center } => { + Drag::Pan { start, center, .. } => { let d = xf.delta_to_doc(p - start); if let Some(vm) = app.view_mut() { vm.center = center - d; @@ -1019,3 +1030,42 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { crate::menus::invoke(app, &c, json!({})); } } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + /// One headless canvas frame on an 800 × 600 window. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + } + + fn middle(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() } + } + + #[test] + fn middle_drag_pans_the_view_with_any_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // A drawing tool: the middle button must pan, never draw. + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(400.0, 300.0))]); + let before = *app.view().unwrap(); + frame(&mut app, &ctx, vec![middle(pos2(400.0, 300.0), true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(450.0, 330.0))]); + frame(&mut app, &ctx, vec![middle(pos2(450.0, 330.0), false)]); + let after = *app.view().unwrap(); + assert_eq!(after.zoom, before.zoom); + assert!((after.center.x - (before.center.x - 50.0 / before.zoom)).abs() < 1e-6); + assert!((after.center.y - (before.center.y - 30.0 / before.zoom)).abs() < 1e-6); + // Released: moving no longer pans, and nothing was drawn. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(300.0, 200.0))]); + assert_eq!(app.view().unwrap().center, after.center); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); + } +}