1use std::ops::Range;
2use std::rc::Rc;
3
4use crate::{ParagraphStyle, SpanStyle};
5
6#[derive(Clone)]
27pub enum LinkAnnotation {
28 Url(String),
32
33 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#[derive(Debug, Clone, PartialEq)]
72pub struct StringAnnotation {
73 pub tag: String,
74 pub annotation: String,
75}
76
77#[derive(Debug, Clone, PartialEq)]
81pub enum LinkKey {
82 Url(String),
84 Clickable(String),
86}
87
88#[derive(Debug, Clone, PartialEq, Default)]
94pub struct RenderString {
95 pub text: String,
96 pub span_styles: Vec<RangeStyle<SpanStyle>>,
97 pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
98 pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
99 pub links: Vec<RangeStyle<LinkKey>>,
102}
103
104const _: () = {
105 fn assert_send<T: Send + Sync>() {}
106 #[allow(dead_code)]
107 fn assert_render_string_is_send_sync() {
108 assert_send::<RenderString>();
109 }
110};
111
112impl RenderString {
113 pub fn len(&self) -> usize {
114 self.text.len()
115 }
116
117 pub fn is_empty(&self) -> bool {
118 self.text.is_empty()
119 }
120
121 pub fn span_boundaries(&self) -> Vec<usize> {
125 span_boundaries_impl(&self.text, &self.span_styles)
126 }
127
128 pub fn render_hash(&self) -> u64 {
132 render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
133 }
134
135 pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
139 if range.is_empty() {
140 return Self {
141 text: String::new(),
142 ..Default::default()
143 };
144 }
145
146 let start = range.start.min(self.text.len());
147 let end = range.end.max(start).min(self.text.len());
148
149 if start == end {
150 return Self {
151 text: String::new(),
152 ..Default::default()
153 };
154 }
155
156 Self {
157 text: self.text[start..end].to_string(),
158 span_styles: clip_range_styles(&self.span_styles, start, end),
159 paragraph_styles: clip_range_styles(&self.paragraph_styles, start, end),
160 string_annotations: clip_range_styles(&self.string_annotations, start, end),
161 links: clip_range_styles(&self.links, start, end),
162 }
163 }
164}
165
166fn clip_range_styles<T: Clone>(
167 styles: &[RangeStyle<T>],
168 start: usize,
169 end: usize,
170) -> Vec<RangeStyle<T>> {
171 let mut clipped = Vec::new();
172 for style in styles {
173 let intersection_start = style.range.start.max(start);
174 let intersection_end = style.range.end.min(end);
175 if intersection_start < intersection_end {
176 clipped.push(RangeStyle {
177 item: style.item.clone(),
178 range: (intersection_start - start)..(intersection_end - start),
179 });
180 }
181 }
182 clipped
183}
184
185fn span_boundaries_impl(text: &str, span_styles: &[RangeStyle<SpanStyle>]) -> Vec<usize> {
186 let mut boundaries = vec![0, text.len()];
187 for span in span_styles {
188 boundaries.push(span.range.start);
189 boundaries.push(span.range.end);
190 }
191 boundaries.sort_unstable();
192 boundaries.dedup();
193 boundaries
194 .into_iter()
195 .filter(|&b| b <= text.len() && text.is_char_boundary(b))
196 .collect()
197}
198
199fn render_hash_impl(
200 text: &str,
201 span_styles: &[RangeStyle<SpanStyle>],
202 paragraph_styles: &[RangeStyle<ParagraphStyle>],
203) -> u64 {
204 use std::hash::{Hash, Hasher};
205
206 let mut hasher = cranpose_ui_graphics::FxHasher::default();
207 text.hash(&mut hasher);
208 span_styles.len().hash(&mut hasher);
209 for span in span_styles {
210 span.range.start.hash(&mut hasher);
211 span.range.end.hash(&mut hasher);
212 span.item.render_hash().hash(&mut hasher);
213 }
214 paragraph_styles.len().hash(&mut hasher);
215 for paragraph in paragraph_styles {
216 paragraph.range.start.hash(&mut hasher);
217 paragraph.range.end.hash(&mut hasher);
218 paragraph.item.render_hash().hash(&mut hasher);
219 }
220 hasher.finish()
221}
222
223#[derive(Debug, Clone, PartialEq, Default)]
227pub struct AnnotatedString {
228 pub text: String,
229 pub span_styles: Vec<RangeStyle<SpanStyle>>,
230 pub paragraph_styles: Vec<RangeStyle<ParagraphStyle>>,
231 pub string_annotations: Vec<RangeStyle<StringAnnotation>>,
234 pub link_annotations: Vec<RangeStyle<LinkAnnotation>>,
237}
238
239#[derive(Debug, Clone, PartialEq)]
241pub struct RangeStyle<T> {
242 pub item: T,
243 pub range: Range<usize>,
244}
245
246pub fn shared_plain_annotated_string(text: &str) -> Rc<AnnotatedString> {
257 use std::cell::RefCell;
258 use std::collections::HashMap;
259 use std::hash::{Hash, Hasher};
260
261 const POOL_CAPACITY: usize = 256;
262 thread_local! {
263 static POOL: RefCell<HashMap<u64, Rc<AnnotatedString>>> =
264 RefCell::new(HashMap::new());
265 }
266
267 let mut hasher = cranpose_ui_graphics::FxHasher::default();
268 text.hash(&mut hasher);
269 let key = hasher.finish();
270
271 POOL.with(|pool| {
272 let mut pool = pool.borrow_mut();
273 if let Some(shared) = pool.get(&key) {
274 if shared.text == text {
275 return Rc::clone(shared);
276 }
277 }
278 let shared = Rc::new(AnnotatedString::new(text.to_owned()));
279 if pool.len() >= POOL_CAPACITY {
280 pool.clear();
281 }
282 pool.insert(key, Rc::clone(&shared));
283 shared
284 })
285}
286
287impl AnnotatedString {
288 pub fn new(text: String) -> Self {
289 Self {
290 text,
291 span_styles: vec![],
292 paragraph_styles: vec![],
293 string_annotations: vec![],
294 link_annotations: vec![],
295 }
296 }
297
298 pub fn builder() -> Builder {
299 Builder::new()
300 }
301
302 pub fn len(&self) -> usize {
303 self.text.len()
304 }
305
306 pub fn is_empty(&self) -> bool {
307 self.text.is_empty()
308 }
309
310 pub fn span_boundaries(&self) -> Vec<usize> {
312 span_boundaries_impl(&self.text, &self.span_styles)
313 }
314
315 pub fn render_string(&self) -> RenderString {
320 RenderString {
321 text: self.text.clone(),
322 span_styles: self.span_styles.clone(),
323 paragraph_styles: self.paragraph_styles.clone(),
324 string_annotations: self.string_annotations.clone(),
325 links: self
326 .link_annotations
327 .iter()
328 .map(|link| RangeStyle {
329 item: match &link.item {
330 LinkAnnotation::Url(url) => LinkKey::Url(url.clone()),
331 LinkAnnotation::Clickable { tag, .. } => LinkKey::Clickable(tag.clone()),
332 },
333 range: link.range.clone(),
334 })
335 .collect(),
336 }
337 }
338
339 pub fn span_styles_hash(&self) -> u64 {
341 use std::hash::{Hash, Hasher};
342 let mut hasher = cranpose_ui_graphics::FxHasher::default();
343 hasher.write_usize(self.span_styles.len());
344 for span in &self.span_styles {
345 hasher.write_usize(span.range.start);
346 hasher.write_usize(span.range.end);
347
348 let dummy = crate::text::TextStyle {
350 span_style: span.item.clone(),
351 ..Default::default()
352 };
353 hasher.write_u64(dummy.measurement_hash());
354
355 if let Some(c) = &span.item.color {
357 hasher.write_u32(c.0.to_bits());
358 hasher.write_u32(c.1.to_bits());
359 hasher.write_u32(c.2.to_bits());
360 hasher.write_u32(c.3.to_bits());
361 }
362 if let Some(bg) = &span.item.background {
363 hasher.write_u32(bg.0.to_bits());
364 hasher.write_u32(bg.1.to_bits());
365 hasher.write_u32(bg.2.to_bits());
366 hasher.write_u32(bg.3.to_bits());
367 }
368 if let Some(d) = &span.item.text_decoration {
369 d.hash(&mut hasher);
370 }
371 }
372 hasher.finish()
373 }
374
375 pub fn render_hash(&self) -> u64 {
376 render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
377 }
378
379 pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
382 if range.is_empty() {
383 return Self::new(String::new());
384 }
385
386 let start = range.start.min(self.text.len());
387 let end = range.end.max(start).min(self.text.len());
388
389 if start == end {
390 return Self::new(String::new());
391 }
392
393 let mut new_spans = Vec::new();
394 for span in &self.span_styles {
395 let intersection_start = span.range.start.max(start);
396 let intersection_end = span.range.end.min(end);
397 if intersection_start < intersection_end {
398 new_spans.push(RangeStyle {
399 item: span.item.clone(),
400 range: (intersection_start - start)..(intersection_end - start),
401 });
402 }
403 }
404
405 let mut new_paragraphs = Vec::new();
406 for span in &self.paragraph_styles {
407 let intersection_start = span.range.start.max(start);
408 let intersection_end = span.range.end.min(end);
409 if intersection_start < intersection_end {
410 new_paragraphs.push(RangeStyle {
411 item: span.item.clone(),
412 range: (intersection_start - start)..(intersection_end - start),
413 });
414 }
415 }
416
417 let mut new_string_annotations = Vec::new();
418 for ann in &self.string_annotations {
419 let intersection_start = ann.range.start.max(start);
420 let intersection_end = ann.range.end.min(end);
421 if intersection_start < intersection_end {
422 new_string_annotations.push(RangeStyle {
423 item: ann.item.clone(),
424 range: (intersection_start - start)..(intersection_end - start),
425 });
426 }
427 }
428
429 let mut new_link_annotations = Vec::new();
430 for ann in &self.link_annotations {
431 let intersection_start = ann.range.start.max(start);
432 let intersection_end = ann.range.end.min(end);
433 if intersection_start < intersection_end {
434 new_link_annotations.push(RangeStyle {
435 item: ann.item.clone(),
436 range: (intersection_start - start)..(intersection_end - start),
437 });
438 }
439 }
440
441 Self {
442 text: self.text[start..end].to_string(),
443 span_styles: new_spans,
444 paragraph_styles: new_paragraphs,
445 string_annotations: new_string_annotations,
446 link_annotations: new_link_annotations,
447 }
448 }
449
450 pub fn get_string_annotations(
454 &self,
455 tag: &str,
456 start: usize,
457 end: usize,
458 ) -> Vec<&RangeStyle<StringAnnotation>> {
459 self.string_annotations
460 .iter()
461 .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
462 .collect()
463 }
464
465 pub fn get_link_annotations(
469 &self,
470 start: usize,
471 end: usize,
472 ) -> Vec<&RangeStyle<LinkAnnotation>> {
473 self.link_annotations
474 .iter()
475 .filter(|ann| ann.range.start < end && ann.range.end > start)
476 .collect()
477 }
478}
479
480impl From<String> for AnnotatedString {
481 fn from(text: String) -> Self {
482 Self::new(text)
483 }
484}
485
486impl From<&str> for AnnotatedString {
487 fn from(text: &str) -> Self {
488 Self::new(text.to_owned())
489 }
490}
491
492impl From<&String> for AnnotatedString {
493 fn from(text: &String) -> Self {
494 Self::new(text.clone())
495 }
496}
497
498impl From<&mut String> for AnnotatedString {
499 fn from(text: &mut String) -> Self {
500 Self::new(text.clone())
501 }
502}
503
504#[derive(Debug, Default, Clone)]
506pub struct Builder {
507 text: String,
508 span_styles: Vec<MutableRange<SpanStyle>>,
509 paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
510 string_annotations: Vec<MutableRange<StringAnnotation>>,
511 link_annotations: Vec<MutableRange<LinkAnnotation>>,
512 style_stack: Vec<StyleStackRecord>,
513}
514
515#[derive(Debug, Clone)]
516struct MutableRange<T> {
517 item: T,
518 start: usize,
519 end: usize,
520}
521
522#[derive(Debug, Clone)]
523struct StyleStackRecord {
524 style_type: StyleType,
525 index: usize,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529enum StyleType {
530 Span,
531 Paragraph,
532 StringAnnotation,
533 LinkAnnotation,
534}
535
536fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
537 let start = range.start.min(text.len());
538 let end = range.end.max(start).min(text.len());
539 start..end
540}
541
542fn append_clipped_ranges<T: Clone>(
543 target: &mut Vec<MutableRange<T>>,
544 source: &[RangeStyle<T>],
545 source_range: Range<usize>,
546 target_offset: usize,
547) {
548 for style in source {
549 let intersection_start = style.range.start.max(source_range.start);
550 let intersection_end = style.range.end.min(source_range.end);
551 if intersection_start < intersection_end {
552 target.push(MutableRange {
553 item: style.item.clone(),
554 start: (intersection_start - source_range.start) + target_offset,
555 end: (intersection_end - source_range.start) + target_offset,
556 });
557 }
558 }
559}
560
561impl Builder {
562 pub fn new() -> Self {
563 Self::default()
564 }
565
566 pub fn append(mut self, text: &str) -> Self {
568 self.text.push_str(text);
569 self
570 }
571
572 pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
573 self.append_annotated_subsequence(annotated, 0..annotated.text.len())
574 }
575
576 pub fn append_annotated_subsequence(
577 mut self,
578 annotated: &AnnotatedString,
579 range: Range<usize>,
580 ) -> Self {
581 let range = clamp_subsequence_range(annotated.text.as_str(), range);
582 if range.is_empty() {
583 return self;
584 }
585
586 debug_assert!(annotated.text.is_char_boundary(range.start));
587 debug_assert!(annotated.text.is_char_boundary(range.end));
588
589 let target_offset = self.text.len();
590 self.text.push_str(&annotated.text[range.clone()]);
591 append_clipped_ranges(
592 &mut self.span_styles,
593 &annotated.span_styles,
594 range.clone(),
595 target_offset,
596 );
597 append_clipped_ranges(
598 &mut self.paragraph_styles,
599 &annotated.paragraph_styles,
600 range.clone(),
601 target_offset,
602 );
603 append_clipped_ranges(
604 &mut self.string_annotations,
605 &annotated.string_annotations,
606 range.clone(),
607 target_offset,
608 );
609 append_clipped_ranges(
610 &mut self.link_annotations,
611 &annotated.link_annotations,
612 range,
613 target_offset,
614 );
615 self
616 }
617
618 pub fn push_style(mut self, style: SpanStyle) -> Self {
622 let index = self.span_styles.len();
623 self.span_styles.push(MutableRange {
624 item: style,
625 start: self.text.len(),
626 end: usize::MAX,
627 });
628 self.style_stack.push(StyleStackRecord {
629 style_type: StyleType::Span,
630 index,
631 });
632 self
633 }
634
635 pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
637 let index = self.paragraph_styles.len();
638 self.paragraph_styles.push(MutableRange {
639 item: style,
640 start: self.text.len(),
641 end: usize::MAX,
642 });
643 self.style_stack.push(StyleStackRecord {
644 style_type: StyleType::Paragraph,
645 index,
646 });
647 self
648 }
649
650 pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
654 let index = self.string_annotations.len();
655 self.string_annotations.push(MutableRange {
656 item: StringAnnotation {
657 tag: tag.to_string(),
658 annotation: annotation.to_string(),
659 },
660 start: self.text.len(),
661 end: usize::MAX,
662 });
663 self.style_stack.push(StyleStackRecord {
664 style_type: StyleType::StringAnnotation,
665 index,
666 });
667 self
668 }
669
670 pub fn push_link(mut self, link: LinkAnnotation) -> Self {
675 let index = self.link_annotations.len();
676 self.link_annotations.push(MutableRange {
677 item: link,
678 start: self.text.len(),
679 end: usize::MAX,
680 });
681 self.style_stack.push(StyleStackRecord {
682 style_type: StyleType::LinkAnnotation,
683 index,
684 });
685 self
686 }
687
688 pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
703 let b = self.push_link(link);
704 let b = block(b);
705 b.pop()
706 }
707
708 pub fn pop(mut self) -> Self {
710 if let Some(record) = self.style_stack.pop() {
711 match record.style_type {
712 StyleType::Span => {
713 self.span_styles[record.index].end = self.text.len();
714 }
715 StyleType::Paragraph => {
716 self.paragraph_styles[record.index].end = self.text.len();
717 }
718 StyleType::StringAnnotation => {
719 self.string_annotations[record.index].end = self.text.len();
720 }
721 StyleType::LinkAnnotation => {
722 self.link_annotations[record.index].end = self.text.len();
723 }
724 }
725 }
726 self
727 }
728
729 pub fn to_annotated_string(mut self) -> AnnotatedString {
731 while let Some(record) = self.style_stack.pop() {
733 match record.style_type {
734 StyleType::Span => {
735 self.span_styles[record.index].end = self.text.len();
736 }
737 StyleType::Paragraph => {
738 self.paragraph_styles[record.index].end = self.text.len();
739 }
740 StyleType::StringAnnotation => {
741 self.string_annotations[record.index].end = self.text.len();
742 }
743 StyleType::LinkAnnotation => {
744 self.link_annotations[record.index].end = self.text.len();
745 }
746 }
747 }
748
749 AnnotatedString {
750 text: self.text,
751 span_styles: self
752 .span_styles
753 .into_iter()
754 .map(|s| RangeStyle {
755 item: s.item,
756 range: s.start..s.end,
757 })
758 .collect(),
759 paragraph_styles: self
760 .paragraph_styles
761 .into_iter()
762 .map(|s| RangeStyle {
763 item: s.item,
764 range: s.start..s.end,
765 })
766 .collect(),
767 string_annotations: self
768 .string_annotations
769 .into_iter()
770 .map(|s| RangeStyle {
771 item: s.item,
772 range: s.start..s.end,
773 })
774 .collect(),
775 link_annotations: self
776 .link_annotations
777 .into_iter()
778 .map(|s| RangeStyle {
779 item: s.item,
780 range: s.start..s.end,
781 })
782 .collect(),
783 }
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
792 fn a_redrawn_string_reuses_the_annotated_copy_from_last_frame() {
793 let first = shared_plain_annotated_string("SCORE 340");
794 let second = shared_plain_annotated_string("SCORE 340");
795 assert!(Rc::ptr_eq(&first, &second));
796 assert_eq!(second.text, "SCORE 340");
797 assert!(second.span_styles.is_empty());
798 }
799
800 #[test]
801 fn distinct_strings_never_share_an_annotated_copy() {
802 let first = shared_plain_annotated_string("READY");
803 let second = shared_plain_annotated_string("GO");
804 assert!(!Rc::ptr_eq(&first, &second));
805 assert_eq!(first.text, "READY");
806 assert_eq!(second.text, "GO");
807 }
808
809 #[test]
810 fn the_pool_survives_overflowing_its_capacity() {
811 for index in 0..600 {
812 let text = format!("distinct-{index}");
813 let shared = shared_plain_annotated_string(&text);
814 assert_eq!(shared.text, text);
815 }
816 let after = shared_plain_annotated_string("still correct");
817 assert_eq!(after.text, "still correct");
818 }
819
820 #[test]
821 fn test_builder_span() {
822 let span1 = SpanStyle {
823 alpha: Some(0.5),
824 ..Default::default()
825 };
826
827 let span2 = SpanStyle {
828 alpha: Some(1.0),
829 ..Default::default()
830 };
831
832 let annotated = AnnotatedString::builder()
833 .append("Hello ")
834 .push_style(span1.clone())
835 .append("World")
836 .push_style(span2.clone())
837 .append("!")
838 .pop()
839 .pop()
840 .to_annotated_string();
841
842 assert_eq!(annotated.text, "Hello World!");
843 assert_eq!(annotated.span_styles.len(), 2);
844 assert_eq!(annotated.span_styles[0].range, 6..12);
845 assert_eq!(annotated.span_styles[0].item, span1);
846 assert_eq!(annotated.span_styles[1].range, 11..12);
847 assert_eq!(annotated.span_styles[1].item, span2);
848 }
849
850 #[test]
851 fn with_link_url_roundtrips() {
852 let url = "https://developer.android.com";
853 let annotated = AnnotatedString::builder()
854 .append("Visit ")
855 .with_link(LinkAnnotation::Url(url.into()), |b| {
856 b.append("Android Developers")
857 })
858 .append(".")
859 .to_annotated_string();
860
861 assert_eq!(annotated.text, "Visit Android Developers.");
862 assert_eq!(annotated.link_annotations.len(), 1);
863 let ann = &annotated.link_annotations[0];
864 assert_eq!(ann.range, 6..24);
866 assert_eq!(ann.item, LinkAnnotation::Url(url.into()));
867 }
868
869 #[test]
870 fn with_link_clickable_calls_handler() {
871 use std::cell::Cell;
872 let called = Rc::new(Cell::new(false));
873 let called_clone = Rc::clone(&called);
874
875 let annotated = AnnotatedString::builder()
876 .with_link(
877 LinkAnnotation::Clickable {
878 tag: "action".into(),
879 handler: Rc::new(move || called_clone.set(true)),
880 },
881 |b| b.append("click me"),
882 )
883 .to_annotated_string();
884
885 assert_eq!(annotated.link_annotations.len(), 1);
886 let ann = &annotated.link_annotations[0];
888 if let LinkAnnotation::Clickable { handler, .. } = &ann.item {
889 handler();
890 }
891 assert!(called.get(), "Clickable handler should have been called");
892 }
893
894 #[test]
895 fn with_link_subsequence_trims_range() {
896 let annotated = AnnotatedString::builder()
897 .append("pre ")
898 .with_link(LinkAnnotation::Url("http://x.com".into()), |b| {
899 b.append("link")
900 })
901 .append(" post")
902 .to_annotated_string();
903
904 let sub = annotated.subsequence(4..8); assert_eq!(sub.link_annotations.len(), 1);
907 assert_eq!(sub.link_annotations[0].range, 0..4);
908 }
909
910 #[test]
911 fn append_annotated_preserves_ranges_with_existing_prefix() {
912 let annotated = AnnotatedString::builder()
913 .append("Hello ")
914 .push_style(SpanStyle {
915 alpha: Some(0.5),
916 ..Default::default()
917 })
918 .append("World")
919 .pop()
920 .push_string_annotation("kind", "planet")
921 .append("!")
922 .pop()
923 .to_annotated_string();
924
925 let combined = AnnotatedString::builder()
926 .append("Prefix ")
927 .append_annotated(&annotated)
928 .to_annotated_string();
929
930 assert_eq!(combined.text, "Prefix Hello World!");
931 assert_eq!(combined.span_styles.len(), 1);
932 assert_eq!(combined.span_styles[0].range, 13..18);
933 assert_eq!(combined.string_annotations.len(), 1);
934 assert_eq!(combined.string_annotations[0].range, 18..19);
935 }
936
937 #[test]
938 fn append_annotated_subsequence_clips_ranges_to_slice() {
939 let annotated = AnnotatedString::builder()
940 .append("Before ")
941 .push_style(SpanStyle {
942 alpha: Some(0.5),
943 ..Default::default()
944 })
945 .append("Styled")
946 .pop()
947 .with_link(LinkAnnotation::Url("https://example.com".into()), |b| {
948 b.append(" Link")
949 })
950 .to_annotated_string();
951
952 let slice = AnnotatedString::builder()
953 .append("-> ")
954 .append_annotated_subsequence(&annotated, 7..18)
955 .to_annotated_string();
956
957 assert_eq!(slice.text, "-> Styled Link");
958 assert_eq!(slice.span_styles.len(), 1);
959 assert_eq!(slice.span_styles[0].range, 3..9);
960 assert_eq!(slice.link_annotations.len(), 1);
961 assert_eq!(slice.link_annotations[0].range, 9..14);
962 }
963
964 #[test]
965 fn render_hash_changes_for_visual_style_ranges() {
966 let plain = AnnotatedString::builder()
967 .append("Hello")
968 .to_annotated_string();
969 let styled = AnnotatedString::builder()
970 .push_style(SpanStyle {
971 color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
972 ..Default::default()
973 })
974 .append("Hello")
975 .pop()
976 .to_annotated_string();
977
978 assert_ne!(plain.render_hash(), styled.render_hash());
979 }
980}