feat: add editor component

This commit is contained in:
2026-11-04 14:01:54 +07:00
parent 50da1d4a9d
commit 503b105bad
13 changed files with 1021 additions and 9 deletions
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "gpui-native-editor"
version = "0.1.0"
edition = "2024"
description = "Read-only code surfaces for gpui applications, one cargo feature per language grammar"
[dependencies]
gpui = { workspace = true }
gpui-native-core = { workspace = true, optional = true }
gpui-native-theme = { workspace = true }
gpui-native-utils = { workspace = true }
tree-sitter = "0.25"
tree-sitter-rust = { version = "0.24", optional = true }
tree-sitter-typescript = { version = "0.23", optional = true }
tree-sitter-javascript = { version = "0.23", optional = true }
tree-sitter-python = { version = "0.23", optional = true }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
[features]
default = ["full"]
full = [
"code_view",
"rust",
"typescript",
"javascript",
"python",
]
code_view = [
"dep:gpui-native-core",
"gpui-native-core/scroll_area",
"gpui-native-core/scrollbar",
]
rust = ["dep:tree-sitter-rust"]
typescript = ["dep:tree-sitter-typescript"]
javascript = ["dep:tree-sitter-javascript"]
python = ["dep:tree-sitter-python"]
+235
View File
@@ -0,0 +1,235 @@
//! The code view: a virtualized, read-only rendering of highlighted source.
//! Rows render only when visible, the line-number gutter is part of each
//! row, and the overlay scrollbar is the core one.
use gpui::prelude::FluentBuilder as _;
use gpui::{
Context, ElementId, HighlightStyle, Hsla, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, Render, ScrollStrategy, SharedString,
StatefulInteractiveElement as _, Styled as _, TextStyle, UniformListScrollHandle, Window, div,
uniform_list,
};
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility};
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
use crate::highlight::{HighlightedCode, Language, Role, highlight};
/// Retained state for a code view.
pub struct CodeViewState {
id: ElementId,
source: SharedString,
language: Language,
code: HighlightedCode,
handle: UniformListScrollHandle,
a11y_label: Option<SharedString>,
}
impl CodeViewState {
/// Builds the state, highlighting `source` with `language`.
pub fn new<I, S>(id: I, source: S, language: Language) -> Self
where
I: Into<ElementId>,
S: Into<SharedString>,
{
let source = source.into();
let code = highlight(&source, language);
Self {
id: id.into(),
source,
language,
code,
handle: UniformListScrollHandle::new(),
a11y_label: None,
}
}
/// Replaces the source and re-highlights it.
pub fn set_source<S>(&mut self, source: S, language: Language, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
self.source = source.into();
self.language = language;
self.code = highlight(&self.source, self.language);
self.handle.scroll_to_item(0, ScrollStrategy::Top);
cx.notify();
}
/// The highlighted source text.
pub fn source(&self) -> &str {
&self.source
}
/// The language the source highlights as.
pub fn language(&self) -> Language {
self.language
}
/// The highlighted lines.
pub fn code(&self) -> &HighlightedCode {
&self.code
}
/// Scrolls `line` to the top of the view.
pub fn scroll_to_line(&self, line: usize) {
self.handle.scroll_to_item(
line.min(self.code.lines.len().saturating_sub(1)),
ScrollStrategy::Top,
);
}
/// Overrides the accessibility label of the view.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
}
impl Render for CodeViewState {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors;
let mono_font = theme.mono_font;
let text_size = theme.sizes.small.text_size;
let line_height = text_size * 1.5;
let id = self.id.clone();
let state = cx.entity();
let line_count = self.code.lines.len();
let gutter_chars = line_count.max(1).to_string().len().max(2);
let gutter_width = text_size * 0.62 * gutter_chars as f32 + text_size;
let rows = uniform_list((id.clone(), "rows"), line_count, {
let id = id.clone();
move |range, _window, cx| {
let code = state.read(cx);
let theme = cx.theme();
range
.map(|line_ix| {
render_row(
code,
&id,
line_ix,
theme.mono_font,
theme.colors,
theme.ramps,
theme.sizes.small.text_size,
line_height,
gutter_width,
)
})
.collect()
}
})
.track_scroll(&self.handle)
.h_full()
.w_full();
div()
.id((id.clone(), "view"))
.relative()
.size_full()
.text_size(text_size)
.font_family(mono_font)
.text_color(colors.foreground)
.when_some(self.a11y_label.clone(), |view, label| {
view.aria_label(label)
})
.when_some(self.a11y_label.clone(), |view, label| {
view.aria_label(label)
})
.child(rows)
.child(
Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform),
)
}
}
#[allow(clippy::too_many_arguments)]
fn render_row(
code: &CodeViewState,
id: &ElementId,
line_ix: usize,
mono_font: &'static str,
colors: ColorTokens,
ramps: Ramps,
text_size: Pixels,
line_height: Pixels,
gutter_width: Pixels,
) -> gpui::AnyElement {
let line = code.code().lines.get(line_ix).cloned().unwrap_or_default();
// One text element per line, colored by byte-range highlights
let mut text = String::new();
let mut highlights: Vec<(std::ops::Range<usize>, HighlightStyle)> = Vec::new();
for span in &line.spans {
let start = text.len();
text.push_str(&span.text);
if span.role != Role::Text {
highlights.push((
start..text.len(),
HighlightStyle {
color: Some(role_color(span.role, colors, ramps)),
..Default::default()
},
));
}
}
let style = TextStyle {
color: colors.foreground,
font_family: SharedString::from(mono_font),
font_size: text_size.into(),
line_height: line_height.into(),
..Default::default()
};
div()
.debug_selector({
let name = format!("{id}-row-{line_ix}");
move || name
})
.id(("code-row", line_ix))
.h(line_height)
.flex()
.items_center()
.overflow_hidden()
.child(
div()
.w(gutter_width)
.flex_none()
.pr_2()
.text_right()
.font_family(mono_font)
.text_size(text_size)
.text_color(colors.muted_foreground)
.child(format!("{}", line_ix + 1)),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)),
)
.into_any_element()
}
/// Maps a highlight role to the theme's semantic palette.
fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
match role {
Role::Text => colors.foreground,
Role::Keyword => ramps.primary.base,
Role::String | Role::Escape => ramps.success.base,
Role::Comment | Role::Punctuation => colors.muted_foreground,
Role::Function | Role::Property => ramps.info.base,
Role::Type => ramps.warning.base,
Role::Number | Role::Constant => ramps.secondary.base,
Role::Operator => colors.foreground,
Role::Tag => ramps.destructive.base,
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Tree-sitter highlighting: source text in, role-styled lines out. Roles
//! are resolved to theme colors at render time, so a theme change re-colors
//! code without re-parsing it.
use gpui::SharedString;
/// A language source can highlight as.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Language {
Rust,
JavaScript,
TypeScript,
Python,
}
/// The semantic role a span plays.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Text,
Keyword,
String,
Comment,
Function,
Type,
Number,
Constant,
Property,
Operator,
Punctuation,
Tag,
Escape,
}
/// A run of text sharing one role.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Span {
pub text: SharedString,
pub role: Role,
}
/// One rendered line of code.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Line {
pub spans: Vec<Span>,
}
/// Source text split into role-styled lines.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HighlightedCode {
pub lines: Vec<Line>,
}
use tree_sitter::StreamingIterator as _;
/// Highlights `source` with `language`'s tree-sitter grammar.
pub fn highlight(source: &str, language: Language) -> HighlightedCode {
let Some((grammar, query_source)) = grammar_for(language) else {
return build_lines(source, &[]);
};
highlight_with(grammar, query_source, source).unwrap_or_else(|| build_lines(source, &[]))
}
/// The grammar and its highlight query for a language, when compiled in.
fn grammar_for(language: Language) -> Option<(tree_sitter::Language, &'static str)> {
match language {
Language::Rust => cfg!(feature = "rust").then(|| {
(
tree_sitter_rust::LANGUAGE.into(),
tree_sitter_rust::HIGHLIGHTS_QUERY,
)
}),
Language::TypeScript => cfg!(feature = "typescript").then(|| {
(
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
tree_sitter_typescript::HIGHLIGHTS_QUERY,
)
}),
Language::JavaScript => cfg!(feature = "javascript").then(|| {
(
tree_sitter_javascript::LANGUAGE.into(),
tree_sitter_javascript::HIGHLIGHT_QUERY,
)
}),
Language::Python => cfg!(feature = "python").then(|| {
(
tree_sitter_python::LANGUAGE.into(),
tree_sitter_python::HIGHLIGHTS_QUERY,
)
}),
}
}
fn highlight_with(
grammar: tree_sitter::Language,
query_source: &str,
source: &str,
) -> Option<HighlightedCode> {
let mut parser = tree_sitter::Parser::new();
parser.set_language(&grammar).ok()?;
let tree = parser.parse(source, None)?;
let query = tree_sitter::Query::new(&grammar, query_source).ok()?;
let roles: Vec<Option<Role>> = query
.capture_names()
.iter()
.map(|name| role_for_capture(name))
.collect();
let mut role_at = vec![None; source.len()];
let mut cursor = tree_sitter::QueryCursor::new();
let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
while let Some(matched) = matches.next() {
for capture in matched.captures {
let Some(role) = roles.get(capture.index as usize).copied().flatten() else {
continue;
};
for byte in capture.node.byte_range() {
if let Some(slot) = role_at.get_mut(byte) {
*slot = Some(role);
}
}
}
}
Some(build_lines(source, &role_at))
}
/// Maps a query capture name to a role; unmapped captures render as text.
fn role_for_capture(name: &str) -> Option<Role> {
match name.split('.').next()? {
"keyword" => Some(Role::Keyword),
"string" => Some(Role::String),
"escape" => Some(Role::Escape),
"comment" => Some(Role::Comment),
"function" | "method" => Some(Role::Function),
"type" | "attribute" => Some(Role::Type),
"number" => Some(Role::Number),
"constant" => Some(Role::Constant),
"property" => Some(Role::Property),
"operator" => Some(Role::Operator),
"punctuation" => Some(Role::Punctuation),
"tag" => Some(Role::Tag),
_ => None,
}
}
/// Splits `source` into lines, grouping consecutive bytes that share a role into one span.
fn build_lines(source: &str, role_at: &[Option<Role>]) -> HighlightedCode {
let role_of =
|byte: usize| -> Role { role_at.get(byte).copied().flatten().unwrap_or(Role::Text) };
let mut lines = Vec::new();
let mut spans: Vec<Span> = Vec::new();
let mut text = String::new();
let mut role = Role::Text;
for (byte, character) in source.char_indices() {
let byte_role = role_of(byte);
if character == '\n' {
flush_run(&mut text, role, &mut spans);
lines.push(Line {
spans: std::mem::take(&mut spans),
});
continue;
}
if byte_role != role && !text.is_empty() {
flush_run(&mut text, role, &mut spans);
}
role = byte_role;
text.push(character);
}
flush_run(&mut text, role, &mut spans);
lines.push(Line {
spans: std::mem::take(&mut spans),
});
HighlightedCode { lines }
}
fn flush_run(text: &mut String, role: Role, spans: &mut Vec<Span>) {
if !text.is_empty() {
spans.push(Span {
text: SharedString::from(std::mem::take(text)),
role,
});
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Read-only code surfaces for gpui applications: tree-sitter highlighted
//! code views, one cargo feature per language grammar. A language whose
//! grammar feature is off renders as plain text.
#[cfg(feature = "code_view")]
pub mod code_view;
pub mod highlight;
+147
View File
@@ -0,0 +1,147 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
TestAppContext, VisualTestContext, Window, div,
};
use gpui_native_editor::code_view::CodeViewState;
use gpui_native_editor::highlight::{HighlightedCode, Language, Role, highlight};
fn spans_with_role(code: &HighlightedCode, role: Role) -> Vec<&str> {
code.lines
.iter()
.flat_map(|line| line.spans.iter())
.filter(|span| span.role == role)
.map(|span| span.text.as_ref())
.collect()
}
#[test]
fn a_rust_snippet_gets_its_roles_from_the_grammar() {
let source = "// a comment\nfn main() {\n let greeting = \"hello\";\n}\n";
let code = highlight(source, Language::Rust);
assert_eq!(code.lines.len(), 5, "a trailing newline ends an empty line");
assert!(spans_with_role(&code, Role::Comment).contains(&"// a comment"));
assert!(
spans_with_role(&code, Role::Keyword)
.iter()
.any(|text| text.contains("fn"))
);
assert!(
spans_with_role(&code, Role::Keyword)
.iter()
.any(|text| text.contains("let"))
);
assert!(spans_with_role(&code, Role::Function).contains(&"main"));
assert!(
spans_with_role(&code, Role::String)
.iter()
.any(|text| text.contains("hello"))
);
}
#[test]
fn a_capture_spanning_newlines_styles_both_lines() {
let source = "let text = \"a\nb\";\n";
let code = highlight(source, Language::Rust);
assert!(
code.lines[0]
.spans
.iter()
.any(|span| span.role == Role::String && span.text.contains("\"a")),
"the string opens on the first line"
);
assert!(
code.lines[1]
.spans
.iter()
.any(|span| span.role == Role::String && span.text.contains("b\"")),
"and continues on the second"
);
}
#[test]
fn set_source_rehighlights_with_the_new_language() {
let mut cx = TestAppContext::single();
let state = cx.new(|_| CodeViewState::new("test-code", "fn main() {}\n", Language::Rust));
cx.update(|cx| {
state.update(cx, |state, cx| {
state.set_source("def main():\n pass\n", Language::Python, cx);
});
});
let code = cx.update(|cx| state.read(cx).code().clone());
assert!(
spans_with_role(&code, Role::Keyword)
.iter()
.any(|text| text.contains("def")),
"python's def highlights as a keyword"
);
assert!(spans_with_role(&code, Role::Function).contains(&"main"));
}
struct Host {
code: Entity<CodeViewState>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.code.clone())
}
}
fn code_view_window(
line_count: usize,
) -> (TestAppContext, VisualTestContext, Entity<CodeViewState>) {
let mut cx = TestAppContext::single();
cx.update(gpui_native_theme::Theme::init);
let source: String = (0..line_count)
.map(|line| format!("fn line_{line}() {{}}\n"))
.collect();
let state = CodeViewState::new("test-code", source, Language::Rust);
let state_cell: Rc<RefCell<Option<Entity<CodeViewState>>>> = Rc::new(RefCell::new(None));
let cell = state_cell.clone();
let window = cx.add_window(move |_, cx| {
let code = cx.new(|_| state);
cell.borrow_mut().replace(code.clone());
Host { code }
});
let state = state_cell.borrow().clone().expect("state captured");
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, visual, state)
}
#[test]
fn rows_render_virtualized_and_scroll() {
let (_cx, mut visual, state) = code_view_window(80);
assert!(
visual.debug_bounds("test-code-row-0").is_some(),
"the first row renders"
);
assert!(
visual.debug_bounds("test-code-row-79").is_none(),
"rows past the viewport never render"
);
state.update_in(&mut visual, |state, _window, _cx| state.scroll_to_line(79));
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
assert!(
visual.debug_bounds("test-code-row-79").is_some(),
"scrolling brings the last row in"
);
assert!(
visual.debug_bounds("test-code-row-0").is_none(),
"the first row scrolled out"
);
}
+106
View File
@@ -0,0 +1,106 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{
AppContext as _, Context, Entity, IntoElement, Modifiers, ParentElement as _, Render,
ScrollDelta, ScrollEvent, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
px,
};
use gpui_native_editor::code_view::CodeViewState;
use gpui_native_editor::highlight::Language;
/// Deterministic pseudo-Rust with mixed tokens, roughly `lines` lines long.
fn large_source(lines: usize) -> String {
let mut source = String::with_capacity(lines * 48);
let mut index = 0usize;
while source.lines().count() < lines {
let i = index;
source.push_str(&format!(
"// Section {i}: generated for the scroll performance test.\n"
));
source.push_str(&format!("struct Section{i} {{\n"));
source.push_str(" id: u64,\n");
source.push_str(" label: String,\n");
source.push_str(" scores: Vec<f64>,\n");
source.push_str("}\n\n");
source.push_str(&format!("impl Section{i} {{\n"));
source.push_str(" /// A one-line summary of this section.\n");
source.push_str(" fn describe(&self) -> String {\n");
source.push_str(&format!(
" format!(\"section {i}: {{}} at {{:?}}\", self.label, self.scores)\n"
));
source.push_str(" }\n");
source.push_str("}\n\n");
index += 1;
}
source
}
struct Host {
code: Entity<CodeViewState>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.code.clone())
}
}
#[test]
fn scrolling_a_large_file_is_not_pathologically_slow() {
let mut cx = TestAppContext::single();
cx.update(gpui_native_theme::Theme::init);
let source = large_source(5000);
let started = Instant::now();
let state = CodeViewState::new("bench-code", source.clone(), Language::Rust);
let highlight_elapsed = started.elapsed();
let state_cell: Rc<RefCell<Option<Entity<CodeViewState>>>> = Rc::new(RefCell::new(None));
let cell = state_cell.clone();
let window = cx.add_window(move |_, cx| {
let code = cx.new(|_| state);
cell.borrow_mut().replace(code.clone());
Host { code }
});
let state = state_cell.borrow().clone().expect("state captured");
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
let line_count = cx.update(|cx| state.read(cx).code().lines.len());
assert!(
(5000..=5020).contains(&line_count),
"the whole file highlighted, got {line_count} lines"
);
let started = Instant::now();
for _ in 0..30 {
visual.simulate_event(ScrollEvent {
position: point(px(300.), px(300.)),
delta: ScrollDelta::Pixels(point(px(0.), px(-240.))),
modifiers: Modifiers::default(),
..Default::default()
});
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
let scroll_elapsed = started.elapsed();
println!(
"highlighting {line_count} lines took {highlight_elapsed:?}; 30 wheel frames over {} lines took {scroll_elapsed:?} ({:?} per frame)",
line_count,
scroll_elapsed / 30
);
assert!(
highlight_elapsed < Duration::from_secs(3),
"highlighting took {highlight_elapsed:?}"
);
assert!(
scroll_elapsed < Duration::from_secs(2),
"scrolling 30 frames took {scroll_elapsed:?}"
);
}