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) -> Vec<usize> {
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
228fn span_boundaries_impl(text: &str, span_styles: &[RangeStyle<SpanStyle>]) -> Vec<usize> {
229    let mut boundaries = vec![0, text.len()];
230    for span in span_styles {
231        boundaries.push(span.range.start);
232        boundaries.push(span.range.end);
233    }
234    boundaries.sort_unstable();
235    boundaries.dedup();
236    boundaries
237        .into_iter()
238        .filter(|&b| b <= text.len() && text.is_char_boundary(b))
239        .collect()
240}
241
242fn render_hash_impl(
243    text: &str,
244    span_styles: &[RangeStyle<SpanStyle>],
245    paragraph_styles: &[RangeStyle<ParagraphStyle>],
246) -> u64 {
247    use std::hash::{Hash, Hasher};
248
249    let mut hasher = cranpose_ui_graphics::FxHasher::default();
250    text.hash(&mut hasher);
251    span_styles.len().hash(&mut hasher);
252    for span in span_styles {
253        span.range.start.hash(&mut hasher);
254        span.range.end.hash(&mut hasher);
255        span.item.render_hash().hash(&mut hasher);
256    }
257    paragraph_styles.len().hash(&mut hasher);
258    for paragraph in paragraph_styles {
259        paragraph.range.start.hash(&mut hasher);
260        paragraph.range.end.hash(&mut hasher);
261        paragraph.item.render_hash().hash(&mut hasher);
262    }
263    hasher.finish()
264}
265
266/// The basic data structure of text with multiple styles.
267///
268/// To construct an `AnnotatedString` you can use `AnnotatedString::builder()`.
269#[derive(Debug, Clone, PartialEq, Default)]
270pub struct AnnotatedString {
271    pub text: String,
272    pub span_styles: Vec<RangeStyle<SpanStyle>>,
273    pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
274    /// Arbitrary tag+value annotations. Used for e.g. clickable link URLs.
275    /// Mirrors JC `AnnotatedString.getStringAnnotations(tag, start, end)`.
276    pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
277    /// Link annotations — URLs and clickable actions.
278    /// Mirrors JC `AnnotatedString.getLinkAnnotations(start, end)`.
279    pub link_annotations: Vec<RangeStyle<LinkAnnotation>>,
280}
281
282/// A style applied to a range of an `AnnotatedString`.
283#[derive(Debug, Clone, PartialEq)]
284pub struct RangeStyle<T> {
285    pub item: T,
286    pub range: Range<usize>,
287}
288
289/// Returns the shared [`AnnotatedString`] for a plain, style-free string,
290/// reusing the copy made on an earlier frame when the content matches.
291///
292/// Draw scopes lower every `draw_text*` call through an `AnnotatedString` on
293/// every frame — once to measure and once to emit — and a HUD label or score
294/// counter has the same characters frame after frame. Without this pool each
295/// pass re-copied a string it had copied the frame before, only to hash it and
296/// hit a layout cache that is keyed by content anyway. Entries are verified by
297/// content on hit, so a hash collision costs a fresh copy, never wrong text.
298/// The pool clears itself when full; a live scene re-warms within one frame.
299pub fn shared_plain_annotated_string(text: &str) -> Rc<AnnotatedString> {
300    with_shared_plain_text(text, |shared| Rc::clone(&shared.annotated))
301}
302
303/// The [`RenderString`] of [`shared_plain_annotated_string`]'s string for
304/// `text`, converted once while the pool holds it, so a canvas redrawing the
305/// same characters hands the renderer the same allocation every frame.
306pub fn shared_plain_render_string(text: &str) -> std::sync::Arc<RenderString> {
307    with_shared_plain_text(text, |shared| {
308        std::sync::Arc::clone(
309            shared
310                .render
311                .get_or_init(|| std::sync::Arc::new(shared.annotated.render_string())),
312        )
313    })
314}
315
316struct SharedPlainText {
317    annotated: Rc<AnnotatedString>,
318    render: std::cell::OnceCell<std::sync::Arc<RenderString>>,
319}
320
321fn with_shared_plain_text<R>(text: &str, read: impl FnOnce(&SharedPlainText) -> R) -> R {
322    use std::{
323        cell::RefCell,
324        collections::HashMap,
325        hash::{Hash, Hasher},
326    };
327
328    const POOL_CAPACITY: usize = 256;
329    thread_local! {
330        static POOL: RefCell<HashMap<u64, SharedPlainText>> = RefCell::new(HashMap::new());
331    }
332
333    let mut hasher = cranpose_ui_graphics::FxHasher::default();
334    text.hash(&mut hasher);
335    let key = hasher.finish();
336
337    POOL.with(|pool| {
338        let mut pool = pool.borrow_mut();
339        if let Some(shared) = pool.get(&key)
340            && shared.annotated.text == text
341        {
342            return read(shared);
343        }
344        if pool.len() >= POOL_CAPACITY {
345            pool.clear();
346        }
347        let shared = pool.entry(key).insert_entry(SharedPlainText {
348            annotated: Rc::new(AnnotatedString::new(text.to_owned())),
349            render: std::cell::OnceCell::new(),
350        });
351        read(shared.get())
352    })
353}
354
355impl AnnotatedString {
356    pub fn new(text: String) -> Self {
357        Self {
358            text,
359            span_styles: vec![],
360            paragraph_styles: vec![],
361            string_annotations: vec![],
362            link_annotations: vec![],
363        }
364    }
365
366    pub fn builder() -> Builder {
367        Builder::new()
368    }
369
370    pub fn len(&self) -> usize {
371        self.text.len()
372    }
373
374    pub fn is_empty(&self) -> bool {
375        self.text.is_empty()
376    }
377
378    /// Returns a sorted list of unique byte indices where styles change.
379    pub fn span_boundaries(&self) -> Vec<usize> {
380        span_boundaries_impl(&self.text, &self.span_styles)
381    }
382
383    /// Returns the [`RenderString`] view of this string: everything rendering
384    /// reads (content, styles, link identity), nothing it must not touch
385    /// (link handlers). A clone-conversion — memoize at the call site when
386    /// the same `AnnotatedString` lowers every frame.
387    pub fn render_string(&self) -> RenderString {
388        RenderString::from_parts(
389            self.text.clone(),
390            self.span_styles.clone(),
391            self.paragraph_styles.clone(),
392            self.string_annotations.clone(),
393            self.link_annotations
394                .iter()
395                .map(|link| RangeStyle {
396                    item: match &link.item {
397                        LinkAnnotation::Url(url) => LinkKey::Url(url.clone()),
398                        LinkAnnotation::Clickable { tag, .. } => LinkKey::Clickable(tag.clone()),
399                    },
400                    range: link.range.clone(),
401                })
402                .collect(),
403        )
404    }
405
406    /// Computes a hash representing the contents of the span styles, suitable for cache invalidation.
407    pub fn span_styles_hash(&self) -> u64 {
408        use std::hash::{Hash, Hasher};
409        let mut hasher = cranpose_ui_graphics::FxHasher::default();
410        hasher.write_usize(self.span_styles.len());
411        for span in &self.span_styles {
412            hasher.write_usize(span.range.start);
413            hasher.write_usize(span.range.end);
414
415            let dummy = crate::text::TextStyle {
416                span_style: span.item.clone(),
417                ..Default::default()
418            };
419            hasher.write_u64(dummy.measurement_hash());
420
421            if let Some(c) = &span.item.color {
422                hasher.write_u32(c.0.to_bits());
423                hasher.write_u32(c.1.to_bits());
424                hasher.write_u32(c.2.to_bits());
425                hasher.write_u32(c.3.to_bits());
426            }
427            if let Some(bg) = &span.item.background {
428                hasher.write_u32(bg.0.to_bits());
429                hasher.write_u32(bg.1.to_bits());
430                hasher.write_u32(bg.2.to_bits());
431                hasher.write_u32(bg.3.to_bits());
432            }
433            if let Some(d) = &span.item.text_decoration {
434                d.hash(&mut hasher);
435            }
436        }
437        hasher.finish()
438    }
439
440    pub fn render_hash(&self) -> u64 {
441        render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
442    }
443
444    /// Returns a new `AnnotatedString` containing a substring of the original text
445    /// and any styles that overlap with the new range, with indices adjusted.
446    pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
447        if range.is_empty() {
448            return Self::new(String::new());
449        }
450
451        let start = range.start.min(self.text.len());
452        let end = range.end.max(start).min(self.text.len());
453
454        if start == end {
455            return Self::new(String::new());
456        }
457
458        let mut new_spans = Vec::new();
459        for span in &self.span_styles {
460            let intersection_start = span.range.start.max(start);
461            let intersection_end = span.range.end.min(end);
462            if intersection_start < intersection_end {
463                new_spans.push(RangeStyle {
464                    item: span.item.clone(),
465                    range: (intersection_start - start)..(intersection_end - start),
466                });
467            }
468        }
469
470        let mut new_paragraphs = Vec::new();
471        for span in &self.paragraph_styles {
472            let intersection_start = span.range.start.max(start);
473            let intersection_end = span.range.end.min(end);
474            if intersection_start < intersection_end {
475                new_paragraphs.push(RangeStyle {
476                    item: span.item.clone(),
477                    range: (intersection_start - start)..(intersection_end - start),
478                });
479            }
480        }
481
482        let mut new_string_annotations = Vec::new();
483        for ann in &self.string_annotations {
484            let intersection_start = ann.range.start.max(start);
485            let intersection_end = ann.range.end.min(end);
486            if intersection_start < intersection_end {
487                new_string_annotations.push(RangeStyle {
488                    item: ann.item.clone(),
489                    range: (intersection_start - start)..(intersection_end - start),
490                });
491            }
492        }
493
494        let mut new_link_annotations = Vec::new();
495        for ann in &self.link_annotations {
496            let intersection_start = ann.range.start.max(start);
497            let intersection_end = ann.range.end.min(end);
498            if intersection_start < intersection_end {
499                new_link_annotations.push(RangeStyle {
500                    item: ann.item.clone(),
501                    range: (intersection_start - start)..(intersection_end - start),
502                });
503            }
504        }
505
506        Self {
507            text: self.text[start..end].to_string(),
508            span_styles: new_spans,
509            paragraph_styles: new_paragraphs,
510            string_annotations: new_string_annotations,
511            link_annotations: new_link_annotations,
512        }
513    }
514
515    /// Returns all string annotations with the given `tag` whose range overlaps `[start, end)`.
516    ///
517    /// JC parity: `AnnotatedString.getStringAnnotations(tag, start, end) -> List<Range<String>>`
518    pub fn get_string_annotations(
519        &self,
520        tag: &str,
521        start: usize,
522        end: usize,
523    ) -> Vec<&RangeStyle<StringAnnotation>> {
524        self.string_annotations
525            .iter()
526            .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
527            .collect()
528    }
529
530    /// Returns all link annotations whose range overlaps `[start, end)`.
531    ///
532    /// JC parity: `AnnotatedString.getLinkAnnotations(start, end)`
533    pub fn get_link_annotations(
534        &self,
535        start: usize,
536        end: usize,
537    ) -> Vec<&RangeStyle<LinkAnnotation>> {
538        self.link_annotations
539            .iter()
540            .filter(|ann| ann.range.start < end && ann.range.end > start)
541            .collect()
542    }
543}
544
545impl From<String> for AnnotatedString {
546    fn from(text: String) -> Self {
547        Self::new(text)
548    }
549}
550
551impl From<&str> for AnnotatedString {
552    fn from(text: &str) -> Self {
553        Self::new(text.to_owned())
554    }
555}
556
557impl From<&String> for AnnotatedString {
558    fn from(text: &String) -> Self {
559        Self::new(text.clone())
560    }
561}
562
563impl From<&mut String> for AnnotatedString {
564    fn from(text: &mut String) -> Self {
565        Self::new(text.clone())
566    }
567}
568
569/// A builder to construct `AnnotatedString`.
570#[derive(Debug, Default, Clone)]
571pub struct Builder {
572    text: String,
573    span_styles: Vec<MutableRange<SpanStyle>>,
574    paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
575    string_annotations: Vec<MutableRange<StringAnnotation>>,
576    link_annotations: Vec<MutableRange<LinkAnnotation>>,
577    style_stack: Vec<StyleStackRecord>,
578}
579
580#[derive(Debug, Clone)]
581struct MutableRange<T> {
582    item: T,
583    start: usize,
584    end: usize,
585}
586
587#[derive(Debug, Clone)]
588struct StyleStackRecord {
589    style_type: StyleType,
590    index: usize,
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594enum StyleType {
595    Span,
596    Paragraph,
597    StringAnnotation,
598    LinkAnnotation,
599}
600
601fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
602    let start = range.start.min(text.len());
603    let end = range.end.max(start).min(text.len());
604    start..end
605}
606
607fn append_clipped_ranges<T: Clone>(
608    target: &mut Vec<MutableRange<T>>,
609    source: &[RangeStyle<T>],
610    source_range: Range<usize>,
611    target_offset: usize,
612) {
613    for style in source {
614        let intersection_start = style.range.start.max(source_range.start);
615        let intersection_end = style.range.end.min(source_range.end);
616        if intersection_start < intersection_end {
617            target.push(MutableRange {
618                item: style.item.clone(),
619                start: (intersection_start - source_range.start) + target_offset,
620                end: (intersection_end - source_range.start) + target_offset,
621            });
622        }
623    }
624}
625
626impl Builder {
627    pub fn new() -> Self {
628        Self::default()
629    }
630
631    /// Appends the given String to this Builder.
632    pub fn append(mut self, text: &str) -> Self {
633        self.text.push_str(text);
634        self
635    }
636
637    pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
638        self.append_annotated_subsequence(annotated, 0..annotated.text.len())
639    }
640
641    pub fn append_annotated_subsequence(
642        mut self,
643        annotated: &AnnotatedString,
644        range: Range<usize>,
645    ) -> Self {
646        let range = clamp_subsequence_range(annotated.text.as_str(), range);
647        if range.is_empty() {
648            return self;
649        }
650
651        debug_assert!(annotated.text.is_char_boundary(range.start));
652        debug_assert!(annotated.text.is_char_boundary(range.end));
653
654        let target_offset = self.text.len();
655        self.text.push_str(&annotated.text[range.clone()]);
656        append_clipped_ranges(
657            &mut self.span_styles,
658            &annotated.span_styles,
659            range.clone(),
660            target_offset,
661        );
662        append_clipped_ranges(
663            &mut self.paragraph_styles,
664            &annotated.paragraph_styles,
665            range.clone(),
666            target_offset,
667        );
668        append_clipped_ranges(
669            &mut self.string_annotations,
670            &annotated.string_annotations,
671            range.clone(),
672            target_offset,
673        );
674        append_clipped_ranges(
675            &mut self.link_annotations,
676            &annotated.link_annotations,
677            range,
678            target_offset,
679        );
680        self
681    }
682
683    /// Applies the given `SpanStyle` to any appended text until a corresponding `pop` is called.
684    ///
685    /// Returns the index of the pushed style, which can be passed to `pop_to` or used as an ID.
686    pub fn push_style(mut self, style: SpanStyle) -> Self {
687        let index = self.span_styles.len();
688        self.span_styles.push(MutableRange {
689            item: style,
690            start: self.text.len(),
691            end: usize::MAX,
692        });
693        self.style_stack.push(StyleStackRecord {
694            style_type: StyleType::Span,
695            index,
696        });
697        self
698    }
699
700    /// Applies the given `ParagraphStyle` to any appended text until a corresponding `pop` is called.
701    pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
702        let index = self.paragraph_styles.len();
703        self.paragraph_styles.push(MutableRange {
704            item: style,
705            start: self.text.len(),
706            end: usize::MAX,
707        });
708        self.style_stack.push(StyleStackRecord {
709            style_type: StyleType::Paragraph,
710            index,
711        });
712        self
713    }
714
715    /// Pushes a string annotation covering subsequent appended text until the matching `pop`.
716    ///
717    /// JC parity: `Builder.pushStringAnnotation(tag, annotation)`
718    pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
719        let index = self.string_annotations.len();
720        self.string_annotations.push(MutableRange {
721            item: StringAnnotation {
722                tag: tag.to_string(),
723                annotation: annotation.to_string(),
724            },
725            start: self.text.len(),
726            end: usize::MAX,
727        });
728        self.style_stack.push(StyleStackRecord {
729            style_type: StyleType::StringAnnotation,
730            index,
731        });
732        self
733    }
734
735    /// Pushes a [`LinkAnnotation`] covering subsequent appended text.
736    /// Call `pop` when done, or use `with_link` for the block form.
737    ///
738    /// JC parity: `Builder.pushLink(link)`
739    pub fn push_link(mut self, link: LinkAnnotation) -> Self {
740        let index = self.link_annotations.len();
741        self.link_annotations.push(MutableRange {
742            item: link,
743            start: self.text.len(),
744            end: usize::MAX,
745        });
746        self.style_stack.push(StyleStackRecord {
747            style_type: StyleType::LinkAnnotation,
748            index,
749        });
750        self
751    }
752
753    /// Block form of `push_link` — mirrors JC's `withLink(link) { ... }` DSL.
754    ///
755    /// # Example
756    ///
757    /// ```rust,ignore
758    /// builder
759    ///     .append("Visit ")
760    ///     .with_link(
761    ///         LinkAnnotation::Url("https://developer.android.com".into()),
762    ///         |b| b.append("Android Developers"),
763    ///     )
764    ///     .append(".")
765    ///     .to_annotated_string()
766    /// ```
767    pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
768        let b = self.push_link(link);
769        let b = block(b);
770        b.pop()
771    }
772
773    /// Ends the style that was most recently pushed.
774    pub fn pop(mut self) -> Self {
775        if let Some(record) = self.style_stack.pop() {
776            match record.style_type {
777                StyleType::Span => {
778                    self.span_styles[record.index].end = self.text.len();
779                }
780                StyleType::Paragraph => {
781                    self.paragraph_styles[record.index].end = self.text.len();
782                }
783                StyleType::StringAnnotation => {
784                    self.string_annotations[record.index].end = self.text.len();
785                }
786                StyleType::LinkAnnotation => {
787                    self.link_annotations[record.index].end = self.text.len();
788                }
789            }
790        }
791        self
792    }
793
794    /// Completes the builder, resolving open styles to the end of the text.
795    pub fn to_annotated_string(mut self) -> AnnotatedString {
796        while let Some(record) = self.style_stack.pop() {
797            match record.style_type {
798                StyleType::Span => {
799                    self.span_styles[record.index].end = self.text.len();
800                }
801                StyleType::Paragraph => {
802                    self.paragraph_styles[record.index].end = self.text.len();
803                }
804                StyleType::StringAnnotation => {
805                    self.string_annotations[record.index].end = self.text.len();
806                }
807                StyleType::LinkAnnotation => {
808                    self.link_annotations[record.index].end = self.text.len();
809                }
810            }
811        }
812
813        AnnotatedString {
814            text: self.text,
815            span_styles: self
816                .span_styles
817                .into_iter()
818                .map(|s| RangeStyle {
819                    item: s.item,
820                    range: s.start..s.end,
821                })
822                .collect(),
823            paragraph_styles: self
824                .paragraph_styles
825                .into_iter()
826                .map(|s| RangeStyle {
827                    item: s.item,
828                    range: s.start..s.end,
829                })
830                .collect(),
831            string_annotations: self
832                .string_annotations
833                .into_iter()
834                .map(|s| RangeStyle {
835                    item: s.item,
836                    range: s.start..s.end,
837                })
838                .collect(),
839            link_annotations: self
840                .link_annotations
841                .into_iter()
842                .map(|s| RangeStyle {
843                    item: s.item,
844                    range: s.start..s.end,
845                })
846                .collect(),
847        }
848    }
849}
850
851#[cfg(test)]
852#[path = "tests/annotated_string_tests.rs"]
853mod tests;