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
331impl Builder {
332    pub fn new() -> Self {
333        Self::default()
334    }
335
336    /// Appends the given String to this Builder.
337    pub fn append(mut self, text: &str) -> Self {
338        self.text.push_str(text);
339        self
340    }
341
342    /// Applies the given `SpanStyle` to any appended text until a corresponding `pop` is called.
343    ///
344    /// Returns the index of the pushed style, which can be passed to `pop_to` or used as an ID.
345    pub fn push_style(mut self, style: SpanStyle) -> Self {
346        let index = self.span_styles.len();
347        self.span_styles.push(MutableRange {
348            item: style,
349            start: self.text.len(),
350            end: usize::MAX,
351        });
352        self.style_stack.push(StyleStackRecord {
353            style_type: StyleType::Span,
354            index,
355        });
356        self
357    }
358
359    /// Applies the given `ParagraphStyle` to any appended text until a corresponding `pop` is called.
360    pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
361        let index = self.paragraph_styles.len();
362        self.paragraph_styles.push(MutableRange {
363            item: style,
364            start: self.text.len(),
365            end: usize::MAX,
366        });
367        self.style_stack.push(StyleStackRecord {
368            style_type: StyleType::Paragraph,
369            index,
370        });
371        self
372    }
373
374    /// Pushes a string annotation covering subsequent appended text until the matching `pop`.
375    ///
376    /// JC parity: `Builder.pushStringAnnotation(tag, annotation)`
377    pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
378        let index = self.string_annotations.len();
379        self.string_annotations.push(MutableRange {
380            item: StringAnnotation {
381                tag: tag.to_string(),
382                annotation: annotation.to_string(),
383            },
384            start: self.text.len(),
385            end: usize::MAX,
386        });
387        self.style_stack.push(StyleStackRecord {
388            style_type: StyleType::StringAnnotation,
389            index,
390        });
391        self
392    }
393
394    /// Pushes a [`LinkAnnotation`] covering subsequent appended text.
395    /// Call [`pop`] when done, or use [`with_link`] for the block form.
396    ///
397    /// JC parity: `Builder.pushLink(link)`
398    pub fn push_link(mut self, link: LinkAnnotation) -> Self {
399        let index = self.link_annotations.len();
400        self.link_annotations.push(MutableRange {
401            item: link,
402            start: self.text.len(),
403            end: usize::MAX,
404        });
405        self.style_stack.push(StyleStackRecord {
406            style_type: StyleType::LinkAnnotation,
407            index,
408        });
409        self
410    }
411
412    /// Block form of [`push_link`] — mirrors JC's `withLink(link) { ... }` DSL.
413    ///
414    /// # Example
415    ///
416    /// ```rust,ignore
417    /// builder
418    ///     .append("Visit ")
419    ///     .with_link(
420    ///         LinkAnnotation::Url("https://developer.android.com".into()),
421    ///         |b| b.append("Android Developers"),
422    ///     )
423    ///     .append(".")
424    ///     .to_annotated_string()
425    /// ```
426    pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
427        let b = self.push_link(link);
428        let b = block(b);
429        b.pop()
430    }
431
432    /// Ends the style that was most recently pushed.
433    pub fn pop(mut self) -> Self {
434        if let Some(record) = self.style_stack.pop() {
435            match record.style_type {
436                StyleType::Span => {
437                    self.span_styles[record.index].end = self.text.len();
438                }
439                StyleType::Paragraph => {
440                    self.paragraph_styles[record.index].end = self.text.len();
441                }
442                StyleType::StringAnnotation => {
443                    self.string_annotations[record.index].end = self.text.len();
444                }
445                StyleType::LinkAnnotation => {
446                    self.link_annotations[record.index].end = self.text.len();
447                }
448            }
449        }
450        self
451    }
452
453    /// Completes the builder, resolving open styles to the end of the text.
454    pub fn to_annotated_string(mut self) -> AnnotatedString {
455        // Resolve unclosed styles
456        while let Some(record) = self.style_stack.pop() {
457            match record.style_type {
458                StyleType::Span => {
459                    self.span_styles[record.index].end = self.text.len();
460                }
461                StyleType::Paragraph => {
462                    self.paragraph_styles[record.index].end = self.text.len();
463                }
464                StyleType::StringAnnotation => {
465                    self.string_annotations[record.index].end = self.text.len();
466                }
467                StyleType::LinkAnnotation => {
468                    self.link_annotations[record.index].end = self.text.len();
469                }
470            }
471        }
472
473        AnnotatedString {
474            text: self.text,
475            span_styles: self
476                .span_styles
477                .into_iter()
478                .map(|s| RangeStyle {
479                    item: s.item,
480                    range: s.start..s.end,
481                })
482                .collect(),
483            paragraph_styles: self
484                .paragraph_styles
485                .into_iter()
486                .map(|s| RangeStyle {
487                    item: s.item,
488                    range: s.start..s.end,
489                })
490                .collect(),
491            string_annotations: self
492                .string_annotations
493                .into_iter()
494                .map(|s| RangeStyle {
495                    item: s.item,
496                    range: s.start..s.end,
497                })
498                .collect(),
499            link_annotations: self
500                .link_annotations
501                .into_iter()
502                .map(|s| RangeStyle {
503                    item: s.item,
504                    range: s.start..s.end,
505                })
506                .collect(),
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_builder_span() {
517        let span1 = SpanStyle {
518            alpha: Some(0.5),
519            ..Default::default()
520        };
521
522        let span2 = SpanStyle {
523            alpha: Some(1.0),
524            ..Default::default()
525        };
526
527        let annotated = AnnotatedString::builder()
528            .append("Hello ")
529            .push_style(span1.clone())
530            .append("World")
531            .push_style(span2.clone())
532            .append("!")
533            .pop()
534            .pop()
535            .to_annotated_string();
536
537        assert_eq!(annotated.text, "Hello World!");
538        assert_eq!(annotated.span_styles.len(), 2);
539        assert_eq!(annotated.span_styles[0].range, 6..12);
540        assert_eq!(annotated.span_styles[0].item, span1);
541        assert_eq!(annotated.span_styles[1].range, 11..12);
542        assert_eq!(annotated.span_styles[1].item, span2);
543    }
544
545    #[test]
546    fn with_link_url_roundtrips() {
547        let url = "https://developer.android.com";
548        let annotated = AnnotatedString::builder()
549            .append("Visit ")
550            .with_link(LinkAnnotation::Url(url.into()), |b| {
551                b.append("Android Developers")
552            })
553            .append(".")
554            .to_annotated_string();
555
556        assert_eq!(annotated.text, "Visit Android Developers.");
557        assert_eq!(annotated.link_annotations.len(), 1);
558        let ann = &annotated.link_annotations[0];
559        // "Android Developers" starts at byte 6
560        assert_eq!(ann.range, 6..24);
561        assert_eq!(ann.item, LinkAnnotation::Url(url.into()));
562    }
563
564    #[test]
565    fn with_link_clickable_calls_handler() {
566        use std::cell::Cell;
567        let called = Rc::new(Cell::new(false));
568        let called_clone = Rc::clone(&called);
569
570        let annotated = AnnotatedString::builder()
571            .with_link(
572                LinkAnnotation::Clickable {
573                    tag: "action".into(),
574                    handler: Rc::new(move || called_clone.set(true)),
575                },
576                |b| b.append("click me"),
577            )
578            .to_annotated_string();
579
580        assert_eq!(annotated.link_annotations.len(), 1);
581        // Invoke the handler
582        let ann = &annotated.link_annotations[0];
583        if let LinkAnnotation::Clickable { handler, .. } = &ann.item {
584            handler();
585        }
586        assert!(called.get(), "Clickable handler should have been called");
587    }
588
589    #[test]
590    fn with_link_subsequence_trims_range() {
591        let annotated = AnnotatedString::builder()
592            .append("pre ")
593            .with_link(LinkAnnotation::Url("http://x.com".into()), |b| {
594                b.append("link")
595            })
596            .append(" post")
597            .to_annotated_string();
598
599        // Take subsequence covering only the link text
600        let sub = annotated.subsequence(4..8); // "link"
601        assert_eq!(sub.link_annotations.len(), 1);
602        assert_eq!(sub.link_annotations[0].range, 0..4);
603    }
604}