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};
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}