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            let dummy = crate::text::TextStyle {
350                span_style: span.item.clone(),
351                ..Default::default()
352            };
353            hasher.write_u64(dummy.measurement_hash());
354
355            if let Some(c) = &span.item.color {
356                hasher.write_u32(c.0.to_bits());
357                hasher.write_u32(c.1.to_bits());
358                hasher.write_u32(c.2.to_bits());
359                hasher.write_u32(c.3.to_bits());
360            }
361            if let Some(bg) = &span.item.background {
362                hasher.write_u32(bg.0.to_bits());
363                hasher.write_u32(bg.1.to_bits());
364                hasher.write_u32(bg.2.to_bits());
365                hasher.write_u32(bg.3.to_bits());
366            }
367            if let Some(d) = &span.item.text_decoration {
368                d.hash(&mut hasher);
369            }
370        }
371        hasher.finish()
372    }
373
374    pub fn render_hash(&self) -> u64 {
375        render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
376    }
377
378    /// Returns a new `AnnotatedString` containing a substring of the original text
379    /// and any styles that overlap with the new range, with indices adjusted.
380    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
381        if range.is_empty() {
382            return Self::new(String::new());
383        }
384
385        let start = range.start.min(self.text.len());
386        let end = range.end.max(start).min(self.text.len());
387
388        if start == end {
389            return Self::new(String::new());
390        }
391
392        let mut new_spans = Vec::new();
393        for span in &self.span_styles {
394            let intersection_start = span.range.start.max(start);
395            let intersection_end = span.range.end.min(end);
396            if intersection_start < intersection_end {
397                new_spans.push(RangeStyle {
398                    item: span.item.clone(),
399                    range: (intersection_start - start)..(intersection_end - start),
400                });
401            }
402        }
403
404        let mut new_paragraphs = Vec::new();
405        for span in &self.paragraph_styles {
406            let intersection_start = span.range.start.max(start);
407            let intersection_end = span.range.end.min(end);
408            if intersection_start < intersection_end {
409                new_paragraphs.push(RangeStyle {
410                    item: span.item.clone(),
411                    range: (intersection_start - start)..(intersection_end - start),
412                });
413            }
414        }
415
416        let mut new_string_annotations = Vec::new();
417        for ann in &self.string_annotations {
418            let intersection_start = ann.range.start.max(start);
419            let intersection_end = ann.range.end.min(end);
420            if intersection_start < intersection_end {
421                new_string_annotations.push(RangeStyle {
422                    item: ann.item.clone(),
423                    range: (intersection_start - start)..(intersection_end - start),
424                });
425            }
426        }
427
428        let mut new_link_annotations = Vec::new();
429        for ann in &self.link_annotations {
430            let intersection_start = ann.range.start.max(start);
431            let intersection_end = ann.range.end.min(end);
432            if intersection_start < intersection_end {
433                new_link_annotations.push(RangeStyle {
434                    item: ann.item.clone(),
435                    range: (intersection_start - start)..(intersection_end - start),
436                });
437            }
438        }
439
440        Self {
441            text: self.text[start..end].to_string(),
442            span_styles: new_spans,
443            paragraph_styles: new_paragraphs,
444            string_annotations: new_string_annotations,
445            link_annotations: new_link_annotations,
446        }
447    }
448
449    /// Returns all string annotations with the given `tag` whose range overlaps `[start, end)`.
450    ///
451    /// JC parity: `AnnotatedString.getStringAnnotations(tag, start, end) -> List<Range<String>>`
452    pub fn get_string_annotations(
453        &self,
454        tag: &str,
455        start: usize,
456        end: usize,
457    ) -> Vec<&RangeStyle<StringAnnotation>> {
458        self.string_annotations
459            .iter()
460            .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
461            .collect()
462    }
463
464    /// Returns all link annotations whose range overlaps `[start, end)`.
465    ///
466    /// JC parity: `AnnotatedString.getLinkAnnotations(start, end)`
467    pub fn get_link_annotations(
468        &self,
469        start: usize,
470        end: usize,
471    ) -> Vec<&RangeStyle<LinkAnnotation>> {
472        self.link_annotations
473            .iter()
474            .filter(|ann| ann.range.start < end && ann.range.end > start)
475            .collect()
476    }
477}
478
479impl From<String> for AnnotatedString {
480    fn from(text: String) -> Self {
481        Self::new(text)
482    }
483}
484
485impl From<&str> for AnnotatedString {
486    fn from(text: &str) -> Self {
487        Self::new(text.to_owned())
488    }
489}
490
491impl From<&String> for AnnotatedString {
492    fn from(text: &String) -> Self {
493        Self::new(text.clone())
494    }
495}
496
497impl From<&mut String> for AnnotatedString {
498    fn from(text: &mut String) -> Self {
499        Self::new(text.clone())
500    }
501}
502
503/// A builder to construct `AnnotatedString`.
504#[derive(Debug, Default, Clone)]
505pub struct Builder {
506    text: String,
507    span_styles: Vec<MutableRange<SpanStyle>>,
508    paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
509    string_annotations: Vec<MutableRange<StringAnnotation>>,
510    link_annotations: Vec<MutableRange<LinkAnnotation>>,
511    style_stack: Vec<StyleStackRecord>,
512}
513
514#[derive(Debug, Clone)]
515struct MutableRange<T> {
516    item: T,
517    start: usize,
518    end: usize,
519}
520
521#[derive(Debug, Clone)]
522struct StyleStackRecord {
523    style_type: StyleType,
524    index: usize,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528enum StyleType {
529    Span,
530    Paragraph,
531    StringAnnotation,
532    LinkAnnotation,
533}
534
535fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
536    let start = range.start.min(text.len());
537    let end = range.end.max(start).min(text.len());
538    start..end
539}
540
541fn append_clipped_ranges<T: Clone>(
542    target: &mut Vec<MutableRange<T>>,
543    source: &[RangeStyle<T>],
544    source_range: Range<usize>,
545    target_offset: usize,
546) {
547    for style in source {
548        let intersection_start = style.range.start.max(source_range.start);
549        let intersection_end = style.range.end.min(source_range.end);
550        if intersection_start < intersection_end {
551            target.push(MutableRange {
552                item: style.item.clone(),
553                start: (intersection_start - source_range.start) + target_offset,
554                end: (intersection_end - source_range.start) + target_offset,
555            });
556        }
557    }
558}
559
560impl Builder {
561    pub fn new() -> Self {
562        Self::default()
563    }
564
565    /// Appends the given String to this Builder.
566    pub fn append(mut self, text: &str) -> Self {
567        self.text.push_str(text);
568        self
569    }
570
571    pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
572        self.append_annotated_subsequence(annotated, 0..annotated.text.len())
573    }
574
575    pub fn append_annotated_subsequence(
576        mut self,
577        annotated: &AnnotatedString,
578        range: Range<usize>,
579    ) -> Self {
580        let range = clamp_subsequence_range(annotated.text.as_str(), range);
581        if range.is_empty() {
582            return self;
583        }
584
585        debug_assert!(annotated.text.is_char_boundary(range.start));
586        debug_assert!(annotated.text.is_char_boundary(range.end));
587
588        let target_offset = self.text.len();
589        self.text.push_str(&annotated.text[range.clone()]);
590        append_clipped_ranges(
591            &mut self.span_styles,
592            &annotated.span_styles,
593            range.clone(),
594            target_offset,
595        );
596        append_clipped_ranges(
597            &mut self.paragraph_styles,
598            &annotated.paragraph_styles,
599            range.clone(),
600            target_offset,
601        );
602        append_clipped_ranges(
603            &mut self.string_annotations,
604            &annotated.string_annotations,
605            range.clone(),
606            target_offset,
607        );
608        append_clipped_ranges(
609            &mut self.link_annotations,
610            &annotated.link_annotations,
611            range,
612            target_offset,
613        );
614        self
615    }
616
617    /// Applies the given `SpanStyle` to any appended text until a corresponding `pop` is called.
618    ///
619    /// Returns the index of the pushed style, which can be passed to `pop_to` or used as an ID.
620    pub fn push_style(mut self, style: SpanStyle) -> Self {
621        let index = self.span_styles.len();
622        self.span_styles.push(MutableRange {
623            item: style,
624            start: self.text.len(),
625            end: usize::MAX,
626        });
627        self.style_stack.push(StyleStackRecord {
628            style_type: StyleType::Span,
629            index,
630        });
631        self
632    }
633
634    /// Applies the given `ParagraphStyle` to any appended text until a corresponding `pop` is called.
635    pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
636        let index = self.paragraph_styles.len();
637        self.paragraph_styles.push(MutableRange {
638            item: style,
639            start: self.text.len(),
640            end: usize::MAX,
641        });
642        self.style_stack.push(StyleStackRecord {
643            style_type: StyleType::Paragraph,
644            index,
645        });
646        self
647    }
648
649    /// Pushes a string annotation covering subsequent appended text until the matching `pop`.
650    ///
651    /// JC parity: `Builder.pushStringAnnotation(tag, annotation)`
652    pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
653        let index = self.string_annotations.len();
654        self.string_annotations.push(MutableRange {
655            item: StringAnnotation {
656                tag: tag.to_string(),
657                annotation: annotation.to_string(),
658            },
659            start: self.text.len(),
660            end: usize::MAX,
661        });
662        self.style_stack.push(StyleStackRecord {
663            style_type: StyleType::StringAnnotation,
664            index,
665        });
666        self
667    }
668
669    /// Pushes a [`LinkAnnotation`] covering subsequent appended text.
670    /// Call `pop` when done, or use `with_link` for the block form.
671    ///
672    /// JC parity: `Builder.pushLink(link)`
673    pub fn push_link(mut self, link: LinkAnnotation) -> Self {
674        let index = self.link_annotations.len();
675        self.link_annotations.push(MutableRange {
676            item: link,
677            start: self.text.len(),
678            end: usize::MAX,
679        });
680        self.style_stack.push(StyleStackRecord {
681            style_type: StyleType::LinkAnnotation,
682            index,
683        });
684        self
685    }
686
687    /// Block form of `push_link` — mirrors JC's `withLink(link) { ... }` DSL.
688    ///
689    /// # Example
690    ///
691    /// ```rust,ignore
692    /// builder
693    ///     .append("Visit ")
694    ///     .with_link(
695    ///         LinkAnnotation::Url("https://developer.android.com".into()),
696    ///         |b| b.append("Android Developers"),
697    ///     )
698    ///     .append(".")
699    ///     .to_annotated_string()
700    /// ```
701    pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
702        let b = self.push_link(link);
703        let b = block(b);
704        b.pop()
705    }
706
707    /// Ends the style that was most recently pushed.
708    pub fn pop(mut self) -> Self {
709        if let Some(record) = self.style_stack.pop() {
710            match record.style_type {
711                StyleType::Span => {
712                    self.span_styles[record.index].end = self.text.len();
713                }
714                StyleType::Paragraph => {
715                    self.paragraph_styles[record.index].end = self.text.len();
716                }
717                StyleType::StringAnnotation => {
718                    self.string_annotations[record.index].end = self.text.len();
719                }
720                StyleType::LinkAnnotation => {
721                    self.link_annotations[record.index].end = self.text.len();
722                }
723            }
724        }
725        self
726    }
727
728    /// Completes the builder, resolving open styles to the end of the text.
729    pub fn to_annotated_string(mut self) -> AnnotatedString {
730        while let Some(record) = self.style_stack.pop() {
731            match record.style_type {
732                StyleType::Span => {
733                    self.span_styles[record.index].end = self.text.len();
734                }
735                StyleType::Paragraph => {
736                    self.paragraph_styles[record.index].end = self.text.len();
737                }
738                StyleType::StringAnnotation => {
739                    self.string_annotations[record.index].end = self.text.len();
740                }
741                StyleType::LinkAnnotation => {
742                    self.link_annotations[record.index].end = self.text.len();
743                }
744            }
745        }
746
747        AnnotatedString {
748            text: self.text,
749            span_styles: self
750                .span_styles
751                .into_iter()
752                .map(|s| RangeStyle {
753                    item: s.item,
754                    range: s.start..s.end,
755                })
756                .collect(),
757            paragraph_styles: self
758                .paragraph_styles
759                .into_iter()
760                .map(|s| RangeStyle {
761                    item: s.item,
762                    range: s.start..s.end,
763                })
764                .collect(),
765            string_annotations: self
766                .string_annotations
767                .into_iter()
768                .map(|s| RangeStyle {
769                    item: s.item,
770                    range: s.start..s.end,
771                })
772                .collect(),
773            link_annotations: self
774                .link_annotations
775                .into_iter()
776                .map(|s| RangeStyle {
777                    item: s.item,
778                    range: s.start..s.end,
779                })
780                .collect(),
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn a_redrawn_string_reuses_the_annotated_copy_from_last_frame() {
791        let first = shared_plain_annotated_string("SCORE 340");
792        let second = shared_plain_annotated_string("SCORE 340");
793        assert!(Rc::ptr_eq(&first, &second));
794        assert_eq!(second.text, "SCORE 340");
795        assert!(second.span_styles.is_empty());
796    }
797
798    #[test]
799    fn distinct_strings_never_share_an_annotated_copy() {
800        let first = shared_plain_annotated_string("READY");
801        let second = shared_plain_annotated_string("GO");
802        assert!(!Rc::ptr_eq(&first, &second));
803        assert_eq!(first.text, "READY");
804        assert_eq!(second.text, "GO");
805    }
806
807    #[test]
808    fn the_pool_survives_overflowing_its_capacity() {
809        for index in 0..600 {
810            let text = format!("distinct-{index}");
811            let shared = shared_plain_annotated_string(&text);
812            assert_eq!(shared.text, text);
813        }
814        let after = shared_plain_annotated_string("still correct");
815        assert_eq!(after.text, "still correct");
816    }
817
818    #[test]
819    fn test_builder_span() {
820        let span1 = SpanStyle {
821            alpha: Some(0.5),
822            ..Default::default()
823        };
824
825        let span2 = SpanStyle {
826            alpha: Some(1.0),
827            ..Default::default()
828        };
829
830        let annotated = AnnotatedString::builder()
831            .append("Hello ")
832            .push_style(span1.clone())
833            .append("World")
834            .push_style(span2.clone())
835            .append("!")
836            .pop()
837            .pop()
838            .to_annotated_string();
839
840        assert_eq!(annotated.text, "Hello World!");
841        assert_eq!(annotated.span_styles.len(), 2);
842        assert_eq!(annotated.span_styles[0].range, 6..12);
843        assert_eq!(annotated.span_styles[0].item, span1);
844        assert_eq!(annotated.span_styles[1].range, 11..12);
845        assert_eq!(annotated.span_styles[1].item, span2);
846    }
847
848    #[test]
849    fn with_link_url_roundtrips() {
850        let url = "https://developer.android.com";
851        let annotated = AnnotatedString::builder()
852            .append("Visit ")
853            .with_link(LinkAnnotation::Url(url.into()), |b| {
854                b.append("Android Developers")
855            })
856            .append(".")
857            .to_annotated_string();
858
859        assert_eq!(annotated.text, "Visit Android Developers.");
860        assert_eq!(annotated.link_annotations.len(), 1);
861        let ann = &annotated.link_annotations[0];
862        assert_eq!(ann.range, 6..24);
863        assert_eq!(ann.item, LinkAnnotation::Url(url.into()));
864    }
865
866    #[test]
867    fn with_link_clickable_calls_handler() {
868        use std::cell::Cell;
869        let called = Rc::new(Cell::new(false));
870        let called_clone = Rc::clone(&called);
871
872        let annotated = AnnotatedString::builder()
873            .with_link(
874                LinkAnnotation::Clickable {
875                    tag: "action".into(),
876                    handler: Rc::new(move || called_clone.set(true)),
877                },
878                |b| b.append("click me"),
879            )
880            .to_annotated_string();
881
882        assert_eq!(annotated.link_annotations.len(), 1);
883        let ann = &annotated.link_annotations[0];
884        if let LinkAnnotation::Clickable { handler, .. } = &ann.item {
885            handler();
886        }
887        assert!(called.get(), "Clickable handler should have been called");
888    }
889
890    #[test]
891    fn with_link_subsequence_trims_range() {
892        let annotated = AnnotatedString::builder()
893            .append("pre ")
894            .with_link(LinkAnnotation::Url("http://x.com".into()), |b| {
895                b.append("link")
896            })
897            .append(" post")
898            .to_annotated_string();
899
900        let sub = annotated.subsequence(4..8);
901        assert_eq!(sub.link_annotations.len(), 1);
902        assert_eq!(sub.link_annotations[0].range, 0..4);
903    }
904
905    #[test]
906    fn append_annotated_preserves_ranges_with_existing_prefix() {
907        let annotated = AnnotatedString::builder()
908            .append("Hello ")
909            .push_style(SpanStyle {
910                alpha: Some(0.5),
911                ..Default::default()
912            })
913            .append("World")
914            .pop()
915            .push_string_annotation("kind", "planet")
916            .append("!")
917            .pop()
918            .to_annotated_string();
919
920        let combined = AnnotatedString::builder()
921            .append("Prefix ")
922            .append_annotated(&annotated)
923            .to_annotated_string();
924
925        assert_eq!(combined.text, "Prefix Hello World!");
926        assert_eq!(combined.span_styles.len(), 1);
927        assert_eq!(combined.span_styles[0].range, 13..18);
928        assert_eq!(combined.string_annotations.len(), 1);
929        assert_eq!(combined.string_annotations[0].range, 18..19);
930    }
931
932    #[test]
933    fn append_annotated_subsequence_clips_ranges_to_slice() {
934        let annotated = AnnotatedString::builder()
935            .append("Before ")
936            .push_style(SpanStyle {
937                alpha: Some(0.5),
938                ..Default::default()
939            })
940            .append("Styled")
941            .pop()
942            .with_link(LinkAnnotation::Url("https://example.com".into()), |b| {
943                b.append(" Link")
944            })
945            .to_annotated_string();
946
947        let slice = AnnotatedString::builder()
948            .append("-> ")
949            .append_annotated_subsequence(&annotated, 7..18)
950            .to_annotated_string();
951
952        assert_eq!(slice.text, "-> Styled Link");
953        assert_eq!(slice.span_styles.len(), 1);
954        assert_eq!(slice.span_styles[0].range, 3..9);
955        assert_eq!(slice.link_annotations.len(), 1);
956        assert_eq!(slice.link_annotations[0].range, 9..14);
957    }
958
959    #[test]
960    fn render_hash_changes_for_visual_style_ranges() {
961        let plain = AnnotatedString::builder()
962            .append("Hello")
963            .to_annotated_string();
964        let styled = AnnotatedString::builder()
965            .push_style(SpanStyle {
966                color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
967                ..Default::default()
968            })
969            .append("Hello")
970            .pop()
971            .to_annotated_string();
972
973        assert_ne!(plain.render_hash(), styled.render_hash());
974    }
975}