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 <DuBearte@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 18:40:07 -04:00
1 parent 6128a20c5f
commit b44ada89e5
5 files changed
+148 -9

No files matched your search

+83
View File
@@ -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<Snapshot>,
/// The up axis preference the view follows now (applied when it changes, and at startup).
applied_up: Option<String>,
/// 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<String> = 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<serde_json::Valu
if !crate::i18n::is_supported(&new.language) {
return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::<Vec<_>>().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<serde_json::Valu
#[cfg(test)]
mod tests {
/// The interface font (#1290): a font that can be read, or empty for the built-in one; saved
/// with the others and reset by Restore Defaults.
#[test]
fn ui_font_is_checked_saved_and_restored() {
use serde_json::json;
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
assert_eq!(app.preferences.ui_font, "");
let family = solidcraft_engine::sketch::DEFAULT_FONT_FAMILY;
let out = super::set(&mut app, &json!({ "ui_font": family })).unwrap();
assert_eq!(out["ui_font"], family);
let e = super::set(&mut app, &json!({"ui_font": "No Such Font 1290"})).unwrap_err();
assert!(e.contains("ui_font"), "{e}");
assert_eq!(app.preferences.ui_font, family, "a refused font leaves the choice as it was");
let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.ui_font, family);
super::defaults(&mut app, 0);
assert_eq!(app.preferences.ui_font, "");
}
#[test]
fn number_fields_take_a_decimal_point_or_a_decimal_comma() {
// #1347