feat: add editor component
This commit is contained in:
Generated
+83
@@ -1693,6 +1693,7 @@ dependencies = [
|
|||||||
"gpui",
|
"gpui",
|
||||||
"gpui-native-core",
|
"gpui-native-core",
|
||||||
"gpui-native-data",
|
"gpui-native-data",
|
||||||
|
"gpui-native-editor",
|
||||||
"gpui-native-icons",
|
"gpui-native-icons",
|
||||||
"gpui-native-layout",
|
"gpui-native-layout",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
@@ -1905,6 +1906,21 @@ dependencies = [
|
|||||||
"gpui-native-utils",
|
"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]]
|
[[package]]
|
||||||
name = "gpui-native-icons"
|
name = "gpui-native-icons"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1920,6 +1936,7 @@ dependencies = [
|
|||||||
"gpui",
|
"gpui",
|
||||||
"gpui-native-core",
|
"gpui-native-core",
|
||||||
"gpui-native-data",
|
"gpui-native-data",
|
||||||
|
"gpui-native-editor",
|
||||||
"gpui-native-icons",
|
"gpui-native-icons",
|
||||||
"gpui-native-layout",
|
"gpui-native-layout",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
@@ -5319,6 +5336,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strict-num"
|
name = "strict-num"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -5850,6 +5873,66 @@ dependencies = [
|
|||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[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]
|
[package]
|
||||||
name = "gpui-native-kit"
|
name = "gpui-native-kit"
|
||||||
@@ -12,11 +12,13 @@ default = ["core", "data"]
|
|||||||
core = ["dep:gpui-native-core", "gpui-native-core/full"]
|
core = ["dep:gpui-native-core", "gpui-native-core/full"]
|
||||||
data = ["dep:gpui-native-data", "gpui-native-data/full"]
|
data = ["dep:gpui-native-data", "gpui-native-data/full"]
|
||||||
layout = ["dep:gpui-native-layout", "gpui-native-layout/full"]
|
layout = ["dep:gpui-native-layout", "gpui-native-layout/full"]
|
||||||
|
editor = ["dep:gpui-native-editor", "gpui-native-editor/full"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-core = { path = "crates/core", optional = true }
|
gpui-native-core = { path = "crates/core", optional = true }
|
||||||
gpui-native-data = { path = "crates/data", 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-layout = { path = "crates/layout", optional = true }
|
||||||
gpui-native-icons = { path = "crates/icons" }
|
gpui-native-icons = { path = "crates/icons" }
|
||||||
gpui-native-theme = { path = "crates/theme" }
|
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-core = { path = "crates/core", default-features = false }
|
||||||
gpui-native-data = { path = "crates/data", 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-layout = { path = "crates/layout" }
|
||||||
gpui-native-icons = { path = "crates/icons" }
|
gpui-native-icons = { path = "crates/icons" }
|
||||||
gpui-native-theme = { path = "crates/theme" }
|
gpui-native-theme = { path = "crates/theme" }
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ pub struct Theme {
|
|||||||
pub titlebar_height: Pixels,
|
pub titlebar_height: Pixels,
|
||||||
/// The platform's native root font size; rem-based sizing scales with it.
|
/// The platform's native root font size; rem-based sizing scales with it.
|
||||||
pub root_font_size: Pixels,
|
pub root_font_size: Pixels,
|
||||||
|
/// The platform's monospace family, for code surfaces.
|
||||||
|
pub mono_font: &'static str,
|
||||||
pub sizes: SizeTable,
|
pub sizes: SizeTable,
|
||||||
pub accessibility: AccessibilityPreferences,
|
pub accessibility: AccessibilityPreferences,
|
||||||
}
|
}
|
||||||
@@ -411,6 +413,7 @@ impl Theme {
|
|||||||
scrollbar: resolved.scrollbar,
|
scrollbar: resolved.scrollbar,
|
||||||
titlebar_height: resolved.titlebar_height,
|
titlebar_height: resolved.titlebar_height,
|
||||||
root_font_size: resolved.root_font_size,
|
root_font_size: resolved.root_font_size,
|
||||||
|
mono_font: resolved.mono_font,
|
||||||
sizes: resolved.sizes,
|
sizes: resolved.sizes,
|
||||||
accessibility: resolved.accessibility,
|
accessibility: resolved.accessibility,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ pub struct ResolvedTheme {
|
|||||||
pub titlebar_height: gpui::Pixels,
|
pub titlebar_height: gpui::Pixels,
|
||||||
/// The platform's native root font size; the fallback theme uses 16.
|
/// The platform's native root font size; the fallback theme uses 16.
|
||||||
pub root_font_size: gpui::Pixels,
|
pub root_font_size: gpui::Pixels,
|
||||||
|
/// The platform's monospace family, for code surfaces.
|
||||||
|
pub mono_font: &'static str,
|
||||||
pub sizes: SizeTable,
|
pub sizes: SizeTable,
|
||||||
pub accessibility: crate::system::AccessibilityPreferences,
|
pub accessibility: crate::system::AccessibilityPreferences,
|
||||||
}
|
}
|
||||||
@@ -244,11 +246,23 @@ pub(crate) fn resolve(
|
|||||||
},
|
},
|
||||||
titlebar_height,
|
titlebar_height,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
|
mono_font: mono_font(),
|
||||||
sizes,
|
sizes,
|
||||||
accessibility,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+13
-8
@@ -9,18 +9,15 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **Scrollbar** (M13) — a simple way to show a scrollbar on gpui's
|
- [ ] **Diff view** (M15, `editor`).
|
||||||
`list` and `uniform_list`.
|
- [ ] API sketch
|
||||||
- [x] API sketch
|
- [ ] Implementation
|
||||||
- [x] Implementation (theme-baked, overlays virtualized lists)
|
- [ ] Tests
|
||||||
- [x] Tests
|
- [ ] Gallery panel
|
||||||
- [x] Gallery panel
|
|
||||||
- [ ] Review
|
- [ ] Review
|
||||||
|
|
||||||
## Queue — to follow
|
## 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.
|
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
|
||||||
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
|
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
|
||||||
unit time).
|
unit time).
|
||||||
@@ -61,12 +58,20 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
screen-reader label
|
screen-reader label
|
||||||
- [x] Combobox — type-to-filter picker, create / keep / revert blur modes
|
- [x] Combobox — type-to-filter picker, create / keep / revert blur modes
|
||||||
- [x] Command palette — filtered actions; the matcher stays caller-owned
|
- [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`)
|
### data (`crates/data`)
|
||||||
|
|
||||||
- [x] List, Tree, Tabs (underline default + signed pill), Sidebar (scrolls
|
- [x] List, Tree, Tabs (underline default + signed pill), Sidebar (scrolls
|
||||||
internally)
|
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`)
|
### layout (`crates/layout`)
|
||||||
|
|
||||||
- [x] ResizeHandle
|
- [x] ResizeHandle
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ gpui_platform = { workspace = true }
|
|||||||
reqwest_client = { workspace = true }
|
reqwest_client = { workspace = true }
|
||||||
gpui-native-core = { workspace = true, features = ["full"] }
|
gpui-native-core = { workspace = true, features = ["full"] }
|
||||||
gpui-native-data = { 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-layout = { workspace = true }
|
||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ use gpui_native_data::list::{List, ListItem};
|
|||||||
use gpui_native_data::sidebar::{Sidebar, SidebarEntry};
|
use gpui_native_data::sidebar::{Sidebar, SidebarEntry};
|
||||||
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
||||||
use gpui_native_data::tree::{Tree, TreeItem};
|
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_icons::{IconAssets, IconRole, icon};
|
||||||
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||||
use gpui_native_layout::resize_handle::ResizeHandle;
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
@@ -82,6 +84,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("tabs", "Tabs"),
|
("tabs", "Tabs"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
("Editor", &[("code-view", "Code view")]),
|
||||||
(
|
(
|
||||||
"Feedback",
|
"Feedback",
|
||||||
&[
|
&[
|
||||||
@@ -623,6 +626,12 @@ impl Gallery {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("code-view", "Code view", |_, cx| {
|
||||||
|
cx.new(CodeViewDemo::new).into()
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
||||||
cx.new(|_| SidebarDemo { selected: None }).into()
|
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<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 CodeViewDemo {
|
||||||
|
code: Entity<CodeViewState>,
|
||||||
|
sample: CodeSample,
|
||||||
|
large_source: SharedString,
|
||||||
|
_subscription: gpui::Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeViewDemo {
|
||||||
|
fn new(cx: &mut Context<Self>) -> 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<Self>) -> 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 {
|
struct SidebarDemo {
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user