Apply the Interface UI Font Size preference (#616)

* Apply the Interface UI Font Size preference

* Regenerate the scorecard after rebase (58 unread settings); drop the before/after screenshots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
才倾梦华V 2026-10-08 04:43:03 +08:00 committed by GitHub
parent fee9ff9613
commit 3f637410cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 181 additions and 4 deletions

View File

@ -804,7 +804,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"general.alwaysCreateSmartObjectsWhenPlacing",
"general.animatedZoom",
"general.zoomResizesWindows",
"interface.uiFontSize",
"interface.showChannelsInColor",
"interface.dynamicColorSliders",
"workspace.autoCollapseIconPanels",

View File

@ -245,6 +245,7 @@ fn add_to_all_families(ctx: &egui::Context, name: String, mut data: FontData) {
}
}
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
crate::theme::size_ui_font(ctx, &name, &mut data);
ctx.add_font(FontInsert { name, data, families });
}
@ -303,6 +304,31 @@ mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn lazy_fallbacks_follow_ui_font_size_and_survive_size_changes() {
use photocraft_engine::prefs::UiFontSize;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
crate::theme::set_ui_font_size(&ctx, UiFontSize::Large);
let name = "test-lazy-fallback".to_string();
add_to_all_families(&ctx, name.clone(), FontData::from_static(photocraft_text::fonts::INTER_REGULAR));
for (size, scale) in [(UiFontSize::Large, 16.0 / 12.0), (UiFontSize::Tiny, 10.0 / 12.0), (UiFontSize::Small, 1.0)] {
crate::theme::set_ui_font_size(&ctx, size);
for _ in 0..2 {
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
}
let fonts = ctx.fonts(|f| f.definitions().clone());
let fallback = fonts.font_data.get(&name).unwrap();
assert_eq!(fallback.tweak.scale, scale);
assert_eq!(fallback.tweak.y_offset_factor, 0.0, "same face as Inter keeps its baseline");
for stack in fonts.families.values() {
assert_eq!(stack.iter().filter(|n| *n == &name).count(), 1);
assert_eq!(stack.last(), Some(&name), "fallback stays at the lowest priority");
}
}
}
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
#[test]

View File

