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