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 <maxbrunsfeld@gmail.com>
This commit is contained in:
1 parent
d705c7f1ae
commit
00e0676e5e
14 files changed
+1026
-32
No files matched your search
@@ -24,6 +24,7 @@ Open <http://localhost:8080/> 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
|
||||
|
||||
+166
-8
@@ -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<dyn FnMut(&mut App) -> bool + 'static>;
|
||||
type Listener = Box<dyn FnMut(&dyn Any, &mut App) -> bool + 'static>;
|
||||
type MissingGlyphCallback = Box<dyn FnMut(&[MissingGlyph], &mut App) + 'static>;
|
||||
pub(crate) type KeystrokeObserver =
|
||||
Box<dyn FnMut(&KeystrokeEvent, &mut Window, &mut App) -> bool + 'static>;
|
||||
type QuitHandler = Box<dyn FnOnce(&mut App) -> LocalBoxFuture<'static, ()> + 'static>;
|
||||
@@ -323,6 +324,63 @@ type WindowClosedHandler = Box<dyn FnMut(&mut App, WindowId)>;
|
||||
type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut App) + 'static>;
|
||||
type NewEntityListener = Box<dyn FnMut(AnyEntity, &mut Option<&mut Window>, &mut App) + 'static>;
|
||||
|
||||
struct MissingGlyphCallbackEntry {
|
||||
registration: Rc<()>,
|
||||
callback: Option<MissingGlyphCallback>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MissingGlyphCallbackSlot {
|
||||
entry: RefCell<Option<MissingGlyphCallbackEntry>>,
|
||||
}
|
||||
|
||||
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<MissingGlyphCallbackSlot>,
|
||||
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<G: Global>(&self) -> bool {
|
||||
self.globals_by_type.contains_key(&TypeId::of::<G>())
|
||||
@@ -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<Cell<usize>>);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<dyn PlatformTextSystem>,
|
||||
) -> 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<TestPlatform>,
|
||||
) -> 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()));
|
||||
|
||||
@@ -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<Cow<'static, [u8]>>) -> Result<()>;
|
||||
/// Installs a nonblocking sink for unresolved grapheme clusters.
|
||||
fn set_missing_glyph_sink(&self, _sink: Option<Arc<dyn MissingGlyphSink>>) {}
|
||||
/// Get all available font names.
|
||||
fn all_font_names(&self) -> Vec<String>;
|
||||
/// Get the font ID for a font descriptor.
|
||||
|
||||
@@ -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<MissingGlyph>);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MissingGlyphState {
|
||||
reported: FxHashSet<MissingGlyph>,
|
||||
reported_order: VecDeque<MissingGlyph>,
|
||||
generation: usize,
|
||||
}
|
||||
|
||||
struct QueuedMissingGlyph {
|
||||
generation: usize,
|
||||
missing_glyph: MissingGlyph,
|
||||
}
|
||||
|
||||
/// Collects missing-glyph reports without invoking application code during layout.
|
||||
struct MissingGlyphReporter {
|
||||
state: Arc<Mutex<MissingGlyphState>>,
|
||||
sender: async_channel::Sender<QueuedMissingGlyph>,
|
||||
}
|
||||
|
||||
impl MissingGlyphSink for MissingGlyphReporter {
|
||||
fn report(&self, missing_glyphs: Vec<MissingGlyph>) {
|
||||
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<Mutex<MissingGlyphState>>,
|
||||
receiver: async_channel::Receiver<QueuedMissingGlyph>,
|
||||
}
|
||||
|
||||
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<Vec<MissingGlyph>, 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<dyn PlatformTextSystem>,
|
||||
@@ -56,11 +212,16 @@ pub struct TextSystem {
|
||||
wrapper_pool: Mutex<FxHashMap<FontIdWithSize, Vec<LineWrapper>>>,
|
||||
font_runs_pool: Mutex<Vec<Vec<FontRun>>>,
|
||||
fallback_font_stack: SmallVec<[Font; 2]>,
|
||||
font_generation: Arc<AtomicUsize>,
|
||||
missing_glyph_reporter: Arc<MissingGlyphReporter>,
|
||||
missing_glyph_receiver: Mutex<Option<MissingGlyphReceiver>>,
|
||||
}
|
||||
|
||||
impl TextSystem {
|
||||
/// Create a new TextSystem with the given platform text system.
|
||||
pub fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
|
||||
let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS);
|
||||
let missing_glyph_state = Arc::<Mutex<MissingGlyphState>>::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<Cow<'static, [u8]>>) -> 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<MissingGlyphReceiver> {
|
||||
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<MissingGlyph>) {
|
||||
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<TextSystem>) -> 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::<Mutex<MissingGlyphState>>::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::<Mutex<MissingGlyphState>>::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());
|
||||
}
|
||||
}
|
||||
@@ -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<FrameCache>,
|
||||
current_frame: RwLock<FrameCache>,
|
||||
platform_text_system: Arc<dyn PlatformTextSystem>,
|
||||
/// Advances when [`TextSystem::add_fonts`] successfully changes the font database.
|
||||
font_generation: Arc<AtomicUsize>,
|
||||
/// 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<dyn PlatformTextSystem>) -> Self {
|
||||
pub fn new(
|
||||
platform_text_system: Arc<dyn PlatformTextSystem>,
|
||||
font_generation: Arc<AtomicUsize>,
|
||||
) -> 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<LineLayoutIndex>) {
|
||||
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<str>,
|
||||
SharedString: From<Text>,
|
||||
{
|
||||
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<str>,
|
||||
SharedString: From<Text>,
|
||||
{
|
||||
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<Pixels>,
|
||||
) -> Option<Arc<LineLayout>> {
|
||||
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<Pixels>,
|
||||
materialize_text: impl FnOnce() -> SharedString,
|
||||
) -> Arc<LineLayout> {
|
||||
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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<Task<()>>,
|
||||
_missing_glyphs: Subscription,
|
||||
}
|
||||
|
||||
impl DynamicFonts {
|
||||
fn new(cx: &mut Context<Self>) -> 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<Self>) {
|
||||
// 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<Vec<u8>> = 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<Self>) -> 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);
|
||||
});
|
||||
}
|
||||
@@ -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<Vec<MissingGlyph>>);
|
||||
|
||||
impl MissingGlyphSink for Reports {
|
||||
fn report(&self, missing: Vec<MissingGlyph>) {
|
||||
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::<Vec<_>>(),
|
||||
["界", "か\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(())
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
<link data-trunk rel="rust" data-type="worker" data-bin="text_wrapper" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
|
||||
<link data-trunk rel="rust" data-type="worker" data-bin="input" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
|
||||
<script data-trunk type="module" src="gallery.js"></script>
|
||||
<link data-trunk rel="rust" data-type="worker" data-bin="dynamic_fonts" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
|
||||
<link data-trunk rel="rust" data-type="worker" data-bin="font_fallback_test" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -100,6 +102,7 @@
|
||||
<li><a href="/text-layout" data-module="text_layout"><strong>Text layout</strong><span>Alignment, decorations, and font weights.</span></a></li>
|
||||
<li><a href="/text-wrapper" data-module="text_wrapper"><strong>Text wrapping</strong><span>Wrapping and truncation, including CJK sample text.</span></a></li>
|
||||
<li><a href="/input" data-module="input"><strong>Text input</strong><span>Typing, cursor movement, and selections.</span></a></li>
|
||||
<li><a href="/dynamic-fonts" data-module="dynamic_fonts"><strong>Dynamic fonts</strong><span>Download a font when missing glyphs are reported.</span></a></li>
|
||||
<li><a href="/prime-sieve" data-module="hello_web"><strong>Prime sieve</strong><span>Background tasks and progress updates.</span></a></li>
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
@@ -110,6 +110,12 @@ try {
|
||||
await delay(100);
|
||||
}
|
||||
assert.ok(await evaluate('!!document.querySelector("textarea")'), "Input example did not launch");
|
||||
await evaluate(`(async () => {
|
||||
const tests = await import("/font_fallback_test.js");
|
||||
await tests.default();
|
||||
tests.test_missing_glyph_notifications();
|
||||
})()`);
|
||||
await evaluate("canvasCalls.length = 0");
|
||||
await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))");
|
||||
// Read GPUI's buffer through its Copy action without changing the system clipboard.
|
||||
await evaluate(`
|
||||
@@ -157,7 +163,7 @@ try {
|
||||
await insert(text);
|
||||
await delay(200);
|
||||
assert.equal(await readText(), text);
|
||||
for (const grapheme of ["❤️", "👍🏽", "🇯🇵", "1⃣", "👩💻", "👨👩👧👦"]) {
|
||||
for (const grapheme of ["❤️", "👍🏽", "🇯🇵", "1️⃣", "👩💻", "👨👩👧👦"]) {
|
||||
assert.ok(await rasterCount(grapheme) > 0, `Missing whole-grapheme raster: ${grapheme}`);
|
||||
}
|
||||
for (const grapheme of ["中", "文", "が", "か\u3099", "각", "각"]) {
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::run_replacements::{Replacement, apply_replacements, collect_candidate
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use gpui::{
|
||||
Bounds, DevicePixels, Font, FontId, FontMetrics, FontRun, FontStyle, GlyphId, Hsla, LineLayout,
|
||||
Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y,
|
||||
ShapedGlyph, Size, TextRenderingMode, point, px, size,
|
||||
MissingGlyphSink, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X,
|
||||
SUBPIXEL_VARIANTS_Y, ShapedGlyph, Size, TextRenderingMode, point, px, size,
|
||||
};
|
||||
use gpui_wgpu::CosmicTextSystem;
|
||||
use parking_lot::RwLock;
|
||||
@@ -24,6 +24,7 @@ pub(crate) struct WebTextSystem {
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
missing_glyph_sink: Option<Arc<dyn MissingGlyphSink>>,
|
||||
descriptors: HashMap<FontId, Font>,
|
||||
canvas_fonts: Vec<Arc<CanvasFont>>,
|
||||
canvas_font_ids: HashMap<FontId, FontId>,
|
||||
@@ -246,6 +247,11 @@ impl WebTextSystem {
|
||||
}
|
||||
|
||||
impl PlatformTextSystem for WebTextSystem {
|
||||
fn set_missing_glyph_sink(&self, sink: Option<Arc<dyn MissingGlyphSink>>) {
|
||||
// Cosmic must not report missing glyphs before Canvas has a chance to replace them.
|
||||
self.state.write().missing_glyph_sink = sink;
|
||||
}
|
||||
|
||||
fn add_fonts(&self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()> {
|
||||
self.native.add_fonts(fonts)?;
|
||||
self.state.write().measurements.clear();
|
||||
@@ -407,6 +413,20 @@ impl PlatformTextSystem for WebTextSystem {
|
||||
};
|
||||
let mut layout = self.native.layout_line(text, font_size, &native_runs);
|
||||
self.apply_fallback(text, &native_runs, &mut layout);
|
||||
let sink = self.state.read().missing_glyph_sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
let missing_indices = layout
|
||||
.runs
|
||||
.iter()
|
||||
.filter(|run| run.font_id.0 & CANVAS_FONT_BIT == 0)
|
||||
.flat_map(|run| &run.glyphs)
|
||||
.filter(|glyph| glyph.id.0 == 0)
|
||||
.map(|glyph| glyph.index);
|
||||
sink.report(
|
||||
self.native
|
||||
.missing_glyphs(text, &native_runs, missing_indices),
|
||||
);
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ wgpu = { workspace = true, features = ["webgl"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
naga = { version = "29.0.4", features = ["wgsl-in"] }
|
||||
|
||||
[[bench]]
|
||||
|
||||
@@ -5,10 +5,10 @@ use cosmic_text::{
|
||||
FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, Weight,
|
||||
};
|
||||
use gpui::{
|
||||
Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId,
|
||||
IsZero as _, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X,
|
||||
SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point,
|
||||
size,
|
||||
Bounds, DevicePixels, FallbackFontClass, Font, FontFallbacks, FontFeatures, FontId,
|
||||
FontMetrics, FontRun, GlyphId, IsZero as _, LineLayout, MissingGlyph, MissingGlyphSink, Pixels,
|
||||
PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ShapedGlyph,
|
||||
ShapedRun, SharedString, Size, TextRenderingMode, point, size,
|
||||
};
|
||||
|
||||
use itertools::Itertools;
|
||||
@@ -40,6 +40,12 @@ impl FontKey {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct LoadedFontKey {
|
||||
database_id: cosmic_text::fontdb::ID,
|
||||
font: FontKey,
|
||||
}
|
||||
|
||||
struct CosmicTextSystemState {
|
||||
font_system: FontSystem,
|
||||
scratch: ShapeBuffer,
|
||||
@@ -47,10 +53,12 @@ struct CosmicTextSystemState {
|
||||
pending_glyph_images: HashMap<RenderGlyphParams, swash::scale::image::Image>,
|
||||
/// Contains all already loaded fonts, including all faces. Indexed by `FontId`.
|
||||
loaded_fonts: Vec<LoadedFont>,
|
||||
loaded_font_ids_by_key: HashMap<LoadedFontKey, FontId>,
|
||||
/// Caches the `FontId`s associated with a specific family to avoid iterating the font database
|
||||
/// for every font face in a family.
|
||||
font_ids_by_family_cache: HashMap<FontKey, SmallVec<[FontId; 4]>>,
|
||||
system_font_fallback: String,
|
||||
missing_glyph_sink: Option<Arc<dyn MissingGlyphSink>>,
|
||||
}
|
||||
|
||||
struct LoadedFont {
|
||||
@@ -107,6 +115,19 @@ impl CosmicTextSystem {
|
||||
Ok((gpui::FontWeight(face.weight.0 as f32), style))
|
||||
}
|
||||
|
||||
/// Builds reports for unresolved source indices after an outer text system
|
||||
/// has applied its own fallback.
|
||||
pub fn missing_glyphs(
|
||||
&self,
|
||||
text: &str,
|
||||
font_runs: &[FontRun],
|
||||
missing_text_indices: impl IntoIterator<Item = usize>,
|
||||
) -> Vec<MissingGlyph> {
|
||||
self.0
|
||||
.read()
|
||||
.missing_glyphs(text, font_runs, missing_text_indices)
|
||||
}
|
||||
|
||||
pub fn new(system_font_fallback: &str) -> Self {
|
||||
let font_system = FontSystem::new();
|
||||
|
||||
@@ -116,8 +137,10 @@ impl CosmicTextSystem {
|
||||
swash_scale_context: ScaleContext::new(),
|
||||
pending_glyph_images: HashMap::default(),
|
||||
loaded_fonts: Vec::new(),
|
||||
loaded_font_ids_by_key: HashMap::default(),
|
||||
font_ids_by_family_cache: HashMap::default(),
|
||||
system_font_fallback: system_font_fallback.to_string(),
|
||||
missing_glyph_sink: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -133,8 +156,10 @@ impl CosmicTextSystem {
|
||||
swash_scale_context: ScaleContext::new(),
|
||||
pending_glyph_images: HashMap::default(),
|
||||
loaded_fonts: Vec::new(),
|
||||
loaded_font_ids_by_key: HashMap::default(),
|
||||
font_ids_by_family_cache: HashMap::default(),
|
||||
system_font_fallback: system_font_fallback.to_string(),
|
||||
missing_glyph_sink: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -144,6 +169,10 @@ impl PlatformTextSystem for CosmicTextSystem {
|
||||
self.0.write().add_fonts(fonts)
|
||||
}
|
||||
|
||||
fn set_missing_glyph_sink(&self, sink: Option<Arc<dyn MissingGlyphSink>>) {
|
||||
self.0.write().missing_glyph_sink = sink;
|
||||
}
|
||||
|
||||
fn all_font_names(&self) -> Vec<String> {
|
||||
let mut result = self
|
||||
.0
|
||||
@@ -298,6 +327,7 @@ impl CosmicTextSystemState {
|
||||
|
||||
#[profiling::function]
|
||||
fn add_fonts(&mut self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()> {
|
||||
self.font_ids_by_family_cache.clear();
|
||||
let db = self.font_system.db_mut();
|
||||
for bytes in fonts {
|
||||
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes)));
|
||||
@@ -312,6 +342,12 @@ impl CosmicTextSystemState {
|
||||
features: &FontFeatures,
|
||||
fallbacks: Option<&FontFallbacks>,
|
||||
) -> Result<SmallVec<[FontId; 4]>> {
|
||||
let loaded_font_key = FontKey::new(
|
||||
SharedString::from(name.to_owned()),
|
||||
features.clone(),
|
||||
fallbacks.cloned(),
|
||||
);
|
||||
|
||||
// recurse with `fallbacks = None` so a fallback family cannot pull in
|
||||
// another chain. missing fallback families are dropped so a typo in
|
||||
// settings still lets the primary family load.
|
||||
@@ -360,10 +396,20 @@ impl CosmicTextSystemState {
|
||||
let cosmic_features = cosmic_font_features(features)?;
|
||||
|
||||
let mut loaded_font_ids = SmallVec::new();
|
||||
for (font_id, postscript_name) in families {
|
||||
for (database_id, postscript_name) in families {
|
||||
let key = LoadedFontKey {
|
||||
database_id,
|
||||
font: loaded_font_key.clone(),
|
||||
};
|
||||
if let Some(&font_id) = self.loaded_font_ids_by_key.get(&key) {
|
||||
self.loaded_fonts[font_id.0].user_fallback_chain = Arc::clone(&user_fallback_chain);
|
||||
loaded_font_ids.push(font_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let font = self
|
||||
.font_system
|
||||
.get_font(font_id, cosmic_text::Weight::NORMAL)
|
||||
.get_font(database_id, cosmic_text::Weight::NORMAL)
|
||||
.context("Could not load font")?;
|
||||
|
||||
// HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback.
|
||||
@@ -387,6 +433,7 @@ impl CosmicTextSystemState {
|
||||
is_known_emoji_font: check_is_known_emoji_font(&postscript_name),
|
||||
user_fallback_chain: Arc::clone(&user_fallback_chain),
|
||||
});
|
||||
self.loaded_font_ids_by_key.insert(key, font_id);
|
||||
}
|
||||
|
||||
Ok(loaded_font_ids)
|
||||
@@ -722,6 +769,18 @@ impl CosmicTextSystemState {
|
||||
};
|
||||
};
|
||||
|
||||
let missing_glyphs = self.missing_glyph_sink.as_ref().map(|_| {
|
||||
self.missing_glyphs(
|
||||
text,
|
||||
font_runs,
|
||||
layout
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|glyph| glyph.glyph_id == 0)
|
||||
.map(|glyph| glyph.start),
|
||||
)
|
||||
});
|
||||
|
||||
let mut runs: Vec<ShapedRun> = Vec::new();
|
||||
for glyph in &layout.glyphs {
|
||||
let mut font_id = FontId(glyph.metadata);
|
||||
@@ -768,6 +827,10 @@ impl CosmicTextSystemState {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((sink, missing_glyphs)) = self.missing_glyph_sink.as_ref().zip(missing_glyphs) {
|
||||
sink.report(missing_glyphs);
|
||||
}
|
||||
|
||||
LineLayout {
|
||||
font_size,
|
||||
width: layout.w.into(),
|
||||
@@ -777,6 +840,80 @@ impl CosmicTextSystemState {
|
||||
len: text.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_glyphs(
|
||||
&self,
|
||||
text: &str,
|
||||
font_runs: &[FontRun],
|
||||
missing_text_indices: impl IntoIterator<Item = usize>,
|
||||
) -> Vec<MissingGlyph> {
|
||||
let mut missing_text_indices = missing_text_indices.into_iter().peekable();
|
||||
if missing_text_indices.peek().is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut missing_text_indices = missing_text_indices.collect::<Vec<_>>();
|
||||
missing_text_indices.sort_unstable();
|
||||
missing_text_indices.dedup();
|
||||
|
||||
let mut font_run_index = 0;
|
||||
let mut font_run_end = font_runs.first().map_or(0, |font_run| font_run.len);
|
||||
let mut missing_glyphs = Vec::new();
|
||||
let mut missing_index = 0;
|
||||
for (grapheme_start, grapheme) in text.grapheme_indices(true) {
|
||||
let grapheme_end = grapheme_start + grapheme.len();
|
||||
while missing_text_indices
|
||||
.get(missing_index)
|
||||
.is_some_and(|text_index| *text_index < grapheme_start)
|
||||
{
|
||||
missing_index += 1;
|
||||
}
|
||||
let Some(&text_index) = missing_text_indices.get(missing_index) else {
|
||||
break;
|
||||
};
|
||||
if text_index >= grapheme_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
while font_run_end <= text_index && font_run_index + 1 < font_runs.len() {
|
||||
font_run_index += 1;
|
||||
let Some(font_run) = font_runs.get(font_run_index) else {
|
||||
break;
|
||||
};
|
||||
font_run_end += font_run.len;
|
||||
}
|
||||
let font_class = self.fallback_font_class(
|
||||
font_runs
|
||||
.get(font_run_index)
|
||||
.or_else(|| font_runs.last())
|
||||
.map(|font_run| font_run.font_id),
|
||||
);
|
||||
missing_glyphs.push(MissingGlyph::new(grapheme.into(), font_class));
|
||||
while missing_text_indices
|
||||
.get(missing_index)
|
||||
.is_some_and(|text_index| *text_index < grapheme_end)
|
||||
{
|
||||
missing_index += 1;
|
||||
}
|
||||
}
|
||||
missing_glyphs
|
||||
}
|
||||
|
||||
fn fallback_font_class(&self, font_id: Option<FontId>) -> FallbackFontClass {
|
||||
let Some(font_id) = font_id else {
|
||||
return FallbackFontClass::Proportional;
|
||||
};
|
||||
let loaded_font = self.loaded_font(font_id);
|
||||
let is_monospace = self
|
||||
.font_system
|
||||
.db()
|
||||
.face(loaded_font.font.id())
|
||||
.is_some_and(|face| face.monospaced);
|
||||
if is_monospace {
|
||||
FallbackFontClass::Monospace
|
||||
} else {
|
||||
FallbackFontClass::Proportional
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
@@ -1063,6 +1200,7 @@ fn check_is_known_emoji_font(postscript_name: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
#[test]
|
||||
fn all_font_names_tracks_available_families() -> Result<()> {
|
||||
@@ -1105,6 +1243,7 @@ mod tests {
|
||||
|
||||
const IBM_PLEX: &[u8] =
|
||||
include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
|
||||
const LILEX: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
|
||||
|
||||
/// Every code point of `Bidi_Class=B`, each of which starts a new bidi
|
||||
/// paragraph and so can split one line into mixed-direction paragraphs.
|
||||
@@ -1175,6 +1314,97 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_graphemes_that_exhaust_font_fallback() -> Result<()> {
|
||||
let platform_text_system = Arc::new(text_system()?);
|
||||
let dispatcher = gpui::TestDispatcher::new(0);
|
||||
let cx =
|
||||
gpui::TestAppContext::build_with_text_system(dispatcher, None, platform_text_system);
|
||||
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);
|
||||
})
|
||||
});
|
||||
let text: SharedString = "界".into();
|
||||
|
||||
cx.update(|cx| {
|
||||
let text_system = gpui::WindowTextSystem::new(cx.text_system().clone());
|
||||
let runs = [gpui::TextRun {
|
||||
len: text.len(),
|
||||
font: gpui::font("IBM Plex Sans"),
|
||||
..Default::default()
|
||||
}];
|
||||
text_system.shape_line(text, gpui::px(14.0), &runs, None);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let observed = observed.borrow();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].grapheme(), "界");
|
||||
assert_eq!(
|
||||
observed[0].font_class(),
|
||||
gpui::FallbackFontClass::Proportional
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combines_missing_glyphs_from_one_grapheme() -> Result<()> {
|
||||
let text_system = text_system()?;
|
||||
let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?;
|
||||
let text = "x\u{0301}";
|
||||
let runs = [FontRun {
|
||||
len: text.len(),
|
||||
font_id,
|
||||
}];
|
||||
|
||||
let missing_glyphs = text_system
|
||||
.0
|
||||
.read()
|
||||
.missing_glyphs(text, &runs, [0, "x".len()]);
|
||||
|
||||
assert_eq!(missing_glyphs.len(), 1);
|
||||
assert_eq!(missing_glyphs[0].grapheme(), text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_fonts_invalidates_cached_line_layouts() -> Result<()> {
|
||||
let platform_text_system = Arc::new(text_system()?);
|
||||
let text_system = Arc::new(gpui::TextSystem::new(platform_text_system.clone()));
|
||||
let window_text_system = gpui::WindowTextSystem::new(text_system.clone());
|
||||
let text: SharedString = "cached text".into();
|
||||
let runs = [gpui::TextRun {
|
||||
len: text.len(),
|
||||
font: gpui::font("IBM Plex Sans"),
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let first_layout = window_text_system.shape_line(text.clone(), gpui::px(14.0), &runs, None);
|
||||
let cached_layout =
|
||||
window_text_system.shape_line(text.clone(), gpui::px(14.0), &runs, None);
|
||||
assert!(std::ptr::eq::<LineLayout>(
|
||||
&**first_layout,
|
||||
&**cached_layout
|
||||
));
|
||||
let loaded_font_count = platform_text_system.0.read().loaded_fonts.len();
|
||||
|
||||
text_system.add_fonts(vec![Cow::Borrowed(LILEX)])?;
|
||||
|
||||
let refreshed_layout = window_text_system.shape_line(text, gpui::px(14.0), &runs, None);
|
||||
assert!(!std::ptr::eq::<LineLayout>(
|
||||
&**first_layout,
|
||||
&**refreshed_layout
|
||||
));
|
||||
assert_eq!(
|
||||
platform_text_system.0.read().loaded_fonts.len(),
|
||||
loaded_font_count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_line_with_mixed_direction_paragraphs() -> Result<()> {
|
||||
let text_system = text_system()?;
|
||||
|
||||
Reference in new issue
Block a user