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(),