Canvas: UI Scale must not magnify the image (#2121) (#2333)

Interface > UI Scale is egui's zoom_factor, folded into pixels_per_point
with the display scale. The canvas divides View::zoom by the full
pixels_per_point (PhotocraftApp::point_zoom) and the GPU shader
multiplies it back, so 100% is one document pixel per physical display
pixel at every UI scale -- fixed by #1978 (Canvas: zoom is physical at
any display scale), which the report predates (v0.5.0). No behaviour
change; the report is stale.

Lock it down with a rendered-output regression test
(tests/ui_scale_canvas.rs): a black document is drawn through the real
app on the GPU and CPU canvas at UI Scale 100/125/150/200% and zoom
100/200%, and its on-screen size is measured in the pixels. It stays
60x60/120x120 physical px at every UI scale, and fails when the canvas
stops using point_zoom (75x75 at 125%) or when the GPU shader stops
multiplying by pixels_per_point (48x48 at 125%). The comments on
View::zoom, PhotocraftApp::ppp and canvas_view now name UI Scale too.
This commit is contained in:
Max Murphy authored and GitHub committed 2026-10-10 12:22:49 -04:00
1 parent 2b79c7c25d
commit 3e000bc1d5
4 files changed
+133 -5

No files matched your search

+3 -1
View File
@@ -2171,7 +2171,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// `View::zoom` is device pixels per document pixel; this canvas's geometry is in its own
// viewport's egui points. A document window can sit on another display with a different
// scale, so only the primary canvas records `app.ppp` for the frame's shared helpers
// (`point_zoom`, the navigator, control-channel coordinate mapping).
// (`point_zoom`, the navigator, control-channel coordinate mapping). `pixels_per_point`
// folds in the display scale and Interface › UI Scale alike, so dividing by it keeps 100%
// one document pixel per physical display pixel at any UI scale (#2121).
let ppp = ctx.pixels_per_point();
if primary {
app.ppp = ppp;
+4 -3
View File
@@ -446,9 +446,10 @@ pub struct PhotocraftApp {
fonts_ready: bool,
/// Screen rect of the main canvas last frame (for overlays and the navigator).
pub last_canvas_rect: egui::Rect,
/// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`). The
/// canvas maps document pixels to physical pixels, so point-space geometry divides the view
/// zoom by this (see [`Self::point_zoom`]).
/// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`, which
/// folds in both the display scale and Interface › UI Scale). The canvas maps document pixels
/// to physical pixels, so point-space geometry divides the view zoom by this (see
/// [`Self::point_zoom`]).
pub ppp: f32,
/// The document area showing the active document's canvas last frame (not the tabs, the
/// start screen or an opening file's card): files dropped here are placed as layers.
+2 -1
View File
@@ -402,7 +402,8 @@ pub enum DialogKind {
pub struct View {
/// Screen (device) pixels per document pixel: the user-facing zoom (`100%` is `1.0`).
/// Canvas geometry works in egui points and divides by `ctx.pixels_per_point`
/// (`PhotocraftApp::point_zoom`), so a scaled display doesn't magnify the image.
/// (`PhotocraftApp::point_zoom`), so neither the display scale nor Interface › UI Scale
/// magnifies the image (#1943, #2121).
pub zoom: f32,
/// Document-space point shown at the canvas centre.
pub center: [f32; 2],
+124
View File
@@ -0,0 +1,124 @@
//! #2121: Interface › UI Scale is egui's `zoom_factor`, folded into `pixels_per_point` with the
//! display scale. It must change only the interface, never the image: at 100% zoom one document
//! pixel stays one *physical* display pixel at every UI scale.
//!
//! These tests drive the real UI Scale preference, render the app offscreen on the GPU and the
//! CPU canvas, and measure the document's on-screen size in the pixels. A regression where the
//! canvas stopped dividing by `pixels_per_point` (drawing the image at `UI scale`×) or the GPU
//! shader stopped multiplying back would change the measured size. They skip when no wgpu
//! adapter is available (like `checker_canvas.rs`).
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::control::{ControlRequest, handle};
use serde_json::{Value, json};
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
/// The app with the GPU canvas (`gpu`), or without it (the CPU fallback path).
fn harness(gpu: bool) -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref().filter(|_| gpu) {
app.set_wgpu(rs.clone());
}
app
})
});
match built {
Ok(h) => Some(h),
Err(_) => {
eprintln!("skipping: no GPU adapter");
None
}
}
}
fn control(h: &mut Harness, method: &str, params: Value) {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
handle(h.state_mut(), &ctx, &req);
h.run_steps(4);
}
/// Longest run of `true` in `flags`, as `(start, len)`.
fn longest_run(flags: impl Iterator<Item = bool>) -> (usize, usize) {
let (mut best, mut start, mut len) = ((0, 0), 0, 0);
for (i, f) in flags.enumerate() {
if f {
if len == 0 {
start = i;
}
len += 1;
if len > best.1 {
best = (start, len);
}
} else {
len = 0;
}
}
best
}
/// The black document's width and height in physical screen pixels, measured through the canvas
/// centre (the longest dark run on the centre row and column; the pasteboard is lighter).
fn document_physical_size(h: &mut Harness) -> (i64, i64) {
let ppp = h.ctx.pixels_per_point();
let rendered = h.render().expect("render");
let (w, height) = (rendered.width(), rendered.height());
let px = rendered.into_raw();
let at = |x: u32, y: u32| -> u8 {
let i = ((y * w + x) * 4) as usize;
px[i]
};
let r = h.state().last_canvas_rect;
let cx = ((r.center().x * ppp) as u32).min(w - 1);
let cy = ((r.center().y * ppp) as u32).min(height - 1);
let on_doc = |x: u32, y: u32| at(x, y) < 12;
let (_, dw) = longest_run((0..w).map(|x| on_doc(x, cy)));
let (_, dh) = longest_run((0..height).map(|y| on_doc(cx, y)));
(dw as i64, dh as i64)
}
/// One black `DOC`-square document at 100%/200% zoom at each UI scale, on the GPU or CPU canvas.
fn assert_document_size_is_physical(gpu: bool) {
let Some(mut h) = harness(gpu) else { return };
h.run_steps(4);
const DOC: u32 = 60;
for ui_scale in ["100", "125", "150", "200"] {
{
let app = h.state_mut();
while app.session.active().is_some() {
app.run("file.close", json!({"discard": true})).expect("close");
}
app.run("prefs.set", json!({"values": {"interface.uiScale": ui_scale}})).expect("ui scale");
app.run("file.new", json!({"width": DOC, "height": DOC, "background": "white"})).expect("new");
app.run("edit.fill", json!({"color": "#000000"})).expect("fill");
app.sync_views();
}
h.run_steps(6);
assert_eq!(h.state().perf.gpu, gpu, "UI Scale {ui_scale}: expected the {} canvas", if gpu { "GPU" } else { "CPU" });
for zoom in [1.0f32, 2.0] {
control(&mut h, "ui.set", json!({"zoom": zoom, "center": [DOC as f32 / 2.0, DOC as f32 / 2.0]}));
h.run_steps(4);
let (w, hgt) = document_physical_size(&mut h);
let want = (DOC as f32 * zoom).round() as i64;
assert!(
(w - want).abs() <= 2 && (hgt - want).abs() <= 2,
"{} canvas, UI Scale {ui_scale}, zoom {zoom}: the document is {w}x{hgt} physical px, want {want}",
if gpu { "GPU" } else { "CPU" }
);
}
}
}
#[test]
fn ui_scale_keeps_the_document_pixel_size_on_the_gpu_canvas() {
assert_document_size_is_physical(true);
}
#[test]
fn ui_scale_keeps_the_document_pixel_size_on_the_cpu_canvas() {
assert_document_size_is_physical(false);
}