From 00e0676e5e9354aa4ae135bb24927d44e68f59ac Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 14 Sep 2026 21:18:17 +0000 Subject: [PATCH] gpui: Support dynamic font installation (#63498) Browser applications cannot rely on operating-system font discovery, but embedding comprehensive emoji and CJK fonts makes the initial WebAssembly payload prohibitively large. GPUI previously offered no structured way to learn that fallback was exhausted, and adding a font at runtime left descriptor and line-layout caches shaped against the old font database. This change lets WGPU text systems report unresolved grapheme clusters through `App::on_missing_glyphs`. GPUI invokes one replaceable application callback after shaping, carrying each complete grapheme and whether its source run is proportional or monospace. Reports are deduplicated and delivered through a bounded, nonblocking channel; reporting remains disabled until the callback is installed, and queued reports from an earlier font generation are discarded. Successful font installation now advances a font generation. GPUI clears descriptor and platform family resolution, reuses stable font IDs for unchanged faces while refreshing their fallback chains, and invalidates each window's current and previous line-layout caches when it observes the new generation. This supplies the GPUI half of an on-demand web font loader; fetching and selecting font assets remains application-owned. Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- crates/gpui/examples/README.md | 21 ++ crates/gpui/src/app.rs | 174 +++++++++++- crates/gpui/src/app/test_context.rs | 47 ++- crates/gpui/src/platform.rs | 9 +- crates/gpui/src/text_system.rs | 267 +++++++++++++++++- crates/gpui/src/text_system/line_layout.rs | 57 +++- crates/gpui_web/examples/hello_web/Cargo.toml | 11 + .../examples/hello_web/dynamic_fonts.rs | 130 +++++++++ .../examples/hello_web/font_fallback_test.rs | 64 +++++ crates/gpui_web/examples/hello_web/index.html | 3 + .../hello_web/test_canvas_fallback.mjs | 8 +- crates/gpui_web/src/text_system.rs | 24 +- crates/gpui_wgpu/Cargo.toml | 1 + crates/gpui_wgpu/src/cosmic_text_system.rs | 242 +++++++++++++++- 14 files changed, 1026 insertions(+), 32 deletions(-) create mode 100644 crates/gpui_web/examples/hello_web/dynamic_fonts.rs create mode 100644 crates/gpui_web/examples/hello_web/font_fallback_test.rs diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index 396fc32296..f4a017cda0 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -24,6 +24,7 @@ Open to choose an example: | `/text-layout` | Alignment and decorations | | `/text-wrapper` | Wrapping and truncation | | `/input` | Text input and selection | +| `/dynamic-fonts` | Download a font on missing-glyph notifications | | `/prime-sieve` | Background task demo | The server supports direct links and reloads at each path. Only the selected @@ -41,6 +42,26 @@ sources. To add another browser-compatible example, register its binary in the gallery's `Cargo.toml`, add an auxiliary Rust asset in `index.html`, and add its gallery link with the corresponding `data-module` name. +### Dynamic font loading + +Open `/dynamic-fonts` to see `App::on_missing_glyphs` fetch a versioned +[Noto Sans Devanagari TTF](https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGoUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv3NaV_SNmI0b8QQCQmHn6B2OHjbL_08AlXQly-A.ttf) +from Google Fonts' CDN. The 219 KB font is licensed under the +[SIL Open Font License](https://github.com/google/fonts/blob/main/ofl/notosansdevanagari/OFL.txt). +The CDN permits cross-origin requests and caches the font for one year. +No sample text is sent to the host. + +The example bundles only IBM Plex Sans, downloads the fallback once on demand, +calls `add_fonts`, and refreshes the windows to reshape the text. Emoji continues +to use the default Canvas fallback. The page displays loading and failure states; +use browser network throttling or block `fonts.gstatic.com` and reload to +exercise them. Reload to retry a failed request. + +This is a small script-to-font mapping, not a general font discovery service. +For production, host versioned font assets on your own CDN and choose an explicit +coverage, caching, and retry policy. Use raw TTF/OTF assets rather than passing +Google Fonts CSS or WOFF2 responses directly to `add_fonts`. + ### Canvas font fallback The web text system uses browser fonts for eligible missing CJK and emoji diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 1d7e2f385c..b0b83862d9 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -45,13 +45,13 @@ use crate::{ AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, ClipboardReadError, CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, - LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, - PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, - ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, - SystemNotification, SystemNotificationResponse, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, - WindowInvalidator, + LayoutId, Menu, MenuItem, MissingGlyph, OwnedMenu, PathPromptOptions, Pixels, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, + PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, + RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, + Subscription, SvgRenderer, SystemNotification, SystemNotificationResponse, Task, + TextRenderingMode, TextSystem, ThermalState, Window, WindowAppearance, WindowButtonLayout, + WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -316,6 +316,7 @@ impl Application { type Handler = Box bool + 'static>; type Listener = Box bool + 'static>; +type MissingGlyphCallback = Box; pub(crate) type KeystrokeObserver = Box bool + 'static>; type QuitHandler = Box LocalBoxFuture<'static, ()> + 'static>; @@ -323,6 +324,63 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +struct MissingGlyphCallbackEntry { + registration: Rc<()>, + callback: Option, +} + +#[derive(Default)] +struct MissingGlyphCallbackSlot { + entry: RefCell>, +} + +impl MissingGlyphCallbackSlot { + fn replace(&self, callback: MissingGlyphCallback) -> Rc<()> { + let registration = Rc::new(()); + self.entry.borrow_mut().replace(MissingGlyphCallbackEntry { + registration: registration.clone(), + callback: Some(callback), + }); + registration + } + + fn invoke(&self, missing_glyphs: &[MissingGlyph], cx: &mut App) { + let Some((registration, mut callback)) = + self.entry.borrow_mut().as_mut().and_then(|entry| { + entry + .callback + .take() + .map(|callback| (entry.registration.clone(), callback)) + }) + else { + return; + }; + callback(missing_glyphs, cx); + + let mut entry = self.entry.borrow_mut(); + let is_current = entry + .as_ref() + .is_some_and(|entry| Rc::ptr_eq(&entry.registration, ®istration)); + if is_current { + let Some(entry) = entry.as_mut() else { + return; + }; + entry.callback = Some(callback); + } + } + + fn remove(&self, registration: &Rc<()>) -> bool { + let mut entry = self.entry.borrow_mut(); + let is_current = entry + .as_ref() + .is_some_and(|entry| Rc::ptr_eq(&entry.registration, registration)); + if is_current { + entry.take(); + } + is_current + } +} + /// Defines when the application should automatically quit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum QuitMode { @@ -712,6 +770,7 @@ pub struct App { pub(crate) keystroke_observers: SubscriberSet<(), KeystrokeObserver>, pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, + missing_glyph_callback: Rc, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, pub(crate) system_sleep_observers: SubscriberSet<(), Handler>, pub(crate) system_wake_observers: SubscriberSet<(), Handler>, @@ -847,6 +906,7 @@ impl App { keystroke_observers: SubscriberSet::new(), keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), + missing_glyph_callback: Rc::default(), thermal_state_observers: SubscriberSet::new(), system_sleep_observers: SubscriberSet::new(), system_wake_observers: SubscriberSet::new(), @@ -2057,6 +2117,38 @@ impl App { &self.text_system } + /// Invokes a callback with grapheme clusters that exhausted font fallback. + /// + /// Registering a callback replaces the previous callback and enables missing-glyph + /// detection. The callback runs on the foreground executor after shaping has + /// released its internal locks. Dropping its subscription disables detection until + /// another callback is registered. + pub fn on_missing_glyphs( + &self, + callback: impl FnMut(&[MissingGlyph], &mut App) + 'static, + ) -> Subscription { + let registration = self.missing_glyph_callback.replace(Box::new(callback)); + + if let Some(receiver) = self.text_system.take_missing_glyph_receiver() { + let callback = self.missing_glyph_callback.clone(); + self.spawn(async move |cx| { + while let Ok(missing_glyphs) = receiver.recv().await { + cx.update(|cx| callback.invoke(&missing_glyphs, cx)); + } + }) + .detach(); + } + self.text_system.enable_missing_glyph_reporting(); + + let callback = self.missing_glyph_callback.clone(); + let text_system = self.text_system.clone(); + Subscription::new(move || { + if callback.remove(®istration) { + text_system.disable_missing_glyph_reporting(); + } + }) + } + /// Check whether a global of the given type has been assigned. pub fn has_global(&self) -> bool { self.globals_by_type.contains_key(&TypeId::of::()) @@ -3150,7 +3242,10 @@ mod test { #[cfg(unix)] use std::os::unix::ffi::OsStringExt; - use crate::{AppContext, Context, Empty, IntoElement, Render, TestAppContext, Window}; + use crate::{ + AppContext, Context, Empty, FallbackFontClass, IntoElement, MissingGlyph, Render, + TestAppContext, Window, + }; struct RenderCounter(Rc>); @@ -3178,6 +3273,65 @@ mod test { assert_eq!(render_count.get(), render_count_before_refresh + 1); } + #[gpui::test] + fn missing_glyph_callbacks_follow_subscription_lifetime(cx: &mut TestAppContext) { + let observed = Rc::new(RefCell::new(Vec::new())); + let subscription = cx.update(|cx| { + let observed = observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + observed.borrow_mut().extend_from_slice(missing_glyphs); + }) + }); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("active")]); + }); + cx.run_until_parked(); + assert_eq!(observed.borrow().as_slice(), &[missing_glyph("active")]); + + let second_observed = Rc::new(RefCell::new(Vec::new())); + let second_subscription = cx.update(|cx| { + let second_observed = second_observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + second_observed + .borrow_mut() + .extend_from_slice(missing_glyphs); + }) + }); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("replacement")]); + }); + cx.run_until_parked(); + assert_eq!(observed.borrow().as_slice(), &[missing_glyph("active")]); + assert_eq!( + second_observed.borrow().as_slice(), + &[missing_glyph("replacement")] + ); + + drop(subscription); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("after old drop")]); + }); + cx.run_until_parked(); + assert_eq!( + second_observed.borrow().as_slice(), + &[ + missing_glyph("replacement"), + missing_glyph("after old drop") + ] + ); + + drop(second_subscription); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("inactive")]); + }); + cx.run_until_parked(); + assert_eq!(second_observed.borrow().len(), 2); + } + #[test] fn test_gpui_borrow() { let cx = TestAppContext::single(); @@ -3230,4 +3384,8 @@ mod test { assert_eq!(path, Some(restart_path)); assert_eq!(restart_arguments, arguments); } + + fn missing_glyph(grapheme: &'static str) -> MissingGlyph { + MissingGlyph::new(grapheme.into(), FallbackFontClass::Proportional) + } } diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index fd4caf9cc3..b2b8179e8f 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -3,10 +3,10 @@ use crate::{ BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable, Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification, - SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, - TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - WindowVisibility, app::GpuiMode, window::ElementArenaScope, + Pixels, Platform, PlatformTextSystem, Point, Render, Result, SharedString, Size, + SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, + TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, + WindowHandle, WindowOptions, WindowVisibility, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -129,6 +129,45 @@ impl TestAppContext { let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(arc_dispatcher); let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); + Self::build_with_platform( + dispatcher, + fn_name, + background_executor, + foreground_executor, + platform, + ) + } + + /// Creates a test context backed by the provided platform text system. + pub fn build_with_text_system( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + platform_text_system: Arc, + ) -> Self { + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + let platform = TestPlatform::with_text_system( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system, + ); + Self::build_with_platform( + dispatcher, + fn_name, + background_executor, + foreground_executor, + platform, + ) + } + + fn build_with_platform( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + platform: Rc, + ) -> Self { let asset_source = Arc::new(()); let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 2c7fbb0bc6..f3d3c05499 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -38,9 +38,10 @@ use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, ExternalDragPayload, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, - LineLayout, Pixels, PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, - RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, - Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, + LineLayout, MissingGlyphSink, Pixels, PlatformGestures, PlatformInput, Point, Priority, + RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, + ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, + hash, point, px, size, }; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use anyhow::bail; @@ -1172,6 +1173,8 @@ pub trait PlatformDispatcher: Send + Sync { #[expect(missing_docs)] pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; + /// Installs a nonblocking sink for unresolved grapheme clusters. + fn set_missing_glyph_sink(&self, _sink: Option>) {} /// Get all available font names. fn all_font_names(&self) -> Vec; /// Get the font ID for a font descriptor. diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index de4a795d20..bb42fd8d0f 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -17,7 +17,7 @@ use crate::{ StrikethroughStyle, TextRenderingMode, UnderlineStyle, px, }; use anyhow::{Context as _, anyhow}; -use collections::FxHashMap; +use collections::{FxHashMap, FxHashSet}; use core::fmt; use derive_more::{Add, Deref, FromStr, Sub}; use itertools::Itertools; @@ -26,10 +26,14 @@ use smallvec::{SmallVec, smallvec}; use std::{ borrow::Cow, cmp, + collections::VecDeque, fmt::{Debug, Display, Formatter}, hash::{Hash, Hasher}, ops::{Deref, DerefMut, Range}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; /// An opaque identifier for a specific font. @@ -47,6 +51,158 @@ pub const SUBPIXEL_VARIANTS_X: u8 = 4; /// Number of subpixel glyph variants along the Y axis. pub const SUBPIXEL_VARIANTS_Y: u8 = 1; +const MAX_REPORTED_MISSING_GLYPHS: usize = 1024; + +/// The spacing behavior required of a fallback font. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum FallbackFontClass { + /// A proportionally spaced fallback font. + Proportional, + /// A fixed-width fallback font. + Monospace, +} + +/// A grapheme cluster that could not be represented by any available font. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MissingGlyph { + grapheme: SharedString, + font_class: FallbackFontClass, +} + +impl MissingGlyph { + /// Creates a missing-glyph report. + pub fn new(grapheme: SharedString, font_class: FallbackFontClass) -> Self { + Self { + grapheme, + font_class, + } + } + + /// Returns the unresolved grapheme cluster. + pub fn grapheme(&self) -> &str { + &self.grapheme + } + + /// Returns the spacing behavior required of a fallback font. + pub fn font_class(&self) -> FallbackFontClass { + self.font_class + } +} + +/// Accepts missing glyphs detected by a platform text system. +pub trait MissingGlyphSink: Send + Sync { + /// Reports grapheme clusters that exhausted font fallback. + fn report(&self, missing_glyphs: Vec); +} + +#[derive(Default)] +struct MissingGlyphState { + reported: FxHashSet, + reported_order: VecDeque, + generation: usize, +} + +struct QueuedMissingGlyph { + generation: usize, + missing_glyph: MissingGlyph, +} + +/// Collects missing-glyph reports without invoking application code during layout. +struct MissingGlyphReporter { + state: Arc>, + sender: async_channel::Sender, +} + +impl MissingGlyphSink for MissingGlyphReporter { + fn report(&self, missing_glyphs: Vec) { + if self.sender.is_closed() { + return; + } + + let mut state = self.state.lock(); + for missing_glyph in missing_glyphs { + if state.reported.contains(&missing_glyph) { + continue; + } + let queued = QueuedMissingGlyph { + generation: state.generation, + missing_glyph: missing_glyph.clone(), + }; + match self.sender.try_send(queued) { + Ok(()) => { + state.reported.insert(missing_glyph.clone()); + state.reported_order.push_back(missing_glyph); + while state.reported.len() > MAX_REPORTED_MISSING_GLYPHS { + if let Some(expired) = state.reported_order.pop_front() { + state.reported.remove(&expired); + } + } + } + Err(async_channel::TrySendError::Full(_)) => break, + Err(async_channel::TrySendError::Closed(_)) => { + state.reported.clear(); + state.reported_order.clear(); + return; + } + } + } + } +} + +impl MissingGlyphReporter { + fn reset(&self) { + let mut state = self.state.lock(); + state.reported.clear(); + state.reported_order.clear(); + state.generation = state.generation.wrapping_add(1); + } +} + +/// Receives batches of grapheme clusters that exhausted font fallback. +pub(crate) struct MissingGlyphReceiver { + state: Arc>, + receiver: async_channel::Receiver, +} + +impl MissingGlyphReceiver { + /// Waits until at least one new missing glyph has been observed. + /// + /// # Errors + /// + /// Returns [`async_channel::RecvError`] if the reporting channel is closed. + pub(crate) async fn recv( + &self, + ) -> std::result::Result, async_channel::RecvError> { + loop { + let queued = self.receiver.recv().await?; + let mut missing_glyphs = Vec::new(); + if queued.generation == self.state.lock().generation { + missing_glyphs.push(queued.missing_glyph); + } + while missing_glyphs.len() < MAX_REPORTED_MISSING_GLYPHS { + let Ok(queued) = self.receiver.try_recv() else { + break; + }; + if queued.generation == self.state.lock().generation { + missing_glyphs.push(queued.missing_glyph); + } + } + if !missing_glyphs.is_empty() { + return Ok(missing_glyphs); + } + } + } +} + +impl Drop for MissingGlyphReceiver { + fn drop(&mut self) { + while self.receiver.try_recv().is_ok() {} + let mut state = self.state.lock(); + state.reported.clear(); + state.reported_order.clear(); + } +} + /// The GPUI text rendering sub system. pub struct TextSystem { platform_text_system: Arc, @@ -56,11 +212,16 @@ pub struct TextSystem { wrapper_pool: Mutex>>, font_runs_pool: Mutex>>, fallback_font_stack: SmallVec<[Font; 2]>, + font_generation: Arc, + missing_glyph_reporter: Arc, + missing_glyph_receiver: Mutex>, } impl TextSystem { /// Create a new TextSystem with the given platform text system. pub fn new(platform_text_system: Arc) -> Self { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let missing_glyph_state = Arc::>::default(); TextSystem { platform_text_system, font_metrics: RwLock::default(), @@ -81,6 +242,15 @@ impl TextSystem { font("DejaVu Sans"), font("Arial"), // macOS, Windows ], + font_generation: Arc::default(), + missing_glyph_reporter: Arc::new(MissingGlyphReporter { + state: missing_glyph_state.clone(), + sender, + }), + missing_glyph_receiver: Mutex::new(Some(MissingGlyphReceiver { + state: missing_glyph_state, + receiver, + })), } } @@ -95,8 +265,38 @@ impl TextSystem { } /// Add a font's data to the text system. + /// + /// Cached font resolution and line layouts are invalidated after installation. + /// Layouts already in progress may complete against the previous font set. pub fn add_fonts(&self, fonts: Vec>) -> Result<()> { - self.platform_text_system.add_fonts(fonts) + self.platform_text_system.add_fonts(fonts)?; + self.font_ids_by_font.write().clear(); + self.missing_glyph_reporter.reset(); + self.font_generation.fetch_add(1, Ordering::Release); + Ok(()) + } + + /// Takes the receiver for missing-glyph reports. + /// + /// Only one receiver is available for each text system. Returns `None` when + /// the receiver was already taken. + pub(crate) fn take_missing_glyph_receiver(&self) -> Option { + self.missing_glyph_receiver.lock().take() + } + + pub(crate) fn enable_missing_glyph_reporting(&self) { + self.platform_text_system + .set_missing_glyph_sink(Some(self.missing_glyph_reporter.clone())); + } + + pub(crate) fn disable_missing_glyph_reporting(&self) { + self.platform_text_system.set_missing_glyph_sink(None); + self.missing_glyph_reporter.reset(); + } + + #[cfg(test)] + pub(crate) fn report_missing_glyphs_in_test(&self, missing_glyphs: Vec) { + self.missing_glyph_reporter.report(missing_glyphs); } /// Get the FontId for the configure font family and style. @@ -383,7 +583,10 @@ impl WindowTextSystem { /// Create a new WindowTextSystem with the given TextSystem. pub fn new(text_system: Arc) -> Self { Self { - line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()), + line_layout_cache: LineLayoutCache::new( + text_system.platform_text_system.clone(), + text_system.font_generation.clone(), + ), text_system, } } @@ -1216,3 +1419,59 @@ pub fn font_name_with_fallbacks_shared<'a>( _ => name, } } + +#[cfg(test)] +mod missing_glyph_tests { + use super::*; + + #[test] + fn bounds_retained_missing_glyphs() { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let state = Arc::>::default(); + let reporter = MissingGlyphReporter { + state: state.clone(), + sender, + }; + reporter.report( + (0..MAX_REPORTED_MISSING_GLYPHS) + .map(|index| { + MissingGlyph::new(index.to_string().into(), FallbackFontClass::Proportional) + }) + .collect(), + ); + assert!(receiver.try_recv().is_ok()); + + let newest = MissingGlyph::new("newest".into(), FallbackFontClass::Monospace); + reporter.report(vec![newest.clone()]); + + let state = state.lock(); + assert_eq!(state.reported.len(), MAX_REPORTED_MISSING_GLYPHS); + assert_eq!(state.reported_order.len(), MAX_REPORTED_MISSING_GLYPHS); + assert!(state.reported.contains(&newest)); + } + + #[test] + fn dropping_receiver_closes_and_clears_reports() { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let state = Arc::>::default(); + let reporter = MissingGlyphReporter { + state: state.clone(), + sender, + }; + let receiver = MissingGlyphReceiver { + state: state.clone(), + receiver, + }; + reporter.report(vec![MissingGlyph::new( + "missing".into(), + FallbackFontClass::Proportional, + )]); + + drop(receiver); + + assert!(reporter.sender.is_closed()); + let state = state.lock(); + assert!(state.reported.is_empty()); + assert!(state.reported_order.is_empty()); + } +} diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index e7362f71e5..7799b36dc8 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -6,7 +6,10 @@ use std::{ borrow::Borrow, hash::{Hash, Hasher}, ops::Range, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; use super::LineWrapper; @@ -455,6 +458,10 @@ pub(crate) struct LineLayoutCache { previous_frame: Mutex, current_frame: RwLock, platform_text_system: Arc, + /// Advances when [`TextSystem::add_fonts`] successfully changes the font database. + font_generation: Arc, + /// Records the generation represented by both frame caches. + cached_font_generation: AtomicUsize, } #[derive(Default)] @@ -478,6 +485,7 @@ struct FrameCache { #[derive(Clone, Default)] pub(crate) struct LineLayoutIndex { + font_generation: usize, lines_index: usize, wrapped_lines_index: usize, lines_by_hash_index: usize, @@ -485,17 +493,25 @@ pub(crate) struct LineLayoutIndex { } impl LineLayoutCache { - pub fn new(platform_text_system: Arc) -> Self { + pub fn new( + platform_text_system: Arc, + font_generation: Arc, + ) -> Self { + let cached_font_generation = font_generation.load(Ordering::Acquire); Self { previous_frame: Mutex::default(), current_frame: RwLock::default(), platform_text_system, + font_generation, + cached_font_generation: AtomicUsize::new(cached_font_generation), } } pub fn layout_index(&self) -> LineLayoutIndex { + let font_generation = self.clear_if_font_generation_changed(); let frame = self.current_frame.read(); LineLayoutIndex { + font_generation, lines_index: frame.used_lines.len(), wrapped_lines_index: frame.used_wrapped_lines.len(), lines_by_hash_index: frame.used_lines_by_hash.len(), @@ -504,8 +520,14 @@ impl LineLayoutCache { } pub fn reuse_layouts(&self, range: Range) { - let mut previous_frame = &mut *self.previous_frame.lock(); + let font_generation = self.clear_if_font_generation_changed(); + if range.start.font_generation != font_generation + || range.end.font_generation != font_generation + { + return; + } let mut current_frame = &mut *self.current_frame.write(); + let mut previous_frame = &mut *self.previous_frame.lock(); for key in &previous_frame.used_lines[range.start.lines_index..range.end.lines_index] { if let Some((key, line)) = previous_frame.lines.remove_entry(key) { @@ -543,6 +565,10 @@ impl LineLayoutCache { } pub fn truncate_layouts(&self, index: LineLayoutIndex) { + let font_generation = self.clear_if_font_generation_changed(); + if index.font_generation != font_generation { + return; + } let mut current_frame = &mut *self.current_frame.write(); current_frame.used_lines.truncate(index.lines_index); current_frame @@ -557,8 +583,9 @@ impl LineLayoutCache { } pub fn finish_frame(&self) { - let mut prev_frame = self.previous_frame.lock(); + let _font_generation = self.clear_if_font_generation_changed(); let mut curr_frame = self.current_frame.write(); + let mut prev_frame = self.previous_frame.lock(); std::mem::swap(&mut *prev_frame, &mut *curr_frame); curr_frame.lines.clear(); curr_frame.wrapped_lines.clear(); @@ -583,6 +610,7 @@ impl LineLayoutCache { Text: AsRef, SharedString: From, { + let _font_generation = self.clear_if_font_generation_changed(); let key = &CacheKeyRef { text: text.as_ref(), font_size, @@ -647,6 +675,7 @@ impl LineLayoutCache { Text: AsRef, SharedString: From, { + let _font_generation = self.clear_if_font_generation_changed(); let key = &CacheKeyRef { text: text.as_ref(), font_size, @@ -705,6 +734,7 @@ impl LineLayoutCache { runs: &[FontRun], force_width: Option, ) -> Option> { + let _font_generation = self.clear_if_font_generation_changed(); let key_ref = HashedCacheKeyRef { text_hash, text_len, @@ -762,6 +792,7 @@ impl LineLayoutCache { force_width: Option, materialize_text: impl FnOnce() -> SharedString, ) -> Arc { + let _font_generation = self.clear_if_font_generation_changed(); let key_ref = HashedCacheKeyRef { text_hash, text_len, @@ -839,6 +870,24 @@ impl LineLayoutCache { current_frame.used_lines_by_hash.push(key); layout } + + fn clear_if_font_generation_changed(&self) -> usize { + let font_generation = self.font_generation.load(Ordering::Acquire); + if self.cached_font_generation.load(Ordering::Acquire) == font_generation { + return font_generation; + } + + let mut current_frame = self.current_frame.write(); + if self.cached_font_generation.load(Ordering::Acquire) == font_generation { + return font_generation; + } + + *current_frame = FrameCache::default(); + *self.previous_frame.lock() = FrameCache::default(); + self.cached_font_generation + .store(font_generation, Ordering::Release); + font_generation + } } // Combining marks (e.g. Thai vowel signs, Arabic diacritics) are shaped by diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml index f6f96de0c0..20826a799f 100644 --- a/crates/gpui_web/examples/hello_web/Cargo.toml +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -30,9 +30,20 @@ path = "../../../gpui/examples/text_wrapper.rs" name = "input" path = "../../../gpui/examples/input.rs" +[[bin]] +name = "font_fallback_test" +path = "font_fallback_test.rs" + +[[bin]] +name = "dynamic_fonts" +path = "dynamic_fonts.rs" + [dependencies] +anyhow = "1" +futures = "0.3" gpui = { path = "../../../gpui" } gpui_platform = { path = "../../../gpui_platform" } +gpui_util = { path = "../../../gpui_util" } unicode-segmentation = "1" wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["console", "Location", "Window"] } diff --git a/crates/gpui_web/examples/hello_web/dynamic_fonts.rs b/crates/gpui_web/examples/hello_web/dynamic_fonts.rs new file mode 100644 index 0000000000..33d4d6cc17 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/dynamic_fonts.rs @@ -0,0 +1,130 @@ +use anyhow::{Result, ensure}; +use futures::AsyncReadExt; +use gpui::{ + App, Bounds, Context, Render, Subscription, Task, Window, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, +}; +use gpui_util::ResultExt; +use std::borrow::Cow; + +// Pin the raw TTF rather than using Google Fonts CSS, which may supply WOFF2 +// subsets that cannot be passed directly to Cosmic Text. +const FONT_URL: &str = "https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGoUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv3NaV_SNmI0b8QQCQmHn6B2OHjbL_08AlXQly-A.ttf"; +const SAMPLE: &str = "नमस्ते 😀 दुनिया 👨‍👩‍👧‍👦 नमस्ते ❤️"; + +struct DynamicFonts { + status: String, + download: Option>, + _missing_glyphs: Subscription, +} + +impl DynamicFonts { + fn new(cx: &mut Context) -> Self { + let view = cx.weak_entity(); + let subscription = cx.on_missing_glyphs(move |missing, cx| { + if missing.iter().any(|glyph| { + glyph + .grapheme() + .chars() + .any(|character| ('\u{0900}'..='\u{097f}').contains(&character)) + }) { + view.update(cx, |view, cx| view.load_font(cx)).log_err(); + } + }); + Self { + status: "Waiting for a missing Devanagari glyph…".into(), + download: None, + _missing_glyphs: subscription, + } + } + + fn load_font(&mut self, cx: &mut Context) { + // Many missing graphemes can request the same font, including after a failed + // download. Keep the task so this demo makes only one attempt per page load. + if self.download.is_some() { + return; + } + self.status = "Downloading Noto Sans Devanagari (219 KB)…".into(); + cx.notify(); + let http_client = cx.http_client(); + self.download = Some(cx.spawn(async move |view, cx| { + let result: Result> = async { + let mut response = http_client.get(FONT_URL, Default::default(), true).await?; + ensure!( + response.status().is_success(), + "Font request failed: HTTP {}", + response.status() + ); + let mut bytes = Vec::new(); + response.body_mut().read_to_end(&mut bytes).await?; + ensure!(!bytes.is_empty(), "Font response was empty"); + Ok(bytes) + } + .await; + view.update(cx, |view, cx| { + let result = + result.and_then(|bytes| cx.text_system().add_fonts(vec![Cow::Owned(bytes)])); + view.status = match result { + Ok(()) => "Font loaded. The same text is now shaped by Cosmic Text.".into(), + Err(error) => format!("Font loading failed: {error:#}. Reload to retry."), + }; + cx.refresh_windows(); + cx.notify(); + }) + .log_err(); + })); + } +} + +impl Render for DynamicFonts { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_4() + .p_8() + .bg(rgb(0x202030)) + .text_color(rgb(0xffffff)) + .font_family("IBM Plex Sans") + .child(div().text_2xl().child("On-demand font loading")) + .child("Only IBM Plex Sans is bundled. Missing Devanagari triggers the font download.") + .child(div().text_size(px(40.)).child(SAMPLE)) + .child( + div() + .text_size(px(32.)) + .child("Emoji still uses Canvas: 😀 👨‍👩‍👧‍👦"), + ) + .child(self.status.clone()) + .child("Source: Google Fonts CDN · Noto Sans Devanagari · SIL Open Font License") + .child( + "Use the browser Network panel to throttle or block the font request, then reload.", + ) + } +} + +fn main() { + gpui_platform::web_init(); + gpui_platform::application().run(|cx: &mut App| { + if cx + .text_system() + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" + ))]) + .log_err() + .is_none() + { + return; + } + let bounds = Bounds::centered(None, size(px(900.), px(500.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(DynamicFonts::new), + ) + .log_err(); + cx.activate(true); + }); +} diff --git a/crates/gpui_web/examples/hello_web/font_fallback_test.rs b/crates/gpui_web/examples/hello_web/font_fallback_test.rs new file mode 100644 index 0000000000..2d62b88be8 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/font_fallback_test.rs @@ -0,0 +1,64 @@ +use gpui::{FallbackFontClass, FontRun, MissingGlyph, MissingGlyphSink, font, px}; +use std::{ + borrow::Cow, + sync::{Arc, Mutex}, +}; +use wasm_bindgen::prelude::*; + +fn main() {} + +#[derive(Default)] +struct Reports(Mutex>); + +impl MissingGlyphSink for Reports { + fn report(&self, missing: Vec) { + self.0.lock().expect("report lock").extend(missing); + } +} + +#[wasm_bindgen] +pub fn test_missing_glyph_notifications() -> Result<(), JsValue> { + let platform = gpui_platform::current_platform(false); + let text_system = platform.text_system(); + text_system + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/lilex/Lilex-Regular.ttf" + ))]) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let font_id = text_system + .font_id(&font("Lilex")) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let reports = Arc::new(Reports::default()); + text_system.set_missing_glyph_sink(Some(reports.clone())); + let text = "A界😀👨‍👩‍👧‍👦か\u{3099}"; + let runs = [FontRun { + len: text.len(), + font_id, + }]; + let layout = text_system.layout_line(text, px(24.), &runs); + assert!( + layout + .runs + .iter() + .any(|run| run.glyphs.iter().any(|glyph| glyph.is_emoji)) + ); + let missing = reports.0.lock().expect("report lock").clone(); + assert_eq!( + missing + .iter() + .map(|report| report.grapheme()) + .collect::>(), + ["界", "か\u{3099}"], + ); + assert!( + missing + .iter() + .all(|report| report.font_class() == FallbackFontClass::Monospace) + ); + + reports.0.lock().expect("report lock").clear(); + text_system.set_missing_glyph_sink(None); + text_system.layout_line(text, px(24.), &runs); + assert!(reports.0.lock().expect("report lock").is_empty()); + Ok(()) +} diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html index c0f9060f5b..1ae203395c 100644 --- a/crates/gpui_web/examples/hello_web/index.html +++ b/crates/gpui_web/examples/hello_web/index.html @@ -13,6 +13,8 @@ + +