Merge pull request #6911 from dotCooCoo/fix/canvas-zoom-ignores-ui-scaling

View: the art keeps its size at a zoom whatever the UI Scaling
This commit is contained in:
BFlat
2026-11-12 23:54:20 +07:00
committed by GitHub
23 changed files with 410 additions and 77 deletions
+1 -1
View File
@@ -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]}"),
(
+1
View File
@@ -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,
+2 -3
View File
@@ -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<vectorcraft_tools::ScreenFrame> {
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()),
+8 -1
View File
@@ -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<crate::canvas::Xf> {
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,
+19 -6
View File
@@ -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>(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;
+11 -8
View File
@@ -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<Pos2> = 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 { .. } => {}
+11 -9
View File
@@ -263,11 +263,11 @@ fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec<b
.collect()
}
/// Fit `v` to show its artboard (the first if it has gone) in `rect`, as Fit Artboard in Window
/// does.
fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect) {
/// Fit `v` to show its artboard (the first if it has gone) in `rect`, with the interface zoomed by
/// `ui_zoom`, as Fit Artboard in Window does.
fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect, ui_zoom: f32) {
if let Some(target) = doc.artboards.get(v.artboard).or(doc.artboards.first()).map(|a| a.rect) {
v.zoom = fit_zoom(rect, target, v.rotation);
v.zoom = fit_zoom(rect, target, v.rotation, ui_zoom);
v.center = target.center();
v.fitted = true;
}
@@ -284,13 +284,14 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect)
let (full, rect) = view_rects(app, cell, ppp);
let Some(st) = app.session.documents().get(w.0) else { return false };
let doc = st.doc.clone();
let ui_zoom = ui.ctx().zoom_factor();
if let Some(v) = crate::doc_windows::view_mut(app, w).filter(|v| !v.fitted) {
fit_pane(v, &doc, rect);
fit_pane(v, &doc, rect, ui_zoom);
}
let v = crate::doc_windows::view(app, w).copied().unwrap_or_default();
let Some(st) = app.session.documents().get(w.0) else { return false };
let slot = (st.uid, w.1);
let xf = Xf::new(rect, &v);
let xf = Xf::at(rect, &v, ui_zoom);
let painter = ui.painter_at(rect);
paint_paper(app, st, ui, &painter, &xf, rect, (&t, v.outline));
let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs));
@@ -301,15 +302,16 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect)
let size = |l: f32| ((l * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX));
let (pw, ph) = (size(rect.width()), size(rect.height()));
// View › Pixel Preview, as the canvas draws 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();
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 };
+26 -3
View File
@@ -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");
}
+3 -3
View File
@@ -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:?}");
}
@@ -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<egui::Event>) {
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<crate::render_worker::Job>) {
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<crate::render_worker::Job>) -> 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:?}"
);
}
}
+26 -23
View File
@@ -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);
+2 -2
View File
@@ -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");
+2 -3
View File
@@ -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))
}
+1 -1
View File
@@ -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");
@@ -202,8 +202,7 @@ fn bounds(app: &mut VectorcraftApp, d: &mut Dialog) -> Option<Rect> {
fn place(app: &mut VectorcraftApp, ctx: &egui::Context, id: egui::Id, bounds: Option<Rect>) -> 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);
+4
View File
@@ -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<egui::Rect>,
/// 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<control::RotateViewPress>,
/// Hover position in document coordinates.
+1 -1
View File
@@ -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]}"),
(
+55 -6
View File
@@ -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<egui::Pos2> {
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<egui::Pos2> {
let xf = crate::canvas::Xf::at(canvas, v, ui_zoom);
let mut pts: Vec<egui::Pos2> = [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<Vec<egui::Pos2>> = 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() {
+2 -2
View File
@@ -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<f64> {
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<f64> {
/// 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.
+1 -1
View File
@@ -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,
};
+16 -1
View File
@@ -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
+2 -1
View File
@@ -5127,7 +5127,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
File diff suppressed because one or more lines are too long