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 <[email protected]> Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
5 files changed
+148
-9
No files matched your search
@@ -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<egui::FontData> {
|
||||
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<Vec<u8>> {
|
||||
/// 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<u8> {
|
||||
let defaults = egui::FontDefinitions::default();
|
||||
let name = &defaults.families[&egui::FontFamily::Proportional][0];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -538,7 +538,7 @@ fn path_row(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut
|
||||
}
|
||||
|
||||
/// The installed font families, listed once in the background (none on the web).
|
||||
fn families(ctx: &egui::Context) -> Option<Arc<Vec<String>>> {
|
||||
pub(crate) fn families(ctx: &egui::Context) -> Option<Arc<Vec<String>>> {
|
||||
static FAMILIES: OnceLock<Arc<Vec<String>>> = OnceLock::new();
|
||||
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
if let Some(list) = FAMILIES.get() {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user