Skip to main content

kitchen_ui/widgets/
code_view.rs

1//! The code view: gpui-kit's code editor (tree-sitter highlighting, line numbers, current line,
2//! selection and copy), read-only or editable, with errors marked on their lines.
3
4use gpui_kit::component::highlighter::{Diagnostic, DiagnosticSeverity};
5use gpui_kit::component::input::{Editor, EditorState, GutterDecoration, GutterDecorationCollection, InputEvent, Position};
6use gpui_kit::assets::IconName;
7use gpui_kit::component::{ActiveTheme as _, Icon, h_flex, v_flex};
8use gpui_kit::prelude::FluentBuilder as _;
9use gpui_kit::{
10    AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, SharedString,
11    StatefulInteractiveElement as _, Styled as _, Subscription, TestSupportExt as _, Window, div, px,
12};
13
14use crate::theme::{self, color};
15
16/// The languages a [`CodeView`] highlights.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18pub enum CodeLanguage {
19    /// Rust.
20    Rust,
21    /// Python.
22    Python,
23    /// JSON.
24    Json,
25    /// No highlighting.
26    #[default]
27    Plain,
28}
29
30impl CodeLanguage {
31    /// The grammar name gpui-kit knows it by.
32    pub fn name(self) -> &'static str {
33        match self {
34            CodeLanguage::Rust => "rust",
35            CodeLanguage::Python => "python",
36            CodeLanguage::Json => "json",
37            CodeLanguage::Plain => "text",
38        }
39    }
40
41    /// The language of a name or file extension (`"rs"`, `"py"`, `"json"`, …); anything else is
42    /// [`CodeLanguage::Plain`].
43    pub fn from_name(name: &str) -> Self {
44        match name.trim().to_ascii_lowercase().as_str() {
45            "rust" | "rs" => CodeLanguage::Rust,
46            "python" | "py" | "pyi" => CodeLanguage::Python,
47            "json" | "jsonc" => CodeLanguage::Json,
48            _ => CodeLanguage::Plain,
49        }
50    }
51}
52
53/// What an editable [`CodeView`] reports when the user changes the text: the whole text.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct CodeChanged(pub String);
56
57/// Code in a monospace editor: line numbers in a gutter, syntax highlighting (Rust, Python, JSON),
58/// the current line highlighted, selection and copy (read-only too). Errors ([`CodeView::errors`],
59/// 1-based line numbers as compilers report them) get a red mark in the gutter and a red
60/// underline on their line, their message on hover, and are listed under the code: each entry
61/// moves the cursor to its line.
62///
63/// Wraps gpui-kit's `EditorState` (its tree-sitter grammars), so the look follows the theme
64/// ([`theme::apply`] sets the code colours). An editable view emits [`CodeChanged`].
65///
66/// ```ignore
67/// let code = cx.new(|cx| CodeView::new(CodeLanguage::Rust, source, window, cx).errors(vec![(6, "mismatched types".into())]).editable(true));
68/// cx.subscribe(&code, |this, _, CodeChanged(text), cx| { /* … */ }).detach();
69/// ```
70pub struct CodeView {
71    editor: Entity<EditorState>,
72    language: CodeLanguage,
73    errors: Vec<(usize, String)>,
74    editable: bool,
75    max_lines: Option<usize>,
76    marks: GutterDecorationCollection,
77    /// Errors or editability set by a builder, handed to the editor on the next draw.
78    pending: bool,
79    _subs: Vec<Subscription>,
80}
81
82impl EventEmitter<CodeChanged> for CodeView {}
83
84impl CodeView {
85    /// A read-only view of `text` highlighted as `language`.
86    pub fn new(language: CodeLanguage, text: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
87        let text: SharedString = text.into();
88        let editor = cx.new(|cx| {
89            let mut state = EditorState::new(window, cx).language(language.name()).default_value(text).gutter_decorations_width(px(4.));
90            state.set_readonly(true, cx);
91            state
92        });
93        let marks = editor.update(cx, |state, cx| state.create_gutter_decorations_collection(Vec::new(), cx));
94        let sub = cx.subscribe(&editor, |this: &mut Self, editor, e: &InputEvent, cx| {
95            if matches!(e, InputEvent::Change) && this.editable {
96                let text = editor.read(cx).value().to_string();
97                // Marks follow the lines they were on; the owner sends new errors when it has them
98                cx.emit(CodeChanged(text));
99            }
100        });
101        Self { editor, language, errors: Vec::new(), editable: false, max_lines: None, marks, pending: false, _subs: vec![sub] }
102    }
103
104    /// Marks these errors: `(line, message)`, lines 1-based.
105    pub fn errors(mut self, errors: Vec<(usize, String)>) -> Self {
106        self.errors = errors;
107        self.pending = true;
108        self
109    }
110
111    /// Editable (`true`) or read-only (the default).
112    pub fn editable(mut self, editable: bool) -> Self {
113        self.editable = editable;
114        self.pending = true;
115        self
116    }
117
118    /// As tall as its text up to `lines` lines, then it scrolls (by default it fills the height
119    /// its parent gives it).
120    pub fn max_lines(mut self, lines: usize) -> Self {
121        self.max_lines = Some(lines.max(1));
122        self
123    }
124
125    /// Marks these errors instead of the ones shown: `(line, message)`, lines 1-based.
126    pub fn set_errors(&mut self, errors: Vec<(usize, String)>, cx: &mut Context<Self>) {
127        self.errors = errors;
128        self.apply_errors(cx);
129        cx.notify();
130    }
131
132    /// The errors shown.
133    pub fn error_list(&self) -> &[(usize, String)] {
134        &self.errors
135    }
136
137    /// Makes the view editable or read-only.
138    pub fn set_editable(&mut self, editable: bool, cx: &mut Context<Self>) {
139        self.editable = editable;
140        self.editor.update(cx, |state, cx| state.set_readonly(!editable, cx));
141        cx.notify();
142    }
143
144    /// Whether the user can edit the text.
145    pub fn is_editable(&self) -> bool {
146        self.editable
147    }
148
149    /// Replaces the text (no [`CodeChanged`]); the errors shown are kept on their line numbers.
150    pub fn set_text(&mut self, text: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) {
151        let text: SharedString = text.into();
152        self.editor.update(cx, |state, cx| state.set_value(text, window, cx));
153        self.apply_errors(cx);
154        cx.notify();
155    }
156
157    /// The text.
158    pub fn text(&self, cx: &gpui_kit::App) -> String {
159        self.editor.read(cx).value().to_string()
160    }
161
162    /// Highlights the text as `language`.
163    pub fn set_language(&mut self, language: CodeLanguage, cx: &mut Context<Self>) {
164        self.language = language;
165        self.editor.update(cx, |state, cx| state.set_highlighter(language.name(), cx));
166        cx.notify();
167    }
168
169    /// The language highlighted.
170    pub fn language(&self) -> CodeLanguage {
171        self.language
172    }
173
174    /// The editor underneath (for its focus handle, cursor, search).
175    pub fn editor(&self) -> &Entity<EditorState> {
176        &self.editor
177    }
178
179    /// Moves the cursor to the start of `line` (1-based) and focuses the code.
180    pub fn go_to_line(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
181        let row = line.saturating_sub(1) as u32;
182        self.editor.update(cx, |state, cx| {
183            state.set_cursor_position(Position::new(row, 0), window, cx);
184            state.focus(window, cx);
185        });
186    }
187
188    /// Hands the errors to the editor: diagnostics (underline, hover) and gutter marks.
189    fn apply_errors(&mut self, cx: &mut Context<Self>) {
190        let danger = color::danger();
191        let errors = self.errors.clone();
192        let marks = self.editor.update(cx, |state, cx| {
193            let text = state.value().to_string();
194            let starts: Vec<usize> = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
195            let mut marks = Vec::new();
196            if let Some(set) = state.diagnostics_mut() {
197                set.clear();
198                for (line, message) in &errors {
199                    let Some(&start) = starts.get(line.saturating_sub(1)) else { continue };
200                    let end = text[start..].find('\n').map_or(text.len(), |i| start + i);
201                    let row = line.saturating_sub(1) as u32;
202                    let chars = text[start..end].encode_utf16().count() as u32;
203                    set.push(Diagnostic::new(Position::new(row, 0)..Position::new(row, chars.max(1)), message.clone()).with_severity(DiagnosticSeverity::Error));
204                    // A whole line; an empty line by its newline
205                    let range = if end > start { start..end } else { start..(start + 1).min(text.len()) };
206                    marks.push(GutterDecoration::new(range).with_color(danger));
207                }
208            }
209            cx.notify();
210            marks
211        });
212        self.marks.set(marks, cx);
213    }
214
215    fn height(&self, cx: &gpui_kit::App) -> Option<Pixels> {
216        let max = self.max_lines?;
217        let lines = (self.editor.read(cx).value().matches('\n').count() + 1).clamp(1, max);
218        let row = cx.theme().mono_font_size * 1.5;
219        Some(row * lines as f32 + px(theme::MD + 2.))
220    }
221}
222
223impl Render for CodeView {
224    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
225        if std::mem::take(&mut self.pending) {
226            let editable = self.editable;
227            self.editor.update(cx, |state, cx| state.set_readonly(!editable, cx));
228            self.apply_errors(cx);
229        }
230        let height = self.height(cx);
231        let editor = Editor::new(&self.editor).readonly(!self.editable).when_some(height, |e, h| e.h(h)).when(height.is_none(), |e| e.h_full());
232        let id = cx.entity_id();
233        let list = self.errors.iter().enumerate().map(|(i, (line, message))| {
234            let line = *line;
235            h_flex()
236                .id(SharedString::from(format!("code-{id}-error-{i}")))
237                .w_full()
238                .min_h(px(theme::TARGET_MIN))
239                .px(px(theme::SM))
240                .gap(px(theme::SM))
241                .items_start()
242                .rounded(px(theme::RADIUS))
243                .cursor_pointer()
244                .hover(|d| d.bg(color::hover()))
245                .child(div().flex_none().mt(px(5.)).w(px(3.)).h(px(14.)).rounded(px(1.)).bg(color::danger()))
246                .child(div().flex_none().py(px(3.)).text_sm().font_weight(FontWeight::SEMIBOLD).text_color(color::danger()).child(format!("Line {line}")))
247                .child(div().flex_1().min_w_0().py(px(3.)).text_sm().child(message.clone()))
248                .on_click(cx.listener(move |this, _, window, cx| this.go_to_line(line, window, cx)))
249                .test_support()
250        });
251        let has_errors = !self.errors.is_empty();
252        v_flex()
253            .w_full()
254            .when(height.is_none(), |d| d.h_full())
255            .gap(px(theme::XS))
256            .child(div().w_full().min_h_0().when(height.is_none(), |d| d.flex_1()).child(editor))
257            .when(has_errors, |d| {
258                d.child(
259                    v_flex()
260                        .flex_none()
261                        .w_full()
262                        .child(
263                            h_flex()
264                                .gap(px(theme::XS))
265                                .px(px(theme::SM))
266                                .text_xs()
267                                .text_color(color::text_muted())
268                                .child(Icon::new(IconName::CircleAlert).size(px(12.)).text_color(color::danger()))
269                                .child(if self.errors.len() == 1 { "1 error".to_string() } else { format!("{} errors", self.errors.len()) }),
270                        )
271                        .children(list),
272                )
273            })
274    }
275}