Skip to main content

cranpose_ui/text/
annotated_string.rs

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