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 @@ + +