git.lucas.co / cce-ui
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 }