T17 Editor: CodeEditor with gutter, folds, minimap, rulers, guides, brackets, whitespace, cursors, multi-cursor, sticky scroll, inlay hints, lenses, decorations, ghost text, inline chat and diff (T17a); completions, signatures, hover, code actions, rename, references, outline, go to symbol, call and type hierarchy (T17b); find and replace, search panel and filters (T17c); problems, output, debug console, extensions, settings with JSON, keybindings, theme previews, welcome, recent projects, switcher, task runner (T17d); status items (T17e)
Shared: generic undo History, OverflowMenu icon and tooltip, StatusBarItem leading, six Lucide icons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c229a096ca
commit
d05754ac19
59 files changed
+10820
-71
No files matched your search
@@ -0,0 +1,353 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, ElementInputHandler, InteractiveElement, IntoElement, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, StyledText, Window,
|
||||
canvas, div, fill, font, point, size, uniform_list,
|
||||
};
|
||||
|
||||
use super::{
|
||||
decor::ReadOnlyBanner,
|
||||
keys::{CONTEXT, listen},
|
||||
layout::{Row, around, hidden, rows},
|
||||
state::{CodeEditor, EditorEvent},
|
||||
syntax::{self, Bracket, brackets, folds},
|
||||
};
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
/// What one render worked out, for the rows the list builds after it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Frame {
|
||||
pub rows: Vec<Row>,
|
||||
pub folds: Vec<(usize, usize)>,
|
||||
pub brackets: Vec<Bracket>,
|
||||
pub advance: Pixels,
|
||||
pub line: Pixels,
|
||||
pub matched: Option<(usize, usize)>,
|
||||
pub occurrences: Vec<Range<usize>>,
|
||||
/// The minimap's box, from its last paint.
|
||||
pub minimap: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
/// Lines of code a row's height holds, as leading.
|
||||
const LEADING: f32 = 1.6;
|
||||
/// Blocks the sticky header shows at most.
|
||||
const STICKY: usize = 3;
|
||||
/// Columns the minimap spans, in code characters.
|
||||
const MINIMAP: f32 = 12.0;
|
||||
|
||||
impl CodeEditor {
|
||||
/// The text the primary selection or its word gives, for highlighting where else it appears.
|
||||
fn needle(&self) -> Option<String> {
|
||||
let primary = self.primary();
|
||||
let range = if primary.is_empty() {
|
||||
let word = self.buffer.word_at(primary.head);
|
||||
let text = &self.buffer.text()[word.clone()];
|
||||
text.chars()
|
||||
.all(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
.then_some(word)?
|
||||
} else {
|
||||
primary.range()
|
||||
};
|
||||
(!range.is_empty()).then(|| self.buffer.text()[range].to_string())
|
||||
}
|
||||
|
||||
/// The first line the list shows, after scrolling.
|
||||
fn top_line(&self) -> Option<usize> {
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let first = (scrolled / self.frame.line).floor().max(0.0) as usize;
|
||||
self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..]
|
||||
.iter()
|
||||
.find_map(|row| match row {
|
||||
Row::Line(line) => Some(*line),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn sticky(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let top = self.top_line()?;
|
||||
let headers = around(&self.frame.folds, top, STICKY);
|
||||
if headers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rows = headers.into_iter().map(|line| {
|
||||
let text = self.buffer.line(line).to_string();
|
||||
let styles = syntax::colors(&text, cx);
|
||||
let jump = cx.entity();
|
||||
div()
|
||||
.id(("sticky", line))
|
||||
.h(self.frame.line)
|
||||
.flex()
|
||||
.items_center()
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
jump.update(cx, |editor, cx| {
|
||||
let at = editor.buffer.line_range(line).start + editor.buffer.indent(line);
|
||||
let cursor = at..at;
|
||||
editor.select([cursor], cx);
|
||||
editor.reveal(false, cx);
|
||||
});
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(self.frame.advance * (self.gutter_columns() + 1) as f32),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_height(self.frame.line)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(StyledText::new(text).with_highlights(styles)),
|
||||
)
|
||||
});
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bg(colors.surface)
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.children(rows)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn minimap_view(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (advance, line) = (self.frame.advance, self.frame.line);
|
||||
let tokens: Vec<Vec<(usize, usize, gpui::Hsla)>> = self
|
||||
.frame
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| match row {
|
||||
Row::Line(at) => {
|
||||
let text = self.buffer.line(*at);
|
||||
syntax::colors(text, cx)
|
||||
.into_iter()
|
||||
.filter_map(|(range, style)| {
|
||||
let start = text[..range.start].chars().count();
|
||||
let len = text[range].chars().count();
|
||||
style.color.map(|color| (start, len, color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let scroll = self.scroll.clone();
|
||||
let entity = cx.entity();
|
||||
let (drag, press) = (cx.entity(), cx.entity());
|
||||
let shade = colors.hover;
|
||||
div()
|
||||
.id("minimap")
|
||||
.relative()
|
||||
.flex_none()
|
||||
.w(advance * MINIMAP)
|
||||
.h_full()
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
entity.update(cx, |editor, _| editor.frame.minimap = bounds);
|
||||
},
|
||||
move |bounds, _, window, _| {
|
||||
let rows = tokens.len().max(1) as f32;
|
||||
let tall = (line * 0.15).min(bounds.size.height / rows);
|
||||
let wide = advance * 0.12;
|
||||
let state = scroll.0.borrow();
|
||||
let viewport = state.base_handle.bounds().size.height;
|
||||
let scrolled = -state.base_handle.offset().y;
|
||||
let top = bounds.origin.y + scrolled / line * tall;
|
||||
let extent = size(bounds.size.width, viewport / line * tall);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(bounds.origin.x, top), extent),
|
||||
shade,
|
||||
));
|
||||
for (ix, spans) in tokens.iter().enumerate() {
|
||||
for (start, len, color) in spans {
|
||||
let origin = point(
|
||||
bounds.origin.x + wide * (*start + 2) as f32,
|
||||
bounds.origin.y + tall * ix as f32,
|
||||
);
|
||||
let extent = size(wide * *len as f32, tall * 0.7);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(origin, extent),
|
||||
color.opacity(0.7),
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| {
|
||||
press.update(cx, |editor, cx| editor.jump(event.position.y, cx));
|
||||
})
|
||||
.on_mouse_move(move |event: &MouseMoveEvent, _, cx| {
|
||||
if event.dragging() {
|
||||
drag.update(cx, |editor, cx| editor.jump(event.position.y, cx));
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Scrolls so the row under a minimap point sits in the middle of the view.
|
||||
fn jump(&mut self, y: Pixels, cx: &mut Context<Self>) {
|
||||
let bounds = self.frame.minimap;
|
||||
let rows = self.frame.rows.len().max(1) as f32;
|
||||
let tall = (self.frame.line * 0.15).min(bounds.size.height / rows);
|
||||
let row = ((y - bounds.origin.y) / tall).max(0.0);
|
||||
let state = self.scroll.0.borrow();
|
||||
let viewport = state.base_handle.bounds().size.height;
|
||||
let top = (self.frame.line * row - viewport / 2.0).max(Pixels::ZERO);
|
||||
state.base_handle.set_offset(point(Pixels::ZERO, -top));
|
||||
drop(state);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CodeEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let sticky = if self.options.sticky {
|
||||
self.sticky(cx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let size = theme.text_size(TextSize::Sm);
|
||||
let pixels = size.to_pixels(window.rem_size());
|
||||
let mono = window
|
||||
.text_system()
|
||||
.resolve_font(&font(theme.mono_family.clone()));
|
||||
let advance = window
|
||||
.text_system()
|
||||
.advance(mono, pixels, 'm')
|
||||
.expect("the code font has an m")
|
||||
.width;
|
||||
let line = (pixels * LEADING).round();
|
||||
let found = folds(&self.buffer);
|
||||
let hide = hidden(self.buffer.lines(), &found, &self.folded);
|
||||
let found_brackets = brackets(&self.buffer);
|
||||
let matched = syntax::matched(&found_brackets, self.primary().head);
|
||||
let occurrences = match self.needle() {
|
||||
Some(needle) if self.focus.is_focused(window) => {
|
||||
syntax::occurrences(&self.buffer, &needle, 0..self.buffer.lines())
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let minimap = self.frame.minimap;
|
||||
self.frame = Frame {
|
||||
rows: rows(&self.buffer, &hide, &self.marks),
|
||||
folds: found,
|
||||
brackets: found_brackets,
|
||||
advance,
|
||||
line,
|
||||
matched,
|
||||
occurrences,
|
||||
minimap,
|
||||
};
|
||||
self.metrics.advance = advance;
|
||||
self.metrics.line = line;
|
||||
let count = self.frame.rows.len();
|
||||
let list = uniform_list(
|
||||
"editor-rows",
|
||||
count,
|
||||
cx.processor(|editor, range: Range<usize>, window, cx| {
|
||||
range.map(|ix| editor.row(ix, window, cx)).collect()
|
||||
}),
|
||||
)
|
||||
.track_scroll(self.scroll.clone())
|
||||
.size_full();
|
||||
let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity());
|
||||
let unlock = cx.entity();
|
||||
let banner = self.options.read_only.then(|| {
|
||||
ReadOnlyBanner::new(
|
||||
("read-only", cx.entity_id().as_u64()),
|
||||
"This file is read-only.",
|
||||
)
|
||||
.action("Make editable", move |_, cx| {
|
||||
unlock.update(cx, |editor, cx| {
|
||||
editor.set_read_only(false, cx);
|
||||
cx.emit(EditorEvent::Unlock);
|
||||
})
|
||||
})
|
||||
});
|
||||
let body = div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.child(list)
|
||||
.children(sticky)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
top.update(cx, |editor, _| editor.metrics.top = bounds.origin.y);
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
);
|
||||
let root = div()
|
||||
.id(("code-editor", cx.entity_id().as_u64()))
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.bg(colors.surface)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(size)
|
||||
.text_color(colors.fg)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|editor, _, _, _| editor.dragging = false),
|
||||
)
|
||||
.on_mouse_up_out(
|
||||
MouseButton::Left,
|
||||
cx.listener(|editor, _, _, _| editor.dragging = false),
|
||||
)
|
||||
.children(banner)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.flex()
|
||||
.child(body)
|
||||
.children(self.options.minimap.then(|| self.minimap_view(cx))),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, cx| {
|
||||
window.handle_input(
|
||||
&focus,
|
||||
ElementInputHandler::new(bounds, input.clone()),
|
||||
cx,
|
||||
);
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
);
|
||||
listen(root, cx)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user