mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
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:
@@ -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]}"),
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]}"),
|
||||
(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user