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