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(&params);
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}