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/// The basic data structure of text with multiple styles.
78///
79/// To construct an `AnnotatedString` you can use `AnnotatedString::builder()`.
80#[derive(Debug, Clone, PartialEq, Default)]
81pub struct AnnotatedString {
82    pub text: String,
83    pub span_styles: Vec<RangeStyle<SpanStyle>>,
84    pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
85    /// Arbitrary tag+value annotations. Used for e.g. clickable link URLs.
86    /// Mirrors JC `AnnotatedString.getStringAnnotations(tag, start, end)`.
87    pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
88    /// Link annotations — URLs and clickable actions.
89    /// Mirrors JC `AnnotatedString.getLinkAnnotations(start, end)`.
90    pub link_annotations: Vec<RangeStyle<LinkAnnotation>>,
91}
92
93/// A style applied to a range of an `AnnotatedString`.
94#[derive(Debug, Clone, PartialEq)]
95pub struct RangeStyle<T> {
96    pub item: T,
97    pub range: Range<usize>,
98}
99
100impl AnnotatedString {
101    pub fn new(text: String) -> Self {
102        Self {
103            text,
104            span_styles: vec![],
105            paragraph_styles: vec![],
106            string_annotations: vec![],
107            link_annotations: vec![],
108        }
109    }
110
111    pub fn builder() -> Builder {
112        Builder::new()
113    }
114
115    pub fn len(&self) -> usize {
116        self.text.len()
117    }
118
119    pub fn is_empty(&self) -> bool {
120        self.text.is_empty()
121    }
122
123    /// Returns a sorted list of unique byte indices where styles change.
124    pub fn span_boundaries(&self) -> Vec<usize> {
125        let mut boundaries = vec![0, self.text.len()];
126        for span in &self.span_styles {
127            boundaries.push(span.range.start);
128            boundaries.push(span.range.end);
129        }
130        boundaries.sort_unstable();
131        boundaries.dedup();
132        boundaries
133            .into_iter()
134            .filter(|&b| b <= self.text.len() && self.text.is_char_boundary(b))
135            .collect()
136    }
137
138    /// Computes a hash representing the contents of the span styles, suitable for cache invalidation.
139    pub fn span_styles_hash(&self) -> u64 {
140        use std::hash::{Hash, Hasher};
141        let mut hasher = std::collections::hash_map::DefaultHasher::new();
142        hasher.write_usize(self.span_styles.len());
143        for span in &self.span_styles {
144            hasher.write_usize(span.range.start);
145            hasher.write_usize(span.range.end);
146
147            // Hash measurement-affecting fields
148            let dummy = crate::text::TextStyle {
149                span_style: span.item.clone(),
150                ..Default::default()
151            };
152            hasher.write_u64(dummy.measurement_hash());
153
154            // Hash visually-affecting fields ignored by measurement
155            if let Some(c) = &span.item.color {
156                hasher.write_u32(c.0.to_bits());
157                hasher.write_u32(c.1.to_bits());
158                hasher.write_u32(c.2.to_bits());
159                hasher.write_u32(c.3.to_bits());
160            }
161            if let Some(bg) = &span.item.background {
162                hasher.write_u32(bg.0.to_bits());
163                hasher.write_u32(bg.1.to_bits());
164                hasher.write_u32(bg.2.to_bits());
165                hasher.write_u32(bg.3.to_bits());
166            }
167            if let Some(d) = &span.item.text_decoration {
168                d.hash(&mut hasher);
169            }
170        }
171        hasher.finish()
172    }
173
174    /// Returns a new `AnnotatedString` containing a substring of the original text
175    /// and any styles that overlap with the new range, with indices adjusted.
176    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
177        if range.is_empty() {
178            return Self::new(String::new());
179        }
180
181        let start = range.start.min(self.text.len());
182        let end = range.end.max(start).min(self.text.len());
183
184        if start == end {
185            return Self::new(String::new());
186        }
187
188        let mut new_spans = Vec::new();
189        for span in &self.span_styles {
190            let intersection_start = span.range.start.max(start);
191            let intersection_end = span.range.end.min(end);
192            if intersection_start < intersection_end {
193                new_spans.push(RangeStyle {
194                    item: span.item.clone(),
195                    range: (intersection_start - start)..(intersection_end - start),
196                });
197            }
198        }
199
200        let mut new_paragraphs = Vec::new();
201        for span in &self.paragraph_styles {
202            let intersection_start = span.range.start.max(start);
203            let intersection_end = span.range.end.min(end);
204            if intersection_start < intersection_end {
205                new_paragraphs.push(RangeStyle {
206                    item: span.item.clone(),
207                    range: (intersection_start - start)..(intersection_end - start),
208                });
209            }
210        }
211
212        let mut new_string_annotations = Vec::new();
213        for ann in &self.string_annotations {
214            let intersection_start = ann.range.start.max(start);
215            let intersection_end = ann.range.end.min(end);
216            if intersection_start < intersection_end {
217                new_string_annotations.push(RangeStyle {
218                    item: ann.item.clone(),
219                    range: (intersection_start - start)..(intersection_end - start),
220                });
221            }
222        }
223
224        let mut new_link_annotations = Vec::new();
225        for ann in &self.link_annotations {
226            let intersection_start = ann.range.start.max(start);
227            let intersection_end = ann.range.end.min(end);
228            if intersection_start < intersection_end {
229                new_link_annotations.push(RangeStyle {
230                    item: ann.item.clone(),
231                    range: (intersection_start - start)..(intersection_end - start),
232                });
233            }
234        }
235
236        Self {
237            text: self.text[start..end].to_string(),
238            span_styles: new_spans,
239            paragraph_styles: new_paragraphs,
240            string_annotations: new_string_annotations,
241            link_annotations: new_link_annotations,
242        }
243    }
244
245    /// Returns all string annotations with the given `tag` whose range overlaps `[start, end)`.
246    ///
247    /// JC parity: `AnnotatedString.getStringAnnotations(tag, start, end) -> List<Range<String>>`
248    pub fn get_string_annotations(
249        &self,
250        tag: &str,
251        start: usize,
252        end: usize,
253    ) -> Vec<&RangeStyle<StringAnnotation>> {
254        self.string_annotations
255            .iter()
256            .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
257            .collect()
258    }
259
260    /// Returns all link annotations whose range overlaps `[start, end)`.
261    ///
262    /// JC parity: `AnnotatedString.getLinkAnnotations(start, end)`
263    pub fn get_link_annotations(
264        &self,
265        start: usize,
266        end: usize,
267    ) -> Vec<&RangeStyle<LinkAnnotation>> {
268        self.link_annotations
269            .iter()
270            .filter(|ann| ann.range.start < end && ann.range.end > start)
271            .collect()
272    }
273}
274
275impl From<String> for AnnotatedString {
276    fn from(text: String) -> Self {
277        Self::new(text)
278    }
279}
280
281impl From<&str> for AnnotatedString {
282    fn from(text: &str) -> Self {
283        Self::new(text.to_owned())
284    }
285}
286
287impl From<&String> for AnnotatedString {
288    fn from(text: &String) -> Self {
289        Self::new(text.clone())
290    }
291}
292
293impl From<&mut String> for AnnotatedString {
294    fn from(text: &mut String) -> Self {
295        Self::new(text.clone())
296    }
297}
298
299/// A builder to construct `AnnotatedString`.
300#[derive(Debug, Default, Clone)]
301pub struct Builder {
302    text: String,
303    span_styles: Vec<MutableRange<SpanStyle>>,
304    paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
305    string_annotations: Vec<MutableRange<StringAnnotation>>,
306    link_annotations: Vec<MutableRange<LinkAnnotation>>,
307    style_stack: Vec<StyleStackRecord>,
308}
309
310#[derive(Debug, Clone)]
311struct MutableRange<T> {
312    item: T,
313    start: usize,
314    end: usize,
315}
316
317#[derive(Debug, Clone)]
318struct StyleStackRecord {
319    style_type: StyleType,
320    index: usize,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324enum StyleType {
325    Span,
326    Paragraph,
327    StringAnnotation,
328    LinkAnnotation,
329}
330
331fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
332    let start = range.start.min(text.len());
333    let end = range.end.max(start).min(text.len());
334    start..end
335}
336
337fn append_clipped_ranges<T: Clone>(
338    target: &mut Vec<MutableRange<T>>,
339    source: &[RangeStyle<T>],
340    source_range: Range<usize>,
341    target_offset: usize,
342) {
343    for style in source {
344        let intersection_start = style.range.start.max(source_range.start);
345        let intersection_end = style.range.end.min(source_range.end);
346        if intersection_start < intersection_end {
347            target.push(MutableRange {
348                item: style.item.clone(),
349                start: (intersection_start - source_range.start) + target_offset,
350                end: (intersection_end - source_range.start) + target_offset,
351            });
352        }
353    }
354}
355
356impl Builder {
357    pub fn new() -> Self {
358        Self::default()
359    }
360
361    /// Appends the given String to this Builder.
362    pub fn append(mut self, text: &str) -> Self {
363        self.text.push_str(text);
364        self
365    }
366
367    pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
368        self.append_annotated_subsequence(annotated, 0..annotated.text.len())
369    }
370
371    pub fn append_annotated_subsequence(
372        mut self,
373        annotated: &AnnotatedString,
374        range: Range<usize>,
375    ) -> Self {
376        let range = clamp_subsequence_range(annotated.text.as_str(), range);
377        if range.is_empty() {
378            return self;
379        }
380
381        debug_assert!(annotated.text.is_char_boundary(range.start));
382        debug_assert!(annotated.text.is_char_boundary(range.end));
383
384        let target_offset = self.text.len();
385        self.text.push_str(&annotated.text[range.clone()]);
386        append_clipped_ranges(
387            &mut self.span_styles,
388            &annotated.span_styles,
389            range.clone(),
390            target_offset,
391        );
392        append_clipped_ranges(
393            &mut self.paragraph_styles,
394            &annotated.paragraph_styles,
395            range.clone(),
396            target_offset,
397        );
398        append_clipped_ranges(
399            &mut self.string_annotations,
400            &annotated.string_annotations,
401            range.clone(),
402            target_offset,
403        );
404        append_clipped_ranges(
405            &mut self.link_annotations,
406            &annotated.link_annotations,
407            range,
408            target_offset,
409        );
410        self
411    }
412
413    /// Applies the given `SpanStyle` to any appended text until a corresponding `pop` is called.
414    ///
415    /// Returns the index of the pushed style, which can be passed to `pop_to` or used as an ID.
416    pub fn push_style(mut self, style: SpanStyle) -> Self {
417        let index = self.span_styles.len();
418        self.span_styles.push(MutableRange {
419            item: style,
420            start: self.text.len(),
421            end: usize::MAX,
422        });
423        self.style_stack.push(StyleStackRecord {
424            style_type: StyleType::Span,
425            index,
426        });
427        self
428    }
429
430    /// Applies the given `ParagraphStyle` to any appended text until a corresponding `pop` is called.
431    pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
432        let index = self.paragraph_styles.len();
433        self.paragraph_styles.push(MutableRange {
434            item: style,
435            start: self.text.len(),
436            end: usize::MAX,
437        });
438        self.style_stack.push(StyleStackRecord {
439            style_type: StyleType::Paragraph,
440            index,
441        });
442        self
443    }
444
445    /// Pushes a string annotation covering subsequent appended text until the matching `pop`.
446    ///
447    /// JC parity: `Builder.pushStringAnnotation(tag, annotation)`
448    pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
449        let index = self.string_annotations.len();
450        self.string_annotations.push(MutableRange {
451            item: StringAnnotation {
452                tag: tag.to_string(),
453                annotation: annotation.to_string(),
454            },
455            start: self.text.len(),
456            end: usize::MAX,
457        });
458        self.style_stack.push(StyleStackRecord {
459            style_type: StyleType::StringAnnotation,
460            index,
461        });
462        self
463    }
464
465    /// Pushes a [`LinkAnnotation`] covering subsequent appended text.
466    /// Call [`pop`] when done, or use [`with_link`] for the block form.
467    ///
468    /// JC parity: `Builder.pushLink(link)`
469    pub fn push_link(mut self, link: LinkAnnotation) -> Self {
470        let index = self.link_annotations.len();
471        self.link_annotations.push(MutableRange {
472            item: link,
473            start: self.text.len(),
474            end: usize::MAX,
475        });
476        self.style_stack.push(StyleStackRecord {
477            style_type: StyleType::LinkAnnotation,
478            index,
479        });
480        self
481    }
482
483    /// Block form of [`push_link`] — mirrors JC's `withLink(link) { ... }` DSL.
484    ///
485    /// # Example
486    ///
487    /// ```rust,ignore
488    /// builder
489    ///     .append("Visit ")
490    ///     .with_link(
491    ///         LinkAnnotation::Url("https://developer.android.com".into()),
492    ///         |b| b.append("Android Developers"),
493    ///     )
494    ///     .append(".")
495    ///     .to_annotated_string()
496    /// ```
497    pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
498        let b = self.push_link(link);
499        let b = block(b);
500        b.pop()
501    }
502
503    /// Ends the style that was most recently pushed.
504    pub fn pop(mut self) -> Self {
505        if let Some(record) = self.style_stack.pop() {
506            match record.style_type {
507                StyleType::Span => {
508                    self.span_styles[record.index].end = self.text.len();
509                }
510                StyleType::Paragraph => {
511                    self.paragraph_styles[record.index].end = self.text.len();
512                }
513                StyleType::StringAnnotation => {
514                    self.string_annotations[record.index].end = self.text.len();
515                }
516                StyleType::LinkAnnotation => {
517                    self.link_annotations[record.index].end = self.text.len();
518                }
519            }
520        }
521        self
522    }
523
524    /// Completes the builder, resolving open styles to the end of the text.
525    pub fn to_annotated_string(mut self) -> AnnotatedString {
526        // Resolve unclosed styles
527        while let Some(record) = self.style_stack.pop() {
528            match record.style_type {
529                StyleType::Span => {
530                    self.span_styles[record.index].end = self.text.len();
531                }
532                StyleType::Paragraph => {
533                    self.paragraph_styles[record.index].end = self.text.len();
534                }
535                StyleType::StringAnnotation => {
536                    self.string_annotations[record.index].end = self.text.len();
537                }
538                StyleType::LinkAnnotation => {
539                    self.link_annotations[record.index].end = self.text.len();
540                }
541            }
542        }
543
544        AnnotatedString {
545            text: self.text,
546            span_styles: self
547                .span_styles
548                .into_iter()
549                .map(|s| RangeStyle {
550                    item: s.item,
551                    range: s.start..s.end,
552                })
553                .collect(),
554            paragraph_styles: self
555                .paragraph_styles
556                .into_iter()
557                .map(|s| RangeStyle {
558                    item: s.item,
559                    range: s.start..s.end,
560                })
561                .collect(),
562            string_annotations: self
563                .string_annotations
564                .into_iter()
565                .map(|s| RangeStyle {
566                    item: s.item,
567                    range: s.start..s.end,
568                })
569                .collect(),
570            link_annotations: self
571                .link_annotations
572                .into_iter()
573                .map(|s| RangeStyle {
574                    item: s.item,
575                    range: s.start..s.end,
576                })
577                .collect(),
578        }
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn test_builder_span() {
588        let span1 = SpanStyle {
589            alpha: Some(0.5),
590            ..Default::default()
591        };
592
593        let span2 = SpanStyle {
594            alpha: Some(1.0),
595            ..Default::default()
596        };
597
598        let annotated = AnnotatedString::builder()
599            .append("Hello ")
600            .push_style(span1.clone())
601            .append("World")
602            .push_style(span2.clone())
603            .append("!")
604            .pop()
605            .pop()
606            .to_annotated_string();
607
608        assert_eq!(annotated.text, "Hello World!");
609        assert_eq!(annotated.span_styles.len(), 2);
610        assert_eq!(annotated.span_styles[0].range, 6..12);
611        assert_eq!(annotated.span_styles[0].item, span1);
612        assert_eq!(annotated.span_styles[1].range, 11..12);
613        assert_eq!(annotated.span_styles[1].item, span2);
614    }
615
616    #[test]
617    fn with_link_url_roundtrips() {
618        let url = "https://developer.android.com";
619        let annotated = AnnotatedString::builder()
620            .append("Visit ")
621            .with_link(LinkAnnotation::Url(url.into()), |b| {
622                b.append("Android Developers")
623            })
624            .append(".")
625            .to_annotated_string();
626
627        assert_eq!(annotated.text, "Visit Android Developers.");
628        assert_eq!(annotated.link_annotations.len(), 1);
629        let ann = &annotated.link_annotations[0];
630        // "Android Developers" starts at byte 6
631        assert_eq!(ann.range, 6..24);
632        assert_eq!(ann.item, LinkAnnotation::Url(url.into()));
633    }
634
635    #[test]
636    fn with_link_clickable_calls_handler() {
637        use std::cell::Cell;
638        let called = Rc::new(Cell::new(false));
639        let called_clone = Rc::clone(&called);
640
641        let annotated = AnnotatedString::builder()
642            .with_link(
643                LinkAnnotation::Clickable {
644                    tag: "action".into(),
645                    handler: Rc::new(move || called_clone.set(true)),
646                },
647                |b| b.append("click me"),
648            )
649            .to_annotated_string();
650
651        assert_eq!(annotated.link_annotations.len(), 1);
652        // Invoke the handler
653        let ann = &annotated.link_annotations[0];
654        if let LinkAnnotation::Clickable { handler, .. } = &ann.item {
655            handler();
656        }
657        assert!(called.get(), "Clickable handler should have been called");
658    }
659
660    #[test]
661    fn with_link_subsequence_trims_range() {
662        let annotated = AnnotatedString::builder()
663            .append("pre ")
664            .with_link(LinkAnnotation::Url("http://x.com".into()), |b| {
665                b.append("link")
666            })
667            .append(" post")
668            .to_annotated_string();
669
670        // Take subsequence covering only the link text
671        let sub = annotated.subsequence(4..8); // "link"
672        assert_eq!(sub.link_annotations.len(), 1);
673        assert_eq!(sub.link_annotations[0].range, 0..4);
674    }
675
676    #[test]
677    fn append_annotated_preserves_ranges_with_existing_prefix() {
678        let annotated = AnnotatedString::builder()
679            .append("Hello ")
680            .push_style(SpanStyle {
681                alpha: Some(0.5),
682                ..Default::default()
683            })
684            .append("World")
685            .pop()
686            .push_string_annotation("kind", "planet")
687            .append("!")
688            .pop()
689            .to_annotated_string();
690
691        let combined = AnnotatedString::builder()
692            .append("Prefix ")
693            .append_annotated(&annotated)
694            .to_annotated_string();
695
696        assert_eq!(combined.text, "Prefix Hello World!");
697        assert_eq!(combined.span_styles.len(), 1);
698        assert_eq!(combined.span_styles[0].range, 13..18);
699        assert_eq!(combined.string_annotations.len(), 1);
700        assert_eq!(combined.string_annotations[0].range, 18..19);
701    }
702
703    #[test]
704    fn append_annotated_subsequence_clips_ranges_to_slice() {
705        let annotated = AnnotatedString::builder()
706            .append("Before ")
707            .push_style(SpanStyle {
708                alpha: Some(0.5),
709                ..Default::default()
710            })
711            .append("Styled")
712            .pop()
713            .with_link(LinkAnnotation::Url("https://example.com".into()), |b| {
714                b.append(" Link")
715            })
716            .to_annotated_string();
717
718        let slice = AnnotatedString::builder()
719            .append("-> ")
720            .append_annotated_subsequence(&annotated, 7..18)
721            .to_annotated_string();
722
723        assert_eq!(slice.text, "-> Styled Link");
724        assert_eq!(slice.span_styles.len(), 1);
725        assert_eq!(slice.span_styles[0].range, 3..9);
726        assert_eq!(slice.link_annotations.len(), 1);
727        assert_eq!(slice.link_annotations[0].range, 9..14);
728    }
729}