Merge pull request #2124 from MarekSpodBiedry/issue-2075

Zoom In/Out shortcuts zoom around the pointer over the canvas (#2075)
This commit is contained in:
Brandon Thomas
2026-11-01 14:49:32 -05:00
committed by GitHub
5 changed files with 101 additions and 8 deletions
+15
View File
@@ -151,9 +151,24 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param
if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) {
return;
}
// Zoom In/Out by key equivalent: the pointer is still in the window. A click in the menu bar
// takes it out, so that zooms around the centre.
if params.is_null()
&& let Some(p) = pointer_zoom_params(ctx, id)
{
activate(app, id, &p);
return;
}
activate(app, id, params);
}
/// Zoom In/Out from a key: the pointer's position (`at`), so the zoom keeps the point under it
/// when it is over the canvas. `None` for other commands or with no pointer in the window.
pub(super) fn pointer_zoom_params(ctx: &egui::Context, id: &str) -> Option<Value> {
let at = ctx.pointer_hover_pos()?;
matches!(id, "view.zoomIn" | "view.zoomOut").then(|| json!({"at": [at.x, at.y]}))
}
/// The editing commands a focused text field takes from the native menu bar.
pub(super) const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"];
+9 -5
View File
@@ -1,4 +1,4 @@
use super::invoke::activate;
use super::invoke::{activate, pointer_zoom_params};
use super::{DesignApp, ScreenMode, Value, json};
/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
@@ -39,7 +39,7 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
}
let typing = app.session.wants_text();
let keys = ctx.input_mut(|i| take_keys(app, i, typing));
run_keys(app, keys, typing);
run_keys(app, ctx, keys, typing);
}
/// Keys that a floating panel in its own OS window left over (craft-ui's
@@ -59,7 +59,7 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec
input.events = events;
input.modifiers = modifiers;
let keys = take_keys(app, &mut input, typing);
run_keys(app, keys, typing);
run_keys(app, ctx, keys, typing);
crate::canvas::forwarded_keys(app, input.events, modifiers);
}
@@ -112,7 +112,7 @@ fn take_keys(app: &mut DesignApp, input: &mut egui::InputState, typing: bool) ->
}
/// Runs what [`take_keys`] took, then Esc's return to the Selection tool.
fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
fn run_keys(app: &mut DesignApp, ctx: &egui::Context, keys: Keys, typing: bool) {
for step in keys.presentation {
match step {
None => {
@@ -138,7 +138,11 @@ fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
app.select_tool(&tool);
} else {
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &hit.command.id, &hit.command.params);
// Zoom In/Out by key zoom around the pointer while it is over the canvas.
match pointer_zoom_params(ctx, &hit.command.id) {
Some(p) => activate(app, &hit.command.id, &p),
None => activate(app, &hit.command.id, &hit.command.params),
}
}
}
// Esc with no shortcut of its own goes back to the Selection tool.
+8 -1
View File
@@ -575,7 +575,14 @@ fn run_ui_part3(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
Some(match id {
"view.zoomIn" | "view.zoomOut" => {
if let Some(r) = rect {
crate::canvas::zoom_at(app, r.center(), if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
// A keyboard zoom names the pointer ("at"); it counts only over the canvas.
let at = p
.get("at")
.and_then(Value::as_array)
.filter(|a| a.len() == 2)
.and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let at = at.filter(|at| r.contains(*at)).unwrap_or_else(|| r.center());
crate::canvas::zoom_at(app, at, if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
}
Ok(Value::Null)
}
+67
View File
@@ -3152,3 +3152,70 @@ fn file_menu_ends_with_quit_but_the_macos_menu_bar_leaves_it_to_the_app_menu() {
let file_items = native.iter().find(|(t, _)| *t == "File").map(|(_, e)| e);
assert!(!matches!(file_items.and_then(|e| e.last()), Some(Item::Sep)), "no separator left at the end of File");
}
#[test]
fn zoom_commands_zoom_around_the_pointer_when_it_is_over_the_canvas() {
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![]);
let rect = app.canvas_rect.unwrap();
let pointer = rect.center() + egui::vec2(200.0, 120.0);
let under = |app: &crate::DesignApp, at: egui::Pos2| crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_canvas(at);
let before = under(&app, pointer);
let zoom = app.view().unwrap().zoom;
// The command with a pointer position keeps the point under it fixed.
app.run("view.zoomIn", json!({"at": [pointer.x, pointer.y]})).unwrap();
assert!(app.view().unwrap().zoom > zoom);
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
app.run("view.zoomOut", json!({"at": [pointer.x, pointer.y]})).unwrap();
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
// A position outside the canvas (or none: the menu) zooms around the centre.
let centre = under(&app, rect.center());
app.run("view.zoomIn", json!({"at": [-50.0, -50.0]})).unwrap();
let kept = under(&app, rect.center());
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
app.run("view.zoomIn", json!({})).unwrap();
let kept = under(&app, rect.center());
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
// The keyboard shortcut uses the pointer.
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
let before = under(&app, pointer);
frame(&mut app, vec![egui::Event::PointerMoved(pointer)]);
let key = egui::Event::Key { key: egui::Key::Equals, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
frame(&mut app, vec![key]);
assert!(app.view().unwrap().zoom > 1.0);
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
// The native menu bar's key equivalent (macOS) leaves the pointer in the window and uses it.
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
let before = under(&app, pointer);
native_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pointer)], Some("view.zoomIn"));
assert!(app.view().unwrap().zoom > 1.0);
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
// A click in the menu bar takes the pointer out of the window: the centre stays.
let centre = under(&app, rect.center());
native_frame(&mut app, &ctx, vec![egui::Event::PointerGone], Some("view.zoomOut"));
let kept = under(&app, rect.center());
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
}
+2 -2
View File
@@ -178,8 +178,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"),
("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"),
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{at?: [x, y] window pixels, kept in place when over the canvas}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{at?: [x, y] window pixels, kept in place when over the canvas}"),
("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"),
("view.fitSelection", "Fit Selection in Window", Some("Cmd+Alt+="), "{}"),
("view.fitSpread", "Fit Spread in Window", Some("Cmd+Alt+0"), "{}"),