Skip to main content

cranpose_ui/text/
annotated_string.rs

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