Skip to main content

sequoia_text/
rich.rs

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
84/// Returns a prefix of `spans` containing at most `max_chars` characters.
85pub(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
106/// Appends an ellipsis to the last span, preserving its style.
107pub(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}