diff --git a/Cargo.toml b/Cargo.toml index 025b6375b..9eeec3b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,10 +92,10 @@ naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] libloading = "0.8" -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps", "Win32_Globalization", "Win32_Security_Authentication_Identity", "Win32_UI_Controls_Dialogs"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps", "Win32_Globalization", "Win32_Security_Authentication_Identity", "Win32_UI_Controls_Dialogs"] } [target.'cfg(target_os = "linux")'.dependencies] -ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } +ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "settings", "wayland"] } # For `src/bin/ocs_launcher.rs` only (the .app's real CFBundleExecutable, # see that file for why) — already in the tree transitively via winit, pinned @@ -104,7 +104,7 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", objc2 = "0.5" # The `NSEvent`/`block2` features are for the trackpad pinch monitor in # `src/input/trackpad.rs` — see that file for why the gesture is read there. -objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "NSRunningApplication", "NSEvent", "block2"] } +objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSFont", "NSResponder", "NSRunningApplication", "NSEvent", "block2"] } block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 043888a51..691b6dcaa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2476,6 +2476,10 @@ pub enum Message { ApplyWebFont(crate::scene::text::web_font::Script), /// Completion of the UI renderer's runtime font registration. WebUiFontLoaded(crate::scene::text::web_font::Script, Result<(), String>), + /// The desktop reported (or a poll may have found) a UI-font change. + UiFontSettingsChanged, + /// A re-read of the desktop UI font finished. + UiFontDetected(Option), /// Ctrl+V. Routed by `update` into an open text editor, the drawing-object /// clipboard, or the system text clipboard. PasteShortcut, @@ -5577,6 +5581,9 @@ use std::path::PathBuf; #[cfg(not(target_arch = "wasm32"))] pub fn run() -> iced::Result { + // UI text follows the desktop's interface font: generic sans-serif, iced's + // default font, is remapped to it before the first window opens. + crate::sys::ui_font::apply(crate::sys::ui_font::detect().as_ref()); iced::daemon( OpenCADStudio::boot, OpenCADStudio::update, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 07935a80e..58d5ef0c7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1052,6 +1052,27 @@ impl OpenCADStudio { Task::none() } + #[cfg(not(target_arch = "wasm32"))] + Message::UiFontSettingsChanged => Task::perform( + async { crate::sys::ui_font::detect() }, + Message::UiFontDetected, + ), + #[cfg(target_arch = "wasm32")] + Message::UiFontSettingsChanged => Task::none(), + + // Re-setting the defaults recreates the renderers and lays out + // every window again with the remapped sans-serif family. + #[cfg(not(target_arch = "wasm32"))] + Message::UiFontDetected(system) => { + if crate::sys::ui_font::apply(system.as_ref()) { + iced::font::set_defaults(iced::Font::DEFAULT, 16.0) + } else { + Task::none() + } + } + #[cfg(target_arch = "wasm32")] + Message::UiFontDetected(_) => Task::none(), + Message::Tick(t) => self.on_tick(t), Message::OpenFile => self.on_open_file(), diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b4ddd00a2..37f166fe2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -146,6 +146,22 @@ pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bo const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; +/// Desktop UI-font changes while running. Linux listens for the Settings +/// portal's `SettingChanged` (no portal: restart to apply). Windows exposes no +/// change notification through iced, but the metrics read is a cheap system +/// call, so it is polled. macOS has no user-adjustable UI font, and the web +/// build follows the font it loads for the UI language. +fn ui_font_changes() -> Subscription { + #[cfg(target_os = "linux")] + return Subscription::run(crate::sys::ui_font::change_signals) + .map(|()| Message::UiFontSettingsChanged); + #[cfg(target_os = "windows")] + return iced::time::every(std::time::Duration::from_secs(2)) + .map(|_| Message::UiFontSettingsChanged); + #[allow(unreachable_code)] + Subscription::none() +} + /// Background used by drafting overlays in model or paper space. fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] { if !is_paper { @@ -3053,6 +3069,7 @@ impl OpenCADStudio { single_instance, hatch_pattern_keys, keyboard_events, + ui_font_changes(), ]) } diff --git a/src/sys.rs b/src/sys.rs index 22d047f4f..85ab0916f 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -1,6 +1,8 @@ // Small platform shims for things the desktop build does natively but the web // (wasm) build must handle differently or skip. +pub mod ui_font; + /// Drawing hyperlinks may open web pages, never local files or custom handlers. pub(crate) fn web_hyperlink(value: &str) -> Option { if value.chars().any(char::is_control) { diff --git a/src/sys/ui_font.rs b/src/sys/ui_font.rs new file mode 100644 index 000000000..b374eb41e --- /dev/null +++ b/src/sys/ui_font.rs @@ -0,0 +1,594 @@ +//! Discovery of the desktop's configured interface (UI) font. +//! +//! Platform settings are read and normalised into a [`SystemUiFont`]; +//! [`apply`] then makes its family what generic sans-serif means in iced's +//! font database, so every widget and canvas text drawn with `Font::DEFAULT` +//! (including its bold and italic variants) follows it without call-site +//! changes. +//! +//! Sizes are reported in logical pixels (device-independent pixels at +//! 96 DPI). Display scaling is left to the window system — winit already +//! folds it into the window scale factor — so it must never be applied here +//! as well. +//! +//! | Platform | Source | +//! |----------|---------------------------------------------------------------| +//! | Linux | GNOME-family gsettings, KDE `kdeglobals`, Xfce xfconf, the XDG | +//! | | Settings portal, GTK `settings.ini`, then a Fontconfig match | +//! | Windows | `SystemParametersInfoForDpi(SPI_GETNONCLIENTMETRICS)` at 96 DPI | +//! | macOS | `NSFont.systemFontOfSize:` / `+systemFontSize` | +//! +//! Detection is best-effort: every failure path returns `None` (or a +//! lower-confidence [`Source`]) and the caller keeps iced's default font. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +/// Where a detected font came from. Used for logging and to tell a real +/// desktop setting apart from a generic fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// `org.gnome.desktop.interface` (also Unity, Budgie, Pantheon) via gsettings. + GnomeSettings, + /// `org.cinnamon.desktop.interface` via gsettings. + CinnamonSettings, + /// `org.mate.interface` via gsettings. + MateSettings, + /// Xfce's xsettings channel via xfconf-query. + XfceSettings, + /// KDE Plasma's `kdeglobals` `[General] font=` entry. + KdeGlobals, + /// Plasma's built-in default when `kdeglobals` names no font. + KdeDefault, + /// `org.freedesktop.portal.Settings` (works inside Flatpak/Snap sandboxes). + Portal, + /// `gtk-font-name` from a GTK 3/4 `settings.ini`. + GtkSettingsIni, + /// Fontconfig's `sans-serif` match. Not proof of a desktop choice. + Fontconfig, + /// Windows' message-box font from the non-client metrics. + WindowsNonClientMetrics, + /// AppKit's system font. + MacSystemFont, +} + +impl Source { + /// Whether the font reflects an explicit desktop UI-font setting rather + /// than a generic family resolution. + pub fn is_desktop_setting(self) -> bool { + !matches!(self, Source::Fontconfig) + } +} + +/// A font size as written in a platform setting. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FontSize { + /// Typographic points (1/72 in). + Points(f32), + /// Logical pixels. + Pixels(f32), +} + +impl FontSize { + /// Logical pixels at the 96 DPI reference resolution, which is what + /// iced's text sizes are expressed in before the window scale factor. + pub fn to_logical_px(self) -> f32 { + match self { + FontSize::Points(pt) => points_to_px(pt), + FontSize::Pixels(px) => px, + } + } +} + +/// Convert typographic points to logical pixels (96 px per inch, 72 pt per +/// inch). Desktops that express the UI font in points (GNOME, KDE, Windows' +/// "9 pt Segoe UI") all assume this reference resolution; the display's +/// actual scaling is applied separately by the window system. +pub fn points_to_px(pt: f32) -> f32 { + pt * 96.0 / 72.0 +} + +/// Convert a Win32 `LOGFONTW::lfHeight` measured at `dpi` to logical pixels. +/// A negative height is the em (character) height — the convention every +/// system font uses; a positive one is the cell height including internal +/// leading, which is taken as an approximate em. Zero means "default size". +pub fn logfont_height_to_px(lf_height: i32, dpi: u32) -> Option { + if lf_height == 0 || dpi == 0 { + return None; + } + Some(lf_height.unsigned_abs() as f32 * 96.0 / dpi as f32) +} + +/// The interface font a desktop is configured to use. +#[derive(Debug, Clone, PartialEq)] +pub struct SystemUiFont { + /// Family name to request first. `None` when the desktop names a generic + /// alias that could not be resolved to a concrete family. + pub family: Option, + /// Further family names to try, in order, if `family` is not installed + /// (macOS reports a private family name that font databases expose + /// under a different one). + pub alternates: Vec, + /// CSS-style weight, 100–900 (400 regular, 700 bold). + pub weight: u16, + /// Whether the UI font itself is italic. + pub italic: bool, + /// Configured size in logical pixels, if the platform reports one. + pub size_px: Option, + /// Where the setting was read from. + pub source: Source, +} + +impl SystemUiFont { + #[cfg(target_os = "linux")] + fn from_parsed(parsed: ParsedFont, source: Source) -> Self { + Self { + family: parsed.family, + alternates: Vec::new(), + weight: parsed.weight, + italic: parsed.italic, + size_px: parsed.size.map(FontSize::to_logical_px), + source, + } + } +} + +/// Read the desktop's UI font. Returns `None` when nothing usable could be +/// read; never panics and never blocks for long (subprocess and D-Bus reads +/// are bounded by short timeouts). +pub fn detect() -> Option { + #[cfg(target_os = "linux")] + return linux::detect(); + #[cfg(target_os = "windows")] + return windows::detect(); + #[cfg(target_os = "macos")] + return macos::detect(); + #[allow(unreachable_code)] + None +} + +/// The family [`apply`] last mapped generic sans-serif to, and the one iced +/// chose itself (restored when the desktop font is unusable). +#[cfg(not(target_arch = "wasm32"))] +struct Applied { + iced_default: String, + current: String, +} + +#[cfg(not(target_arch = "wasm32"))] +static APPLIED: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Bumped each time [`apply`] changes the UI font. +static GENERATION: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Counter that changes whenever the UI font does, for caches of measured +/// UI text. +pub fn generation() -> u32 { + GENERATION.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Make `system`'s family the generic sans-serif family in iced's font +/// database. iced's `Font::DEFAULT` is `Family::SansSerif`, so all UI text +/// picks it up. The family must be installed; otherwise (or with no desktop +/// font) iced's own sans-serif choice is kept or restored, so text never ends +/// up in per-glyph fallback faces. The font's weight and style are ignored: +/// regular UI text stays regular. Returns whether the mapping changed. +#[cfg(not(target_arch = "wasm32"))] +pub fn apply(system: Option<&SystemUiFont>) -> bool { + use iced::advanced::graphics::text::{cosmic_text::fontdb::Family, font_system}; + + let mut font_system = font_system() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut applied = APPLIED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let at_startup = applied.is_none(); + let db = font_system.raw().db(); + let applied = applied.get_or_insert_with(|| { + let name = db.family_name(&Family::SansSerif).to_string(); + Applied { + iced_default: name.clone(), + current: name, + } + }); + + // Family lookups are exact, so take the database's own spelling. + let installed = system + .into_iter() + .flat_map(|font| font.family.iter().chain(&font.alternates)) + .filter(|name| !is_generic_alias(name)) + .find_map(|wanted| { + db.faces() + .flat_map(|face| &face.families) + .find(|(name, _)| name.eq_ignore_ascii_case(wanted)) + .map(|(name, _)| name.clone()) + }); + if installed.is_none() { + if let Some(family) = system.and_then(|font| font.family.as_deref()) { + log::info!("System UI font \"{family}\" is not installed; using iced's default"); + } + } + let family = installed.unwrap_or_else(|| applied.iced_default.clone()); + if family == applied.current { + return false; + } + + // `db_mut` also clears cosmic-text's font-match cache. + font_system + .raw() + .db_mut() + .set_sans_serif_family(family.clone()); + if !at_startup { + // Laid-out paragraphs only reshape when the font system's version + // changes, and loading a font is the only way to bump it. The empty + // source adds no face (fontdb logs a warning for it). + font_system.load_font(std::borrow::Cow::Owned(Vec::new())); + } + applied.current = family; + GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + true +} + +/// Yields each time the desktop reports a changed UI-font setting. Linux +/// only; the stream is empty when no Settings portal is reachable, in which +/// case new settings apply after a restart. +#[cfg(target_os = "linux")] +pub fn change_signals() -> impl iced::futures::Stream + Send + 'static { + linux::change_signals() +} + +/// A platform font description reduced to the parts the UI uses. +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedFont { + pub family: Option, + pub weight: u16, + pub italic: bool, + pub size: Option, +} + +/// Generic family aliases that name no concrete font. Fontconfig resolves +/// them (on Linux) to the configured default sans face. +pub fn is_generic_alias(family: &str) -> bool { + matches!( + family.trim().to_ascii_lowercase().as_str(), + "sans" | "sans-serif" | "sans serif" | "system-ui" | "ui-sans-serif" | "default" + ) +} + +/// Parse a Pango font description such as `"Cantarell 11"`, +/// `"Noto Sans Semi-Bold Italic 10"` or `"Inter, Sans 14px"` — the format of +/// GNOME's `font-name`, Xfce's `/Gtk/FontName` and GTK's `gtk-font-name`. +pub fn parse_pango(desc: &str) -> Option { + let mut words: Vec<&str> = desc.split_whitespace().collect(); + let mut size = None; + if let Some(last) = words.last() { + if let Some(px) = last.strip_suffix("px") { + if let Ok(v) = px.parse::() { + size = Some(FontSize::Pixels(v)); + words.pop(); + } + } else if let Ok(v) = last.parse::() { + size = Some(FontSize::Points(v)); + words.pop(); + } + } + let mut weight = 400; + let mut italic = false; + // Style words trail the family; consume them from the end so a family + // that merely contains one ("Bold Sans Display") keeps its leading words. + while words.len() > 1 { + let word = words.last().unwrap().to_ascii_lowercase(); + if let Some(w) = pango_weight(&word) { + weight = w; + } else if word == "italic" || word == "oblique" { + italic = true; + } else if !is_pango_modifier(&word) { + break; + } + words.pop(); + } + let family_list = words.join(" "); + let family = family_list + .split(',') + .map(str::trim) + .find(|f| !f.is_empty()) + .map(str::to_string); + if family.is_none() && size.is_none() { + return None; + } + Some(ParsedFont { + family, + weight, + italic, + size: size.filter(|s| s.to_logical_px() > 0.0), + }) +} + +fn pango_weight(word: &str) -> Option { + Some(match word { + "thin" | "hairline" => 100, + "ultra-light" | "ultralight" | "extra-light" | "extralight" => 200, + "light" => 300, + "semi-light" | "semilight" | "demi-light" | "demilight" => 350, + "book" => 380, + "regular" | "normal" => 400, + "medium" => 500, + "semi-bold" | "semibold" | "demi-bold" | "demibold" => 600, + "bold" => 700, + "ultra-bold" | "ultrabold" | "extra-bold" | "extrabold" => 800, + "heavy" | "black" => 900, + "ultra-heavy" | "ultraheavy" | "extra-black" => 1000, + _ => return None, + }) +} + +/// Pango stretch, variant and gravity words — consumed but not used. +fn is_pango_modifier(word: &str) -> bool { + matches!( + word, + "ultra-condensed" + | "extra-condensed" + | "condensed" + | "semi-condensed" + | "semi-expanded" + | "expanded" + | "extra-expanded" + | "ultra-expanded" + | "small-caps" + | "all-small-caps" + | "petite-caps" + | "all-petite-caps" + | "unicase" + | "title-caps" + | "not-rotated" + | "south" + | "upside-down" + | "north" + | "rotated-left" + | "east" + | "rotated-right" + | "west" + | "roman" + ) +} + +/// Parse a `QFont::toString()` value — KDE's `kdeglobals` `font=` entry and +/// the KDE portal's value. Qt 5 writes 10–11 fields with weights on a 0–99 +/// scale; Qt 6 writes 16–17 fields with CSS-style 1–1000 weights: +/// +/// `family,pointSizeF,pixelSize,styleHint,weight,style,underline,strikeOut,fixedPitch,…` +pub fn parse_qfont(desc: &str) -> Option { + let fields: Vec<&str> = desc.trim().trim_matches('"').split(',').collect(); + if fields.len() < 6 { + return None; + } + let family = Some(fields[0].trim().to_string()).filter(|f| !f.is_empty()); + let point_size = fields[1].trim().parse::().ok().filter(|v| *v > 0.0); + let pixel_size = fields[2].trim().parse::().ok().filter(|v| *v > 0.0); + let size = point_size + .map(FontSize::Points) + .or(pixel_size.map(FontSize::Pixels)); + let raw_weight = fields[4].trim().parse::().ok(); + let qt6 = fields.len() >= 16; + let weight = match raw_weight { + Some(w) if qt6 => w.clamp(100, 1000), + Some(w) => qt5_weight_to_css(w), + None => 400, + }; + // Field 5 is QFont::Style: 0 normal, 1 italic, 2 oblique. + let italic = matches!(fields[5].trim(), "1" | "2"); + family.as_ref()?; + Some(ParsedFont { + family, + weight, + italic, + size, + }) +} + +/// Qt 5's `QFont::Weight` (0–99) to the CSS scale, using Qt's own table. +fn qt5_weight_to_css(w: u16) -> u16 { + const TABLE: [(u16, u16); 9] = [ + (0, 100), + (12, 200), + (25, 300), + (50, 400), + (57, 500), + (63, 600), + (75, 700), + (81, 800), + (87, 900), + ]; + TABLE + .iter() + .min_by_key(|(qt, _)| qt.abs_diff(w)) + .map(|&(_, css)| css) + .unwrap_or(400) +} + +/// Value of `key` inside `[group]` of an INI/KConfig style file. KConfig +/// keys may carry a locale suffix (`font[de]`) or `[$e]`-style flags; only +/// the plain key is read. +pub fn ini_value(text: &str, group: &str, key: &str) -> Option { + let mut in_group = false; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_group = line.trim_start_matches('[').trim_end_matches(']') == group; + continue; + } + if !in_group || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + let k = k.trim(); + let k = k.strip_suffix("[$e]").unwrap_or(k).trim(); + if k == key { + let v = v.trim(); + let v = v + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(v); + return Some(v.to_string()); + } + } + None +} + +/// Unquote a GVariant string printed by `gsettings get` (`'Cantarell 11'`). +pub fn parse_gvariant_string(out: &str) -> Option { + let s = out.trim(); + let s = s.strip_prefix("@s ").unwrap_or(s); + let inner = s + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| s.strip_prefix('"').and_then(|s| s.strip_suffix('"')))?; + Some(inner.replace("\\'", "'").replace("\\\\", "\\")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pango_plain_family_and_points() { + let f = parse_pango("Cantarell 11").unwrap(); + assert_eq!(f.family.as_deref(), Some("Cantarell")); + assert_eq!(f.size, Some(FontSize::Points(11.0))); + assert_eq!(f.weight, 400); + assert!(!f.italic); + } + + #[test] + fn pango_style_words_and_multiword_family() { + let f = parse_pango("Noto Sans Semi-Bold Italic 10.5").unwrap(); + assert_eq!(f.family.as_deref(), Some("Noto Sans")); + assert_eq!(f.weight, 600); + assert!(f.italic); + assert_eq!(f.size, Some(FontSize::Points(10.5))); + } + + #[test] + fn pango_pixel_size_and_family_list() { + let f = parse_pango("Inter, Cantarell, Sans 14px").unwrap(); + assert_eq!(f.family.as_deref(), Some("Inter")); + assert_eq!(f.size, Some(FontSize::Pixels(14.0))); + } + + #[test] + fn pango_family_only_and_keyword_only_family() { + let f = parse_pango("Ubuntu").unwrap(); + assert_eq!(f.family.as_deref(), Some("Ubuntu")); + assert_eq!(f.size, None); + // A lone style word is the family, not a modifier. + let f = parse_pango("Bold 9").unwrap(); + assert_eq!(f.family.as_deref(), Some("Bold")); + assert_eq!(f.weight, 400); + } + + #[test] + fn pango_rejects_empty() { + assert!(parse_pango("").is_none()); + assert!(parse_pango(" ").is_none()); + } + + #[test] + fn qfont_qt5_format() { + let f = parse_qfont("Noto Sans,10,-1,5,50,0,0,0,0,0").unwrap(); + assert_eq!(f.family.as_deref(), Some("Noto Sans")); + assert_eq!(f.size, Some(FontSize::Points(10.0))); + assert_eq!(f.weight, 400); + assert!(!f.italic); + let f = parse_qfont("Hack,9,-1,5,75,1,0,0,0,0,Bold Italic").unwrap(); + assert_eq!(f.weight, 700); + assert!(f.italic); + } + + #[test] + fn qfont_qt6_format_and_pixel_size() { + let f = parse_qfont("Inter,10.5,-1,5,500,0,0,0,0,0,0,0,0,0,0,1").unwrap(); + assert_eq!(f.family.as_deref(), Some("Inter")); + assert_eq!(f.weight, 500); + assert_eq!(f.size, Some(FontSize::Points(10.5))); + let f = parse_qfont("Inter,-1,15,5,400,0,0,0,0,0,0,0,0,0,0,1").unwrap(); + assert_eq!(f.size, Some(FontSize::Pixels(15.0))); + } + + #[test] + fn qfont_rejects_garbage() { + assert!(parse_qfont("").is_none()); + assert!(parse_qfont("Noto Sans").is_none()); + assert!(parse_qfont(",10,-1,5,50,0,0,0,0,0").is_none()); + } + + #[test] + fn unit_conversion() { + assert_eq!(points_to_px(9.0), 12.0); + assert_eq!(FontSize::Points(10.0).to_logical_px(), 40.0 / 3.0); + assert_eq!(FontSize::Pixels(13.0).to_logical_px(), 13.0); + } + + #[test] + fn logfont_heights() { + // Segoe UI 9 pt as reported at 96 DPI and at 144 DPI (150 %). + assert_eq!(logfont_height_to_px(-12, 96), Some(12.0)); + assert_eq!(logfont_height_to_px(-18, 144), Some(12.0)); + assert_eq!(logfont_height_to_px(16, 96), Some(16.0)); + assert_eq!(logfont_height_to_px(0, 96), None); + assert_eq!(logfont_height_to_px(-12, 0), None); + } + + #[test] + fn kdeglobals_general_font() { + let text = "[Colors:View]\nfont=Wrong,1,-1,5,50,0,0,0,0,0\n\n\ + [General]\nColorScheme=BreezeDark\nfont[$e]=Fira Sans,11,-1,5,50,0,0,0,0,0\n"; + assert_eq!( + ini_value(text, "General", "font").as_deref(), + Some("Fira Sans,11,-1,5,50,0,0,0,0,0") + ); + assert_eq!(ini_value(text, "General", "missing"), None); + } + + #[test] + fn gtk_settings_ini() { + let text = "[Settings]\ngtk-theme-name=Adwaita\ngtk-font-name = \"DejaVu Sans 10\"\n"; + assert_eq!( + ini_value(text, "Settings", "gtk-font-name").as_deref(), + Some("DejaVu Sans 10") + ); + } + + #[test] + fn gvariant_strings() { + assert_eq!( + parse_gvariant_string("'Cantarell 11'\n").as_deref(), + Some("Cantarell 11") + ); + assert_eq!( + parse_gvariant_string("@s 'Ubuntu 11'").as_deref(), + Some("Ubuntu 11") + ); + assert_eq!(parse_gvariant_string("1.25"), None); + } + + #[test] + fn generic_aliases() { + assert!(is_generic_alias("Sans")); + assert!(is_generic_alias(" sans-serif ")); + assert!(!is_generic_alias("Noto Sans")); + } + + #[test] + fn only_fontconfig_is_not_a_desktop_setting() { + assert!(!Source::Fontconfig.is_desktop_setting()); + assert!(Source::GnomeSettings.is_desktop_setting()); + assert!(Source::WindowsNonClientMetrics.is_desktop_setting()); + } +} diff --git a/src/sys/ui_font/linux.rs b/src/sys/ui_font/linux.rs new file mode 100644 index 000000000..b9b329a1d --- /dev/null +++ b/src/sys/ui_font/linux.rs @@ -0,0 +1,359 @@ +//! Linux UI-font discovery. +//! +//! The desktop's own configuration store is read first because it is the +//! source of truth outside a sandbox: gsettings for GNOME and its relatives, +//! `kdeglobals` for KDE Plasma, xfconf for Xfce. The XDG Settings portal +//! comes next — it serves the same values to Flatpak/Snap sandboxes that +//! cannot reach dconf or `~/.config` — then GTK's `settings.ini`. Only when +//! none of those name a font is Fontconfig's `sans-serif` match used, and it +//! is reported as [`Source::Fontconfig`] because it says nothing about the +//! desktop's choice. +//! +//! These sources are the same on Wayland and X11. What differs is scaling: +//! GNOME's `text-scaling-factor` reaches X11 clients through `Xft.dpi`, which +//! winit already turns into the window scale factor, so it is only applied +//! here for Wayland windows. + +use super::{ + ini_value, is_generic_alias, parse_gvariant_string, parse_pango, parse_qfont, Source, + SystemUiFont, +}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Upper bound for any single external read (subprocess or D-Bus call). A +/// hung settings daemon must not hold up startup. +const READ_TIMEOUT: Duration = Duration::from_millis(1500); + +const GNOME_INTERFACE: &str = "org.gnome.desktop.interface"; +const CINNAMON_INTERFACE: &str = "org.cinnamon.desktop.interface"; +const MATE_INTERFACE: &str = "org.mate.interface"; +const KDE_GENERAL: &str = "org.kde.kdeglobals.General"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Desktop { + Gnome, + Cinnamon, + Mate, + Kde, + Xfce, + Other, +} + +impl Desktop { + fn current() -> Self { + let names = [ + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + ] + .iter() + .filter_map(|v| std::env::var(v).ok()) + .collect::>() + .join(":"); + Self::from_names(&names) + } + + fn from_names(names: &str) -> Self { + let names = names.to_ascii_lowercase(); + let has = |n: &str| names.split([':', ';']).any(|d| d.trim() == n); + if has("kde") || has("plasma") || has("plasmawayland") { + Desktop::Kde + } else if has("x-cinnamon") || has("cinnamon") { + Desktop::Cinnamon + } else if has("mate") { + Desktop::Mate + } else if has("xfce") { + Desktop::Xfce + } else if [ + "gnome", + "gnome-classic", + "unity", + "budgie", + "pantheon", + "ubuntu", + "pop", + ] + .iter() + .any(|n| has(n)) + { + Desktop::Gnome + } else { + Desktop::Other + } + } +} + +pub(super) fn detect() -> Option { + let desktop = Desktop::current(); + let mut found = from_desktop_store(desktop) + .or_else(|| from_portal(desktop)) + .or_else(from_gtk_settings_ini); + if found.is_none() && desktop == Desktop::Kde { + // Plasma writes no `font=` until the user changes it; its built-in + // default is Noto Sans 10 pt. + found = Some(SystemUiFont::from_parsed( + parse_qfont("Noto Sans,10,-1,5,50,0,0,0,0,0")?, + Source::KdeDefault, + )); + } + let Some(mut font) = found else { + return fontconfig_sans(); + }; + // "Sans 10" and friends name Fontconfig's default face; resolve it so + // iced asks for the same family the desktop renders. + if font.family.as_deref().is_none_or(is_generic_alias) { + font.family = fc_match("sans-serif"); + } + Some(font) +} + +fn from_desktop_store(desktop: Desktop) -> Option { + match desktop { + Desktop::Gnome => gsettings_font(GNOME_INTERFACE, Source::GnomeSettings), + Desktop::Cinnamon => gsettings_font(CINNAMON_INTERFACE, Source::CinnamonSettings), + Desktop::Mate => gsettings_font(MATE_INTERFACE, Source::MateSettings), + Desktop::Kde => kdeglobals_font(), + Desktop::Xfce => run("xfconf-query", &["-c", "xsettings", "-p", "/Gtk/FontName"]) + .and_then(|out| parse_pango(out.trim())) + .map(|p| SystemUiFont::from_parsed(p, Source::XfceSettings)), + Desktop::Other => None, + } +} + +fn gsettings_font(schema: &str, source: Source) -> Option { + let desc = parse_gvariant_string(&run("gsettings", &["get", schema, "font-name"])?)?; + let mut font = SystemUiFont::from_parsed(parse_pango(&desc)?, source); + if schema == GNOME_INTERFACE { + let factor = run("gsettings", &["get", schema, "text-scaling-factor"]) + .and_then(|out| out.trim().parse::().ok()); + apply_text_scaling(&mut font, factor); + } + Some(font) +} + +/// GNOME's accessibility "Large Text" / text-scaling-factor multiplies every +/// font size. X11 clients already receive it through `Xft.dpi` (winit's +/// scale factor), so only Wayland windows need it here. +fn apply_text_scaling(font: &mut SystemUiFont, factor: Option) { + let Some(factor) = factor.filter(|f| f.is_finite() && *f > 0.0) else { + return; + }; + if winit_uses_wayland() { + font.size_px = font.size_px.map(|px| px * factor as f32); + } +} + +/// winit prefers Wayland whenever a compositor socket is advertised. +fn winit_uses_wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) + || std::env::var_os("WAYLAND_SOCKET").is_some_and(|v| !v.is_empty()) +} + +fn config_home() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) +} + +fn config_dirs() -> Vec { + let mut dirs: Vec = config_home().into_iter().collect(); + let system = std::env::var("XDG_CONFIG_DIRS").unwrap_or_else(|_| "/etc/xdg".into()); + dirs.extend( + system + .split(':') + .filter(|d| !d.is_empty()) + .map(PathBuf::from), + ); + dirs +} + +/// KConfig cascades from the user's file to the system ones; the first file +/// that sets the key wins. +fn kdeglobals_font() -> Option { + config_dirs().into_iter().find_map(|dir| { + let text = std::fs::read_to_string(dir.join("kdeglobals")).ok()?; + let value = ini_value(&text, "General", "font")?; + Some(SystemUiFont::from_parsed( + parse_qfont(&value)?, + Source::KdeGlobals, + )) + }) +} + +fn from_gtk_settings_ini() -> Option { + let home = config_home()?; + ["gtk-4.0", "gtk-3.0"].iter().find_map(|dir| { + let text = std::fs::read_to_string(home.join(dir).join("settings.ini")).ok()?; + let desc = ini_value(&text, "Settings", "gtk-font-name")?; + Some(SystemUiFont::from_parsed( + parse_pango(&desc)?, + Source::GtkSettingsIni, + )) + }) +} + +/// Read the UI font through `org.freedesktop.portal.Settings`. The GNOME and +/// GTK portal backends expose `org.gnome.desktop.interface`; the KDE backend +/// exposes `kdeglobals` groups as `org.kde.kdeglobals.`. +fn from_portal(desktop: Desktop) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + // A detached thread: if the bus never answers, startup proceeds after the + // timeout and the thread exits whenever the call finally fails. + std::thread::Builder::new() + .name("ocs-ui-font-portal".into()) + .spawn(move || { + let result = iced::futures::executor::block_on(read_portal(desktop)); + let _ = tx.send(result); + }) + .ok()?; + rx.recv_timeout(READ_TIMEOUT).ok().flatten() +} + +async fn read_portal(desktop: Desktop) -> Option { + use ashpd::desktop::settings::Settings; + let settings = Settings::new().await.ok()?; + let kde = async { + let value = settings.read::(KDE_GENERAL, "font").await.ok()?; + Some(SystemUiFont::from_parsed( + parse_qfont(&value)?, + Source::Portal, + )) + }; + let gnome = async { + let desc = settings + .read::(GNOME_INTERFACE, "font-name") + .await + .ok()?; + let mut font = SystemUiFont::from_parsed(parse_pango(&desc)?, Source::Portal); + let factor = settings + .read::(GNOME_INTERFACE, "text-scaling-factor") + .await + .ok(); + apply_text_scaling(&mut font, factor); + Some(font) + }; + if desktop == Desktop::Kde { + match kde.await { + Some(font) => Some(font), + None => gnome.await, + } + } else { + match gnome.await { + Some(font) => Some(font), + None => kde.await, + } + } +} + +/// Whether a portal `SettingChanged` signal concerns the UI font. +fn is_font_setting(namespace: &str, key: &str) -> bool { + match namespace { + GNOME_INTERFACE => matches!(key, "font-name" | "text-scaling-factor"), + CINNAMON_INTERFACE | MATE_INTERFACE => key == "font-name", + KDE_GENERAL => key == "font", + _ => false, + } +} + +pub(super) fn change_signals() -> impl iced::futures::Stream + Send + 'static { + use iced::futures::{SinkExt, StreamExt}; + // The signal stream borrows the proxy, so both live in the producer. + iced::stream::channel(1, async |mut output| { + let Ok(settings) = ashpd::desktop::settings::Settings::new().await else { + log::info!("UI font: no Settings portal; font changes apply after restart"); + return; + }; + let Ok(mut changes) = settings.receive_setting_changed().await else { + log::info!("UI font: portal change signal unavailable; changes apply after restart"); + return; + }; + while let Some(setting) = changes.next().await { + if is_font_setting(setting.namespace(), setting.key()) && output.send(()).await.is_err() + { + return; + } + } + }) +} + +/// Fontconfig's resolution of a pattern to a concrete family name. +fn fc_match(pattern: &str) -> Option { + let out = run("fc-match", &["--format=%{family[0]}", pattern])?; + Some(out.trim().to_string()).filter(|f| !f.is_empty()) +} + +fn fontconfig_sans() -> Option { + Some(SystemUiFont { + family: Some(fc_match("sans-serif")?), + alternates: Vec::new(), + weight: 400, + italic: false, + size_px: None, + source: Source::Fontconfig, + }) +} + +/// Run a settings helper with a bounded wait; `None` if it is missing, fails +/// or does not finish in time. +fn run(program: &str, args: &[&str]) -> Option { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + READ_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(Some(_)) | Err(_) => return None, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => std::thread::sleep(Duration::from_millis(5)), + } + } + let mut out = String::new(); + use std::io::Read; + child.stdout.take()?.read_to_string(&mut out).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_names() { + assert_eq!(Desktop::from_names("KDE"), Desktop::Kde); + assert_eq!(Desktop::from_names("ubuntu:GNOME"), Desktop::Gnome); + assert_eq!(Desktop::from_names("X-Cinnamon"), Desktop::Cinnamon); + assert_eq!(Desktop::from_names("MATE"), Desktop::Mate); + assert_eq!(Desktop::from_names("XFCE"), Desktop::Xfce); + assert_eq!(Desktop::from_names("sway"), Desktop::Other); + assert_eq!(Desktop::from_names(""), Desktop::Other); + } + + #[test] + fn font_change_filter() { + assert!(is_font_setting(GNOME_INTERFACE, "font-name")); + assert!(is_font_setting(GNOME_INTERFACE, "text-scaling-factor")); + assert!(is_font_setting(KDE_GENERAL, "font")); + assert!(!is_font_setting(GNOME_INTERFACE, "color-scheme")); + assert!(!is_font_setting( + "org.freedesktop.appearance", + "color-scheme" + )); + } + + #[test] + fn missing_helper_is_none() { + assert_eq!(run("ocs-definitely-not-a-program", &[]), None); + } +} diff --git a/src/sys/ui_font/macos.rs b/src/sys/ui_font/macos.rs new file mode 100644 index 000000000..f3dca9de2 --- /dev/null +++ b/src/sys/ui_font/macos.rs @@ -0,0 +1,41 @@ +//! macOS UI-font discovery. +//! +//! AppKit's system font (`+[NSFont systemFontOfSize:0]`) at +//! `+[NSFont systemFontSize]`. macOS expresses sizes in points that are +//! already logical pixels (the Retina backing scale is separate), so the +//! size is used as-is. +//! +//! The family AppKit reports is a private name (`.AppleSystemUIFont`) that +//! font databases list differently, so the names San Francisco is exposed +//! under are carried as alternates; the typography layer picks the first +//! one the renderer actually has. + +use super::{Source, SystemUiFont}; +use objc2_app_kit::NSFont; + +const SYSTEM_FONT_ALIASES: [&str; 5] = [ + ".SF NS", + "System Font", + "SF Pro Text", + "SF Pro", + "Helvetica Neue", +]; + +pub(super) fn detect() -> Option { + // SAFETY: these class methods have no preconditions and may be called + // from any thread. + let (family, size) = unsafe { + let size = NSFont::systemFontSize(); + let font = NSFont::systemFontOfSize(0.0); + (font.familyName().map(|name| name.to_string()), size) + }; + let size = size as f32; + Some(SystemUiFont { + family, + alternates: SYSTEM_FONT_ALIASES.iter().map(|s| s.to_string()).collect(), + weight: 400, + italic: false, + size_px: (size.is_finite() && size > 0.0).then_some(size), + source: Source::MacSystemFont, + }) +} diff --git a/src/sys/ui_font/windows.rs b/src/sys/ui_font/windows.rs new file mode 100644 index 000000000..b0cfa0117 --- /dev/null +++ b/src/sys/ui_font/windows.rs @@ -0,0 +1,78 @@ +//! Windows UI-font discovery. +//! +//! The UI font is the non-client metrics' message font (`lfMessageFont`, +//! Segoe UI 9 pt by default) — the face Windows itself uses for dialogs and +//! message boxes. It is read with `SystemParametersInfoForDpi` at 96 DPI so +//! the height comes back in logical pixels: the window's DPI scale is +//! applied by winit, and reading at the monitor DPI would apply it twice. + +use super::{logfont_height_to_px, Source, SystemUiFont}; +use windows_sys::Win32::Graphics::Gdi::LOGFONTW; +use windows_sys::Win32::UI::HiDpi::SystemParametersInfoForDpi; +use windows_sys::Win32::UI::WindowsAndMessaging::{NONCLIENTMETRICSW, SPI_GETNONCLIENTMETRICS}; + +/// The DPI at which one logical pixel is one physical pixel. +const REFERENCE_DPI: u32 = 96; + +pub(super) fn detect() -> Option { + // SAFETY: NONCLIENTMETRICSW is plain old data; all-zero is a valid value. + let mut metrics: NONCLIENTMETRICSW = unsafe { std::mem::zeroed() }; + metrics.cbSize = std::mem::size_of::() as u32; + // SAFETY: `metrics` is a properly sized, writable NONCLIENTMETRICSW and + // `cbSize`/`uiParam` both carry its size, as the API requires. + let ok = unsafe { + SystemParametersInfoForDpi( + SPI_GETNONCLIENTMETRICS, + metrics.cbSize, + (&mut metrics as *mut NONCLIENTMETRICSW).cast(), + 0, + REFERENCE_DPI, + ) + }; + if ok == 0 { + return None; + } + from_logfont(&metrics.lfMessageFont) +} + +fn from_logfont(font: &LOGFONTW) -> Option { + let len = font + .lfFaceName + .iter() + .position(|&c| c == 0) + .unwrap_or(font.lfFaceName.len()); + let family = String::from_utf16(&font.lfFaceName[..len]).ok()?; + let family = family.trim(); + if family.is_empty() { + return None; + } + // FW_DONTCARE (0) means the face's regular weight. + let weight = match font.lfWeight { + w if w <= 0 => 400, + w => w.clamp(100, 900) as u16, + }; + Some(SystemUiFont { + family: Some(family.to_string()), + alternates: Vec::new(), + weight, + italic: font.lfItalic != 0, + size_px: logfont_height_to_px(font.lfHeight, REFERENCE_DPI), + source: Source::WindowsNonClientMetrics, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_message_font() { + let font = detect().expect("non-client metrics are always available"); + assert!(font.family.as_deref().is_some_and(|f| !f.is_empty())); + let px = font.size_px.expect("system fonts carry an explicit height"); + // Logical pixels at 96 DPI: Segoe UI 9 pt is 12 px; anything sane is + // well inside this range whatever the display scaling. + assert!((6.0..=48.0).contains(&px), "{px}"); + assert_eq!(font.source, Source::WindowsNonClientMetrics); + } +} diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index e60d87142..34086b73c 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -94,8 +94,9 @@ const COMBO_PANEL_PAD: Padding = Padding { thread_local! { /// Ribbon layout runs on every pointer-driven view update. Cache the font- /// measured width per translated label so the automatic sizing stays cheap. - static LARGE_WIDTH_CACHE: RefCell> = - RefCell::new(HashMap::default()); + /// Tagged with the UI-font generation it was measured with. + static LARGE_WIDTH_CACHE: RefCell<(u32, HashMap)> = + RefCell::new((0, HashMap::default())); } /// Dropdown items that report a state and cannot be picked. @@ -173,13 +174,20 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 { } fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 { - if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) { + let generation = crate::sys::ui_font::generation(); + if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.0 != generation { + *cache = (generation, HashMap::default()); + } + cache.1.get(label).copied() + }) { return width; } let width = measure_large_width(renderer, label); LARGE_WIDTH_CACHE.with(|cache| { - cache.borrow_mut().insert(label.to_string(), width); + cache.borrow_mut().1.insert(label.to_string(), width); }); width }