Skip to main content

twrite_gpui/
canvas.rs

1use gpui::*;
2use twrite_core::{
3    HighlightTag, Point as BufferPoint, StyleSpan, StyleValue, UnderlineDecoration,
4    split_line_intervals,
5};
6
7use std::ops::Range;
8
9use crate::editor::Editor;
10use crate::theme::EditorTheme;
11
12/// Visual layout metrics and block-level decorations for a single rendered line.
13#[derive(Debug, Clone)]
14pub struct LineMetrics {
15    /// Font size in pixels for this line.
16    pub font_size: Pixels,
17    /// Vertical line height in pixels for this line.
18    pub line_height: Pixels,
19    /// Whether this line is a blockquote.
20    pub is_quote: bool,
21    /// Whether this line is part of a fenced code block.
22    pub is_code_block: bool,
23    /// Whether this line is a thematic divider break (---, ***, ___).
24    pub is_thematic_break: bool,
25    /// Whether this line is a task list item (Some(false) for unchecked, Some(true) for checked).
26    pub task_state: Option<bool>,
27}
28
29impl LineMetrics {
30    /// Calculates layout metrics and block-level decorations based on syntax spans and line text.
31    ///
32    /// `spans` are the original (pre-concealment) highlight spans for the line. Structural
33    /// decorations (`Blockquote`, `HorizontalRule`, `Task*`) are detected solely via
34    /// `HighlightTag`s so custom languages work without raw string checks. Code-block
35    /// detection relies on a full-line `Code` span rather than fence prefixes.
36    pub fn for_line(
37        raw_line_text: &str,
38        _concealed_display_text: &str,
39        spans: &[StyleSpan],
40        base_font_size: Pixels,
41        base_line_height: Pixels,
42    ) -> Self {
43        let mut font_size = base_font_size;
44        let mut line_height = base_line_height;
45
46        // Lowest heading level wins (level 1 outranks level 6); out-of-range
47        // levels keep the base size.
48        let heading_level = spans
49            .iter()
50            .filter_map(|s| match s.style {
51                StyleValue::Tag(HighlightTag::Heading(level)) => Some(level),
52                _ => None,
53            })
54            .min();
55
56        match heading_level {
57            Some(1) => {
58                font_size = base_font_size * 2.0;
59                line_height = base_line_height * 1.8;
60            }
61            Some(2) => {
62                font_size = base_font_size * 1.5;
63                line_height = base_line_height * 1.4;
64            }
65            Some(3) => {
66                font_size = base_font_size * 1.25;
67                line_height = base_line_height * 1.2;
68            }
69            Some(4) => {
70                font_size = base_font_size * 1.125;
71                line_height = base_line_height * 1.1;
72            }
73            Some(5) => {
74                font_size = base_font_size * 1.0;
75                line_height = base_line_height * 1.0;
76            }
77            Some(6) => {
78                font_size = base_font_size * 0.875;
79                line_height = base_line_height * 0.95;
80            }
81            _ => {}
82        }
83
84        let is_quote = spans
85            .iter()
86            .any(|s| matches!(s.style, StyleValue::Tag(HighlightTag::Blockquote)));
87
88        let is_code_block = spans.iter().any(|s| {
89            (raw_line_text.is_empty() || s.range.len() == raw_line_text.len())
90                && matches!(s.style, StyleValue::Tag(HighlightTag::Code))
91        });
92
93        let is_thematic_break = spans
94            .iter()
95            .any(|s| matches!(s.style, StyleValue::Tag(HighlightTag::HorizontalRule)));
96
97        let task_state = if spans
98            .iter()
99            .any(|s| matches!(s.style, StyleValue::Tag(HighlightTag::TaskChecked)))
100        {
101            Some(true)
102        } else if spans
103            .iter()
104            .any(|s| matches!(s.style, StyleValue::Tag(HighlightTag::TaskUnchecked)))
105        {
106            Some(false)
107        } else {
108            None
109        };
110
111        Self {
112            font_size,
113            line_height,
114            is_quote,
115            is_code_block,
116            is_thematic_break,
117            task_state,
118        }
119    }
120}
121
122/// Fonts used when building styled text runs for a line.
123#[derive(Debug, Clone, Copy)]
124pub struct RunFonts<'a> {
125    /// Base font for normal spans.
126    pub base: &'a Font,
127    /// Base font for `Code` spans (bold/italic still derive from it).
128    pub code: &'a Font,
129}
130
131/// Builds styled text runs for a single line by blending syntax spans and active selection.
132pub fn build_line_text_runs(
133    line_text: &str,
134    spans: &[StyleSpan],
135    selection_line_range: Option<(usize, usize)>,
136    fonts: &RunFonts,
137    theme: &EditorTheme,
138    is_code_block: bool,
139    is_checked_task: bool,
140) -> Vec<TextRun> {
141    if line_text.is_empty() {
142        return Vec::new();
143    }
144
145    let segments = split_line_intervals(line_text.len(), spans, selection_line_range);
146    let mut runs = Vec::with_capacity(segments.len());
147
148    for segment in segments {
149        let resolved = segment.style.map(|s| theme.resolve_style(s));
150
151        let color = if is_checked_task && !segment.is_selected {
152            theme.syntax.comment
153        } else {
154            resolved
155                .as_ref()
156                .map(|r| r.color)
157                .unwrap_or(theme.foreground)
158        };
159
160        let background_color = if segment.is_selected {
161            Some(theme.selection)
162        } else if is_code_block {
163            None
164        } else {
165            resolved.as_ref().and_then(|r| r.background)
166        };
167        let mut run_font = if matches!(segment.style, Some(StyleValue::Tag(HighlightTag::Code))) {
168            fonts.code.clone()
169        } else {
170            fonts.base.clone()
171        };
172        if let Some(r) = resolved.as_ref() {
173            if r.bold {
174                run_font.weight = FontWeight::BOLD;
175            }
176            if r.italic {
177                run_font.style = FontStyle::Italic;
178            }
179        }
180
181        let underline = resolved
182            .as_ref()
183            .and_then(|r| r.underline)
184            .map(|u| match u {
185                UnderlineDecoration::Solid => UnderlineStyle {
186                    color: Some(color),
187                    thickness: px(1.0),
188                    wavy: false,
189                },
190                UnderlineDecoration::Wavy => UnderlineStyle {
191                    color: Some(theme.syntax.error),
192                    thickness: px(1.2),
193                    wavy: true,
194                },
195            });
196
197        let strikethrough = if is_checked_task {
198            Some(StrikethroughStyle {
199                thickness: px(1.0),
200                color: Some(theme.syntax.comment),
201            })
202        } else {
203            resolved.as_ref().and_then(|r| {
204                if r.strikethrough {
205                    Some(StrikethroughStyle {
206                        color: Some(color),
207                        thickness: px(1.0),
208                    })
209                } else {
210                    None
211                }
212            })
213        };
214
215        runs.push(TextRun {
216            len: segment.range.end - segment.range.start,
217            font: run_font,
218            color,
219            background_color,
220            underline,
221            strikethrough,
222        });
223    }
224
225    let mut merged: Vec<TextRun> = Vec::with_capacity(runs.len());
226    for run in runs {
227        if let Some(last) = merged.last_mut()
228            && last.font == run.font
229            && last.color == run.color
230            && last.background_color == run.background_color
231            && last.underline == run.underline
232            && last.strikethrough == run.strikethrough
233        {
234            last.len += run.len;
235            continue;
236        }
237        merged.push(run);
238    }
239
240    merged
241}
242
243/// Data computed during prepaint for each visible line.
244struct PreparedLine {
245    gutter_num: Option<(Point<Pixels>, ShapedLine)>,
246    text_origin: Point<Pixels>,
247    text_line: WrappedLine,
248    line_height: Pixels,
249    quote_bar_quad: Option<PaintQuad>,
250    code_block_bg_quad: Option<PaintQuad>,
251    thematic_break_quad: Option<PaintQuad>,
252    empty_selection_quad: Option<PaintQuad>,
253    cursor_quad: Option<PaintQuad>,
254    task_checkbox_quad: Option<PaintQuad>,
255    /// Highlight-all search washes for this line (painted under the text).
256    search_match_quads: Vec<PaintQuad>,
257}
258
259/// Intersects ascending `matches` with `[line_start, line_end)`, advancing
260/// `*cursor` past ranges ending at or before the line. Returns clamped
261/// overlapping sub-ranges. Both inputs ascend, so a full frame walks each
262/// list once.
263fn wash_ranges_for_line(
264    matches: &[Range<usize>],
265    cursor: &mut usize,
266    line_start: usize,
267    line_end: usize,
268) -> Vec<Range<usize>> {
269    while *cursor < matches.len() && matches[*cursor].end <= line_start {
270        *cursor += 1;
271    }
272    let mut out = Vec::new();
273    let mut j = *cursor;
274    while j < matches.len() && matches[j].start < line_end {
275        let start = matches[j].start.max(line_start);
276        let end = matches[j].end.min(line_end);
277        if end > start {
278            out.push(start..end);
279        }
280        j += 1;
281    }
282    out
283}
284
285/// The state struct (T) passed from `prepaint` to `paint`.
286struct EditorCanvasPrepaint {
287    background_quad: PaintQuad,
288    lines: Vec<PreparedLine>,
289}
290
291/// The GPUI canvas element responsible for shaping and rendering text runs and quads.
292#[derive(IntoElement)]
293pub struct EditorCanvas {
294    editor: Entity<Editor>,
295}
296
297impl EditorCanvas {
298    /// Creates a new canvas element linked to the given editor entity.
299    pub fn new(editor: Entity<Editor>) -> Self {
300        EditorCanvas { editor }
301    }
302}
303
304impl RenderOnce for EditorCanvas {
305    fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
306        window.request_animation_frame();
307
308        let editor_handle = self.editor.clone();
309
310        canvas(
311            move |bounds, window, cx| {
312                editor_handle.update(cx, |editor, _| {
313                    editor.last_bounds = Some(bounds);
314                    // One sample per presented frame for the FPS testing HUD.
315                    editor.frame_stats.record();
316                    // Probe + auto-select when inputs change: explicit families,
317                    // code override, or host font. Explicit families are trusted
318                    // verbatim (probed for the indicator only); otherwise the
319                    // first candidate with bold + italic faces wins.
320                    let host = window.text_style().font();
321                    let key = (
322                        editor.config.font_family.clone(),
323                        editor.config.code_font_family.clone(),
324                        host.clone(),
325                    );
326                    if editor.face_probe_key.as_ref() != Some(&key) {
327                        let text_system = window.text_system();
328                        let mut probe_one = |family: &str| {
329                            let mut base = host.clone();
330                            // gpui 0.2.2's SharedString is ArcCow<'static, str>:
331                            // `&str.into()` would demand a 'static borrow, so
332                            // copy through the owning constructor instead.
333                            base.family = SharedString::new(family);
334                            let base_id = text_system.resolve_font(&base);
335                            let mut bold = base.clone();
336                            bold.weight = FontWeight::BOLD;
337                            let mut italic = base.clone();
338                            italic.style = FontStyle::Italic;
339                            (
340                                text_system.resolve_font(&bold) != base_id,
341                                text_system.resolve_font(&italic) != base_id,
342                            )
343                        };
344                        let candidates = editor.config.font_candidates();
345                        let (selected, mut availability) = match editor.config.font_family.clone() {
346                            Some(explicit) => {
347                                let (bold, italic) = probe_one(explicit.as_ref());
348                                (
349                                    Some(explicit),
350                                    crate::editor::FaceAvailability { bold, italic },
351                                )
352                            }
353                            None => match crate::config::EditorConfig::pick_family(
354                                &candidates,
355                                &mut probe_one,
356                            ) {
357                                Some(winner) => {
358                                    let (bold, italic) = probe_one(winner.as_ref());
359                                    (
360                                        Some(winner.clone()),
361                                        crate::editor::FaceAvailability { bold, italic },
362                                    )
363                                }
364                                None => (
365                                    None,
366                                    crate::editor::FaceAvailability {
367                                        bold: false,
368                                        italic: false,
369                                    },
370                                ),
371                            },
372                        };
373                        // An explicitly overridden code family gets its own say.
374                        if let Some(code_family) = &editor.config.code_font_family
375                            && Some(code_family) != selected.as_ref()
376                        {
377                            let (bold, italic) = probe_one(code_family.as_ref());
378                            availability.bold &= bold;
379                            availability.italic &= italic;
380                        }
381                        editor.selected_font_family = selected;
382                        editor.face_availability = Some(availability);
383                        editor.face_probe_key = Some(key);
384                    }
385                });
386                // Take the cache out for the frame: the read guard below borrows
387                // the editor immutably, so the cache travels as a local.
388                let mut layout_cache =
389                    editor_handle.update(cx, |editor, _| std::mem::take(&mut editor.layout_cache));
390                let editor = editor_handle.read(cx);
391                let theme = editor.theme.clone();
392                let config = editor.config.clone();
393                let host_font = window.text_style().font();
394                let font = editor.resolved_base_font(&host_font);
395                let code_font = editor.resolved_code_font(&host_font);
396
397                let gutter_width = if config.line_numbers {
398                    px(48.0)
399                } else {
400                    px(0.0)
401                };
402                let text_origin_x = bounds.left() + gutter_width + px(12.0);
403
404                // Per-line override point: `cached.allow_wrap` (set by the
405                // highlighter) can opt rows out of soft-wrapping below.
406                let wrap_width = if config.line_wrap {
407                    let available = bounds.size.width - gutter_width - px(24.0);
408                    Some(available.max(px(50.0)))
409                } else {
410                    None
411                };
412
413                let total_lines = editor.buffer.len_lines();
414                let total_bytes = editor.buffer.len_bytes();
415                let scroll_row = editor.scroll_row;
416
417                let cursor_offset = editor.buffer.cursor_offset();
418                let cursor_point = editor.buffer.cursor_point();
419                let selection = editor.selection;
420
421                let is_all_selected = selection.is_some_and(|s| {
422                    let range = s.byte_range();
423                    !s.is_empty() && range.start == 0 && range.end == total_bytes
424                });
425
426                let mut lines: Vec<PreparedLine> = Vec::new();
427                let mut visible_line_layouts: Vec<crate::editor::VisibleLineLayout> = Vec::new();
428                let mut current_y = bounds.top();
429                let mut computed_cursor_pixel = None;
430                // Highlight-all wash, cloned once per frame; the per-line walk
431                // below advances linearly through both ascending lists.
432                let search_wash: Vec<Range<usize>> = if editor.search_highlight_all {
433                    editor.search_matches.clone()
434                } else {
435                    Vec::new()
436                };
437                let mut wash_cursor = 0;
438
439                for row in scroll_row..total_lines {
440                    if current_y >= bounds.bottom() {
441                        break;
442                    }
443
444                    let raw_line = editor.buffer.line_to_string(row);
445                    let line_text = raw_line.trim_end_matches(['\r', '\n']);
446                    let line_start_byte = editor.buffer.point_to_offset(BufferPoint::new(row, 0));
447                    let line_end_byte = line_start_byte + line_text.len();
448
449                    let gutter_num = if config.line_numbers {
450                        let is_cursor_row = cursor_point.row == row;
451                        let line_num_str = format!("{:>3}", row + 1);
452                        let num_color = if is_cursor_row {
453                            theme.line_number_active
454                        } else {
455                            theme.line_number
456                        };
457
458                        let shaped = window.text_system().shape_line(
459                            line_num_str.into(),
460                            config.font_size,
461                            &[TextRun {
462                                len: 3,
463                                font: font.clone(),
464                                color: num_color,
465                                background_color: None,
466                                underline: None,
467                                strikethrough: None,
468                            }],
469                            None,
470                        );
471
472                        Some((point(bounds.left() + px(8.0), current_y), shaped))
473                    } else {
474                        None
475                    };
476
477                    let cursor_row = cursor_point.row;
478                    let highlighter_rev = editor.highlighter_rev;
479                    let cached = layout_cache.cached_input(
480                        &editor.buffer,
481                        editor.highlighter.as_deref(),
482                        highlighter_rev,
483                        cursor_row,
484                        row,
485                        line_text,
486                    );
487                    let spans = &cached.spans;
488                    let concealed = &cached.concealed;
489
490                    // Pass original spans: structural tags on concealed bytes would
491                    // otherwise be stripped and invisible to layout.
492                    let metrics = LineMetrics::for_line(
493                        line_text,
494                        &concealed.display_text,
495                        spans,
496                        config.font_size,
497                        config.line_height,
498                    );
499
500                    let selection_line_range = if let Some(sel) = selection {
501                        let sel_range = sel.byte_range();
502                        if sel_range.end > line_start_byte && sel_range.start < line_end_byte {
503                            let raw_start = sel_range
504                                .start
505                                .saturating_sub(line_start_byte)
506                                .min(line_text.len());
507                            let raw_end = (sel_range.end - line_start_byte).min(line_text.len());
508                            let sel_start = concealed.source_to_display(raw_start);
509                            let sel_end = concealed.source_to_display(raw_end);
510                            if sel_end > sel_start {
511                                Some((sel_start, sel_end))
512                            } else {
513                                None
514                            }
515                        } else {
516                            None
517                        }
518                    } else {
519                        None
520                    };
521
522                    let has_task = metrics.task_state.is_some();
523                    let is_concealed_task =
524                        has_task && line_text.len() != concealed.display_text.len();
525                    let is_checked_task = is_concealed_task && metrics.task_state == Some(true);
526
527                    let (task_checkbox_quad, line_text_origin_x) = if is_concealed_task {
528                        let checked = metrics.task_state.unwrap();
529                        let indent = line_text.len() - line_text.trim_start().len();
530                        let box_size = px(15.0);
531                        let box_x = text_origin_x + px((indent as f32) * 8.0);
532                        let box_y = current_y + (metrics.line_height - box_size) / 2.0;
533
534                        if checked {
535                            let quad = fill(
536                                Bounds::new(point(box_x, box_y), size(box_size, box_size)),
537                                theme.syntax.function,
538                            )
539                            .corner_radii(px(3.5))
540                            .border_widths(px(1.5))
541                            .border_color(theme.syntax.function);
542
543                            (Some(quad), box_x + px(24.0))
544                        } else {
545                            let quad = fill(
546                                Bounds::new(point(box_x, box_y), size(box_size, box_size)),
547                                gpui::hsla(0.65, 0.4, 0.6, 0.1),
548                            )
549                            .corner_radii(px(3.5))
550                            .border_widths(px(1.5))
551                            .border_color(theme.syntax.comment);
552                            (Some(quad), box_x + px(24.0))
553                        }
554                    } else {
555                        (None, text_origin_x)
556                    };
557
558                    let fonts = RunFonts {
559                        base: &font,
560                        code: &code_font,
561                    };
562                    let runs = build_line_text_runs(
563                        &concealed.display_text,
564                        &concealed.spans,
565                        selection_line_range,
566                        &fonts,
567                        &theme,
568                        metrics.is_code_block,
569                        is_checked_task,
570                    );
571
572                    let text_line = window
573                        .text_system()
574                        .shape_text(
575                            concealed.display_text.clone().into(),
576                            metrics.font_size,
577                            &runs,
578                            wrap_width.filter(|_| cached.allow_wrap),
579                            None,
580                        )
581                        .ok()
582                        .and_then(|mut l| l.pop())
583                        .unwrap_or_default();
584
585                    let line_visual_lines = text_line.wrap_boundaries.len() + 1;
586                    let line_total_height = metrics.line_height * line_visual_lines;
587
588                    #[allow(unused_mut)]
589                    let mut visible_links = Vec::new();
590                    // Link ranges come from the same cached input: no second parse.
591                    for (src_range, url) in &cached.link_src {
592                        let disp_start = concealed.source_to_display(src_range.start);
593                        let disp_end = concealed.source_to_display(src_range.end);
594                        if disp_start < disp_end {
595                            let start_pt =
596                                text_line.position_for_index(disp_start, metrics.line_height);
597                            let end_pt =
598                                text_line.position_for_index(disp_end, metrics.line_height);
599                            if let (Some(s), Some(e)) = (start_pt, end_pt) {
600                                let width = if e.x > s.x { e.x - s.x } else { px(20.0) };
601                                visible_links.push(crate::editor::VisibleLink {
602                                    bounds: Bounds::new(
603                                        point(line_text_origin_x + s.x, current_y + s.y),
604                                        size(width.max(px(5.0)), metrics.line_height),
605                                    ),
606                                    url: url.clone(),
607                                });
608                            }
609                        }
610                    }
611
612                    let mut search_match_quads = Vec::new();
613                    for sub in wash_ranges_for_line(
614                        &search_wash,
615                        &mut wash_cursor,
616                        line_start_byte,
617                        line_end_byte,
618                    ) {
619                        let raw_start = sub
620                            .start
621                            .saturating_sub(line_start_byte)
622                            .min(line_text.len());
623                        let raw_end = sub.end.saturating_sub(line_start_byte).min(line_text.len());
624                        let disp_start = concealed.source_to_display(raw_start);
625                        let disp_end = concealed.source_to_display(raw_end);
626                        if disp_end <= disp_start {
627                            continue;
628                        }
629                        let (Some(s), Some(e)) = (
630                            text_line.position_for_index(disp_start, metrics.line_height),
631                            text_line.position_for_index(disp_end, metrics.line_height),
632                        ) else {
633                            continue;
634                        };
635                        // Same visual line: exact wash. Wrapped matches fall
636                        // back to the remainder of the first visual line.
637                        let (x, width) = if s.y == e.y && e.x > s.x {
638                            (s.x, e.x - s.x)
639                        } else {
640                            let edge =
641                                (bounds.right() - px(12.0)).max(line_text_origin_x + s.x + px(4.0));
642                            (s.x, edge - (line_text_origin_x + s.x))
643                        };
644                        search_match_quads.push(fill(
645                            Bounds::new(
646                                point(line_text_origin_x + x, current_y + s.y),
647                                size(width.max(px(4.0)), metrics.line_height),
648                            ),
649                            theme.search_match,
650                        ));
651                    }
652
653                    let quote_bar_quad = if metrics.is_quote {
654                        Some(fill(
655                            Bounds::new(
656                                point(bounds.left() + gutter_width + px(4.0), current_y),
657                                size(px(3.0), line_total_height),
658                            ),
659                            theme.syntax.comment,
660                        ))
661                    } else {
662                        None
663                    };
664
665                    let code_block_bg_quad = if metrics.is_code_block {
666                        let bg_width = (bounds.size.width - gutter_width - px(8.0)).max(px(0.0));
667                        Some(fill(
668                            Bounds::new(
669                                point(bounds.left() + gutter_width + px(4.0), current_y),
670                                size(bg_width, line_total_height),
671                            ),
672                            theme.syntax.code_bg,
673                        ))
674                    } else {
675                        None
676                    };
677
678                    let thematic_break_quad = if metrics.is_thematic_break {
679                        let width = (bounds.size.width - gutter_width - px(24.0)).max(px(0.0));
680                        let line_y = current_y + metrics.line_height / 2.0;
681                        Some(fill(
682                            Bounds::new(point(text_origin_x, line_y), size(width, px(1.0))),
683                            theme.syntax.punctuation,
684                        ))
685                    } else {
686                        None
687                    };
688
689                    let cursor_quad = if cursor_point.row == row {
690                        let col_in_line = cursor_offset
691                            .saturating_sub(line_start_byte)
692                            .min(line_text.len());
693                        let col_in_display = concealed.source_to_display(col_in_line);
694                        let pos = text_line
695                            .position_for_index(col_in_display, metrics.line_height)
696                            .unwrap_or(point(px(0.0), px(0.0)));
697
698                        computed_cursor_pixel = Some(point(
699                            text_origin_x + pos.x,
700                            current_y + pos.y + metrics.line_height,
701                        ));
702
703                        let should_draw_cursor =
704                            !is_all_selected && (!config.cursor_blink || editor.cursor_visible);
705
706                        if should_draw_cursor {
707                            let style = if config.block_cursor {
708                                twrite_core::CursorStyle::Block
709                            } else {
710                                editor.cursor_style
711                            };
712
713                            match style {
714                                twrite_core::CursorStyle::Hidden => None,
715                                twrite_core::CursorStyle::Block => Some(fill(
716                                    Bounds::new(
717                                        point(text_origin_x + pos.x, current_y + pos.y),
718                                        size(px(8.5), metrics.line_height),
719                                    ),
720                                    theme.cursor,
721                                )),
722                                twrite_core::CursorStyle::Underline => Some(fill(
723                                    Bounds::new(
724                                        point(
725                                            text_origin_x + pos.x,
726                                            current_y + pos.y + metrics.line_height - px(2.0),
727                                        ),
728                                        size(px(8.5), px(2.0)),
729                                    ),
730                                    theme.cursor,
731                                )),
732                                twrite_core::CursorStyle::Bar => Some(fill(
733                                    Bounds::new(
734                                        point(text_origin_x + pos.x, current_y + pos.y),
735                                        size(px(2.0), metrics.line_height),
736                                    ),
737                                    theme.cursor,
738                                )),
739                            }
740                        } else {
741                            None
742                        }
743                    } else {
744                        None
745                    };
746
747                    let empty_selection_quad = if line_text.is_empty() {
748                        if let Some(sel) = selection {
749                            let sel_range = sel.byte_range();
750                            if sel_range.start <= line_start_byte && sel_range.end > line_start_byte
751                            {
752                                Some(fill(
753                                    Bounds::new(
754                                        point(text_origin_x, current_y),
755                                        size(px(6.0), metrics.line_height),
756                                    ),
757                                    theme.selection,
758                                ))
759                            } else {
760                                None
761                            }
762                        } else {
763                            None
764                        }
765                    } else {
766                        None
767                    };
768
769                    lines.push(PreparedLine {
770                        gutter_num,
771                        text_origin: point(line_text_origin_x, current_y),
772                        text_line,
773                        line_height: metrics.line_height,
774                        quote_bar_quad,
775                        code_block_bg_quad,
776                        thematic_break_quad,
777                        empty_selection_quad,
778                        cursor_quad,
779                        task_checkbox_quad,
780                        search_match_quads,
781                    });
782
783                    let checkbox_box_x = if has_task {
784                        let indent = line_text.len() - line_text.trim_start().len();
785                        text_origin_x + px((indent as f32) * 8.0)
786                    } else {
787                        px(0.0)
788                    };
789
790                    visible_line_layouts.push(crate::editor::VisibleLineLayout {
791                        row,
792                        top: current_y,
793                        bottom: current_y + line_total_height,
794                        line_start_byte,
795                        line_len_bytes: line_text.len(),
796                        text_origin_x: line_text_origin_x,
797                        line_height: metrics.line_height,
798                        is_task_checkbox: has_task,
799                        checkbox_box_x,
800                        task_state: metrics.task_state,
801                        links: visible_links,
802                    });
803
804                    current_y += line_total_height;
805                }
806
807                editor_handle.update(cx, |editor, _| {
808                    editor.layout_cache = layout_cache;
809                    editor.last_cursor_pixel = computed_cursor_pixel;
810                    editor.visible_lines = visible_line_layouts;
811                });
812
813                EditorCanvasPrepaint {
814                    background_quad: fill(bounds, theme.background),
815                    lines,
816                }
817            },
818            move |_bounds, prepaint, window, cx| {
819                window.paint_quad(prepaint.background_quad);
820
821                for line in prepaint.lines {
822                    let is_break = line.thematic_break_quad.is_some();
823
824                    if let Some(code_bg) = line.code_block_bg_quad {
825                        window.paint_quad(code_bg);
826                    }
827                    if let Some(quote_bar) = line.quote_bar_quad {
828                        window.paint_quad(quote_bar);
829                    }
830                    if let Some(thematic_break) = line.thematic_break_quad {
831                        window.paint_quad(thematic_break);
832                    }
833                    if let Some(cb_quad) = line.task_checkbox_quad {
834                        window.paint_quad(cb_quad);
835                    }
836
837                    if let Some((origin, shaped_num)) = line.gutter_num {
838                        // gpui 0.2.2 bakes TextAlign::Left into ShapedLine::paint.
839                        let _ = shaped_num.paint(origin, line.line_height, window, cx);
840                    }
841
842                    if !is_break {
843                        let _ = line.text_line.paint_background(
844                            line.text_origin,
845                            line.line_height,
846                            TextAlign::Left,
847                            None,
848                            window,
849                            cx,
850                        );
851                    }
852
853                    for quad in line.search_match_quads {
854                        window.paint_quad(quad);
855                    }
856
857                    if let Some(empty_sel) = line.empty_selection_quad {
858                        window.paint_quad(empty_sel);
859                    }
860
861                    if let Some(cursor_quad) = line.cursor_quad {
862                        window.paint_quad(cursor_quad);
863                    }
864
865                    if !is_break {
866                        let _ = line.text_line.paint(
867                            line.text_origin,
868                            line.line_height,
869                            TextAlign::Left,
870                            None,
871                            window,
872                            cx,
873                        );
874                    }
875                }
876            },
877        )
878        .size_full()
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::wash_ranges_for_line;
885    use std::ops::Range;
886
887    fn matches_vec(ranges: &[Range<usize>]) -> Vec<Range<usize>> {
888        ranges.to_vec()
889    }
890
891    #[test]
892    fn wash_ranges_clip_and_advance_linearly() {
893        let matches = matches_vec(&[0..3, 8..11, 20..25]);
894        let mut cursor = 0;
895
896        // Line 0..7 overlaps only the first match, clamped.
897        assert_eq!(
898            wash_ranges_for_line(&matches, &mut cursor, 0, 7),
899            vec![0..3]
900        );
901        // Next line skips the consumed range without rescanning.
902        assert_eq!(
903            wash_ranges_for_line(&matches, &mut cursor, 7, 15),
904            vec![8..11]
905        );
906        assert_eq!(cursor, 1);
907        // No overlap yields nothing but keeps the cursor.
908        assert!(wash_ranges_for_line(&matches, &mut cursor, 12, 18).is_empty());
909        assert_eq!(
910            wash_ranges_for_line(&matches, &mut cursor, 18, 30),
911            vec![20..25]
912        );
913        // Past the end stays empty.
914        assert!(wash_ranges_for_line(&matches, &mut cursor, 30, 40).is_empty());
915    }
916
917    #[test]
918    fn wash_ranges_split_cross_line_matches() {
919        let matches = matches_vec(&[5..15, 30..35]);
920        let mut cursor = 0;
921        assert_eq!(
922            wash_ranges_for_line(&matches, &mut cursor, 0, 10),
923            vec![5..10]
924        );
925        assert_eq!(
926            wash_ranges_for_line(&matches, &mut cursor, 10, 20),
927            vec![10..15]
928        );
929        assert_eq!(
930            wash_ranges_for_line(&matches, &mut cursor, 20, 40),
931            vec![30..35]
932        );
933    }
934}