diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b011af59..200db63d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -804,7 +804,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.alwaysCreateSmartObjectsWhenPlacing", "general.animatedZoom", "general.zoomResizesWindows", - "interface.uiFontSize", "interface.showChannelsInColor", "interface.dynamicColorSliders", "workspace.autoCollapseIconPanels", diff --git a/crates/ui-egui/src/cjk_fonts.rs b/crates/ui-egui/src/cjk_fonts.rs index 7966bc78..73f4aa58 100644 --- a/crates/ui-egui/src/cjk_fonts.rs +++ b/crates/ui-egui/src/cjk_fonts.rs @@ -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> = Mutex::new(None); #[test] diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 7d30735b..0985218a 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -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] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 30eeea37..3ede0441 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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::(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::(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 { + 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)); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3a701c8c..5f0c5719 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/docs/scorecard.md b/docs/scorecard.md index 9f028d7d..adaa9bc2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | |---|---|---|---|---| diff --git a/docs/ui-design.md b/docs/ui-design.md index 0ce68485..03ed8c1c 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -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