kui_core/runtime/resources_api.rs
1//! Resources through a core: fonts, sounds and images live in the
2//! session's registry (`resources`), so registering one here registers
3//! it for every window; playback is commands the driver drains
4//! (`audio`). Nothing here touches a device.
5
6use super::*;
7
8impl Core {
9 /// Turns the sounds nodes asked for (`click_sound` / `hover_sound`)
10 /// into play commands. Declarative sounds carry no tag, so they never
11 /// report `ended`.
12 pub(crate) fn flush_sound_requests(&mut self) {
13 let window = self.env.window.id;
14 let mut sess = self.session.state();
15 for sound in self.interaction.take_sound_requests() {
16 sess.audio.play(
17 OriginId::HOST,
18 window,
19 Key::ROOT,
20 sound,
21 crate::audio::PlayOptions::default(),
22 );
23 }
24 }
25
26 // -- Fragments ------------------------------------------------------
27
28 /// Registers a WGSL fragment function for a `fragment` node.
29 /// The app writes one function:
30 ///
31 /// ```wgsl
32 /// fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32>
33 /// ```
34 ///
35 /// and the core wraps it in the prelude and epilogue that give it the
36 /// node's rounded box, the inherited clip, the group opacity and the
37 /// blend (see `crate::fragment`). `None` when the source does not
38 /// compile, with a `fragment-rejected` warning carrying naga's message
39 /// in the app's own line numbers — so a bad shader is a warning at
40 /// registration, in a headless test included, and never a blank box in
41 /// a window.
42 ///
43 /// Idempotent by source: the same text gets the same handle without
44 /// validating again, so a view may call this every frame. Registering
45 /// at startup is still the advice, because the *backend* builds a
46 /// pipeline the first time it sees a handle.
47 pub fn add_fragment(&mut self, wgsl: &str) -> Option<crate::resources::FragmentId> {
48 if let Some(id) = self.session.state().resources.find_fragment(wgsl) {
49 return Some(id);
50 }
51 if let Err(message) = crate::fragment::validate(wgsl) {
52 self.warn(crate::diag::Warning {
53 code: crate::diag::FRAGMENT_REJECTED,
54 key: crate::key::Key::ROOT,
55 message: format!("fragment source rejected: {message}"),
56 });
57 return None;
58 }
59 Some(self.session.state().resources.add_fragment(wgsl))
60 }
61
62 /// Forgets a registered fragment. Nodes still naming it draw nothing,
63 /// and the next frame any window draws has the backend drop the
64 /// pipelines it built for it.
65 pub fn remove_fragment(&mut self, id: crate::resources::FragmentId) {
66 self.session.state().remove_fragment(id);
67 // The stock polygon's handle, if that is what went: the next
68 // `polygon` node registers it again rather than drawing nothing.
69 if self.stock_polygon == Some(id) {
70 self.stock_polygon = None;
71 }
72 }
73
74 /// The whole WGSL module behind a fragment handle — the app's source
75 /// between the core's prelude and epilogue — which is what a backend
76 /// compiles. A host rendering the display list itself asks for this
77 /// rather than assembling its own, so what it compiles is what the
78 /// core validated.
79 pub fn fragment_module_source(&self, id: crate::resources::FragmentId) -> Option<String> {
80 let sess = self.session.state();
81 let app = sess.resources.fragment(id)?;
82 Some(crate::fragment::module_source(app))
83 }
84
85 // -- Fonts ----------------------------------------------------------
86
87 /// Registers a font from its file bytes (TTF/OTF/TTC); `None` when the
88 /// data holds no usable face — none the font database can read, or
89 /// none whose glyphs can be measured (no `head`, `hhea` or `hmtx`).
90 /// Shape with it via `TextStyle::font`.
91 pub fn add_font_data(&mut self, data: Vec<u8>) -> Option<crate::resources::FontId> {
92 use cosmic_text::fontdb::Source;
93 let id = {
94 let sess = &mut *self.session.state();
95 let ids = sess
96 .fonts
97 .db_mut()
98 .load_font_source(Source::Binary(std::sync::Arc::new(data)));
99 sess.share_loaded_faces();
100 let db = sess.fonts.db_mut();
101 let ids = crate::text::keep_measurable(db, ids.to_vec());
102 let family = db.face(*ids.first()?)?.families.first()?.0.clone();
103 sess.fonts_rev += 1;
104 let touched = families_of(db, &ids);
105 let id = sess.resources.add_font(family, ids.to_vec());
106 if sess.resources.reweigh(sess.fonts.db(), &touched, Some(id)) {
107 sess.weights_rev += 1;
108 }
109 id
110 };
111 self.sync_font_names();
112 Some(id)
113 }
114
115 /// Registers a font file (TTF/OTF/TTC) by path, memory-mapped by the
116 /// font database; `None` when it cannot be read or holds no usable
117 /// face (see [`add_font_data`](Self::add_font_data)). Shape with it
118 /// via `TextStyle::font`.
119 pub fn load_font_file(
120 &mut self,
121 path: impl Into<std::path::PathBuf>,
122 ) -> Option<crate::resources::FontId> {
123 use cosmic_text::fontdb::Source;
124 let id = {
125 let sess = &mut *self.session.state();
126 let ids = sess
127 .fonts
128 .db_mut()
129 .load_font_source(Source::File(path.into()));
130 // Its own faces too, not only the ones before it (DX25).
131 sess.share_loaded_faces();
132 let db = sess.fonts.db_mut();
133 let ids = crate::text::keep_measurable(db, ids.to_vec());
134 let family = db.face(*ids.first()?)?.families.first()?.0.clone();
135 sess.fonts_rev += 1;
136 let touched = families_of(db, &ids);
137 let id = sess.resources.add_font(family, ids.to_vec());
138 if sess.resources.reweigh(sess.fonts.db(), &touched, Some(id)) {
139 sess.weights_rev += 1;
140 }
141 id
142 };
143 self.sync_font_names();
144 Some(id)
145 }
146
147 /// Loads every font file under `dir` (recursively) into the font
148 /// database, so their families become available to `add_system_font`
149 /// by name; returns how many faces were added. A face whose glyphs
150 /// cannot be measured is left out and not counted. A
151 /// bundled `fonts/` folder next to the app is the usual case.
152 pub fn load_fonts_dir(&mut self, dir: impl AsRef<std::path::Path>) -> usize {
153 let sess = &mut *self.session.state();
154 let db = sess.fonts.db_mut();
155 let before: rustc_hash::FxHashSet<_> = db.faces().map(|face| face.id).collect();
156 db.load_fonts_dir(dir);
157 sess.share_loaded_faces();
158 let db = sess.fonts.db_mut();
159 let added = db
160 .faces()
161 .map(|face| face.id)
162 .filter(|id| !before.contains(id))
163 .collect();
164 let added = crate::text::keep_measurable(db, added);
165 // A registered family may have gained a bold (backlog F100).
166 let touched = families_of(db, &added);
167 if sess.resources.reweigh(sess.fonts.db(), &touched, None) {
168 sess.weights_rev += 1;
169 }
170 added.len()
171 }
172
173 /// Scans the system's fonts again and brings this session's font
174 /// database up to it: a face installed since the last scan joins it,
175 /// one uninstalled leaves it. Returns how many faces came and went, 0
176 /// when nothing did.
177 ///
178 /// The system's fonts are scanned once a process, and every session
179 /// starts from that scan, so a font the user installs while the app
180 /// runs is not seen until something asks. The winit runner
181 /// (`kui-native`) asks itself when macOS or Windows says the installed
182 /// fonts changed; a host with its own windowing, or on Linux, where
183 /// fontconfig says nothing, calls this when it has reason to think the
184 /// set changed (a "fonts" pane opening, the window taking focus back).
185 /// It opens every font file on the system — tens of milliseconds on a
186 /// Mac's 1300 faces — so it is not a per-frame call. Sessions made after
187 /// it start from the new scan; another
188 /// session that already exists keeps what it has until it calls this
189 /// too.
190 ///
191 /// Faces still installed keep their handles and their place in every
192 /// cache; fonts the app loaded itself (`add_font_data`,
193 /// `load_font_file`, `load_fonts_dir`) are not touched. Every window
194 /// of the session shapes its text again on its next frame, since
195 /// fallback can land on a new face anywhere; this window is asked for
196 /// that frame, and each window's next frame reports a `fonts` event to
197 /// the host, for an app that keeps the font list in its model. A face
198 /// whose file was replaced in place, under the same
199 /// path, is not read again.
200 pub fn reload_system_fonts(&mut self) -> usize {
201 let fresh = crate::text::rescan_system_fonts();
202 let changed = self.session.state().apply_system_fonts(fresh);
203 if changed > 0 {
204 self.sync_font_names();
205 self.request_frame();
206 }
207 changed
208 }
209
210 /// The handle for a font family by name (`"Menlo"`, `"Antonio"`) —
211 /// installed on the system or loaded with `load_fonts_dir` /
212 /// `load_font_file`; `None` when no face matches (see
213 /// `system_font_families`). Idempotent: the same family gets the same
214 /// handle, so views can call it every frame.
215 pub fn add_system_font(&mut self, name: &str) -> Option<crate::resources::FontId> {
216 let id = self.session.register_family(name)?;
217 self.sync_font_names();
218 Some(id)
219 }
220
221 /// Forgets a registered font; faces loaded from bytes leave the font
222 /// database. Styles still naming it shape as sans-serif.
223 pub fn remove_font(&mut self, id: crate::resources::FontId) {
224 {
225 let sess = &mut *self.session.state();
226 let Some(entry) = sess.resources.remove_font(id) else {
227 return;
228 };
229 sess.fonts_rev += 1;
230 let db = sess.fonts.db_mut();
231 let touched = families_of(db, &entry.faces);
232 for face in entry.faces {
233 db.remove_face(face);
234 }
235 if sess.resources.reweigh(sess.fonts.db(), &touched, None) {
236 sess.weights_rev += 1;
237 }
238 }
239 self.sync_font_names();
240 }
241
242 /// The registered family name behind a font handle, if it is live.
243 /// Read from this window's mirror of the session's fonts (see
244 /// `session`'s module doc), which every registration refreshes and so
245 /// does every frame — a font another window registered mid-frame shows
246 /// up here on the next one.
247 pub fn font_family(&self, id: crate::resources::FontId) -> Option<&str> {
248 self.font_names.get(&id).map(|s| &**s)
249 }
250
251 /// Family names of every installed font the core can see (sorted,
252 /// deduplicated) — what `add_system_font` accepts. The names of
253 /// [`system_fonts`](Self::system_fonts), which says what each is.
254 pub fn system_font_families(&self) -> Vec<String> {
255 self.system_fonts().into_iter().map(|f| f.family).collect()
256 }
257
258 /// Every family the core can see, installed or loaded, one per family
259 /// and sorted by name — the families `system_font_families` names —
260 /// with what its faces say they are: monospaced, the weights, an
261 /// italic. Read from what the font database recorded
262 /// when it scanned each face, so a fonts pane showing the monospaced
263 /// ones first costs no file loaded and no glyph shaped. A face whose
264 /// glyphs cannot be measured never entered the database,
265 /// so a family of only such faces — macOS's GB18030 Bitmap — is not
266 /// listed.
267 pub fn system_fonts(&self) -> Vec<crate::resources::SystemFont> {
268 use crate::resources::SystemFont;
269 use cosmic_text::fontdb::Style;
270 let sess = self.session.state();
271 let mut by_family = std::collections::BTreeMap::<&str, SystemFont>::new();
272 for face in sess.fonts.db().faces() {
273 let Some((name, _)) = face.families.first() else {
274 continue;
275 };
276 let font = by_family.entry(name).or_insert_with(|| SystemFont {
277 family: name.clone(),
278 monospaced: true,
279 weights: Vec::new(),
280 italic: false,
281 });
282 font.monospaced &= face.monospaced;
283 font.italic |= face.style != Style::Normal;
284 font.weights.push(face.weight.0);
285 }
286 by_family
287 .into_values()
288 .map(|mut font| {
289 font.weights.sort_unstable();
290 font.weights.dedup();
291 font
292 })
293 .collect()
294 }
295
296 /// The installed families `FontFamily::Sans`, `Serif` and `Mono` shape
297 /// with, in that order — pinned per platform when the session's font
298 /// database was built, so the devtools can say which face
299 /// "mono" is on this machine.
300 pub(crate) fn default_font_families(&self) -> [String; 3] {
301 crate::text::default_families(&self.session.state().fonts).map(str::to_string)
302 }
303
304 // -- Audio ----------------------------------------------------------
305 // Sounds are resources, playback is commands the driver drains; see
306 // `audio`. Nothing here touches a device.
307
308 /// Registers a sound from its encoded file bytes (wav/ogg/mp3/flac —
309 /// the driver's backend decodes; the core only keeps the bytes).
310 pub fn add_sound(&mut self, bytes: Vec<u8>) -> crate::resources::SoundId {
311 self.session.state().resources.add_sound(bytes)
312 }
313
314 /// Forgets a sound; the driver drops its decoded copy. Playbacks
315 /// already running keep going.
316 pub fn remove_sound(&mut self, id: crate::resources::SoundId) {
317 let sess = &mut *self.session.state();
318 if sess.resources.remove_sound(id).is_some() {
319 sess.audio.unload(id);
320 }
321 }
322
323 /// Starts a playback; the returned id addresses it in `stop` /
324 /// `set_volume` / `pause` / `resume`. With a tag in the options, the
325 /// playback finishing on its own comes back as
326 /// `{kind="sound", phase="ended", playback, tag}` on the current
327 /// origin's root — the host's, or the extension's during its view.
328 pub fn play(
329 &mut self,
330 sound: crate::resources::SoundId,
331 opts: crate::audio::PlayOptions,
332 ) -> crate::audio::PlaybackId {
333 let origin = self.origin;
334 let window = self.env.window.id;
335 let sess = &mut *self.session.state();
336 // The driver's backend resolves the handle when it plays; a
337 // headless app has no driver, so a foreign handle is noticed here.
338 let _ = sess.resources.sound(sound);
339 sess.audio.play(origin, window, Key::ROOT, sound, opts)
340 }
341
342 /// Stops a playback, fading over `fade_ms` (0 = at once). A stopped
343 /// playback never reports `ended`.
344 pub fn stop(&mut self, playback: crate::audio::PlaybackId, fade_ms: f32) {
345 self.session.state().audio.stop(playback, fade_ms);
346 }
347
348 /// Sets a playback's volume (linear amplitude), tweening over `tween_ms`.
349 pub fn set_volume(&mut self, playback: crate::audio::PlaybackId, volume: f32, tween_ms: f32) {
350 self.session
351 .state()
352 .audio
353 .set_volume(playback, volume, tween_ms);
354 }
355
356 pub fn pause(&mut self, playback: crate::audio::PlaybackId, fade_ms: f32) {
357 self.session.state().audio.pause(playback, fade_ms);
358 }
359
360 pub fn resume(&mut self, playback: crate::audio::PlaybackId, fade_ms: f32) {
361 self.session.state().audio.resume(playback, fade_ms);
362 }
363
364 /// Sets the master volume (linear amplitude), tweening over `tween_ms`.
365 pub fn set_master_volume(&mut self, volume: f32, tween_ms: f32) {
366 self.session.state().audio.master_volume(volume, tween_ms);
367 }
368
369 /// An `audio` node: a playback retained by key for as long as the view
370 /// keeps declaring it — present means playing (once, or looped),
371 /// gone means stopped; `volume` / `paused` changes apply live, a
372 /// changed `src` restarts. The key is auto-assigned from the tree
373 /// position; see `audio_node_keyed` for a stable label. Draws nothing
374 /// and takes no layout space. The mount is this window's: it is
375 /// reconciled against this window's frames, and another window's
376 /// frame declaring nothing leaves it playing.
377 pub fn audio_node(&mut self, spec: crate::audio::AudioSpec) -> Key {
378 if self.tree.is_empty() {
379 return Key::ROOT;
380 }
381 let key = self.auto_key();
382 self.declare_audio(key, spec);
383 key
384 }
385
386 /// `audio_node` with a label-derived key (stable across reorders).
387 pub fn audio_node_keyed(&mut self, label: &str, spec: crate::audio::AudioSpec) -> Key {
388 if self.tree.is_empty() {
389 return Key::ROOT;
390 }
391 let key = self.child_key(label);
392 self.declare_audio(key, spec);
393 key
394 }
395
396 fn declare_audio(&mut self, key: Key, spec: crate::audio::AudioSpec) {
397 let origin = self.origin;
398 let window = self.env.window.id;
399 let sess = &mut *self.session.state();
400 let _ = sess.resources.sound(spec.src);
401 sess.audio.declare(window, key, origin, spec);
402 }
403
404 /// The playback an `audio` node of this window holds, if it is
405 /// mounted — after the frame that declared it has finished.
406 pub fn playback_of(&self, key: Key) -> Option<crate::audio::PlaybackId> {
407 self.audio.playback_of(self.env.window.id, key)
408 }
409
410 /// Drains the audio commands queued since the last drain. Frame
411 /// drivers apply them to a real device after each input dispatch and
412 /// after each frame; headless drivers may simply never call.
413 pub fn take_audio_commands(&mut self) -> Vec<crate::audio::AudioCommand> {
414 self.audio.take_commands()
415 }
416
417 /// The driver reports a playback finished on its own (not stopped).
418 /// A tagged playback becomes an `ended` event, pending like a `resize`
419 /// (see `take_pending_events`).
420 pub fn audio_ended(&mut self, playback: crate::audio::PlaybackId) {
421 let ended = self.session.state().audio.ended(playback);
422 if let Some(ev) = ended {
423 self.pending.push(ev);
424 }
425 }
426
427 /// The driver reports it stopped a playback that was still running,
428 /// `at` seconds into the sound. When that stop was a one-shot `audio`
429 /// node going away (or changing its `src`) without
430 /// [`finish`](crate::audio::AudioSpec::finish), the node is named in a
431 /// [`TRUNCATED_PLAYBACK`](crate::diag::TRUNCATED_PLAYBACK) warning;
432 /// anything else — a stop that landed after the sound ended, an
433 /// imperative [`Self::stop`], a loop, a released playback — reports
434 /// nothing. The driver stays key-blind, as [`Self::audio_ended`] is.
435 pub fn audio_truncated(&mut self, playback: crate::audio::PlaybackId, at: f64) {
436 let cut = self.session.state().audio.truncated(playback);
437 if let Some((key, why)) = cut {
438 self.diag
439 .raise(crate::diag::truncated_playback(key, why, at));
440 }
441 }
442
443 /// The driver refused a play: the device's voices are all held, or
444 /// the sound failed to decode. The playback never started, so it can
445 /// never report `ended` — a tagged one gets
446 /// `{kind="sound", phase="refused", playback, tag}` instead, pending
447 /// like an `ended` is, so a view waiting on the sound is unstuck and
448 /// can tell the two apart. [`crate::diag::PLAYBACK_REFUSED`] is raised
449 /// on the node that asked either way, behind the usual diagnostics
450 /// gate, so an untagged refusal is not silent.
451 pub fn audio_refused(&mut self, playback: crate::audio::PlaybackId) {
452 let (event, warning) = self.session.state().audio.refused(playback);
453 if let Some(ev) = event {
454 self.pending.push(ev);
455 }
456 self.warn(warning);
457 }
458
459 /// Drops what this window shaped when a registered family's weights
460 /// changed since it last looked: text shaped with a bold
461 /// synthesized for a family that has since gained its Bold, or at a
462 /// face since removed, would otherwise stay as it was until evicted.
463 /// Shaped text and cell tables shape again on their next draw; an
464 /// editor keeps its text and takes the new weights. Rare — a face of
465 /// a family already registered coming or going — so dropping every
466 /// family's text is cheaper than knowing which.
467 pub(crate) fn sync_weights(&mut self) {
468 let sess = self.session.state();
469 if sess.weights_rev == self.weights_rev {
470 return;
471 }
472 self.weights_rev = sess.weights_rev;
473 self.text.forget_shaped();
474 self.cells.forget_shaped();
475 self.edit.reweigh(&sess.resources);
476 }
477
478 /// Re-reads the session's font family names into the mirror
479 /// `font_family` lends from, when a registration has moved since.
480 pub(crate) fn sync_font_names(&mut self) {
481 let sess = self.session.state();
482 if sess.fonts_rev == self.fonts_rev {
483 return;
484 }
485 self.fonts_rev = sess.fonts_rev;
486 self.font_names.clear();
487 for (id, entry) in sess.resources.fonts.iter() {
488 self.font_names.insert(id, entry.family.as_str().into());
489 }
490 }
491
492 /// Replaces an image's pixels in place; see `Resources::update_image`.
493 /// If the image had been drawn from the atlas
494 /// its slot is forgotten — one eviction, once — and from here on it is
495 /// texture-backed. A foreign or removed handle warns and changes
496 /// nothing, and so does a buffer that is not `width × height × 4`
497 /// bytes; the return says whether the pixels were taken.
498 pub fn update_image(
499 &mut self,
500 id: crate::resources::ImageId,
501 width: u32,
502 height: u32,
503 rgba: Vec<u8>,
504 ) -> bool {
505 let taken = self
506 .session
507 .state()
508 .resources
509 .update_image(id, width, height, rgba);
510 if taken {
511 self.atlas.evict_image(id);
512 }
513 taken
514 }
515
516 /// Replaces an image's pixels by writing them into a buffer the core
517 /// recycles; see `Resources::update_image_with`. `fill` gets
518 /// `width × height × 4` bytes holding an earlier frame's pixels and
519 /// writes every one. Where [`Self::update_image`] takes a buffer the
520 /// app allocated — and frees the one it replaces — this one stops
521 /// allocating after a stream's third update, which on Windows is most
522 /// of what a 1080p update cost. Render into `fill`'s
523 /// slice rather than into a buffer of your own to skip the copy too.
524 /// `fill` runs while the session's resources are borrowed, so it must
525 /// not reach them through another window's `Core`; that panics.
526 pub fn update_image_with(
527 &mut self,
528 id: crate::resources::ImageId,
529 width: u32,
530 height: u32,
531 fill: impl FnOnce(&mut [u8]),
532 ) -> bool {
533 let taken = self
534 .session
535 .state()
536 .resources
537 .update_image_with(id, width, height, fill);
538 if taken {
539 self.atlas.evict_image(id);
540 }
541 taken
542 }
543
544 /// The pixels behind an image handle — its size and a shared handle on
545 /// the bytes — for a host that renders the display list itself and
546 /// meets a `QuadKind::Texture` quad. `None` for a dead or foreign
547 /// handle, which is also noted as a miss.
548 pub fn image_pixels(
549 &self,
550 id: crate::resources::ImageId,
551 ) -> Option<(u32, u32, std::sync::Arc<Vec<u8>>)> {
552 let sess = self.session.state();
553 sess.resources
554 .image(id)
555 .map(|e| (e.width, e.height, e.rgba.clone()))
556 }
557
558 /// Unregisters an image and forgets its atlas slot — every other
559 /// window's atlas forgets its own at that window's next frame — or,
560 /// for a texture-backed one, has the next display list any window
561 /// builds tell the backend to drop the texture.
562 pub fn remove_image(&mut self, id: crate::resources::ImageId) {
563 self.session.state().remove_image(id);
564 self.atlas.evict_image(id);
565 }
566
567 /// What the session removed and no display list has carried yet,
568 /// onto this frame's — a backend frees a texture or a pipeline
569 /// once, on whichever window draws next, since both are the device's
570 /// and the device is shared. And this window's own atlas slots for
571 /// images the registry no longer holds, when a removal has moved the
572 /// revision since this core last looked: the atlas is per window, so
573 /// the removing core's eviction reached only its own.
574 /// How many `path` masks this core draws from textures of their own
575 /// rather than the atlas — too big for a page, or animating (ADR
576 /// 0040, decisions 7 and 8). For a test of which road a path took.
577 pub fn path_texture_count(&self) -> usize {
578 self.path_textures.len()
579 }
580
581 pub(crate) fn sync_dropped(&mut self) {
582 // A path's own texture the last frame did not draw goes with the
583 // removed images (ADR 0040, decisions 7 and 8).
584 self.display
585 .dropped_textures
586 .extend(self.path_textures.sweep(self.frame_no));
587 let mut sess = self.session.state();
588 self.display
589 .dropped_textures
590 .append(&mut sess.dropped.images);
591 self.display
592 .dropped_fragments
593 .append(&mut sess.dropped.fragments);
594 if sess.images_rev != self.images_rev {
595 self.images_rev = sess.images_rev;
596 let live = &sess.resources.images;
597 self.atlas.retain_images(|id| live.contains_key(id));
598 }
599 }
600}
601
602/// Every family name the faces `ids` answer to, for
603/// [`Resources::reweigh`](crate::resources::Resources::reweigh).
604fn families_of(
605 db: &cosmic_text::fontdb::Database,
606 ids: &[cosmic_text::fontdb::ID],
607) -> rustc_hash::FxHashSet<String> {
608 ids.iter()
609 .filter_map(|&id| db.face(id))
610 .flat_map(|face| face.families.iter().map(|(name, _)| name.clone()))
611 .collect()
612}
613
614impl crate::session::Session {
615 /// `Core::add_system_font`'s registration, on the session every window
616 /// shares: a query against the font database's scan and an idempotent
617 /// registry entry, with no file opened. Here rather than on `Core` so
618 /// a binding's prop parser, which holds the token lookup's borrow of
619 /// the core, can name a family while it parses.
620 pub(crate) fn register_family(&self, name: &str) -> Option<crate::resources::FontId> {
621 use cosmic_text::fontdb::{Family, Query};
622 let sess = &mut *self.state();
623 sess.share_faces_once();
624 let db = sess.fonts.db();
625 let query = Query {
626 families: &[Family::Name(name)],
627 ..Default::default()
628 };
629 let id = db.query(&query)?;
630 // The canonical spelling, so the style matches the way fontdb does.
631 let family = db.face(id)?.families.first()?.0.clone();
632 if let Some((id, _)) = sess
633 .resources
634 .fonts
635 .iter()
636 .find(|(_, f)| f.faces.is_empty() && f.family == family)
637 {
638 return Some(id);
639 }
640 sess.fonts_rev += 1;
641 let touched = std::iter::once(family.clone()).collect();
642 let id = sess.resources.add_font(family, Vec::new());
643 if sess.resources.reweigh(sess.fonts.db(), &touched, Some(id)) {
644 sess.weights_rev += 1;
645 }
646 Some(id)
647 }
648}
649
650impl crate::session::SessionState {
651 /// [`share_faces`], the first time an app names a family, unless a
652 /// load has shared them already, and never again.
653 pub(crate) fn share_faces_once(&mut self) {
654 if !self.faces_shared {
655 self.share_loaded_faces();
656 }
657 }
658
659 /// [`share_faces`] after a load, so the faces it added are shared with
660 /// the rest. Sharing before the load, as DX24 first did,
661 /// left every face a file brought in reading its file again each time
662 /// a text shaped in a new family, weight or style: kawoosh loads 167
663 /// files it ships, and each new family cost a frame ~5 ms in opens.
664 /// The walk maps only what is unshared, so a load after the first
665 /// maps its own faces and nothing else. Not for `register_family`,
666 /// which runs every frame a view names a family: `db_mut` empties
667 /// cosmic-text's match cache.
668 pub(crate) fn share_loaded_faces(&mut self) {
669 self.faces_shared = true;
670 share_faces(self.fonts.db_mut());
671 }
672
673 /// `Core::reload_system_fonts`' half on the session: the faces the
674 /// scan this session holds had and `fresh` has not leave the database,
675 /// those `fresh` has and it had not are loaded, and the rest stay
676 /// where they are, handles and all — a new database would hand out
677 /// fresh ids, and every window's atlas, raster axes and shaped text
678 /// key glyphs by id. Returns how many faces came and went.
679 pub(crate) fn apply_system_fonts(
680 &mut self,
681 fresh: std::sync::Arc<crate::text::SystemFonts>,
682 ) -> usize {
683 use cosmic_text::fontdb::{ID, Source};
684 let old = std::mem::replace(&mut self.system, fresh.clone());
685 let gone: rustc_hash::FxHashSet<_> = old.faces.difference(&fresh.faces).collect();
686 let came: Vec<_> = fresh.faces.difference(&old.faces).collect();
687 if gone.is_empty() && came.is_empty() {
688 return 0;
689 }
690 let db = self.fonts.db_mut();
691 let leaving: Vec<ID> = db
692 .faces()
693 .filter(|face| crate::text::face_file(face).is_some_and(|key| gone.contains(&key)))
694 .map(|face| face.id)
695 .collect();
696 let mut touched = families_of(db, &leaving);
697 for &id in &leaving {
698 db.remove_face(id);
699 }
700 // What came, a file at a time: fontdb loads every face of a file,
701 // and only the ones the scan kept (measurable, and not already
702 // here through a sibling that stayed) are wanted.
703 let mut by_file = rustc_hash::FxHashMap::<&std::path::Path, Vec<u32>>::default();
704 for (path, index) in &came {
705 by_file.entry(path.as_path()).or_default().push(*index);
706 }
707 let mut arrived = Vec::new();
708 for (path, indices) in by_file {
709 for id in db.load_font_source(Source::File(path.to_path_buf())) {
710 let wanted = db
711 .face(id)
712 .is_some_and(|face| indices.contains(&face.index));
713 if wanted {
714 arrived.push(id);
715 } else {
716 db.remove_face(id);
717 }
718 }
719 }
720 touched.extend(families_of(db, &arrived));
721 crate::text::pin_default_families(db);
722 // cosmic-text works out its monospaced faces, and which scripts
723 // each covers, when the font system is built; rebuilt over the
724 // same database, the ids stay and the lists are new.
725 let fonts = std::mem::replace(
726 &mut self.fonts,
727 cosmic_text::FontSystem::new_with_locale_and_db(String::new(), Default::default()),
728 );
729 let (locale, db) = fonts.into_locale_and_db();
730 self.fonts = cosmic_text::FontSystem::new_with_locale_and_db(locale, db);
731 if self.faces_shared {
732 share_faces(self.fonts.db_mut());
733 }
734 self.resources.reweigh(self.fonts.db(), &touched, None);
735 // Every window shapes again, whatever reweigh found: a family no
736 // one registered can still be what fallback picks.
737 self.fonts_rev += 1;
738 self.weights_rev += 1;
739 self.system_fonts_rev += 1;
740 leaving.len() + arrived.len()
741 }
742}
743
744/// Maps every file-backed face in the database once and shares the
745/// mapping, as cosmic-text does for each face it loads.
746///
747/// The first time a text shapes in a family (a weight, a style) it has not
748/// shaped in, cosmic-text ranks every face in the database against it, and
749/// for each face of another weight it reads that face's `wght` axis — with
750/// the face unshared, opening and mapping its file for that one read. On a
751/// Mac's 1,311 faces that was 9.7 ms a new family, in release; kawoosh's
752/// fonts pane, drawing each of 613 families in itself, warmed them ahead of
753/// time to keep it off the frames. Shared, the read is a slice of a mapping
754/// already made: 0.42 ms a family, for 30 ms once. Done on the first font
755/// an app registers — a family by name, bytes, a file or a folder — where
756/// the per-family cost starts to add up, and after every load from then on
757/// so a loaded file's own faces are shared too; an app on the stock
758/// three pays what it always did.
759///
760/// The mapping is what fontdb's `make_shared_face_data` documents as
761/// unsafe: a font file another process rewrites while it is mapped can
762/// show the change, and may crash the read. cosmic-text already takes that
763/// risk for every face it shapes with; this takes it for every installed
764/// face, which on a desktop are the system's and the user's fonts.
765pub(crate) fn share_faces(db: &mut cosmic_text::fontdb::Database) -> usize {
766 use cosmic_text::fontdb::Source;
767 let ids: Vec<_> = db
768 .faces()
769 .filter(|f| matches!(f.source, Source::File(_)))
770 .map(|f| f.id)
771 .collect();
772 let mut shared = 0;
773 for id in ids {
774 // A face whose file was shared through a sibling face is skipped by
775 // fontdb itself: `make_shared_face_data` updates every face of the
776 // file at once and answers the existing mapping after that.
777 // SAFETY: see the function's doc — the mapping cosmic-text makes
778 // for every face it loads, made for the rest.
779 if unsafe { db.make_shared_face_data(id) }.is_some() {
780 shared += 1;
781 }
782 }
783 shared
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 /// The atlas's kept page lives for the frame it was kept for and no
791 /// longer: `finish` drops it, where the next `begin_frame` did — on a
792 /// window that goes idle after emptying a 4096 page, never.
793 #[test]
794 fn the_emptied_atlas_page_is_dropped_when_its_frame_finishes() {
795 let frame = |core: &mut Core| {
796 let mut ui = core.frame(crate::Size::new(200.0, 100.0), 1.0);
797 ui.text("kept for a frame", crate::TextStyle::new(14.0));
798 ui.finish();
799 };
800 let mut core = Core::new();
801 frame(&mut core);
802 core.atlas.reset_next_frame();
803 core.begin_frame(crate::Size::new(200.0, 100.0), 1.0);
804 assert!(
805 core.atlas.keeps_prev(),
806 "the frame that emptied it keeps it"
807 );
808 core.finish_frame();
809 assert!(!core.atlas.keeps_prev());
810 }
811
812 /// `reload_system_fonts`' session half, against a scan made up for
813 /// the test (installing a font on the machine running it is not the
814 /// test's to do): the session's own scan less one installed face, plus
815 /// a font file written for it. The new face is found by name, the
816 /// gone one is gone, one that stayed keeps its id, every window is
817 /// told to shape again and hears one `fonts` event, and the same scan
818 /// a second time changes nothing.
819 #[test]
820 fn a_rescan_brings_in_what_came_drops_what_went_and_keeps_the_rest() {
821 use crate::text::{SystemFonts, face_file};
822 let mut core = Core::new();
823 let held = core.session.state().system.clone();
824 let dir = std::env::temp_dir().join(format!("kui-rescan-{}", std::process::id()));
825 std::fs::create_dir_all(&dir).unwrap();
826 let installed = dir.join("rescan.ttf");
827 std::fs::write(
828 &installed,
829 crate::testing::font_face("Kui Rescan Face", 400, false, false),
830 )
831 .unwrap();
832
833 // A file's face is uninstalled whole: one index of a file can be
834 // several faces (Ubuntu's variable `Ubuntu[wdth,wght].ttf` is two
835 // at index 0), and a scan keys on the file, so removing one id
836 // would leave the file installed. The face that stays is another
837 // file's.
838 let mut db = held.db().clone();
839 let uninstalled = db.faces().find_map(face_file);
840 let leaving: Vec<_> = db
841 .faces()
842 .filter(|f| uninstalled.is_some() && face_file(f) == uninstalled)
843 .map(|f| f.id)
844 .collect();
845 let stays = db
846 .faces()
847 .filter_map(|f| Some((f.id, face_file(f)?)))
848 .find(|(_, key)| Some(key) != uninstalled.as_ref());
849 for &id in &leaving {
850 db.remove_face(id);
851 }
852 db.load_font_file(&installed).unwrap();
853 let fresh = std::sync::Arc::new(SystemFonts::from_db(held.locale().into(), db));
854
855 let framed = |core: &mut Core| {
856 core.frame(crate::Size::new(100.0, 100.0), 1.0).finish();
857 let evs = core.take_pending_events();
858 evs.iter()
859 .filter_map(|e| e.kind().map(str::to_owned))
860 .collect::<Vec<_>>()
861 };
862 let mut other = Core::new_in(&core.session);
863 assert!(
864 framed(&mut core).is_empty(),
865 "the first frame establishes the set"
866 );
867 assert!(framed(&mut other).is_empty());
868 let face_of = |core: &Core, key: &(std::path::PathBuf, u32)| {
869 let sess = core.session.state();
870 sess.fonts
871 .db()
872 .faces()
873 .find(|f| face_file(f).as_ref() == Some(key))
874 .map(|f| f.id)
875 };
876 let weights = core.session.state().weights_rev;
877 let changed = core.session.state().apply_system_fonts(fresh.clone());
878 assert_eq!(changed, 1 + leaving.len());
879 assert!(
880 core.session.state().weights_rev > weights,
881 "every window shapes again"
882 );
883 assert!(
884 core.add_system_font("Kui Rescan Face").is_some(),
885 "the installed face is found"
886 );
887 if let Some(key) = &uninstalled {
888 assert_eq!(face_of(&core, key), None, "the uninstalled face is gone");
889 }
890 if let Some((id, key)) = &stays {
891 assert_eq!(
892 face_of(&core, key),
893 Some(*id),
894 "a face that stayed keeps its id"
895 );
896 }
897 assert_eq!(framed(&mut core), ["fonts"], "one event, on the root");
898 assert_eq!(
899 framed(&mut other),
900 ["fonts"],
901 "and one in every window of the session"
902 );
903 assert!(framed(&mut core).is_empty(), "once");
904 assert_eq!(core.session.state().apply_system_fonts(fresh), 0);
905 assert!(
906 framed(&mut core).is_empty(),
907 "a scan that found nothing new is no event"
908 );
909 let _ = std::fs::remove_dir_all(&dir);
910 }
911
912 /// The real rescan: with nothing installed or removed since the
913 /// session's scan, nothing changes and no frame is asked for — and a
914 /// session made after it starts from it.
915 #[test]
916 fn a_rescan_of_an_unchanged_system_changes_nothing() {
917 let mut core = Core::new();
918 core.frame(crate::Size::new(100.0, 100.0), 1.0).finish();
919 core.take_pending_events();
920 assert_eq!(core.reload_system_fonts(), 0);
921 // Nor does a font the app loads itself raise a `fonts` event.
922 core.add_font_data(crate::testing::font_face(
923 "Kui Rescan App",
924 400,
925 false,
926 false,
927 ))
928 .expect("the app's own font loads");
929 core.frame(crate::Size::new(100.0, 100.0), 1.0).finish();
930 assert!(
931 !core
932 .take_pending_events()
933 .iter()
934 .any(|e| e.kind() == Some("fonts"))
935 );
936 let after = Core::new();
937 assert!(std::sync::Arc::ptr_eq(
938 &after.session.state().system,
939 &core.session.state().system
940 ));
941 }
942
943 /// DX24: naming a family shares the database's file-backed faces, once;
944 /// before it, an app on the stock families has mapped nothing.
945 #[test]
946 fn the_first_named_family_shares_the_faces_once() {
947 use cosmic_text::fontdb::Source;
948 let mut core = Core::new();
949 let file_backed = |core: &Core| {
950 let sess = core.session.state();
951 sess.fonts
952 .db()
953 .faces()
954 .filter(|f| matches!(f.source, Source::File(_)))
955 .count()
956 };
957 let before = file_backed(&core);
958 assert!(!core.session.state().faces_shared);
959 let name = core.system_fonts().into_iter().map(|f| f.family).next();
960 let Some(name) = name else {
961 return; // a machine with no installed fonts has nothing to share
962 };
963 core.add_system_font(&name);
964 assert!(core.session.state().faces_shared);
965 assert_eq!(
966 file_backed(&core),
967 0,
968 "all {before} file-backed faces shared"
969 );
970 // A second name does not walk them again.
971 assert_eq!(share_faces(core.session.state().fonts.db_mut()), 0);
972 }
973
974 /// DX25: a file loaded by path or in a folder is shared with the rest,
975 /// its own faces too, whether it is the first font or a later one.
976 /// Sharing before the load left them reading their file each time a
977 /// text shaped in a new family.
978 #[test]
979 fn a_loaded_file_is_shared_too() {
980 use cosmic_text::fontdb::Source;
981 let file_backed = |core: &Core| {
982 let sess = core.session.state();
983 sess.fonts
984 .db()
985 .faces()
986 .filter(|f| matches!(f.source, Source::File(_)))
987 .count()
988 };
989 let dir = std::env::temp_dir().join(format!("kui-dx25-{}", std::process::id()));
990 let (one, two) = (dir.join("one"), dir.join("two"));
991 for d in [&one, &two] {
992 std::fs::create_dir_all(d).unwrap();
993 std::fs::write(d.join("face.ttf"), crate::testing::liga_font()).unwrap();
994 }
995
996 let mut core = Core::new();
997 core.load_font_file(one.join("face.ttf"))
998 .expect("the first font");
999 assert_eq!(file_backed(&core), 0, "the first file's faces shared");
1000 core.load_font_file(two.join("face.ttf"))
1001 .expect("a later font");
1002 assert_eq!(file_backed(&core), 0, "a later file's faces shared");
1003
1004 let mut fresh = Core::new();
1005 assert!(fresh.load_fonts_dir(&one) >= 1);
1006 assert!(fresh.session.state().faces_shared);
1007 assert_eq!(file_backed(&fresh), 0, "a folder's faces shared");
1008 std::fs::remove_dir_all(&dir).ok();
1009 }
1010}