From b44ada89e58cc253dd9722122de10c00170de101 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:40:07 -0400 Subject: [PATCH] Preferences: choose the interface font (#1408) Adds an Interface font setting that leads the proportional family, with the default face and CJK/Hangul fallbacks kept behind it (#1290). Co-authored-by: DuBearte Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/fonts.rs | 62 +++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 8 ++- crates/ui-egui/src/prefs.rs | 83 +++++++++++++++++++++++++++++++ crates/ui-egui/src/sketch_text.rs | 2 +- docs/control-protocol.md | 2 +- 5 files changed, 148 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 00c9c868..b02fb892 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -16,6 +16,34 @@ pub const CJK_FALLBACK: &str = "cjk_fallback"; /// Chinese falls through to this one per glyph. pub const CHINESE_FALLBACK: &str = "chinese_fallback"; +/// Name of the chosen interface font in [`egui::FontDefinitions`], first in the Proportional +/// family when one is set (#1290). +pub const UI_FONT: &str = "ui_font"; + +/// The interface font a preference names (#1290): an installed family, a font file, or the +/// sketch text's built-in family. `None` for the default (empty) or a font that can't be read. +pub fn ui_font(name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + let data = solidcraft_engine::sketch::font_data(Some(name)).ok()?; + ttf_parser::Face::parse(data.bytes(), data.index).ok()?; + let mut font = egui::FontData::from_owned(data.bytes().to_vec()); + font.index = data.index; + Some(font) +} + +/// `fonts` with `font` first in the Proportional family (menus, dialogs, labels); the default +/// face and the fallbacks stay after it for the glyphs it lacks. +pub fn with_ui_font(mut fonts: egui::FontDefinitions, font: egui::FontData) -> egui::FontDefinitions { + fonts.font_data.insert(UI_FONT.to_owned(), Arc::new(font)); + let list = fonts.families.entry(egui::FontFamily::Proportional).or_default(); + list.retain(|entry| entry != UI_FONT); + list.insert(0, UI_FONT.to_owned()); + fonts +} + /// Name of the Hangul fallback entry in [`egui::FontDefinitions`], last. pub const HANGUL_FALLBACK: &str = "hangul_fallback"; @@ -441,14 +469,20 @@ pub fn fallback_order(language: &str) -> [&'static str; 3] { } /// Configure font definitions with system CJK, Chinese and Hangul fallbacks where available, -/// ordered for the UI language (see [`fallback_order`]). +/// ordered for the UI language (see [`fallback_order`]), after the interface font `ui_font` +/// when one is chosen (see [`ui_font`]). #[cfg(not(target_arch = "wasm32"))] -pub fn setup_fonts_for(ctx: &egui::Context, language: &str) { +pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) { let cjk = find_system_cjk_font(); let chinese = find_system_chinese_font().filter(|bytes| cjk.as_ref() != Some(bytes)); let hangul = HANGUL_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path))); let mut fonts = egui::FontDefinitions::default(); - let mut found = false; + // Set the fonts also when only the interface font changes, so going back to the default + // takes the chosen one away. + let mut found = !ui_font.trim().is_empty(); + if let Some(font) = self::ui_font(ui_font) { + fonts = with_ui_font(fonts, font); + } for name in fallback_order(language) { let bytes = match name { CJK_FALLBACK => cjk.clone(), @@ -472,7 +506,7 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str) { /// follows the language, see [`setup_fonts_for`]). #[cfg(not(target_arch = "wasm32"))] pub fn setup_fonts(ctx: &egui::Context) { - setup_fonts_for(ctx, "en"); + setup_fonts_for(ctx, "en", ""); } /// Find a font covering simplified Chinese in the known candidate paths or by scanning the @@ -499,7 +533,7 @@ fn find_system_chinese_font() -> Option> { /// On web, setup_fonts does not perform synchronous disk reads. /// Use asynchronous loading through [`solidcraft_web`] or host scripts. #[cfg(target_arch = "wasm32")] -pub fn setup_fonts_for(_ctx: &egui::Context, _language: &str) {} +pub fn setup_fonts_for(_ctx: &egui::Context, _language: &str, _ui_font: &str) {} #[cfg(target_arch = "wasm32")] pub fn setup_fonts(_ctx: &egui::Context) {} @@ -508,6 +542,24 @@ pub fn setup_fonts(_ctx: &egui::Context) {} mod tests { use super::*; + #[test] + fn the_interface_font_comes_first_and_keeps_the_fallbacks() { + // #1290: the chosen font leads the Proportional family; the default face stays after it. + assert!(ui_font("").is_none()); + assert!(ui_font("No Such Font 1290").is_none()); + let font = ui_font(solidcraft_engine::sketch::DEFAULT_FONT_FAMILY).expect("the built-in sketch font reads"); + let defaults = egui::FontDefinitions::default(); + let first_default = defaults.families[&egui::FontFamily::Proportional][0].clone(); + let fonts = with_ui_font(defaults, font.clone()); + let list = &fonts.families[&egui::FontFamily::Proportional]; + assert_eq!(list[0], UI_FONT); + assert!(list.contains(&first_default), "{list:?}"); + assert!(!fonts.families[&egui::FontFamily::Monospace].contains(&UI_FONT.to_string())); + // Choosing again replaces it rather than adding a second entry. + let again = with_ui_font(fonts, font); + assert_eq!(again.families[&egui::FontFamily::Proportional].iter().filter(|n| *n == UI_FONT).count(), 1); + } + fn latin_font_bytes() -> Vec { let defaults = egui::FontDefinitions::default(); let name = &defaults.families[&egui::FontFamily::Proportional][0]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46106d4d..3e58cd15 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -374,6 +374,8 @@ pub struct SolveApp { /// The language the installed font fallback chain is ordered for (`fonts::fallback_order`); /// changing `preferences.language` re-installs the fonts on the next frame. applied_fonts_language: String, + /// The interface font the fonts were last set up with (`Prefs::ui_font`). + applied_ui_font: String, } impl SolveApp { @@ -456,6 +458,7 @@ impl SolveApp { file_load: file_load::FileLoad::default(), docs: documents::Documents::default(), applied_fonts_language: "en".into(), + applied_ui_font: String::new(), } } @@ -1273,9 +1276,10 @@ impl SolveApp { pub fn ui(&mut self, ui: &mut egui::Ui) { expression_complete::begin_frame(ui.ctx()); let t0 = now_ms(); - if self.preferences.language != self.applied_fonts_language { + if self.preferences.language != self.applied_fonts_language || self.preferences.ui_font != self.applied_ui_font { self.applied_fonts_language.clone_from(&self.preferences.language); - crate::fonts::setup_fonts_for(ui.ctx(), &self.preferences.language); + self.applied_ui_font.clone_from(&self.preferences.ui_font); + crate::fonts::setup_fonts_for(ui.ctx(), &self.preferences.language, &self.preferences.ui_font); } let _language = crate::i18n::scope(&self.preferences.language); let mut report_input = None; diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 9722bc36..b519acad 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -292,6 +292,9 @@ pub struct Prefs { pub language: String, /// Interface scale (1 = 100 %). pub ui_scale: f32, + /// Interface font: an installed font family (or a font file), or empty for the built-in one + /// (#1290). Menus, dialogs and labels use it; glyphs it lacks fall back as before. + pub ui_font: String, /// Put the feature history above the parts browser in the left sidebar instead of along the /// bottom of the viewport. pub timeline_left: bool, @@ -398,6 +401,7 @@ impl Default for Prefs { theme: "dark".into(), language: "en".into(), ui_scale: 1.0, + ui_font: String::new(), timeline_left: false, timeline_left_height: 360.0, timeline_left_browser_collapsed: false, @@ -441,6 +445,8 @@ pub struct PrefsWindow { snapshot: Option, /// The up axis preference the view follows now (applied when it changes, and at startup). applied_up: Option, + /// The search text of the interface font list. + font_filter: String, } /// Open the dialog at a page. @@ -504,6 +510,54 @@ impl Snapshot { } } +/// The interface font (#1290): the built-in font first, then the installed families, with a +/// search box. The choice applies at once, like the other preferences. +fn ui_font_row(app: &mut SolveApp, ui: &mut egui::Ui) { + let builtin = app.tr_label("Built-in").to_string(); + let current = if app.preferences.ui_font.trim().is_empty() { builtin.clone() } else { app.preferences.ui_font.clone() }; + let families = crate::sketch_text::families(ui.ctx()); + let mut chosen: Option = None; + let combo = egui::ComboBox::from_id_salt("pref_ui_font") + .width(220.0) + .height(340.0) + .selected_text(current) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { + let search = egui::TextEdit::singleline(&mut app.prefs_window.font_filter).hint_text(crate::i18n::tr("sketch_text.font_search")); + ui.add(search.desired_width(f32::INFINITY)); + let filter = app.prefs_window.font_filter.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(280.0).auto_shrink([false, false]).show(ui, |ui| { + if builtin.to_lowercase().contains(&filter) && ui.selectable_label(app.preferences.ui_font.trim().is_empty(), &builtin).clicked() { + chosen = Some(String::new()); + } + match &families { + Some(list) => { + for family in list.iter().filter(|f| f.to_lowercase().contains(&filter)) { + let item = ui.selectable_label(app.preferences.ui_font == *family, family); + crate::scenario::publish_handle(&format!("prefs:font:{family}"), item.rect.center()); + if item.clicked() { + chosen = Some(family.clone()); + } + } + } + None => { + ui.label(crate::i18n::tr_label("Listing the installed fonts…")); + } + } + }); + }); + crate::scenario::publish_handle("prefs:ui_font", combo.response.rect.center()); + if let Some(name) = chosen { + if name.is_empty() || crate::fonts::ui_font(&name).is_some() { + app.preferences.ui_font = name; + } else { + app.set_status(format!("The font `{name}` could not be read"), true); + } + app.prefs_window.font_filter.clear(); + ui.close(); + } +} + /// Restore Defaults for one page. fn defaults(app: &mut SolveApp, page: usize) { let d = Prefs::default(); @@ -513,6 +567,7 @@ fn defaults(app: &mut SolveApp, page: usize) { p.theme = d.theme; p.language = d.language; p.ui_scale = d.ui_scale; + p.ui_font = d.ui_font; p.timeline_left = d.timeline_left; p.timeline_left_height = d.timeline_left_height; p.timeline_left_browser_collapsed = d.timeline_left_browser_collapsed; @@ -1004,6 +1059,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ); crate::scenario::publish_rect("prefs:scale", scale.rect); ui.end_row(); + if !cfg!(target_arch = "wasm32") { + ui.label(app.tr_label("Interface font")); + ui_font_row(app, ui); + ui.end_row(); + } ui.label(app.tr("ui.feature_history")); let timeline_bottom = app.tr("ui.timeline_bottom").to_string(); let timeline_left = app.tr("ui.timeline_left_sidebar").to_string(); @@ -1429,6 +1489,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result>().join(", "))); } + if new.ui_font != app.preferences.ui_font && !new.ui_font.trim().is_empty() && crate::fonts::ui_font(&new.ui_font).is_none() { + return Err(format!("`ui_font`: `{}` is not an installed font family or a readable font file", new.ui_font.trim())); + } if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) { return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into()); } @@ -1458,6 +1521,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result Option>> { +pub(crate) fn families(ctx: &egui::Context) -> Option>> { static FAMILIES: OnceLock>> = OnceLock::new(); static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); if let Some(list) = FAMILIES.get() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index be49d189..5c5a6e92 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -132,7 +132,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |