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 {
36        /// Name that identifies the link.
37        tag: String,
38        /// Called when the user clicks the link.
39        handler: Rc<dyn Fn()>,
40    },
41}
42
43impl std::fmt::Debug for LinkAnnotation {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Url(url) => f.debug_tuple("Url").field(url).finish(),
47            Self::Clickable { tag, .. } => f.debug_struct("Clickable").field("tag", tag).finish(),
48        }
49    }
50}
51
52impl PartialEq for LinkAnnotation {
53    fn eq(&self, other: &Self) -> bool {
54        match (self, other) {
55            (Self::Url(a), Self::Url(b)) => a == b,
56            (
57                Self::Clickable {
58                    tag: ta,
59                    handler: ha,
60                },
61                Self::Clickable {
62                    tag: tb,
63                    handler: hb,
64                },
65            ) => ta == tb && Rc::ptr_eq(ha, hb),
66            _ => false,
67        }
68    }
69}
70
71/// Mirrors Jetpack Compose's `AnnotatedString.Range<String>` — a tag+value
72/// annotation covering a byte range.
73///
74/// JC ref: `androidx.compose.ui.text.AnnotatedString.Range`
75#[derive(Debug, Clone, PartialEq)]
76pub struct StringAnnotation {
77    /// Name that groups annotations. Look annotations up by it with
78    /// `AnnotatedString::get_string_annotations`.
79    pub tag: String,
80    /// The value stored for the tag.
81    pub annotation: String,
82}
83
84/// Link identity without the link behavior: what rendering may know about a
85/// [`LinkAnnotation`]. URL links keep their URL, clickable links keep their
86/// tag — the handler stays UI-side.
87#[derive(Debug, Clone, PartialEq)]
88pub enum LinkKey {
89    /// Identity of a [`LinkAnnotation::Url`].
90    Url(String),
91    /// Identity of a [`LinkAnnotation::Clickable`] — its tag.
92    Clickable(String),
93}
94
95/// What rendering reads from an [`AnnotatedString`]: content, styles, and
96/// link identity — never the link handlers, which live UI-side only.
97///
98/// Unlike `AnnotatedString` this is plain owned data (`Send + Sync`), so a
99/// lowered scene that carries it can cross threads.
100///
101/// Built once and never changed, it hashes itself when built: renderers key
102/// caches by [`RenderString::render_hash`] every frame.
103#[derive(Debug, Clone, PartialEq)]
104pub struct RenderString {
105    text: String,
106    span_styles: Vec<RangeStyle<SpanStyle>>,
107    paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
108    string_annotations: Vec<RangeStyle<StringAnnotation>>,
109    links: Vec<RangeStyle<LinkKey>>,
110    hash: u64,
111}
112
113impl Default for RenderString {
114    fn default() -> Self {
115        Self::from_parts(
116            String::new(),
117            Vec::new(),
118            Vec::new(),
119            Vec::new(),
120            Vec::new(),
121        )
122    }
123}
124
125const _: () = {
126    fn assert_send<T: Send + Sync>() {}
127    #[expect(dead_code)]
128    fn assert_render_string_is_send_sync() {
129        assert_send::<RenderString>();
130    }
131};
132
133impl RenderString {
134    fn from_parts(
135        text: String,
136        span_styles: Vec<RangeStyle<SpanStyle>>,
137        paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
138        string_annotations: Vec<RangeStyle<StringAnnotation>>,
139        links: Vec<RangeStyle<LinkKey>>,
140    ) -> Self {
141        let hash = render_hash_impl(&text, &span_styles, &paragraph_styles);
142        Self {
143            text,
144            span_styles,
145            paragraph_styles,
146            string_annotations,
147            links,
148            hash,
149        }
150    }
151
152    /// The characters.
153    pub fn text(&self) -> &str {
154        &self.text
155    }
156
157    /// Character styles with the byte ranges they cover.
158    pub fn span_styles(&self) -> &[RangeStyle<SpanStyle>] {
159        &self.span_styles
160    }
161
162    /// Paragraph styles with the byte ranges they cover.
163    pub fn paragraph_styles(&self) -> &[RangeStyle<ParagraphStyle>] {
164        &self.paragraph_styles
165    }
166
167    /// Tag and value annotations with the byte ranges they cover.
168    pub fn string_annotations(&self) -> &[RangeStyle<StringAnnotation>] {
169        &self.string_annotations
170    }
171
172    /// Link ranges by identity (tag/url): enough to hash and to key caches,
173    /// never enough to invoke a link.
174    pub fn links(&self) -> &[RangeStyle<LinkKey>] {
175        &self.links
176    }
177
178    /// Length of the text in bytes.
179    pub fn len(&self) -> usize {
180        self.text.len()
181    }
182
183    /// Returns true when the text is empty.
184    pub fn is_empty(&self) -> bool {
185        self.text.is_empty()
186    }
187
188    /// Returns a sorted list of unique byte indices where styles change.
189    ///
190    /// Mirrors [`AnnotatedString::span_boundaries`].
191    pub fn span_boundaries(&self) -> SpanBoundaries {
192        span_boundaries_impl(&self.text, &self.span_styles)
193    }
194
195    /// Mirrors [`AnnotatedString::render_hash`]: hashes the exact same fields
196    /// with the exact same formula, so a cache keyed by either stays keyed by
197    /// the same distinctions.
198    pub fn render_hash(&self) -> u64 {
199        self.hash
200    }
201
202    /// Returns a new `RenderString` containing a substring of the original
203    /// text and any styles that overlap with the new range, with indices
204    /// adjusted. Mirrors [`AnnotatedString::subsequence`].
205    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
206        let start = range.start.min(self.text.len());
207        let end = range.end.max(start).min(self.text.len());
208        if start == end {
209            return Self::default();
210        }
211
212        Self::from_parts(
213            self.text[start..end].to_string(),
214            clip_range_styles(&self.span_styles, start, end),
215            clip_range_styles(&self.paragraph_styles, start, end),
216            clip_range_styles(&self.string_annotations, start, end),
217            clip_range_styles(&self.links, start, end),
218        )
219    }
220}
221
222fn clip_range_styles<T: Clone>(
223    styles: &[RangeStyle<T>],
224    start: usize,
225    end: usize,
226) -> Vec<RangeStyle<T>> {
227    let mut clipped = Vec::new();
228    for style in styles {
229        let intersection_start = style.range.start.max(start);
230        let intersection_end = style.range.end.min(end);
231        if intersection_start < intersection_end {
232            clipped.push(RangeStyle {
233                item: style.item.clone(),
234                range: (intersection_start - start)..(intersection_end - start),
235            });
236        }
237    }
238    clipped
239}
240
241/// Byte offsets where a string's styles change, held inline for the few
242/// spans most text carries.
243pub type SpanBoundaries = smallvec::SmallVec<[usize; 8]>;
244
245fn span_boundaries_impl(text: &str, span_styles: &[RangeStyle<SpanStyle>]) -> SpanBoundaries {
246    let mut boundaries: SpanBoundaries = smallvec::smallvec![0, text.len()];
247    for span in span_styles {
248        boundaries.push(span.range.start);
249        boundaries.push(span.range.end);
250    }
251    boundaries.sort_unstable();
252    boundaries.dedup();
253    boundaries.retain(|boundary| *boundary <= text.len() && text.is_char_boundary(*boundary));
254    boundaries
255}
256
257fn render_hash_impl(
258    text: &str,
259    span_styles: &[RangeStyle<SpanStyle>],
260    paragraph_styles: &[RangeStyle<ParagraphStyle>],
261) -> u64 {
262    use std::hash::{Hash, Hasher};
263
264    let mut hasher = cranpose_ui_graphics::FxHasher::default();
265    text.hash(&mut hasher);
266    span_styles.len().hash(&mut hasher);
267    for span in span_styles {
268        span.range.start.hash(&mut hasher);
269        span.range.end.hash(&mut hasher);
270        span.item.render_hash().hash(&mut hasher);
271    }
272    paragraph_styles.len().hash(&mut hasher);
273    for paragraph in paragraph_styles {
274        paragraph.range.start.hash(&mut hasher);
275        paragraph.range.end.hash(&mut hasher);
276        paragraph.item.render_hash().hash(&mut hasher);
277    }
278    hasher.finish()
279}
280
281/// The basic data structure of text with multiple styles.
282///
283/// To construct an `AnnotatedString` you can use `AnnotatedString::builder()`.
284#[derive(Debug, Clone, PartialEq, Default)]
285pub struct AnnotatedString {
286    /// The characters.
287    pub text: String,
288    /// Character styles with the byte ranges they cover.
289    pub span_styles: Vec<RangeStyle<SpanStyle>>,
290    /// Paragraph styles with the byte ranges they cover.
291    pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
292    /// Arbitrary tag+value annotations. Used for e.g. clickable link URLs.
293    /// Mirrors JC `AnnotatedString.getStringAnnotations(tag, start, end)`.
294    pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
295    /// Link annotations — URLs and clickable actions.
296    /// Mirrors JC `AnnotatedString.getLinkAnnotations(start, end)`.
297    pub link_annotations: Vec<RangeStyle<LinkAnnotation>>,
298}
299
300/// A style applied to a range of an `AnnotatedString`.
301#[derive(Debug, Clone, PartialEq)]
302pub struct RangeStyle<T> {
303    /// The style or annotation that applies to the range.
304    pub item: T,
305    /// Byte range of the text the item covers. Both ends lie on character boundaries.
306    pub range: Range<usize>,
307}
308
309/// Returns the shared [`AnnotatedString`] for a plain, style-free string,
310/// reusing the copy made on an earlier frame when the content matches.
311///
312/// Draw scopes lower every `draw_text*` call through an `AnnotatedString` on
313/// every frame — once to measure and once to emit — and a HUD label or score
314/// counter has the same characters frame after frame. Without this pool each
315/// pass re-copied a string it had copied the frame before, only to hash it and
316/// hit a layout cache that is keyed by content anyway. Entries are verified by
317/// content on hit, so a hash collision costs a fresh copy, never wrong text.
318/// The pool clears itself when full; a live scene re-warms within one frame.
319pub fn shared_plain_annotated_string(text: &str) -> Rc<AnnotatedString> {
320    with_shared_plain_text(text, |shared| Rc::clone(&shared.annotated))
321}
322
323/// The [`RenderString`] of [`shared_plain_annotated_string`]'s string for
324/// `text`, converted once while the pool holds it, so a canvas redrawing the
325/// same characters hands the renderer the same allocation every frame.
326pub fn shared_plain_render_string(text: &str) -> std::sync::Arc<RenderString> {
327    with_shared_plain_text(text, |shared| {
328        std::sync::Arc::clone(
329            shared
330                .render
331                .get_or_init(|| std::sync::Arc::new(shared.annotated.render_string())),
332        )
333    })
334}
335
336pub(crate) struct SharedPlainText {
337    annotated: Rc<AnnotatedString>,
338    render: std::cell::OnceCell<std::sync::Arc<RenderString>>,
339}
340
341fn with_shared_plain_text<R>(text: &str, read: impl FnOnce(&SharedPlainText) -> R) -> R {
342    use std::hash::{Hash, Hasher};
343
344    const POOL_CAPACITY: usize = 256;
345
346    let mut hasher = cranpose_ui_graphics::FxHasher::default();
347    text.hash(&mut hasher);
348    let key = hasher.finish();
349
350    crate::thread_state::with(|state| {
351        let mut pool = state.shared_plain_texts.borrow_mut();
352        if let Some(shared) = pool.get(&key)
353            && shared.annotated.text == text
354        {
355            return read(shared);
356        }
357        if pool.len() >= POOL_CAPACITY {
358            pool.clear();
359        }
360        let shared = pool.entry(key).insert_entry(SharedPlainText {
361            annotated: Rc::new(AnnotatedString::new(text.to_owned())),
362            render: std::cell::OnceCell::new(),
363        });
364        read(shared.get())
365    })
366}
367
368impl AnnotatedString {
369    /// Creates a string holding `text` with no styles and no annotations.
370    pub fn new(text: String) -> Self {
371        Self {
372            text,
373            span_styles: vec![],
374            paragraph_styles: vec![],
375            string_annotations: vec![],
376            link_annotations: vec![],
377        }
378    }
379
380    /// Starts a [`Builder`] for a string with styles and annotations.
381    pub fn builder() -> Builder {
382        Builder::new()
383    }
384
385    /// Length of the text in bytes.
386    pub fn len(&self) -> usize {
387        self.text.len()
388    }
389
390    /// Returns true when the text is empty.
391    pub fn is_empty(&self) -> bool {
392        self.text.is_empty()
393    }
394
395    /// Returns a sorted list of unique byte indices where styles change.
396    pub fn span_boundaries(&self) -> SpanBoundaries {
397        span_boundaries_impl(&self.text, &self.span_styles)
398    }
399
400    /// Returns the [`RenderString`] view of this string: everything rendering
401    /// reads (content, styles, link identity), nothing it must not touch
402    /// (link handlers). A clone-conversion — memoize at the call site when
403    /// the same `AnnotatedString` lowers every frame.
404    pub fn render_string(&self) -> RenderString {
405        RenderString::from_parts(
406            self.text.clone(),
407            self.span_styles.clone(),
408            self.paragraph_styles.clone(),
409            self.string_annotations.clone(),
410            self.link_annotations
411                .iter()
412                .map(|link| RangeStyle {
413                    item: match &link.item {
414                        LinkAnnotation::Url(url) => LinkKey::Url(url.clone()),
415                        LinkAnnotation::Clickable { tag, .. } => LinkKey::Clickable(tag.clone()),
416                    },
417                    range: link.range.clone(),
418                })
419                .collect(),
420        )
421    }
422
423    /// Hash of the span styles' ranges and the attributes that change how
424    /// the text measures: two strings with the same text and hash measure
425    /// alike, whatever their spans' colors, backgrounds or decorations.
426    pub fn span_measurement_hash(&self) -> u64 {
427        use std::hash::Hasher;
428        let mut hasher = cranpose_ui_graphics::FxHasher::default();
429        hasher.write_usize(self.span_styles.len());
430        for span in &self.span_styles {
431            hasher.write_usize(span.range.start);
432            hasher.write_usize(span.range.end);
433            crate::text::style::hash_span_measurement(&span.item, &mut hasher);
434        }
435        hasher.finish()
436    }
437
438    /// Hash of the text and of the span and paragraph styles, as renderers use it to key caches.
439    pub fn render_hash(&self) -> u64 {
440        render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
441    }
442
443    /// Returns a new `AnnotatedString` containing a substring of the original text
444    /// and any styles that overlap with the new range, with indices adjusted.
445    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
446        if range.is_empty() {
447            return Self::new(String::new());
448        }
449
450        let start = range.start.min(self.text.len());
451        let end = range.end.max(start).min(self.text.len());
452
453        if start == end {
454            return Self::new(String::new());
455        }
456
457        let mut new_spans = Vec::new();
458        for span in &self.span_styles {
459            let intersection_start = span.range.start.max(start);
460            let intersection_end = span.range.end.min(end);
461            if intersection_start < intersection_end {
462                new_spans.push(RangeStyle {
463                    item: span.item.clone(),
464                    range: (intersection_start - start)..(intersection_end - start),
465                });
466            }
467        }
468
469        let mut new_paragraphs = Vec::new();
470        for span in &self.paragraph_styles {
471            let intersection_start = span.range.start.max(start);
472            let intersection_end = span.range.end.min(end);
473            if intersection_start < intersection_end {
474                new_paragraphs.push(RangeStyle {
475                    item: span.item.clone(),
476                    range: (intersection_start - start)..(intersection_end - start),
477                });
478            }
479        }
480
481        let mut new_string_annotations = Vec::new();
482        for ann in &self.string_annotations {
483            let intersection_start = ann.range.start.max(start);
484            let intersection_end = ann.range.end.min(end);
485            if intersection_start < intersection_end {
486                new_string_annotations.push(RangeStyle {
487                    item: ann.item.clone(),
488                    range: (intersection_start - start)..(intersection_end - start),
489                });
490            }
491        }
492
493        let mut new_link_annotations = Vec::new();
494        for ann in &self.link_annotations {
495            let intersection_start = ann.range.start.max(start);
496            let intersection_end = ann.range.end.min(end);
497            if intersection_start < intersection_end {
498                new_link_annotations.push(RangeStyle {
499                    item: ann.item.clone(),
500                    range: (intersection_start - start)..(intersection_end - start),
501                });
502            }
503        }
504
505        Self {
506            text: self.text[start..end].to_string(),
507            span_styles: new_spans,
508            paragraph_styles: new_paragraphs,
509            string_annotations: new_string_annotations,
510            link_annotations: new_link_annotations,
511        }
512    }
513
514    /// Returns all string annotations with the given `tag` whose range overlaps `[start, end)`.
515    ///
516    /// JC parity: `AnnotatedString.getStringAnnotations(tag, start, end) -> List<Range<String>>`
517    pub fn get_string_annotations(
518        &self,
519        tag: &str,
520        start: usize,
521        end: usize,
522    ) -> Vec<&RangeStyle<StringAnnotation>> {
523        self.string_annotations
524            .iter()
525            .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
526            .collect()
527    }
528
529    /// Returns all link annotations whose range overlaps `[start, end)`.
530    ///
531    /// JC parity: `AnnotatedString.getLinkAnnotations(start, end)`
532    pub fn get_link_annotations(
533        &self,
534        start: usize,
535        end: usize,
536    ) -> Vec<&RangeStyle<LinkAnnotation>> {
537        self.link_annotations
538            .iter()
539            .filter(|ann| ann.range.start < end && ann.range.end > start)
540            .collect()
541    }
542}
543
544impl From<String> for AnnotatedString {
545    fn from(text: String) -> Self {
546        Self::new(text)
547    }
548}
549
550impl From<&str> for AnnotatedString {
551    fn from(text: &str) -> Self {
552        Self::new(text.to_owned())
553    }
554}
555
556impl From<&String> for AnnotatedString {
557    fn from(text: &String) -> Self {
558        Self::new(text.clone())
559    }
560}
561
562impl From<&mut String> for AnnotatedString {
563    fn from(text: &mut String) -> Self {
564        Self::new(text.clone())
565    }
566}
567
568/// A builder to construct `AnnotatedString`.
569#[derive(Debug, Default, Clone)]
570pub struct Builder {
571    text: String,
572    span_styles: Vec<MutableRange<SpanStyle>>,
573    paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
574    string_annotations: Vec<MutableRange<StringAnnotation>>,
575    link_annotations: Vec<MutableRange<LinkAnnotation>>,
576    style_stack: Vec<StyleStackRecord>,
577}
578
579#[derive(Debug, Clone)]
580struct MutableRange<T> {
581    item: T,
582    start: usize,
583    end: usize,
584}
585
586#[derive(Debug, Clone)]
587struct StyleStackRecord {
588    style_type: StyleType,
589    index: usize,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593enum StyleType {
594    Span,
595    Paragraph,
596    StringAnnotation,
597    LinkAnnotation,
598}
599
600fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
601    let start = range.start.min(text.len());
602    let end = range.end.max(start).min(text.len());
603    start..end
604}
605
606/// The ranges a builder collected, in a vector that keeps no room past
607/// them: a string outlives its builder, and a span style is 296 bytes, so
608/// the room growth left for four took 1,184 bytes for a string of one or
609/// two spans.
610fn finished_ranges<T>(ranges: Vec<MutableRange<T>>) -> Vec<RangeStyle<T>> {
611    let mut finished: Vec<RangeStyle<T>> = ranges
612        .into_iter()
613        .map(|range| RangeStyle {
614            item: range.item,
615            range: range.start..range.end,
616        })
617        .collect();
618    finished.shrink_to_fit();
619    finished
620}
621
622fn append_clipped_ranges<T: Clone>(
623    target: &mut Vec<MutableRange<T>>,
624    source: &[RangeStyle<T>],
625    source_range: Range<usize>,
626    target_offset: usize,
627) {
628    for style in source {
629        let intersection_start = style.range.start.max(source_range.start);
630        let intersection_end = style.range.end.min(source_range.end);
631        if intersection_start < intersection_end {
632            target.push(MutableRange {
633                item: style.item.clone(),
634                start: (intersection_start - source_range.start) + target_offset,
635                end: (intersection_end - source_range.start) + target_offset,
636            });
637        }
638    }
639}
640
641impl Builder {
642    /// Creates a builder with no text.
643    pub fn new() -> Self {
644        Self::default()
645    }
646
647    /// Appends the given String to this Builder.
648    pub fn append(mut self, text: &str) -> Self {
649        self.text.push_str(text);
650        self
651    }
652
653    /// Appends the text of `annotated` together with its styles and annotations.
654    pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
655        self.append_annotated_subsequence(annotated, 0..annotated.text.len())
656    }
657
658    /// Appends the bytes of `annotated` in `range` with the styles and annotations that overlap
659    /// them. The range is clamped to the text and both ends must lie on character boundaries.
660    pub fn append_annotated_subsequence(
661        mut self,
662        annotated: &AnnotatedString,
663        range: Range<usize>,
664    ) -> Self {
665        let range = clamp_subsequence_range(annotated.text.as_str(), range);
666        if range.is_empty() {
667            return self;
668        }
669
670        debug_assert!(annotated.text.is_char_boundary(range.start));
671        debug_assert!(annotated.text.is_char_boundary(range.end));
672
673        let target_offset = self.text.len();
674        self.text.push_str(&annotated.text[range.clone()]);
675        append_clipped_ranges(
676            &mut self.span_styles,
677            &annotated.span_styles,
678            range.clone(),
679            target_offset,
680        );
681        append_clipped_ranges(
682            &mut self.paragraph_styles,
683            &annotated.paragraph_styles,
684            range.clone(),
685            target_offset,
686        );
687        append_clipped_ranges(
688            &mut self.string_annotations,
689            &annotated.string_annotations,
690            range.clone(),
691            target_offset,
692        );
693        append_clipped_ranges(
694            &mut self.link_annotations,
695            &annotated.link_annotations,
696            range,
697            target_offset,
698        );
699        self
700    }
701
702    /// Applies the given `SpanStyle` to any appended text until a corresponding `pop` is called.
703    ///
704    /// Returns the index of the pushed style, which can be passed to `pop_to` or used as an ID.
705    pub fn push_style(mut self, style: SpanStyle) -> Self {
706        let index = self.span_styles.len();
707        self.span_styles.push(MutableRange {
708            item: style,
709            start: self.text.len(),
710            end: usize::MAX,
711        });
712        self.style_stack.push(StyleStackRecord {
713            style_type: StyleType::Span,
714            index,
715        });
716        self
717    }
718
719    /// Applies the given `ParagraphStyle` to any appended text until a corresponding `pop` is called.
720    pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
721        let index = self.paragraph_styles.len();
722        self.paragraph_styles.push(MutableRange {
723            item: style,
724            start: self.text.len(),
725            end: usize::MAX,
726        });
727        self.style_stack.push(StyleStackRecord {
728            style_type: StyleType::Paragraph,
729            index,
730        });
731        self
732    }
733
734    /// Pushes a string annotation covering subsequent appended text until the matching `pop`.
735    ///
736    /// JC parity: `Builder.pushStringAnnotation(tag, annotation)`
737    pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
738        let index = self.string_annotations.len();
739        self.string_annotations.push(MutableRange {
740            item: StringAnnotation {
741                tag: tag.to_string(),
742                annotation: annotation.to_string(),
743            },
744            start: self.text.len(),
745            end: usize::MAX,
746        });
747        self.style_stack.push(StyleStackRecord {
748            style_type: StyleType::StringAnnotation,
749            index,
750        });
751        self
752    }
753
754    /// Pushes a [`LinkAnnotation`] covering subsequent appended text.
755    /// Call `pop` when done, or use `with_link` for the block form.
756    ///
757    /// JC parity: `Builder.pushLink(link)`
758    pub fn push_link(mut self, link: LinkAnnotation) -> Self {
759        let index = self.link_annotations.len();
760        self.link_annotations.push(MutableRange {
761            item: link,
762            start: self.text.len(),
763            end: usize::MAX,
764        });
765        self.style_stack.push(StyleStackRecord {
766            style_type: StyleType::LinkAnnotation,
767            index,
768        });
769        self
770    }
771
772    /// Block form of `push_link` — mirrors JC's `withLink(link) { ... }` DSL.
773    ///
774    /// # Example
775    ///
776    /// ```rust,ignore
777    /// builder
778    ///     .append("Visit ")
779    ///     .with_link(
780    ///         LinkAnnotation::Url("https://developer.android.com".into()),
781    ///         |b| b.append("Android Developers"),
782    ///     )
783    ///     .append(".")
784    ///     .to_annotated_string()
785    /// ```
786    pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
787        let b = self.push_link(link);
788        let b = block(b);
789        b.pop()
790    }
791
792    /// Ends the style that was most recently pushed.
793    pub fn pop(mut self) -> Self {
794        if let Some(record) = self.style_stack.pop() {
795            match record.style_type {
796                StyleType::Span => {
797                    self.span_styles[record.index].end = self.text.len();
798                }
799                StyleType::Paragraph => {
800                    self.paragraph_styles[record.index].end = self.text.len();
801                }
802                StyleType::StringAnnotation => {
803                    self.string_annotations[record.index].end = self.text.len();
804                }
805                StyleType::LinkAnnotation => {
806                    self.link_annotations[record.index].end = self.text.len();
807                }
808            }
809        }
810        self
811    }
812
813    /// Completes the builder, resolving open styles to the end of the text.
814    pub fn to_annotated_string(mut self) -> AnnotatedString {
815        while let Some(record) = self.style_stack.pop() {
816            match record.style_type {
817                StyleType::Span => {
818                    self.span_styles[record.index].end = self.text.len();
819                }
820                StyleType::Paragraph => {
821                    self.paragraph_styles[record.index].end = self.text.len();
822                }
823                StyleType::StringAnnotation => {
824                    self.string_annotations[record.index].end = self.text.len();
825                }
826                StyleType::LinkAnnotation => {
827                    self.link_annotations[record.index].end = self.text.len();
828                }
829            }
830        }
831
832        AnnotatedString {
833            text: self.text,
834            span_styles: finished_ranges(self.span_styles),
835            paragraph_styles: finished_ranges(self.paragraph_styles),
836            string_annotations: finished_ranges(self.string_annotations),
837            link_annotations: finished_ranges(self.link_annotations),
838        }
839    }
840}
841
842#[cfg(test)]
843#[path = "tests/annotated_string_tests.rs"]
844mod tests;