From 5d40a1c6d088c0fdf6c592c64bb6a332544811f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:58:00 -0800 Subject: [PATCH] View: the art keeps its size at a zoom whatever the UI Scaling Preferences > User Interface > UI Scaling sets egui's zoom factor, and the canvas mapped one document point to one of the interface's points at 100%. A document point then covered UI Scaling times as many device pixels, so the art grew and shrank with the interface. The canvas now maps the document at the view's zoom divided by the UI zoom (View::screen_zoom, Xf::at). The rendered frame, the pointer, rulers, grids, Fit and Actual Size, pan, scroll, zoom and turn about a point, the Zoom tool's marquee, Pixel Preview's check, tiled panes, the Navigator's view box and the tools' tolerances use that mapping. The zoom value means the same at every UI Scaling, and the interface still follows UI Scaling. fit_zoom, zoom_about, rotate_about, pan_pixels and scroll_by take the UI zoom, and Xf::new is now test-only. Found while looking into a report by bortek about the art's size at 100%. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/frame.rs | 5 +- crates/ui-egui/src/app/view.rs | 9 +- crates/ui-egui/src/canvas.rs | 25 +- crates/ui-egui/src/canvas/input.rs | 19 +- crates/ui-egui/src/canvas/panes.rs | 20 +- crates/ui-egui/src/canvas/panes/tests.rs | 29 ++- crates/ui-egui/src/canvas/tests.rs | 6 +- crates/ui-egui/src/canvas/tests_ui_scaling.rs | 214 ++++++++++++++++++ crates/ui-egui/src/canvas/view.rs | 49 ++-- crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/dialogs/gradient_stop.rs | 5 +- crates/ui-egui/src/dialogs/revolve_gizmo.rs | 2 +- crates/ui-egui/src/dialogs/simplify_slider.rs | 3 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/navigator.rs | 61 ++++- crates/ui-egui/src/panels/tabs.rs | 4 +- crates/ui-egui/src/place.rs | 2 +- crates/ui-egui/src/state.rs | 17 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 23 files changed, 410 insertions(+), 77 deletions(-) create mode 100644 crates/ui-egui/src/canvas/tests_ui_scaling.rs diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b07964b225..91c7155e75 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -404,7 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3ce7143224..7865375b69 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -127,6 +127,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + canvas_ui_zoom: 1.0, control_rotate_press: None, hover_doc: None, titlebar_from_env: false, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0b87eb16b8..ce9aa6bf93 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas}; +use super::{GeometryStamp, InputGeneration, VectorcraftApp}; impl VectorcraftApp { /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a @@ -9,8 +9,7 @@ impl VectorcraftApp { /// The canvas on screen in document coordinates (none before it is laid out). pub fn screen_frame(&self) -> Option { - let (rect, view) = (self.canvas_rect?, self.view()?); - let xf = canvas::Xf::new(rect, view); + let (rect, xf) = (self.canvas_rect?, self.canvas_xf()?); let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); Some(vectorcraft_tools::ScreenFrame { origin: xf.to_doc(rect.left_top()), diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index f88367cb60..0949b9290f 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -110,9 +110,16 @@ impl VectorcraftApp { self.views.get_mut(i) } + /// The mapping between the document and the canvas of the last frame, at the active view (none + /// before the canvas is laid out or without a document). + pub(crate) fn canvas_xf(&self) -> Option { + Some(crate::canvas::Xf::at(self.canvas_rect?, self.view()?, self.canvas_ui_zoom)) + } + pub fn view_info(&self) -> ViewInfo { ViewInfo { - zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + // The tools measure their tolerances and widgets in the interface's points. + zoom: self.view().map_or(1.0, |v| v.screen_zoom(self.canvas_ui_zoom)), outline: self.outline_view(), smart_guides: self.ui.view.smart_guides, guides: self.ui.view.guides, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6e6806e075..202a0542a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -98,8 +98,16 @@ pub struct Xf { } impl Xf { + /// The mapping of view `v` onto the canvas `rect`, in the points of an interface zoomed by + /// `ui_zoom` (Preferences › User Interface › UI Scaling): a document point covers the same + /// device pixels at every UI Scaling ([`View::screen_zoom`]). + pub fn at(rect: egui::Rect, v: &View, ui_zoom: f32) -> Self { + Self { rect, zoom: v.screen_zoom(ui_zoom), center: v.center, rot: v.rotation.to_radians() } + } + /// [`Self::at`] with the interface unscaled. + #[cfg(test)] pub fn new(rect: egui::Rect, v: &View) -> Self { - Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() } + Self::at(rect, v, 1.0) } pub fn to_screen(&self, p: Point) -> Pos2 { let c = self.rect.center(); @@ -255,6 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bars = app.ui.screen_mode < 2; let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); + app.canvas_ui_zoom = ui.ctx().zoom_factor(); let fitted = app.view().is_some_and(|v| v.fitted); // A document's opening view is fitted to the canvas of the first frame that shows it, and the // window's layout can still move the canvas's edges over the next frames. Until the view is @@ -276,7 +285,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); canvas_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time)); if let Some(secs) = wait { @@ -313,12 +322,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); // View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel // is bigger than a screen pixel (below that the screen render already shows it). - let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images, plugins)); + // The frame renders the view at its zoom in the interface's points, as `xf` maps it. + let drawn = v.on_screen(app.canvas_ui_zoom); + let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins)); // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); @@ -348,7 +359,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { !waiting }); if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { - let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); + let (w, h, view) = frame_view(pixel, (w, h), &drawn, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel }); } @@ -446,7 +457,7 @@ fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view // there. A dialog, floating panel or menu over the canvas hides it (Object Highlighting // showed through them: #4518), unless a drag that began on the canvas goes on there. - let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + let moved = Xf::at(rect, app.view().unwrap_or(&View::default()), app.canvas_ui_zoom); let dragging = ui.data(|d| d.get_temp::(drag_id()).is_some()); app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p) && (dragging || drag::on_canvas(ui, resp, *p))).map(|p| moved.to_doc(p)); @@ -1208,3 +1219,5 @@ fn selection_visuals( #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_ui_scaling; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 43e5df0bcd..5f06eb980d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -14,7 +14,7 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), // so a tap whose press and lift come in one frame has no hover position: it presses (and // double-taps) where it was seen last (#491). @@ -227,10 +227,11 @@ fn wheel_view( // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The // tool's events below take their document points from the view this moves to. if resp.hovered() { + let ui_zoom = app.canvas_ui_zoom; if (factor - 1.0).abs() > 1e-6 && let (Some(p), Some(vm)) = (hover, app.view_mut()) { - zoom_about(vm, rect, p, vm.zoom * factor); + zoom_about(vm, rect, p, vm.zoom * factor, ui_zoom); } if scroll != egui::Vec2::ZERO { scroll_view(app, ui, scroll); @@ -239,7 +240,7 @@ fn wheel_view( && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) && let Some(vm) = app.view_mut() { - rotate_about(vm, rect, p, deg); + rotate_about(vm, rect, p, deg, ui_zoom); } } (pointer, m, space, hover) @@ -328,12 +329,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::Pan { start, center, .. } => { let ppp = ui.ctx().pixels_per_point(); if let Some(vm) = app.view_mut() { vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp)); } } Drag::RotateView { start_angle, start_rot, snaps } => { @@ -360,7 +362,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) if exponent != 0.0 && let Some(vm) = app.view_mut() { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + zoom_about(vm, rect, start, vm.zoom * exponent.exp(), ui_zoom); } ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); } @@ -446,6 +448,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po }); // Backtick as it was at the button-up: a key pressed or released later in the frame does not count. let backquote = backquote_at_release(ui, egui::PointerButton::Primary, f.backquote_before); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::ZoomBox { start } => { let start = zoom_marquee_start(ui, start, before, p, space); @@ -456,16 +459,16 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po // The marquee's center and its size on screen set the view, at any rotation. vm.center = xf.to_doc(r.center()); let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + vm.zoom = (xf.zoom * f64::from(ui_zoom) * f64::from(fill)).clamp(0.0313, 640.0); } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } } // A click (not held, not dragged) steps the zoom as with the marquee. Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } Drag::ZoomScrub { .. } => {} diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index c8dc007538..fb305d29dd 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -263,11 +263,11 @@ fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images, plugins)); + let drawn = v.on_screen(ui_zoom); + let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins)); // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); - let (fw, fh, view) = frame_view(pixel, (pw, ph), &v, ppp); + let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); let Some(st) = app.session.documents().get(w.0) else { return false }; diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 91a0ca5737..9dc008a896 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -105,7 +105,7 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { for (i, r) in docs.iter().zip(&rects) { let v = app.views[*i]; let shown = if *i == 2 { canvas } else { view_rects(&app, *r, ctx.pixels_per_point()).1 }; - assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); + assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); assert_eq!(v.center, artboard.center()); } assert_eq!(app.canvas.panes.shown.len(), 2, "the other two documents are rendered for their panes"); @@ -115,6 +115,29 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { assert_eq!(active, [false, false, true]); } +/// With the interface zoomed by Preferences › User Interface › UI Scaling, each tiled document +/// fits its pane with 30 interface points to spare: its zoom is the zoom that fits it in an +/// unscaled interface times the UI Scaling. +#[test] +fn tiled_documents_fit_their_panes_at_every_ui_scaling() { + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = app_with(3); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(ui_zoom); + let size = vec2(1200.0, 800.0) / ui_zoom; + frame_in(&mut app, &ctx, vec![], size); + app.run("window.arrange.tile", json!({})).unwrap(); + frame_in(&mut app, &ctx, vec![], size); + let (docs, rects) = panes(&app); + for (i, r) in docs.iter().zip(&rects).filter(|(i, _)| **i != 2) { + let shown = view_rects(&app, *r, ctx.pixels_per_point()).1; + let unscaled = ((f64::from(shown.width()) - 60.0) / 400.0).min((f64::from(shown.height()) - 60.0) / 300.0); + let v = app.views[*i]; + assert!(v.fitted && (v.zoom - unscaled * f64::from(ui_zoom)).abs() < 1e-9, "UI Scaling {ui_zoom}×: document {i} fits its pane: {v:?}"); + } + } +} + #[test] fn a_press_in_another_pane_makes_its_document_current_without_drawing() { let mut app = app_with(3); @@ -281,7 +304,7 @@ fn opening_a_document_while_tiled_fits_each_document_to_its_new_pane() { let pane = panes(&app).1[0]; let rect = view_rects(&app, pane, ctx.pixels_per_point()).1; let v = app.views[0]; - assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); + assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0, 1.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); assert_ne!(v.zoom, side_by_side, "the pane changed size"); } @@ -315,7 +338,7 @@ fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { let (_, rects) = panes(&app); for i in [0, 2] { let area = if i == 2 { app.canvas_rect.unwrap() } else { view_rects(&app, rects[i], ctx.pixels_per_point()).1 }; - assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); + assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); } assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom"); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 503178a22b..e01c09c1e2 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4144,11 +4144,11 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0, outline: false }; let mut v = view; - let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); + let rest = scroll_by(&mut v, 1, (1.0, 1.0), vec2(0.4, -0.3), ScrollRest::default()); assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6); let mut again = v; - scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest); + scroll_by(&mut again, 1, (1.0, 1.0), vec2(0.4, -0.3), rest); assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part"); let changed = [ ("zoomed", View { zoom: 2.0, ..v }, 1, 1.0), @@ -4159,7 +4159,7 @@ fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { ]; for (name, before, doc, ppp) in changed { let mut after = before; - let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest); + let left = scroll_by(&mut after, doc, (ppp, 1.0), vec2(0.4 / ppp, -0.3 / ppp), rest); assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped"); assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}"); } diff --git a/crates/ui-egui/src/canvas/tests_ui_scaling.rs b/crates/ui-egui/src/canvas/tests_ui_scaling.rs new file mode 100644 index 0000000000..d9e2a96edf --- /dev/null +++ b/crates/ui-egui/src/canvas/tests_ui_scaling.rs @@ -0,0 +1,214 @@ +//! Preferences › User Interface › UI Scaling zooms the interface and not the art: at a given zoom +//! a document point covers the same device pixels at every UI Scaling, and the pointer lands on the +//! art drawn under it. The Navigator's view box and tiled panes have their own tests +//! (`panels::navigator`, `canvas::panes`). + +use super::*; +use vectorcraft_engine::Session; + +/// The window, in points of the screen before UI Scaling. +const WINDOW: egui::Vec2 = vec2(1200.0, 900.0); + +/// One canvas frame in a `WINDOW` window at `native` device pixels a point, with the interface +/// zoomed by `ui_zoom`. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec) { + ctx.set_zoom_factor(ui_zoom); + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), events, ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); +} + +/// A 512 × 512 pt document shown at `native` device pixels a point and UI Scaling `ui_zoom`, with a +/// controlled render worker, and the receiver of the frames it is asked to render. +fn shown(native: f32, ui_zoom: f32) -> (VectorcraftApp, egui::Context, std::sync::mpsc::Receiver) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 512, "height": 512})).unwrap(); + app.canvas.worker_started = true; + let (worker, jobs, _done) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + // The first frames set the UI zoom and fit the artboard. + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert!((ctx.pixels_per_point() - native * ui_zoom).abs() < 1e-4, "{} device pixels a point", ctx.pixels_per_point()); + (app, ctx, jobs) +} + +/// The view matrix of the last frame the canvas asked the worker to render. +fn rendered(jobs: &std::sync::mpsc::Receiver) -> Affine { + jobs.try_iter().last().expect("a frame was rendered").view +} + +/// The device pixels between two document points in the frame `view` renders. +fn device_length(view: Affine, a: Point, b: Point) -> f64 { + (view * b - view * a).hypot() +} + +/// At 100% a 128 pt edge covers 128 device pixels a point of the screen, whatever the UI Scaling, +/// in the rendered frame and in the canvas's pointer mapping; a fitted artboard draws at the zoom +/// the view reports and fits the canvas. +#[test] +fn a_document_point_covers_the_same_device_pixels_at_every_ui_scaling() { + let (a, b) = (Point::new(100.0, 100.0), Point::new(228.0, 100.0)); + for native in [1.0_f32, 2.0] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + let device = |zoom: f64| 128.0 * zoom * f64::from(native); + + app.run("view.actualSize", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert_eq!(app.view().unwrap().zoom, 1.0, "{at}: Actual Size is 100%"); + let drawn = device_length(rendered(&jobs), a, b); + assert!((drawn - device(1.0)).abs() < 1e-6, "{at}: the frame draws 128 pt over {drawn} device pixels"); + let xf = app.canvas_xf().unwrap(); + let pointed = f64::from((xf.to_screen(b) - xf.to_screen(a)).length() * ctx.pixels_per_point()); + assert!((pointed - device(1.0)).abs() < 1e-3, "{at}: the pointer maps 128 pt to {pointed} device pixels"); + + app.run("view.fitArtboard", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let zoom = app.view().unwrap().zoom; + let view = rendered(&jobs); + let drawn = device_length(view, a, b); + assert!((drawn - device(zoom)).abs() < 1e-6, "{at}: at {zoom} the frame draws 128 pt over {drawn} device pixels"); + // The artboard fits the canvas with 30 interface points to spare on its closer sides. + let rect = app.canvas_rect.unwrap(); + let ppp = f64::from(ctx.pixels_per_point()); + let (lo, hi) = (view * Point::new(0.0, 0.0), view * Point::new(512.0, 512.0)); + let (w, h) = (f64::from(rect.width()) * ppp, f64::from(rect.height()) * ppp); + let spare = [lo.x, lo.y, w - hi.x, h - hi.y].into_iter().fold(f64::INFINITY, f64::min); + assert!((spare - 30.0 * ppp).abs() < 1.0, "{at}: {spare} device pixels to spare in {w} × {h}"); + } + } +} + +/// View › Pixel Preview shows document pixels while one is bigger than a device pixel: at 80% on +/// a screen of one device pixel a point it does not, whatever the UI Scaling. +#[test] +fn pixel_preview_compares_a_document_pixel_with_a_device_pixel() { + for (native, zoom, pixels) in [(1.0_f32, 0.8, false), (1.0, 1.25, true), (2.0, 0.8, true)] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.ui.view.pixel_preview = true; + app.run("view.setZoom", json!({"zoom": zoom * 100.0})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let job = jobs.try_iter().last().expect("a frame was rendered"); + assert_eq!(job.key.pixel.is_some(), pixels, "{native} device pixels a point at {zoom}, UI Scaling {ui_zoom}×"); + } + } +} + +/// At 100% a 20 pt square 100 pt to the right of the view's center is 100 device pixels a point +/// to the right of the canvas's center: a click there selects it, the frame draws it there, and the +/// tools measure their tolerances in the interface's points, whatever the UI Scaling. +#[test] +fn a_click_at_the_true_size_position_selects_the_object_drawn_there_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 1.5), (1.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.run("view.actualSize", json!({})).unwrap(); + let c = app.view().unwrap().center; + let square = Point::new(c.x + 100.0, c.y); + let id = + app.session.execute("shape.rectangle", &json!({"x": square.x - 10.0, "y": square.y - 10.0, "width": 20, "height": 20})).unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let ppp = ctx.pixels_per_point(); + let p = rect.center() + vec2(100.0 * native / ppp, 0.0); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(false)]); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert_eq!(selected, vec![vectorcraft_doc::NodeId(id)], "{at}: a click at {p:?}"); + let device = rendered(&jobs) * square; + let drawn = rect.min + vec2(device.x as f32, device.y as f32) / ppp; + assert!((drawn - p).length() * ppp < 1.0, "{at}: drawn at {drawn:?}, not {p:?}"); + assert!((app.view_info().zoom - 1.0 / f64::from(ui_zoom)).abs() < 1e-9, "{at}: the tools' zoom is {}", app.view_info().zoom); + } +} + +/// A pinch zooms about the pointer: the document point under the pointer stays under it, whatever +/// the UI Scaling. +#[test] +fn a_pinch_zooms_about_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let p = rect.center() + vec2(150.0, -90.0) / ui_zoom; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + let (before, z0) = (app.canvas_xf().unwrap().to_doc(p), app.view().unwrap().zoom); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p), egui::Event::Zoom(1.25)]); + for _ in 0..4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + } + let z1 = app.view().unwrap().zoom; + assert!(z1 > z0 * 1.01, "{at}: the pinch zooms in, {z0} → {z1}"); + let after = app.canvas_xf().unwrap().to_doc(p); + let moved = (after - before).hypot() * app.view().unwrap().screen_zoom(ui_zoom) * f64::from(ctx.pixels_per_point()); + assert!(moved < 1e-3, "{at}: {before:?} under the pointer moved {moved} device pixels to {after:?}"); + } +} + +/// With Animated Zoom off, the Zoom tool zooms to the area dragged across: the zoom grows by the +/// times the marquee fits in the canvas, and the marquee's area then fills the canvas, whatever +/// the UI Scaling. +#[test] +fn the_zoom_tools_marquee_fills_the_canvas_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(40.0, 30.0)); + let xf = app.canvas_xf().unwrap(); + let (a, b, z0) = (xf.to_doc(from), xf.to_doc(to), app.view().unwrap().zoom); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(from, true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(to, false)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let fill = f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + let z1 = app.view().unwrap().zoom; + assert!((z1 - z0 * fill).abs() < 1e-6, "{at}: {z0} → {z1}, not {}", z0 * fill); + let xf = app.canvas_xf().unwrap(); + let shown = xf.to_screen(b) - xf.to_screen(a); + let filled = (shown.x - rect.width()).abs().min((shown.y - rect.height()).abs()); + assert!(filled < 1e-2, "{at}: the marquee's area is {shown:?} in a {:?} canvas", rect.size()); + } +} + +/// A middle-button drag moves the art with the pointer: the document point under the press is +/// under the release, whatever the UI Scaling. +#[test] +fn a_pan_moves_the_art_with_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center(), rect.center() + vec2(60.0, -40.0)); + let pressed = app.canvas_xf().unwrap().to_doc(from); + let middle = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(from, true)]); + for k in 1..=4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]); + } + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(to, false)]); + let released = app.canvas_xf().unwrap().to_doc(to); + let ppp = f64::from(ctx.pixels_per_point()); + let zoom = app.view().unwrap().screen_zoom(ui_zoom); + assert!( + (released - pressed).hypot() * zoom * ppp < 1.0, + "{native} device pixels a point, UI Scaling {ui_zoom}×: {pressed:?} is now under the pointer as {released:?}" + ); + } +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 60cb6687b4..dae1060502 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -33,7 +33,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { let zoom = if how == "view.actualSize" { actual_size_zoom(app.session.prefs.display_print_size) } else { - fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation)) + fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation), app.canvas_ui_zoom) }; let Some(v) = app.view_mut() else { return }; v.center = target.center(); @@ -41,15 +41,15 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { v.zoom = zoom; } -/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` with a -/// 30-point margin on each side. -pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64) -> f64 { +/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` (points of +/// an interface zoomed by `ui_zoom`) with a 30-point margin on each side. +pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64, ui_zoom: f32) -> f64 { // The width and height of the box round the target turned with the view, at zoom 1. let (sn, cs) = rotation.to_radians().sin_cos(); let (w, h) = (target.width(), target.height()); let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0); let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0); - zx.min(zy).clamp(0.0313, 640.0) + (zx.min(zy) * f64::from(ui_zoom)).clamp(0.0313, 640.0) } /// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). @@ -110,26 +110,27 @@ pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 { } /// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document -/// point under screen point `p` where it is. -pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// point under screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.zoom = zoom.clamp(0.0313, 640.0); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under -/// screen point `p` where it is. -pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device -/// pixels a point: the canvas's frame moves by exactly those pixels. -pub(super) fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) { +/// pixels a point of an interface zoomed by `ui_zoom`: the canvas's frame moves by exactly those +/// pixels. +pub(super) fn pan_pixels(vm: &mut View, (ppp, ui_zoom): (f32, f32), (dx, dy): (f64, f64)) { let (sn, cs) = vm.rotation.to_radians().sin_cos(); - let k = vm.zoom * f64::from(ppp); + let k = vm.screen_zoom(ui_zoom) * f64::from(ppp); vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k); } @@ -155,20 +156,21 @@ pub(super) struct ScrollRest { /// of another kind, another document) or the device pixels a point did. pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { let ppp = ui.ctx().pixels_per_point(); + let ui_zoom = app.canvas_ui_zoom; let Some(doc) = app.session.active().map(|st| st.uid) else { return }; let Some(vm) = app.view_mut() else { return }; let id = egui::Id::unique("canvas-scroll-rest"); - let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); + let rest = scroll_by(vm, doc, (ppp, ui_zoom), by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); ui.data_mut(|d| d.insert_temp(id, rest)); } -/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the -/// scrolls that left `rest`: what this scroll leaves. -pub(super) fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest { +/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point of an interface +/// zoomed by `ui_zoom`, after the scrolls that left `rest`: what this scroll leaves. +pub(super) fn scroll_by(vm: &mut View, doc: u64, (ppp, ui_zoom): (f32, f32), by: egui::Vec2, rest: ScrollRest) -> ScrollRest { let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) }; let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp)); let (dx, dy) = (x.round(), y.round()); - pan_pixels(vm, ppp, (dx, dy)); + pan_pixels(vm, (ppp, ui_zoom), (dx, dy)); ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) } } @@ -279,7 +281,8 @@ pub(super) fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { let content = app.content_bounds(); let Some(v) = app.view().copied() else { return }; - let xf = Xf::new(rect, &v); + let ui_zoom = app.canvas_ui_zoom; + let xf = Xf::at(rect, &v, ui_zoom); let painter = ui.painter(); painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); let hover = ui.input(|i| i.pointer.hover_pos()); @@ -346,7 +349,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o && let Some(vm) = app.view_mut() { vm.center = c; - pan_pixels(vm, ppp, whole_pixels(-by, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(-by, ppp)); } if moved != egui::Vec2::ZERO { scroll_view(app, ui, -moved); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..1e3abd6592 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -870,7 +870,7 @@ fn rotate_view_pointer( None => { let rect = app.canvas_rect.ok_or("no canvas yet")?; let view = app.view().ok_or("no document")?; - RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + RotateViewPress { window, document_resets, xf: Xf::at(rect, view, app.canvas_ui_zoom), start_angle: 0.0, start_rotation: view.rotation } } }; // A gesture cannot turn a different document/view or resume after its document was reset. @@ -975,7 +975,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Err(e) => return err(e), }; let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let xf = app.canvas_xf(); let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); if !rotating && xf.is_none() && events.iter().any(screen) { return err("no canvas yet"); diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index c4c043b39e..63dcfa916b 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -92,9 +92,8 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); let area = egui::Id::unique("gradient-stop-popover"); let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size()); - let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) { - Some((canvas, v)) => { - let xf = Xf::new(canvas, &v); + let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.canvas_xf()) { + Some((canvas, xf)) => { let at = xf.to_screen(chip); selection_on_screen(app, &xf).map_or(at + vec2(14.0, 14.0), |art| beside_art(at, art, size, canvas)) } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 5016eacd06..4595447ac2 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -92,7 +92,7 @@ impl Geometry { let state = app.session.active()?; let node = state.doc.node(*state.selection.objects.first()?)?; let bounds = node.geometric_bounds()?; - let xf = Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let options = super::revolve::light_options(d); // Both Revolves turn about their axis; the others about the centre of the art's bounds. let id = d.str("__effect"); diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index 820ab9af90..88838585ee 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -202,8 +202,7 @@ fn bounds(app: &mut VectorcraftApp, d: &mut Dialog) -> Option { fn place(app: &mut VectorcraftApp, ctx: &egui::Context, id: egui::Id, bounds: Option) -> Pos2 { let size = egui::AreaState::load(ctx, id).and_then(|s| s.size).unwrap_or(vec2(270.0, 42.0)); let screen = ctx.content_rect(); - let Some((rect, view)) = app.canvas_rect.zip(app.view().copied()) else { return screen.center() - size / 2.0 }; - let xf = crate::canvas::Xf::new(rect, &view); + let Some((rect, xf)) = app.canvas_rect.zip(app.canvas_xf()) else { return screen.center() - size / 2.0 }; let under = match bounds { Some(b) => { let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be668e1e4f..4a1888337e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -197,6 +197,10 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, + /// The UI zoom (egui's zoom factor, set by Preferences › User Interface › UI Scaling) of the + /// frame that laid out [`Self::canvas_rect`]; 1.0 before the first one. [`Self::canvas_xf`] + /// maps the document to that rect with it. + pub(crate) canvas_ui_zoom: f32, /// An unfinished control-channel Rotate View gesture, kept only for this app instance. pub(crate) control_rotate_press: Option, /// Hover position in document coordinates. diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f435bfff22..f15ff97744 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -405,7 +405,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index da8048407b..60eb4c18d0 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -249,11 +249,12 @@ pub fn thumb_to_doc(region: DRect, thumb: Rect, p: egui::Pos2) -> Point { Point::new(region.x0 + (p.x as f64 - ox) / s, region.y0 + (p.y as f64 - oy) / s) } -/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v`, mapped -/// into the document (turned with the view) and then into the thumbnail, cut to the thumbnail -/// grown by a point. Fewer than three points when the view is outside the thumbnail. -fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View) -> Vec { - let xf = crate::canvas::Xf::new(canvas, v); +/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v` (with the +/// interface zoomed by `ui_zoom`), mapped into the document (turned with the view) and then into +/// the thumbnail, cut to the thumbnail grown by a point. Fewer than three points when the view is +/// outside the thumbnail. +fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View, ui_zoom: f32) -> Vec { + let xf = crate::canvas::Xf::at(canvas, v, ui_zoom); let mut pts: Vec = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()] .iter() .map(|c| doc_to_thumb(region, thumb, xf.to_doc(*c))) @@ -330,7 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The view box: the canvas's corners in the document, turned with the view, cut to the thumbnail. let view = app.view().copied(); if let (Some(v), Some(cr)) = (view, app.canvas_rect) { - let box_points = view_box(region, thumb, cr, &v); + let box_points = view_box(region, thumb, cr, &v, app.canvas_ui_zoom); if box_points.len() > 2 { let color = app.ui.navigator_panel.view_box(); ui.painter().with_clip_rect(thumb).add(egui::Shape::closed_line(box_points, Stroke::new(1.5, color))); @@ -449,6 +450,54 @@ mod tests { assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{points:?} is not {want:?}"); } + /// With the interface zoomed by Preferences › User Interface › UI Scaling, the view box still + /// outlines what the canvas shows: at 400% and UI Scaling 1.5×, a canvas 600 interface points + /// wide shows 600 × 1.5 / 4 document points. + #[test] + fn the_view_box_outlines_the_canvas_at_every_ui_scaling() { + use vectorcraft_engine::Session; + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + app.canvas_rect = Some(canvas); + app.canvas_ui_zoom = ui_zoom; + app.run("view.setZoom", json!({"zoom": 400, "center": [200, 150]})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let pasteboard = Tokens::get(&ctx).pasteboard; + let thumb = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == pasteboard => Some(r.rect), + _ => None, + }) + .expect("the thumbnail"); + let red = egui::epaint::ColorMode::Solid(Color32::from_rgb(0xff, 0x30, 0x30)); + let drawn: Vec> = out + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Path(p) if p.closed && p.stroke.color == red => Some(p.points), + _ => None, + }) + .collect(); + let [points] = &drawn[..] else { panic!("UI Scaling {ui_zoom}×: one view box: {drawn:?}") }; + let region = CACHE.with(|c| c.borrow().region.unwrap().1); + // Each canvas corner is (its offset from the canvas's center) × UI Scaling / zoom + // document points from the view's center. + let k = f64::from(ui_zoom) / 4.0; + let want = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()].map(|c| { + let d = c - canvas.center(); + doc_to_thumb(region, thumb, Point::new(200.0 + f64::from(d.x) * k, 150.0 + f64::from(d.y) * k)) + }); + assert_eq!(points.len(), 4, "UI Scaling {ui_zoom}×: {points:?}"); + assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "UI Scaling {ui_zoom}×: {points:?} is not {want:?}"); + } + } + /// The panel menu's Panel Options… is enabled, and a click opens the Navigator Panel Options. #[test] fn panel_options_opens_from_the_panel_menu() { diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 6a984a1ced..c7b8e47828 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -346,7 +346,7 @@ fn place_above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, spa /// Screen points per point of text `n` at the canvas zoom, as [`above_text`] sizes the ruler: /// across the frame for area type, the zoom for point type. fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let per = match &n.kind { NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => f64::from(xf.rect_to_screen(n.geometric_bounds()?).width()) / span, _ => xf.zoom, @@ -358,7 +358,7 @@ fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { /// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the /// column it sets (#729). `span`: the ruler's length in the text's points. fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) -> Option<()> { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let on_screen = xf.rect_to_screen(n.geometric_bounds()?); // Screen points per point of the text: across the frame for area type (so a scaled frame // lines up too), the zoom for point type. diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index de9407e2f3..1169422789 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -451,7 +451,7 @@ impl VectorcraftApp { let at = match pos { _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, - Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), + Some(p) => crate::canvas::Xf::at(rect, v, self.canvas_ui_zoom).to_doc(p), None if is_document(name) => return DropTarget::Open, None => v.center, }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4a0e73da6b..ec026573d0 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,7 +9,9 @@ use crate::theme::Brightness; /// Per-document view (camera). #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct View { - /// Screen points per document point (1.0 = 100%). + /// The zoom (1.0 = 100%): points of the screen per document point, counted before + /// Preferences › User Interface › UI Scaling, which scales the interface and not the art. + /// [`Self::screen_zoom`] is the zoom in the interface's points. pub zoom: f64, /// Document point at the centre of the canvas. pub center: Point, @@ -55,6 +57,19 @@ impl View { outline: false, }) } + + /// The interface's points per document point when the interface is zoomed by `ui_zoom` (egui's + /// zoom factor, which Preferences › User Interface › UI Scaling sets). A document point then + /// covers the same device pixels at every UI Scaling. + pub fn screen_zoom(&self, ui_zoom: f32) -> f64 { + self.zoom / f64::from(ui_zoom) + } + + /// This view with its zoom in the interface's points ([`Self::screen_zoom`]), as the canvas + /// renders it. + pub(crate) fn on_screen(&self, ui_zoom: f32) -> Self { + Self { zoom: self.screen_zoom(ui_zoom), ..*self } + } } /// The status bar's angle menu and View › Rotate View list these preset view angles, each as diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..47ed67641a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5033,7 +5033,8 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +96 screen points, an inch at the system's reference density whatever the display scaling). `uiScaling` scales the +interface and not the art: a zoom shows the art at the same size at every UI Scaling. The tool shortcut preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..3e63245e78 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -870,6 +870,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. Under either wheel preference, Cmd/Ctrl with a touchpad zooms about the pointer. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). + - Preferences › User Interface › UI Scaling scales the interface ([help](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/modify-workspaces.html)). VectorCraft's own rule is that it leaves the art's size alone: at a given zoom a document point covers the same device pixels at every UI Scaling, so View › Actual Size shows the art at the same size whatever the UI Scaling. A click, a pinch or wheel zoom about the pointer, a pan, the Zoom tool's marquee, Fit, tiled panes, Pixel Preview and the Navigator's view box use the same mapping. Evidence: `canvas::tests_ui_scaling`, `tiled_documents_fit_their_panes_at_every_ui_scaling`, `the_view_box_outlines_the_canvas_at_every_ui_scaling`. Scores unchanged. - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except User Interface › Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only; [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on.