ailloli_ui_text/system.rs
1//! Central text subsystem: shared Parley engine, layout LRU cache, font blobs per face.
2
3use std::collections::hash_map::DefaultHasher;
4use std::collections::HashMap;
5use std::hash::{Hash, Hasher};
6use std::num::NonZeroUsize;
7use std::sync::Arc;
8
9use ailloli_ui_core::style::TextStyle;
10use lru::LruCache;
11
12use crate::engine_parley::ParleyEngine;
13use crate::params::{StyledTextLayoutParams, StyledTextSpan, TextLayoutParams, WrapMode};
14use crate::prepared::{prepare_layout, prepare_styled_layout, PreparedTextLayout};
15
16/// Hashes only metric-affecting uniform style fields.
17///
18/// Color and decoration are deliberately excluded because the uniform paint
19/// path supplies both independently of shaping.
20fn hash_style(style: TextStyle) -> u64 {
21 let mut h = DefaultHasher::new();
22 style.font.hash(&mut h);
23 style.px_size.hash(&mut h);
24 h.finish()
25}
26
27/// Hashes metric fields and color quantized through [`ailloli_ui_core::Color::as_rgba8`].
28///
29/// Paint-only decoration remains excluded.
30fn hash_style_with_color(style: TextStyle) -> u64 {
31 let mut h = DefaultHasher::new();
32 style.font.hash(&mut h);
33 style.px_size.hash(&mut h);
34 style.color.as_rgba8().hash(&mut h);
35 h.finish()
36}
37
38/// Hashes ordered span ranges and their size, family, and 8-bit color values.
39fn hash_spans(spans: &[StyledTextSpan]) -> u64 {
40 let mut h = DefaultHasher::new();
41 for span in spans {
42 span.range.start.hash(&mut h);
43 span.range.end.hash(&mut h);
44 hash_style_with_color(span.style).hash(&mut h);
45 }
46 h.finish()
47}
48
49/// Cache key for a text layout (content + style + quantized max width + wrap mode).
50///
51/// Text is represented by a [`DefaultHasher`] digest plus byte length. Uniform
52/// style keys include font and size but intentionally omit color and decoration.
53/// Styled keys add base/span colors after 8-bit quantization and still omit
54/// decoration. Widths are rounded to thousandths of a logical pixel. Therefore
55/// the key is optimized for cache reuse rather than being a lossless serialization
56/// of every request field.
57///
58/// # Examples
59///
60/// ```
61/// use ailloli_ui_core::{Color, FontId, TextStyle};
62/// use ailloli_ui_text::{TextLayoutKey, TextLayoutParams, WrapMode};
63/// let style = TextStyle::new(FontId::Ui, 14, Color::WHITE);
64/// let a = TextLayoutKey::from_params(&TextLayoutParams {
65/// text: "same", style, max_width: Some(80.0), wrap_mode: WrapMode::NoWrap,
66/// });
67/// let b = TextLayoutKey::from_params(&TextLayoutParams {
68/// text: "same", style, max_width: Some(800.0), wrap_mode: WrapMode::NoWrap,
69/// });
70/// assert_eq!(a, b);
71/// ```
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub struct TextLayoutKey {
74 /// Hash of all source-text bytes.
75 text_hash: u64,
76 /// Source byte length, adding a cheap collision discriminator.
77 text_len: usize,
78 /// Uniform or color-aware style hash, depending on the constructor.
79 style_hash: u64,
80 /// Ordered styled-span hash, or zero for uniform text.
81 span_hash: u64,
82 /// `max_width` quantized to thousandths of a pixel (`None` => `u32::MAX`).
83 max_width_q: u32,
84 /// Stable local tag: zero for no-wrap, one for word, two for anywhere.
85 wrap: u8,
86}
87
88impl TextLayoutKey {
89 /// Builds a cache key from layout parameters.
90 ///
91 /// `NoWrap` always uses the same sentinel width. Otherwise `None` uses
92 /// `u32::MAX`; finite widths are lower-bounded at zero, multiplied by 1000,
93 /// rounded, and saturating-cast to `u32`. NaN behaves as zero, while positive
94 /// infinity and sufficiently large widths collide with the `None` sentinel.
95 /// Uniform color and decoration do not affect this key.
96 ///
97 /// # Examples
98 ///
99 /// ```
100 /// use ailloli_ui_core::{Color, FontId, TextStyle};
101 /// use ailloli_ui_text::{TextLayoutKey, TextLayoutParams, WrapMode};
102 /// let white = TextStyle::new(FontId::Ui, 14, Color::WHITE);
103 /// let black = TextStyle::new(FontId::Ui, 14, Color::BLACK).underline();
104 /// let a = TextLayoutKey::from_params(&TextLayoutParams { text: "x", style: white, max_width: Some(10.0001), wrap_mode: WrapMode::Word });
105 /// let b = TextLayoutKey::from_params(&TextLayoutParams { text: "x", style: black, max_width: Some(10.0002), wrap_mode: WrapMode::Word });
106 /// assert_eq!(a, b);
107 /// ```
108 pub fn from_params(params: &TextLayoutParams<'_>) -> Self {
109 let mut h = DefaultHasher::new();
110 params.text.hash(&mut h);
111 let text_hash = h.finish();
112 let wrap = match params.wrap_mode {
113 WrapMode::NoWrap => 0u8,
114 WrapMode::Word => 1u8,
115 WrapMode::WordOrAnywhere => 2u8,
116 };
117 let max_width_q = match params.wrap_mode {
118 WrapMode::NoWrap => u32::MAX,
119 WrapMode::Word | WrapMode::WordOrAnywhere => params
120 .max_width
121 .map(|w: f32| (w.max(0.0) * 1000.0).round() as u32)
122 .unwrap_or(u32::MAX),
123 };
124 Self {
125 text_hash,
126 text_len: params.text.len(),
127 style_hash: hash_style(params.style),
128 span_hash: 0,
129 max_width_q,
130 wrap,
131 }
132 }
133
134 /// Builds a cache key from styled layout parameters.
135 ///
136 /// The key includes ordered span ranges and base/span colors quantized to
137 /// eight bits per channel. It omits all decorations. Unlike
138 /// [`Self::from_params`], a base color change normally changes the key;
139 /// colors that quantize to identical RGBA8 values remain equivalent.
140 /// Width normalization and sentinels are the same as for uniform text.
141 ///
142 /// # Examples
143 ///
144 /// ```
145 /// use ailloli_ui_core::{Color, FontId, TextStyle};
146 /// use ailloli_ui_text::{StyledTextLayoutParams, TextLayoutKey, WrapMode};
147 /// let white = TextStyle::new(FontId::Mono, 13, Color::WHITE);
148 /// let black = TextStyle::new(FontId::Mono, 13, Color::BLACK);
149 /// let a = TextLayoutKey::from_styled_params(&StyledTextLayoutParams { text: "x", base_style: white, spans: &[], max_width: None, wrap_mode: WrapMode::NoWrap });
150 /// let b = TextLayoutKey::from_styled_params(&StyledTextLayoutParams { text: "x", base_style: black, spans: &[], max_width: None, wrap_mode: WrapMode::NoWrap });
151 /// assert_ne!(a, b);
152 /// ```
153 pub fn from_styled_params(params: &StyledTextLayoutParams<'_>) -> Self {
154 let mut h = DefaultHasher::new();
155 params.text.hash(&mut h);
156 let text_hash = h.finish();
157 let wrap = match params.wrap_mode {
158 WrapMode::NoWrap => 0u8,
159 WrapMode::Word => 1u8,
160 WrapMode::WordOrAnywhere => 2u8,
161 };
162 let max_width_q = match params.wrap_mode {
163 WrapMode::NoWrap => u32::MAX,
164 WrapMode::Word | WrapMode::WordOrAnywhere => params
165 .max_width
166 .map(|w: f32| (w.max(0.0) * 1000.0).round() as u32)
167 .unwrap_or(u32::MAX),
168 };
169 Self {
170 text_hash,
171 text_len: params.text.len(),
172 style_hash: hash_style_with_color(params.base_style),
173 span_hash: hash_spans(params.spans),
174 max_width_q,
175 wrap,
176 }
177 }
178
179 /// Builds a styled key from already-normalized span parts.
180 fn from_styled_parts(
181 text: &str,
182 base_style: TextStyle,
183 spans: &[StyledTextSpan],
184 max_width: Option<f32>,
185 wrap_mode: WrapMode,
186 ) -> Self {
187 let mut h = DefaultHasher::new();
188 text.hash(&mut h);
189 let text_hash = h.finish();
190 let wrap = match wrap_mode {
191 WrapMode::NoWrap => 0u8,
192 WrapMode::Word => 1u8,
193 WrapMode::WordOrAnywhere => 2u8,
194 };
195 let max_width_q = match wrap_mode {
196 WrapMode::NoWrap => u32::MAX,
197 WrapMode::Word | WrapMode::WordOrAnywhere => max_width
198 .map(|w: f32| (w.max(0.0) * 1000.0).round() as u32)
199 .unwrap_or(u32::MAX),
200 };
201 Self {
202 text_hash,
203 text_len: text.len(),
204 style_hash: hash_style_with_color(base_style),
205 span_hash: hash_spans(spans),
206 max_width_q,
207 wrap,
208 }
209 }
210}
211
212/// Cheap handle to a prepared layout (`Arc` clone).
213///
214/// Cloning the handle increments a reference count and does not repeat shaping
215/// or copy glyph/font data.
216///
217/// # Examples
218///
219/// ```
220/// use std::sync::Arc;
221/// use ailloli_ui_core::{Color, FontId, TextStyle};
222/// use ailloli_ui_text::{TextLayoutHandle, TextLayoutParams, TextSystem};
223/// let mut system = TextSystem::new();
224/// let handle: TextLayoutHandle = system.layout_cached(TextLayoutParams::new(
225/// "shared", TextStyle::new(FontId::Ui, 14, Color::WHITE),
226/// ));
227/// assert!(Arc::ptr_eq(&handle, &handle.clone()));
228/// ```
229pub type TextLayoutHandle = Arc<PreparedTextLayout>;
230
231/// Retained text system: one instance per window (or shared app-wide).
232///
233/// One system owns mutable Parley contexts, a 2048-entry LRU layout cache, and
234/// an unbounded registry of font-face byte blobs encountered by prepared
235/// layouts. Methods require `&mut self` while shaping or updating LRU recency;
236/// external synchronization is required for shared concurrent use.
237///
238/// # Examples
239///
240/// ```
241/// use ailloli_ui_text::TextSystem;
242/// let system = TextSystem::new();
243/// assert_eq!(system.cached_layout_count(), 0);
244/// assert_eq!(system.metrics_revision(), 1);
245/// ```
246pub struct TextSystem {
247 /// Stateful font and layout contexts.
248 engine: ParleyEngine,
249 /// Font bytes retained by face ID without eviction.
250 face_blobs: HashMap<u64, Arc<[u8]>>,
251 /// Prepared-layout LRU with a fixed entry capacity.
252 cache: LruCache<TextLayoutKey, TextLayoutHandle>,
253 /// Nonzero wrapping generation for metric-dependent consumers.
254 metrics_revision: u64,
255}
256
257impl Default for TextSystem {
258 fn default() -> Self {
259 Self::new()
260 }
261}
262
263impl TextSystem {
264 /// Creates a new system with an empty LRU cache (capacity 2048 entries).
265 ///
266 /// The bundled monospace font is registered in the internal engine, while
267 /// the renderer-facing face registry remains empty until the first layout
268 /// produces glyphs. The metrics revision starts at the nonzero sentinel 1.
269 /// Construction may scan the relative `assets/fonts` directory through the
270 /// legacy Parley constructor.
271 ///
272 /// # Panics
273 ///
274 /// Panics only if the fixed cache-capacity constant is changed from 2048 to
275 /// zero.
276 ///
277 /// # Examples
278 ///
279 /// ```
280 /// use ailloli_ui_text::TextSystem;
281 /// let system = TextSystem::new();
282 /// assert_eq!(system.cached_layout_count(), 0);
283 /// assert!(system.face_blobs().is_empty());
284 /// assert_eq!(system.metrics_revision(), 1);
285 /// ```
286 pub fn new() -> Self {
287 let cap = NonZeroUsize::new(2048).expect("2048 > 0");
288 Self {
289 #[allow(deprecated)]
290 engine: ParleyEngine::new(),
291 face_blobs: HashMap::new(),
292 cache: LruCache::new(cap),
293 metrics_revision: 1,
294 }
295 }
296
297 /// Lays out text with LRU caching; skips relayout when the key and text match.
298 ///
299 /// A hit clones the stored [`Arc`] and updates LRU recency. Full source-text
300 /// equality guards text-hash collisions, but other lossy key components are
301 /// not secondarily compared. In particular, uniform color and decoration
302 /// are paint-only and reuse the same entry, so [`PreparedTextLayout::style`]
303 /// on a hit can be the style from the request that populated the cache.
304 /// Widths differing by less than key quantization may likewise share a layout.
305 /// The cache evicts least-recently-used layouts after 2048 distinct keys;
306 /// registered font blobs are not evicted.
307 ///
308 /// # Examples
309 ///
310 /// ```
311 /// use std::sync::Arc;
312 /// use ailloli_ui_core::{Color, FontId, TextStyle};
313 /// use ailloli_ui_text::{TextLayoutParams, TextSystem};
314 /// let mut system = TextSystem::new();
315 /// let params = TextLayoutParams::new("cached", TextStyle::new(FontId::Ui, 14, Color::WHITE));
316 /// let first = system.layout_cached(params);
317 /// let second = system.layout_cached(params);
318 /// assert!(Arc::ptr_eq(&first, &second));
319 /// assert_eq!(system.cached_layout_count(), 1);
320 /// ```
321 pub fn layout_cached(&mut self, params: TextLayoutParams<'_>) -> TextLayoutHandle {
322 let key = TextLayoutKey::from_params(¶ms);
323 if let Some(hit) = self.cache.get(&key) {
324 if hit.text() == params.text {
325 return hit.clone();
326 }
327 }
328 let prepared = prepare_layout(&mut self.engine, params, &mut self.face_blobs);
329 let arc = Arc::new(prepared);
330 self.cache.put(key, arc.clone());
331 arc
332 }
333
334 /// Lays out styled text with LRU caching.
335 ///
336 /// Spans are first clamped, validated, overlap-resolved with later input
337 /// winning, stripped when equal to the base style, and merged when adjacent.
338 /// If none remain, this delegates to the uniform cache. Styled keys include
339 /// RGBA8 colors but omit decoration, so sub-byte color differences and
340 /// decorations can reuse the first prepared entry. A cache hit also verifies
341 /// exact source text. Output glyphs carry their effective run colors.
342 ///
343 /// # Examples
344 ///
345 /// ```
346 /// use std::sync::Arc;
347 /// use ailloli_ui_core::{Color, FontId, TextStyle};
348 /// use ailloli_ui_text::{StyledTextLayoutParams, StyledTextSpan, TextSystem, WrapMode};
349 /// let mut system = TextSystem::new();
350 /// let base = TextStyle::new(FontId::Mono, 13, Color::WHITE);
351 /// let accent = TextStyle::new(FontId::Mono, 13, Color::BLACK);
352 /// let spans = [StyledTextSpan { range: 0..2, style: accent }];
353 /// let params = StyledTextLayoutParams { text: "fn main", base_style: base, spans: &spans, max_width: None, wrap_mode: WrapMode::NoWrap };
354 /// let first = system.layout_styled_cached(params);
355 /// let second = system.layout_styled_cached(params);
356 /// assert!(Arc::ptr_eq(&first, &second));
357 /// assert!(first.glyphs().iter().any(|glyph| glyph.color == Some(accent.color)));
358 /// ```
359 pub fn layout_styled_cached(&mut self, params: StyledTextLayoutParams<'_>) -> TextLayoutHandle {
360 let spans = normalize_styled_spans(params.text, params.base_style, params.spans);
361 if spans.is_empty() {
362 return self.layout_cached(TextLayoutParams {
363 text: params.text,
364 style: params.base_style,
365 max_width: params.max_width,
366 wrap_mode: params.wrap_mode,
367 });
368 }
369 let key = TextLayoutKey::from_styled_parts(
370 params.text,
371 params.base_style,
372 &spans,
373 params.max_width,
374 params.wrap_mode,
375 );
376 if let Some(hit) = self.cache.get(&key) {
377 if hit.text() == params.text {
378 return hit.clone();
379 }
380 }
381 let prepared = prepare_styled_layout(
382 &mut self.engine,
383 StyledTextLayoutParams {
384 text: params.text,
385 base_style: params.base_style,
386 spans: &spans,
387 max_width: params.max_width,
388 wrap_mode: params.wrap_mode,
389 },
390 &mut self.face_blobs,
391 );
392 let arc = Arc::new(prepared);
393 self.cache.put(key, arc.clone());
394 arc
395 }
396
397 /// Direct access to the Parley engine (migration / diagnostics).
398 ///
399 /// Layouts created directly through this reference bypass this system's LRU
400 /// and face-blob registration. Mutating fonts or shaping state can make
401 /// cached metrics stale; call [`Self::invalidate_metrics`] afterward when
402 /// the change can affect existing results.
403 ///
404 /// # Examples
405 ///
406 /// ```
407 /// use ailloli_ui_text::{ParleyEngine, TextSystem};
408 /// let mut system = TextSystem::new();
409 /// let _: &mut ParleyEngine = system.parley_engine_mut();
410 /// system.invalidate_metrics();
411 /// assert_eq!(system.cached_layout_count(), 0);
412 /// ```
413 pub fn parley_engine_mut(&mut self) -> &mut ParleyEngine {
414 &mut self.engine
415 }
416
417 /// Font bytes for a Parley `face_id`, if registered.
418 ///
419 /// A face becomes registered only after a cached prepared layout emits at
420 /// least one glyph using it. `None` means the system has not encountered the
421 /// ID. The returned slice is borrowed from retained reference-counted data.
422 ///
423 /// # Examples
424 ///
425 /// ```
426 /// use ailloli_ui_core::{Color, FontId, TextStyle};
427 /// use ailloli_ui_text::{TextLayoutParams, TextSystem};
428 /// let mut system = TextSystem::new();
429 /// let layout = system.layout_cached(TextLayoutParams::new("x", TextStyle::new(FontId::Ui, 14, Color::WHITE)));
430 /// let face_id = layout.glyphs()[0].face_id;
431 /// assert!(!system.face_blob(face_id).unwrap().is_empty());
432 /// assert_eq!(system.face_blob(u64::MAX), None);
433 /// ```
434 pub fn face_blob(&self, face_id: u64) -> Option<&[u8]> {
435 self.face_blobs.get(&face_id).map(|b| b.as_ref())
436 }
437
438 /// All registered face blobs (for renderer lookup).
439 ///
440 /// The borrowed map grows as new faces are shaped and is not pruned by LRU
441 /// eviction or metric invalidation. Keys are Parley face IDs; values hold
442 /// complete font bytes shared with snapshots.
443 ///
444 /// # Examples
445 ///
446 /// ```
447 /// use ailloli_ui_core::{Color, FontId, TextStyle};
448 /// use ailloli_ui_text::{TextLayoutParams, TextSystem};
449 /// let mut system = TextSystem::new();
450 /// assert!(system.face_blobs().is_empty());
451 /// let _ = system.layout_cached(TextLayoutParams::new("x", TextStyle::new(FontId::Mono, 14, Color::WHITE)));
452 /// assert!(!system.face_blobs().is_empty());
453 /// ```
454 pub fn face_blobs(&self) -> &HashMap<u64, Arc<[u8]>> {
455 &self.face_blobs
456 }
457
458 /// Cheap snapshot for the GPU renderer (`Arc` values inside).
459 ///
460 /// This allocates and clones the hash-map structure in O(number of faces),
461 /// but only increments reference counts for the potentially large font byte
462 /// buffers. Later registrations do not appear in an existing snapshot.
463 ///
464 /// # Examples
465 ///
466 /// ```
467 /// use std::sync::Arc;
468 /// use ailloli_ui_text::TextSystem;
469 /// let system = TextSystem::new();
470 /// let snapshot = system.face_blobs_snapshot();
471 /// assert!(snapshot.is_empty());
472 /// assert_eq!(Arc::strong_count(&snapshot), 1);
473 /// ```
474 pub fn face_blobs_snapshot(&self) -> Arc<HashMap<u64, Arc<[u8]>>> {
475 Arc::new(self.face_blobs.clone())
476 }
477
478 /// Returns the number of resident LRU entries, from zero through 2048.
479 ///
480 /// Styled requests that normalize to no spans share the uniform cache and
481 /// do not necessarily add a distinct entry.
482 ///
483 /// # Examples
484 ///
485 /// ```
486 /// use ailloli_ui_core::{Color, FontId, TextStyle};
487 /// use ailloli_ui_text::{TextLayoutParams, TextSystem};
488 /// let mut system = TextSystem::new();
489 /// assert_eq!(system.cached_layout_count(), 0);
490 /// let _ = system.layout_cached(TextLayoutParams::new("x", TextStyle::new(FontId::Ui, 14, Color::WHITE)));
491 /// assert_eq!(system.cached_layout_count(), 1);
492 /// ```
493 pub fn cached_layout_count(&self) -> usize {
494 self.cache.len()
495 }
496
497 /// Nonzero revision of inputs that can alter text metrics.
498 ///
499 /// The value starts at one and never returns zero. It increases on
500 /// [`Self::invalidate_metrics`] until `u64` overflow, at which point it wraps
501 /// back to one; consumers should compare for inequality rather than ordering.
502 ///
503 /// # Examples
504 ///
505 /// ```
506 /// use ailloli_ui_text::TextSystem;
507 /// let mut system = TextSystem::new();
508 /// let before = system.metrics_revision();
509 /// system.invalidate_metrics();
510 /// assert_ne!(system.metrics_revision(), before);
511 /// ```
512 pub const fn metrics_revision(&self) -> u64 {
513 self.metrics_revision
514 }
515
516 /// Invalidates metric-dependent caches after a font or shaping change.
517 ///
518 /// This clears every prepared layout and advances the nonzero wrapping
519 /// revision. It deliberately retains registered face blobs and all internal
520 /// Parley/font caches; memory associated with those resources is not freed.
521 ///
522 /// # Examples
523 ///
524 /// ```
525 /// use ailloli_ui_core::{Color, FontId, TextStyle};
526 /// use ailloli_ui_text::{TextLayoutParams, TextSystem};
527 /// let mut system = TextSystem::new();
528 /// let _ = system.layout_cached(TextLayoutParams::new("x", TextStyle::new(FontId::Ui, 14, Color::WHITE)));
529 /// let faces = system.face_blobs().len();
530 /// system.invalidate_metrics();
531 /// assert_eq!(system.cached_layout_count(), 0);
532 /// assert_eq!(system.face_blobs().len(), faces);
533 /// ```
534 pub fn invalidate_metrics(&mut self) {
535 self.metrics_revision = self.metrics_revision.wrapping_add(1).max(1);
536 self.cache.clear();
537 }
538}
539
540/// Canonicalizes externally supplied style ranges before shaping and caching.
541///
542/// Endpoints are clamped to the text length. Empty, reversed, or non-UTF-8-boundary
543/// ranges are dropped. Where valid ranges overlap, the later input slice entry
544/// wins. Segments equal to `base_style` are removed, and adjacent segments with
545/// identical styles are merged. The result is sorted, disjoint, and contains no
546/// empty ranges.
547///
548/// # Examples
549///
550/// The canonicalization is observable through public styled-cache reuse:
551///
552/// ```
553/// use std::sync::Arc;
554/// use ailloli_ui_core::{Color, FontId, TextStyle};
555/// use ailloli_ui_text::{StyledTextLayoutParams, StyledTextSpan, TextSystem, WrapMode};
556/// let mut system = TextSystem::new();
557/// let base = TextStyle::new(FontId::Mono, 13, Color::WHITE);
558/// let accent = TextStyle::new(FontId::Mono, 13, Color::BLACK);
559/// let split = [
560/// StyledTextSpan { range: 0..1, style: accent },
561/// StyledTextSpan { range: 1..2, style: accent },
562/// ];
563/// let merged = [StyledTextSpan { range: 0..2, style: accent }];
564/// let a = system.layout_styled_cached(StyledTextLayoutParams { text: "fn", base_style: base, spans: &split, max_width: None, wrap_mode: WrapMode::NoWrap });
565/// let b = system.layout_styled_cached(StyledTextLayoutParams { text: "fn", base_style: base, spans: &merged, max_width: None, wrap_mode: WrapMode::NoWrap });
566/// assert!(Arc::ptr_eq(&a, &b));
567/// ```
568pub(crate) fn normalize_styled_spans(
569 text: &str,
570 base_style: TextStyle,
571 spans: &[StyledTextSpan],
572) -> Vec<StyledTextSpan> {
573 #[derive(Clone, Copy)]
574 /// Validated input span with its precedence position.
575 struct Candidate {
576 /// Inclusive start byte.
577 start: usize,
578 /// Exclusive end byte.
579 end: usize,
580 /// Input order used for last-wins resolution.
581 index: usize,
582 /// Complete style carried by this candidate.
583 style: TextStyle,
584 }
585
586 let mut candidates = Vec::new();
587 let text_len = text.len();
588 for (index, span) in spans.iter().enumerate() {
589 let start = span.range.start.min(text_len);
590 let end = span.range.end.min(text_len);
591 if start >= end || !text.is_char_boundary(start) || !text.is_char_boundary(end) {
592 continue;
593 }
594 candidates.push(Candidate {
595 start,
596 end,
597 index,
598 style: span.style,
599 });
600 }
601 if candidates.is_empty() {
602 return Vec::new();
603 }
604
605 let mut points = Vec::with_capacity(candidates.len() * 2);
606 for span in &candidates {
607 points.push(span.start);
608 points.push(span.end);
609 }
610 points.sort_unstable();
611 points.dedup();
612
613 let mut normalized: Vec<StyledTextSpan> = Vec::new();
614 for window in points.windows(2) {
615 let start = window[0];
616 let end = window[1];
617 if start >= end {
618 continue;
619 }
620 let Some(active) = candidates
621 .iter()
622 .filter(|span| span.start <= start && end <= span.end)
623 .max_by_key(|span| span.index)
624 else {
625 continue;
626 };
627 if active.style == base_style {
628 continue;
629 }
630 if let Some(previous) = normalized.last_mut() {
631 if previous.range.end == start && previous.style == active.style {
632 previous.range.end = end;
633 continue;
634 }
635 }
636 normalized.push(StyledTextSpan {
637 range: start..end,
638 style: active.style,
639 });
640 }
641 normalized
642}
643
644/// Placeholder for future multi-paragraph document storage.
645///
646/// This is currently a zero-sized marker with no storage or behavior. Use
647/// [`crate::TextBuffer`] for implemented rope-backed paragraph operations.
648///
649/// # Examples
650///
651/// ```
652/// use ailloli_ui_text::ParagraphStore;
653/// let store = ParagraphStore;
654/// assert_eq!(core::mem::size_of_val(&store), 0);
655/// ```
656#[derive(Debug, Default)]
657pub struct ParagraphStore;
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use ailloli_ui_core::style::Color;
663 use ailloli_ui_core::FontId;
664
665 #[test]
666 fn layout_cached_returns_same_arc_for_identical_key() {
667 let mut ts = TextSystem::new();
668 let style = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 1.0, 1.0, 1.0));
669 let params = TextLayoutParams {
670 text: "hello world",
671 style,
672 max_width: Some(200.0),
673 wrap_mode: WrapMode::NoWrap,
674 };
675 let a = ts.layout_cached(params);
676 let b = ts.layout_cached(params);
677 assert!(Arc::ptr_eq(&a, &b));
678 }
679
680 #[test]
681 fn paint_only_decoration_reuses_cached_layout() {
682 let mut ts = TextSystem::new();
683 let plain = TextStyle::new(FontId::Ui, 14, Color::WHITE);
684 let underlined = plain.underline();
685 let a = ts.layout_cached(TextLayoutParams {
686 text: "Documentation",
687 style: plain,
688 max_width: None,
689 wrap_mode: WrapMode::NoWrap,
690 });
691 let b = ts.layout_cached(TextLayoutParams {
692 text: "Documentation",
693 style: underlined,
694 max_width: None,
695 wrap_mode: WrapMode::NoWrap,
696 });
697
698 assert!(Arc::ptr_eq(&a, &b));
699 }
700
701 #[test]
702 fn no_wrap_key_ignores_max_width() {
703 let style = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 1.0, 1.0, 1.0));
704 let a = TextLayoutKey::from_params(&TextLayoutParams {
705 text: "stable width",
706 style,
707 max_width: Some(80.0),
708 wrap_mode: WrapMode::NoWrap,
709 });
710 let b = TextLayoutKey::from_params(&TextLayoutParams {
711 text: "stable width",
712 style,
713 max_width: Some(240.0),
714 wrap_mode: WrapMode::NoWrap,
715 });
716 assert_eq!(a, b);
717 }
718
719 #[test]
720 fn no_wrap_cache_reuses_layout_across_widths() {
721 let mut ts = TextSystem::new();
722 let style = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 1.0, 1.0, 1.0));
723 let a = ts.layout_cached(TextLayoutParams {
724 text: "paint reuses layout",
725 style,
726 max_width: Some(80.0),
727 wrap_mode: WrapMode::NoWrap,
728 });
729 let b = ts.layout_cached(TextLayoutParams {
730 text: "paint reuses layout",
731 style,
732 max_width: Some(240.0),
733 wrap_mode: WrapMode::NoWrap,
734 });
735 assert!(Arc::ptr_eq(&a, &b));
736 }
737
738 #[test]
739 fn styled_layout_cache_includes_span_styles() {
740 let mut ts = TextSystem::new();
741 let base = TextStyle::new(FontId::Mono, 13, Color::WHITE);
742 let keyword = TextStyle::new(FontId::Mono, 13, Color::hex_rgb(0xFF7A18));
743 let string = TextStyle::new(FontId::Mono, 13, Color::hex_rgb(0x22C55E));
744 let a_spans = [StyledTextSpan {
745 range: 0..2,
746 style: keyword,
747 }];
748 let b_spans = [StyledTextSpan {
749 range: 0..2,
750 style: string,
751 }];
752
753 let a = ts.layout_styled_cached(StyledTextLayoutParams {
754 text: "fn main",
755 base_style: base,
756 spans: &a_spans,
757 max_width: None,
758 wrap_mode: WrapMode::NoWrap,
759 });
760 let a_again = ts.layout_styled_cached(StyledTextLayoutParams {
761 text: "fn main",
762 base_style: base,
763 spans: &a_spans,
764 max_width: None,
765 wrap_mode: WrapMode::NoWrap,
766 });
767 let b = ts.layout_styled_cached(StyledTextLayoutParams {
768 text: "fn main",
769 base_style: base,
770 spans: &b_spans,
771 max_width: None,
772 wrap_mode: WrapMode::NoWrap,
773 });
774
775 assert!(Arc::ptr_eq(&a, &a_again));
776 assert!(!Arc::ptr_eq(&a, &b));
777 }
778
779 #[test]
780 fn normalize_styled_spans_sorts_clamps_overlaps_and_merges() {
781 let base = TextStyle::new(FontId::Mono, 13, Color::WHITE);
782 let keyword = TextStyle::new(FontId::Mono, 13, Color::hex_rgb(0xFF7A18));
783 let string = TextStyle::new(FontId::Mono, 13, Color::hex_rgb(0x22C55E));
784 let text = "fn café value";
785 let spans = [
786 StyledTextSpan {
787 range: 8..99,
788 style: keyword,
789 },
790 StyledTextSpan {
791 range: 0..2,
792 style: keyword,
793 },
794 StyledTextSpan {
795 range: 1..10,
796 style: string,
797 },
798 StyledTextSpan {
799 range: 4..5,
800 style: keyword,
801 },
802 ];
803
804 let normalized = normalize_styled_spans(text, base, &spans);
805
806 assert_eq!(
807 normalized,
808 vec![
809 StyledTextSpan {
810 range: 0..1,
811 style: keyword,
812 },
813 StyledTextSpan {
814 range: 1..4,
815 style: string,
816 },
817 StyledTextSpan {
818 range: 4..5,
819 style: keyword,
820 },
821 StyledTextSpan {
822 range: 5..10,
823 style: string,
824 },
825 StyledTextSpan {
826 range: 10..14,
827 style: keyword,
828 },
829 ]
830 );
831 }
832
833 #[test]
834 fn normalize_styled_spans_drops_empty_base_and_invalid_utf8_ranges() {
835 let base = TextStyle::new(FontId::Mono, 13, Color::WHITE);
836 let keyword = TextStyle::new(FontId::Mono, 13, Color::hex_rgb(0xFF7A18));
837 let text = "éclair";
838 let spans = [
839 StyledTextSpan {
840 range: 0..0,
841 style: keyword,
842 },
843 StyledTextSpan {
844 range: 1..3,
845 style: keyword,
846 },
847 StyledTextSpan {
848 range: 2..6,
849 style: base,
850 },
851 ];
852
853 assert!(normalize_styled_spans(text, base, &spans).is_empty());
854 }
855
856 #[test]
857 fn styled_layout_cache_uses_normalized_spans_and_fallback_uniform() {
858 let mut ts = TextSystem::new();
859 let base = TextStyle::new(FontId::Mono, 13, Color::WHITE);
860 let keyword = TextStyle::new(FontId::Mono, 13, Color::hex_rgb(0xFF7A18));
861 let sorted = [
862 StyledTextSpan {
863 range: 0..1,
864 style: keyword,
865 },
866 StyledTextSpan {
867 range: 1..2,
868 style: keyword,
869 },
870 ];
871 let merged = [StyledTextSpan {
872 range: 0..2,
873 style: keyword,
874 }];
875
876 let a = ts.layout_styled_cached(StyledTextLayoutParams {
877 text: "fn main",
878 base_style: base,
879 spans: &sorted,
880 max_width: None,
881 wrap_mode: WrapMode::NoWrap,
882 });
883 let b = ts.layout_styled_cached(StyledTextLayoutParams {
884 text: "fn main",
885 base_style: base,
886 spans: &merged,
887 max_width: Some(500.0),
888 wrap_mode: WrapMode::NoWrap,
889 });
890 let uniform = ts.layout_cached(TextLayoutParams {
891 text: "fn main",
892 style: base,
893 max_width: None,
894 wrap_mode: WrapMode::NoWrap,
895 });
896 let styled_without_spans = ts.layout_styled_cached(StyledTextLayoutParams {
897 text: "fn main",
898 base_style: base,
899 spans: &[],
900 max_width: Some(100.0),
901 wrap_mode: WrapMode::NoWrap,
902 });
903
904 assert!(Arc::ptr_eq(&a, &b));
905 assert!(Arc::ptr_eq(&uniform, &styled_without_spans));
906 assert!(a
907 .glyphs()
908 .iter()
909 .any(|glyph| glyph.color == Some(keyword.color)));
910 }
911
912 #[test]
913 fn style_key_ignores_color() {
914 let white = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 1.0, 1.0, 1.0));
915 let red = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 0.0, 0.0, 1.0));
916 let a = TextLayoutKey::from_params(&TextLayoutParams {
917 text: "same layout",
918 style: white,
919 max_width: Some(200.0),
920 wrap_mode: WrapMode::Word,
921 });
922 let b = TextLayoutKey::from_params(&TextLayoutParams {
923 text: "same layout",
924 style: red,
925 max_width: Some(200.0),
926 wrap_mode: WrapMode::Word,
927 });
928 assert_eq!(a, b);
929 }
930
931 #[test]
932 fn wrap_mode_is_part_of_cache_key() {
933 let style = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 1.0, 1.0, 1.0));
934 let word = TextLayoutKey::from_params(&TextLayoutParams {
935 text: "same layout",
936 style,
937 max_width: Some(80.0),
938 wrap_mode: WrapMode::Word,
939 });
940 let anywhere = TextLayoutKey::from_params(&TextLayoutParams {
941 text: "same layout",
942 style,
943 max_width: Some(80.0),
944 wrap_mode: WrapMode::WordOrAnywhere,
945 });
946 assert_ne!(word, anywhere);
947 }
948
949 #[test]
950 fn word_wrap_keeps_long_unspaced_text_on_one_line() {
951 let mut ts = TextSystem::new();
952 let style = TextStyle::new(FontId::Mono, 13, Color::new(1.0, 1.0, 1.0, 1.0));
953 let layout = ts.layout_cached(TextLayoutParams {
954 text: "cccccccccccccccccccccccccccccccccccccccccccccccc",
955 style,
956 max_width: Some(80.0),
957 wrap_mode: WrapMode::Word,
958 });
959 assert_eq!(layout.lines.len(), 1);
960 }
961
962 #[test]
963 fn word_or_anywhere_wraps_long_unspaced_text() {
964 let mut ts = TextSystem::new();
965 let style = TextStyle::new(FontId::Mono, 13, Color::new(1.0, 1.0, 1.0, 1.0));
966 let layout = ts.layout_cached(TextLayoutParams {
967 text: "cccccccccccccccccccccccccccccccccccccccccccccccc",
968 style,
969 max_width: Some(80.0),
970 wrap_mode: WrapMode::WordOrAnywhere,
971 });
972 assert!(layout.lines.len() > 1);
973 assert!(layout.width() <= 82.0);
974 }
975
976 #[test]
977 fn caret_rect_reports_visual_line_y() {
978 let mut ts = TextSystem::new();
979 let style = TextStyle::new(FontId::Mono, 13, Color::new(1.0, 1.0, 1.0, 1.0));
980 let text = "aaaaaaaaaaaa bbbbbbbbbbbb";
981 let layout = ts.layout_cached(TextLayoutParams {
982 text,
983 style,
984 max_width: Some(90.0),
985 wrap_mode: WrapMode::WordOrAnywhere,
986 });
987 assert!(layout.lines.len() > 1);
988 let start = layout.caret_rect_at(0, 1.0);
989 let end = layout.caret_rect_at(text.len(), 1.0);
990 assert!(end.y > start.y);
991 }
992
993 #[test]
994 fn layout_cached_repopulates_face_blobs() {
995 let mut ts = TextSystem::new();
996 let style = TextStyle::new(FontId::Ui, 14, Color::new(1.0, 1.0, 1.0, 1.0));
997 let _ = ts.layout_cached(TextLayoutParams {
998 text: "blob registration",
999 style,
1000 max_width: None,
1001 wrap_mode: WrapMode::NoWrap,
1002 });
1003 assert!(!ts.face_blobs().is_empty());
1004 }
1005}