diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 2009e818d..368d6585a 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -236,11 +236,8 @@ fn snapshot_script_failure_exits_non_zero() { #[test] fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() { let script = tmp("snap-ui-zoom.jsonl"); - std::fs::write( - &script, - format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})), - ) - .unwrap(); + std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"}))) + .unwrap(); for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] { let out = tmp(&format!("snap-ui-zoom-{scale}.png")); let o = Command::new(BIN) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dcd3d1f8b..95751f71a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -340,6 +340,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(Value::Null) } + "ui.zoomFactor" => match f("factor") { + // egui's interface scale (its keyboard shortcuts are off: Cmd+= / Cmd+- zoom the photo) + Some(factor) if factor.is_finite() && (0.5..=3.0).contains(&factor) => { + ctx.set_zoom_factor(factor as f32); + ok(Value::Null) + } + _ => err("ui.zoomFactor: factor must be a number from 0.5 to 3"), + }, "ui.screenshot" => { // never over a photo's original if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ac206240a..6027de094 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -453,20 +453,14 @@ mod tests { { let mut view = HeadlessView::new(); for frame in 0..9 { - let key = match frame { - 1 => Some(egui::Key::Plus), - 5 => Some(egui::Key::Minus), - _ => None, - }; - let mut raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]); - if let Some(key) = key { - let modifiers = egui::Modifiers { command: true, ..Default::default() }; - raw.events.push(egui::Event::ModifiersChanged(modifiers)); - raw.events.extend(vec![ - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, - egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, - ]); + // the interface scale changes between frames (Cmd+Plus / Cmd+Minus are the + // photo's zoom, issue #566: egui's own keyboard zoom is off) + match frame { + 1 => view.ctx.set_zoom_factor(1.1), + 5 => view.ctx.set_zoom_factor(1.0), + _ => {} } + let raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]); view.run(raw, |ui| { ui.label("UI zoom changes layout, keeping the host viewport size"); }); @@ -489,7 +483,8 @@ mod tests { app.ui.settings.gpu = false; let mut h = Headless::new(app, [480.0, 320.0], 2.0); let t = Duration::from_secs(5); - assert_eq!(h.request("ui.key", json!({"key": "Plus", "cmd": true}), t)["ok"], true); + h.view.ctx.set_zoom_factor(1.1); + assert_eq!(h.request("ui.inspect", json!({}), t)["ok"], true); assert!(h.view.ctx.zoom_factor() > 1.0); let main = h.view.ctx.clone(); let screenshot = h.app.headless_screenshot(&main, true).unwrap(); @@ -506,7 +501,9 @@ mod tests { app.ui.settings.gpu = false; let mut h = Headless::new(app, [480.0, 320.0], scale); let t = Duration::from_secs(5); - assert_eq!(h.request("ui.key", json!({"key": "Plus", "cmd": true}), t)["ok"], true); + assert_eq!(h.request("ui.zoomFactor", json!({"factor": 1.1}), t)["ok"], true); + h.step(); + assert!(h.view.ctx.zoom_factor() > 1.0); let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap(); let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap(); assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true); @@ -538,7 +535,8 @@ mod tests { app.ui.settings.gpu = false; let mut h = Headless::new(app, [480.0, 320.0], 0.9); let t = Duration::from_secs(5); - assert_eq!(h.request("ui.key", json!({"key": "Minus", "cmd": true}), t)["ok"], true); + h.view.ctx.set_zoom_factor(0.9); + h.step(); assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001); assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true); assert_eq!(h.size, egui::vec2(135.0, 135.0)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e1256341..cf968924c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -68,6 +68,8 @@ mod tests_titlebar; mod tests_unsaved; #[cfg(test)] mod tests_zoom; +#[cfg(test)] +mod tests_zoom_keys; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/tests_zoom_keys.rs b/crates/ui-egui/src/tests_zoom_keys.rs new file mode 100644 index 000000000..3c1111217 --- /dev/null +++ b/crates/ui-egui/src/tests_zoom_keys.rs @@ -0,0 +1,87 @@ +//! Headless tests of the View ▸ Zoom shortcuts and modifier-wheel zoom: Cmd+= / Cmd+- / Cmd+0 +//! (Ctrl on Windows and Linux) zoom the photo and never egui's interface scale, which would +//! otherwise take the same keys at the end of the frame; Ctrl+wheel over the photo in the Edit +//! view zooms it. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Zoom; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn edit_view() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn key(h: &mut Headless, k: &str) { + let r = h.request("ui.key", json!({"key": k, "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn zoom_shortcuts_zoom_the_photo_and_leave_the_interface_scale_alone() { + let mut h = edit_view(); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + let scale = h.view.ctx.zoom_factor(); + // Cmd+= (Ctrl+= on Windows / Linux) is View ▸ Zoom In: the photo, not the UI + key(&mut h, "="); + assert_eq!(h.app.ui.zoom, Zoom::Percent(50.0), "{:?}", h.app.ui.zoom); + assert_eq!(h.view.ctx.zoom_factor(), scale, "Cmd+= scaled the interface"); + // Cmd+Plus (a keypad or layout that reports `+`) is egui's other interface-zoom key + key(&mut h, "Plus"); + assert_eq!(h.view.ctx.zoom_factor(), scale, "Cmd+Plus scaled the interface"); + key(&mut h, "-"); + assert_eq!(h.app.ui.zoom, Zoom::Percent(25.0), "{:?}", h.app.ui.zoom); + assert_eq!(h.view.ctx.zoom_factor(), scale, "Cmd+- scaled the interface"); + key(&mut h, "-"); + assert_eq!(h.app.ui.zoom, Zoom::Fit, "{:?}", h.app.ui.zoom); + assert_eq!(h.view.ctx.zoom_factor(), scale, "Cmd+- scaled the interface"); + // Cmd+0 is Zoom to Fit; egui would reset its interface scale with the same key + h.app.run("view.navigate", json!({"zoom": {"percent": 200.0}})).unwrap(); + h.view.ctx.set_zoom_factor(1.2); + h.step(); + h.step(); + assert!((h.view.ctx.zoom_factor() - 1.2).abs() < 0.001); + key(&mut h, "0"); + assert_eq!(h.app.ui.zoom, Zoom::Fit, "{:?}", h.app.ui.zoom); + assert!((h.view.ctx.zoom_factor() - 1.2).abs() < 0.001, "Cmd+0 reset the interface scale"); +} + +#[test] +fn ctrl_wheel_over_the_photo_zooms_it_in_the_edit_view() { + let mut h = edit_view(); + let img = h.app.image_rect.unwrap(); + let canvas = h.app.canvas_rect.unwrap(); + assert!(canvas.contains(img.center()), "{img:?} outside {canvas:?}"); + let r = h.request("ui.move", json!({"x": img.center().x, "y": img.center().y}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.scroll", json!({"dy": 120.0, "ctrl": true, "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..40 { + h.step(); + } + assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if p > 0.0), "Ctrl+wheel did not zoom: {:?}", h.app.ui.zoom); + assert!(h.app.image_rect.unwrap().width() > img.width(), "the photo did not grow"); + // a plain wheel over the photo pans (or does nothing at Fit): never zooms further + let zoomed = h.app.image_rect.unwrap(); + let r = h.request("ui.scroll", json!({"dy": 120.0}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..40 { + h.step(); + } + assert!((h.app.image_rect.unwrap().width() - zoomed.width()).abs() < 0.01, "a plain wheel changed the zoom"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b04ef1be2..0d7e1570c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -216,6 +216,11 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { } pub fn apply(ctx: &egui::Context) { + // Cmd+= / Cmd+- / Cmd+0 (Ctrl on Windows and Linux) are View ▸ Zoom In / Zoom Out / Zoom to Fit + // for the photo. egui would take the same keys at the end of every frame for its browser-style + // interface zoom (issue #566); on macOS the native menu bar got them first, elsewhere the whole + // UI scaled. + ctx.options_mut(|o| o.zoom_with_keyboard = false); let t = Tokens::default(); ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); let mut v = Visuals::dark(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 14f044093..74c6fc88a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,6 +42,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | +| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | @@ -106,7 +107,8 @@ Headless screenshot dimensions must be finite and at least one logical point, wi positive scale. Rounded output must be at least one pixel per edge, at most 16,384 pixels per edge and 64 million pixels in total. `snapshot` and `ui.resize` reject requests outside these limits before layout or rasterization; supplied resize dimensions must be numbers. -UI zoom changes text and control sizes while preserving the snapshot viewport's requested +An interface zoom (egui's zoom factor; the keyboard never changes it — ⌘= / ⌘− / ⌘0 zoom the +photo, issue #566) changes text and control sizes while preserving the snapshot viewport's requested physical pixel dimensions. Later `ui.resize` requests use current egui points; validation and rasterization both convert them to native viewport points before rounding the output pixels. A native edge below one point after zooming out is valid if it rounds to at least diff --git a/docs/parity.md b/docs/parity.md index 91990b106..8ca702a36 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -656,7 +656,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-AUTO | Auto — ⇧A | P0 | ✅ | `cmd:develop.auto` | | | KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ✅ | `cmd:photo.editInExternal` | ⇧⌘E as observed; the export dialog is ⇧E | | KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | -| KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut` | | +| KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `crates/ui-egui/src/tests_zoom_keys.rs` | the keys zoom the photo only: egui's own interface zoom on ⌘= / ⌘− / ⌘0 (Ctrl on Windows and Linux, where no native menu bar took the keys first) is off (issue #566) | | KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | | KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | | KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ✅ | `cmd:dialog.pasteSettings` | |