forked from 1b-agent/opencadstudio-upstream
Merge pull request #1831 from manuvarkey/system-ui-font-face
This commit is contained in:
commit
012ccc7b65
10 files changed
+1134
-7
No files matched your search
+3
-3
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user