Merge pull request #1831 from manuvarkey/system-ui-font-face

This commit is contained in:
Hakan Seven committed 2026-10-16 01:11:11 +03:00
commit 012ccc7b65
10 files changed
+1134 -7

No files matched your search

+3 -3
View File
@@ -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"] }
+7
View File
@@ -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<crate::sys::ui_font::SystemUiFont>),
/// 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,
+21
View File
@@ -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(),
+17
View File
@@ -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<Message> {
#[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(),
])
}
+2
View File
@@ -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<String> {
if value.chars().any(char::is_control) {
+594
View File
@@ -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<f32> {
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<String>,
/// 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<String>,
/// 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<f32>,
/// 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<SystemUiFont> {
#[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<Option<Applied>> = 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<Item = ()> + 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<String>,
pub weight: u16,
pub italic: bool,
pub size: Option<FontSize>,
}
/// 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<ParsedFont> {
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::<f32>() {
size = Some(FontSize::Pixels(v));
words.pop();
}
} else if let Ok(v) = last.parse::<f32>() {
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<u16> {
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<ParsedFont> {
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::<f32>().ok().filter(|v| *v > 0.0);
let pixel_size = fields[2].trim().parse::<f32>().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::<u16>().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<String> {
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<String> {
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());
}
}
+359
View File
@@ -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::<Vec<_>>()
.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<SystemUiFont> {
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<SystemUiFont> {
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<SystemUiFont> {
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::<f64>().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<f64>) {
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<PathBuf> {
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<PathBuf> {
let mut dirs: Vec<PathBuf> = 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<SystemUiFont> {
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<SystemUiFont> {
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.<Group>`.
fn from_portal(desktop: Desktop) -> Option<SystemUiFont> {
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<SystemUiFont> {
use ashpd::desktop::settings::Settings;
let settings = Settings::new().await.ok()?;
let kde = async {
let value = settings.read::<String>(KDE_GENERAL, "font").await.ok()?;
Some(SystemUiFont::from_parsed(
parse_qfont(&value)?,
Source::Portal,
))
};
let gnome = async {
let desc = settings
.read::<String>(GNOME_INTERFACE, "font-name")
.await
.ok()?;
let mut font = SystemUiFont::from_parsed(parse_pango(&desc)?, Source::Portal);
let factor = settings
.read::<f64>(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<Item = ()> + 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<String> {
let out = run("fc-match", &["--format=%{family[0]}", pattern])?;
Some(out.trim().to_string()).filter(|f| !f.is_empty())
}
fn fontconfig_sans() -> Option<SystemUiFont> {
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<String> {
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);
}
}
+41
View File
@@ -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<SystemUiFont> {
// 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,
})
}
+78
View File
@@ -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<SystemUiFont> {
// 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::<NONCLIENTMETRICSW>() 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<SystemUiFont> {
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);
}
}
+12 -4
View File
@@ -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<HashMap<String, f32>> =
RefCell::new(HashMap::default());
/// Tagged with the UI-font generation it was measured with.
static LARGE_WIDTH_CACHE: RefCell<(u32, HashMap<String, f32>)> =
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
}