This commit is contained in:
Brandon Thomas
2026-10-09 20:53:53 -04:00
8 changed files with 122 additions and 23 deletions
+2 -5
View File
@@ -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)
+8
View File
@@ -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)) {
+14 -16
View File
@@ -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));
+2
View File
@@ -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};
+87
View File
@@ -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");
}
+5
View File
@@ -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();
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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` | |