Skip to main content

gpui_kit/content/
code_view.rs

1//! Read-only code, with line numbers and marked lines.
2//!
3//! # No grammar, and no new dependency
4//!
5//! Deciding that a word is a keyword needs a grammar, which is the same kind
6//! of fact the calendar is: answered correctly only by the library the
7//! application already depends on. `docs/coverage.md` records syntax
8//! highlighting as out of scope, and this view keeps that line — it takes
9//! **pre-classified spans** from the caller, exactly as `Markdown` colours a
10//! fenced block.
11//!
12//! What the caller owes, per line:
13//!
14//! 1. the line's own text, with no newline in it;
15//! 2. [`CodeSpan`] ranges in byte offsets into *that line's* text, not into
16//!    the whole document, on character boundaries;
17//! 3. ranges sorted ascending and not overlapping.
18//!
19//! A span that breaks any of those is skipped rather than drawn wrongly: the
20//! line stays readable without its colour, and colour that landed in the wrong
21//! place would be a lie about the code. This is the same boundary
22//! [`crate::display::highlight`] states for search hits, and it is deliberately
23//! the same one.
24//!
25//! # Long lines scroll, they do not wrap
26//!
27//! In prose a wrap is invisible; in code a column carries meaning. Indentation
28//! is structure, an aligned comment is a column, and a diagnostic that says
29//! "column 34" is pointing at a place a wrapped line no longer has. Wrapping
30//! also breaks the one thing the gutter claims — that line 41 is one row — so
31//! a marked line would stop lining up with its mark. So a long line runs off
32//! the edge and the view scrolls to it.
33//!
34//! # Size, and what virtualization costs here
35//!
36//! With no [`CodeView::visible_lines`] every line is laid out inside a
37//! [`ScrollArea`] that scrolls both ways, which is
38//! the mode that keeps the horizontal scroll above.
39//!
40//! With `visible_lines` the body becomes the virtualized [`List`], which draws
41//! only the rows the viewport holds — and then the horizontal scroll goes,
42//! for the reason `DataGrid` already states: `uniform_list` owns its own
43//! scroll offset and lays every row out at the width it is given. A long line
44//! is clipped at the frame in that mode. The two are named rather than
45//! blended, because a view that silently dropped the right-hand half of a line
46//! would be worse than one that says which mode it is in.
47//!
48//! # Copying
49//!
50//! GPUI offers no text-selection primitive, which `docs/coverage.md` records
51//! as a library-wide gap; nothing rendered here can be selected with a
52//! pointer. So the view carries a control that copies the whole text, the way
53//! a `Markdown` code block does, and does not pretend a caret exists.
54
55use gpui::{
56    AnyElement, App, ClipboardItem, InteractiveElement, IntoElement, ParentElement, RenderOnce,
57    SharedString, Styled, Window, div, prelude::FluentBuilder, px,
58};
59use gpui_kit_semantics::{NodeSpec, Role, Semantic};
60use gpui_kit_theme::{
61    ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, Theme, TypeScale,
62};
63
64use crate::content::markdown::CodeSpan;
65use crate::controls::button::Button;
66use crate::data::{List, ListItem};
67use crate::display::empty::{EmptyKind, EmptyState};
68use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
69use crate::layout::{ScrollArea, ScrollAxis};
70use crate::strings::{ActiveStrings, StringKey};
71
72/// How wide the gutter is per digit, and how far a line's text sits from it.
73/// Both occur once, so they stay next to the component.
74const DIGIT_WIDTH: f32 = 8.0;
75const GUTTER_GAP: f32 = 12.0;
76
77/// What a line is being called out for.
78///
79/// These are the host's claims about the code, not judgements this view makes:
80/// nothing here diffs anything or finds an error.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum LineMark {
83    Added,
84    Removed,
85    Changed,
86    /// Called out for attention without saying anything about correctness.
87    Highlighted,
88    Error,
89}
90
91impl LineMark {
92    /// The name the semantic tree publishes, so a test tells the five apart
93    /// without reading a colour.
94    pub fn name(self) -> &'static str {
95        match self {
96            Self::Added => "added",
97            Self::Removed => "removed",
98            Self::Changed => "changed",
99            Self::Highlighted => "highlighted",
100            Self::Error => "error",
101        }
102    }
103
104    fn key(self) -> StringKey {
105        match self {
106            Self::Added => StringKey::CodeLineAdded,
107            Self::Removed => StringKey::CodeLineRemoved,
108            Self::Changed => StringKey::CodeLineChanged,
109            Self::Highlighted => StringKey::CodeLineHighlighted,
110            Self::Error => StringKey::CodeLineError,
111        }
112    }
113
114    /// The rail colour and the wash behind the row.
115    fn colors(self, theme: &Theme) -> (gpui::Hsla, gpui::Hsla) {
116        let tint = match self {
117            Self::Added => theme.colors.success,
118            Self::Removed => theme.colors.danger,
119            Self::Changed => theme.colors.warning,
120            Self::Highlighted => theme.colors.accent,
121            Self::Error => theme.colors.danger,
122        };
123        (tint, tint.opacity(theme.effects.selected_ring_alpha))
124    }
125
126    /// A removed line is struck through as well as tinted, because a colour
127    /// alone is not a difference anyone reading in monochrome can see.
128    fn struck(self) -> bool {
129        matches!(self, Self::Removed)
130    }
131}
132
133/// One line of code, identified by its number.
134///
135/// A line number is a line's business identity in a file: it survives the view
136/// scrolling, and it is what a diagnostic and a review comment already point
137/// at. It is not the row's position in whatever slice the caller passed, which
138/// is why a view of lines 400 to 450 numbers them 400 to 450.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct CodeLine {
141    pub number: usize,
142    pub text: SharedString,
143    pub spans: Vec<CodeSpan>,
144    pub mark: Option<LineMark>,
145}
146
147impl CodeLine {
148    pub fn new(number: usize, text: impl Into<SharedString>) -> Self {
149        Self {
150            number,
151            text: text.into(),
152            spans: Vec::new(),
153            mark: None,
154        }
155    }
156
157    /// Pre-classified runs, in byte offsets into this line's own text.
158    pub fn spans(mut self, spans: impl IntoIterator<Item = CodeSpan>) -> Self {
159        self.spans = spans.into_iter().collect();
160        self
161    }
162
163    pub fn mark(mut self, mark: LineMark) -> Self {
164        self.mark = Some(mark);
165        self
166    }
167}
168
169/// Read-only code with a gutter.
170#[derive(IntoElement)]
171pub struct CodeView {
172    ident: Ident,
173    lines: Vec<CodeLine>,
174    /// The fence's info string equivalent, shown exactly as written. Nothing
175    /// here parses it.
176    language: Option<SharedString>,
177    line_numbers: bool,
178    visible_lines: Option<usize>,
179    copyable: bool,
180}
181
182impl std::fmt::Debug for CodeView {
183    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        formatter
185            .debug_struct("CodeView")
186            .field("ident", &self.ident)
187            .field("lines", &self.lines.len())
188            .field("language", &self.language)
189            .field("visible_lines", &self.visible_lines)
190            .finish()
191    }
192}
193
194impl CodeView {
195    pub fn new(ident: impl Into<Ident>, lines: impl IntoIterator<Item = CodeLine>) -> Self {
196        Self {
197            ident: ident.into(),
198            lines: lines.into_iter().collect(),
199            language: None,
200            line_numbers: true,
201            visible_lines: None,
202            copyable: true,
203        }
204    }
205
206    /// Splits plain text into numbered lines starting at 1.
207    ///
208    /// A convenience over [`CodeView::new`] and nothing more: it classifies
209    /// nothing and marks nothing.
210    pub fn from_text(ident: impl Into<Ident>, text: &str) -> Self {
211        Self::new(
212            ident,
213            text.lines()
214                .enumerate()
215                .map(|(index, line)| CodeLine::new(index + 1, line.to_string())),
216        )
217    }
218
219    /// The language's name, shown as written. Nothing here reads it.
220    pub fn language(mut self, language: impl Into<SharedString>) -> Self {
221        self.language = Some(language.into());
222        self
223    }
224
225    pub fn line_numbers(mut self, line_numbers: bool) -> Self {
226        self.line_numbers = line_numbers;
227        self
228    }
229
230    /// Bounds the viewport to `lines` rows and virtualizes the body.
231    ///
232    /// Horizontal scrolling goes with it; see the module documentation.
233    pub fn visible_lines(mut self, lines: usize) -> Self {
234        self.visible_lines = Some(lines);
235        self
236    }
237
238    /// Whether the view carries a control that copies the whole text.
239    pub fn copyable(mut self, copyable: bool) -> Self {
240        self.copyable = copyable;
241        self
242    }
243
244    /// The text a copy would put on the clipboard: every line, in order.
245    pub fn text(&self) -> String {
246        self.lines
247            .iter()
248            .map(|line| line.text.as_ref())
249            .collect::<Vec<_>>()
250            .join("\n")
251    }
252
253    fn gutter_width(&self) -> f32 {
254        let widest = self
255            .lines
256            .iter()
257            .map(|line| line.number)
258            .max()
259            .unwrap_or(1)
260            .max(1)
261            .to_string()
262            .len();
263        widest as f32 * DIGIT_WIDTH + GUTTER_GAP
264    }
265}
266
267impl Sizable for CodeView {
268    /// Accepted for uniformity with every other component; the code itself is
269    /// set from the `code` typographic step whatever this says, because a code
270    /// listing that changed size with a button would stop being comparable
271    /// with the one beside it.
272    fn control_size(self, _size: ControlSize) -> Self {
273        self
274    }
275}
276
277/// One rendered line: the gutter number, the rail, and the coloured runs.
278fn line_element(
279    ident: &Ident,
280    line: &CodeLine,
281    gutter: f32,
282    line_numbers: bool,
283    theme: &Theme,
284    cx: &App,
285) -> AnyElement {
286    let (rail, wash) = line
287        .mark
288        .map(|mark| mark.colors(theme))
289        .unzip_or(theme.colors.hairline, gpui::transparent_black());
290    let struck = line.mark.is_some_and(LineMark::struck);
291
292    let row = div()
293        .row()
294        .items_start()
295        .w_full()
296        .h(px(theme.typography.code.line_height))
297        .when(line.mark.is_some(), |element| element.bg(wash))
298        .when(line_numbers, |element| {
299            element.child(
300                div()
301                    .flex_none()
302                    .w(px(gutter))
303                    .pr(px(GUTTER_GAP / 2.0))
304                    .text_align(gpui::TextAlign::Right)
305                    .text_color(theme.colors.text_faint)
306                    .child(SharedString::from(line.number.to_string())),
307            )
308        })
309        // The rail is what a reader in monochrome sees, and it is drawn even
310        // when the gutter is off, because the mark is the fact.
311        .child(
312            div()
313                .flex_none()
314                .w(px(theme.borders.thick))
315                .h_full()
316                .when(line.mark.is_some(), |element| element.bg(rail)),
317        )
318        .child(
319            // The runs sit in a row. Without one they are laid out as blocks
320            // and every run paints from the same left edge, one word over the
321            // next, which is what a line with more than one span looked like.
322            div()
323                .row()
324                .items_baseline()
325                .flex_1()
326                .min_w_0()
327                .whitespace_nowrap()
328                .pl(px(GUTTER_GAP / 2.0))
329                .when(struck, |element| element.line_through())
330                .children(code_runs(theme, line.text.as_ref(), &line.spans)),
331        );
332
333    match line.mark {
334        // Only a marked line is an assertion target. A thousand unmarked lines
335        // would bury every other node under rows that repeat their own text.
336        Some(mark) => row
337            .semantic_in(
338                cx,
339                NodeSpec::new(line_id(ident, line.number), Role::Row)
340                    .parent(ident.semantic_id())
341                    // The mark's wording, not the line's source: what the view
342                    // claims about the line is the fact it adds, and the code
343                    // itself is content nobody here wrote.
344                    .text(cx.strings().text(mark.key()))
345                    .value(mark.name())
346                    .invalid(matches!(mark, LineMark::Error)),
347            )
348            .into_any_element(),
349        None => row.into_any_element(),
350    }
351}
352
353/// One line's stable id.
354///
355/// The number is prefixed rather than trailing on its own, because an id whose
356/// last segment is a bare number reads as a list position, and the audit that
357/// catches that mistake elsewhere is worth more than the two characters.
358fn line_id(ident: &Ident, number: usize) -> SharedString {
359    ident.child(format!("line-{number}")).semantic_id()
360}
361
362/// The coloured runs of one line, skipping any span that does not name a slice
363/// this line actually holds.
364///
365/// Each run holds its own width. Inside a row that lays its children out in
366/// one direction, a run that is allowed to shrink is shrunk to nothing, and
367/// every run then paints from the same left edge with one word on top of the
368/// next.
369pub(crate) fn code_runs(theme: &Theme, text: &str, spans: &[CodeSpan]) -> Vec<AnyElement> {
370    let mut out: Vec<AnyElement> = Vec::new();
371    let mut cut = 0usize;
372    for span in spans {
373        if span.range.start < cut || span.range.start >= span.range.end {
374            continue;
375        }
376        let (Some(before), Some(inside)) = (
377            text.get(cut..span.range.start),
378            text.get(span.range.start..span.range.end),
379        ) else {
380            continue;
381        };
382        if !before.is_empty() {
383            out.push(
384                div()
385                    .flex_none()
386                    .child(SharedString::from(before.to_string()))
387                    .into_any_element(),
388            );
389        }
390        out.push(
391            div()
392                .flex_none()
393                .text_color(span.tone.color(theme))
394                .child(SharedString::from(inside.to_string()))
395                .into_any_element(),
396        );
397        cut = span.range.end;
398    }
399    if let Some(rest) = text.get(cut..)
400        && !rest.is_empty()
401    {
402        out.push(
403            div()
404                .flex_none()
405                .child(SharedString::from(rest.to_string()))
406                .into_any_element(),
407        );
408    }
409    out
410}
411
412/// Splits an optional pair into two values with defaults, so a marked and an
413/// unmarked line take the same code path.
414trait UnzipOr<A, B> {
415    fn unzip_or(self, first: A, second: B) -> (A, B);
416}
417
418impl<A, B> UnzipOr<A, B> for Option<(A, B)> {
419    fn unzip_or(self, first: A, second: B) -> (A, B) {
420        self.unwrap_or((first, second))
421    }
422}
423
424impl RenderOnce for CodeView {
425    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
426        let theme = cx.theme().clone();
427        let gutter = self.gutter_width();
428        let line_numbers = self.line_numbers;
429        let total = self.lines.len();
430        let body_ident = self.ident.child("lines");
431
432        let copy = self.copyable.then(|| {
433            let clipboard = self.text();
434            Button::new(self.ident.child("copy"))
435                .label(cx.strings().text(StringKey::Copy))
436                .ghost()
437                .control_size(ControlSize::Xs)
438                .semantic_parent(self.ident.semantic_id())
439                .disabled(clipboard.is_empty())
440                .on_click(move |_, cx| {
441                    cx.write_to_clipboard(ClipboardItem::new_string(clipboard.clone()));
442                })
443        });
444
445        let body: AnyElement = if total == 0 {
446            EmptyState::new(
447                self.ident.child("empty"),
448                cx.strings().text(StringKey::CodeEmpty),
449            )
450            .kind(EmptyKind::Empty)
451            .into_any_element()
452        } else if let Some(visible) = self.visible_lines {
453            let lines = std::rc::Rc::new(self.lines);
454            let list_ident = body_ident.clone();
455            let theme_for_rows = theme.clone();
456            List::new(body_ident.clone(), total, move |index, _window, cx| {
457                let line = &lines[index];
458                ListItem::new(
459                    line_id(&list_ident, line.number),
460                    line_element(&list_ident, line, gutter, line_numbers, &theme_for_rows, cx),
461                )
462            })
463            .row_height(theme.typography.code.line_height)
464            .visible_lines(visible)
465            .into_any_element()
466        } else {
467            // Every line is laid out, so the block is as tall as its code. A
468            // scroll area that filled the height it was offered was offered
469            // none by a column that states no height, and the body collapsed
470            // to an empty strip. Only the horizontal scroll does work here.
471            ScrollArea::new(body_ident.clone())
472                .axis(ScrollAxis::Both)
473                .fit_height()
474                .child(
475                    div().column().children(
476                        self.lines
477                            .iter()
478                            .map(|line| {
479                                line_element(&body_ident, line, gutter, line_numbers, &theme, cx)
480                            })
481                            .collect::<Vec<_>>(),
482                    ),
483                )
484                .into_any_element()
485        };
486
487        div()
488            .id(self.ident.element_id())
489            .column()
490            .w_full()
491            .gap_token(&theme, Space::Xs)
492            .p_token(&theme, Space::Sm)
493            .radius(&theme, Radius::Card)
494            .frame(&theme, Surface::Raised, Elevation::Raised)
495            .when(self.language.is_some() || copy.is_some(), |element| {
496                element.child(
497                    div()
498                        .row()
499                        .w_full()
500                        .justify_between()
501                        .type_scale(&theme, TypeScale::Caption)
502                        .text_color(theme.colors.text_faint)
503                        .child(div().child(self.language.clone().unwrap_or_default()))
504                        .children(copy),
505                )
506            })
507            .child(
508                div()
509                    .w_full()
510                    .font_family(theme.typography.mono.clone())
511                    .text_size(px(theme.typography.code.size))
512                    .line_height(px(theme.typography.code.line_height))
513                    .text_color(theme.colors.text)
514                    .child(body),
515            )
516            .semantic_in(
517                cx,
518                NodeSpec::new(self.ident.semantic_id(), Role::Region)
519                    .when_language(self.language)
520                    // A container publishes how much it holds, which here is
521                    // the number of lines it was handed — not the number drawn.
522                    .value(total.to_string()),
523            )
524    }
525}
526
527/// Adds the language to a spec only when the caller supplied one.
528trait LanguageSpec {
529    fn when_language(self, language: Option<SharedString>) -> Self;
530}
531
532impl LanguageSpec for NodeSpec {
533    fn when_language(self, language: Option<SharedString>) -> Self {
534        match language {
535            Some(language) => self.text(language),
536            None => self,
537        }
538    }
539}
540
541/// Adds the `visible_lines` bound to a `List`, named for this view.
542trait VisibleLines {
543    fn visible_lines(self, lines: usize) -> Self;
544}
545
546impl VisibleLines for List {
547    fn visible_lines(self, lines: usize) -> Self {
548        self.visible_rows(lines)
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::display::badge::Tone;
556
557    #[test]
558    fn every_mark_publishes_a_name_of_its_own() {
559        let names = [
560            LineMark::Added,
561            LineMark::Removed,
562            LineMark::Changed,
563            LineMark::Highlighted,
564            LineMark::Error,
565        ]
566        .map(LineMark::name);
567        let mut sorted = names.to_vec();
568        sorted.sort_unstable();
569        sorted.dedup();
570        assert_eq!(sorted.len(), names.len());
571    }
572
573    #[test]
574    fn a_span_naming_no_slice_of_the_line_is_skipped() {
575        let theme = Theme::studio_dark();
576        let line = CodeLine::new(1, "let x = 1;").spans([CodeSpan {
577            range: 40..50,
578            tone: Tone::Accent,
579        }]);
580        // One run, the whole line, because the span named nothing real.
581        assert_eq!(code_runs(&theme, line.text.as_ref(), &line.spans).len(), 1);
582    }
583
584    #[test]
585    fn a_view_keeps_the_numbers_it_was_given() {
586        let view = CodeView::new(
587            "review.hunk",
588            [CodeLine::new(400, "a"), CodeLine::new(401, "b")],
589        );
590        assert_eq!(view.lines[0].number, 400);
591        assert_eq!(view.text(), "a\nb");
592    }
593
594    #[test]
595    fn splitting_text_numbers_from_one() {
596        let view = CodeView::from_text("file", "first\nsecond\nthird");
597        assert_eq!(view.lines.len(), 3);
598        assert_eq!(view.lines[2].number, 3);
599    }
600}