fix(ui): respect native desktop scale under Auto UI scaling (#1278) (#3165)

* fix(ui): respect native desktop scale under Auto UI scaling (#1278)

* UI scale: drop the now-unused monitor size, update docs and scorecard

Merge main; Auto no longer reads the monitor size, so remove the parameter and its
computation, simplify the test, and update ui-design.md and UI-217-9 to match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 10:53:55 -04:00
1 parent acce8e0e99
commit a1de9ce4c8
4 files changed
+20 -44

No files matched your search

+15 -37
View File
@@ -341,30 +341,20 @@ fn presets_store(app: &mut PhotocraftApp) {
/// Resolve an absolute pixels-per-point preference without multiplying the OS DPI twice.
/// Monitor dimensions are physical pixels, independent of window size and UI zoom.
fn display_scale(pref: prefs::UiScale, native: Option<f32>, monitor_px: Option<egui::Vec2>) -> f32 {
fn display_scale(pref: prefs::UiScale, native: Option<f32>) -> f32 {
let native = native.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
match pref {
prefs::UiScale::Auto => {
// Follow the system's scale, fractional ones included (125%, 150%, 175%: #1072).
// Only when the system reports no scaling at all (100%) does a 4K display fall back
// to 200%, since unscaled 4K controls are unreadably small (#225). Overriding a scale
// the user picked in their desktop settings left only "too small" or "too big".
let unscaled = (native - 1.0).abs() < 0.01;
let is_4k = monitor_px.is_some_and(|s| s.x.is_finite() && s.y.is_finite() && s.x.min(s.y) >= 2160.0 && s.x.max(s.y) >= 3840.0);
if unscaled && is_4k { 2.0 } else { native }
}
// The system's scale, fractional ones included (125%, 150%, 175%: #1072), and 100% on a
// large 4K display run unscaled (#1278); 200% there is a fixed choice.
prefs::UiScale::Auto => native,
fixed => fixed.name().parse::<f32>().map_or(1.0, |pct| pct / 100.0),
}
}
fn sync_display_scale(app: &PhotocraftApp, ctx: &egui::Context) {
let native = ctx.native_pixels_per_point();
// ViewportInfo uses egui points, including the current UI zoom. Converting back to
// physical pixels avoids oscillating between 100% and 200% on successive frames.
// logic() can receive new viewport DPI before InputState::pixels_per_point updates.
let native_scale = native.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
let monitor_px = ctx.input(|i| i.viewport().monitor_size).map(|s| s * (native_scale * ctx.zoom_factor()));
let scale = display_scale(app.session.prefs().interface.ui_scale, native, monitor_px);
let scale = display_scale(app.session.prefs().interface.ui_scale, native);
ctx.set_zoom_factor(scale / native_scale);
}
@@ -2463,28 +2453,16 @@ mod tests {
}
#[test]
fn auto_scale_detects_4k_and_preserves_system_scale() {
fn auto_scale_preserves_system_scale() {
use prefs::UiScale::Auto;
for size in [vec2(3840.0, 2160.0), vec2(4096.0, 2160.0), vec2(2160.0, 3840.0)] {
assert_eq!(display_scale(Auto, Some(1.0), Some(size)), 2.0);
// A fractional system scale is the user's choice and is kept as is (#1072).
for dpi in [1.25, 1.5, 1.75, 2.0, 2.5] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
}
// Whatever the monitor, 4K included: an unscaled desktop stays at 100% (#1278), and a
// fractional or below-100% system scale is the user's choice (#1072).
for dpi in [0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] {
assert_eq!(display_scale(Auto, Some(dpi)), dpi);
}
// Below 100% is a system choice too, even on 4K.
assert_eq!(display_scale(Auto, Some(0.75), Some(vec2(3840.0, 2160.0))), 0.75);
for size in [vec2(1920.0, 1080.0), vec2(2560.0, 1440.0)] {
for dpi in [1.25, 1.5, 1.75] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
}
}
assert_eq!(display_scale(Auto, Some(3.0), Some(vec2(3840.0, 2160.0))), 3.0);
for size in [vec2(1920.0, 1080.0), vec2(2560.0, 1440.0), vec2(3840.0, 1080.0)] {
assert_eq!(display_scale(Auto, Some(1.0), Some(size)), 1.0);
}
assert_eq!(display_scale(Auto, None, None), 1.0);
assert_eq!(display_scale(Auto, Some(f32::NAN), Some(vec2(f32::INFINITY, 2160.0))), 1.0);
assert_eq!(display_scale(Auto, None), 1.0);
assert_eq!(display_scale(Auto, Some(f32::NAN)), 1.0);
assert_eq!(display_scale(Auto, Some(-2.0)), 1.0);
}
#[test]
@@ -2507,13 +2485,13 @@ mod tests {
assert!((ctx.pixels_per_point() - expected).abs() < 1e-4);
};
for _ in 0..4 {
step(vec2(3840.0, 2160.0), 1.0, 2.0);
step(vec2(3840.0, 2160.0), 1.0, 1.0);
}
step(vec2(1920.0, 1080.0), 1.0, 1.0);
step(vec2(3840.0, 2160.0), 1.5, 1.5);
step(vec2(3840.0, 2160.0), 1.25, 1.25);
step(vec2(2560.0, 1440.0), 1.75, 1.75);
step(vec2(3840.0, 2160.0), 1.0, 2.0);
step(vec2(3840.0, 2160.0), 1.0, 1.0);
}
for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("95", 0.95), ("90", 0.9), ("85", 0.85), ("80", 0.8), ("auto", 1.5)] {
app.run("prefs.set", json!({"values": {"interface.uiScale": pref}})).unwrap();
+1 -1
View File
@@ -190,7 +190,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move |
| UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay |
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet |
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale and only jumps to 200 percent on an unscaled 4K display, #1072/#225) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents |
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes |
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
+3 -5
View File
@@ -164,11 +164,9 @@ change the current window's decorations; the next launch reads the saved choice.
Edit → Preferences → Interface → UI Scale applies immediately. Auto follows the operating
system's display scale, including fractional scales such as 125%, 150% and 175% (on Linux,
Wayland's fractional scale or X11's `Xft.dpi`). Only when the system reports no scaling (100%) on
a 4K or larger monitor does Auto use 200%, so text and controls remain readable. Detection uses the current monitor,
including portrait displays, and updates when the window moves between monitors. If monitor
size is unavailable, Auto follows system DPI. The fixed choices (75% to 300%) set an absolute UI
scale, allowing large 4K displays to use smaller controls when desired. Canvas zoom shortcuts
Wayland's fractional scale or X11's `Xft.dpi`), and updates when the window moves between
monitors. A 4K display the system runs at 100% stays at 100% (#1278). The fixed choices (75% to
300%) set an absolute UI scale, for example 200% on an unscaled 4K display. Canvas zoom shortcuts
continue to control the document independently of UI scaling.
Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom.
+1 -1
View File
@@ -66,7 +66,7 @@ id = "UI-217-9"
target = "UI scale presets 100/115/125/150/200 % through zoom_factor, remembered"
status = "done"
issue = 217
note = "prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale and only jumps to 200 percent on an unscaled 4K display, #1072/#225) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting"
note = "prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting"
[[item]]
id = "UI-217-10"