diff --git a/Cargo.lock b/Cargo.lock index a290fc0..0473252 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1693,6 +1693,7 @@ dependencies = [ "gpui", "gpui-native-core", "gpui-native-data", + "gpui-native-editor", "gpui-native-icons", "gpui-native-layout", "gpui-native-theme", @@ -1905,6 +1906,21 @@ dependencies = [ "gpui-native-utils", ] +[[package]] +name = "gpui-native-editor" +version = "0.1.0" +dependencies = [ + "gpui", + "gpui-native-core", + "gpui-native-theme", + "gpui-native-utils", + "tree-sitter", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + [[package]] name = "gpui-native-icons" version = "0.1.0" @@ -1920,6 +1936,7 @@ dependencies = [ "gpui", "gpui-native-core", "gpui-native-data", + "gpui-native-editor", "gpui-native-icons", "gpui-native-layout", "gpui-native-theme", @@ -5319,6 +5336,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -5850,6 +5873,66 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 72cc58f..f319166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/theme", "crates/utils", "crates/icons", "crates/core", "crates/data", "crates/layout", "examples/gallery"] +members = ["crates/theme", "crates/utils", "crates/icons", "crates/core", "crates/data", "crates/editor", "crates/layout", "examples/gallery"] [package] name = "gpui-native-kit" @@ -12,11 +12,13 @@ default = ["core", "data"] core = ["dep:gpui-native-core", "gpui-native-core/full"] data = ["dep:gpui-native-data", "gpui-native-data/full"] layout = ["dep:gpui-native-layout", "gpui-native-layout/full"] +editor = ["dep:gpui-native-editor", "gpui-native-editor/full"] [dependencies] gpui = { workspace = true } gpui-native-core = { path = "crates/core", optional = true } gpui-native-data = { path = "crates/data", optional = true } +gpui-native-editor = { path = "crates/editor", optional = true } gpui-native-layout = { path = "crates/layout", optional = true } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } @@ -28,6 +30,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } gpui-native-core = { path = "crates/core", default-features = false } gpui-native-data = { path = "crates/data", default-features = false } +gpui-native-editor = { path = "crates/editor", default-features = false } gpui-native-layout = { path = "crates/layout" } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml new file mode 100644 index 0000000..e8f26fa --- /dev/null +++ b/crates/editor/Cargo.toml @@ -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"] diff --git a/crates/editor/src/code_view.rs b/crates/editor/src/code_view.rs new file mode 100644 index 0000000..38dc4f1 --- /dev/null +++ b/crates/editor/src/code_view.rs @@ -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, +} + +impl CodeViewState { + /// Builds the state, highlighting `source` with `language`. + pub fn new(id: I, source: S, language: Language) -> Self + where + I: Into, + S: Into, + { + 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(&mut self, source: S, language: Language, cx: &mut Context) + where + S: Into, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl Render for CodeViewState { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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, 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, + } +} diff --git a/crates/editor/src/highlight.rs b/crates/editor/src/highlight.rs new file mode 100644 index 0000000..e4fd98b --- /dev/null +++ b/crates/editor/src/highlight.rs @@ -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, +} + +/// Source text split into role-styled lines. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HighlightedCode { + pub lines: Vec, +} + +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 { + 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> = 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 { + 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]) -> 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 = 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) { + if !text.is_empty() { + spans.push(Span { + text: SharedString::from(std::mem::take(text)), + role, + }); + } +} diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs new file mode 100644 index 0000000..14b5f25 --- /dev/null +++ b/crates/editor/src/lib.rs @@ -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; diff --git a/crates/editor/tests/code_view.rs b/crates/editor/tests/code_view.rs new file mode 100644 index 0000000..7310f60 --- /dev/null +++ b/crates/editor/tests/code_view.rs @@ -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, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.code.clone()) + } +} + +fn code_view_window( + line_count: usize, +) -> (TestAppContext, VisualTestContext, Entity) { + 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>>> = 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" + ); +} diff --git a/crates/editor/tests/code_view_bench.rs b/crates/editor/tests/code_view_bench.rs new file mode 100644 index 0000000..0019328 --- /dev/null +++ b/crates/editor/tests/code_view_bench.rs @@ -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,\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, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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>>> = 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:?}" + ); +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 87f933f..3f84858 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -57,6 +57,8 @@ pub struct Theme { pub titlebar_height: Pixels, /// The platform's native root font size; rem-based sizing scales with it. pub root_font_size: Pixels, + /// The platform's monospace family, for code surfaces. + pub mono_font: &'static str, pub sizes: SizeTable, pub accessibility: AccessibilityPreferences, } @@ -411,6 +413,7 @@ impl Theme { scrollbar: resolved.scrollbar, titlebar_height: resolved.titlebar_height, root_font_size: resolved.root_font_size, + mono_font: resolved.mono_font, sizes: resolved.sizes, accessibility: resolved.accessibility, } diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 3a66fbe..725824a 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -33,6 +33,8 @@ pub struct ResolvedTheme { pub titlebar_height: gpui::Pixels, /// The platform's native root font size; the fallback theme uses 16. pub root_font_size: gpui::Pixels, + /// The platform's monospace family, for code surfaces. + pub mono_font: &'static str, pub sizes: SizeTable, pub accessibility: crate::system::AccessibilityPreferences, } @@ -244,11 +246,23 @@ pub(crate) fn resolve( }, titlebar_height, root_font_size, + mono_font: mono_font(), sizes, accessibility, } } +/// The platform's monospace family, for code surfaces. +fn mono_font() -> &'static str { + if cfg!(target_os = "macos") { + "Menlo" + } else if cfg!(target_os = "windows") { + "Consolas" + } else { + "monospace" + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index bc2ba82..329917f 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,18 +9,15 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Scrollbar** (M13) — a simple way to show a scrollbar on gpui's - `list` and `uniform_list`. - - [x] API sketch - - [x] Implementation (theme-baked, overlays virtualized lists) - - [x] Tests - - [x] Gallery panel +- [ ] **Diff view** (M15, `editor`). + - [ ] API sketch + - [ ] Implementation + - [ ] Tests + - [ ] Gallery panel - [ ] Review ## Queue — to follow -- [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting. -- [ ] **Diff view** (M15, `editor`). - [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms. - [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at unit time). @@ -61,12 +58,20 @@ is the first downstream consumer; its screens drive the order. screen-reader label - [x] Combobox — type-to-filter picker, create / keep / revert blur modes - [x] Command palette — filtered actions; the matcher stays caller-owned +- [x] Scrollbar — overlay thumbs for gpui's `list` and `uniform_list`, + platform autohide with a `visibility` override ### data (`crates/data`) - [x] List, Tree, Tabs (underline default + signed pill), Sidebar (scrolls internally) +### editor (`crates/editor`) + +- [x] Code view — tree-sitter highlighted, virtualized rows with a gutter + and the overlay scrollbar; per-language grammar features, roles map to + the theme's semantic palette at render + ### layout (`crates/layout`) - [x] ResizeHandle diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index b07091b..b1e7608 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -10,6 +10,7 @@ gpui_platform = { workspace = true } reqwest_client = { workspace = true } gpui-native-core = { workspace = true, features = ["full"] } gpui-native-data = { workspace = true, features = ["full"] } +gpui-native-editor = { workspace = true, features = ["full"] } gpui-native-layout = { workspace = true } gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index d86411a..ef08788 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -40,6 +40,8 @@ use gpui_native_data::list::{List, ListItem}; use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; use gpui_native_data::tabs::{Tab, Tabs, TabsVariant}; use gpui_native_data::tree::{Tree, TreeItem}; +use gpui_native_editor::code_view::CodeViewState; +use gpui_native_editor::highlight::Language; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection}; use gpui_native_layout::resize_handle::ResizeHandle; @@ -82,6 +84,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("tabs", "Tabs"), ], ), + ("Editor", &[("code-view", "Code view")]), ( "Feedback", &[ @@ -623,6 +626,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("code-view", "Code view", |_, cx| { + cx.new(CodeViewDemo::new).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("sidebar", "Sidebar", |_, cx| { cx.new(|_| SidebarDemo { selected: None }).into() @@ -2736,6 +2745,176 @@ impl Render for ScrollbarDemo { } } +/// The code view demo's Rust sample. +const RUST_SAMPLE: &str = r#"// The palette is the theme's semantic ramps. +struct Application { + name: String, + ports: Vec, +} + +impl Application { + /// Binds every port, one by one. + fn bind_all(&self) -> Result<(), String> { + for port in &self.ports { + if port > &1024 { + continue; + } + return Err(format!("port {} is privileged", port)); + } + Ok(()) + } +} + +fn main() { + let app = Application { + name: "signed".to_string(), + ports: vec![8080, 443, 9090], + }; + println!("{} binds on {:?}", app.name, app.ports); + app.bind_all().unwrap_or_else(|err| eprintln!("{}", err)); +} +"#; + +/// The code view demo's TypeScript sample. +const TYPESCRIPT_SAMPLE: &str = r#"// A relay flips from stale to fresh as commits land. +interface Relay { + name: string; + commits: number; + fresh: boolean; +} + +const STALE_AFTER_MS = 60000; + +export function refresh(relays: Relay[]): Relay[] { + return relays.map((relay) => ({ + ...relay, + fresh: Date.now() - STALE_AFTER_MS > 0, + })); +} + +console.log(refresh([]).length); // 0 +"#; + +/// The code view demo's sample choice. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum CodeSample { + #[default] + Rust, + TypeScript, + /// A generated file with thousands of lines, to test scroll performance. + Large, +} + +/// The line count of the large-file sample. +const LARGE_SAMPLE_LINES: usize = 5000; + +/// Generates a deterministic pseudo-Rust file of roughly `lines` lines, so +/// scrolling the code view can be exercised on realistic content. +fn large_sample(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,\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 CodeViewDemo { + code: Entity, + sample: CodeSample, + large_source: SharedString, + _subscription: gpui::Subscription, +} + +impl CodeViewDemo { + fn new(cx: &mut Context) -> Self { + let code = cx.new(|_| CodeViewState::new("code-view-demo", RUST_SAMPLE, Language::Rust)); + // The panel is a cached view in the dock: it re-renders only when + // the code state it renders is notified. + let subscription = cx.observe(&code, |_, _, cx| cx.notify()); + Self { + code, + sample: CodeSample::Rust, + large_source: large_sample(LARGE_SAMPLE_LINES).into(), + _subscription: subscription, + } + } +} + +impl Render for CodeViewDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + let radius = cx.theme().radius; + let view = cx.entity(); + let line_count = self.code.read(cx).code().lines.len(); + + ScrollArea::new("code-view-demo").size_full().p_4().child( + v_flex() + .gap_3() + .child(demo_section( + "language", + colors, + Segmented::new("code-view-demo-sample", self.sample) + .options(vec![ + (CodeSample::Rust, "Rust"), + (CodeSample::TypeScript, "TypeScript"), + (CodeSample::Large, "Large file"), + ]) + .on_change(move |next, _, cx| { + view.update(cx, |demo, cx| { + demo.sample = next; + let (source, language) = match next { + CodeSample::Rust => (RUST_SAMPLE.into(), Language::Rust), + CodeSample::TypeScript => { + (TYPESCRIPT_SAMPLE.into(), Language::TypeScript) + } + CodeSample::Large => { + (demo.large_source.clone(), Language::Rust) + } + }; + demo.code + .update(cx, |code, cx| code.set_source(source, language, cx)); + }); + }), + )) + .child(demo_section( + "source", + colors, + div() + .h(px(480.)) + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child(self.code.clone()), + )) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!("{line_count} lines — scroll to test performance")), + ), + ) + } +} + struct SidebarDemo { selected: Option, }