@ -210,6 +210,7 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
app.session.prefs.edit(|p| p.interface.theme = t);
app.prefs_rt.theme_pref = Some(t);
}
crate::theme::set_ui_font_size(ctx, app.session.prefs().interface.ui_font_size);
presets_store(app);
sync_tooltips(app, ctx);
app.sync_recent();
@ -1603,6 +1604,31 @@ mod tests {
}
}
#[test]
fn ui_font_size_commands_follow_themes_and_dpi_and_reset_live() {
let (mut app, _) = app_with_store();
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, ThemeKind::Pro);
let mut input = egui::RawInput::default();
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5);
for (theme, scale, expected) in
[("pro", "100", 1.0), ("studio", "auto", 1.5), ("studioLight", "200", 2.0), ("proMedium", "100", 1.0), ("classic", "auto", 1.5)]
{
app.run("prefs.set", json!({"values": {"interface.uiFontSize": "large", "interface.theme": theme, "interface.uiScale": scale}})).unwrap();
for _ in 0..3 {
ctx.run_ui(input.clone(), |ui| tick(&mut app, ui.ctx())).textures_delta.clear();
}
assert!((ctx.pixels_per_point() - expected).abs() < 1e-4);
assert_eq!(ctx.fonts(|f| f.definitions().font_data["Inter"].tweak.scale), 16.0 / 12.0);
}
app.run("prefs.reset", json!({"path": "interface.uiFontSize"})).unwrap();
for _ in 0..2 {
ctx.run_ui(input.clone(), |ui| tick(&mut app, ui.ctx())).textures_delta.clear();
}
assert_eq!(ctx.fonts(|f| f.definitions().font_data["Inter"].tweak.scale), 1.0);
assert!((ctx.pixels_per_point() - 1.5).abs() < 1e-4, "resetting font size keeps UI Scale");
}
#[test]
fn recent_files_survive_a_restart_and_honour_the_count() {
let (mut app, store) = app_with_store();
@ -1664,6 +1690,7 @@ mod tests {
assert!(prefs::is_hidden("rawDefaults.applyAutoTone"));
assert!(!prefs::is_hidden("general.autoShowHomeScreen"));
assert!(!prefs::is_hidden("interface.uiScale"));
assert!(!prefs::is_hidden("interface.uiFontSize"));
// Hidden values still round-trip through the dialog untouched.
let (mut app, _) = app_with_store();
app.run("prefs.set", json!({"values": {"type.smartQuotes": false}})).unwrap();
@ -1760,6 +1787,7 @@ mod tests {
let values = h.state_mut().ui.dialog_mut(id).unwrap().fields.get_mut("values").unwrap();
values["interface"]["theme"] = json!("studioLight");
values["interface"]["uiFontSize"] = json!("large");
values["performance"]["historyStates"] = json!(12);
h.run_steps(2);
assert!(!h.get_by_label("Apply").accesskit_node().is_disabled());
@ -1768,6 +1796,7 @@ mod tests {
assert_eq!(h.state().session.prefs().performance.history_states, 12);
assert_eq!(h.state().ui.theme, ThemeKind::StudioLight);
assert_eq!(h.ctx.fonts(|f| f.definitions().font_data["Inter"].tweak.scale), 16.0 / 12.0);
assert!(!h.state().session.prefs().type_.smart_quotes, "hidden settings round-trip unchanged");
let d = h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap();
assert_eq!(d.fields["section"], "interface");
@ -1776,6 +1805,7 @@ mod tests {
let saved: Value = serde_json::from_str(store.lock().unwrap().as_ref().unwrap()).unwrap();
assert_eq!(saved["performance"]["historyStates"], 12);
assert_eq!(saved["interface"]["theme"], "studioLight");
assert_eq!(saved["interface"]["uiFontSize"], "large");
// Repeated Apply starts from the validated values, not the dialog's original snapshot.
h.state_mut().ui.dialog_mut(id).unwrap().fields.get_mut("values").unwrap()["performance"]["historyStates"] = json!(22);
@ -1784,16 +1814,19 @@ mod tests {
h.run_steps(4);
assert_eq!(h.state().session.prefs().performance.history_states, 22);
h.state_mut().ui.dialog_mut(id).unwrap().fields.get_mut("values").unwrap()["performance"]["historyStates"] = json!(33);
h.state_mut().ui.dialog_mut(id).unwrap().fields.get_mut("values").unwrap()["interface"]["uiFontSize"] = json!("tiny");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(4);
assert!(h.state().ui.dialogs.iter().all(|d| d.id != id));
assert_eq!(h.state().session.prefs().performance.history_states, 22);
assert_eq!(h.ctx.fonts(|f| f.definitions().font_data["Inter"].tweak.scale), 16.0 / 12.0);
let saved = store.lock().unwrap().clone().unwrap();
let (mut restarted, _) = app_with_saved(Some(saved));
tick(&mut restarted, &egui::Context::default());
assert_eq!(restarted.session.prefs().performance.history_states, 22);
assert_eq!(restarted.ui.theme, ThemeKind::StudioLight);
assert_eq!(restarted.session.prefs().interface.ui_font_size, prefs::UiFontSize::Large);
}
#[test]

View File

@ -9,6 +9,7 @@
//! Widgets read [`Tokens::get`] instead of hard-coding colours, so every theme applies everywhere.
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
use photocraft_engine::prefs::UiFontSize;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@ -342,12 +343,81 @@ pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) {
stack.extend(fallback.iter().cloned());
fonts.families.insert(FontFamily::Name(fam.into()), stack);
}
let size = ui_font_size(ctx);
for (name, data) in &mut fonts.font_data {
size_ui_font(ctx, name, Arc::make_mut(data));
}
ctx.set_fonts(fonts);
ctx.add_plugin(UiFontSizePlugin { applied: size });
// Japanese / Chinese / Korean fallback fonts (craft-fonts' Japanese ones if built in, then
// the system's) are registered on demand (cjk_fonts.rs).
crate::cjk_fonts::install_with(ctx, cjk);
}
fn ui_fonts_id() -> egui::Id {
egui::Id::new("photocraft-ui-fonts")
}
fn ui_font_size(ctx: &egui::Context) -> UiFontSize {
ctx.data(|d| d.get_temp::<UiFontSize>(ui_fonts_id())).unwrap_or_default()
}
fn font_scale(size: UiFontSize) -> f32 {
match size {
UiFontSize::Tiny => 10.0 / 12.0,
UiFontSize::Small => 1.0,
UiFontSize::Medium => 14.0 / 12.0,
UiFontSize::Large => 16.0 / 12.0,
}
}
/// Register the original multiplier before sizing a face, including lazy CJK fallbacks.
pub(crate) fn size_ui_font(ctx: &egui::Context, name: &str, data: &mut FontData) {
ctx.data_mut(|d| d.insert_temp(ui_fonts_id().with(name), data.tweak.scale));
data.tweak.scale *= font_scale(ui_font_size(ctx));
}
/// Apply Interface › UI Font Size independently of display/canvas zoom. Scaling the registered
/// faces covers explicit RichText and painter font sizes as well as egui's text styles. egui
/// 0.36 uses these tweaks in shaping and row metrics; the layout tests below guard that contract.
pub(crate) fn set_ui_font_size(ctx: &egui::Context, size: UiFontSize) {
if ui_font_size(ctx) == size {
return;
}
ctx.data_mut(|d| d.insert_temp(ui_fonts_id(), size));
ctx.request_repaint();
}
/// Font access is valid after the first pass, including when preferences load before it.
/// Keep only the original multipliers, not a second copy of large system font files.
struct UiFontSizePlugin {
applied: UiFontSize,
}
impl egui::Plugin for UiFontSizePlugin {
fn debug_name(&self) -> &'static str {
"photocraft-ui-font-size"
}
fn output_hook(&mut self, ctx: &egui::Context, _output: &mut egui::FullOutput) {
let size = ui_font_size(ctx);
if size == self.applied {
return;
}
// Start from the current stack so lazily registered fallback faces are retained.
let mut fonts = ctx.fonts(|f| f.definitions().clone());
for (name, data) in &mut fonts.font_data {
let id = ui_fonts_id().with(name);
let original = ctx.data(|d| d.get_temp::<f32>(id)).unwrap_or(data.tweak.scale / font_scale(self.applied));
ctx.data_mut(|d| d.insert_temp(id, original));
Arc::make_mut(data).tweak.scale = original * font_scale(size);
}
ctx.set_fonts(fonts);
self.applied = size;
ctx.request_repaint();
}
}
pub fn medium(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("medium".into()))
}
@ -443,6 +513,45 @@ pub fn canvas_bg(t: &Tokens) -> Color32 {
mod tests {
use super::*;
fn text_sizes(ctx: &egui::Context) -> Vec<egui::Vec2> {
let mut sizes = Vec::new();
// The plugin queues changed fonts at the end of a pass; egui applies them next pass.
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
ctx.run_ui(Default::default(), |ui| {
// Include custom painter fonts and explicit RichText sizes, not just text styles.
for font in [FontId::proportional(11.5), medium(12.0), semibold(15.0), mono(12.0), TextStyle::Body.resolve(ui.style())] {
sizes.push(ui.painter().layout_no_wrap("Interface 123".into(), font, Color32::WHITE).size());
}
sizes.push(ui.label(egui::RichText::new("Interface 123").size(13.0)).rect.size());
})
.textures_delta
.clear();
sizes
}
#[test]
fn ui_font_sizes_scale_shaping_and_row_height_without_accumulating() {
let ctx = egui::Context::default();
install_fonts(&ctx);
apply(&ctx, ThemeKind::Pro);
let original = text_sizes(&ctx);
let definitions = ctx.fonts(|f| f.definitions().clone());
for size in [UiFontSize::Tiny, UiFontSize::Small, UiFontSize::Medium, UiFontSize::Large, UiFontSize::Tiny, UiFontSize::Small] {
set_ui_font_size(&ctx, size);
let measured = text_sizes(&ctx);
for (before, after) in original.iter().zip(&measured) {
let expected = *before * font_scale(size);
assert!((after.x - expected.x).abs() < 1.0, "{size:?}: width {after:?} vs {expected:?}");
// egui rounds ascent/descent and the final row height to pixels separately.
assert!((after.y - expected.y).abs() < 1.1, "{size:?}: height {after:?} vs {expected:?}");
}
set_ui_font_size(&ctx, size);
assert_eq!(text_sizes(&ctx), measured, "setting the same size twice is stable");
}
assert_eq!(ctx.fonts(|f| f.definitions().clone()), definitions, "Small restores original font tweaks exactly");
assert_eq!(ctx.zoom_factor(), 1.0);
}
#[test]
fn theme_names_parse() {
assert_eq!(ThemeKind::from_name("Classic"), Some(ThemeKind::Classic));

View File

@ -43,6 +43,10 @@ their #209–#211 targets (first baseline, taken on a heavily loaded machine), a
nudge scenario crashes the GPU compositor; the open area issues' checklists (#203–#220) are
almost entirely missing or partial.
2026-10-07: UI Font Size now applies to interface text, including CJK fallbacks, independently
of display and canvas zoom (#532). The current preference audit drops from 59 to 58 unread
settings out of 135; see the regenerated scorecard.
**Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real
Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional
workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have

View File

@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
| [Tools](#tools) | 6 | 13 | 11 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 59 of 135 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 58 of 135 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
@ -154,9 +154,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 59 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 58 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|

View File

@ -72,6 +72,12 @@ size is unavailable, Auto follows system DPI. The fixed choices (75% to 300%) se
scale, allowing large 4K displays to use smaller controls when desired. 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.
Tiny, Small (the default), Medium and Large use 10/12, 1, 14/12 and 16/12 of the theme's original
font sizes, preserving the relative sizes of headings, captions and numeric fields. Apply or OK
updates text immediately; the choice persists across launches and theme changes. CJK fallback
fonts use the same size, including fonts loaded after the preference changes.
## Localisation
Strings in code stay English and are the default lookup keys. `crates/ui-egui/src/i18n` maps them