Skip to main content

ui/components/
code_editor.rs

1use std::ops::Range;
2use std::sync::LazyLock;
3
4use gpui::{
5    AnyElement, Context, Entity, Focusable, HighlightStyle, Render, StyledText, rgb, white,
6};
7use language::{DefaultLanguageRegistry, LanguageRegistry};
8
9use crate::TextInput;
10use crate::prelude::*;
11
12/// Compiled once per process — grammars/queries are immutable after
13/// construction, so every `CodeEditor` shares one registry instead of
14/// re-parsing highlight queries per instance.
15static LANGUAGES: LazyLock<DefaultLanguageRegistry> = LazyLock::new(DefaultLanguageRegistry::new);
16
17/// Per-digit gutter width at `CODE_FONT_SIZE`/`CODE_FONT_FAMILY` (monospace,
18/// ~0.6em advance per digit) — the gutter box grows with the line count
19/// instead of clipping once line numbers exceed a fixed digit budget.
20const GUTTER_DIGIT_WIDTH: Pixels = px(8.);
21/// Gutter never shrinks below a 2-digit budget, so 1-9 line files don't get a
22/// visually cramped near-zero-width gutter.
23const GUTTER_MIN_DIGITS: usize = 2;
24/// Gap between the line numbers and the divider/code content.
25const GUTTER_RIGHT_PADDING: Pixels = px(16.);
26/// Monospace font used for code content.
27const CODE_FONT_FAMILY: &str = "IBM Plex Mono";
28/// Code text size.
29const CODE_FONT_SIZE: Pixels = px(12.5);
30/// Code line height (relative).
31const CODE_LINE_HEIGHT: f32 = 1.7;
32
33/// Gutter width scaled to the number of digits in `line_count`.
34fn gutter_width(line_count: usize) -> Pixels {
35    let digits = line_count.max(1).to_string().len().max(GUTTER_MIN_DIGITS);
36    GUTTER_DIGIT_WIDTH * digits as f32
37}
38
39/// Resolves a tree-sitter capture name (e.g. `"type.builtin"`) to a style,
40/// falling back to its first dotted segment (`"type"`) if the full name
41/// isn't in the theme. `SyntaxTheme::style_for_name` only does an exact
42/// lookup — the dotted-prefix fallback it documents (`highlight_id`) is a
43/// separate method that returns an index, not a style — so this reproduces
44/// just enough of that fallback to avoid losing color on every capture a
45/// grammar specializes beyond the theme's base vocabulary (confirmed by the
46/// coverage tests below: `type.builtin`/`variable.parameter` in TypeScript
47/// and `constant.builtin`/`string.special.key` in JSON all need this to
48/// resolve against the One Dark theme's flatter capture list).
49fn style_for_capture(syntax: &syntax_theme::SyntaxTheme, name: &str) -> Option<HighlightStyle> {
50    syntax.style_for_name(name).or_else(|| {
51        let prefix = name.split('.').next()?;
52        (prefix != name)
53            .then(|| syntax.style_for_name(prefix))
54            .flatten()
55    })
56}
57
58/// A multi-line code area with a line-number gutter, composed from a
59/// multiline `TextInput`. Real tree-sitter syntax highlighting is available
60/// via [`Self::language`], but only while [`Self::read_only`] is set — see
61/// that method's doc comment for why live-typing highlighting is a
62/// separately-scoped effort.
63///
64/// Stateful view — create with `cx.new(|cx| CodeEditor::new(cx))` and store
65/// the resulting `Entity<CodeEditor>`.
66pub struct CodeEditor {
67    input: Entity<TextInput>,
68    read_only: bool,
69    language_extension: Option<&'static str>,
70}
71
72impl CodeEditor {
73    pub fn new(cx: &mut Context<Self>) -> Self {
74        let input = cx.new(|cx| TextInput::new(cx).multiline(true));
75        cx.observe(&input, |_, _, cx| cx.notify()).detach();
76        Self {
77            input,
78            read_only: false,
79            language_extension: None,
80        }
81    }
82
83    /// Toggles read-only mode (e.g. for a read-only code preview that reuses
84    /// this component rather than a separate one).
85    /// Forwards the flag to the wrapped `TextInput` — no key-handling logic
86    /// is duplicated here; the `TextInput`'s own `read_only` flag remains the
87    /// single source of truth.
88    pub fn read_only(mut self, cx: &mut Context<Self>, read_only: bool) -> Self {
89        self.set_read_only(read_only, cx);
90        self
91    }
92
93    /// Dynamically toggles read-only mode after construction.
94    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
95        self.read_only = read_only;
96        self.input
97            .update(cx, |input, cx| input.set_read_only(read_only, cx));
98        cx.notify();
99    }
100
101    /// Enables real tree-sitter syntax highlighting for the given file
102    /// extension (no leading dot, e.g. `"rs"`) — see
103    /// [`language::DefaultLanguageRegistry`] for which extensions are
104    /// compiled in. Highlighting only renders while [`Self::read_only`] is
105    /// set: `TextInput` (which this component wraps for keystroke handling)
106    /// has no cursor-position tracking beyond append-at-the-end (see the
107    /// current-line-highlight note in `render` below), so there is no way to
108    /// keep a caret visually correct inside syntax-highlighted rich text
109    /// while the user is actively typing. Read-only code previews — the
110    /// primary use case this component documents — get full highlighting;
111    /// live editing still renders as plain colored text.
112    pub fn language(mut self, extension: &'static str) -> Self {
113        self.language_extension = Some(extension);
114        self
115    }
116
117    /// The current code content.
118    pub fn text(&self, cx: &App) -> String {
119        self.input.read(cx).text().to_string()
120    }
121
122    /// Programmatically sets the code content (e.g. loading content into a
123    /// read-only preview).
124    pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
125        self.input.update(cx, |input, cx| input.set_text(text, cx));
126        cx.notify();
127    }
128}
129
130impl Render for CodeEditor {
131    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
132        let text = self.input.read(cx).text().to_string();
133        let line_count = text.matches('\n').count() + 1;
134        let line_height = CODE_FONT_SIZE * CODE_LINE_HEIGHT;
135
136        // `TextInput` has no cursor-position tracking beyond "append at the
137        // end" (see its `on_key_down`: backspace pops the last char, there is
138        // no caret movement) — so the only line that can ever be "current" is
139        // the last one. This highlight is an honest approximation of that,
140        // not a real per-position caret; it only shows while focused so it
141        // doesn't imply cursor state on an unfocused/read-only preview.
142        let focused = self.input.read(cx).focus_handle(cx).is_focused(window);
143        let current_line_top = line_height * (line_count - 1);
144
145        let gutter = v_flex()
146            .flex_none()
147            .w(gutter_width(line_count))
148            .pr(GUTTER_RIGHT_PADDING)
149            .text_right()
150            // Gutter line-number text color.
151            .text_color(rgb(0x3A424E))
152            .font_family(CODE_FONT_FAMILY)
153            .text_size(CODE_FONT_SIZE)
154            .line_height(relative(CODE_LINE_HEIGHT))
155            .children((1..=line_count).map(|line| div().child(line.to_string())));
156
157        div()
158            .id(("code-editor", cx.entity_id()))
159            .relative()
160            .w_full()
161            .overflow_y_scroll()
162            .when(focused, |this| {
163                this.child(
164                    div()
165                        .absolute()
166                        .top(current_line_top)
167                        .left_0()
168                        .w_full()
169                        .h(line_height)
170                        .bg(white().opacity(0.04)),
171                )
172            })
173            .child({
174                let content: AnyElement = self
175                    .read_only
176                    .then_some(self.language_extension)
177                    .flatten()
178                    .and_then(|extension| LANGUAGES.language_for_extension(extension))
179                    .map(|language| {
180                        let syntax = cx.theme().syntax();
181                        let highlights: Vec<(Range<usize>, HighlightStyle)> =
182                            language::highlighted_spans(language, &text)
183                                .into_iter()
184                                .filter_map(|(range, name)| {
185                                    style_for_capture(syntax, &name).map(|style| (range, style))
186                                })
187                                .collect();
188                        StyledText::new(text.clone())
189                            .with_highlights(highlights)
190                            .into_any_element()
191                    })
192                    .unwrap_or_else(|| self.input.clone().into_any_element());
193
194                h_flex().w_full().items_start().child(gutter).child(
195                    div()
196                        .flex_1()
197                        .min_w_0()
198                        // Code text color (also the base color for
199                        // unhighlighted spans/plain-text editing mode).
200                        .text_color(rgb(0xB7BEC7))
201                        .font_family(CODE_FONT_FAMILY)
202                        .text_size(CODE_FONT_SIZE)
203                        .line_height(relative(CODE_LINE_HEIGHT))
204                        .child(content),
205                )
206            })
207    }
208}
209
210/// Standalone gallery preview for `CodeEditor` (not registered in the
211/// `Component` catalog since it is a stateful `Entity`, matching
212/// `SearchInput`'s existing convention in this crate). Shows both modes:
213/// an editable plain-text buffer, and a read-only buffer with real
214/// tree-sitter syntax highlighting (see [`CodeEditor::language`]).
215pub fn code_editor_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
216    v_flex()
217        .gap_4()
218        .child(cx.new(|cx| CodeEditor::new(cx)))
219        .child(cx.new(|cx| {
220            let mut editor = CodeEditor::new(cx).language("rs");
221            editor.set_text(
222                "fn main() {\n    let greeting = \"hello, world\";\n    println!(\"{greeting}\");\n}",
223                cx,
224            );
225            editor.read_only(cx, true)
226        }))
227        .into_any_element()
228}
229
230#[cfg(test)]
231mod tests {
232    use language::{DefaultLanguageRegistry, LanguageRegistry};
233    use theme::default_themes;
234
235    /// Audits that every capture name tree-sitter produces for each
236    /// registered grammar resolves to *some* style via the real One Dark
237    /// fallback theme, through the same [`super::style_for_capture`]
238    /// dotted-prefix fallback `render` uses (e.g. `function.method` ->
239    /// `function`). This is the "syntax theme mismatch" risk the Phase B
240    /// plan flagged as needing verification before/while adding grammars —
241    /// run for every grammar added here, not just Rust.
242    ///
243    /// Unmapped captures aren't a hard failure (`code_editor.rs` just skips
244    /// coloring them — see `render`'s `filter_map`), so this asserts a
245    /// coverage ratio rather than 100%: a sudden drop would mean a grammar's
246    /// query uses a capture-naming convention the theme doesn't anticipate.
247    fn assert_theme_covers_language(extension: &str, source: &str, min_coverage: f32) {
248        let registry = DefaultLanguageRegistry::new();
249        let language = registry
250            .language_for_extension(extension)
251            .unwrap_or_else(|| panic!("no grammar registered for .{extension}"));
252        let theme = &default_themes().themes[0];
253        let syntax = theme.syntax();
254
255        let spans = language::highlighted_spans(language, source);
256        assert!(
257            !spans.is_empty(),
258            "expected at least one highlight span for .{extension}"
259        );
260
261        let distinct_names: std::collections::BTreeSet<_> =
262            spans.iter().map(|(_, name)| name.clone()).collect();
263        let covered = distinct_names
264            .iter()
265            .filter(|name| super::style_for_capture(syntax, name).is_some())
266            .count();
267        let coverage = covered as f32 / distinct_names.len() as f32;
268
269        assert!(
270            coverage >= min_coverage,
271            ".{extension}: only {covered}/{len} distinct capture names resolved via the \
272             One Dark theme (coverage {coverage:.2} < {min_coverage:.2}). Uncovered: {uncovered:?}",
273            len = distinct_names.len(),
274            uncovered = distinct_names
275                .iter()
276                .filter(|name| super::style_for_capture(syntax, name).is_none())
277                .collect::<Vec<_>>()
278        );
279    }
280
281    #[test]
282    fn rust_highlights_covered_by_theme() {
283        assert_theme_covers_language(
284            "rs",
285            "fn main() {\n    let s: &str = \"hi\";\n    println!(\"{s}\");\n}\n",
286            0.7,
287        );
288    }
289
290    #[test]
291    fn javascript_highlights_covered_by_theme() {
292        assert_theme_covers_language(
293            "js",
294            "function greet(name) {\n  return `hi ${name}`;\n}\nconst x = 1;\n",
295            0.6,
296        );
297    }
298
299    #[test]
300    fn typescript_highlights_covered_by_theme() {
301        assert_theme_covers_language(
302            "ts",
303            "interface Point { x: number; y: number }\nfunction f(p: Point): number { return p.x; }\n",
304            0.6,
305        );
306    }
307
308    #[test]
309    fn json_highlights_covered_by_theme() {
310        assert_theme_covers_language(
311            "json",
312            "{\"name\": \"base\", \"count\": 3, \"ok\": true}",
313            0.6,
314        );
315    }
316
317    #[test]
318    fn markdown_highlights_covered_by_theme() {
319        assert_theme_covers_language(
320            "md",
321            "# Title\n\nSome *text* with a [link](https://example.com).\n",
322            0.4,
323        );
324    }
325}