Skip to main content

cranpose_ui/text/
annotated_string.rs

1use std::ops::Range;
2use std::rc::Rc;
3
4use crate::{ParagraphStyle, SpanStyle};
5
6/// Mirrors Jetpack Compose's `LinkAnnotation` sealed class.
7///
8/// Attach to a text range via [`Builder::push_link`] or [`Builder::with_link`].
9/// [`crate::widgets::LinkedText`] automatically opens URLs and invokes handlers
10/// when the user taps the annotated text.
11///
12/// # JC ref
13/// `androidx.compose.foundation.text.input.internal.selection.LinkAnnotation`
14///
15/// # Example
16///
17/// ```rust,ignore
18/// let text = AnnotatedString::builder()
19///     .append("Visit ")
20///     .with_link(
21///         LinkAnnotation::Url("https://developer.android.com".into()),
22///         |b| b.append("Android Developers"),
23///     )
24///     .to_annotated_string();
25/// ```
26#[derive(Clone)]
27pub enum LinkAnnotation {
28    /// Opens the given URL via the platform URI handler when clicked.
29    ///
30    /// JC parity: `LinkAnnotation.Url(url)`
31    Url(String),
32
33    /// Calls an arbitrary handler when clicked.
34    ///
35    /// JC parity: `LinkAnnotation.Clickable(tag, linkInteractionListener)`
36    Clickable { tag: String, handler: Rc<dyn Fn()> },
37}
38
39impl std::fmt::Debug for LinkAnnotation {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::Url(url) => f.debug_tuple("Url").field(url).finish(),
43            Self::Clickable { tag, .. } => f.debug_struct("Clickable").field("tag", tag).finish(),
44        }
45    }
46}
47
48impl PartialEq for LinkAnnotation {
49    fn eq(&self, other: &Self) -> bool {
50        match (self, other) {
51            (Self::Url(a), Self::Url(b)) => a == b,
52            (
53                Self::Clickable {
54                    tag: ta,
55                    handler: ha,
56                },
57                Self::Clickable {
58                    tag: tb,
59                    handler: hb,
60                },
61            ) => ta == tb && Rc::ptr_eq(ha, hb),
62            _ => false,
63        }
64    }
65}
66
67/// Mirrors Jetpack Compose's `AnnotatedString.Range<String>` — a tag+value
68/// annotation covering a byte range.
69///
70/// JC ref: `androidx.compose.ui.text.AnnotatedString.Range`
71#[derive(Debug, Clone, PartialEq)]
72pub struct StringAnnotation {
73    pub tag: String,
74    pub annotation: String,
75}
76
77/// Link identity without the link behavior: what rendering may know about a
78/// [`LinkAnnotation`]. URL links keep their URL, clickable links keep their
79/// tag — the handler stays UI-side.
80#[derive(Debug, Clone, PartialEq)]
81pub enum LinkKey {
82    /// Identity of a [`LinkAnnotation::Url`].
83    Url(String),
84    /// Identity of a [`LinkAnnotation::Clickable`] — its tag.
85    Clickable(String),
86}
87
88/// What rendering reads from an [`AnnotatedString`]: content, styles, and
89/// link identity — never the link handlers, which live UI-side only.
90///
91/// Unlike `AnnotatedString` this is plain owned data (`Send + Sync`), so a
92/// lowered scene that carries it can cross threads.
93#[derive(Debug, Clone, PartialEq, Default)]
94pub struct RenderString {
95    pub text: String,
96    pub span_styles: Vec<RangeStyle<SpanStyle>>,
97    pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
98    pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
99    /// Link ranges by identity (tag/url) — enough to hash and to key caches,
100    /// never enough to invoke a link.
101    pub links: Vec<RangeStyle<LinkKey>>,
102}
103
104const _: () = {
105    fn assert_send<T: Send + Sync>() {}
106    #[allow(dead_code)]
107    fn assert_render_string_is_send_sync() {
108        assert_send::<RenderString>();
109    }
110};
111
112impl RenderString {
113    pub fn len(&self) -> usize {
114        self.text.len()
115    }
116
117    pub fn is_empty(&self) -> bool {
118        self.text.is_empty()
119    }
120
121    /// Returns a sorted list of unique byte indices where styles change.
122    ///
123    /// Mirrors [`AnnotatedString::span_boundaries`].
124    pub fn span_boundaries(&self) -> Vec<usize> {
125        span_boundaries_impl(&self.text, &self.span_styles)
126    }
127
128    /// Mirrors [`AnnotatedString::render_hash`]: hashes the exact same fields
129    /// with the exact same formula, so a cache keyed by either stays keyed by
130    /// the same distinctions.
131    pub fn render_hash(&self) -> u64 {
132        render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
133    }
134
135    /// Returns a new `RenderString` containing a substring of the original
136    /// text and any styles that overlap with the new range, with indices
137    /// adjusted. Mirrors [`AnnotatedString::subsequence`].
138    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
139        if range.is_empty() {
140            return Self {
141                text: String::new(),
142                ..Default::default()
143            };
144        }
145
146        let start = range.start.min(self.text.len());
147        let end = range.end.max(start).min(self.text.len());
148
149        if start == end {
150            return Self {
151                text: String::new(),
152                ..Default::default()
153            };
154        }
155
156        Self {
157            text: self.text[start..end].to_string(),
158            span_styles: clip_range_styles(&self.span_styles, start, end),
159            paragraph_styles: clip_range_styles(&self.paragraph_styles, start, end),
160            string_annotations: clip_range_styles(&self.string_annotations, start, end),
161            links: clip_range_styles(&self.links, start, end),
162        }
163    }
164}
165
166fn clip_range_styles<T: Clone>(
167    styles: &[RangeStyle<T>],
168    start: usize,
169    end: usize,
170) -> Vec<RangeStyle<T>> {
171    let mut clipped = Vec::new();
172    for style in styles {
173        let intersection_start = style.range.start.max(start);
174        let intersection_end = style.range.end.min(end);
175        if intersection_start < intersection_end {
176            clipped.push(RangeStyle {
177                item: style.item.clone(),
178                range: (intersection_start - start)..(intersection_end - start),
179            });
180        }
181    }
182    clipped
183}
184
185fn span_boundaries_impl(text: &str, span_styles: &[RangeStyle<SpanStyle>]) -> Vec<usize> {
186    let mut boundaries = vec![0, text.len()];
187    for span in span_styles {
188        boundaries.push(span.range.start);
189        boundaries.push(span.range.end);
190    }
191    boundaries.sort_unstable();
192    boundaries.dedup();
193    boundaries
194        .into_iter()
195        .filter(|&b| b <= text.len() && text.is_char_boundary(b))
196        .collect()
197}
198
199fn render_hash_impl(
200    text: &str,
201    span_styles: &[RangeStyle<SpanStyle>],
202    paragraph_styles: &[RangeStyle<ParagraphStyle>],
203) -> u64 {
204    use std::hash::{Hash, Hasher};
205
206    let mut hasher = cranpose_ui_graphics::FxHasher::default();
207    text.hash(&mut hasher);
208    span_styles.len().hash(&mut hasher);
209    for span in span_styles {
210        span.range.start.hash(&mut hasher);
211        span.range.end.hash(&mut hasher);
212        span.item.render_hash().hash(&mut hasher);
213    }
214    paragraph_styles.len().hash(&mut hasher);
215    for paragraph in paragraph_styles {
216        paragraph.range.start.hash(&mut hasher);
217        paragraph.range.end.hash(&mut hasher);
218        paragraph.item.render_hash().hash(&mut hasher);
219    }
220    hasher.finish()
221}
222
223/// The basic data structure of text with multiple styles.
224///
225/// To construct an `AnnotatedString` you can use `AnnotatedString::builder()`.
226#[derive(Debug, Clone, PartialEq, Default)]
227pub struct AnnotatedString {
228    pub text: String,
229    pub span_styles: Vec<RangeStyle<SpanStyle>>,
230    pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
231    /// Arbitrary tag+value annotations. Used for e.g. clickable link URLs.
232    /// Mirrors JC `AnnotatedString.getStringAnnotations(tag, start, end)`.
233    pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
234    /// Link annotations — URLs and clickable actions.
235    /// Mirrors JC `AnnotatedString.getLinkAnnotations(start, end)`.
236    pub link_annotations: Vec<RangeStyle<LinkAnnotation>>,
237}
238
239/// A style applied to a range of an `AnnotatedString`.
240#[derive(Debug, Clone, PartialEq)]
241pub struct RangeStyle<T> {
242    pub item: T,
243    pub range: Range<usize>,
244}
245
246/// Returns the shared [`AnnotatedString`] for a plain, style-free string,
247/// reusing the copy made on an earlier frame when the content matches.
248///
249/// Draw scopes lower every `draw_text*` call through an `AnnotatedString` on
250/// every frame — once to measure and once to emit — and a HUD label or score
251/// counter has the same characters frame after frame. Without this pool each
252/// pass re-copied a string it had copied the frame before, only to hash it and
253/// hit a layout cache that is keyed by content anyway. Entries are verified by
254/// content on hit, so a hash collision costs a fresh copy, never wrong text.
255/// The pool clears itself when full; a live scene re-warms within one frame.
256pub fn shared_plain_annotated_string(text: &str) -> Rc<AnnotatedString> {
257    use std::cell::RefCell;
258    use std::collections::HashMap;
259    use std::hash::{Hash, Hasher};
260
261    const POOL_CAPACITY: usize = 256;
262    thread_local! {
263        static POOL: RefCell<HashMap<u64, Rc<AnnotatedString>>> =
264            RefCell::new(HashMap::new());
265    }
266
267    let mut hasher = cranpose_ui_graphics::FxHasher::default();
268    text.hash(&mut hasher);
269    let key = hasher.finish();
270
271    POOL.with(|pool| {
272        let mut pool = pool.borrow_mut();
273        if let Some(shared) = pool.get(&key) {
274            if shared.text == text {
275                return Rc::clone(shared);
276            }
277        }
278        let shared = Rc::new(AnnotatedString::new(text.to_owned()));
279        if pool.len() >= POOL_CAPACITY {
280            pool.clear();
281        }
282        pool.insert(key, Rc::clone(&shared));
283        shared
284    })
285}
286
287impl AnnotatedString {
288    pub fn new(text: String) -> Self {
289        Self {
290            text,
291            span_styles: vec![],
292            paragraph_styles: vec![],
293            string_annotations: vec![],
294            link_annotations: vec![],
295        }
296    }
297
298    pub fn builder() -> Builder {
299        Builder::new()
300    }
301
302    pub fn len(&self) -> usize {
303        self.text.len()
304    }
305
306    pub fn is_empty(&self) -> bool {
307        self.text.is_empty()
308    }
309
310    /// Returns a sorted list of unique byte indices where styles change.
311    pub fn span_boundaries(&self) -> Vec<usize> {
312        span_boundaries_impl(&self.text, &self.span_styles)
313    }
314
315    /// Returns the [`RenderString`] view of this string: everything rendering
316    /// reads (content, styles, link identity), nothing it must not touch
317    /// (link handlers). A clone-conversion — memoize at the call site when
318    /// the same `AnnotatedString` lowers every frame.
319    pub fn render_string(&self) -> RenderString {
320        RenderString {
321            text: self.text.clone(),
322            span_styles: self.span_styles.clone(),
323            paragraph_styles: self.paragraph_styles.clone(),
324            string_annotations: self.string_annotations.clone(),
325            links: self
326                .link_annotations
327                .iter()
328                .map(|link| RangeStyle {
329                    item: match &link.item {
330                        LinkAnnotation::Url(url) => LinkKey::Url(url.clone()),
331                        LinkAnnotation::Clickable { tag, .. } => LinkKey::Clickable(tag.clone()),
332                    },
333                    range: link.range.clone(),
334                })
335                .collect(),
336        }
337    }
338
339    /// Computes a hash representing the contents of the span styles, suitable for cache invalidation.
340    pub fn span_styles_hash(&self) -> u64 {
341        use std::hash::{Hash, Hasher};
342        let mut hasher = cranpose_ui_graphics::FxHasher::default();
343        hasher.write_usize(self.span_styles.len());
344        for span in &self.span_styles {
345            hasher.write_usize(span.range.start);
346            hasher.write_usize(span.range.end);
347
348            // Hash measurement-affecting fields
349            let dummy = crate::text::TextStyle {
350                span_style: span.item.clone(),
351                ..Default::default()
352            };
353            hasher.write_u64(dummy.measurement_hash());
354
355            // Hash visually-affecting fields ignored by measurement
356            if let Some(c) = &span.item.color {
357                hasher.write_u32(c.0.to_bits());
358                hasher.write_u32(c.1.to_bits());
359                hasher.write_u32(c.2.to_bits());
360                hasher.write_u32(c.3.to_bits());
361            }
362            if let Some(bg) = &span.item.background {
363                hasher.write_u32(bg.0.to_bits());
364                hasher.write_u32(bg.1.to_bits());
365                hasher.write_u32(bg.2.to_bits());
366                hasher.write_u32(bg.3.to_bits());
367            }
368            if let Some(d) = &span.item.text_decoration {
369                d.hash(&mut hasher);
370            }
371        }
372        hasher.finish()
373    }
374
375    pub fn render_hash(&self) -> u64 {
376        render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
377    }
378
379    /// Returns a new `AnnotatedString` containing a substring of the original text
380    /// and any styles that overlap with the new range, with indices adjusted.
381    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
382        if range.is_empty() {
383            return Self::new(String::new());
384        }
385
386        let start = range.start.min(self.text.len());
387        let end = range.end.max(start).min(self.text.len());
388
389        if start == end {
390            return Self::new(String::new());
391        }
392
393        let mut new_spans = Vec::new();
394        for span in &self.span_styles {
395            let intersection_start = span.range.start.max(start);
396            let intersection_end = span.range.end.min(end);
397            if intersection_start < intersection_end {
398                new_spans.push(RangeStyle {
399                    item: span.item.clone(),
400                    range: (intersection_start - start)..(intersection_end - start),
401                });
402            }
403        }
404
405        let mut new_paragraphs = Vec::new();
406        for span in &self.paragraph_styles {
407            let intersection_start = span.range.start.max(start);
408            let intersection_end = span.range.end.min(end);
409            if intersection_start < intersection_end {
410                new_paragraphs.push(RangeStyle {
411                    item: span.item.clone(),
412                    range: (intersection_start - start)..(intersection_end - start),
413                });
414            }
415        }
416
417        let mut new_string_annotations = Vec::new();
418        for ann in &self.string_annotations {
419            let intersection_start = ann.range.start.max(start);
420            let intersection_end = ann.range.end.min(end);
421            if intersection_start < intersection_end {
422                new_string_annotations.push(RangeStyle {
423                    item: ann.item.clone(),
424                    range: (intersection_start - start)..(intersection_end - start),
425                });
426            }
427        }
428
429        let mut new_link_annotations = Vec::new();
430        for ann in &self.link_annotations {
431            let intersection_start = ann.range.start.max(start);
432            let intersection_end = ann.range.end.min(end);
433            if intersection_start < intersection_end {
434                new_link_annotations.push(RangeStyle {
435                    item: ann.item.clone(),
436                    range: (intersection_start - start)..(intersection_end - start),
437                });
438            }
439        }
440
441        Self {
442            text: self.text[start..end].to_string(),
443            span_styles: new_spans,
444            paragraph_styles: new_paragraphs,
445            string_annotations: new_string_annotations,
446            link_annotations: new_link_annotations,
447        }
448    }
449
450    /// Returns all string annotations with the given `tag` whose range overlaps `[start, end)`.
451    ///
452    /// JC parity: `AnnotatedString.getStringAnnotations(tag, start, end) -> List<Range<String>>`
453    pub fn get_string_annotations(
454        &self,
455        tag: &str,
456        start: usize,
457        end: usize,
458    ) -> Vec<&RangeStyle<StringAnnotation>> {
459        self.string_annotations
460            .iter()
461            .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
462            .collect()
463    }
464
465    /// Returns all link annotations whose range overlaps `[start, end)`.
466    ///
467    /// JC parity: `AnnotatedString.getLinkAnnotations(start, end)`
468    pub fn get_link_annotations(
469        &self,
470        start: usize,
471        end: usize,
472    ) -> Vec<&RangeStyle<LinkAnnotation>> {
473        self.link_annotations
474            .iter()
475            .filter(|ann| ann.range.start < end && ann.range.end > start)
476            .collect()
477    }
478}
479
480impl From<String> for AnnotatedString {
481    fn from(text: String) -> Self {
482        Self::new(text)
483    }
484}
485
486impl From<&str> for AnnotatedString {
487    fn from(text: &str) -> Self {
488        Self::new(text.to_owned())
489    }
490}
491
492impl From<&String> for AnnotatedString {
493    fn from(text: &String) -> Self {
494        Self::new(text.clone())
495    }
496}
497
498impl From<&mut String> for AnnotatedString {
499    fn from(text: &mut String) -> Self {
500        Self::new(text.clone())
501    }
502}
503
504/// A builder to construct `AnnotatedString`.
505#[derive(Debug, Default, Clone)]
506pub struct Builder {
507    text: String,
508    span_styles: Vec<MutableRange<SpanStyle>>,
509    paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
510    string_annotations: Vec<MutableRange<StringAnnotation>>,
511    link_annotations: Vec<MutableRange<LinkAnnotation>>,
512    style_stack: Vec<StyleStackRecord>,
513}
514
515#[derive(Debug, Clone)]
516struct MutableRange<T> {
517    item: T,
518    start: usize,
519    end: usize,
520}
521
522#[derive(Debug, Clone)]
523struct StyleStackRecord {
524    style_type: StyleType,
525    index: usize,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529enum StyleType {
530    Span,
531    Paragraph,
532    StringAnnotation,
533    LinkAnnotation,
534}
535
536fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
537    let start = range.start.min(text.len());
538    let end = range.end.max(start).min(text.len());
539    start..end
540}
541
542fn append_clipped_ranges<T: Clone>(
543    target: &mut Vec<MutableRange<T>>,
544    source: &[RangeStyle<T>],
545    source_range: Range<usize>,
546    target_offset: usize,
547) {
548    for style in source {
549        let intersection_start = style.range.start.max(source_range.start);
550        let intersection_end = style.range.end.min(source_range.end);
551        if intersection_start < intersection_end {
552            target.push(MutableRange {
553                item: style.item.clone(),
554                start: (intersection_start - source_range.start) + target_offset,
555                end: (intersection_end - source_range.start) + target_offset,
556            });
557        }
558    }
559}
560
561impl Builder {
562    pub fn new() -> Self {
563        Self::default()
564    }
565
566    /// Appends the given String to this Builder.
567    pub fn append(mut self, text: &str) -> Self {
568        self.text.push_str(text);
569        self
570    }
571
572    pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
573        self.append_annotated_subsequence(annotated, 0..annotated.text.len())
574    }
575
576    pub fn append_annotated_subsequence(
577        mut self,
578        annotated: &AnnotatedString,
579        range: Range<usize>,
580    ) -> Self {
581        let range = clamp_subsequence_range(annotated.text.as_str(), range);
582        if range.is_empty() {
583            return self;
584        }
585
586        debug_assert!(annotated.text.is_char_boundary(range.start));
587        debug_assert!(annotated.text.is_char_boundary(range.end));
588
589        let target_offset = self.text.len();
590        self.text.push_str(&annotated.text[range.clone()]);
591        append_clipped_ranges(
592            &mut self.span_styles,
593            &annotated.span_styles,
594            range.clone(),
595            target_offset,
596        );
597        append_clipped_ranges(
598            &mut self.paragraph_styles,
599            &annotated.paragraph_styles,
600            range.clone(),
601            target_offset,
602        );
603        append_clipped_ranges(
604            &mut self.string_annotations,
605            &annotated.string_annotations,
606            range.clone(),
607            target_offset,
608        );
609        append_clipped_ranges(
610            &mut self.link_annotations,
611            &annotated.link_annotations,
612            range,
613            target_offset,
614        );
615        self
616    }
617
618    /// Applies the given `SpanStyle` to any appended text until a corresponding `pop` is called.
619    ///
620    /// Returns the index of the pushed style, which can be passed to `pop_to` or used as an ID.
621    pub fn push_style(mut self, style: SpanStyle) -> Self {
622        let index = self.span_styles.len();
623        self.span_styles.push(MutableRange {
624            item: style,
625            start: self.text.len(),
626            end: usize::MAX,
627        });
628        self.style_stack.push(StyleStackRecord {
629            style_type: StyleType::Span,
630            index,
631        });
632        self
633    }
634
635    /// Applies the given `ParagraphStyle` to any appended text until a corresponding `pop` is called.
636    pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
637        let index = self.paragraph_styles.len();
638        self.paragraph_styles.push(MutableRange {
639            item: style,
640            start: self.text.len(),
641            end: usize::MAX,
642        });
643        self.style_stack.push(StyleStackRecord {
644            style_type: StyleType::Paragraph,
645            index,
646        });
647        self
648    }
649
650    /// Pushes a string annotation covering subsequent appended text until the matching `pop`.
651    ///
652    /// JC parity: `Builder.pushStringAnnotation(tag, annotation)`
653    pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
654        let index = self.string_annotations.len();
655        self.string_annotations.push(MutableRange {
656            item: StringAnnotation {
657                tag: tag.to_string(),
658                annotation: annotation.to_string(),
659            },
660            start: self.text.len(),
661            end: usize::MAX,
662        });
663        self.style_stack.push(StyleStackRecord {
664            style_type: StyleType::StringAnnotation,
665            index,
666        });
667        self
668    }
669
670    /// Pushes a [`LinkAnnotation`] covering subsequent appended text.
671    /// Call [`pop`] when done, or use [`with_link`] for the block form.
672    ///
673    /// JC parity: `Builder.pushLink(link)`
674    pub fn push_link(mut self, link: LinkAnnotation) -> Self {
675        let index = self.link_annotations.len();
676        self.link_annotations.push(MutableRange {
677            item: link,
678            start: self.text.len(),
679            end: usize::MAX,
680        });
681        self.style_stack.push(StyleStackRecord {
682            style_type: StyleType::LinkAnnotation,
683            index,
684        });
685        self
686    }
687
688    /// Block form of [`push_link`] — mirrors JC's `withLink(link) { ... }` DSL.
689    ///
690    /// # Example
691    ///
692    /// ```rust,ignore
693    /// builder
694    ///     .append("Visit ")
695    ///     .with_link(
696    ///         LinkAnnotation::Url("https://developer.android.com".into()),
697    ///         |b| b.append("Android Developers"),
698    ///     )
699    ///     .append(".")
700    ///     .to_annotated_string()
701    /// ```
702    pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
703        let b = self.push_link(link);
704        let b = block(b);
705        b.pop()
706    }
707
708    /// Ends the style that was most recently pushed.
709    pub fn pop(mut self) -> Self {
710        if let Some(record) = self.style_stack.pop() {
711            match record.style_type {
712                StyleType::Span => {
713                    self.span_styles[record.index].end = self.text.len();
714                }
715                StyleType::Paragraph => {
716                    self.paragraph_styles[record.index].end = self.text.len();
717                }
718                StyleType::StringAnnotation => {
719                    self.string_annotations[record.index].end = self.text.len();
720                }
721                StyleType::LinkAnnotation => {
722                    self.link_annotations[record.index].end = self.text.len();
723                }
724            }
725        }
726        self
727    }
728
729    /// Completes the builder, resolving open styles to the end of the text.
730    pub fn to_annotated_string(mut self) -> AnnotatedString {
731        // Resolve unclosed styles
732        while let Some(record) = self.style_stack.pop() {
733            match record.style_type {
734                StyleType::Span => {
735                    self.span_styles[record.index].end = self.text.len();
736                }
737                StyleType::Paragraph => {
738                    self.paragraph_styles[record.index].end = self.text.len();
739                }
740                StyleType::StringAnnotation => {
741                    self.string_annotations[record.index].end = self.text.len();
742                }
743                StyleType::LinkAnnotation => {
744                    self.link_annotations[record.index].end = self.text.len();
745                }
746            }
747        }
748
749        AnnotatedString {
750            text: self.text,
751            span_styles: self
752                .span_styles
753                .into_iter()
754                .map(|s| RangeStyle {
755                    item: s.item,
756                    range: s.start..s.end,
757                })
758                .collect(),
759            paragraph_styles: self
760                .paragraph_styles
761                .into_iter()
762                .map(|s| RangeStyle {
763                    item: s.item,
764                    range: s.start..s.end,
765                })
766                .collect(),
767            string_annotations: self
768                .string_annotations
769                .into_iter()
770                .map(|s| RangeStyle {
771                    item: s.item,
772                    range: s.start..s.end,
773                })
774                .collect(),
775            link_annotations: self
776                .link_annotations
777                .into_iter()
778                .map(|s| RangeStyle {
779                    item: s.item,
780                    range: s.start..s.end,
781                })
782                .collect(),
783        }
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn a_redrawn_string_reuses_the_annotated_copy_from_last_frame() {
793        let first = shared_plain_annotated_string("SCORE 340");
794        let second = shared_plain_annotated_string("SCORE 340");
795        assert!(Rc::ptr_eq(&first, &second));
796        assert_eq!(second.text, "SCORE 340");
797        assert!(second.span_styles.is_empty());
798    }
799
800    #[test]
801    fn distinct_strings_never_share_an_annotated_copy() {
802        let first = shared_plain_annotated_string("READY");
803        let second = shared_plain_annotated_string("GO");
804        assert!(!Rc::ptr_eq(&first, &second));
805        assert_eq!(first.text, "READY");
806        assert_eq!(second.text, "GO");
807    }
808
809    #[test]
810    fn the_pool_survives_overflowing_its_capacity() {
811        for index in 0..600 {
812            let text = format!("distinct-{index}");
813            let shared = shared_plain_annotated_string(&text);
814            assert_eq!(shared.text, text);
815        }
816        let after = shared_plain_annotated_string("still correct");
817        assert_eq!(after.text, "still correct");
818    }
819
820    #[test]
821    fn test_builder_span() {
822        let span1 = SpanStyle {
823            alpha: Some(0.5),
824            ..Default::default()
825        };
826
827        let span2 = SpanStyle {
828            alpha: Some(1.0),
829            ..Default::default()
830        };
831
832        let annotated = AnnotatedString::builder()
833            .append("Hello ")
834            .push_style(span1.clone())
835            .append("World")
836            .push_style(span2.clone())
837            .append("!")
838            .pop()
839            .pop()
840            .to_annotated_string();
841
842        assert_eq!(annotated.text, "Hello World!");
843        assert_eq!(annotated.span_styles.len(), 2);
844        assert_eq!(annotated.span_styles[0].range, 6..12);
845        assert_eq!(annotated.span_styles[0].item, span1);
846        assert_eq!(annotated.span_styles[1].range, 11..12);
847        assert_eq!(annotated.span_styles[1].item, span2);
848    }
849
850    #[test]
851    fn with_link_url_roundtrips() {
852        let url = "https://developer.android.com";
853        let annotated = AnnotatedString::builder()
854            .append("Visit ")
855            .with_link(LinkAnnotation::Url(url.into()), |b| {
856                b.append("Android Developers")
857            })
858            .append(".")
859            .to_annotated_string();
860
861        assert_eq!(annotated.text, "Visit Android Developers.");
862        assert_eq!(annotated.link_annotations.len(), 1);
863        let ann = &annotated.link_annotations[0];
864        // "Android Developers" starts at byte 6
865        assert_eq!(ann.range, 6..24);
866        assert_eq!(ann.item, LinkAnnotation::Url(url.into()));
867    }
868
869    #[test]
870    fn with_link_clickable_calls_handler() {
871        use std::cell::Cell;
872        let called = Rc::new(Cell::new(false));
873        let called_clone = Rc::clone(&called);
874
875        let annotated = AnnotatedString::builder()
876            .with_link(
877                LinkAnnotation::Clickable {
878                    tag: "action".into(),
879                    handler: Rc::new(move || called_clone.set(true)),
880                },
881                |b| b.append("click me"),
882            )
883            .to_annotated_string();
884
885        assert_eq!(annotated.link_annotations.len(), 1);
886        // Invoke the handler
887        let ann = &annotated.link_annotations[0];
888        if let LinkAnnotation::Clickable { handler, .. } = &ann.item {
889            handler();
890        }
891        assert!(called.get(), "Clickable handler should have been called");
892    }
893
894    #[test]
895    fn with_link_subsequence_trims_range() {
896        let annotated = AnnotatedString::builder()
897            .append("pre ")
898            .with_link(LinkAnnotation::Url("http://x.com".into()), |b| {
899                b.append("link")
900            })
901            .append(" post")
902            .to_annotated_string();
903
904        // Take subsequence covering only the link text
905        let sub = annotated.subsequence(4..8); // "link"
906        assert_eq!(sub.link_annotations.len(), 1);
907        assert_eq!(sub.link_annotations[0].range, 0..4);
908    }
909
910    #[test]
911    fn append_annotated_preserves_ranges_with_existing_prefix() {
912        let annotated = AnnotatedString::builder()
913            .append("Hello ")
914            .push_style(SpanStyle {
915                alpha: Some(0.5),
916                ..Default::default()
917            })
918            .append("World")
919            .pop()
920            .push_string_annotation("kind", "planet")
921            .append("!")
922            .pop()
923            .to_annotated_string();
924
925        let combined = AnnotatedString::builder()
926            .append("Prefix ")
927            .append_annotated(&annotated)
928            .to_annotated_string();
929
930        assert_eq!(combined.text, "Prefix Hello World!");
931        assert_eq!(combined.span_styles.len(), 1);
932        assert_eq!(combined.span_styles[0].range, 13..18);
933        assert_eq!(combined.string_annotations.len(), 1);
934        assert_eq!(combined.string_annotations[0].range, 18..19);
935    }
936
937    #[test]
938    fn append_annotated_subsequence_clips_ranges_to_slice() {
939        let annotated = AnnotatedString::builder()
940            .append("Before ")
941            .push_style(SpanStyle {
942                alpha: Some(0.5),
943                ..Default::default()
944            })
945            .append("Styled")
946            .pop()
947            .with_link(LinkAnnotation::Url("https://example.com".into()), |b| {
948                b.append(" Link")
949            })
950            .to_annotated_string();
951
952        let slice = AnnotatedString::builder()
953            .append("-> ")
954            .append_annotated_subsequence(&annotated, 7..18)
955            .to_annotated_string();
956
957        assert_eq!(slice.text, "-> Styled Link");
958        assert_eq!(slice.span_styles.len(), 1);
959        assert_eq!(slice.span_styles[0].range, 3..9);
960        assert_eq!(slice.link_annotations.len(), 1);
961        assert_eq!(slice.link_annotations[0].range, 9..14);
962    }
963
964    #[test]
965    fn render_hash_changes_for_visual_style_ranges() {
966        let plain = AnnotatedString::builder()
967            .append("Hello")
968            .to_annotated_string();
969        let styled = AnnotatedString::builder()
970            .push_style(SpanStyle {
971                color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
972                ..Default::default()
973            })
974            .append("Hello")
975            .pop()
976            .to_annotated_string();
977
978        assert_ne!(plain.render_hash(), styled.render_hash());
979    }
980}