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