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