Canvas: middle-mouse drag pans the view

Dragging with the middle mouse button pans the canvas with any tool, like
Space-drag, and shows the grabbing hand. Tools still only see the primary
button. ui.click / ui.drag accept "button": "middle".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
epfabian 2026-10-06 20:31:52 +02:00
parent e8d6cedfea
commit 9bbaa2940a
4 changed files with 50 additions and 4 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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<egui::Event>| {
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::<usize>();
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

View File

@ -18,7 +18,7 @@ Start the app with `--control <port>` (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 |