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}