diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1562c6..ede8cd0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -328,7 +328,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Cursor. if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) { let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text(); - let c = if space { Cursor::Hand } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; + let middle = ui.input(|i| i.pointer.middle_down()); + let c = if middle { + Cursor::HandGrab + } else if space { + Cursor::Hand + } else { + app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) + }; ui.ctx().set_cursor_icon(cursor_icon(c)); } } @@ -1345,9 +1352,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } } // Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live - // Distribute while resizing). + // Distribute while resizing). Middle-drag pans too, with any tool; tools only + // see the primary button. let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); - if space && !tool_drag && resp.dragged() { + if !tool_drag && ((space && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) { let d = xf.unrotate_delta(resp.drag_delta()); if let Some(v) = app.view_mut() { v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7ff3d79..3245025 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -183,6 +183,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, + Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81cfc1c..6034101 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2173,6 +2173,43 @@ mod tests { assert!((app.view().unwrap().zoom - z0).abs() < 1e-9, "back at the zoom it started from"); } + #[test] + fn middle_drag_pans_with_any_tool() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // A drawing tool: a primary drag would make a frame, a middle drag must not. + app.select_tool("rectangleFrame"); + let p = app.canvas_rect.unwrap().center(); + let v0 = *app.view().unwrap(); + let items = |app: &crate::DesignApp| app.session.active().unwrap().doc.spreads.iter().map(|s| s.items.len()).sum::(); + let items0 = items(&app); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(p), button(p, true)]); + for i in 1..=8 { + frame(&mut app, vec![egui::Event::PointerMoved(p + egui::vec2(10.0 * i as f32, 5.0 * i as f32))]); + } + frame(&mut app, vec![button(p + egui::vec2(80.0, 40.0), false)]); + let v = *app.view().unwrap(); + assert!((v.zoom - v0.zoom).abs() < 1e-9, "panning keeps the zoom"); + assert!(v.origin.x < v0.origin.x && v.origin.y < v0.origin.y, "dragging right/down moves the view: {v0:?} -> {v:?}"); + assert_eq!(items(&app), items0, "the tool saw nothing"); + } + #[test] fn tool_shortcuts_work_after_drawing_on_the_canvas() { // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c6f334..c7f6abd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,7 +18,7 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |