Shared: generic undo History, OverflowMenu icon and tooltip, StatusBarItem leading, six Lucide icons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
13 KiB
Rust
354 lines
13 KiB
Rust
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)
|
|
}
|
|
}
|