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 {
351 span_style: span.item.clone(),
352 ..Default::default()
353 };
354 hasher.write_u64(dummy.measurement_hash());
355
356 if let Some(c) = &span.item.color {
358 hasher.write_u32(c.0.to_bits());
359 hasher.write_u32(c.1.to_bits());
360 hasher.write_u32(c.2.to_bits());
361 hasher.write_u32(c.3.to_bits());
362 }
363 if let Some(bg) = &span.item.background {
364 hasher.write_u32(bg.0.to_bits());
365 hasher.write_u32(bg.1.to_bits());
366 hasher.write_u32(bg.2.to_bits());
367 hasher.write_u32(bg.3.to_bits());
368 }
369 if let Some(d) = &span.item.text_decoration {
370 d.hash(&mut hasher);
371 }
372 }
373 hasher.finish()
374 }
375
376 pub fn render_hash(&self) -> u64 {
377 render_hash_impl(&self.text, &self.span_styles, &self.paragraph_styles)
378 }
379
380 pub fn subsequence(&self, range: std::ops::Range<usize>) -> Self {
383 if range.is_empty() {
384 return Self::new(String::new());
385 }
386
387 let start = range.start.min(self.text.len());
388 let end = range.end.max(start).min(self.text.len());
389
390 if start == end {
391 return Self::new(String::new());
392 }
393
394 let mut new_spans = Vec::new();
395 for span in &self.span_styles {
396 let intersection_start = span.range.start.max(start);
397 let intersection_end = span.range.end.min(end);
398 if intersection_start < intersection_end {
399 new_spans.push(RangeStyle {
400 item: span.item.clone(),
401 range: (intersection_start - start)..(intersection_end - start),
402 });
403 }
404 }
405
406 let mut new_paragraphs = Vec::new();
407 for span in &self.paragraph_styles {
408 let intersection_start = span.range.start.max(start);
409 let intersection_end = span.range.end.min(end);
410 if intersection_start < intersection_end {
411 new_paragraphs.push(RangeStyle {
412 item: span.item.clone(),
413 range: (intersection_start - start)..(intersection_end - start),
414 });
415 }
416 }
417
418 let mut new_string_annotations = Vec::new();
419 for ann in &self.string_annotations {
420 let intersection_start = ann.range.start.max(start);
421 let intersection_end = ann.range.end.min(end);
422 if intersection_start < intersection_end {
423 new_string_annotations.push(RangeStyle {
424 item: ann.item.clone(),
425 range: (intersection_start - start)..(intersection_end - start),
426 });
427 }
428 }
429
430 let mut new_link_annotations = Vec::new();
431 for ann in &self.link_annotations {
432 let intersection_start = ann.range.start.max(start);
433 let intersection_end = ann.range.end.min(end);
434 if intersection_start < intersection_end {
435 new_link_annotations.push(RangeStyle {
436 item: ann.item.clone(),
437 range: (intersection_start - start)..(intersection_end - start),
438 });
439 }
440 }
441
442 Self {
443 text: self.text[start..end].to_string(),
444 span_styles: new_spans,
445 paragraph_styles: new_paragraphs,
446 string_annotations: new_string_annotations,
447 link_annotations: new_link_annotations,
448 }
449 }
450
451 pub fn get_string_annotations(
455 &self,
456 tag: &str,
457 start: usize,
458 end: usize,
459 ) -> Vec<&RangeStyle<StringAnnotation>> {
460 self.string_annotations
461 .iter()
462 .filter(|ann| ann.item.tag == tag && ann.range.start < end && ann.range.end > start)
463 .collect()
464 }
465
466 pub fn get_link_annotations(
470 &self,
471 start: usize,
472 end: usize,
473 ) -> Vec<&RangeStyle<LinkAnnotation>> {
474 self.link_annotations
475 .iter()
476 .filter(|ann| ann.range.start < end && ann.range.end > start)
477 .collect()
478 }
479}
480
481impl From<String> for AnnotatedString {
482 fn from(text: String) -> Self {
483 Self::new(text)
484 }
485}
486
487impl From<&str> for AnnotatedString {
488 fn from(text: &str) -> Self {
489 Self::new(text.to_owned())
490 }
491}
492
493impl From<&String> for AnnotatedString {
494 fn from(text: &String) -> Self {
495 Self::new(text.clone())
496 }
497}
498
499impl From<&mut String> for AnnotatedString {
500 fn from(text: &mut String) -> Self {
501 Self::new(text.clone())
502 }
503}
504
505#[derive(Debug, Default, Clone)]
507pub struct Builder {
508 text: String,
509 span_styles: Vec<MutableRange<SpanStyle>>,
510 paragraph_styles: Vec<MutableRange<ParagraphStyle>>,
511 string_annotations: Vec<MutableRange<StringAnnotation>>,
512 link_annotations: Vec<MutableRange<LinkAnnotation>>,
513 style_stack: Vec<StyleStackRecord>,
514}
515
516#[derive(Debug, Clone)]
517struct MutableRange<T> {
518 item: T,
519 start: usize,
520 end: usize,
521}
522
523#[derive(Debug, Clone)]
524struct StyleStackRecord {
525 style_type: StyleType,
526 index: usize,
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530enum StyleType {
531 Span,
532 Paragraph,
533 StringAnnotation,
534 LinkAnnotation,
535}
536
537fn clamp_subsequence_range(text: &str, range: Range<usize>) -> Range<usize> {
538 let start = range.start.min(text.len());
539 let end = range.end.max(start).min(text.len());
540 start..end
541}
542
543fn append_clipped_ranges<T: Clone>(
544 target: &mut Vec<MutableRange<T>>,
545 source: &[RangeStyle<T>],
546 source_range: Range<usize>,
547 target_offset: usize,
548) {
549 for style in source {
550 let intersection_start = style.range.start.max(source_range.start);
551 let intersection_end = style.range.end.min(source_range.end);
552 if intersection_start < intersection_end {
553 target.push(MutableRange {
554 item: style.item.clone(),
555 start: (intersection_start - source_range.start) + target_offset,
556 end: (intersection_end - source_range.start) + target_offset,
557 });
558 }
559 }
560}
561
562impl Builder {
563 pub fn new() -> Self {
564 Self::default()
565 }
566
567 pub fn append(mut self, text: &str) -> Self {
569 self.text.push_str(text);
570 self
571 }
572
573 pub fn append_annotated(self, annotated: &AnnotatedString) -> Self {
574 self.append_annotated_subsequence(annotated, 0..annotated.text.len())
575 }
576
577 pub fn append_annotated_subsequence(
578 mut self,
579 annotated: &AnnotatedString,
580 range: Range<usize>,
581 ) -> Self {
582 let range = clamp_subsequence_range(annotated.text.as_str(), range);
583 if range.is_empty() {
584 return self;
585 }
586
587 debug_assert!(annotated.text.is_char_boundary(range.start));
588 debug_assert!(annotated.text.is_char_boundary(range.end));
589
590 let target_offset = self.text.len();
591 self.text.push_str(&annotated.text[range.clone()]);
592 append_clipped_ranges(
593 &mut self.span_styles,
594 &annotated.span_styles,
595 range.clone(),
596 target_offset,
597 );
598 append_clipped_ranges(
599 &mut self.paragraph_styles,
600 &annotated.paragraph_styles,
601 range.clone(),
602 target_offset,
603 );
604 append_clipped_ranges(
605 &mut self.string_annotations,
606 &annotated.string_annotations,
607 range.clone(),
608 target_offset,
609 );
610 append_clipped_ranges(
611 &mut self.link_annotations,
612 &annotated.link_annotations,
613 range,
614 target_offset,
615 );
616 self
617 }
618
619 pub fn push_style(mut self, style: SpanStyle) -> Self {
623 let index = self.span_styles.len();
624 self.span_styles.push(MutableRange {
625 item: style,
626 start: self.text.len(),
627 end: usize::MAX,
628 });
629 self.style_stack.push(StyleStackRecord {
630 style_type: StyleType::Span,
631 index,
632 });
633 self
634 }
635
636 pub fn push_paragraph_style(mut self, style: ParagraphStyle) -> Self {
638 let index = self.paragraph_styles.len();
639 self.paragraph_styles.push(MutableRange {
640 item: style,
641 start: self.text.len(),
642 end: usize::MAX,
643 });
644 self.style_stack.push(StyleStackRecord {
645 style_type: StyleType::Paragraph,
646 index,
647 });
648 self
649 }
650
651 pub fn push_string_annotation(mut self, tag: &str, annotation: &str) -> Self {
655 let index = self.string_annotations.len();
656 self.string_annotations.push(MutableRange {
657 item: StringAnnotation {
658 tag: tag.to_string(),
659 annotation: annotation.to_string(),
660 },
661 start: self.text.len(),
662 end: usize::MAX,
663 });
664 self.style_stack.push(StyleStackRecord {
665 style_type: StyleType::StringAnnotation,
666 index,
667 });
668 self
669 }
670
671 pub fn push_link(mut self, link: LinkAnnotation) -> Self {
676 let index = self.link_annotations.len();
677 self.link_annotations.push(MutableRange {
678 item: link,
679 start: self.text.len(),
680 end: usize::MAX,
681 });
682 self.style_stack.push(StyleStackRecord {
683 style_type: StyleType::LinkAnnotation,
684 index,
685 });
686 self
687 }
688
689 pub fn with_link(self, link: LinkAnnotation, block: impl FnOnce(Self) -> Self) -> Self {
704 let b = self.push_link(link);
705 let b = block(b);
706 b.pop()
707 }
708
709 pub fn pop(mut self) -> Self {
711 if let Some(record) = self.style_stack.pop() {
712 match record.style_type {
713 StyleType::Span => {
714 self.span_styles[record.index].end = self.text.len();
715 }
716 StyleType::Paragraph => {
717 self.paragraph_styles[record.index].end = self.text.len();
718 }
719 StyleType::StringAnnotation => {
720 self.string_annotations[record.index].end = self.text.len();
721 }
722 StyleType::LinkAnnotation => {
723 self.link_annotations[record.index].end = self.text.len();
724 }
725 }
726 }
727 self
728 }
729
730 pub fn to_annotated_string(mut self) -> AnnotatedString {
732 while let Some(record) = self.style_stack.pop() {
734 match record.style_type {
735 StyleType::Span => {
736 self.span_styles[record.index].end = self.text.len();
737 }
738 StyleType::Paragraph => {
739 self.paragraph_styles[record.index].end = self.text.len();
740 }
741 StyleType::StringAnnotation => {
742 self.string_annotations[record.index].end = self.text.len();
743 }
744 StyleType::LinkAnnotation => {
745 self.link_annotations[record.index].end = self.text.len();
746 }
747 }
748 }
749
750 AnnotatedString {
751 text: self.text,
752 span_styles: self
753 .span_styles
754 .into_iter()
755 .map(|s| RangeStyle {
756 item: s.item,
757 range: s.start..s.end,
758 })
759 .collect(),
760 paragraph_styles: self
761 .paragraph_styles
762 .into_iter()
763 .map(|s| RangeStyle {
764 item: s.item,
765 range: s.start..s.end,
766 })
767 .collect(),
768 string_annotations: self
769 .string_annotations
770 .into_iter()
771 .map(|s| RangeStyle {
772 item: s.item,
773 range: s.start..s.end,
774 })
775 .collect(),
776 link_annotations: self
777 .link_annotations
778 .into_iter()
779 .map(|s| RangeStyle {
780 item: s.item,
781 range: s.start..s.end,
782 })
783 .collect(),
784 }
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 #[test]
793 fn a_redrawn_string_reuses_the_annotated_copy_from_last_frame() {
794 let first = shared_plain_annotated_string("SCORE 340");
795 let second = shared_plain_annotated_string("SCORE 340");
796 assert!(Rc::ptr_eq(&first, &second));
797 assert_eq!(second.text, "SCORE 340");
798 assert!(second.span_styles.is_empty());
799 }
800
801 #[test]
802 fn distinct_strings_never_share_an_annotated_copy() {
803 let first = shared_plain_annotated_string("READY");
804 let second = shared_plain_annotated_string("GO");
805 assert!(!Rc::ptr_eq(&first, &second));
806 assert_eq!(first.text, "READY");
807 assert_eq!(second.text, "GO");
808 }
809
810 #[test]
811 fn the_pool_survives_overflowing_its_capacity() {
812 for index in 0..600 {
813 let text = format!("distinct-{index}");
814 let shared = shared_plain_annotated_string(&text);
815 assert_eq!(shared.text, text);
816 }
817 let after = shared_plain_annotated_string("still correct");
818 assert_eq!(after.text, "still correct");
819 }
820
821 #[test]
822 fn test_builder_span() {
823 let span1 = SpanStyle {
824 alpha: Some(0.5),
825 ..Default::default()
826 };
827
828 let span2 = SpanStyle {
829 alpha: Some(1.0),
830 ..Default::default()
831 };
832
833 let annotated = AnnotatedString::builder()
834 .append("Hello ")
835 .push_style(span1.clone())
836 .append("World")
837 .push_style(span2.clone())
838 .append("!")
839 .pop()
840 .pop()
841 .to_annotated_string();
842
843 assert_eq!(annotated.text, "Hello World!");
844 assert_eq!(annotated.span_styles.len(), 2);
845 assert_eq!(annotated.span_styles[0].range, 6..12);
846 assert_eq!(annotated.span_styles[0].item, span1);
847 assert_eq!(annotated.span_styles[1].range, 11..12);
848 assert_eq!(annotated.span_styles[1].item, span2);
849 }
850
851 #[test]
852 fn with_link_url_roundtrips() {
853 let url = "https://developer.android.com";
854 let annotated = AnnotatedString::builder()
855 .append("Visit ")
856 .with_link(LinkAnnotation::Url(url.into()), |b| {
857 b.append("Android Developers")
858 })
859 .append(".")
860 .to_annotated_string();
861
862 assert_eq!(annotated.text, "Visit Android Developers.");
863 assert_eq!(annotated.link_annotations.len(), 1);
864 let ann = &annotated.link_annotations[0];
865 assert_eq!(ann.range, 6..24);
867 assert_eq!(ann.item, LinkAnnotation::Url(url.into()));
868 }
869
870 #[test]
871 fn with_link_clickable_calls_handler() {
872 use std::cell::Cell;
873 let called = Rc::new(Cell::new(false));
874 let called_clone = Rc::clone(&called);
875
876 let annotated = AnnotatedString::builder()
877 .with_link(
878 LinkAnnotation::Clickable {
879 tag: "action".into(),
880 handler: Rc::new(move || called_clone.set(true)),
881 },
882 |b| b.append("click me"),
883 )
884 .to_annotated_string();
885
886 assert_eq!(annotated.link_annotations.len(), 1);
887 let ann = &annotated.link_annotations[0];
889 if let LinkAnnotation::Clickable { handler, .. } = &ann.item {
890 handler();
891 }
892 assert!(called.get(), "Clickable handler should have been called");
893 }
894
895 #[test]
896 fn with_link_subsequence_trims_range() {
897 let annotated = AnnotatedString::builder()
898 .append("pre ")
899 .with_link(LinkAnnotation::Url("http://x.com".into()), |b| {
900 b.append("link")
901 })
902 .append(" post")
903 .to_annotated_string();
904
905 let sub = annotated.subsequence(4..8); assert_eq!(sub.link_annotations.len(), 1);
908 assert_eq!(sub.link_annotations[0].range, 0..4);
909 }
910
911 #[test]
912 fn append_annotated_preserves_ranges_with_existing_prefix() {
913 let annotated = AnnotatedString::builder()
914 .append("Hello ")
915 .push_style(SpanStyle {
916 alpha: Some(0.5),
917 ..Default::default()
918 })
919 .append("World")
920 .pop()
921 .push_string_annotation("kind", "planet")
922 .append("!")
923 .pop()
924 .to_annotated_string();
925
926 let combined = AnnotatedString::builder()
927 .append("Prefix ")
928 .append_annotated(&annotated)
929 .to_annotated_string();
930
931 assert_eq!(combined.text, "Prefix Hello World!");
932 assert_eq!(combined.span_styles.len(), 1);
933 assert_eq!(combined.span_styles[0].range, 13..18);
934 assert_eq!(combined.string_annotations.len(), 1);
935 assert_eq!(combined.string_annotations[0].range, 18..19);
936 }
937
938 #[test]
939 fn append_annotated_subsequence_clips_ranges_to_slice() {
940 let annotated = AnnotatedString::builder()
941 .append("Before ")
942 .push_style(SpanStyle {
943 alpha: Some(0.5),
944 ..Default::default()
945 })
946 .append("Styled")
947 .pop()
948 .with_link(LinkAnnotation::Url("https://example.com".into()), |b| {
949 b.append(" Link")
950 })
951 .to_annotated_string();
952
953 let slice = AnnotatedString::builder()
954 .append("-> ")
955 .append_annotated_subsequence(&annotated, 7..18)
956 .to_annotated_string();
957
958 assert_eq!(slice.text, "-> Styled Link");
959 assert_eq!(slice.span_styles.len(), 1);
960 assert_eq!(slice.span_styles[0].range, 3..9);
961 assert_eq!(slice.link_annotations.len(), 1);
962 assert_eq!(slice.link_annotations[0].range, 9..14);
963 }
964
965 #[test]
966 fn render_hash_changes_for_visual_style_ranges() {
967 let plain = AnnotatedString::builder()
968 .append("Hello")
969 .to_annotated_string();
970 let styled = AnnotatedString::builder()
971 .push_style(SpanStyle {
972 color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
973 ..Default::default()
974 })
975 .append("Hello")
976 .pop()
977 .to_annotated_string();
978
979 assert_ne!(plain.render_hash(), styled.render_hash());
980 }
981}