feat: add editor component
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user