1use std::ops::Range;
2
3use cosmic_text::CacheKey;
4use sequoia_buffer::{SpanAttr, StyledSpan};
5use sequoia_core::{Color, Rect, Size};
6
7use crate::{FontStyle, FontWeight};
8
9pub(crate) const RICH_CACHE_MAX: usize = 128;
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12struct SpanKey {
13 text: String,
14 color: Option<[u32; 4]>,
15 background: Option<[u32; 4]>,
16 weight: u16,
17 style: u8,
18 family: String,
19 decorations: u8,
20 size_delta_q: i32,
21 link: Option<String>,
22 code: bool,
23 mention: Option<u64>,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, Hash)]
27pub(crate) struct RichKey {
28 spans: Vec<SpanKey>,
29 size: u32,
30 width: u32,
31 wrap: u8,
32 spacing: i32,
33 align: u8,
34}
35
36impl RichKey {
37 pub(crate) fn new(
38 spans: &[TextSpan],
39 size: f32,
40 width: f32,
41 wrap: u8,
42 spacing: f32,
43 align: RichAlign,
44 ) -> Self {
45 fn bits(color: &Color) -> [u32; 4] {
46 [
47 color.r.to_bits(),
48 color.g.to_bits(),
49 color.b.to_bits(),
50 color.a.to_bits(),
51 ]
52 }
53 Self {
54 spans: spans
55 .iter()
56 .map(|s| SpanKey {
57 text: s.text.clone(),
58 color: s.color.as_ref().map(bits),
59 background: s.background.as_ref().map(bits),
60 weight: s.weight.unwrap_or_default().value(),
61 style: s.style.unwrap_or_default().key(),
62 family: s.family.clone().unwrap_or_default(),
63 decorations: (u8::from(s.underline))
64 | (u8::from(s.strikethrough) << 1)
65 | (u8::from(s.code) << 2),
66 size_delta_q: (s.size_delta * 4.0).round() as i32,
67 link: s.link.clone(),
68 code: s.code,
69 mention: s.mention,
70 })
71 .collect(),
72 size: (size * 4.0).round() as u32,
73 width: (width.max(0.0) * 4.0).round() as u32,
74 wrap,
75 spacing: crate::spacing_key(spacing),
76 align: align.key(),
77 }
78 }
79}
80
81#[derive(Clone, Debug, Default, PartialEq)]
82pub struct TextSpan {
83 pub text: String,
84 pub color: Option<Color>,
85 pub background: Option<Color>,
86 pub weight: Option<FontWeight>,
87 pub style: Option<FontStyle>,
88 pub family: Option<String>,
89 pub underline: bool,
90 pub strikethrough: bool,
91 pub size_delta: f32,
92 pub link: Option<String>,
93 pub code: bool,
94 pub mention: Option<u64>,
95}
96
97impl TextSpan {
98 pub fn new(text: impl Into<String>) -> Self {
99 Self {
100 text: text.into(),
101 ..Self::default()
102 }
103 }
104
105 pub fn color(mut self, color: Color) -> Self {
106 self.color = Some(color);
107 self
108 }
109
110 pub fn background(mut self, color: Color) -> Self {
111 self.background = Some(color);
112 self
113 }
114
115 pub fn weight(mut self, weight: FontWeight) -> Self {
116 self.weight = Some(weight);
117 self
118 }
119
120 pub fn bold(mut self) -> Self {
121 self.weight = Some(FontWeight::BOLD);
122 self
123 }
124
125 pub fn semibold(mut self) -> Self {
126 self.weight = Some(FontWeight::SEMIBOLD);
127 self
128 }
129
130 pub fn medium(mut self) -> Self {
131 self.weight = Some(FontWeight::MEDIUM);
132 self
133 }
134
135 pub fn italic(mut self) -> Self {
136 self.style = Some(FontStyle::Italic);
137 self
138 }
139
140 pub fn oblique(mut self) -> Self {
141 self.style = Some(FontStyle::Oblique);
142 self
143 }
144
145 pub fn font_style(mut self, style: FontStyle) -> Self {
146 self.style = Some(style);
147 self
148 }
149
150 pub fn font(mut self, family: impl Into<String>) -> Self {
151 let family = family.into();
152 self.family = if family.is_empty() {
153 None
154 } else {
155 Some(family)
156 };
157 self
158 }
159
160 pub fn family(self, family: impl Into<String>) -> Self {
161 self.font(family)
162 }
163
164 pub fn underline(mut self, on: bool) -> Self {
165 self.underline = on;
166 self
167 }
168
169 pub fn strikethrough(mut self, on: bool) -> Self {
170 self.strikethrough = on;
171 self
172 }
173
174 pub fn size_delta(mut self, delta: f32) -> Self {
175 self.size_delta = if delta.is_finite() { delta } else { 0.0 };
176 self
177 }
178
179 pub fn link(mut self, url: impl Into<String>) -> Self {
180 let url = url.into();
181 self.link = if url.is_empty() { None } else { Some(url) };
182 self
183 }
184
185 pub fn mention(mut self, id: u64) -> Self {
186 self.mention = Some(id);
187 self
188 }
189
190 pub fn code(mut self, on: bool) -> Self {
191 self.code = on;
192 self
193 }
194
195 pub(crate) fn from_span_attr(text: String, attr: &SpanAttr) -> Self {
196 Self {
197 text,
198 color: attr.color,
199 background: attr.bg,
200 weight: if attr.bold {
201 Some(FontWeight::BOLD)
202 } else {
203 None
204 },
205 style: if attr.italic {
206 Some(FontStyle::Italic)
207 } else {
208 None
209 },
210 family: None,
211 underline: attr.underline,
212 strikethrough: attr.strike,
213 size_delta: if attr.size_delta.is_finite() {
214 attr.size_delta
215 } else {
216 0.0
217 },
218 link: attr.link.clone(),
219 code: attr.code,
220 mention: attr.mention,
221 }
222 }
223}
224
225fn floor_char_boundary(s: &str, mut idx: usize) -> usize {
226 idx = idx.min(s.len());
227 while idx > 0 && !s.is_char_boundary(idx) {
228 idx -= 1;
229 }
230 idx
231}
232
233pub fn styled_spans_to_text_spans(
234 line_text: &str,
235 line_byte_start: usize,
236 styled: &[StyledSpan],
237) -> Vec<TextSpan> {
238 if line_text.is_empty() {
239 return vec![TextSpan::new("")];
240 }
241 let line_len = line_text.len();
242 let mut sorted: Vec<&StyledSpan> = styled.iter().filter(|s| !s.is_empty()).collect();
243 sorted.sort_by_key(|s| (s.start, s.end));
244 let mut out: Vec<TextSpan> = Vec::new();
245 let mut cursor_rel = 0usize;
246 for s in sorted {
247 if s.end <= line_byte_start {
248 continue;
249 }
250 let mut rs = s.start.saturating_sub(line_byte_start).min(line_len);
251 let mut re = s.end.saturating_sub(line_byte_start).min(line_len);
252 rs = floor_char_boundary(line_text, rs);
253 re = floor_char_boundary(line_text, re).max(rs);
254 if re <= rs {
255 continue;
256 }
257
258 if rs < cursor_rel {
259 rs = cursor_rel;
260 if re <= rs {
261 continue;
262 }
263 }
264 if rs > cursor_rel {
265 let gap = floor_char_boundary(line_text, rs).max(cursor_rel);
266 if gap > cursor_rel
267 && let Some(g) = line_text.get(cursor_rel..gap)
268 {
269 out.push(TextSpan::new(g));
270 }
271 cursor_rel = gap;
272 if rs > cursor_rel {
273 rs = cursor_rel;
274 }
275 }
276 if rs < cursor_rel {
277 continue;
278 }
279 match line_text.get(rs..re) {
280 Some(slice) => {
281 out.push(TextSpan::from_span_attr(slice.to_owned(), &s.attr));
282 cursor_rel = re;
283 }
284 None => {
285 let mut end = re;
286 while end > rs && line_text.get(rs..end).is_none() {
287 end -= 1;
288 }
289 let end = floor_char_boundary(line_text, end).max(rs);
290 if end > rs
291 && let Some(slice) = line_text.get(rs..end)
292 {
293 out.push(TextSpan::from_span_attr(slice.to_owned(), &s.attr));
294 cursor_rel = end;
295 }
296 }
297 }
298 }
299 if cursor_rel < line_len
300 && let Some(g) = line_text.get(cursor_rel..)
301 {
302 out.push(TextSpan::new(g));
303 }
304 if out.is_empty() {
305 out.push(TextSpan::new(line_text));
306 }
307 out
308}
309
310#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
311#[non_exhaustive]
312pub enum RichAlign {
313 #[default]
314 Left,
315 Center,
316 Right,
317}
318
319impl RichAlign {
320 pub(crate) fn key(self) -> u8 {
321 match self {
322 Self::Left => 0,
323 Self::Center => 1,
324 Self::Right => 2,
325 }
326 }
327}
328
329#[derive(Clone, Debug, PartialEq)]
330pub struct RichLine {
331 pub index: usize,
332
333 pub top: f32,
334
335 pub height: f32,
336
337 pub baseline: f32,
338
339 pub width: f32,
340
341 pub chars: Range<usize>,
342}
343
344impl RichLine {
345 pub fn bottom(&self) -> f32 {
346 self.top + self.height
347 }
348
349 pub fn contains_y(&self, y: f32) -> bool {
350 y >= self.top && y < self.bottom()
351 }
352}
353
354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
355pub struct RichHit {
356 pub char_index: usize,
357
358 pub span: Option<usize>,
359
360 pub line: usize,
361}
362
363impl RichHit {
364 pub fn as_tuple(&self) -> (usize, Option<usize>) {
365 (self.char_index, self.span)
366 }
367}
368
369#[derive(Clone, Copy, Debug, PartialEq)]
370pub struct RichCaret {
371 pub x: f32,
372
373 pub line: usize,
374
375 pub top: f32,
376
377 pub height: f32,
378}
379
380#[derive(Clone, Debug)]
381pub(crate) struct RichGlyph {
382 pub x: f32,
383 pub y: f32,
384 pub key: CacheKey,
385 pub span: usize,
386 pub color: Option<Color>,
387}
388
389#[derive(Clone, Copy, Debug, PartialEq)]
390pub struct RichRect {
391 pub rect: Rect,
392 pub color: Color,
393}
394
395#[derive(Clone, Debug, PartialEq)]
396pub struct Squiggle {
397 pub range: Range<usize>,
398 pub color: Color,
399}
400
401#[derive(Clone, Debug)]
402pub struct RichParagraph {
403 pub(crate) glyphs: Vec<RichGlyph>,
404 pub(crate) backgrounds: Vec<RichRect>,
405 pub(crate) decorations: Vec<RichRect>,
406 pub(crate) lines: Vec<RichLine>,
407
408 pub(crate) carets: Vec<Vec<f32>>,
409 pub(crate) span_ranges: Vec<Range<usize>>,
410 pub(crate) span_links: Vec<Option<String>>,
411 pub(crate) span_mentions: Vec<Option<u64>>,
412 pub(crate) span_codes: Vec<bool>,
413
414 pub(crate) squiggles: Vec<Squiggle>,
415 pub(crate) squiggle_rects: Vec<RichRect>,
416 pub(crate) width: f32,
417 pub(crate) height: f32,
418 pub(crate) line_height: f32,
419 pub(crate) font_size: f32,
420 pub(crate) len_chars: usize,
421}
422
423impl RichParagraph {
424 pub fn is_empty(&self) -> bool {
425 self.len_chars == 0
426 }
427
428 pub fn len_chars(&self) -> usize {
429 self.len_chars
430 }
431
432 pub fn size(&self) -> Size {
433 Size::new(self.width, self.height)
434 }
435
436 pub fn width(&self) -> f32 {
437 self.width
438 }
439
440 pub fn height(&self) -> f32 {
441 self.height
442 }
443
444 pub fn line_height(&self) -> f32 {
445 self.line_height
446 }
447
448 pub fn font_size(&self) -> f32 {
449 self.font_size
450 }
451
452 pub fn line_count(&self) -> usize {
453 self.lines.len()
454 }
455
456 pub fn line(&self, index: usize) -> Option<&RichLine> {
457 self.lines.get(index)
458 }
459
460 pub fn lines(&self) -> &[RichLine] {
461 &self.lines
462 }
463
464 pub fn span_count(&self) -> usize {
465 self.span_ranges.len()
466 }
467
468 pub fn span_range(&self, span: usize) -> Option<Range<usize>> {
469 self.span_ranges.get(span).cloned()
470 }
471
472 pub fn span_at(&self, char_index: usize) -> Option<usize> {
473 for (i, range) in self.span_ranges.iter().enumerate() {
474 if range.is_empty() {
475 continue;
476 }
477 if range.start <= char_index && char_index <= range.end {
478 return Some(i);
479 }
480 }
481 None
482 }
483
484 pub fn line_at(&self, char_index: usize) -> usize {
485 for (i, line) in self.lines.iter().enumerate() {
486 if char_index <= line.chars.end {
487 return i;
488 }
489 }
490 self.lines.len().saturating_sub(1)
491 }
492
493 pub fn caret_pos(&self, char_index: usize) -> RichCaret {
494 let index = char_index.min(self.len_chars);
495 if self.lines.is_empty() {
496 return RichCaret {
497 x: 0.0,
498 line: 0,
499 top: 0.0,
500 height: self.line_height,
501 };
502 }
503 let li = self.line_at(index);
504 let line = &self.lines[li];
505 let k = index.saturating_sub(line.chars.start);
506 let x = self
507 .carets
508 .get(li)
509 .and_then(|c| c.get(k.min(c.len().saturating_sub(1))))
510 .copied()
511 .unwrap_or(0.0);
512 RichCaret {
513 x,
514 line: li,
515 top: line.top,
516 height: line.height,
517 }
518 }
519
520 pub fn hit_point(&self, x: f32, y: f32) -> RichHit {
521 if self.lines.is_empty() {
522 return RichHit {
523 char_index: 0,
524 span: None,
525 line: 0,
526 };
527 }
528 let mut li = 0usize;
529 for (i, line) in self.lines.iter().enumerate() {
530 if y >= line.top {
531 li = i;
532 } else {
533 break;
534 }
535 }
536 let line = &self.lines[li];
537 let carets = &self.carets[li];
538 let mut best = 0usize;
539 let mut best_d = f32::MAX;
540 for (k, &cx) in carets.iter().enumerate() {
541 let d = (cx - x).abs();
542 if d < best_d {
543 best_d = d;
544 best = k;
545 }
546 }
547 let char_index = (line.chars.start + best).min(self.len_chars);
548 RichHit {
549 char_index,
550 span: self.span_at(char_index),
551 line: li,
552 }
553 }
554
555 pub fn backgrounds(&self) -> &[RichRect] {
556 &self.backgrounds
557 }
558
559 pub fn decorations(&self) -> &[RichRect] {
560 &self.decorations
561 }
562
563 pub fn squiggles(&self) -> &[Squiggle] {
564 &self.squiggles
565 }
566
567 pub fn squiggle_rects(&self) -> &[RichRect] {
568 &self.squiggle_rects
569 }
570
571 pub fn span_link(&self, span: usize) -> Option<&str> {
572 self.span_links.get(span).and_then(|o| o.as_deref())
573 }
574
575 pub fn span_mention(&self, span: usize) -> Option<u64> {
576 self.span_mentions.get(span).copied().flatten()
577 }
578
579 pub fn span_is_code(&self, span: usize) -> bool {
580 self.span_codes.get(span).copied().unwrap_or(false)
581 }
582
583 pub fn add_squiggle(&mut self, range: Range<usize>, color: Color) {
584 let start = range.start.min(self.len_chars);
585 let end = range.end.min(self.len_chars);
586 if end <= start {
587 return;
588 }
589 self.squiggles.push(Squiggle {
590 range: start..end,
591 color,
592 });
593
594 for (li, line) in self.lines.iter().enumerate() {
595 let s0 = start.max(line.chars.start);
596 let s1 = end.min(line.chars.end);
597 if s1 <= s0 {
598 continue;
599 }
600 let carets = match self.carets.get(li) {
601 Some(c) => c,
602 None => continue,
603 };
604 let x_of = |c: usize| -> f32 {
605 let k = c.saturating_sub(line.chars.start);
606 carets
607 .get(k.min(carets.len().saturating_sub(1)))
608 .copied()
609 .unwrap_or(0.0)
610 };
611 let x0 = x_of(s0);
612 let x1 = x_of(s1).max(x0);
613 if x1 <= x0 {
614 continue;
615 }
616 let y_base = (line.baseline + self.font_size * 0.12).round();
617 let thick = 1.0f32.max((self.font_size * 0.06).round());
618
619 let mut x = x0;
620 let mut up = false;
621 while x < x1 {
622 let w = (2.0f32).min(x1 - x);
623 let dy = if up { -1.0 } else { 1.0 };
624 self.squiggle_rects.push(RichRect {
625 rect: Rect::from_xywh(x, y_base + dy, w, thick),
626 color,
627 });
628 x += w;
629 up = !up;
630 }
631 }
632 }
633
634 pub fn glyph_count(&self) -> usize {
635 self.glyphs.len()
636 }
637
638 pub fn glyph_span(&self, glyph: usize) -> Option<usize> {
639 self.glyphs.get(glyph).map(|g| g.span)
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use std::rc::Rc;
646
647 use super::*;
648 use crate::{TextSystem, WrapMode};
649
650 fn has_fonts() -> bool {
651 TextSystem::new().shape("0", 14.0).glyph_count() > 0
652 }
653
654 fn spans2() -> Vec<TextSpan> {
655 vec![
656 TextSpan::new("ab").color(Color::RED).bold(),
657 TextSpan::new("cd").color(Color::BLUE),
658 ]
659 }
660
661 #[test]
662 fn glyphs_carry_span_indices() {
663 if !has_fonts() {
664 return;
665 }
666 let mut text = TextSystem::new();
667 let rich = text.shape_rich(&spans2(), 14.0);
668 assert!(rich.glyph_count() > 0);
669 assert!(rich.span_count() == 2);
670 let mut seen = [false, false];
671 for i in 0..rich.glyph_count() {
672 let s = rich.glyph_span(i).expect("span");
673 assert!(s < 2);
674 seen[s] = true;
675 }
676 assert_eq!(seen, [true, true]);
677 assert_eq!(rich.span_range(0), Some(0..2));
678 assert_eq!(rich.span_range(1), Some(2..4));
679 }
680
681 #[test]
682 fn boundaries_belong_to_the_left_span() {
683 if !has_fonts() {
684 return;
685 }
686 let mut text = TextSystem::new();
687 let rich = text.shape_rich(&spans2(), 14.0);
688 assert_eq!(rich.span_at(0), Some(0));
689 assert_eq!(rich.span_at(1), Some(0));
690 assert_eq!(rich.span_at(2), Some(0));
691 assert_eq!(rich.span_at(3), Some(1));
692 assert_eq!(rich.span_at(4), Some(1));
693 }
694
695 #[test]
696 fn multiline_split_and_caret_lines() {
697 if !has_fonts() {
698 return;
699 }
700 let mut text = TextSystem::new();
701 let rich = text.shape_rich(&[TextSpan::new("a\nb\nc")], 14.0);
702 assert_eq!(rich.line_count(), 3);
703 assert_eq!(rich.line(0).unwrap().chars, 0..1);
704 assert_eq!(rich.line(1).unwrap().chars, 2..3);
705 assert_eq!(rich.line(2).unwrap().chars, 4..5);
706 assert!((rich.height() - 3.0 * rich.line_height()).abs() < 0.01);
707 assert_eq!(rich.caret_pos(0).line, 0);
708 assert_eq!(rich.caret_pos(2).line, 1);
709 assert_eq!(rich.caret_pos(4).line, 2);
710
711 assert_eq!(rich.caret_pos(1).line, 0);
712 assert!(rich.caret_pos(1).x > rich.caret_pos(0).x);
713 }
714
715 #[test]
716 fn first_line_baseline_preserves_ascent() {
717 if !has_fonts() {
718 return;
719 }
720 let mut text = TextSystem::new();
721 let rich = text.shape_rich(&[TextSpan::new("a\nb\nc")], 14.0);
722 for i in 0..rich.line_count() {
723 let line = rich.line(i).unwrap();
724 assert!(
725 line.baseline > line.top,
726 "line {i}: baseline must sit below top (ascent)"
727 );
728 assert!(
729 line.baseline < line.top + line.height,
730 "line {i}: baseline inside the box"
731 );
732 }
733 let l0 = rich.line(0).unwrap();
734 for g in rich.glyphs.iter() {
735 assert!(g.y >= l0.top - 0.5, "glyph above paragraph box: {}", g.y);
736 }
737 let a0 = l0.baseline - l0.top;
738 for i in 1..rich.line_count() {
739 let line = rich.line(i).unwrap();
740 let a = line.baseline - line.top;
741 assert!((a - a0).abs() < 0.5, "line {i}: ascent drift {a} vs {a0}");
742 }
743 }
744
745 #[test]
746 fn trailing_newline_adds_an_empty_line() {
747 if !has_fonts() {
748 return;
749 }
750 let mut text = TextSystem::new();
751 let rich = text.shape_rich(&[TextSpan::new("a\n")], 14.0);
752 assert_eq!(rich.line_count(), 2);
753 assert_eq!(rich.line(1).unwrap().chars, 2..2);
754 assert_eq!(rich.caret_pos(2).line, 1);
755 }
756
757 #[test]
758 fn hit_point_roundtrip_near_left_edges() {
759 if !has_fonts() {
760 return;
761 }
762 let mut text = TextSystem::new();
763 let rich = text.shape_rich(&[TextSpan::new("abcdef")], 16.0);
764 let mid_y = rich.line(0).unwrap().top + rich.line_height() * 0.5;
765 for c in 0..6 {
766 let x0 = rich.caret_pos(c).x;
767 let x1 = rich.caret_pos(c + 1).x;
768 assert!(x1 > x0, "glyph {c} has no width");
769 let hit = rich.hit_point(x0 + (x1 - x0) * 0.25, mid_y);
770 assert_eq!(hit.char_index, c);
771 assert_eq!(hit.line, 0);
772 assert_eq!(hit.span, Some(0));
773 }
774
775 assert_eq!(rich.hit_point(1.0, -100.0).line, 0);
776 assert_eq!(rich.hit_point(1.0, 100_000.0).line, 0);
777 }
778
779 #[test]
780 fn background_rect_matches_span_extent() {
781 if !has_fonts() {
782 return;
783 }
784 let mut text = TextSystem::new();
785 let spans = vec![
786 TextSpan::new("ab"),
787 TextSpan::new("cd").background(Color::GREEN),
788 ];
789 let rich = text.shape_rich(&spans, 14.0);
790 assert_eq!(rich.backgrounds().len(), 1);
791 let bg = rich.backgrounds()[0];
792 assert_eq!(bg.color, Color::GREEN);
793 assert!((bg.rect.left() - rich.caret_pos(2).x).abs() < 0.01);
794 assert!(
795 (bg.rect.right() - rich.caret_pos(4).x).abs() < 0.5,
796 "bg right {:?} vs caret {:?}",
797 bg.rect.right(),
798 rich.caret_pos(4).x
799 );
800 assert!((bg.rect.size.height - rich.line_height()).abs() < 0.01);
801 }
802
803 #[test]
804 fn wrap_creates_visual_lines_covering_all_chars() {
805 if !has_fonts() {
806 return;
807 }
808 let mut text = TextSystem::new();
809 let content: String = "abcdefghij".chars().collect();
810 let len = content.chars().count();
811 let rich =
812 text.shape_rich_wrapped(&[TextSpan::new(content)], 16.0, 40.0, WrapMode::Character);
813 assert!(rich.line_count() > 1);
814 let mut covered = vec![false; len];
815 for line in rich.lines() {
816 assert!(line.width <= 40.0 + 0.5, "line too wide: {}", line.width);
817 for c in line.chars.clone() {
818 covered[c] = true;
819 }
820 }
821 assert!(covered.iter().all(|&b| b));
822
823 let second = &rich.lines()[1];
824 let hit = rich.hit_point(1.0, second.top + 1.0);
825 assert_eq!(hit.line, 1);
826 assert!(second.chars.contains(&hit.char_index));
827 }
828
829 #[test]
830 fn center_align_shifts_short_lines_right() {
831 if !has_fonts() {
832 return;
833 }
834 let mut text = TextSystem::new();
835 let spans = vec![TextSpan::new("ab")];
836 let left = text.shape_rich_aligned(&spans, 16.0, 200.0, WrapMode::Word, RichAlign::Left);
837 let center =
838 text.shape_rich_aligned(&spans, 16.0, 200.0, WrapMode::Word, RichAlign::Center);
839 assert!((left.caret_pos(0).x - 0.0).abs() < 0.01);
840 assert!(center.caret_pos(0).x > 10.0);
841
842 let dx = center.caret_pos(0).x - left.caret_pos(0).x;
843 assert!((center.caret_pos(2).x - left.caret_pos(2).x - dx).abs() < 1.0);
844 }
845
846 #[test]
847 fn empty_spans_keep_stable_indices() {
848 if !has_fonts() {
849 return;
850 }
851 let mut text = TextSystem::new();
852 let rich = text.shape_rich(
853 &[TextSpan::new(""), TextSpan::new("x"), TextSpan::new("")],
854 14.0,
855 );
856 assert_eq!(rich.span_count(), 3);
857 assert_eq!(rich.span_range(0), Some(0..0));
858 assert_eq!(rich.span_range(1), Some(0..1));
859 assert_eq!(rich.span_at(0), Some(1));
860 for i in 0..rich.glyph_count() {
861 assert_eq!(rich.glyph_span(i), Some(1));
862 }
863 }
864
865 #[test]
866 fn cache_returns_shared_paragraphs() {
867 if !has_fonts() {
868 return;
869 }
870 let mut text = TextSystem::new();
871 let a = text.shape_rich(&spans2(), 14.0);
872 let b = text.shape_rich(&spans2(), 14.0);
873 assert!(Rc::ptr_eq(&a, &b));
874 }
875
876 #[test]
877 fn draw_splits_text_by_color_with_background_first() {
878 use sequoia_core::{RenderItem, RenderList};
879
880 if !has_fonts() {
881 return;
882 }
883 let mut text = TextSystem::new();
884 let spans = vec![
885 TextSpan::new("ab").color(Color::RED),
886 TextSpan::new("cd")
887 .color(Color::BLUE)
888 .background(Color::GREEN),
889 ];
890 let rich = text.shape_rich(&spans, 16.0);
891 let mut list = RenderList::new();
892 text.draw_rich(&rich, (10.0, 20.0), Color::WHITE, &mut list);
893 let mut colors: Vec<Color> = Vec::new();
894 let mut quads = 0usize;
895 for item in list.items() {
896 match item {
897 RenderItem::Text(t) => colors.push(t.color),
898 RenderItem::Quad(_) => quads += 1,
899 _ => {}
900 }
901 }
902 assert_eq!(colors, vec![Color::RED, Color::BLUE]);
903 assert_eq!(quads, 1, "one background quad");
904
905 let first_text = list
906 .items()
907 .iter()
908 .position(|i| matches!(i, RenderItem::Text(_)))
909 .unwrap();
910 let first_quad = list
911 .items()
912 .iter()
913 .position(|i| matches!(i, RenderItem::Quad(_)))
914 .unwrap();
915 assert!(first_quad < first_text);
916 }
917
918 #[test]
919 fn decorations_follow_underlined_spans() {
920 if !has_fonts() {
921 return;
922 }
923 let mut text = TextSystem::new();
924 let rich = text.shape_rich(&[TextSpan::new("ab").underline(true)], 16.0);
925 assert_eq!(rich.decorations().len(), 1);
926 let d = rich.decorations()[0];
927 let line = rich.line(0).unwrap();
928 assert!(d.rect.top() > line.baseline);
929 assert!((d.rect.size.width - line.width).abs() < 1.0);
930 }
931
932 use sequoia_buffer::{SpanAttr, StyledBuffer, StyledSpan};
933
934 #[test]
935 fn per_span_size_delta_changes_height_not_just_width() {
936 if !has_fonts() {
937 return;
938 }
939 let mut text = TextSystem::new();
940 let base = text.shape_rich(&[TextSpan::new("Hello world")], 14.0);
941 let big = text.shape_rich(&[TextSpan::new("Hello world").size_delta(10.0)], 14.0);
942 assert!(
943 big.height() > base.height() + 2.0,
944 "larger span = higher line: {} vs {}",
945 big.height(),
946 base.height()
947 );
948 assert!(big.width() > base.width());
949
950 let mixed = text.shape_rich(
951 &[TextSpan::new("ab"), TextSpan::new("CD").size_delta(12.0)],
952 14.0,
953 );
954 assert!(mixed.height() > base.height());
955
956 let again = text.shape_rich(&[TextSpan::new("Hello world").size_delta(10.0)], 14.0);
957 assert!(Rc::ptr_eq(&big, &again));
958 assert!(!Rc::ptr_eq(&base, &big));
959 }
960
961 #[test]
962 fn hit_test_link_and_mention_returns_span_idx() {
963 if !has_fonts() {
964 return;
965 }
966 let mut text = TextSystem::new();
967
968 let full = "hello @user world https://x.y";
969 let mut buf = StyledBuffer::from(full);
970 buf.set_attr(
971 6..11,
972 SpanAttr {
973 bold: true,
974 underline: true,
975 color: Some(Color::BLUE),
976 mention: Some(42),
977 ..SpanAttr::plain()
978 },
979 );
980 buf.set_attr(
981 11..full.len(),
982 SpanAttr {
983 underline: true,
984 color: Some(Color::BLUE),
985 link: Some("https://x.y".to_owned()),
986 code: true,
987 ..SpanAttr::plain()
988 },
989 );
990 let styled: Vec<StyledSpan> = buf.spans_for_line(0..full.len());
991 assert_eq!(styled.len(), 2, "tylko nie-czyste spany z modelu");
992 let spans = super::styled_spans_to_text_spans(full, 0, &styled);
993 assert_eq!(spans.len(), 3, "luka plain + mention + link");
994 let rich = text.shape_rich(&spans, 16.0);
995 assert_eq!(rich.span_count(), 3);
996 assert_eq!(rich.span_mention(1), Some(42));
997 assert_eq!(rich.span_link(2), Some("https://x.y"));
998 assert!(rich.span_is_code(2));
999 assert_eq!(rich.span_link(0), None);
1000
1001 let mid_y = rich.line(0).unwrap().top + rich.line_height() * 0.5;
1002 let x_mid_mention = (rich.caret_pos(7).x + rich.caret_pos(10).x) * 0.5;
1003 let hit = rich.hit_point(x_mid_mention, mid_y);
1004 assert_eq!(hit.as_tuple(), (hit.char_index, hit.span));
1005 assert_eq!(hit.span, Some(1), "mention span_idx");
1006 assert!((6..=11).contains(&hit.char_index));
1007
1008 let x_mid_link = (rich.caret_pos(12).x + rich.caret_pos(20).x) * 0.5;
1009 let hit_link = rich.hit_point(x_mid_link, mid_y);
1010 assert_eq!(hit_link.span, Some(2), "link span_idx");
1011 assert_eq!(rich.span_link(hit_link.span.unwrap()), Some("https://x.y"));
1012 }
1013
1014 #[test]
1015 fn squiggle_does_not_change_layout() {
1016 use sequoia_core::{RenderItem, RenderList};
1017 if !has_fonts() {
1018 return;
1019 }
1020 let mut text = TextSystem::new();
1021 let spans = vec![
1022 TextSpan::new("hello world, "),
1023 TextSpan::new("this is an error").underline(true),
1024 ];
1025 let rich = text.shape_rich(&spans, 16.0);
1026 let (w0, h0) = (rich.width(), rich.height());
1027 let lines0: Vec<_> = rich.lines().to_vec();
1028 let glyphs0 = rich.glyph_count();
1029 let bg0 = rich.backgrounds().len();
1030 let deco0 = rich.decorations().len();
1031
1032 let mut owned = (*rich).clone();
1033
1034 owned.add_squiggle(5..5, Color::RED);
1035 assert!(owned.squiggles().is_empty());
1036 owned.add_squiggle(13..18, Color::RED);
1037 assert_eq!(owned.squiggles().len(), 1);
1038 assert!(!owned.squiggle_rects().is_empty());
1039
1040 assert!((owned.width() - w0).abs() < 1e-4);
1041 assert!((owned.height() - h0).abs() < 1e-4);
1042 assert_eq!(owned.lines(), lines0.as_slice());
1043 assert_eq!(owned.glyph_count(), glyphs0);
1044 assert_eq!(owned.backgrounds().len(), bg0);
1045 assert_eq!(
1046 owned.decorations().len(),
1047 deco0,
1048 "squiggle nie w decorations"
1049 );
1050
1051 let mut before = RenderList::new();
1052 text.draw_rich(&rich, (0.0, 0.0), Color::WHITE, &mut before);
1053 let mut after = RenderList::new();
1054 text.draw_rich(&owned, (0.0, 0.0), Color::WHITE, &mut after);
1055 let quads = |l: &RenderList| {
1056 l.items()
1057 .iter()
1058 .filter(|i| matches!(i, RenderItem::Quad(_)))
1059 .count()
1060 };
1061 assert!(quads(&after) > quads(&before), "fala dorzuca quady");
1062
1063 let texts = |l: &RenderList| {
1064 l.items()
1065 .iter()
1066 .filter(|i| matches!(i, RenderItem::Text(_)))
1067 .count()
1068 };
1069 assert_eq!(texts(&before), texts(&after));
1070 }
1071
1072 #[test]
1073 fn wrap_width_comes_from_call_and_heights_vary() {
1074 if !has_fonts() {
1075 return;
1076 }
1077 let mut text = TextSystem::new();
1078 let spans = vec![
1079 TextSpan::new("ab cd ef gh ij kl mn "),
1080 TextSpan::new("BIG").size_delta(14.0),
1081 ];
1082 let narrow = text.shape_rich_wrapped(&spans, 14.0, 60.0, WrapMode::Word);
1083 let wide = text.shape_rich_wrapped(&spans, 14.0, 600.0, WrapMode::Word);
1084 assert!(narrow.line_count() > wide.line_count(), "call wrap");
1085 assert!(
1086 !Rc::ptr_eq(&narrow, &wide),
1087 "different widths = separate entries"
1088 );
1089
1090 let plain =
1091 text.shape_rich_wrapped(&[TextSpan::new("ab cd ef")], 14.0, 60.0, WrapMode::Word);
1092 assert!(narrow.height() >= plain.height());
1093 let max_h = narrow
1094 .lines()
1095 .iter()
1096 .map(|l| l.height)
1097 .fold(0.0f32, f32::max);
1098 let min_h = narrow
1099 .lines()
1100 .iter()
1101 .map(|l| l.height)
1102 .fold(f32::MAX, f32::min);
1103 assert!(max_h >= min_h);
1104 }
1105
1106 #[test]
1107 fn bidi_mixed_text_shapes_and_hits() {
1108 if !has_fonts() {
1109 return;
1110 }
1111 let mut text = TextSystem::new();
1112 let spans = vec![TextSpan::new("Hello שלום world مرحبا end")];
1113 let rich = text.shape_rich(&spans, 16.0);
1114 assert!(rich.glyph_count() > 0);
1115 assert!(rich.line_count() >= 1);
1116 assert!(rich.width() > 0.0);
1117
1118 let mid_y = rich.line(0).unwrap().top + rich.line(0).unwrap().height * 0.5;
1119 let hit = rich.hit_point(rich.width() * 0.5, mid_y);
1120 assert_eq!(hit.span, Some(0));
1121 assert!(hit.char_index <= rich.len_chars());
1122 }
1123
1124 #[test]
1125 fn styled_mapper_is_utf8_safe_not_ascii() {
1126 let line = "yellow the goose of the self";
1127 let oto = |s: &str| s.len();
1128
1129 let w2_start = oto("turn yellow");
1130 let w2_end = w2_start + oto("gooseberry");
1131 let styled = vec![StyledSpan::new(
1132 w2_start,
1133 w2_end,
1134 SpanAttr {
1135 bold: true,
1136 mention: Some(99),
1137 ..SpanAttr::plain()
1138 },
1139 )];
1140 let spans = super::styled_spans_to_text_spans(line, 0, &styled);
1141 assert_eq!(spans.len(), 3, "przed + styl + po");
1142 let joined: String = spans.iter().map(|s| s.text.clone()).collect();
1143 assert_eq!(joined, line, "mapper covers the entire line without loss");
1144 assert_eq!(spans[1].mention, Some(99));
1145 assert_eq!(spans[1].weight, Some(crate::FontWeight::BOLD));
1146
1147 let shifted: Vec<StyledSpan> = styled
1148 .iter()
1149 .map(|s| StyledSpan::new(s.start + 1000, s.end + 1000, s.attr.clone()))
1150 .collect();
1151 let spans2 = super::styled_spans_to_text_spans(line, 1000, &shifted);
1152 assert_eq!(spans2, spans, "relatywizacja do line_byte_start");
1153
1154 let emoji_line = "a👨👩👧b";
1155 let b_len = "a".len();
1156 let e_len = "👨👩👧".len();
1157 let estyled = vec![StyledSpan::new(
1158 b_len,
1159 b_len + e_len,
1160 SpanAttr {
1161 link: Some("x".into()),
1162 ..SpanAttr::plain()
1163 },
1164 )];
1165 let espans = super::styled_spans_to_text_spans(emoji_line, 0, &estyled);
1166 let ejoined: String = espans.iter().map(|s| s.text.clone()).collect();
1167 assert_eq!(ejoined, emoji_line);
1168 assert_eq!(espans[1].link.as_deref(), Some("x"));
1169 }
1170
1171 #[test]
1172 fn span_attr_hash_sees_every_field() {
1173 use std::collections::hash_map::DefaultHasher;
1174 use std::hash::Hasher;
1175
1176 let h = |f: fn(SpanAttr) -> SpanAttr| {
1177 let mut s = DefaultHasher::new();
1178 sequoia_buffer::hash_span_attr(&f(SpanAttr::plain()), &mut s);
1179 s.finish()
1180 };
1181 let hashes = [
1182 h(|a| SpanAttr { bold: true, ..a }),
1183 h(|a| SpanAttr { italic: true, ..a }),
1184 h(|a| SpanAttr {
1185 underline: true,
1186 ..a
1187 }),
1188 h(|a| SpanAttr { strike: true, ..a }),
1189 h(|a| SpanAttr {
1190 color: Some(Color::RED),
1191 ..a
1192 }),
1193 h(|a| SpanAttr {
1194 color: Some(Color::BLUE),
1195 ..a
1196 }),
1197 h(|a| SpanAttr {
1198 bg: Some(Color::GREEN),
1199 ..a
1200 }),
1201 h(|a| SpanAttr {
1202 size_delta: 4.0,
1203 ..a
1204 }),
1205 h(|a| SpanAttr {
1206 link: Some("u".into()),
1207 ..a
1208 }),
1209 h(|a| SpanAttr {
1210 mention: Some(1),
1211 ..a
1212 }),
1213 h(|a| SpanAttr { code: true, ..a }),
1214 ];
1215 for i in 0..hashes.len() {
1216 for j in (i + 1)..hashes.len() {
1217 assert_ne!(hashes[i], hashes[j], "pole {i} vs {j}");
1218 }
1219 }
1220 }
1221}