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