From cf07f10a59a5a274d69e8b8e105668d3e53cca97 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:31:42 -0600 Subject: [PATCH] fix(settings): make the Font row actually change the font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It shipped doing nothing. Measured on the built binary: picking a face persisted the pref and changed ZERO of 600600 pixels in the settings label column. The row was a control that lied. Two measurements say why, and both were run against this Qt build rather than reasoned about: - `ApplicationWindow.font.family` propagates through Qt Quick CONTROLS only. In a standalone scene with a family set on the window, a `Label` resolved to it and a sibling `Text` stayed on the APPLICATION font. This tree is 1082 plain `Text` items against 13 that name a family, so the app's typeface IS the application font. - The application font is read when an item is CONSTRUCTED. Driving PySide6 against the same Qt, `QGuiApplication::setFont` updated `QGuiApplication::font()` and left an already-built `Text` on its old family. Together those close the door on changing it live, so the ordering in main() is the feature: FontPreload.qml registers the bundled faces (a family does not exist until its file is loaded, and `load()` returns with its FontLoaders already in the database), the application font is set from the persisted choice, and only then does Main.qml build the UI. Swap any two of those and the setting silently does nothing again. The row now says "(requires restart)", the same wording Interface size two rows above already uses, in all eight catalogues. "System" resolves to "" and takes no branch, so anyone who never opens the row gets exactly the font this app rendered before — verified by capturing the window on both settings: 0 log lines and an untouched frame for System, 27065 changed pixels for Montserrat. NOT ADDRESSED, and worth its own decision: those 1082 Text items have never used the bundled Inter either. `Main.qml` has always named it, but that only ever reached the Controls, so the app renders in whatever Qt picks. Making "System" mean Inter is a one-line change and a visible one, so it is left alone here. cc --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/FontPreload.qml | 43 +++++++++++++++++++ crates/qbz-qt/qml/Main.qml | 38 +++++----------- .../qml/settings/AppearanceSettings.qml | 2 +- crates/qbz-qt/src/main.rs | 37 ++++++++++++++++ crates/qbz-qt/src/settings_qt.rs | 43 ++++++++++++++++--- crates/qbz-qt/src/shell_bridge.rs | 25 ++++++----- 7 files changed, 143 insertions(+), 46 deletions(-) create mode 100644 crates/qbz-qt/qml/FontPreload.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 24e7a0596..dc33562d7 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -395,6 +395,7 @@ fn main() { qml_files: &[ "qml/LoginScreen.qml", "qml/Main.qml", + "qml/FontPreload.qml", "qml/cards/AlbumCard.qml", "qml/cards/ArtistCard.qml", "qml/cards/CollectionMosaic.qml", diff --git a/crates/qbz-qt/qml/FontPreload.qml b/crates/qbz-qt/qml/FontPreload.qml new file mode 100644 index 000000000..e207ca0ee --- /dev/null +++ b/crates/qbz-qt/qml/FontPreload.qml @@ -0,0 +1,43 @@ +// FontPreload — registers the bundled typefaces BEFORE any Text exists. +// +// WHY THIS FILE IS A SEPARATE DOCUMENT, loaded before Main.qml. +// +// A plain `Text` does NOT inherit a font from its parents, and it does NOT +// follow `ApplicationWindow.font` — that property propagates through Qt Quick +// CONTROLS only. Proven on this Qt build with a standalone scene: with a +// family set on the ApplicationWindow, a `Label` resolved to it and a `Text` +// sibling stayed on the APPLICATION font. This tree has 1082 plain `Text` +// items and 13 that name a family, so "the app's typeface" IS the application +// font, and nothing else. +// +// And the application font is only read when an item is CONSTRUCTED. Also +// proven, with PySide6 against this same Qt: calling QGuiApplication::setFont +// at runtime updated `QGuiApplication::font()` and left an already-built +// `Text` on its old family. So the font has to be set before the UI is built, +// which means the families have to be REGISTERED before that — and a family +// is only registered once something loads its file. +// +// Hence this document: `QQmlApplicationEngine::load` returns with its +// FontLoaders already in the font database (verified: the family is present +// the instant load() returns), so main.rs can set the application font in the +// gap between this document and Main.qml. +// +// It is deliberately NON-VISUAL — a QtObject, not a Window — so loading it +// adds a root object and nothing else. +// +// The five faces are settings_qt::APP_FONT_VALUES, in that order. Inter is +// here too: it is the app's own face, named by Main.qml for the Controls, and +// it needs registering for exactly the same reason. + +import QtQuick + +QtObject { + readonly property FontLoader inter: FontLoader { source: "assets/fonts/Inter_18pt-Regular.ttf" } + readonly property FontLoader interMedium: FontLoader { source: "assets/fonts/Inter_18pt-Medium.ttf" } + readonly property FontLoader interSemiBold: FontLoader { source: "assets/fonts/Inter_18pt-SemiBold.ttf" } + readonly property FontLoader interBold: FontLoader { source: "assets/fonts/Inter_18pt-Bold.ttf" } + readonly property FontLoader lineSeed: FontLoader { source: "assets/fonts/LINESeedJP-Regular.ttf" } + readonly property FontLoader montserrat: FontLoader { source: "assets/fonts/Montserrat-VariableFont_wght.ttf" } + readonly property FontLoader notoSans: FontLoader { source: "assets/fonts/NotoSans-VariableFont_wdth,wght.ttf" } + readonly property FontLoader sourceSans3: FontLoader { source: "assets/fonts/SourceSans3-VariableFont_wght.ttf" } +} diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index 4b1d3575f..45873be6e 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -179,36 +179,18 @@ ApplicationWindow { // own loader into every row it renders in. FontLoader { id: lineSeed; source: "assets/fonts/LINESeedJP-Regular.ttf" } - // ── Settings > Appearance > Typography & Language > Font ─────────────── - // The app-wide typeface. Every Text in the tree that does not name a - // family of its own inherits THIS one, which is why the setting lives on - // the ApplicationWindow and applies live with no restart. + // Settings > Appearance > Typography & Language > Font — the CONTROLS half. // - // The catalog is settings_qt::APP_FONT_VALUES and the index rides - // `QbzShell.appFontIndex` — a property of its own rather than a read out - // of settingsJson, so the root window does not parse the whole settings - // document to find one integer. + // The app's own text does not come from here: a plain `Text` takes the + // APPLICATION font at construction, which main() sets before this document + // is even loaded (see qml/FontPreload.qml for the measurements). What this + // line still owns is Qt Quick Controls, which follow ApplicationWindow.font + // and would otherwise sit on Inter while every label around them moved. // - // LAZY, exactly like the lyrics panel's own picker - // (shell/LyricsLinesView.qml:66-80): a family nobody selected is never - // loaded, and a family that has not FINISHED loading falls back to Inter - // rather than rendering the app in a wrong face for a frame. - FontLoader { id: appMontserrat; source: QbzShell.appFontIndex === 2 ? "assets/fonts/Montserrat-VariableFont_wght.ttf" : "" } - FontLoader { id: appNoto; source: QbzShell.appFontIndex === 3 ? "assets/fonts/NotoSans-VariableFont_wdth,wght.ttf" : "" } - FontLoader { id: appSource; source: QbzShell.appFontIndex === 4 ? "assets/fonts/SourceSans3-VariableFont_wght.ttf" : "" } - readonly property string appFontFamily: { - if (QbzShell.appFontIndex === 1) - return lineSeed.status === FontLoader.Ready ? lineSeed.name : "" - if (QbzShell.appFontIndex === 2) - return appMontserrat.status === FontLoader.Ready ? appMontserrat.name : "" - if (QbzShell.appFontIndex === 3) - return appNoto.status === FontLoader.Ready ? appNoto.name : "" - if (QbzShell.appFontIndex === 4) - return appSource.status === FontLoader.Ready ? appSource.name : "" - return "" - } - font.family: window.appFontFamily !== "" - ? window.appFontFamily + // "" = System, i.e. leave Qt's own choice alone, which is what this app + // rendered before the setting existed. + font.family: QbzShell.appFontFamily !== "" + ? QbzShell.appFontFamily : (interRegular.status === FontLoader.Ready ? interRegular.name : "Sans Serif") // Phase 23: every domain singleton boots (registers its Qt-thread diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index 34abdcd25..20a1b2eb2 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -246,7 +246,7 @@ Column { // means the window default, which is Inter (Main.qml). SettingRow { label: QbzSession.tr("Font", QbzSession.trRev) - description: QbzSession.tr("The typeface used across the app. Only fonts shipped with QBZ are offered, so the choice looks the same on every machine.", QbzSession.trRev) + description: QbzSession.tr("The typeface used across the app. Only fonts shipped with QBZ are offered, so the choice looks the same on every machine (requires restart)", QbzSession.trRev) QbzSelect { menuWidth: 200 options: root.doc.appFonts || [] diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 1706d7159..a50d9b983 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -3050,6 +3050,43 @@ fn main() { let mut app = QGuiApplication::new(); let mut engine = QQmlApplicationEngine::new(); + // ── THE APP TYPEFACE, AND WHY IT IS SET RIGHT HERE ──────────────────── + // + // Settings > Appearance > Typography & Language > Font. This is the only + // place it CAN be applied, and the ordering below is the whole feature: + // + // 1. FontPreload.qml registers the bundled faces. A family does not + // exist for Qt until something loads its file, and `load()` returns + // with its FontLoaders already in the font database. + // 2. The application font is set from the persisted choice. A plain + // `Text` reads the application font when it is CONSTRUCTED — it does + // not follow ApplicationWindow.font (Controls only), and it ignores + // a later QGuiApplication::setFont. Both measured against this Qt + // build; the evidence is written up in qml/FontPreload.qml. + // 3. Main.qml builds the UI, and every one of its 1082 Text items is + // born with the chosen face. + // + // Swap any two of those steps and the setting silently does nothing, + // which is exactly how it shipped the first time. + // + // "System" resolves to "" and takes no branch at all, so a user who never + // opens the row gets precisely the font this app rendered before the + // setting existed. + if let Some(engine) = engine.as_mut() { + engine.load(&QUrl::from( + "qrc:/qt/qml/com/blitzfc/qbz/qml/FontPreload.qml", + )); + } + let app_font_family = settings_qt::app_font_family(); + if !app_font_family.is_empty() { + if let Some(app) = app.as_mut() { + let mut font = cxx_qt_lib::QFont::default(); + font.set_family(&QString::from(app_font_family.as_str())); + app.set_application_font(&font); + log::info!("[qbz-qt] app font -> {app_font_family}"); + } + } + if let Some(engine) = engine.as_mut() { engine.load(&QUrl::from( "qrc:/qt/qml/com/blitzfc/qbz/qml/Main.qml", diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 2c77927ae..04263a202 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1326,11 +1326,34 @@ const APP_FONT_VALUES: &[&str] = &[ "source-sans-3", ]; +/// The family names the slugs resolve to, as the font files register them. +/// Index-aligned with [`APP_FONT_VALUES`]; "system" maps to the empty string. +const APP_FONT_FAMILIES: &[&str] = &[ + "", + "LINE Seed JP", + "Montserrat", + "Noto Sans", + "Source Sans 3", +]; + /// The persisted app-font choice as an index into [`APP_FONT_VALUES`]. -/// `pub` because shell_bridge seeds `app_font_index` from it at boot. pub fn app_font_index() -> i32 { index_of(APP_FONT_VALUES, &pref_str("app_font", "system"), 0) } + +/// The family the persisted choice resolves to, or "" for "System". +/// +/// "System" means the font Qt would pick on its own, i.e. exactly what this +/// app rendered before the setting existed. It deliberately does NOT mean +/// Inter: the bundled Inter has only ever reached Qt Quick CONTROLS (see +/// qml/FontPreload.qml), so making "System" mean Inter would change every +/// label in the app for people who never touched the setting. +pub fn app_font_family() -> String { + APP_FONT_FAMILIES + .get(app_font_index() as usize) + .unwrap_or(&"") + .to_string() +} const IMMERSIVE_SEARCH_LABELS: &[&str] = &["Disabled", "Replace current queue", "Play next", "Add to queue"]; const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"]; @@ -2624,12 +2647,18 @@ pub async fn settings_select(runtime: &Arc>, key: &st return; }; save_pref("app_font", serde_json::json!(font)); - // LIVE, no restart: Main.qml rebinds the ApplicationWindow's - // `font.family` off this property and every unstyled Text in the - // tree inherits it. - let idx = index as i32; - crate::shell_bridge::ui(move |mut b| b.as_mut().set_app_font_index(idx)); - log::info!("[qbz-qt] app_font -> {font}"); + // APPLIED AT THE NEXT START, and that is not a shortcut. + // + // The app's text is 1082 plain `Text` items, and a plain Text + // takes the APPLICATION font at CONSTRUCTION — it does not follow + // ApplicationWindow.font (Controls only) and it does not react to + // QGuiApplication::setFont afterwards. Both were measured against + // this Qt build before this arm was written; the notes are in + // qml/FontPreload.qml. So a running app cannot be re-faced without + // rebuilding every item, and the honest thing is to persist the + // choice and let main() apply it on the next start, exactly like + // the Interface size row two lines above. + log::info!("[qbz-qt] app_font -> {font} (applies at next start)"); } "ui-scale" => { let Some(scale) = UI_SCALE_VALUES.get(index) else { diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index e47a5782d..8fb9e0a43 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -108,15 +108,18 @@ pub mod qbz_shell { #[qproperty(QString, theme_list_json)] // Dropdown filter: 0 All / 1 Dark / 2 Light (ui_prefs theme_filter). #[qproperty(i32, theme_filter)] - // Settings > Appearance > Typography & Language > Font: an index into - // settings_qt::APP_FONT_VALUES (0 = System = the window default). + // Settings > Appearance > Typography & Language > Font, resolved to a + // family name ("" = System, i.e. leave Qt's own choice alone). // - // It rides its OWN property rather than the settings document because - // the consumer is Main.qml — the ApplicationWindow whose `font.family` - // every unstyled Text inherits — and making the root window parse the - // whole settingsJson on every republish to read one integer is the - // wrong trade. Same shape as `theme_filter` above. - #[qproperty(i32, app_font_index)] + // The APP's text does not come from here — a plain Text takes the + // application font at construction, which main() sets before the UI + // exists (see qml/FontPreload.qml). This property exists so the Qt + // Quick CONTROLS, which DO follow ApplicationWindow.font, land on the + // same face instead of staying on Inter while everything else moved. + // + // Read once at startup and never written again: the choice cannot be + // applied to a running UI, so a notify would be a lie. + #[qproperty(QString, app_font_family)] // The custom-theme EDITOR state (custom_theme_qt.rs): // {"isDark":bool,"tokens":{"":"#aarrggbb"}} — the eleven // editable base tokens plus the polarity, mirroring the Slint @@ -758,7 +761,7 @@ pub struct QbzShellRust { theme_slug: QString, theme_list_json: QString, theme_filter: i32, - app_font_index: i32, + app_font_family: QString, custom_theme_json: QString, diagnostics_json: QString, log_viewer_json: QString, @@ -851,7 +854,9 @@ impl Default for QbzShellRust { theme_slug: QString::from(crate::theme_qt::current_slug().as_str()), theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()), theme_filter: crate::theme_qt::theme_filter(), - app_font_index: crate::settings_qt::app_font_index(), + app_font_family: QString::from( + crate::settings_qt::app_font_family().as_str(), + ), custom_theme_json: QString::from(crate::custom_theme_qt::state_json().as_str()), diagnostics_json: QString::from( crate::diagnostics_qt::empty_doc_json().as_str(),