From d92424d5df29bd9444fcda288cc23545def8d7ab Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Wed, 29 Apr 2026 17:52:14 +0200 Subject: [PATCH] slot system --- frontends/rioterm/src/grid_emit.rs | 7 +- frontends/rioterm/src/screen/mod.rs | 54 +---- rio-backend/src/config/mod.rs | 4 +- rio-backend/src/config/renderer.rs | 49 ++-- rio-backend/src/error/mod.rs | 20 +- sugarloaf/src/font/constants.rs | 41 ---- sugarloaf/src/font/fonts.rs | 140 ++++++----- sugarloaf/src/font/macos.rs | 348 ++++++++++++++++++++-------- sugarloaf/src/font/mod.rs | 231 ++++++++---------- sugarloaf/src/text.rs | 7 +- 10 files changed, 476 insertions(+), 425 deletions(-) diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index 590d0660..d5e6bfd2 100644 --- a/frontends/rioterm/src/grid_emit.rs +++ b/frontends/rioterm/src/grid_emit.rs @@ -1932,9 +1932,14 @@ fn rasterize_glyph_native( Source::Outline, ]; let mut image = GlyphImage::new(); + let embolden_amount = if synthetic_bold { + (size_u16 as f32 / 14.0).max(1.0) + } else { + 0.0 + }; let ok = Render::new(sources) .format(Format::Alpha) - .embolden(if synthetic_bold { 0.5 } else { 0.0 }) + .embolden(embolden_amount) .transform(if synthetic_italic { Some(Transform::skew( Angle::from_degrees(14.0), diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 60bf8ada..a6d86221 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -141,41 +141,7 @@ impl Screen<'_> { SugarloafBackend::Cpu } else { match config.renderer.backend { - Backend::Automatic => { - // Linux + macOS pick their native GPU backend (ash - // / Metal). Other targets fall back to the wgpu - // umbrella (only available with the `wgpu` - // feature; otherwise we degrade to CPU rasterizer). - #[cfg(target_os = "linux")] - { - SugarloafBackend::Vulkan - } - #[cfg(target_os = "macos")] - { - SugarloafBackend::Metal - } - #[cfg(all( - not(any(target_os = "linux", target_os = "macos")), - feature = "wgpu", - ))] - { - #[cfg(target_arch = "wasm32")] - let default_backend = - wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL; - #[cfg(not(target_arch = "wasm32"))] - let default_backend = wgpu::Backends::all(); - - SugarloafBackend::Wgpu(default_backend) - } - #[cfg(all( - not(any(target_os = "linux", target_os = "macos")), - not(feature = "wgpu"), - ))] - { - SugarloafBackend::Cpu - } - } - // `Backend::Vulkan` from the user config now means the + // `Backend::Vulkan` from the user config means the // native ash backend on Linux. Other OSes fall through // to the wgpu Vulkan path when the `wgpu` feature is // on; otherwise we degrade to CPU rasterizer. @@ -185,20 +151,16 @@ impl Screen<'_> { Backend::Vulkan => SugarloafBackend::Wgpu(wgpu::Backends::VULKAN), #[cfg(all(not(target_os = "linux"), not(feature = "wgpu")))] Backend::Vulkan => SugarloafBackend::Cpu, - #[cfg(feature = "wgpu")] - Backend::GL => SugarloafBackend::Wgpu(wgpu::Backends::GL), - #[cfg(not(feature = "wgpu"))] - Backend::GL => SugarloafBackend::Cpu, - #[cfg(feature = "wgpu")] - Backend::WgpuMetal => SugarloafBackend::Wgpu(wgpu::Backends::METAL), - #[cfg(not(feature = "wgpu"))] - Backend::WgpuMetal => SugarloafBackend::Cpu, #[cfg(target_os = "macos")] Backend::Metal => SugarloafBackend::Metal, - #[cfg(feature = "wgpu")] - Backend::DX12 => SugarloafBackend::Wgpu(wgpu::Backends::DX12), + #[cfg(all(feature = "wgpu", target_arch = "wasm32"))] + Backend::Webgpu => SugarloafBackend::Wgpu( + wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL, + ), + #[cfg(all(feature = "wgpu", not(target_arch = "wasm32")))] + Backend::Webgpu => SugarloafBackend::Wgpu(wgpu::Backends::all()), #[cfg(not(feature = "wgpu"))] - Backend::DX12 => SugarloafBackend::Cpu, + Backend::Webgpu => SugarloafBackend::Cpu, } }; diff --git a/rio-backend/src/config/mod.rs b/rio-backend/src/config/mod.rs index fff0646e..c82f1701 100644 --- a/rio-backend/src/config/mod.rs +++ b/rio-backend/src/config/mod.rs @@ -1174,7 +1174,7 @@ mod tests { r#" [renderer] performance = "Low" - backend = "GL" + backend = "Webgpu" [developer] enable-fps-counter = true @@ -1182,7 +1182,7 @@ mod tests { "#, ); - assert_eq!(result.renderer.backend, renderer::Backend::GL); + assert_eq!(result.renderer.backend, renderer::Backend::Webgpu); // Developer assert_eq!(result.developer.log_level, String::from("INFO")); assert!(result.developer.enable_fps_counter); diff --git a/rio-backend/src/config/renderer.rs b/rio-backend/src/config/renderer.rs index eecfe9c9..abacb08a 100644 --- a/rio-backend/src/config/renderer.rs +++ b/rio-backend/src/config/renderer.rs @@ -75,50 +75,31 @@ impl Default for Renderer { #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] pub enum Backend { - // Leave Sugarloaf/WGPU to decide - #[serde(alias = "automatic")] - #[cfg_attr(not(target_os = "macos"), default)] - Automatic, - // Supported on Linux/Android, the web through webassembly via WebGL, and Windows and macOS/iOS via ANGLE - #[serde(alias = "gl")] - GL, - // Supported on Windows, Linux/Android, and macOS/iOS via Vulkan Portability (with the Vulkan feature enabled) - #[serde(alias = "vulkan")] - Vulkan, - // Supported on Windows 10 - #[serde(alias = "dx12")] - DX12, - // Supported on macOS/iOS - #[serde(alias = "wgpumetal")] - WgpuMetal, + /// Native Metal (macOS only). #[cfg(target_os = "macos")] #[cfg_attr(target_os = "macos", default)] #[serde(alias = "metal")] Metal, + /// Native Vulkan on Linux; wgpu Vulkan elsewhere (requires the + /// `wgpu` feature). + #[cfg_attr(target_os = "linux", default)] + #[serde(alias = "vulkan")] + Vulkan, + /// wgpu umbrella backend — wgpu picks the best available native + /// API (Metal / Vulkan / DX12 / GL / WebGPU). Requires the `wgpu` + /// feature. + #[cfg_attr(not(any(target_os = "macos", target_os = "linux")), default)] + #[serde(alias = "webgpu", alias = "wgpu")] + Webgpu, } impl Display for Backend { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - Backend::Automatic => { - write!(f, "Automatic") - } #[cfg(target_os = "macos")] - Backend::Metal => { - write!(f, "Metal") - } - Backend::WgpuMetal => { - write!(f, "Metal") - } - Backend::Vulkan => { - write!(f, "Vulkan") - } - Backend::GL => { - write!(f, "GL") - } - Backend::DX12 => { - write!(f, "DX12") - } + Backend::Metal => write!(f, "Metal"), + Backend::Vulkan => write!(f, "Vulkan"), + Backend::Webgpu => write!(f, "Webgpu"), } } } diff --git a/rio-backend/src/error/mod.rs b/rio-backend/src/error/mod.rs index cab8409e..1b960b16 100644 --- a/rio-backend/src/error/mod.rs +++ b/rio-backend/src/error/mod.rs @@ -70,17 +70,19 @@ impl std::fmt::Display for RioErrorType { RioErrorType::FontsNotFound(fonts) => { let mut font_str = String::from(""); for font in fonts.iter() { - let weight = if font.weight.is_none() { - String::from("any weight") - } else { - format!("{} weight", font.weight.unwrap()) + let style = match &font.style { + crate::sugarloaf::font::fonts::FontStyle::Default => { + String::from("default style") + } + crate::sugarloaf::font::fonts::FontStyle::Disabled => { + String::from("disabled") + } + crate::sugarloaf::font::fonts::FontStyle::Named(s) => { + format!("style \"{s}\"") + } }; - - let style = format!("{:?} style", font.style); - font_str += - format!("\n• \"{}\" using {:?} {:?}", font.family, weight, style) - .as_str(); + format!("\n• \"{}\" using {}", font.family, style).as_str(); } write!(f, "Font(s) not found:\n{font_str}") diff --git a/sugarloaf/src/font/constants.rs b/sugarloaf/src/font/constants.rs index 163f410a..b704a0fc 100644 --- a/sugarloaf/src/font/constants.rs +++ b/sugarloaf/src/font/constants.rs @@ -7,55 +7,14 @@ macro_rules! font { pub const DEFAULT_FONT_FAMILY: &str = "cascadiacode"; -// Fonts: -// CascadiaCode-Bold.ttf -// CascadiaCode-BoldItalic.ttf -// CascadiaCode-ExtraLight.ttf -// CascadiaCode-ExtraLightItalic.ttf -// CascadiaCode-Italic.ttf -// CascadiaCode-Light.ttf -// CascadiaCode-LightItalic.ttf -// CascadiaCode-Regular.ttf -// CascadiaCode-SemiBold.ttf -// CascadiaCode-SemiBoldItalic.ttf -// CascadiaCode-SemiLight.ttf -// CascadiaCode-SemiLightItalic.ttf - pub const FONT_CASCADIAMONO_BOLD: &[u8] = font!("./resources/CascadiaCode/CascadiaCode-Bold.otf"); pub const FONT_CASCADIAMONO_BOLD_ITALIC: &[u8] = font!("./resources/CascadiaCode/CascadiaCode-BoldItalic.otf"); -pub const FONT_CASCADIAMONO_EXTRA_LIGHT: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-ExtraLight.otf"); - -pub const FONT_CASCADIAMONO_EXTRA_LIGHT_ITALIC: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-ExtraLightItalic.otf"); - pub const FONT_CASCADIAMONO_ITALIC: &[u8] = font!("./resources/CascadiaCode/CascadiaCode-Italic.otf"); -pub const FONT_CASCADIAMONO_LIGHT: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-Light.otf"); - -pub const FONT_CASCADIAMONO_LIGHT_ITALIC: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-LightItalic.otf"); - pub const FONT_CASCADIAMONO_NF_REGULAR: &[u8] = font!("./resources/CascadiaCode/CascadiaCodeNF-Regular.otf"); - -pub const FONT_CASCADIAMONO_SEMI_BOLD: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-SemiBold.otf"); - -pub const FONT_CASCADIAMONO_SEMI_BOLD_ITALIC: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-SemiBoldItalic.otf"); - -pub const FONT_CASCADIAMONO_SEMI_LIGHT: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-SemiLight.otf"); - -pub const FONT_CASCADIAMONO_SEMI_LIGHT_ITALIC: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-SemiLightItalic.otf"); - -// pub const FONT_SYMBOLS_NERD_FONT_MONO: &[u8] = -// font!("./resources/SymbolsNerdFontMono/SymbolsNerdFontMono-Regular.ttf"); diff --git a/sugarloaf/src/font/fonts.rs b/sugarloaf/src/font/fonts.rs index 056c7512..16ff9994 100644 --- a/sugarloaf/src/font/fonts.rs +++ b/sugarloaf/src/font/fonts.rs @@ -1,48 +1,98 @@ use crate::font::DEFAULT_FONT_FAMILY; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)] -pub enum SugarloafFontStyle { - #[default] - #[serde(alias = "normal")] - Normal, - #[serde(alias = "italic")] - Italic, -} - -#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)] -pub enum SugarloafFontWidth { - UltraCondensed, - ExtraCondensed, - Condensed, - SemiCondensed, +use serde::de::{self, Deserializer, Visitor}; +use serde::{Deserialize, Serialize, Serializer}; +use std::fmt; + +/// Per-slot font style override. Mirrors Ghostty's `FontStyle` enum: +/// - `Default`: let font discovery pick the face implied by the slot +/// (regular / bold / italic / bold+italic traits). +/// - `Disabled`: skip this slot entirely; the regular face is reused +/// when the terminal asks for this style. Spelled `false` in TOML. +/// - `Named(String)`: match a specific style name from the family, +/// e.g. `"Light"`, `"Medium"`, `"Heavy"`. CoreText / fontconfig +/// resolves this against the face's style/PostScript name. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum FontStyle { #[default] - Normal, - SemiExpanded, - Expanded, - ExtraExpanded, - UltraExpanded, + Default, + Disabled, + Named(String), +} + +impl FontStyle { + #[inline] + pub fn name(&self) -> Option<&str> { + match self { + FontStyle::Named(s) => Some(s.as_str()), + _ => None, + } + } + + #[inline] + pub fn is_disabled(&self) -> bool { + matches!(self, FontStyle::Disabled) + } +} + +impl Serialize for FontStyle { + fn serialize(&self, ser: S) -> Result { + match self { + FontStyle::Default => ser.serialize_str("default"), + FontStyle::Disabled => ser.serialize_bool(false), + FontStyle::Named(s) => ser.serialize_str(s), + } + } +} + +impl<'de> Deserialize<'de> for FontStyle { + fn deserialize>(de: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = FontStyle; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("\"default\", false, or a font style name string") + } + fn visit_bool(self, v: bool) -> Result { + if v { + Err(E::custom( + "font style cannot be `true`; use \"default\" or a name", + )) + } else { + Ok(FontStyle::Disabled) + } + } + fn visit_str(self, v: &str) -> Result { + Ok(match v { + "default" => FontStyle::Default, + "false" => FontStyle::Disabled, + other => FontStyle::Named(other.to_string()), + }) + } + fn visit_string(self, v: String) -> Result { + Ok(match v.as_str() { + "default" => FontStyle::Default, + "false" => FontStyle::Disabled, + _ => FontStyle::Named(v), + }) + } + } + de.deserialize_any(V) + } } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub struct SugarloafFont { #[serde(default = "default_font_family")] pub family: String, - #[serde(default = "Option::default")] - pub weight: Option, - #[serde(default = "SugarloafFontStyle::default")] - pub style: SugarloafFontStyle, - #[serde(default = "Option::default")] - pub width: Option, + #[serde(default)] + pub style: FontStyle, } impl Default for SugarloafFont { fn default() -> Self { Self { family: default_font_family(), - weight: None, - style: SugarloafFontStyle::Normal, - width: None, + style: FontStyle::Default, } } } @@ -78,39 +128,19 @@ fn default_font_family() -> String { } pub fn default_font_regular() -> SugarloafFont { - SugarloafFont { - family: default_font_family(), - weight: Some(400), - style: SugarloafFontStyle::Normal, - width: None, - } + SugarloafFont::default() } pub fn default_font_bold() -> SugarloafFont { - SugarloafFont { - family: default_font_family(), - weight: Some(800), - style: SugarloafFontStyle::Normal, - width: None, - } + SugarloafFont::default() } pub fn default_font_italic() -> SugarloafFont { - SugarloafFont { - family: default_font_family(), - weight: Some(300), - style: SugarloafFontStyle::Italic, - width: None, - } + SugarloafFont::default() } pub fn default_font_bold_italic() -> SugarloafFont { - SugarloafFont { - family: default_font_family(), - weight: Some(800), - style: SugarloafFontStyle::Italic, - width: None, - } + SugarloafFont::default() } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] diff --git a/sugarloaf/src/font/macos.rs b/sugarloaf/src/font/macos.rs index 17dcf9e6..449b3c5d 100644 --- a/sugarloaf/src/font/macos.rs +++ b/sugarloaf/src/font/macos.rs @@ -29,9 +29,10 @@ use core_text::{ font::{CTFont, CTFontRef}, font_collection, font_descriptor::{ - self, kCTFontFamilyNameAttribute, kCTFontOrientationDefault, kCTFontSlantTrait, - kCTFontTraitsAttribute, kCTFontWeightTrait, kCTFontWidthTrait, CTFontDescriptor, - CTFontDescriptorCreateMatchingFontDescriptor, CTFontDescriptorRef, + self, kCTFontBoldTrait, kCTFontFamilyNameAttribute, kCTFontItalicTrait, + kCTFontOrientationDefault, kCTFontStyleNameAttribute, kCTFontSymbolicTrait, + kCTFontTraitsAttribute, kCTFontVariationAttribute, CTFontDescriptor, + CTFontDescriptorCopyAttribute, CTFontDescriptorRef, }, font_manager, line::CTLine, @@ -505,106 +506,259 @@ pub fn rasterize_glyph( }) } -/// Stretch axis, used to build a CoreText width trait when resolving a font. -/// Mirrors CSS `font-stretch` values. `Normal` is the no-op default. -#[derive(Clone, Copy, Debug, Default)] -pub enum Stretch { - UltraCondensed, - ExtraCondensed, - Condensed, - SemiCondensed, - #[default] - Normal, - SemiExpanded, - Expanded, - ExtraExpanded, - UltraExpanded, -} +pub fn find_font_path( + family: &str, + bold: bool, + italic: bool, + style_name: Option<&str>, +) -> Option { + use core_foundation::array::CFArray; + + let family_cf = CFString::new(family); + + let family_key = unsafe { CFString::wrap_under_get_rule(kCTFontFamilyNameAttribute) }; + let mut pairs: Vec<(CFString, CFType)> = vec![(family_key, family_cf.as_CFType())]; + + let mut symbolic: u32 = 0; + if style_name.is_none() { + if bold { + symbolic |= kCTFontBoldTrait; + } + if italic { + symbolic |= kCTFontItalicTrait; + } + } + + if symbolic != 0 { + let symbolic_key = unsafe { CFString::wrap_under_get_rule(kCTFontSymbolicTrait) }; + let traits: CFDictionary = + CFDictionary::from_CFType_pairs(&[( + symbolic_key, + CFNumber::from(symbolic as i64).as_CFType(), + )]); + let traits_attr_key = + unsafe { CFString::wrap_under_get_rule(kCTFontTraitsAttribute) }; + pairs.push((traits_attr_key, traits.as_CFType())); + } + + if let Some(name) = style_name { + let style_key = + unsafe { CFString::wrap_under_get_rule(kCTFontStyleNameAttribute) }; + pairs.push((style_key, CFString::new(name).as_CFType())); + } + let attrs: CFDictionary = CFDictionary::from_CFType_pairs(&pairs); + let desc = font_descriptor::new_from_attributes(&attrs); + + let descs_arr = CFArray::from_CFTypes(&[desc]); + let collection = font_collection::new_from_descriptors(&descs_arr); + let candidates = collection.get_descriptors()?; -impl Stretch { - /// Map to CoreText's normalized width trait (-1.0 = narrowest, 1.0 = widest). - fn as_ct_width(self) -> f64 { - match self { - Self::UltraCondensed => -1.0, - Self::ExtraCondensed => -0.75, - Self::Condensed => -0.5, - Self::SemiCondensed => -0.25, - Self::Normal => 0.0, - Self::SemiExpanded => 0.25, - Self::Expanded => 0.5, - Self::ExtraExpanded => 0.75, - Self::UltraExpanded => 1.0, + let desired_styles = derive_desired_styles(bold, italic, style_name); + + let mut best: Option<(u64, CTFontDescriptor)> = None; + for d in candidates.iter() { + let score = score_candidate(&d, bold, italic, &desired_styles); + let take = match &best { + None => true, + Some((b, _)) => score > *b, + }; + if take { + best = Some((score, d.clone())); } } + + best.and_then(|(_, d)| d.font_path()) } -/// Map CSS-style font weight (100–900) to CoreText's normalized weight trait -/// (-1.0 thin .. 0.0 regular .. 1.0 black). Values picked from the mapping -/// CoreText itself uses internally, rounded to the nearest standard step. -fn css_weight_to_ct(weight: u16) -> f64 { - match weight { - 0..=149 => -0.8, - 150..=249 => -0.6, - 250..=349 => -0.4, - 350..=449 => 0.0, - 450..=549 => 0.23, - 550..=649 => 0.3, - 650..=749 => 0.4, - 750..=849 => 0.56, - _ => 0.62, +fn derive_desired_styles( + bold: bool, + italic: bool, + style_name: Option<&str>, +) -> Vec { + if let Some(user) = style_name { + return vec![user.to_string()]; } + let primary = match (bold, italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + vec![primary.to_string()] } -/// Resolve a font spec to a file path via CoreText descriptor matching. -/// -/// Build a descriptor with family + weight + slant + width, let CoreText do -/// the match, extract the URL. No CSS-spec matching code on our side — -/// CoreText handles proximity scoring and "closest match" rules natively. -/// Returns `None` if CoreText can't find anything, or the resolved -/// descriptor has no URL (e.g. system-supplied font without a backing file, -/// which shouldn't happen for user-installable fonts). -pub fn find_font_path( - family: &str, - weight: u16, +fn score_candidate( + desc: &CTFontDescriptor, + want_bold: bool, + want_italic: bool, + desired_styles: &[String], +) -> u64 { + let font = ct_font::new_from_descriptor(desc, 12.0); + let traits = font.symbolic_traits(); + let mut is_bold = (traits & kCTFontBoldTrait) != 0; + let mut is_italic = (traits & kCTFontItalicTrait) != 0; + let monospace = (traits & (1u32 << 10)) != 0; + + apply_head_table_traits(&font, &mut is_bold, &mut is_italic); + apply_os2_table_traits(&font, &mut is_bold, &mut is_italic); + apply_variation_overrides(desc, &font, &mut is_bold, &mut is_italic); + + let style_str = desc.style_name(); + let style_lower = style_str.to_ascii_lowercase(); + + let exact_style = desired_styles + .first() + .map(|s| s.eq_ignore_ascii_case(&style_str)) + .unwrap_or(false); + + let mut diff: usize = style_str.len().min(255); + for s in desired_styles { + if style_lower.contains(&s.to_ascii_lowercase()) { + diff = diff.saturating_sub(s.len()); + } + } + let fuzzy_style = (255usize.saturating_sub(diff)).min(255) as u8; + + let glyph_count = (font.glyph_count() as u64).min(u16::MAX as u64) as u16; + + pack_score(ScoredCandidate { + glyph_count, + fuzzy_style, + bold: is_bold == want_bold, + italic: is_italic == want_italic, + exact_style, + monospace, + }) +} + +struct ScoredCandidate { + glyph_count: u16, + fuzzy_style: u8, + bold: bool, italic: bool, - stretch: Stretch, -) -> Option { - let family_cf = CFString::new(family); - let ct_weight = css_weight_to_ct(weight); - let ct_slant: f64 = if italic { 1.0 } else { 0.0 }; - let ct_width = stretch.as_ct_width(); - - let weight_key = unsafe { CFString::wrap_under_get_rule(kCTFontWeightTrait) }; - let slant_key = unsafe { CFString::wrap_under_get_rule(kCTFontSlantTrait) }; - let width_key = unsafe { CFString::wrap_under_get_rule(kCTFontWidthTrait) }; - let traits: CFDictionary = CFDictionary::from_CFType_pairs(&[ - (weight_key, CFNumber::from(ct_weight).as_CFType()), - (slant_key, CFNumber::from(ct_slant).as_CFType()), - (width_key, CFNumber::from(ct_width).as_CFType()), - ]); + exact_style: bool, + monospace: bool, +} - let family_key = unsafe { CFString::wrap_under_get_rule(kCTFontFamilyNameAttribute) }; - let traits_attr_key = - unsafe { CFString::wrap_under_get_rule(kCTFontTraitsAttribute) }; - let attrs: CFDictionary = CFDictionary::from_CFType_pairs(&[ - (family_key, family_cf.as_CFType()), - (traits_attr_key, traits.as_CFType()), - ]); +fn pack_score(s: ScoredCandidate) -> u64 { + (s.monospace as u64) << 27 + | (s.exact_style as u64) << 26 + | (s.italic as u64) << 25 + | (s.bold as u64) << 24 + | (s.fuzzy_style as u64) << 16 + | (s.glyph_count as u64) +} - let desc = font_descriptor::new_from_attributes(&attrs); +fn apply_head_table_traits(font: &CTFont, is_bold: &mut bool, is_italic: &mut bool) { + const HEAD_TAG: u32 = + (b'h' as u32) << 24 | (b'e' as u32) << 16 | (b'a' as u32) << 8 | (b'd' as u32); + let Some(data) = font.get_font_table(HEAD_TAG) else { + return; + }; + let bytes = data.bytes(); + if bytes.len() < 46 { + return; + } + let mac_style = u16::from_be_bytes([bytes[44], bytes[45]]); + if mac_style & 0x0001 != 0 { + *is_bold = true; + } + if mac_style & 0x0002 != 0 { + *is_italic = true; + } +} + +fn apply_os2_table_traits(font: &CTFont, is_bold: &mut bool, is_italic: &mut bool) { + const OS2_TAG: u32 = + (b'O' as u32) << 24 | (b'S' as u32) << 16 | (b'/' as u32) << 8 | (b'2' as u32); + let Some(data) = font.get_font_table(OS2_TAG) else { + return; + }; + let bytes = data.bytes(); + if bytes.len() < 64 { + return; + } + let fs_selection = u16::from_be_bytes([bytes[62], bytes[63]]); + if fs_selection & 0x0001 != 0 { + *is_italic = true; + } + if fs_selection & 0x0020 != 0 { + *is_bold = true; + } +} - let matched = unsafe { - let raw = CTFontDescriptorCreateMatchingFontDescriptor( +fn apply_variation_overrides( + desc: &CTFontDescriptor, + font: &CTFont, + is_bold: &mut bool, + is_italic: &mut bool, +) { + use core_foundation::base::CFType; + use core_foundation::dictionary::CFDictionary as CFDict; + use core_foundation::number::CFNumber; + + let var_value = unsafe { + CTFontDescriptorCopyAttribute( desc.as_concrete_TypeRef(), - std::ptr::null(), - ); - if raw.is_null() { - return None; - } - CTFontDescriptor::wrap_under_create_rule(raw) + kCTFontVariationAttribute, + ) + }; + if var_value.is_null() { + return; + } + let values_untyped: CFDict = + unsafe { CFDict::wrap_under_create_rule(var_value as _) }; + + let Some(axes) = font.get_variation_axes() else { + return; }; - matched.font_path() + let id_key = unsafe { kCTFontVariationAxisIdentifierKeyFFI }; + + const WGHT_TAG: i64 = + (b'w' as i64) << 24 | (b'g' as i64) << 16 | (b'h' as i64) << 8 | (b't' as i64); + const ITAL_TAG: i64 = + (b'i' as i64) << 24 | (b't' as i64) << 16 | (b'a' as i64) << 8 | (b'l' as i64); + const SLNT_TAG: i64 = + (b's' as i64) << 24 | (b'l' as i64) << 16 | (b'n' as i64) << 8 | (b't' as i64); + + let mut ital_seen = false; + for axis in axes.iter() { + let Some(id_item) = axis.find(id_key) else { + continue; + }; + let Some(id_num) = id_item.downcast::() else { + continue; + }; + let Some(tag) = id_num.to_i64() else { + continue; + }; + + let id_as_key: CFType = id_num.as_CFType(); + let val: f64 = match values_untyped.find(&id_as_key) { + Some(v) => match v.downcast::() { + Some(n) => n.to_f64().unwrap_or(0.0), + None => continue, + }, + None => continue, + }; + + match tag { + WGHT_TAG => *is_bold = val > 600.0, + ITAL_TAG => { + *is_italic = val > 0.5; + ital_seen = true; + } + SLNT_TAG if !ital_seen => *is_italic = val <= -5.0, + _ => {} + } + } +} + +#[link(name = "CoreText", kind = "framework")] +extern "C" { + #[link_name = "kCTFontVariationAxisIdentifierKey"] + static kCTFontVariationAxisIdentifierKeyFFI: core_foundation::string::CFStringRef; } /// System default cascade (fallback) font file paths for `handle`'s font. @@ -841,27 +995,23 @@ pub fn max_ascii_advance_px(handle: &FontHandle, size_px: f32) -> Option { #[derive(Debug, Clone, Copy)] pub struct FontAttributes { pub weight: u16, + pub is_bold: bool, pub is_italic: bool, pub is_monospace: bool, pub is_color: bool, } -/// Read `(weight, italic, monospace, color)` traits from a CTFont. -/// -/// `core-text`'s `TraitAccessors` / `SymbolicTraitAccessors` traits are -/// private to the crate, so rather than fight the SDK we read symbolic -/// traits as a raw `u32` bitfield (constants from Apple's CoreText.h). -/// Weight is left at the CSS default (`400`) — Rio only uses the weight -/// on fallback fonts to decide whether to synthesize bold, and cascade / -/// discovered fonts are overwhelmingly weight-neutral regulars anyway. pub fn font_attributes(handle: &FontHandle) -> FontAttributes { const K_CTFONT_TRAIT_ITALIC: u32 = 1 << 0; + const K_CTFONT_TRAIT_BOLD: u32 = 1 << 1; const K_CTFONT_TRAIT_MONOSPACE: u32 = 1 << 10; const K_CTFONT_TRAIT_COLOR_GLYPHS: u32 = 1 << 13; let traits: u32 = handle.base_font.symbolic_traits(); + let is_bold = (traits & K_CTFONT_TRAIT_BOLD) != 0; FontAttributes { - weight: 400, + weight: if is_bold { 700 } else { 400 }, + is_bold, is_italic: (traits & K_CTFONT_TRAIT_ITALIC) != 0, is_monospace: (traits & K_CTFONT_TRAIT_MONOSPACE) != 0, is_color: (traits & K_CTFONT_TRAIT_COLOR_GLYPHS) != 0, @@ -1525,8 +1675,8 @@ mod tests { #[test] fn find_font_path_resolves_system_family() { // Menlo ships on every macOS install since 10.6. - let path = find_font_path("Menlo", 400, false, Stretch::Normal) - .expect("Menlo should resolve"); + let path = + find_font_path("Menlo", false, false, None).expect("Menlo should resolve"); assert!(path.exists(), "resolved path should exist: {path:?}"); assert!( path.extension() diff --git a/sugarloaf/src/font/mod.rs b/sugarloaf/src/font/mod.rs index 0aa72da5..d381eb46 100644 --- a/sugarloaf/src/font/mod.rs +++ b/sugarloaf/src/font/mod.rs @@ -18,7 +18,7 @@ mod cjk_metrics_tests; pub const FONT_ID_REGULAR: usize = 0; use crate::font::constants::*; -use crate::font::fonts::{parse_unicode, SugarloafFontStyle, SugarloafFontWidth}; +use crate::font::fonts::{parse_unicode, FontStyle}; use crate::font::metrics::{FaceMetrics, Metrics}; use crate::layout::SpanStyle; use crate::SugarloafErrors; @@ -37,22 +37,44 @@ use swash::{tag_from_bytes, CacheKey, FontRef, Synthesis}; pub use swash::{Style, Weight}; +/// Which font face slot a spec is being resolved for. Drives bold/italic +/// trait selection (Ghostty-style), so the user's spec doesn't need to +/// carry a CSS weight number — the slot itself encodes intent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Slot { + Regular, + Bold, + Italic, + BoldItalic, +} + +impl Slot { + #[inline] + pub fn is_bold(self) -> bool { + matches!(self, Slot::Bold | Slot::BoldItalic) + } + #[inline] + pub fn is_italic(self) -> bool { + matches!(self, Slot::Italic | Slot::BoldItalic) + } +} + /// Cross-platform shim: non-macOS threads `&loader::Database` through to /// `find_font`; macOS drops it since CoreText handles matching directly and /// we never build a Database there. The macro lets call sites stay uniform -/// (`try_find_font!(&db, spec, evict)`) even though `db` doesn't exist on -/// macOS — macOS expansion simply discards that token. +/// (`try_find_font!(&db, spec, slot, evict)`) even though `db` doesn't exist +/// on macOS — macOS expansion simply discards that token. #[cfg(target_os = "macos")] macro_rules! try_find_font { - ($_db:expr, $spec:expr, $evictable:expr) => {{ - find_font($spec, $evictable) + ($_db:expr, $spec:expr, $slot:expr, $evictable:expr) => {{ + find_font($spec, $slot, $evictable) }}; } #[cfg(not(target_os = "macos"))] macro_rules! try_find_font { - ($db:expr, $spec:expr, $evictable:expr) => {{ - find_font($db, $spec, $evictable) + ($db:expr, $spec:expr, $slot:expr, $evictable:expr) => {{ + find_font($db, $spec, $slot, $evictable) }}; } @@ -730,7 +752,8 @@ impl FontLibraryData { db.load_fonts_dir(dir); } - match try_find_font!(&db, spec.regular, false) { + let regular_index = self.len(); + match try_find_font!(&db, spec.regular, Slot::Regular, false) { FindResult::Found(data) => { self.insert(data); } @@ -739,46 +762,34 @@ impl FontLibraryData { fonts_not_fount.push(spec.to_owned()); } - // The first font should always have a fallback - self.insert(load_fallback_from_memory(&spec)); + self.insert(load_fallback_from_memory(Slot::Regular)); } } - match try_find_font!(&db, spec.italic, false) { - FindResult::Found(data) => { - self.insert(data); - } - FindResult::NotFound(spec) => { - if !spec.is_default_family() { - fonts_not_fount.push(spec); - } else { - self.insert(load_fallback_from_memory(&spec)); + for (slot, slot_spec, evictable) in [ + (Slot::Italic, spec.italic, false), + (Slot::Bold, spec.bold, false), + (Slot::BoldItalic, spec.bold_italic, true), + ] { + if slot_spec.style.is_disabled() { + let reg = self.inner.get(®ular_index).cloned(); + match reg { + Some(data) => self.insert(data), + None => self.insert(load_fallback_from_memory(Slot::Regular)), } + continue; } - } - match try_find_font!(&db, spec.bold, false) { - FindResult::Found(data) => { - self.insert(data); - } - FindResult::NotFound(spec) => { - if !spec.is_default_family() { - fonts_not_fount.push(spec); - } else { - self.insert(load_fallback_from_memory(&spec)); + match try_find_font!(&db, slot_spec, slot, evictable) { + FindResult::Found(data) => { + self.insert(data); } - } - } - - match try_find_font!(&db, spec.bold_italic, true) { - FindResult::Found(data) => { - self.insert(data); - } - FindResult::NotFound(spec) => { - if !spec.is_default_family() { - fonts_not_fount.push(spec); - } else { - self.insert(load_fallback_from_memory(&spec)); + FindResult::NotFound(spec) => { + if !spec.is_default_family() { + fonts_not_fount.push(spec); + } else { + self.insert(load_fallback_from_memory(slot)); + } } } } @@ -806,7 +817,8 @@ impl FontLibraryData { if let Some(primary_handle) = primary_handle { let default_spec = SugarloafFont::default(); for path in crate::font::macos::default_cascade_list(&primary_handle) { - if let Ok(font_data) = FontData::from_path_macos(path, &default_spec) + if let Ok(font_data) = + FontData::from_path_macos(path, Slot::Regular, &default_spec) { self.insert(font_data); } @@ -838,6 +850,7 @@ impl FontLibraryData { family: extra_font_from_symbol_map.font_family, ..SugarloafFont::default() }, + Slot::Regular, true ) { FindResult::Found(data) => { @@ -1139,6 +1152,7 @@ impl FontData { data: SharedData, path: PathBuf, evictable: bool, + slot: Slot, font_spec: &SugarloafFont, ) -> Result> { let font = FontRef::from_index(&data, 0) @@ -1151,10 +1165,10 @@ impl FontData { let style = attributes.style(); let weight = attributes.weight(); + let synth_allowed = !matches!(font_spec.style, FontStyle::Named(_)); let should_italicize = - font_spec.style == SugarloafFontStyle::Italic && style != Style::Italic; - - let should_embolden = font_spec.weight >= Some(700) && weight < Weight(700); + synth_allowed && slot.is_italic() && style != Style::Italic; + let should_embolden = synth_allowed && slot.is_bold() && weight < Weight(600); let stretch = attributes.stretch(); let synth = attributes.synthesize(attributes); @@ -1236,6 +1250,7 @@ impl FontData { #[cfg(target_os = "macos")] pub fn from_path_macos( path: PathBuf, + slot: Slot, font_spec: &SugarloafFont, ) -> Result> { let handle = crate::font::macos::FontHandle::from_path(&path) @@ -1249,9 +1264,9 @@ impl FontData { }; let weight = swash::Weight(attrs.weight); - let should_italicize = - font_spec.style == SugarloafFontStyle::Italic && !attrs.is_italic; - let should_embolden = font_spec.weight >= Some(700) && attrs.weight < 700; + let synth_allowed = !matches!(font_spec.style, FontStyle::Named(_)); + let should_italicize = synth_allowed && slot.is_italic() && !attrs.is_italic; + let should_embolden = synth_allowed && slot.is_bold() && !attrs.is_bold; let postscript_name = Some(handle.postscript_name()); Ok(Self { @@ -1449,19 +1464,23 @@ enum FindResult { #[cfg(target_os = "macos")] #[inline] -fn find_font(font_spec: SugarloafFont, evictable: bool) -> FindResult { +fn find_font(font_spec: SugarloafFont, slot: Slot, evictable: bool) -> FindResult { if font_spec.is_default_family() { return FindResult::NotFound(font_spec); } let family = font_spec.family.to_string(); - let weight = font_spec.weight.unwrap_or(400); - let italic = font_spec.style == SugarloafFontStyle::Italic; - let stretch = map_stretch_macos(&font_spec.width); + let style_name = font_spec.style.name(); + let bold = slot.is_bold(); + let italic = slot.is_italic(); - info!("Font search (CoreText): family='{family}' weight={weight} italic={italic}"); + info!( + "Font search (CoreText): family='{family}' bold={bold} italic={italic} style={:?}", + style_name + ); - let Some(path) = crate::font::macos::find_font_path(&family, weight, italic, stretch) + let Some(path) = + crate::font::macos::find_font_path(&family, bold, italic, style_name) else { warn!("CoreText found no match for family='{family}'"); return FindResult::NotFound(font_spec); @@ -1471,7 +1490,7 @@ fn find_font(font_spec: SugarloafFont, evictable: bool) -> FindResult { // macOS path since `FontData.data` is always `None` here — there's // nothing to evict. let _ = evictable; - match FontData::from_path_macos(path.clone(), &font_spec) { + match FontData::from_path_macos(path.clone(), slot, &font_spec) { Ok(d) => { info!("Font '{family}' matched via CoreText at {}", path.display()); FindResult::Found(d) @@ -1483,27 +1502,12 @@ fn find_font(font_spec: SugarloafFont, evictable: bool) -> FindResult { } } -#[cfg(target_os = "macos")] -fn map_stretch_macos(width: &Option) -> crate::font::macos::Stretch { - use crate::font::macos::Stretch; - match width { - Some(SugarloafFontWidth::UltraCondensed) => Stretch::UltraCondensed, - Some(SugarloafFontWidth::ExtraCondensed) => Stretch::ExtraCondensed, - Some(SugarloafFontWidth::Condensed) => Stretch::Condensed, - Some(SugarloafFontWidth::SemiCondensed) => Stretch::SemiCondensed, - Some(SugarloafFontWidth::Normal) | None => Stretch::Normal, - Some(SugarloafFontWidth::SemiExpanded) => Stretch::SemiExpanded, - Some(SugarloafFontWidth::Expanded) => Stretch::Expanded, - Some(SugarloafFontWidth::ExtraExpanded) => Stretch::ExtraExpanded, - Some(SugarloafFontWidth::UltraExpanded) => Stretch::UltraExpanded, - } -} - #[cfg(all(not(target_os = "macos"), not(target_arch = "wasm32")))] #[inline] fn find_font( db: &crate::font::loader::Database, font_spec: SugarloafFont, + slot: Slot, evictable: bool, ) -> FindResult { if !font_spec.is_default_family() { @@ -1513,42 +1517,22 @@ fn find_font( ..crate::font::loader::Query::default() }; - if let Some(weight) = font_spec.weight { - query.weight = crate::font::loader::Weight(weight); - } - - if let Some(ref width) = font_spec.width { - query.stretch = match width { - SugarloafFontWidth::UltraCondensed => { - crate::font::loader::Stretch::UltraCondensed - } - SugarloafFontWidth::ExtraCondensed => { - crate::font::loader::Stretch::ExtraCondensed - } - SugarloafFontWidth::Condensed => crate::font::loader::Stretch::Condensed, - SugarloafFontWidth::SemiCondensed => { - crate::font::loader::Stretch::SemiCondensed - } - SugarloafFontWidth::Normal => crate::font::loader::Stretch::Normal, - SugarloafFontWidth::SemiExpanded => { - crate::font::loader::Stretch::SemiExpanded - } - SugarloafFontWidth::Expanded => crate::font::loader::Stretch::Expanded, - SugarloafFontWidth::ExtraExpanded => { - crate::font::loader::Stretch::ExtraExpanded - } - SugarloafFontWidth::UltraExpanded => { - crate::font::loader::Stretch::UltraExpanded - } - }; - } + query.weight = if slot.is_bold() { + crate::font::loader::Weight::BOLD + } else { + crate::font::loader::Weight::NORMAL + }; - query.style = match font_spec.style { - SugarloafFontStyle::Italic => crate::font::loader::Style::Italic, - _ => crate::font::loader::Style::Normal, + query.style = if slot.is_italic() { + crate::font::loader::Style::Italic + } else { + crate::font::loader::Style::Normal }; - info!("Font search: '{query:?}'"); + info!( + "Font search: '{query:?}' style_override={:?}", + font_spec.style.name() + ); match db.query(&query) { Some(id) => { @@ -1620,39 +1604,12 @@ fn find_font( FindResult::NotFound(font_spec) } -fn load_fallback_from_memory(font_spec: &SugarloafFont) -> FontData { - let style = &font_spec.style; - let weight = font_spec.weight.unwrap_or(400); - - let font_to_load = match (weight, style) { - (100, SugarloafFontStyle::Italic) => { - constants::FONT_CASCADIAMONO_EXTRA_LIGHT_ITALIC - } - (200, SugarloafFontStyle::Italic) => constants::FONT_CASCADIAMONO_LIGHT_ITALIC, - (300, SugarloafFontStyle::Italic) => { - constants::FONT_CASCADIAMONO_SEMI_LIGHT_ITALIC - } - (400, SugarloafFontStyle::Italic) => constants::FONT_CASCADIAMONO_ITALIC, - (500, SugarloafFontStyle::Italic) => constants::FONT_CASCADIAMONO_ITALIC, - (600, SugarloafFontStyle::Italic) => { - constants::FONT_CASCADIAMONO_SEMI_BOLD_ITALIC - } - (700, SugarloafFontStyle::Italic) => { - constants::FONT_CASCADIAMONO_SEMI_BOLD_ITALIC - } - (800, SugarloafFontStyle::Italic) => constants::FONT_CASCADIAMONO_BOLD_ITALIC, - (900, SugarloafFontStyle::Italic) => constants::FONT_CASCADIAMONO_BOLD_ITALIC, - (_, SugarloafFontStyle::Italic) => constants::FONT_CASCADIAMONO_ITALIC, - (100, _) => constants::FONT_CASCADIAMONO_EXTRA_LIGHT, - (200, _) => constants::FONT_CASCADIAMONO_LIGHT, - (300, _) => constants::FONT_CASCADIAMONO_SEMI_LIGHT, - (400, _) => constants::FONT_CASCADIAMONO_NF_REGULAR, - (500, _) => constants::FONT_CASCADIAMONO_NF_REGULAR, - (600, _) => constants::FONT_CASCADIAMONO_SEMI_BOLD, - (700, _) => constants::FONT_CASCADIAMONO_SEMI_BOLD, - (800, _) => constants::FONT_CASCADIAMONO_BOLD, - (900, _) => constants::FONT_CASCADIAMONO_BOLD, - (_, _) => constants::FONT_CASCADIAMONO_NF_REGULAR, +fn load_fallback_from_memory(slot: Slot) -> FontData { + let font_to_load = match slot { + Slot::Regular => constants::FONT_CASCADIAMONO_NF_REGULAR, + Slot::Bold => constants::FONT_CASCADIAMONO_BOLD, + Slot::Italic => constants::FONT_CASCADIAMONO_ITALIC, + Slot::BoldItalic => constants::FONT_CASCADIAMONO_BOLD_ITALIC, }; FontData::from_static_slice(font_to_load).unwrap() diff --git a/sugarloaf/src/text.rs b/sugarloaf/src/text.rs index 1f5d4ae7..37dca8a7 100644 --- a/sugarloaf/src/text.rs +++ b/sugarloaf/src/text.rs @@ -1256,9 +1256,14 @@ fn rasterize_swash_glyph( Source::ColorBitmap(StrikeWith::BestFit), Source::Outline, ]; + let embolden_amount = if synthetic_bold { + (size_px / 14.0).max(1.0) + } else { + 0.0 + }; let rendered = Render::new(sources) .format(Format::Alpha) - .embolden(if synthetic_bold { 0.5 } else { 0.0 }) + .embolden(embolden_amount) .transform(if synthetic_italic { Some(Transform::skew( Angle::from_degrees(14.0), -- 2.51.2