GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/backend/text.rs (60.7K)
1 //! Text shaping for the runner: the shaped-buffer cache, family resolution,
2 //! the display list's text prims gathered for the glyph pass, and the
3 //! popover-occlusion clamp. Platform-neutral — nothing here knows the window
4 //! system; moved out of `window_runner` so another shell can share it.
5
6 use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
7 use std::rc::Rc;
8 use crate::draw::TextSpan;
9
10 /// One shaped buffer in the text cache, keyed by everything that shapes it
11 /// beyond its text (the text is the outer map's key, so a lookup borrows it
12 /// rather than allocating).
13 struct CachedBuffer {
14 /// Physical font size, in thousandths of a px.
15 size_milli: u32,
16 /// The family as the font string names it, size stripped.
17 font: Option<String>,
18 is_vertical: bool,
19 attrs: crate::scene::paint::TextAttrs,
20 /// The scale factor's bits: a laid-out buffer's wrap width and box are
21 /// logical px turned physical by it.
22 scale_bits: u32,
23 /// `Some` for a boxed [`Prim::Text`](crate::scene::paint::Prim::Text) laid
24 /// out by [`get_text_buffer_laid_out`]; `None` for a single run.
25 layout: Option<crate::scene::paint::TextLayout>,
26 /// The laid-out buffer's vertical offset in its box (0 for a single run).
27 voff: f32,
28 /// Shared, not cloned, on a hit: a `Buffer` owns every shaped line and
29 /// glyph, and the frame used to deep-copy one per text prim per frame.
30 buffer: Rc<Buffer>,
31 /// [`BUFFER_TICK`] at the last hit, for least-recently-used eviction.
32 last_used: u64,
33 }
34
35 impl CachedBuffer {
36 fn matches(&self, k: &BufferKey<'_>) -> bool {
37 self.size_milli == k.size_milli
38 && self.font.as_deref() == k.font
39 && self.is_vertical == k.is_vertical
40 && self.attrs == k.attrs
41 && self.scale_bits == k.scale_bits
42 && self.layout == k.layout
43 }
44 }
45
46 /// A cache lookup's key, borrowed from the caller.
47 struct BufferKey<'a> {
48 size_milli: u32,
49 font: Option<&'a str>,
50 is_vertical: bool,
51 attrs: crate::scene::paint::TextAttrs,
52 scale_bits: u32,
53 layout: Option<crate::scene::paint::TextLayout>,
54 }
55
56 /// The text cache holds at most this many buffers; past it the least
57 /// recently used [`BUFFER_EVICT`] go.
58 const BUFFER_CAP: usize = 2000;
59 const BUFFER_EVICT: usize = 100;
60
61 std::thread_local! {
62 /// Text → every shaped variant of it. Variants per text are few (a size,
63 /// a weight, a box), so they are scanned rather than hashed.
64 static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<CachedBuffer>>> =
65 std::cell::RefCell::new(std::collections::HashMap::new());
66 static BUFFER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
67 static BUFFER_TICK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
68 }
69
70 fn buffer_tick() -> u64 {
71 BUFFER_TICK.with(|t| {
72 let n = t.get() + 1;
73 t.set(n);
74 n
75 })
76 }
77
78 /// Vertical text, for a process whose window is a vertical bar (the status bar on a
79 /// screen edge): while it is on, a [`TextLabel`](crate::widget::display::TextLabel) stacks
80 /// its characters one per line, centred in a column `bar_thickness` px wide, and text is
81 /// shaped at a looser 1.05 line height. Process-wide because it is a property of the app,
82 /// not of one window or one label, and shaping may run on any thread. It was two bare
83 /// `pub static`s at the crate root (`IS_VERTICAL`, `BAR_THICKNESS`) until 2026-10-07.
84 pub fn set_vertical_text(bar_thickness: Option<u32>) {
85 use std::sync::atomic::Ordering::Relaxed;
86 // The window's own (`crate::window_state::Props`), and the process's for a worker.
87 crate::window_state::entered(|w| w.props.borrow_mut().vertical_text = bar_thickness);
88 if let Some(t) = bar_thickness {
89 VERTICAL_BAR_THICKNESS.store(t, Relaxed);
90 }
91 VERTICAL_TEXT.store(bar_thickness.is_some(), Relaxed);
92 }
93
94 /// The vertical bar's thickness while vertical text is on (see [`set_vertical_text`]).
95 pub fn vertical_text() -> Option<u32> {
96 crate::window_state::entered(|w| w.props.borrow().vertical_text).unwrap_or_else(process_vertical_text)
97 }
98
99 /// The process-wide vertical text: the last any window set (what a worker thread reads).
100 pub(crate) fn process_vertical_text() -> Option<u32> {
101 use std::sync::atomic::Ordering::Relaxed;
102 VERTICAL_TEXT.load(Relaxed).then(|| VERTICAL_BAR_THICKNESS.load(Relaxed))
103 }
104
105 static VERTICAL_TEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
106 static VERTICAL_BAR_THICKNESS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(24);
107
108 /// The cached buffer for `text` under `key`, and its vertical offset.
109 fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
110 BUFFER_CACHE.with(|cache| {
111 let mut cache = cache.borrow_mut();
112 let hit = cache.get_mut(text)?.iter_mut().find(|e| e.matches(key))?;
113 hit.last_used = buffer_tick();
114 Some((Rc::clone(&hit.buffer), hit.voff))
115 })
116 }
117
118 fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f32) {
119 BUFFER_CACHE.with(|cache| {
120 let mut cache = cache.borrow_mut();
121 if BUFFER_COUNT.with(|c| c.get()) >= BUFFER_CAP {
122 let mut ticks: Vec<u64> = cache.values().flatten().map(|e| e.last_used).collect();
123 ticks.sort_unstable();
124 let cutoff = ticks[BUFFER_EVICT.min(ticks.len()) - 1];
125 cache.retain(|_, v| {
126 v.retain(|e| e.last_used > cutoff);
127 !v.is_empty()
128 });
129 BUFFER_COUNT.with(|c| c.set(cache.values().map(Vec::len).sum()));
130 }
131 let entry = CachedBuffer {
132 size_milli: key.size_milli,
133 font: key.font.map(str::to_owned),
134 is_vertical: key.is_vertical,
135 attrs: key.attrs,
136 scale_bits: key.scale_bits,
137 layout: key.layout,
138 voff,
139 buffer,
140 last_used: buffer_tick(),
141 };
142 match cache.get_mut(text) {
143 Some(v) => v.push(entry),
144 None => {
145 cache.insert(text.to_owned(), vec![entry]);
146 }
147 }
148 BUFFER_COUNT.with(|c| c.set(c.get() + 1));
149 });
150 }
151
152 /// The family-name prefix of a [`face_family`] alias.
153 const FACE_ALIAS_PREFIX: &str = "cce-face:";
154
155 /// One [`face_family`] alias: the face at `path`#`index`, named `cce-face:<n>`
156 /// for its place `n` in [`FONT_OPS`].
157 struct FaceAlias {
158 path: std::path::PathBuf,
159 index: u32,
160 }
161
162 /// A font-directory rescan ([`crate::rescan_fonts`]): what changed on disk,
163 /// as fixed lists rather than a scan each database makes for itself, so every
164 /// database applies the same change however long after the others. A change,
165 /// not the whole set: a database built after the rescan already has it, and
166 /// replaying it there is a no-op rather than a drop of whatever arrived since.
167 #[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
168 pub(crate) struct Rescan {
169 /// Font files new on disk, in a system-fonts build's load order.
170 added: Vec<std::path::PathBuf>,
171 /// Font files gone from disk.
172 removed: std::collections::HashSet<std::path::PathBuf>,
173 /// The files a bundled-only build loads ([`crate::create_font_system`]):
174 /// the CCE fonts dir and the fallback faces.
175 bundled: std::collections::HashSet<std::path::PathBuf>,
176 }
177
178 #[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
179 impl Rescan {
180 pub(crate) fn new(
181 added: Vec<std::path::PathBuf>,
182 removed: std::collections::HashSet<std::path::PathBuf>,
183 bundled: std::collections::HashSet<std::path::PathBuf>,
184 ) -> Self {
185 Rescan { added, removed, bundled }
186 }
187
188 pub(crate) fn is_empty(&self) -> bool {
189 self.added.is_empty() && self.removed.is_empty()
190 }
191 }
192
193 /// A change every `FontSystem` makes to its database, in one order.
194 #[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
195 enum FontOp {
196 Alias(FaceAlias),
197 Rescan(std::sync::Arc<Rescan>),
198 }
199
200 /// Every change to the font set, in the order made. Process-wide, and only
201 /// ever appended to: each `FontSystem` replays it into its database in this
202 /// order (see [`sync_font_ops`]), so two databases loaded alike give every
203 /// face the same fontdb ID. They must — the shaped-buffer cache is shared by
204 /// every `FontSystem` on the thread, and a buffer the app's measuring system
205 /// shaped is rasterized by the engine's with the face IDs it carries. A
206 /// rescan rides the same log for that reason: applied at a different point
207 /// relative to the aliases in two databases, it would number them apart.
208 static FONT_OPS: std::sync::Mutex<Vec<FontOp>> = std::sync::Mutex::new(Vec::new());
209
210 thread_local! {
211 /// Database address → how many of [`FONT_OPS`] it has applied, and the ID
212 /// and op number of the last alias it pushed — checked before it is
213 /// trusted, since a dropped `FontSystem`'s address can be reused by a
214 /// fresh one.
215 static FONT_OPS_SYNCED: std::cell::RefCell<
216 std::collections::HashMap<usize, (usize, Option<(cosmic_text::fontdb::ID, usize)>)>,
217 > = std::cell::RefCell::new(std::collections::HashMap::new());
218 }
219
220 /// A family name that resolves to exactly the face at `path`#`index` (the
221 /// fontdb face index: non-zero only inside a collection), for a font string or
222 /// a [`Prim::Text`](crate::scene::paint::Prim::Text)'s font.
223 ///
224 /// Text names a face by family + style, stretch and weight, so a family with
225 /// two faces alike in all three can show only whichever fontdb met first.
226 /// They are not rare: BodonianScript's seven numbered cuts and FormP Color
227 /// Six's colourways are all Regular 400, and Old Timey Mono's Condensed and
228 /// Compressed files claim normal width, so the font picker previewed each of
229 /// them as its family's first face. The alias is a family of that one face,
230 /// so any attrs land on it.
231 ///
232 /// Repeated calls for the same face return the same name. Every `FontSystem`
233 /// the text backend shapes or draws with picks the aliases up by itself; one
234 /// whose database lacks the file leaves the alias unresolved, and its text
235 /// falls back as an unknown family's would.
236 pub fn face_family(path: impl AsRef<std::path::Path>, index: u32) -> String {
237 let path = path.as_ref();
238 let mut ops = FONT_OPS.lock().unwrap_or_else(|e| e.into_inner());
239 let n = match ops.iter().position(|op| matches!(op, FontOp::Alias(a) if a.path == path && a.index == index)) {
240 Some(n) => n,
241 None => {
242 ops.push(FontOp::Alias(FaceAlias { path: path.to_path_buf(), index }));
243 ops.len() - 1
244 }
245 };
246 format!("{FACE_ALIAS_PREFIX}{n}")
247 }
248
249 /// Record a rescan for every `FontSystem` to apply (see [`FontOp`]), and drop
250 /// this thread's caches that a family appearing or vanishing makes stale:
251 /// the shaped buffers (a family that fell back before resolves now, and a
252 /// removed face's ID must not reach the glyph pass) and the per-family face
253 /// lookups.
254 #[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
255 pub(crate) fn push_rescan(rescan: Rescan) {
256 FONT_OPS.lock().unwrap_or_else(|e| e.into_inner()).push(FontOp::Rescan(std::sync::Arc::new(rescan)));
257 BUFFER_CACHE.with(|c| c.borrow_mut().clear());
258 BUFFER_COUNT.with(|c| c.set(0));
259 MONO_FAMILY_CACHE.with(|c| c.borrow_mut().clear());
260 FAMILY_FACES_CACHE.with(|c| c.borrow_mut().clear());
261 }
262
263 /// Bring `fs` up to date with every font-set change made so far — the face
264 /// aliases and the rescans ([`crate::rescan_fonts`]). The text backend does
265 /// this itself before it shapes or draws; call it before reading a database
266 /// directly (a font picker listing its faces) after a rescan.
267 pub fn sync_font_set(fs: &mut FontSystem) {
268 sync_font_ops(fs);
269 }
270
271 pub(crate) fn is_alias_face(face: &cosmic_text::fontdb::FaceInfo) -> bool {
272 face.families.first().is_some_and(|(fam, _)| fam.starts_with(FACE_ALIAS_PREFIX))
273 }
274
275 /// Replay the [`FONT_OPS`] `fs` has not applied yet into its database, in
276 /// their order. Cheap when there is nothing new: a map lookup and one face
277 /// check. Every op is idempotent, so a database the record cannot vouch for
278 /// (never seen, or a reused address) replays the whole log safely.
279 fn sync_font_ops(fs: &mut FontSystem) {
280 let ops = FONT_OPS.lock().unwrap_or_else(|e| e.into_inner());
281 replay_font_ops(fs, &ops);
282 }
283
284 /// [`sync_font_ops`] against a given log (the tests keep their own, so their
285 /// rescans reach no other test's databases).
286 fn replay_font_ops(fs: &mut FontSystem, ops: &[FontOp]) {
287 use cosmic_text::fontdb::{Language, Source};
288 if ops.is_empty() {
289 return;
290 }
291 let key = fs.db() as *const cosmic_text::fontdb::Database as usize;
292 let alias_name = |n: usize| format!("{FACE_ALIAS_PREFIX}{n}");
293 let is_alias = |fs: &FontSystem, id: cosmic_text::fontdb::ID, n: usize| {
294 fs.db().face(id).is_some_and(|f| f.families.first().is_some_and(|(fam, _)| *fam == alias_name(n)))
295 };
296 let synced = FONT_OPS_SYNCED.with(|m| m.borrow().get(&key).copied());
297 let (mut done, mut last) = match synced {
298 Some((done, last)) if last.is_none_or(|(id, n)| is_alias(fs, id, n)) => (done, last),
299 _ => (0, None),
300 };
301 if done == ops.len() {
302 return;
303 }
304 // Replaying from the start: the aliases already present, so none is
305 // pushed twice. (Past a trusted count none can be.)
306 let mut present: std::collections::HashMap<String, cosmic_text::fontdb::ID> = if done == 0 {
307 fs.db().faces().filter(|f| is_alias_face(f)).map(|f| (f.families[0].0.clone(), f.id)).collect()
308 } else {
309 Default::default()
310 };
311 for (n, op) in ops.iter().enumerate().skip(done) {
312 match op {
313 FontOp::Alias(alias) => {
314 let name = alias_name(n);
315 if let Some(&id) = present.get(&name) {
316 last = Some((id, n));
317 } else {
318 let source = fs
319 .db()
320 .faces()
321 .find(|f| {
322 f.index == alias.index
323 && matches!(&f.source, Source::File(p) | Source::SharedFile(p, _) if *p == alias.path)
324 })
325 .cloned();
326 if let Some(mut face) = source {
327 face.families = vec![(name.clone(), Language::English_UnitedStates)];
328 fs.db_mut().push_face_info(face);
329 if let Some(f) = fs.db().faces().find(|f| f.families.first().is_some_and(|(fam, _)| *fam == name)) {
330 present.insert(name, f.id);
331 last = Some((f.id, n));
332 }
333 }
334 }
335 }
336 FontOp::Rescan(rescan) => apply_rescan(fs, rescan),
337 }
338 done = n + 1;
339 }
340 FONT_OPS_SYNCED.with(|m| m.borrow_mut().insert(key, (done, last)));
341 }
342
343 /// One rescan, into one database: the faces of removed files are dropped,
344 /// and added files are loaded — in the rescan's order, so databases alike stay
345 /// alike. A bundled-only database (one holding nothing outside the bundled
346 /// set) takes only added bundled files: a rescan does not hand the system's
347 /// fonts to an app that never loaded them. Alias faces stay even when their
348 /// file goes — they mark how far a database has synced; one whose file is
349 /// gone simply no longer resolves.
350 fn apply_rescan(fs: &mut FontSystem, rescan: &Rescan) {
351 use cosmic_text::fontdb::Source;
352 let mut present = std::collections::HashSet::new();
353 let mut gone = Vec::new();
354 let mut has_system = false;
355 for f in fs.db().faces() {
356 if is_alias_face(f) {
357 continue;
358 }
359 let (Source::File(p) | Source::SharedFile(p, _)) = &f.source else { continue };
360 if rescan.removed.contains(p) {
361 gone.push(f.id);
362 continue;
363 }
364 has_system |= !rescan.bundled.contains(p);
365 present.insert(p.clone());
366 }
367 let db = fs.db_mut();
368 for id in gone {
369 db.remove_face(id);
370 }
371 for p in &rescan.added {
372 if !present.contains(p) && (has_system || rescan.bundled.contains(p)) {
373 let _ = db.load_font_file(p);
374 present.insert(p.clone());
375 }
376 }
377 }
378
379 fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
380 let lower_name = name.to_lowercase();
381 for face in fs.db().faces() {
382 for (family, _) in &face.families {
383 if family.to_lowercase() == lower_name {
384 return Some(family.clone());
385 }
386 }
387 }
388 None
389 }
390
391 thread_local! {
392 /// Family name → is-monospaced, resolved once per family from fontdb's
393 /// face metadata (the post table's isFixedPitch, as fontdb records it).
394 static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
395 std::cell::RefCell::new(std::collections::HashMap::new());
396 }
397
398 fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
399 MONO_FAMILY_CACHE.with(|cache| {
400 if let Some(&mono) = cache.borrow().get(name) {
401 return mono;
402 }
403 let lower = name.to_lowercase();
404 let mono = fs
405 .db()
406 .faces()
407 .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
408 .map(|face| face.monospaced)
409 .unwrap_or(false);
410 cache.borrow_mut().insert(name.to_string(), mono);
411 mono
412 })
413 }
414
415 thread_local! {
416 /// Family name → the (style, stretch, weight) of every face fontdb holds
417 /// under that name, resolved once per family for [`snap_to_family_face`].
418 static FAMILY_FACES_CACHE: std::cell::RefCell<
419 std::collections::HashMap<String, Vec<(cosmic_text::Style, cosmic_text::Stretch, u16)>>,
420 > = std::cell::RefCell::new(std::collections::HashMap::new());
421 }
422
423 /// The `cosmic_text::Stretch` for an OpenType width class (1–9, clamped; see
424 /// [`TextAttrs::stretch`](crate::scene::paint::TextAttrs::stretch)).
425 fn stretch_from_width_class(class: u16) -> cosmic_text::Stretch {
426 use cosmic_text::Stretch::*;
427 match class {
428 0 | 1 => UltraCondensed,
429 2 => ExtraCondensed,
430 3 => Condensed,
431 4 => SemiCondensed,
432 5 => Normal,
433 6 => SemiExpanded,
434 7 => Expanded,
435 8 => ExtraExpanded,
436 _ => UltraExpanded,
437 }
438 }
439
440 /// `attrs` moved onto the nearest face its named family actually has.
441 ///
442 /// cosmic-text 0.12 takes a face of the requested family only when its style
443 /// and stretch equal the request (`Attrs::matches`) AND its weight does too
444 /// (`font_weight_diff == 0` in `FontFallbackIter`); anything else falls
445 /// through to the fallback families. So a family with no face at the asked
446 /// weight rendered in some other font entirely: a Thin-only cut at weight
447 /// 280 asked for at 400, a pixel font that only ships Medium, a
448 /// Condensed-only family asked for at normal width — the font picker's
449 /// preview showed the fallback sans for each. Matching here instead follows
450 /// CSS font matching's order — stretch, then style, then weight — so a named
451 /// family always renders as itself, in its closest face. A request the
452 /// family can meet exactly, a generic family, or a name fontdb does not know
453 /// passes through untouched.
454 fn snap_to_family_face<'a>(fs: &FontSystem, attrs: Attrs<'a>) -> Attrs<'a> {
455 use cosmic_text::{Stretch, Style};
456 let cosmic_text::Family::Name(name) = attrs.family else { return attrs };
457 let faces = FAMILY_FACES_CACHE.with(|cache| {
458 cache
459 .borrow_mut()
460 .entry(name.to_string())
461 .or_insert_with(|| {
462 fs.db()
463 .faces()
464 .filter(|face| face.families.iter().any(|(f, _)| f == name))
465 .map(|face| (face.style, face.stretch, face.weight.0))
466 .collect()
467 })
468 .clone()
469 });
470 let want = (attrs.style, attrs.stretch, attrs.weight.0);
471 if faces.is_empty() || faces.contains(&want) {
472 return attrs;
473 }
474 let stretch_rank = |s: Stretch| {
475 let (w, f) = (attrs.stretch.to_number() as i32, s.to_number() as i32);
476 // Narrower-first below normal width, wider-first above it (CSS).
477 let toward = if w <= 5 { f < w } else { f > w };
478 ((w - f).abs() * 2 + if toward || f == w { 0 } else { 1 }) as u32
479 };
480 let style_rank = |s: Style| match (attrs.style, s) {
481 (a, b) if a == b => 0u32,
482 (Style::Italic, Style::Oblique) | (Style::Oblique, Style::Italic) => 1,
483 _ => 2,
484 };
485 let weight_rank = |w: u16| {
486 let want = attrs.weight.0;
487 // Ties go lighter for a light-to-regular request, heavier above it.
488 let off_side = if want <= 450 { w > want } else { w < want };
489 (want.abs_diff(w) as u32) * 2 + off_side as u32
490 };
491 let Some(&(style, stretch, weight)) = faces
492 .iter()
493 .min_by_key(|(st, sr, w)| (stretch_rank(*sr), style_rank(*st), weight_rank(*w)))
494 else {
495 return attrs;
496 };
497 attrs.style(style).stretch(stretch).weight(cosmic_text::Weight(weight))
498 }
499
500 /// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
501 /// shapes `Basic`, everything else `Advanced`.
502 ///
503 /// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
504 /// mono face that is exactly right: a mono font's ligatures are the one thing
505 /// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
506 /// squeezes f+i into a single-advance fi glyph, which is why the bar's window
507 /// titles rendered "file" with a cramped fi — while mono faces carry no
508 /// kerning to lose. Proportional faces keep `Advanced` (their kerning and
509 /// ligatures are wanted — a font preview must not misrepresent the face), and
510 /// any non-ASCII text keeps real shaping (combining marks, emoji, complex
511 /// scripts) whatever the face.
512 pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
513 if text.is_ascii() {
514 if let cosmic_text::Family::Name(name) = family {
515 if family_is_monospaced(fs, name) {
516 return cosmic_text::Shaping::Basic;
517 }
518 }
519 }
520 cosmic_text::Shaping::Advanced
521 }
522
523 pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
524 get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
525 }
526
527 /// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
528 /// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
529 pub fn get_text_buffer_attrs(
530 fs: &mut FontSystem,
531 text: &str,
532 size: f32,
533 font: Option<&str>,
534 text_attrs: crate::scene::paint::TextAttrs,
535 ) -> Buffer {
536 Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs))
537 }
538
539 /// [`get_text_buffer_attrs`] without the copy: the cached buffer itself,
540 /// shared. What the frame and the toolkit's own measuring use — a `Buffer`
541 /// owns every shaped line and glyph, so the clone the public functions hand
542 /// out costs as much as the text is long.
543 pub(crate) fn shared_text_buffer(
544 fs: &mut FontSystem,
545 text: &str,
546 size: f32,
547 font: Option<&str>,
548 text_attrs: crate::scene::paint::TextAttrs,
549 ) -> Rc<Buffer> {
550 let scale = crate::scale::scale_factor();
551 let (family_name, font_size) = match font {
552 Some(font_str) => {
553 let (family, parsed_size) = crate::layout::split_font_string(font_str);
554 (Some(family), parsed_size.unwrap_or(size))
555 }
556 None => (None, size),
557 };
558
559 let physical_size = font_size * scale;
560 let is_vertical = vertical_text().is_some();
561 let key = BufferKey {
562 size_milli: (physical_size * 1000.0).round() as u32,
563 font: family_name,
564 is_vertical,
565 attrs: text_attrs,
566 scale_bits: scale.to_bits(),
567 layout: None,
568 };
569 if let Some((buf, _)) = buffer_cache_get(text, &key) {
570 return buf;
571 }
572 // Before any family resolves: a face alias must be in this database first.
573 sync_font_ops(fs);
574
575 let line_height = if is_vertical {
576 physical_size * 1.05
577 } else {
578 physical_size * 1.0
579 };
580 let metrics = Metrics::new(physical_size, line_height);
581 let mut buf = Buffer::new(fs, metrics);
582 let mut attrs = Attrs::new();
583
584 let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
585
586 let resolved_storage = family_name.and_then(|font_name| match font_name {
587 "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
588 "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
589 "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
590 _ => None,
591 });
592
593 let resolved_sans = if !sans_fallback.is_empty() {
594 find_cased_family(fs, &sans_fallback)
595 } else {
596 None
597 };
598
599 let family = if let Some(font_family) = family_name {
600 match font_family {
601 "monospace" => {
602 if !mono_fallback.is_empty() {
603 if let Some(ref cased) = resolved_storage {
604 cosmic_text::Family::Name(cased)
605 } else {
606 cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
607 }
608 } else {
609 cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
610 }
611 }
612 "sans-serif" => {
613 if !sans_fallback.is_empty() {
614 if let Some(ref cased) = resolved_storage {
615 cosmic_text::Family::Name(cased)
616 } else {
617 cosmic_text::Family::SansSerif
618 }
619 } else {
620 cosmic_text::Family::SansSerif
621 }
622 }
623 "serif" => {
624 if !serif_fallback.is_empty() {
625 if let Some(ref cased) = resolved_storage {
626 cosmic_text::Family::Name(cased)
627 } else {
628 cosmic_text::Family::Serif
629 }
630 } else {
631 cosmic_text::Family::Serif
632 }
633 }
634 name => cosmic_text::Family::Name(name),
635 }
636 } else {
637 if !sans_fallback.is_empty() {
638 if let Some(ref cased) = resolved_sans {
639 cosmic_text::Family::Name(cased)
640 } else {
641 cosmic_text::Family::SansSerif
642 }
643 } else {
644 cosmic_text::Family::SansSerif
645 }
646 };
647 attrs = attrs.family(family);
648 if text_attrs.italic {
649 attrs = attrs.style(cosmic_text::Style::Italic);
650 }
651 if let Some(w) = text_attrs.weight {
652 attrs = attrs.weight(cosmic_text::Weight(w));
653 }
654 if let Some(s) = text_attrs.stretch {
655 attrs = attrs.stretch(stretch_from_width_class(s));
656 }
657 let attrs = snap_to_family_face(fs, attrs);
658 let shaping = shaping_for(fs, text, &family);
659 buf.set_text(fs, text, attrs, shaping);
660 buf.shape_until_scroll(fs, true);
661
662 let buf = Rc::new(buf);
663 buffer_cache_put(text, &key, Rc::clone(&buf), 0.0);
664 buf
665 }
666
667 /// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
668 /// same buffer cache as the draw, so this is a lookup when the text is already on screen.
669 /// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
670 /// the text origin), terminated by `(text.len(), total_advance)`. A cluster's x is its
671 /// LEADING edge — its left in left-to-right text, its right in right-to-left — so the pairs
672 /// are ascending in bytes but not in x where the text turns; the closing pair is the width,
673 /// which is where the caret after the text stands only in left-to-right text. For carets and
674 /// selections in text of either direction use [`shaped_run`]. This is the correct
675 /// source for caret placement and click→cursor mapping in hand-rolled text fields:
676 /// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
677 /// resolution, which disagrees with cosmic-text's advance and can even resolve a
678 /// different face — a caret placed with it drifts off the drawn glyphs.
679 pub fn shaped_cluster_offsets(
680 fs: &mut FontSystem,
681 text: &str,
682 size: f32,
683 font: Option<&str>,
684 ) -> Vec<(usize, f32)> {
685 let scale = crate::scale::scale_factor().max(1.0);
686 let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
687 let run = shaped_run(&buffer, text, scale);
688 let mut out: Vec<(usize, f32)> = run.clusters.iter().map(|c| (c.start, c.leading())).collect();
689 out.push((text.len(), run.width));
690 out
691 }
692
693 /// Whether `text` is a right-to-left paragraph: its first strong character is right to left
694 /// (Hebrew, Arabic, …), as the Unicode bidirectional algorithm decides a paragraph's base
695 /// direction. Text with no strong character at all is left to right.
696 pub fn paragraph_rtl(text: &str) -> bool {
697 matches!(unicode_bidi::get_base_direction(text), unicode_bidi::Direction::Rtl)
698 }
699
700 /// The visual order of runs of one line, given each run's text, in a paragraph whose base
701 /// direction is `rtl`: the bidirectional algorithm's reordering (rule L2) at the
702 /// granularity of runs. A run is at the paragraph's level when it has no strong character,
703 /// one level up when its first strong character goes against the paragraph, and the
704 /// sequences at each level from the highest down are reversed. So in a left-to-right line
705 /// two Hebrew runs side by side swap places, and in a right-to-left line every run is
706 /// placed from the right while English runs keep their order among themselves. Returns
707 /// indices into `runs`, left to right.
708 pub fn visual_run_order(runs: &[&str], rtl: bool) -> Vec<usize> {
709 let base: u8 = if rtl { 1 } else { 0 };
710 let levels: Vec<u8> = runs
711 .iter()
712 .map(|t| match unicode_bidi::get_base_direction(*t) {
713 unicode_bidi::Direction::Rtl => if base % 2 == 1 { base } else { base + 1 },
714 unicode_bidi::Direction::Ltr => if base.is_multiple_of(2) { base } else { base + 1 },
715 unicode_bidi::Direction::Mixed => base,
716 })
717 .collect();
718 visual_order(&levels)
719 }
720
721 /// The embedding level of every byte of `text` as one paragraph (`rtl` its base
722 /// direction), neutrals resolved from their neighbours — the bidirectional algorithm's
723 /// levels, rules W1–I2. Odd is right to left.
724 pub fn bidi_levels(text: &str, rtl: bool) -> Vec<u8> {
725 let base = if rtl { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() };
726 unicode_bidi::BidiInfo::new(text, Some(base)).levels.iter().map(|l| l.number()).collect()
727 }
728
729 /// Left-to-right order of items at `levels` (rule L2): from the highest level down to the
730 /// lowest odd one, every maximal run of items at that level or above is reversed.
731 pub fn visual_order(levels: &[u8]) -> Vec<usize> {
732 let mut order: Vec<usize> = (0..levels.len()).collect();
733 let max = levels.iter().copied().max().unwrap_or(0);
734 for level in (1..=max).rev() {
735 let mut i = 0;
736 while i < order.len() {
737 if levels[order[i]] >= level {
738 let start = i;
739 while i < order.len() && levels[order[i]] >= level {
740 i += 1;
741 }
742 order[start..i].reverse();
743 } else {
744 i += 1;
745 }
746 }
747 }
748 order
749 }
750
751 /// One cluster of a [`ShapedRun`]: the bytes `start..end` drawn as one unit (a glyph, a
752 /// ligature, a base with its marks), spanning `x0..x1` (logical px, left to right whatever
753 /// the direction), and whether it reads right to left.
754 #[derive(Debug, Clone, Copy, PartialEq)]
755 pub struct ShapedCluster {
756 pub start: usize,
757 pub end: usize,
758 pub x0: f32,
759 pub x1: f32,
760 pub rtl: bool,
761 }
762
763 impl ShapedCluster {
764 /// Where the caret before this cluster stands: its left in left-to-right text, its
765 /// right in right-to-left.
766 pub fn leading(&self) -> f32 {
767 if self.rtl { self.x1 } else { self.x0 }
768 }
769
770 /// Where the caret after it stands.
771 pub fn trailing(&self) -> f32 {
772 if self.rtl { self.x0 } else { self.x1 }
773 }
774 }
775
776 /// A single line of text as shaped, in the terms an editor needs whatever its direction:
777 /// where the caret stands at every char boundary, the clusters in logical order with their
778 /// boxes, the width, and the paragraph's base direction (cosmic-text takes it from the first
779 /// strong character, as the Unicode bidirectional algorithm does).
780 #[derive(Debug, Clone, Default, PartialEq)]
781 pub struct ShapedRun {
782 /// `(byte, x)` for every char boundary, ascending in bytes, ending at `text.len()`: the
783 /// caret before the char at `byte` stands at its cluster's leading edge (a boundary
784 /// inside a cluster takes the cluster's), and the caret after the text at the last
785 /// char's trailing edge — the RIGHT end of left-to-right text, the LEFT end of
786 /// right-to-left. In mixed text x is not monotonic.
787 pub stops: Vec<(usize, f32)>,
788 /// The clusters, in logical order (by `start`).
789 pub clusters: Vec<ShapedCluster>,
790 /// The glyphs' extent, trailing spaces included.
791 pub width: f32,
792 /// The paragraph's base direction.
793 pub rtl: bool,
794 }
795
796 impl ShapedRun {
797 /// The char boundary nearest x: a click's caret.
798 pub fn index_at(&self, x: f32) -> usize {
799 self.stops
800 .iter()
801 .min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs()))
802 .map_or(0, |s| s.0)
803 }
804
805 /// The caret x before byte `at` (the nearest boundary at or before it).
806 pub fn x_of(&self, at: usize) -> f32 {
807 self.stops.iter().rev().find(|s| s.0 <= at).map_or(0.0, |s| s.1)
808 }
809
810 /// The x spans the bytes `a..b` cover, left to right and merged where they touch: one
811 /// span in text of one direction, more where a selection crosses a change of direction
812 /// (the logical range is then visually apart).
813 pub fn spans(&self, a: usize, b: usize) -> Vec<(f32, f32)> {
814 let mut boxes: Vec<(f32, f32)> =
815 self.clusters.iter().filter(|c| c.start < b && c.end > a).map(|c| (c.x0, c.x1)).collect();
816 boxes.sort_by(|p, q| p.0.total_cmp(&q.0));
817 let mut out: Vec<(f32, f32)> = Vec::new();
818 for (x0, x1) in boxes {
819 match out.last_mut() {
820 Some(last) if x0 <= last.1 + 0.5 => last.1 = last.1.max(x1),
821 _ => out.push((x0, x1)),
822 }
823 }
824 out
825 }
826 }
827
828 /// Shape-derived positions of single-line `text` from its `buffer` (see [`ShapedRun`]),
829 /// in logical px at `scale`.
830 pub fn shaped_run(buffer: &Buffer, text: &str, scale: f32) -> ShapedRun {
831 let scale = scale.max(0.01);
832 let mut rtl = false;
833 let mut glyphs: Vec<ShapedCluster> = Vec::new();
834 let mut first = true;
835 for run in buffer.layout_runs() {
836 if first {
837 rtl = run.rtl;
838 first = false;
839 }
840 for g in run.glyphs {
841 glyphs.push(ShapedCluster { start: g.start, end: g.end, x0: g.x / scale, x1: (g.x + g.w) / scale, rtl: g.level.is_rtl() });
842 }
843 }
844 // The Basic shaping path's span-relative starts (see `normalized_glyph_starts`): ASCII
845 // only, one glyph per char in logical order.
846 if text.is_ascii() && glyphs.windows(2).any(|w| w[1].start < w[0].start) {
847 for (g, (i, _)) in glyphs.iter_mut().zip(text.char_indices()) {
848 g.start = i;
849 g.end = i + 1;
850 }
851 }
852 // One cluster per byte range: a base and its marks are several glyphs of one cluster.
853 glyphs.sort_by(|a, b| a.start.cmp(&b.start).then(a.x0.total_cmp(&b.x0)));
854 let mut clusters: Vec<ShapedCluster> = Vec::new();
855 for g in glyphs {
856 match clusters.last_mut() {
857 Some(c) if c.start == g.start && c.end == g.end => {
858 c.x0 = c.x0.min(g.x0);
859 c.x1 = c.x1.max(g.x1);
860 }
861 _ => clusters.push(g),
862 }
863 }
864 let width = clusters.iter().map(|c| c.x1).fold(0.0, f32::max);
865 let mut stops: Vec<(usize, f32)> = Vec::with_capacity(text.len() + 1);
866 let mut ci = 0;
867 let mut last_x = if rtl { width } else { 0.0 };
868 for (b, _) in text.char_indices() {
869 while ci < clusters.len() && clusters[ci].end <= b {
870 ci += 1;
871 }
872 if let Some(c) = clusters.get(ci).filter(|c| c.start <= b) {
873 last_x = c.leading();
874 }
875 stops.push((b, last_x));
876 }
877 let end_x = match clusters.last() {
878 Some(c) => c.trailing(),
879 None => 0.0,
880 };
881 stops.push((text.len(), end_x));
882 ShapedRun { stops, clusters, width, rtl }
883 }
884
885 /// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
886 /// with `start` normalized to be text-relative.
887 ///
888 /// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
889 /// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
890 /// word — while the Advanced path emits line-relative starts. `shaping_for`
891 /// picks Basic exactly for ASCII text in a monospace family (the DE's default
892 /// control font), so any multi-word value hit the bug: offsets keyed by those
893 /// starts collide on the low columns and the caret/selection walk off the
894 /// glyphs. That path shapes strictly one glyph per char in logical order, and
895 /// only ASCII text — so in ASCII text a reset means it, and byte starts are
896 /// rebuilt by walking the text's chars. Anywhere else glyph starts fall
897 /// because the text turns right to left (glyphs are in visual order), and are
898 /// kept: until 2026-10-08 any fall was read as the reset, which scrambled
899 /// every right-to-left run. `text` must be the single line the buffer was
900 /// shaped from.
901 pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
902 let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
903 let mut monotonic = true;
904 let mut prev = 0usize;
905 for run in buffer.layout_runs() {
906 for g in run.glyphs {
907 if g.start < prev {
908 monotonic = false;
909 }
910 prev = g.start;
911 glyphs.push((g.start, g.x, g.w));
912 }
913 }
914 if !monotonic && text.is_ascii() {
915 let mut starts = text.char_indices().map(|(i, _)| i);
916 for g in glyphs.iter_mut() {
917 g.0 = starts.next().unwrap_or(text.len());
918 }
919 }
920 glyphs
921 }
922
923 /// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
924 /// Starts from [`get_text_buffer_attrs`]'s single run for all the family resolution, then
925 /// re-lays it out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
926 /// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside
927 /// the box per `align_v`. Cached beside the single runs, keyed by the box as well.
928 pub fn get_text_buffer_laid_out(
929 fs: &mut FontSystem,
930 text: &str,
931 size: f32,
932 font: Option<&str>,
933 text_attrs: crate::scene::paint::TextAttrs,
934 layout: crate::scene::paint::TextLayout,
935 ) -> (Buffer, f32) {
936 let (buf, voff) = shared_laid_out_buffer(fs, text, size, font, text_attrs, layout);
937 (Buffer::clone(&buf), voff)
938 }
939
940 /// [`get_text_buffer_laid_out`] without the copy, as [`shared_text_buffer`] is to
941 /// [`get_text_buffer_attrs`]. Until 2026-10-04 a boxed text was re-shaped from scratch
942 /// every frame it was drawn; the box is part of the key now.
943 pub(crate) fn shared_laid_out_buffer(
944 fs: &mut FontSystem,
945 text: &str,
946 size: f32,
947 font: Option<&str>,
948 text_attrs: crate::scene::paint::TextAttrs,
949 layout: crate::scene::paint::TextLayout,
950 ) -> (Rc<Buffer>, f32) {
951 use crate::scene::paint::{AlignH, AlignV};
952 let scale = crate::scale::scale_factor();
953
954 // The font string may override the size ("family:size") — mirror shared_text_buffer.
955 let (family, font_size) = match font {
956 Some(font_str) => {
957 let (family, parsed_size) = crate::layout::split_font_string(font_str);
958 (Some(family), parsed_size.unwrap_or(size))
959 }
960 None => (None, size),
961 };
962 let physical_size = font_size * scale;
963 let key = BufferKey {
964 size_milli: (physical_size * 1000.0).round() as u32,
965 font: family,
966 is_vertical: vertical_text().is_some(),
967 attrs: text_attrs,
968 scale_bits: scale.to_bits(),
969 layout: Some(layout),
970 };
971 if let Some(hit) = buffer_cache_get(text, &key) {
972 return hit;
973 }
974
975 // Resolved family + attrs come from the single run; this copy is ours to re-lay-out.
976 let mut buf = Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs));
977 let line_height = physical_size * 1.4;
978 buf.set_metrics(fs, Metrics::new(physical_size, line_height));
979 buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
980
981 let align = match layout.align_h {
982 AlignH::Left => cosmic_text::Align::Left,
983 AlignH::Center => cosmic_text::Align::Center,
984 AlignH::Right => cosmic_text::Align::Right,
985 };
986 for line in &mut buf.lines {
987 line.set_align(Some(align));
988 }
989 buf.shape_until_scroll(fs, true);
990
991 // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
992 let runs = buf.layout_runs().count();
993 let total_h = runs as f32 * font_size * 1.4;
994 let voff = match layout.align_v {
995 AlignV::Top => 0.0,
996 AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
997 AlignV::Bottom => (layout.box_height - total_h).max(0.0),
998 };
999 let buf = Rc::new(buf);
1000 buffer_cache_put(text, &key, Rc::clone(&buf), voff);
1001 (buf, voff)
1002 }
1003
1004 /// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
1005 /// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
1006 /// it is defined here now that the dependency is cosmic-text directly. Same plain
1007 /// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
1008 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1009 pub struct TextBounds {
1010 pub left: i32,
1011 pub top: i32,
1012 pub right: i32,
1013 pub bottom: i32,
1014 }
1015
1016 /// A display-list text prim ready for the glyph pass: a [`TextItem`](crate::widget::TextItem) whose
1017 /// buffer is the cache's own, shared rather than copied. Public so a shell
1018 /// outside the crate can hold what [`build_frame`](super::frame::build_frame)
1019 /// fills; its fields are the frame's own.
1020 pub struct DlText {
1021 pub(crate) buffer: Rc<Buffer>,
1022 pub(crate) x: f32,
1023 pub(crate) y: f32,
1024 pub(crate) color: cosmic_text::Color,
1025 pub(crate) bounds: Option<[f32; 4]>,
1026 pub(crate) clip_circle: Option<[f32; 3]>,
1027 pub(crate) clip_rrect: Option<[f32; 5]>,
1028 }
1029
1030 /// The display list's Text prims, shaped through the shared buffer cache and
1031 /// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
1032 /// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
1033 /// logical space. Shared by the window's frame and the context-menu popup's.
1034 pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
1035 // A cached buffer may have been shaped by another `FontSystem` (the app's
1036 // measuring one); the glyph pass rasterizes its face-alias IDs with this one.
1037 sync_font_ops(fs);
1038 for item in &dl.items {
1039 if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
1040 let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
1041 let merged = match (clip, *bounds) {
1042 (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
1043 (Some(a), None) => Some(a),
1044 (None, b) => b,
1045 };
1046 // Boxed text (wrap/align) is laid out in its box and shifts down by the
1047 // vertical offset; ordinary labels are a single run. Both cached.
1048 let (buffer, y_off) = match layout {
1049 Some(l) => shared_laid_out_buffer(fs, text, *font_size, font.as_deref(), *attrs, *l),
1050 None => (shared_text_buffer(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
1051 };
1052 out.push(DlText {
1053 buffer,
1054 x: *x,
1055 y: *y + y_off,
1056 color: cosmic_text::Color::rgba(
1057 color[0],
1058 color[1],
1059 color[2],
1060 (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
1061 ),
1062 bounds: merged,
1063 clip_circle: item.clip_circle,
1064 clip_rrect: item.clip_rrect,
1065 });
1066 }
1067 }
1068 }
1069
1070 /// The glyph pass's spans for `items`: each clamped to the surface and its
1071 /// own bounds, then by the popover-occlusion clamp against `overlays`.
1072 pub(crate) fn dl_text_spans<'a>(
1073 items: &'a [DlText],
1074 scale_f32: f32,
1075 bounds: TextBounds,
1076 overlays: &[(f32, f32, f32, f32)],
1077 ) -> Vec<TextSpan<'a>> {
1078 let mut spans: Vec<TextSpan<'a>> = Vec::new();
1079 for ti in items {
1080 let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
1081 TextBounds {
1082 left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
1083 top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
1084 right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
1085 bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
1086 }
1087 } else {
1088 bounds
1089 };
1090 popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
1091 spans.push(TextSpan {
1092 buffer: &ti.buffer,
1093 left: (ti.x * scale_f32).round(),
1094 top: (ti.y * scale_f32).round(),
1095 // Buffers are shaped at physical size (get_text_buffer_attrs).
1096 scale: 1.0,
1097 bounds: Some([
1098 item_bounds.left,
1099 item_bounds.top,
1100 item_bounds.right,
1101 item_bounds.bottom,
1102 ]),
1103 default_color: [
1104 ti.color.r() as f32 / 255.0,
1105 ti.color.g() as f32 / 255.0,
1106 ti.color.b() as f32 / 255.0,
1107 ti.color.a() as f32 / 255.0,
1108 ],
1109 rotation: None,
1110 // Circle wins when both are set (the circular pane's innermost clip);
1111 // otherwise a rounded-rect clip rides as center+radius with extents.
1112 clip_circle: match (ti.clip_circle, ti.clip_rrect) {
1113 (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
1114 (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
1115 (None, None) => [0.0; 3],
1116 },
1117 clip_extents: match (ti.clip_circle, ti.clip_rrect) {
1118 (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
1119 _ => [0.0; 2],
1120 },
1121 });
1122 }
1123 spans
1124 }
1125
1126 /// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
1127 /// the display-list text path: clip a text item's bounds so it does not bleed through an open
1128 /// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
1129 /// text and is left alone; anything else that intersects gets clamped horizontally toward
1130 /// whichever side of the popover it starts on.
1131 /// Clamp a text item's bounds away from the registered popover rects it
1132 /// runs under, so page text does not bleed through a floating plate.
1133 ///
1134 /// A text item BELONGS to a popover when it carries exactly that popover's
1135 /// rect as its bounds (the convention every popover's own labels follow),
1136 /// and it is then clamped only against the popovers registered AFTER its
1137 /// own — `overlay_rects` is in stacking order, the shared context menu
1138 /// last. Before 2026-09-22 a popover's text was exempt from its own rect
1139 /// alone and clamped against every other, so a context menu opened over a
1140 /// modal dialog had its labels clipped by the dialog it was drawn on top
1141 /// of, and showed as a plate with no legible entries.
1142 fn popover_occlusion_clamp(
1143 overlay_rects: &[(f32, f32, f32, f32)],
1144 ti: &DlText,
1145 scale_f32: f32,
1146 item_bounds: &mut TextBounds,
1147 ) {
1148 let owner = ti.bounds.and_then(|[l, t, r, b]| {
1149 overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
1150 (l - ox).abs() < 1.0
1151 && (t - oy).abs() < 1.0
1152 && (r - (ox + ow)).abs() < 1.0
1153 && (b - (oy + oh)).abs() < 1.0
1154 })
1155 });
1156 let first_above = owner.map_or(0, |k| k + 1);
1157 for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
1158 let ol = (ox * scale_f32).round() as i32;
1159 let ot = (oy * scale_f32).round() as i32;
1160 let or = ((ox + ow) * scale_f32).round() as i32;
1161 let ob = ((oy + oh) * scale_f32).round() as i32;
1162
1163 let tx_pixel = ti.x * scale_f32;
1164 let ty_pixel = ti.y * scale_f32;
1165
1166 let mut text_w = 0.0f32;
1167 let mut run_count = 0;
1168 for run in ti.buffer.layout_runs() {
1169 text_w = text_w.max(run.line_w);
1170 run_count += 1;
1171 }
1172 let text_h = run_count as f32 * ti.buffer.metrics().line_height;
1173
1174 let actual_left = tx_pixel;
1175 let actual_right = tx_pixel + text_w;
1176 let actual_top = ty_pixel;
1177 let actual_bottom = ty_pixel + text_h;
1178
1179 if actual_left < or as f32
1180 && actual_right > ol as f32
1181 && actual_top < ob as f32
1182 && actual_bottom > ot as f32
1183 {
1184 if tx_pixel < ol as f32 {
1185 item_bounds.right = item_bounds.right.min(ol);
1186 } else {
1187 item_bounds.left = item_bounds.left.max(or);
1188 }
1189 }
1190 }
1191 }
1192
1193 #[cfg(test)]
1194 mod text_cache_tests {
1195 use super::*;
1196 use crate::scene::paint::{AlignH, AlignV, TextAttrs, TextLayout};
1197
1198 fn boxed(wrap: f32) -> TextLayout {
1199 TextLayout { wrap_width: Some(wrap), box_height: 80.0, align_h: AlignH::Center, align_v: AlignV::Middle }
1200 }
1201
1202 /// A hit hands back the cached buffer itself: the frame used to deep-copy
1203 /// every text prim's shaped buffer, every frame.
1204 #[test]
1205 fn a_hit_shares_the_buffer_rather_than_copying_it() {
1206 let mut fs = crate::geometry_font_system().lock().unwrap();
1207 let attrs = TextAttrs::default();
1208 let a = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
1209 let b = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
1210 assert!(Rc::ptr_eq(&a, &b));
1211 let bold = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), TextAttrs { weight: Some(700), ..attrs });
1212 assert!(!Rc::ptr_eq(&a, &bold), "attrs are part of the key");
1213 let sized = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace 18"), attrs);
1214 assert!(!Rc::ptr_eq(&a, &sized), "a size in the font string is part of the key");
1215 }
1216
1217 /// Boxed text is cached by its box, and a hit is what a fresh layout of
1218 /// the same box would be.
1219 #[test]
1220 fn a_laid_out_buffer_is_cached_by_its_box() {
1221 let mut fs = crate::geometry_font_system().lock().unwrap();
1222 let text = "a line long enough to wrap inside a narrow box";
1223 let attrs = TextAttrs::default();
1224 let (a, va) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
1225 let (b, vb) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
1226 assert!(Rc::ptr_eq(&a, &b));
1227 assert_eq!(va, vb);
1228 let (wide, _) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(400.0));
1229 assert!(!Rc::ptr_eq(&a, &wide), "a different box is a different layout");
1230 let single = shared_text_buffer(&mut fs, text, 14.0, None, attrs);
1231 assert!(!Rc::ptr_eq(&a, &single), "a box never answers for the single run");
1232
1233 // The cached layout against one shaped from nothing.
1234 BUFFER_CACHE.with(|c| c.borrow_mut().clear());
1235 BUFFER_COUNT.with(|c| c.set(0));
1236 let (fresh, vf) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
1237 assert!(!Rc::ptr_eq(&a, &fresh));
1238 assert_eq!(va, vf);
1239 let runs = |b: &Buffer| b.layout_runs().map(|r| (r.line_y, r.line_w, r.glyphs.len())).collect::<Vec<_>>();
1240 assert_eq!(runs(&a), runs(&fresh));
1241 }
1242
1243 /// The cache holds at most `BUFFER_CAP` buffers, and eviction takes the
1244 /// least recently used, not the oldest inserted.
1245 #[test]
1246 fn eviction_keeps_the_cap_and_the_recently_used() {
1247 BUFFER_CACHE.with(|c| c.borrow_mut().clear());
1248 BUFFER_COUNT.with(|c| c.set(0));
1249 let key = |size_milli| BufferKey {
1250 size_milli,
1251 font: None,
1252 is_vertical: false,
1253 attrs: TextAttrs::default(),
1254 scale_bits: 1.0f32.to_bits(),
1255 layout: None,
1256 };
1257 let empty = || Rc::new(Buffer::new_empty(Metrics::new(10.0, 10.0)));
1258 for i in 0..BUFFER_CAP as u32 {
1259 buffer_cache_put(&format!("t{i}"), &key(i), empty(), 0.0);
1260 }
1261 // The first inserted is touched, so it is no longer the least recent.
1262 assert!(buffer_cache_get("t0", &key(0)).is_some());
1263 buffer_cache_put("over", &key(0), empty(), 0.0);
1264 let count = BUFFER_CACHE.with(|c| c.borrow().values().map(Vec::len).sum::<usize>());
1265 assert_eq!(count, BUFFER_CAP - BUFFER_EVICT + 1);
1266 assert_eq!(BUFFER_COUNT.with(|c| c.get()), count);
1267 assert!(buffer_cache_get("t0", &key(0)).is_some(), "recently used survives");
1268 assert!(buffer_cache_get("t1", &key(1)).is_none(), "least recently used goes");
1269 assert!(buffer_cache_get("over", &key(0)).is_some());
1270 }
1271
1272 /// Two copies of one font are one family whose faces are alike in every
1273 /// attribute; a [`face_family`] alias reaches each copy, and two systems
1274 /// loaded alike give the aliases the same IDs whatever order they shape in.
1275 #[test]
1276 fn a_face_alias_reaches_its_own_face_of_a_family_of_twins() {
1277 use cosmic_text::fontdb::{Database, Source};
1278 let src = {
1279 let fs = crate::geometry_font_system().lock().unwrap();
1280 let found = fs.db().faces().find_map(|f| match &f.source {
1281 Source::File(p) | Source::SharedFile(p, _) if f.index == 0 && f.families.len() == 1
1282 && p.extension().is_some_and(|e| e == "ttf") => Some(p.clone()),
1283 _ => None,
1284 });
1285 found.expect("a .ttf in the font set")
1286 };
1287 let dir = std::env::temp_dir().join(format!("cce-ui-face-alias-{}", std::process::id()));
1288 std::fs::create_dir_all(&dir).unwrap();
1289 let (a, b) = (dir.join("a.ttf"), dir.join("b.ttf"));
1290 std::fs::copy(&src, &a).unwrap();
1291 std::fs::copy(&src, &b).unwrap();
1292 let system = || {
1293 let mut db = Database::new();
1294 db.load_font_file(&a).unwrap();
1295 db.load_font_file(&b).unwrap();
1296 FontSystem::new_with_locale_and_db(crate::locale::locale().into(), db)
1297 };
1298 let (mut one, mut two) = (system(), system());
1299 let (fam_a, fam_b) = (face_family(&a, 0), face_family(&b, 0));
1300 assert_eq!(face_family(&b, 0), fam_b, "one face, one alias");
1301 let shaped_from = |fs: &mut FontSystem, family: &str| {
1302 let buf = shared_text_buffer(fs, "Alias", 14.0, Some(family), TextAttrs::default());
1303 let id = buf.layout_runs().next().unwrap().glyphs[0].font_id;
1304 match &fs.db().face(id).unwrap().source {
1305 Source::File(p) | Source::SharedFile(p, _) => (id, p.clone()),
1306 _ => unreachable!(),
1307 }
1308 };
1309 // Opposite orders, through a cleared cache so each system shapes.
1310 BUFFER_CACHE.with(|c| c.borrow_mut().clear());
1311 let one_b = shaped_from(&mut one, &fam_b);
1312 let one_a = shaped_from(&mut one, &fam_a);
1313 BUFFER_CACHE.with(|c| c.borrow_mut().clear());
1314 let two_a = shaped_from(&mut two, &fam_a);
1315 let two_b = shaped_from(&mut two, &fam_b);
1316 assert_eq!((one_a.1.as_path(), one_b.1.as_path()), (a.as_path(), b.as_path()));
1317 assert_eq!((one_a.0, one_b.0), (two_a.0, two_b.0), "the same IDs in both systems");
1318 let _ = std::fs::remove_dir_all(&dir);
1319 }
1320
1321 /// A rescan rides the alias log: two databases loaded alike — one synced
1322 /// after every change, one only at the end — give the new file's face and
1323 /// the aliases either side of it the same IDs; a removed file's face goes
1324 /// from both while its alias stays; a database built after the rescan
1325 /// replays it as a no-op; and a bundled-only one takes no system file.
1326 /// (A log of the test's own: through the real one its rescans would
1327 /// reach every other test's databases.)
1328 #[test]
1329 fn a_rescan_keeps_databases_alike() {
1330 use cosmic_text::fontdb::{Database, Source};
1331 use std::collections::HashSet;
1332 use std::path::{Path, PathBuf};
1333 use std::sync::Arc;
1334 let src = {
1335 let fs = crate::geometry_font_system().lock().unwrap();
1336 let found = fs.db().faces().find_map(|f| match &f.source {
1337 Source::File(p) | Source::SharedFile(p, _) if f.index == 0 && p.extension().is_some_and(|e| e == "ttf") => Some(p.clone()),
1338 _ => None,
1339 });
1340 found.expect("a .ttf in the font set")
1341 };
1342 let dir = std::env::temp_dir().join(format!("cce-ui-rescan-{}", std::process::id()));
1343 std::fs::create_dir_all(&dir).unwrap();
1344 let (a, b) = (dir.join("a.ttf"), dir.join("b.ttf"));
1345 std::fs::copy(&src, &a).unwrap();
1346 std::fs::copy(&src, &b).unwrap();
1347 let system = |file: &Path| {
1348 let mut db = Database::new();
1349 db.load_font_file(file).unwrap();
1350 FontSystem::new_with_locale_and_db(crate::locale::locale().into(), db)
1351 };
1352 let faces = |fs: &FontSystem| {
1353 fs.db()
1354 .faces()
1355 .map(|f| {
1356 let (Source::File(p) | Source::SharedFile(p, _)) = &f.source else { unreachable!() };
1357 (f.id, f.families[0].0.clone(), p.clone())
1358 })
1359 .collect::<Vec<_>>()
1360 };
1361 // (is an alias, file) per face, in database order.
1362 let names = |fs: &FontSystem| faces(fs).into_iter().map(|(_, fam, p)| (fam.starts_with(FACE_ALIAS_PREFIX), p)).collect::<Vec<_>>();
1363 let alias = |path: &Path| FontOp::Alias(FaceAlias { path: path.to_path_buf(), index: 0 });
1364 let rescan = |added: Vec<PathBuf>, removed: &[&Path], bundled: &[&Path]| {
1365 let set = |ps: &[&Path]| ps.iter().map(|p| p.to_path_buf()).collect::<HashSet<_>>();
1366 FontOp::Rescan(Arc::new(Rescan::new(added, set(removed), set(bundled))))
1367 };
1368
1369 let (mut eager, mut lazy) = (system(&a), system(&a));
1370 let mut ops = vec![alias(&a)];
1371 replay_font_ops(&mut eager, &ops);
1372 ops.push(rescan(vec![b.clone()], &[], &[]));
1373 replay_font_ops(&mut eager, &ops);
1374 ops.push(alias(&b));
1375 replay_font_ops(&mut eager, &ops);
1376 replay_font_ops(&mut lazy, &ops);
1377 assert_eq!(
1378 names(&eager),
1379 [(false, a.clone()), (true, a.clone()), (false, b.clone()), (true, b.clone())],
1380 "the new file lands between the aliases"
1381 );
1382 assert_eq!(faces(&eager), faces(&lazy), "the same IDs in both");
1383
1384 ops.push(rescan(Vec::new(), &[&a], &[]));
1385 replay_font_ops(&mut eager, &ops);
1386 replay_font_ops(&mut lazy, &ops);
1387 assert_eq!(names(&eager), [(true, a.clone()), (false, b.clone()), (true, b.clone())], "the removed file's face goes, its alias stays");
1388 assert_eq!(faces(&eager), faces(&lazy), "the same IDs in both");
1389
1390 let mut later = system(&b);
1391 replay_font_ops(&mut later, &ops);
1392 assert_eq!(names(&later), [(false, b.clone()), (true, b.clone())], "built after the rescan: nothing dropped or doubled");
1393
1394 let mut bundled_only = system(&a);
1395 replay_font_ops(&mut bundled_only, &[rescan(vec![b.clone()], &[], &[&a])]);
1396 assert_eq!(names(&bundled_only), [(false, a.clone())], "a bundled-only database takes no system file");
1397 let _ = std::fs::remove_dir_all(&dir);
1398 }
1399 }
1400
1401 #[cfg(test)]
1402 mod visual_order_tests {
1403 use super::*;
1404
1405 #[test]
1406 fn runs_are_reordered_as_the_bidi_algorithm_draws_them() {
1407 assert_eq!(visual_run_order(&["say ", "שלום", " עולם", " now"], false), [0, 2, 1, 3]);
1408 assert_eq!(visual_run_order(&["שלום ", "bold", " and", " עולם"], true), [3, 1, 2, 0], "English keeps its order inside");
1409 assert_eq!(visual_run_order(&["a", "b", "c"], false), [0, 1, 2]);
1410 assert_eq!(visual_run_order(&["א", "ב"], true), [1, 0]);
1411 assert_eq!(visual_run_order(&[" ", "123"], true), [1, 0], "neutral runs sit at the paragraph's level");
1412 assert!(paragraph_rtl(" «שלום» hello") && !paragraph_rtl("hello שלום") && !paragraph_rtl("123 ..."));
1413 }
1414 }