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,254 @@
|
||||
use std::ops::Range;
|
||||
|
||||
/// Source text and where each line starts, in bytes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Buffer {
|
||||
text: String,
|
||||
starts: Vec<usize>,
|
||||
}
|
||||
|
||||
fn starts(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
.chain(text.match_indices('\n').map(|(at, _)| at + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
let starts = starts(&text);
|
||||
Self { text, starts }
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.text.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
/// How many lines it holds; an empty buffer holds one.
|
||||
pub fn lines(&self) -> usize {
|
||||
self.starts.len()
|
||||
}
|
||||
|
||||
/// A line's bytes, without its line break.
|
||||
pub fn line_range(&self, line: usize) -> Range<usize> {
|
||||
let start = self.starts[line];
|
||||
let end = self
|
||||
.starts
|
||||
.get(line + 1)
|
||||
.map_or(self.text.len(), |next| next - 1);
|
||||
start..end
|
||||
}
|
||||
|
||||
pub fn line(&self, line: usize) -> &str {
|
||||
&self.text[self.line_range(line)]
|
||||
}
|
||||
|
||||
/// The line holding `offset`.
|
||||
pub fn line_of(&self, offset: usize) -> usize {
|
||||
self.starts.partition_point(|start| *start <= offset) - 1
|
||||
}
|
||||
|
||||
/// An offset's line, and its column in characters.
|
||||
pub fn point(&self, offset: usize) -> (usize, usize) {
|
||||
let line = self.line_of(offset);
|
||||
let column = self.text[self.starts[line]..offset].chars().count();
|
||||
(line, column)
|
||||
}
|
||||
|
||||
/// The offset at a line and a column in characters, held to the line's end.
|
||||
pub fn offset(&self, line: usize, column: usize) -> usize {
|
||||
let range = self.line_range(line.min(self.lines() - 1));
|
||||
self.text[range.clone()]
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(range.end, |(at, _)| range.start + at)
|
||||
}
|
||||
|
||||
/// The character after `offset`, or the end.
|
||||
pub fn next(&self, offset: usize) -> usize {
|
||||
self.text[offset..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(offset, |ch| offset + ch.len_utf8())
|
||||
}
|
||||
|
||||
/// The character before `offset`, or the start.
|
||||
pub fn previous(&self, offset: usize) -> usize {
|
||||
self.text[..offset]
|
||||
.chars()
|
||||
.next_back()
|
||||
.map_or(offset, |ch| offset - ch.len_utf8())
|
||||
}
|
||||
|
||||
/// How far a line is indented, in characters.
|
||||
pub fn indent(&self, line: usize) -> usize {
|
||||
self.line(line)
|
||||
.chars()
|
||||
.take_while(|ch| *ch == ' ' || *ch == '\t')
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(crate) fn replace(&mut self, range: Range<usize>, text: &str) {
|
||||
assert!(
|
||||
range.start <= range.end
|
||||
&& range.end <= self.text.len()
|
||||
&& self.text.is_char_boundary(range.start)
|
||||
&& self.text.is_char_boundary(range.end),
|
||||
"an edit covers whole characters inside the text: {range:?} of {}",
|
||||
self.text.len()
|
||||
);
|
||||
self.text.replace_range(range, text);
|
||||
self.starts = starts(&self.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of character a word motion sees.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Class {
|
||||
Space,
|
||||
Word,
|
||||
Mark,
|
||||
}
|
||||
|
||||
fn class(ch: char) -> Class {
|
||||
if ch.is_alphanumeric() || ch == '_' {
|
||||
Class::Word
|
||||
} else if ch.is_whitespace() {
|
||||
Class::Space
|
||||
} else {
|
||||
Class::Mark
|
||||
}
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
/// The start of the word before `offset`, over any spaces first; a line start stops it.
|
||||
pub fn word_start(&self, offset: usize) -> usize {
|
||||
let line_start = self.starts[self.line_of(offset)];
|
||||
if offset == line_start {
|
||||
return self.previous(offset);
|
||||
}
|
||||
let mut chars = self.text[line_start..offset]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.peekable();
|
||||
while chars
|
||||
.next_if(|(_, ch)| class(*ch) == Class::Space)
|
||||
.is_some()
|
||||
{}
|
||||
let Some(&(_, first)) = chars.peek() else {
|
||||
return line_start;
|
||||
};
|
||||
let kind = class(first);
|
||||
let mut at = offset;
|
||||
for (ix, ch) in chars {
|
||||
if class(ch) != kind {
|
||||
break;
|
||||
}
|
||||
at = line_start + ix;
|
||||
}
|
||||
at.min(offset)
|
||||
}
|
||||
|
||||
/// The end of the word after `offset`, over any spaces first; a line end stops it.
|
||||
pub fn word_end(&self, offset: usize) -> usize {
|
||||
let line_end = self.line_range(self.line_of(offset)).end;
|
||||
if offset == line_end {
|
||||
return self.next(offset);
|
||||
}
|
||||
let mut chars = self.text[offset..line_end].char_indices().peekable();
|
||||
while chars
|
||||
.next_if(|(_, ch)| class(*ch) == Class::Space)
|
||||
.is_some()
|
||||
{}
|
||||
let Some(&(_, first)) = chars.peek() else {
|
||||
return line_end;
|
||||
};
|
||||
let kind = class(first);
|
||||
let mut at = line_end;
|
||||
for (ix, ch) in chars {
|
||||
if class(ch) != kind {
|
||||
at = offset + ix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The word around `offset`, or the run of marks or spaces it sits in.
|
||||
pub fn word_at(&self, offset: usize) -> Range<usize> {
|
||||
let range = self.line_range(self.line_of(offset));
|
||||
let line = &self.text[range.clone()];
|
||||
let at = offset - range.start;
|
||||
let kind = line[at..]
|
||||
.chars()
|
||||
.next()
|
||||
.or_else(|| line[..at].chars().next_back())
|
||||
.map(class);
|
||||
let Some(kind) = kind else {
|
||||
return offset..offset;
|
||||
};
|
||||
let start = line[..at]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.take_while(|(_, ch)| class(*ch) == kind)
|
||||
.last()
|
||||
.map_or(at, |(ix, _)| ix);
|
||||
let end = line[at..]
|
||||
.char_indices()
|
||||
.find(|(_, ch)| class(*ch) != kind)
|
||||
.map_or(line.len(), |(ix, _)| at + ix);
|
||||
range.start + start..range.start + end
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_split_at_breaks_and_points_count_characters() {
|
||||
let buffer = Buffer::new("fn main() {\n let π = 3.14;\n}");
|
||||
assert_eq!(buffer.lines(), 3);
|
||||
assert_eq!(buffer.line(1), " let π = 3.14;");
|
||||
let pi = buffer.text().find('π').expect("pi");
|
||||
assert_eq!(buffer.point(pi), (1, 8));
|
||||
assert_eq!(
|
||||
buffer.point(pi + 'π'.len_utf8()),
|
||||
(1, 9),
|
||||
"a column is a character"
|
||||
);
|
||||
assert_eq!(buffer.offset(1, 9), pi + 'π'.len_utf8());
|
||||
assert_eq!(buffer.offset(0, 99), 11, "held to the line's end");
|
||||
assert_eq!(Buffer::new("").lines(), 1);
|
||||
assert_eq!(buffer.indent(1), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_keep_line_starts_in_step() {
|
||||
let mut buffer = Buffer::new("a\nb");
|
||||
buffer.replace(1..1, "\nc");
|
||||
assert_eq!(buffer.text(), "a\nc\nb");
|
||||
assert_eq!(buffer.lines(), 3);
|
||||
assert_eq!(buffer.line(2), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_stop_at_their_class_and_at_line_edges() {
|
||||
let buffer = Buffer::new("let total = price * 2;\nnext");
|
||||
assert_eq!(buffer.word_start(9), 4, "back to the start of total");
|
||||
assert_eq!(buffer.word_start(4), 0, "over the space to let");
|
||||
assert_eq!(buffer.word_end(4), 9);
|
||||
assert_eq!(buffer.word_end(9), 11, "over the space, then the mark");
|
||||
assert_eq!(buffer.word_end(22), 23, "a line end steps to the next line");
|
||||
assert_eq!(buffer.word_at(6), 4..9);
|
||||
assert_eq!(buffer.word_at(10), 10..11, "a mark is its own run");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Div, ElementId, Entity, IntoElement, ParentElement, Refineable, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Input, TextInput},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnAsk = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A prompt between lines of code: ask for a change, send it, or close it. A working state shows while an answer comes.
|
||||
#[derive(IntoElement)]
|
||||
pub struct InlineChat {
|
||||
base: Div,
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
working: bool,
|
||||
on_ask: Option<OnAsk>,
|
||||
on_close: Option<OnClose>,
|
||||
}
|
||||
|
||||
impl InlineChat {
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
base: div(),
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
working: false,
|
||||
on_ask: None,
|
||||
on_close: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An answer is on its way.
|
||||
pub fn working(mut self, working: bool) -> Self {
|
||||
self.working = working;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ask(
|
||||
mut self,
|
||||
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_ask = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_close = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for InlineChat {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for InlineChat {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let asked = self.field.read(cx).text().trim().to_string();
|
||||
let (field, ask) = (self.field.clone(), self.on_ask.clone());
|
||||
let send = move |window: &mut Window, cx: &mut App| {
|
||||
let prompt = field.read(cx).text().trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("inline chat: asked {prompt:?}");
|
||||
if let Some(ask) = &ask {
|
||||
ask(prompt.into(), window, cx);
|
||||
}
|
||||
};
|
||||
let mut chat = div();
|
||||
chat.style().refine(self.base.style());
|
||||
chat.flex()
|
||||
.flex_col()
|
||||
.justify_center()
|
||||
.gap_1p5()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border_strong)
|
||||
.bg(colors.surface)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::Sparkles)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.accent),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&self.field).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
Button::new(
|
||||
(self.id.clone(), "send"),
|
||||
if self.working { "Working" } else { "Send" },
|
||||
)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.loading(self.working)
|
||||
.disabled(asked.is_empty() || self.working)
|
||||
.on_click(move |_, window, cx| send(window, cx)),
|
||||
)
|
||||
.children(self.on_close.map(|close| {
|
||||
IconButton::new((self.id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close")
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Enter sends · Escape closes"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
forms::{Choice, Enter, Input, Select, TextInput},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// The color a log line reads in, from the level it opens with.
|
||||
pub(crate) fn tone(line: &str, colors: &Palette) -> Hsla {
|
||||
let lower = line.trim_start().to_ascii_lowercase();
|
||||
if lower.starts_with("error") || lower.starts_with("[error") {
|
||||
colors.danger
|
||||
} else if lower.starts_with("warn") || lower.starts_with("[warn") {
|
||||
colors.warning
|
||||
} else if lower.starts_with("debug") || lower.starts_with("[debug") {
|
||||
colors.fg_subtle
|
||||
} else {
|
||||
colors.fg_muted
|
||||
}
|
||||
}
|
||||
|
||||
type OnText = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
type OnRun = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A tool's log by channel: pick the channel, clear it, and keep the newest line in view or let it stay put.
|
||||
#[derive(IntoElement)]
|
||||
pub struct OutputPanel {
|
||||
id: ElementId,
|
||||
channels: Vec<SharedString>,
|
||||
channel: SharedString,
|
||||
lines: Vec<SharedString>,
|
||||
follow: bool,
|
||||
on_channel: Option<OnText>,
|
||||
on_clear: Option<OnRun>,
|
||||
on_follow: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl OutputPanel {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
channels: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||
channel: impl Into<SharedString>,
|
||||
lines: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||
) -> Self {
|
||||
let channels: Vec<SharedString> = channels.into_iter().map(Into::into).collect();
|
||||
let channel = channel.into();
|
||||
assert!(
|
||||
channels.contains(&channel),
|
||||
"the shown channel is one of the channels"
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
channels,
|
||||
channel,
|
||||
lines: lines.into_iter().map(Into::into).collect(),
|
||||
follow: true,
|
||||
on_channel: None,
|
||||
on_clear: None,
|
||||
on_follow: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the newest line stays in view.
|
||||
pub fn follow(mut self, follow: bool) -> Self {
|
||||
self.follow = follow;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_channel(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_channel = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_clear = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_follow(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_follow = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for OutputPanel {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let scroll = window.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| {
|
||||
gpui::ScrollHandle::new()
|
||||
});
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let scroll = scroll.read(cx).clone();
|
||||
if self.follow {
|
||||
scroll.scroll_to_bottom();
|
||||
}
|
||||
let channel = Select::new(
|
||||
(self.id.clone(), "channel"),
|
||||
self.channels
|
||||
.iter()
|
||||
.map(|name| Choice::new(name.clone(), name.clone())),
|
||||
)
|
||||
.selected(self.channel.clone())
|
||||
.size(ControlSize::Sm);
|
||||
let channel = match self.on_channel {
|
||||
Some(on_channel) => {
|
||||
channel.on_change(move |name, window, cx| on_channel(name, window, cx))
|
||||
}
|
||||
None => channel,
|
||||
};
|
||||
let follow = ToggleButton::new(
|
||||
(self.id.clone(), "follow"),
|
||||
ToggleItem::new("follow")
|
||||
.icon(IconName::ArrowDownWideNarrow)
|
||||
.tooltip("Keep the newest line in view"),
|
||||
self.follow,
|
||||
)
|
||||
.size(ControlSize::Sm);
|
||||
let follow = match self.on_follow {
|
||||
Some(on_follow) => follow.on_toggle(move |on, window, cx| on_follow(on, window, cx)),
|
||||
None => follow,
|
||||
};
|
||||
let lines = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| div().text_color(tone(line, &colors)).child(line.clone()));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().w(theme.label_width() * 1.5).child(channel))
|
||||
.child(div().flex_1())
|
||||
.child(follow)
|
||||
.children(self.on_clear.map(|clear| {
|
||||
IconButton::new((self.id.clone(), "clear"), IconName::Eraser)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Clear")
|
||||
.on_click(move |_, window, cx| clear(window, cx))
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id((self.id.clone(), "lines"))
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&scroll)
|
||||
.p_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.children(lines),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a debug console shows: what was typed, what came back, a value with its type, or an error.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConsoleEntry {
|
||||
Input(SharedString),
|
||||
Output(SharedString),
|
||||
Value(SharedString, SharedString),
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// A read-eval-print prompt beside a paused program: entries above, the prompt below; Enter evaluates.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DebugConsole {
|
||||
id: ElementId,
|
||||
entries: Vec<ConsoleEntry>,
|
||||
field: Entity<TextInput>,
|
||||
on_eval: Option<OnText>,
|
||||
}
|
||||
|
||||
impl DebugConsole {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
entries: impl IntoIterator<Item = ConsoleEntry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
entries: entries.into_iter().collect(),
|
||||
field: field.clone(),
|
||||
on_eval: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_eval(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_eval = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for DebugConsole {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let syntax = colors.syntax;
|
||||
let prompt = || {
|
||||
div()
|
||||
.flex_none()
|
||||
.w(theme.icon_size(IconSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("›")
|
||||
};
|
||||
let entries = self.entries.iter().map(|entry| {
|
||||
let row = div().flex().items_start().gap_1p5();
|
||||
match entry {
|
||||
ConsoleEntry::Input(text) => row
|
||||
.child(prompt())
|
||||
.child(div().text_color(colors.fg).child(text.clone())),
|
||||
ConsoleEntry::Output(text) => row
|
||||
.pl(theme.icon_size(IconSize::Sm))
|
||||
.child(div().text_color(colors.fg_muted).child(text.clone())),
|
||||
ConsoleEntry::Value(text, kind) => {
|
||||
let ink = match kind.as_ref() {
|
||||
"string" | "&str" | "String" => syntax.string,
|
||||
"bool" => syntax.constant,
|
||||
_ if kind.starts_with(['i', 'u', 'f']) => syntax.number,
|
||||
_ => syntax.variable,
|
||||
};
|
||||
row.pl(theme.icon_size(IconSize::Sm))
|
||||
.child(div().text_color(ink).child(text.clone()))
|
||||
.child(div().text_color(colors.fg_subtle).child(kind.clone()))
|
||||
}
|
||||
ConsoleEntry::Error(text) => row
|
||||
.child(
|
||||
Icon::new(IconName::CircleX)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.danger),
|
||||
)
|
||||
.child(div().text_color(colors.danger).child(text.clone())),
|
||||
}
|
||||
});
|
||||
let (field, eval) = (self.field.clone(), self.on_eval);
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.children(entries)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
let text = field.read(cx).text().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("debug console: {text}");
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
if let Some(eval) = &eval {
|
||||
eval(&text.into(), window, cx);
|
||||
}
|
||||
})
|
||||
.child(prompt())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&self.field).size(ControlSize::Sm)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_take_their_levels_color() {
|
||||
let colors = Palette::light(false);
|
||||
assert_eq!(tone("ERROR: build failed", &colors), colors.danger);
|
||||
assert_eq!(tone(" warning: unused import", &colors), colors.warning);
|
||||
assert_eq!(tone("[debug] tick", &colors), colors.fg_subtle);
|
||||
assert_eq!(tone("Compiling ely v0.1.0", &colors), colors.fg_muted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use super::buffer::Buffer;
|
||||
|
||||
/// A cursor and the text it holds: `head` moves, `anchor` stays; `goal` keeps the column a vertical move aims for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
pub anchor: usize,
|
||||
pub head: usize,
|
||||
pub goal: Option<usize>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn caret(at: usize) -> Self {
|
||||
Self {
|
||||
anchor: at,
|
||||
head: at,
|
||||
goal: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Range<usize> {
|
||||
self.anchor.min(self.head)..self.anchor.max(self.head)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.anchor == self.head
|
||||
}
|
||||
|
||||
/// Moves the head, keeping the anchor when `extend`, or collapsing onto the head.
|
||||
fn to(self, head: usize, extend: bool) -> Self {
|
||||
Self {
|
||||
anchor: if extend { self.anchor } else { head },
|
||||
head,
|
||||
goal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted by where they start, with overlapping selections joined into one.
|
||||
pub(crate) fn merged(mut all: Vec<Selection>) -> Vec<Selection> {
|
||||
assert!(!all.is_empty(), "an editor keeps at least one cursor");
|
||||
all.sort_by_key(|selection| selection.range().start);
|
||||
let mut out: Vec<Selection> = Vec::with_capacity(all.len());
|
||||
for next in all {
|
||||
match out.last_mut() {
|
||||
Some(last)
|
||||
if next.range().start < last.range().end
|
||||
|| (next.range() == last.range() && next.is_empty()) =>
|
||||
{
|
||||
let range = last.range().start..last.range().end.max(next.range().end);
|
||||
let forward = last.head >= last.anchor;
|
||||
*last = Selection {
|
||||
anchor: if forward { range.start } else { range.end },
|
||||
head: if forward { range.end } else { range.start },
|
||||
goal: None,
|
||||
};
|
||||
}
|
||||
_ => out.push(next),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a cursor goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Motion {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
WordLeft,
|
||||
WordRight,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
Start,
|
||||
End,
|
||||
/// Down or up a page of this many lines.
|
||||
Page(isize),
|
||||
}
|
||||
|
||||
/// A selection after a motion. Left and Right close a selection to its edge first.
|
||||
pub(crate) fn moved(
|
||||
buffer: &Buffer,
|
||||
selection: Selection,
|
||||
motion: Motion,
|
||||
extend: bool,
|
||||
) -> Selection {
|
||||
let head = selection.head;
|
||||
if !extend && !selection.is_empty() {
|
||||
match motion {
|
||||
Motion::Left => return Selection::caret(selection.range().start),
|
||||
Motion::Right => return Selection::caret(selection.range().end),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let vertical = |lines: isize| {
|
||||
let (line, column) = buffer.point(head);
|
||||
let goal = selection.goal.unwrap_or(column);
|
||||
let target = line as isize + lines;
|
||||
let offset = if target < 0 {
|
||||
0
|
||||
} else if target >= buffer.lines() as isize {
|
||||
buffer.len()
|
||||
} else {
|
||||
buffer.offset(target as usize, goal)
|
||||
};
|
||||
Selection {
|
||||
goal: Some(goal),
|
||||
..selection.to(offset, extend)
|
||||
}
|
||||
};
|
||||
match motion {
|
||||
Motion::Left => selection.to(buffer.previous(head), extend),
|
||||
Motion::Right => selection.to(buffer.next(head), extend),
|
||||
Motion::Up => vertical(-1),
|
||||
Motion::Down => vertical(1),
|
||||
Motion::Page(lines) => vertical(lines),
|
||||
Motion::WordLeft => selection.to(buffer.word_start(head), extend),
|
||||
Motion::WordRight => selection.to(buffer.word_end(head), extend),
|
||||
Motion::LineStart => {
|
||||
let line = buffer.line_of(head);
|
||||
let start = buffer.line_range(line).start;
|
||||
let text = buffer.line_range(line).start + buffer.indent(line);
|
||||
selection.to(if head == text { start } else { text }, extend)
|
||||
}
|
||||
Motion::LineEnd => selection.to(buffer.line_range(buffer.line_of(head)).end, extend),
|
||||
Motion::Start => selection.to(0, extend),
|
||||
Motion::End => selection.to(buffer.len(), extend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces each selection's range with its text, all at once, and returns a caret after each insert.
|
||||
pub(crate) fn replace_each(
|
||||
buffer: &mut Buffer,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
) -> Vec<Selection> {
|
||||
let mut order: Vec<usize> = (0..edits.len()).collect();
|
||||
order.sort_by_key(|ix| edits[*ix].0.start);
|
||||
assert!(
|
||||
order
|
||||
.windows(2)
|
||||
.all(|pair| edits[pair[0]].0.end <= edits[pair[1]].0.start),
|
||||
"edits do not overlap"
|
||||
);
|
||||
let mut carets = vec![Selection::caret(0); edits.len()];
|
||||
let mut shift: isize = 0;
|
||||
for ix in &order {
|
||||
let (range, text) = &edits[*ix];
|
||||
let start = (range.start as isize + shift) as usize;
|
||||
carets[*ix] = Selection::caret(start + text.len());
|
||||
shift += text.len() as isize - range.len() as isize;
|
||||
}
|
||||
for ix in order.iter().rev() {
|
||||
let (range, text) = &edits[*ix];
|
||||
buffer.replace(range.clone(), text);
|
||||
}
|
||||
carets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overlapping_selections_join_and_carets_on_one_spot_become_one() {
|
||||
let joined = merged(vec![
|
||||
Selection {
|
||||
anchor: 4,
|
||||
head: 8,
|
||||
goal: None,
|
||||
},
|
||||
Selection {
|
||||
anchor: 6,
|
||||
head: 10,
|
||||
goal: None,
|
||||
},
|
||||
Selection::caret(12),
|
||||
Selection::caret(12),
|
||||
]);
|
||||
assert_eq!(joined.len(), 2);
|
||||
assert_eq!(joined[0].range(), 4..10);
|
||||
assert_eq!(joined[1], Selection::caret(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_moves_keep_their_column_through_short_lines() {
|
||||
let buffer = Buffer::new("abcdef\nab\nabcdef");
|
||||
let start = Selection::caret(5);
|
||||
let down = moved(&buffer, start, Motion::Down, false);
|
||||
assert_eq!(
|
||||
buffer.point(down.head),
|
||||
(1, 2),
|
||||
"held to the short line's end"
|
||||
);
|
||||
let again = moved(&buffer, down, Motion::Down, false);
|
||||
assert_eq!(
|
||||
buffer.point(again.head),
|
||||
(2, 5),
|
||||
"back to the column it aimed for"
|
||||
);
|
||||
let selected = moved(&buffer, start, Motion::End, true);
|
||||
assert_eq!(selected.range(), 5..buffer.len());
|
||||
assert_eq!(
|
||||
moved(&buffer, selected, Motion::Left, false),
|
||||
Selection::caret(5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_toggles_between_the_text_and_the_line_start() {
|
||||
let buffer = Buffer::new(" let x;");
|
||||
let first = moved(&buffer, Selection::caret(9), Motion::LineStart, false);
|
||||
assert_eq!(first.head, 4);
|
||||
assert_eq!(moved(&buffer, first, Motion::LineStart, false).head, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_at_many_cursors_land_where_each_one_was() {
|
||||
let mut buffer = Buffer::new("a1\nb2\nc3");
|
||||
let carets = replace_each(
|
||||
&mut buffer,
|
||||
vec![(7..7, "!".into()), (1..1, "!".into()), (4..4, "!".into())],
|
||||
);
|
||||
assert_eq!(buffer.text(), "a!1\nb!2\nc!3");
|
||||
assert_eq!(
|
||||
carets.iter().map(|caret| caret.head).collect::<Vec<_>>(),
|
||||
[10, 2, 6]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A problem in a span of code: squiggled under it and marked in the gutter.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
pub range: Range<usize>,
|
||||
pub severity: Severity,
|
||||
pub message: SharedString,
|
||||
}
|
||||
|
||||
/// A note set into the code at an offset, such as an inferred type or a parameter name.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct InlayHint {
|
||||
pub offset: usize,
|
||||
pub text: SharedString,
|
||||
}
|
||||
|
||||
/// Actions over a line, such as "3 references" or "Run test", in a quiet row above it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CodeLens {
|
||||
pub line: usize,
|
||||
pub items: Vec<SharedString>,
|
||||
}
|
||||
|
||||
/// A suggestion shown in grey at an offset; its first line continues the line, the rest follow below.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GhostText {
|
||||
pub offset: usize,
|
||||
pub text: SharedString,
|
||||
}
|
||||
|
||||
/// A change against the saved text: lines added from `line` on, and the lines they replaced, shown above them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiffHunk {
|
||||
pub line: usize,
|
||||
pub added: usize,
|
||||
pub removed: Vec<SharedString>,
|
||||
}
|
||||
|
||||
/// A line's state against version control.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GitMark {
|
||||
Added,
|
||||
Modified,
|
||||
/// Lines were removed just below this one.
|
||||
Deleted,
|
||||
}
|
||||
|
||||
type OnUnlock = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A strip above code that cannot be changed here, why, and a way to change it anyway.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReadOnlyBanner {
|
||||
id: ElementId,
|
||||
message: SharedString,
|
||||
action: Option<(SharedString, OnUnlock)>,
|
||||
}
|
||||
|
||||
impl ReadOnlyBanner {
|
||||
pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
message: message.into(),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
handler: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.action = Some((label.into(), Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReadOnlyBanner {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.bg(colors.warning.opacity(0.1))
|
||||
.border_b_1()
|
||||
.border_color(colors.warning.opacity(0.3))
|
||||
.font_family(theme.font_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(
|
||||
Icon::new(IconName::Lock)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.warning),
|
||||
)
|
||||
.child(div().flex_1().child(self.message))
|
||||
.children(self.action.map(|(label, handler)| {
|
||||
Button::new(self.id, label)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| handler(window, cx))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which mode a modal editor is in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Replace,
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
fn word(self) -> &'static str {
|
||||
match self {
|
||||
VimMode::Normal => "NORMAL",
|
||||
VimMode::Insert => "INSERT",
|
||||
VimMode::Visual => "VISUAL",
|
||||
VimMode::Replace => "REPLACE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The modal editing mode as a small tag, tinted by mode, and a pending command after it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VimModeIndicator {
|
||||
mode: VimMode,
|
||||
pending: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl VimModeIndicator {
|
||||
pub fn new(mode: VimMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys typed toward a command, such as `d2`.
|
||||
pub fn pending(mut self, keys: impl Into<SharedString>) -> Self {
|
||||
self.pending = Some(keys.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VimModeIndicator {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let ink = match self.mode {
|
||||
VimMode::Normal => colors.fg_muted,
|
||||
VimMode::Insert => colors.success,
|
||||
VimMode::Visual => colors.warning,
|
||||
VimMode::Replace => colors.danger,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(ink.opacity(0.14))
|
||||
.text_color(ink)
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child(self.mode.word()),
|
||||
)
|
||||
.children(self.pending.map(|keys| {
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg_muted)
|
||||
.child(keys)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{ClipboardItem, Context};
|
||||
|
||||
use super::{
|
||||
buffer::Buffer,
|
||||
cursor::{Motion, Selection, merged, replace_each},
|
||||
state::CodeEditor,
|
||||
};
|
||||
|
||||
/// Pairs typing an opener closes at once.
|
||||
const PAIRS: [(char, char); 5] = [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')];
|
||||
|
||||
impl CodeEditor {
|
||||
/// Replaces each selection with `text`; typing in one burst makes one undo step.
|
||||
pub(crate) fn insert(&mut self, text: &str, typing: bool, cx: &mut Context<Self>) {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), text.to_string()))
|
||||
.collect();
|
||||
self.apply(edits, typing, cx);
|
||||
}
|
||||
|
||||
/// Replaces ranges all at once and leaves a caret after each insert.
|
||||
pub(crate) fn apply(
|
||||
&mut self,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
typing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.editable() {
|
||||
log::debug!("code editor: read only, edit dropped");
|
||||
return;
|
||||
}
|
||||
let before = self.snapshot();
|
||||
let carets = replace_each(&mut self.buffer, joined(edits));
|
||||
self.selections = merged(carets);
|
||||
self.marked = None;
|
||||
self.commit(before, typing, cx);
|
||||
self.reveal(true, cx);
|
||||
}
|
||||
|
||||
/// Types text at each cursor; an opener brings its closer, and a closer already there is stepped over.
|
||||
pub(crate) fn type_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
let mut chars = text.chars();
|
||||
let (Some(ch), None) = (chars.next(), chars.next()) else {
|
||||
return self.insert(text, true, cx);
|
||||
};
|
||||
let next = |caret: usize| self.buffer.text()[caret..].chars().next();
|
||||
let all_empty = self.selections.iter().all(Selection::is_empty);
|
||||
if all_empty
|
||||
&& PAIRS.iter().any(|(_, close)| *close == ch)
|
||||
&& self
|
||||
.selections
|
||||
.iter()
|
||||
.all(|selection| next(selection.head) == Some(ch))
|
||||
{
|
||||
return self.motion(Motion::Right, false, cx);
|
||||
}
|
||||
let Some((open, close)) = PAIRS.iter().find(|(open, _)| *open == ch) else {
|
||||
return self.insert(text, true, cx);
|
||||
};
|
||||
let free = |caret: usize| {
|
||||
next(caret).is_none_or(|after| after.is_whitespace() || ")]}".contains(after))
|
||||
};
|
||||
if !all_empty || !self.selections.iter().all(|selection| free(selection.head)) {
|
||||
return self.insert(text, true, cx);
|
||||
}
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), format!("{open}{close}")))
|
||||
.collect();
|
||||
self.apply(edits, true, cx);
|
||||
let back: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|caret| Selection::caret(caret.head - close.len_utf8()))
|
||||
.collect();
|
||||
self.set_selections(back, cx);
|
||||
}
|
||||
|
||||
/// Deletes each selection, or what `reach` finds from each bare caret.
|
||||
pub(crate) fn delete_by(
|
||||
&mut self,
|
||||
reach: fn(&Buffer, usize) -> Range<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
reach(&self.buffer, selection.head)
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
(range, String::new())
|
||||
})
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
/// A new line at each cursor, indented like its own; one step deeper after an opener, with the closer on a line of its own.
|
||||
pub(crate) fn newline(&mut self, cx: &mut Context<Self>) {
|
||||
let tab = self.options.tab;
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let at = selection.range().start;
|
||||
let line = self.buffer.line_of(at);
|
||||
let indent: String = self
|
||||
.buffer
|
||||
.line(line)
|
||||
.chars()
|
||||
.take(self.buffer.indent(line))
|
||||
.collect();
|
||||
let before = self.buffer.text()[..at].trim_end().chars().next_back();
|
||||
let after = self.buffer.text()[selection.range().end..].chars().next();
|
||||
let opens = before.is_some_and(|ch| "{[(:".contains(ch));
|
||||
let text = match (opens, after) {
|
||||
(true, Some(close)) if "}])".contains(close) => {
|
||||
format!("\n{indent}{}\n{indent}", " ".repeat(tab))
|
||||
}
|
||||
(true, _) => format!("\n{indent}{}", " ".repeat(tab)),
|
||||
_ => format!("\n{indent}"),
|
||||
};
|
||||
(selection.range(), text)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let closers: Vec<bool> = edits
|
||||
.iter()
|
||||
.map(|(_, text)| text.matches('\n').count() == 2)
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
let placed: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.zip(closers)
|
||||
.map(|(caret, split)| {
|
||||
if split {
|
||||
let line = self.buffer.line_of(caret.head);
|
||||
Selection::caret(self.buffer.line_range(line - 1).end)
|
||||
} else {
|
||||
*caret
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.set_selections(placed, cx);
|
||||
}
|
||||
|
||||
/// The lines the selections touch, each once.
|
||||
fn touched(&self) -> Vec<usize> {
|
||||
let mut lines: Vec<usize> = self
|
||||
.selections
|
||||
.iter()
|
||||
.flat_map(|selection| {
|
||||
let range = selection.range();
|
||||
let last = if range.end > range.start && self.buffer.point(range.end).1 == 0 {
|
||||
range.end - 1
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
self.buffer.line_of(range.start)..=self.buffer.line_of(last)
|
||||
})
|
||||
.collect();
|
||||
lines.dedup();
|
||||
lines
|
||||
}
|
||||
|
||||
/// Tab: takes ghost text when there is some, indents touched lines when a selection spans lines, or fills to the next tab stop.
|
||||
pub(crate) fn indent(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(ghost) = self.marks.ghost.take() {
|
||||
log::info!("code editor: ghost text taken");
|
||||
self.set_selections(vec![Selection::caret(ghost.offset)], cx);
|
||||
return self.insert(&ghost.text, false, cx);
|
||||
}
|
||||
let tab = self.options.tab;
|
||||
let spans_lines = self.selections.iter().any(|selection| {
|
||||
self.buffer.line_of(selection.range().start)
|
||||
!= self.buffer.line_of(selection.range().end)
|
||||
});
|
||||
if !spans_lines {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let column = self.buffer.point(selection.range().start).1;
|
||||
(selection.range(), " ".repeat(tab - column % tab))
|
||||
})
|
||||
.collect();
|
||||
return self.apply(edits, false, cx);
|
||||
}
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter(|line| !self.buffer.line(*line).trim().is_empty())
|
||||
.map(|line| {
|
||||
let start = self.buffer.line_range(line).start;
|
||||
(start..start, " ".repeat(tab))
|
||||
})
|
||||
.collect();
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
/// Shift-Tab: takes one tab stop of indent off each touched line.
|
||||
pub(crate) fn outdent(&mut self, cx: &mut Context<Self>) {
|
||||
let tab = self.options.tab;
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter_map(|line| {
|
||||
let start = self.buffer.line_range(line).start;
|
||||
let cut = self.buffer.indent(line).min(tab);
|
||||
(cut > 0).then(|| (start..start + cut, String::new()))
|
||||
})
|
||||
.collect();
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
/// Applies line-start edits and keeps each selection over the same text.
|
||||
fn shift_lines(
|
||||
&mut self,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
kept: Vec<Selection>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let moves: Vec<(std::ops::Range<usize>, isize)> = edits
|
||||
.iter()
|
||||
.map(|(range, text)| (range.clone(), text.len() as isize - range.len() as isize))
|
||||
.collect();
|
||||
let shift = |offset: usize| -> usize {
|
||||
let mut moved = offset as isize;
|
||||
for (range, delta) in &moves {
|
||||
if range.start < offset {
|
||||
if offset < range.end {
|
||||
moved -= (offset - range.start) as isize;
|
||||
} else {
|
||||
moved += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
moved.max(0) as usize
|
||||
};
|
||||
let kept: Vec<Selection> = kept
|
||||
.into_iter()
|
||||
.map(|selection| Selection {
|
||||
anchor: shift(selection.anchor),
|
||||
head: shift(selection.head),
|
||||
goal: None,
|
||||
})
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
self.set_selections(kept, cx);
|
||||
}
|
||||
|
||||
/// Comments the touched lines with `//`, or takes the comment off when every one has it.
|
||||
pub(crate) fn toggle_comment(&mut self, cx: &mut Context<Self>) {
|
||||
let lines: Vec<usize> = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter(|line| !self.buffer.line(*line).trim().is_empty())
|
||||
.collect();
|
||||
let commented = |line: usize| self.buffer.line(line).trim_start().starts_with("//");
|
||||
let kept = self.selections.clone();
|
||||
let edits = if !lines.is_empty() && lines.iter().all(|line| commented(*line)) {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let start = self.buffer.line_range(*line).start + self.buffer.indent(*line);
|
||||
let text = &self.buffer.text()[start..];
|
||||
let cut = if text.starts_with("// ") { 3 } else { 2 };
|
||||
(start..start + cut, String::new())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let column = lines
|
||||
.iter()
|
||||
.map(|line| self.buffer.indent(*line))
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let at = self.buffer.offset(*line, column);
|
||||
(at..at, "// ".to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each selection's text, a line apiece; the whole line for a bare caret.
|
||||
fn copied(&self) -> Vec<(Range<usize>, String)> {
|
||||
self.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
let line = self.buffer.line_of(selection.head);
|
||||
let range = self.buffer.line_range(line);
|
||||
range.start..self.buffer.next(range.end)
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
(range.clone(), self.buffer.text()[range].to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn copy(&mut self, cx: &mut Context<Self>) {
|
||||
let text: Vec<String> = self.copied().into_iter().map(|(_, text)| text).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
}
|
||||
|
||||
pub(crate) fn cut(&mut self, cx: &mut Context<Self>) {
|
||||
let copied = self.copied();
|
||||
let text: Vec<String> = copied.iter().map(|(_, text)| text.clone()).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
self.apply(
|
||||
copied
|
||||
.into_iter()
|
||||
.map(|(range, _)| (range, String::new()))
|
||||
.collect(),
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pastes one line at each cursor when the counts match, else the whole text at every one.
|
||||
pub(crate) fn paste(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let edits = if lines.len() == self.selections.len() && lines.len() > 1 {
|
||||
self.selections
|
||||
.iter()
|
||||
.zip(lines)
|
||||
.map(|(selection, line)| (selection.range(), line.to_string()))
|
||||
.collect()
|
||||
} else {
|
||||
self.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), text.clone()))
|
||||
.collect()
|
||||
};
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits sorted, with overlapping ranges joined; the first text of a joined run is kept.
|
||||
fn joined(mut edits: Vec<(Range<usize>, String)>) -> Vec<(Range<usize>, String)> {
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
let mut out: Vec<(Range<usize>, String)> = Vec::with_capacity(edits.len());
|
||||
for (range, text) in edits {
|
||||
match out.last_mut() {
|
||||
Some((last, _)) if range.start < last.end => last.end = last.end.max(range.end),
|
||||
_ => out.push((range, text)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
charts::compact,
|
||||
data_display::Avatar,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// Where an extension stands here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtensionState {
|
||||
Available,
|
||||
Installing,
|
||||
Installed,
|
||||
Disabled,
|
||||
/// Installed, with a newer version out.
|
||||
Outdated,
|
||||
}
|
||||
|
||||
/// An extension in the marketplace: who makes it, what it does, how many use it and how they rate it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Extension {
|
||||
pub id: SharedString,
|
||||
pub name: SharedString,
|
||||
pub publisher: SharedString,
|
||||
pub description: SharedString,
|
||||
pub version: SharedString,
|
||||
pub installs: u64,
|
||||
pub rating: f32,
|
||||
pub state: ExtensionState,
|
||||
}
|
||||
|
||||
/// What a press on an extension's button asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtensionAction {
|
||||
Install,
|
||||
Uninstall,
|
||||
Enable,
|
||||
Update,
|
||||
}
|
||||
|
||||
type OnAction = Rc<dyn Fn(&SharedString, ExtensionAction, &mut Window, &mut App)>;
|
||||
|
||||
/// Extensions, each with its mark, name, maker, what it does, its reach and rating, and the one action it needs next.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ExtensionsPanel {
|
||||
id: ElementId,
|
||||
extensions: Vec<Extension>,
|
||||
on_action: Option<OnAction>,
|
||||
}
|
||||
|
||||
impl ExtensionsPanel {
|
||||
pub fn new(id: impl Into<ElementId>, extensions: impl IntoIterator<Item = Extension>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
extensions: extensions.into_iter().collect(),
|
||||
on_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_action(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, ExtensionAction, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_action = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ExtensionsPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let rows = self.extensions.iter().enumerate().map(|(ix, extension)| {
|
||||
let action = match extension.state {
|
||||
ExtensionState::Available => {
|
||||
Some((ExtensionAction::Install, "Install", ButtonVariant::Primary))
|
||||
}
|
||||
ExtensionState::Installing => None,
|
||||
ExtensionState::Installed => Some((
|
||||
ExtensionAction::Uninstall,
|
||||
"Uninstall",
|
||||
ButtonVariant::Ghost,
|
||||
)),
|
||||
ExtensionState::Disabled => {
|
||||
Some((ExtensionAction::Enable, "Enable", ButtonVariant::Secondary))
|
||||
}
|
||||
ExtensionState::Outdated => {
|
||||
Some((ExtensionAction::Update, "Update", ButtonVariant::Primary))
|
||||
}
|
||||
};
|
||||
let button = match action {
|
||||
Some((action, label, variant)) => {
|
||||
let (on_action, id) = (self.on_action.clone(), extension.id.clone());
|
||||
Button::new((self.id.clone(), format!("action-{ix}")), label)
|
||||
.variant(variant)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("extensions: {action:?} {id}");
|
||||
if let Some(on_action) = &on_action {
|
||||
on_action(&id, action, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
None => Button::new((self.id.clone(), format!("action-{ix}")), "Installing")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.size(ControlSize::Sm)
|
||||
.loading(true),
|
||||
};
|
||||
let muted = extension.state == ExtensionState::Disabled;
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.px_2()
|
||||
.py_2p5()
|
||||
.border_b_1()
|
||||
.border_color(colors.border.opacity(0.6))
|
||||
.when(muted, |row| row.opacity(0.6))
|
||||
.child(
|
||||
Avatar::new(
|
||||
(self.id.clone(), format!("avatar-{ix}")),
|
||||
extension.name.clone(),
|
||||
)
|
||||
.size(AvatarSize::Lg),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(extension.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("v{}", extension.version)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(Ellipsis::new(extension.description.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(extension.publisher.clone())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::Download)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(compact(extension.installs as f64)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::Star)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.warning),
|
||||
)
|
||||
.child(format!("{:.1}", extension.rating)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(button)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
|
||||
use crate::{
|
||||
buttons::{ToggleButton, ToggleItem},
|
||||
forms::{Input, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
/// Whether a path passes comma-separated globs: some include pattern when there are any, and no exclude one.
|
||||
pub fn passes(path: &str, include: &str, exclude: &str) -> bool {
|
||||
let patterns = |list: &str| -> Vec<regex::Regex> {
|
||||
list.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|glob| !glob.is_empty())
|
||||
.map(|glob| {
|
||||
let mut pattern = String::from("(^|/)");
|
||||
let mut chars = glob.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'*' if chars.peek() == Some(&'*') => {
|
||||
chars.next();
|
||||
pattern.push_str(".*");
|
||||
}
|
||||
'*' => pattern.push_str("[^/]*"),
|
||||
'?' => pattern.push_str("[^/]"),
|
||||
other => pattern.push_str(®ex::escape(&other.to_string())),
|
||||
}
|
||||
}
|
||||
pattern.push('$');
|
||||
regex::Regex::new(&pattern).expect("an escaped glob compiles")
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (include, exclude) = (patterns(include), patterns(exclude));
|
||||
(include.is_empty() || include.iter().any(|glob| glob.is_match(path)))
|
||||
&& !exclude.iter().any(|glob| glob.is_match(path))
|
||||
}
|
||||
|
||||
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Which files a search reads: globs to include and to exclude, and whether ignored files stay out.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchFilters {
|
||||
id: ElementId,
|
||||
include: Entity<TextInput>,
|
||||
exclude: Entity<TextInput>,
|
||||
ignore: bool,
|
||||
on_ignore: Option<OnBool>,
|
||||
}
|
||||
|
||||
impl SearchFilters {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
include: &Entity<TextInput>,
|
||||
exclude: &Entity<TextInput>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
include: include.clone(),
|
||||
exclude: exclude.clone(),
|
||||
ignore: true,
|
||||
on_ignore: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether files the project ignores stay out.
|
||||
pub fn ignore(mut self, ignore: bool) -> Self {
|
||||
self.ignore = ignore;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ignore(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_ignore = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchFilters {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let label = |words: &'static str| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(words)
|
||||
};
|
||||
let ignore = ToggleButton::new(
|
||||
(self.id.clone(), "ignore"),
|
||||
ToggleItem::new("ignore")
|
||||
.icon(IconName::EyeOff)
|
||||
.tooltip("Leave out ignored files"),
|
||||
self.ignore,
|
||||
)
|
||||
.size(ControlSize::Sm);
|
||||
let ignore = match self.on_ignore {
|
||||
Some(on_ignore) => ignore.on_toggle(move |on, window, cx| on_ignore(on, window, cx)),
|
||||
None => ignore,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.child(label("Files to include"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&self.include).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(ignore),
|
||||
)
|
||||
.child(label("Files to exclude"))
|
||||
.child(Input::new(&self.exclude).size(ControlSize::Sm))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn globs_take_includes_then_drop_excludes() {
|
||||
assert!(passes("src/editor/view.rs", "src/**/*.rs", ""));
|
||||
assert!(!passes("examples/main.rs", "src/**", ""));
|
||||
assert!(!passes(
|
||||
"src/editor/tests.rs",
|
||||
"*.rs",
|
||||
"tests.rs, target/**"
|
||||
));
|
||||
assert!(passes("README.md", "", "*.rs"));
|
||||
assert!(!passes("src/a.rs", "src/?.md", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
|
||||
div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
forms::{Input, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// How a search matches: case exactly, whole words only, the query as a regular expression.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FindOptions {
|
||||
pub case: bool,
|
||||
pub word: bool,
|
||||
pub regex: bool,
|
||||
}
|
||||
|
||||
/// Where `query` appears in `text` under `options`; a pattern that does not compile is an error to show.
|
||||
pub fn find_all(
|
||||
text: &str,
|
||||
query: &str,
|
||||
options: FindOptions,
|
||||
) -> Result<Vec<Range<usize>>, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let pattern = if options.regex {
|
||||
query.to_string()
|
||||
} else {
|
||||
regex::escape(query)
|
||||
};
|
||||
let pattern = if options.word {
|
||||
format!(r"\b(?:{pattern})\b")
|
||||
} else {
|
||||
pattern
|
||||
};
|
||||
let found = regex::RegexBuilder::new(&pattern)
|
||||
.case_insensitive(!options.case)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(found
|
||||
.find_iter(text)
|
||||
.filter(|hit| !hit.is_empty())
|
||||
.map(|hit| hit.range())
|
||||
.collect())
|
||||
}
|
||||
|
||||
type OnOptions = Rc<dyn Fn(FindOptions, &mut Window, &mut App)>;
|
||||
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A find box over code: the query with its options, where the current match stands among all, steps between them, and a replace row that folds away.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FindWidget {
|
||||
id: ElementId,
|
||||
find: Entity<TextInput>,
|
||||
replace: Option<Entity<TextInput>>,
|
||||
options: FindOptions,
|
||||
matches: Result<(Option<usize>, usize), SharedString>,
|
||||
on_options: Option<OnOptions>,
|
||||
on_step: Option<OnBool>,
|
||||
on_replace: Option<OnBool>,
|
||||
on_toggle_replace: Option<OnBool>,
|
||||
on_close: Option<OnClose>,
|
||||
}
|
||||
|
||||
impl FindWidget {
|
||||
/// `find` holds the query; `current` is the chosen match among `total`.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
find: &Entity<TextInput>,
|
||||
current: Option<usize>,
|
||||
total: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
find: find.clone(),
|
||||
replace: None,
|
||||
options: FindOptions::default(),
|
||||
matches: Ok((current, total)),
|
||||
on_options: None,
|
||||
on_step: None,
|
||||
on_replace: None,
|
||||
on_toggle_replace: None,
|
||||
on_close: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The query failed to compile; its message shows in place of a count.
|
||||
pub fn error(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.matches = Err(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn options(mut self, options: FindOptions) -> Self {
|
||||
self.options = options;
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the replace row, its text in `field`.
|
||||
pub fn replace(mut self, field: &Entity<TextInput>) -> Self {
|
||||
self.replace = Some(field.clone());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_options(
|
||||
mut self,
|
||||
handler: impl Fn(FindOptions, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_options = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets `true` for the next match, `false` for the previous.
|
||||
pub fn on_step(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_step = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets `true` to replace every match, `false` for the current one.
|
||||
pub fn on_replace(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_replace = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets whether the replace row should show.
|
||||
pub fn on_toggle_replace(
|
||||
mut self,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle_replace = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_close = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FindWidget {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let options = self.options;
|
||||
let toggle = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
on: bool,
|
||||
set: fn(&mut FindOptions, bool)| {
|
||||
let on_options = self.on_options.clone();
|
||||
ToggleButton::new(
|
||||
(self.id.clone(), key),
|
||||
ToggleItem::new(key).icon(icon).tooltip(words),
|
||||
on,
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |on, window, cx| {
|
||||
let mut next = options;
|
||||
set(&mut next, on);
|
||||
log::info!("find: {next:?}");
|
||||
if let Some(on_options) = &on_options {
|
||||
on_options(next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let (count, missing) = match &self.matches {
|
||||
Ok((_, 0)) => ("No results".to_string(), true),
|
||||
Ok((Some(current), total)) => (format!("{} of {total}", current + 1), false),
|
||||
Ok((None, total)) => (format!("{total} found"), false),
|
||||
Err(message) => (message.to_string(), true),
|
||||
};
|
||||
let found = matches!(self.matches, Ok((_, total)) if total > 0);
|
||||
let icon_button = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
enabled: bool,
|
||||
action: Option<OnBool>,
|
||||
value: bool| {
|
||||
IconButton::new((self.id.clone(), key), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.disabled(!enabled)
|
||||
.when_some(action, move |button, action| {
|
||||
button.on_click(move |_, window, cx| action(value, window, cx))
|
||||
})
|
||||
};
|
||||
let showing = self.replace.is_some();
|
||||
let row = || div().flex().items_center().gap_1();
|
||||
div()
|
||||
.w(theme.label_width() * 2.75)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
row()
|
||||
.child(icon_button(
|
||||
"fold",
|
||||
if showing {
|
||||
IconName::ChevronDown
|
||||
} else {
|
||||
IconName::ChevronRight
|
||||
},
|
||||
"Toggle replace",
|
||||
true,
|
||||
self.on_toggle_replace.clone(),
|
||||
!showing,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Input::new(&self.find).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(toggle(
|
||||
"case",
|
||||
IconName::CaseSensitive,
|
||||
"Match case",
|
||||
options.case,
|
||||
|next, on| next.case = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"word",
|
||||
IconName::WholeWord,
|
||||
"Whole word",
|
||||
options.word,
|
||||
|next, on| next.word = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"regex",
|
||||
IconName::Regex,
|
||||
"Regular expression",
|
||||
options.regex,
|
||||
|next, on| next.regex = on,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 0.5)
|
||||
.text_center()
|
||||
.text_color(if missing {
|
||||
colors.danger
|
||||
} else {
|
||||
colors.fg_muted
|
||||
})
|
||||
.child(count),
|
||||
)
|
||||
.child(icon_button(
|
||||
"previous",
|
||||
IconName::ArrowUp,
|
||||
"Previous match",
|
||||
found,
|
||||
self.on_step.clone(),
|
||||
false,
|
||||
))
|
||||
.child(icon_button(
|
||||
"next",
|
||||
IconName::ArrowDown,
|
||||
"Next match",
|
||||
found,
|
||||
self.on_step.clone(),
|
||||
true,
|
||||
))
|
||||
.children(self.on_close.clone().map(|close| {
|
||||
IconButton::new((self.id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close")
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})),
|
||||
)
|
||||
.children(self.replace.as_ref().map(|field| {
|
||||
let (one, all) = (self.on_replace.clone(), self.on_replace.clone());
|
||||
row()
|
||||
.pl(theme.control_height(ControlSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Input::new(field).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "replace"), "Replace")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(one, |button, one| {
|
||||
button.on_click(move |_, window, cx| one(false, window, cx))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "all"), "Replace all")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(all, |button, all| {
|
||||
button.on_click(move |_, window, cx| all(true, window, cx))
|
||||
}),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_by_case_word_and_pattern() {
|
||||
let text = "Let total = total_cost + TOTAL;";
|
||||
let plain = FindOptions::default();
|
||||
assert_eq!(
|
||||
find_all(text, "total", plain).map(|found| found.len()),
|
||||
Ok(3)
|
||||
);
|
||||
let case = FindOptions {
|
||||
case: true,
|
||||
..plain
|
||||
};
|
||||
assert_eq!(
|
||||
find_all(text, "total", case).map(|found| found.len()),
|
||||
Ok(2)
|
||||
);
|
||||
let word = FindOptions {
|
||||
word: true,
|
||||
..plain
|
||||
};
|
||||
assert_eq!(find_all(text, "total", word), Ok(vec![4..9, 25..30]));
|
||||
let pattern = FindOptions {
|
||||
regex: true,
|
||||
..plain
|
||||
};
|
||||
let word = 12..22;
|
||||
assert_eq!(find_all(text, r"total_\w+", pattern), Ok(vec![word]));
|
||||
assert!(
|
||||
find_all(text, "(", pattern).is_err(),
|
||||
"a broken pattern says so"
|
||||
);
|
||||
assert_eq!(
|
||||
find_all(text, "(", plain),
|
||||
Ok(Vec::new()),
|
||||
"and plain text is escaped"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
SharedString, Styled, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
decor::GitMark,
|
||||
state::{CodeEditor, LineNumbers},
|
||||
};
|
||||
use crate::{
|
||||
primitives::{Disclosure, Icon, Severity},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
/// What a gutter cell shows beside a row.
|
||||
pub(crate) enum Cell {
|
||||
Line(usize),
|
||||
/// A removed line of a diff.
|
||||
Removed,
|
||||
Blank,
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Columns the gutter spans: a slot for a breakpoint or a problem, the number, a fold chevron and the git bar.
|
||||
pub(crate) fn gutter_columns(&self) -> usize {
|
||||
let digits = self.buffer.lines().to_string().len().max(2);
|
||||
match self.options.numbers {
|
||||
LineNumbers::Hidden => 5,
|
||||
_ => digits + 6,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(&self, line: usize) -> SharedString {
|
||||
let current = self.buffer.line_of(self.primary().head);
|
||||
match self.options.numbers {
|
||||
LineNumbers::Hidden => SharedString::default(),
|
||||
LineNumbers::Relative if line != current => line.abs_diff(current).to_string().into(),
|
||||
_ => (line + 1).to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The worst problem on a line, if any.
|
||||
fn worst(&self, line: usize) -> Option<Severity> {
|
||||
let range = self.buffer.line_range(line);
|
||||
self.marks
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| {
|
||||
diagnostic.range.start <= range.end && range.start <= diagnostic.range.end
|
||||
})
|
||||
.map(|diagnostic| diagnostic.severity)
|
||||
.max_by_key(|severity| match severity {
|
||||
Severity::Danger => 3,
|
||||
Severity::Warning => 2,
|
||||
Severity::Info => 1,
|
||||
Severity::Success => 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_color(&self, line: usize, cx: &Context<Self>) -> Option<Hsla> {
|
||||
let colors = &cx.theme().colors;
|
||||
let added = self
|
||||
.marks
|
||||
.hunks
|
||||
.iter()
|
||||
.any(|hunk| (hunk.line..hunk.line + hunk.added).contains(&line));
|
||||
if added {
|
||||
return Some(colors.success);
|
||||
}
|
||||
self.marks
|
||||
.git
|
||||
.iter()
|
||||
.find(|(at, _)| *at == line)
|
||||
.map(|(_, mark)| match mark {
|
||||
GitMark::Added => colors.success,
|
||||
GitMark::Modified => colors.info,
|
||||
GitMark::Deleted => colors.danger,
|
||||
})
|
||||
}
|
||||
|
||||
/// The gutter cell for a row.
|
||||
pub(crate) fn gutter(&self, cell: Cell, row: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let advance = self.frame.advance;
|
||||
let digits = self.gutter_columns() - 6;
|
||||
let frame = || {
|
||||
div()
|
||||
.flex_none()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
};
|
||||
let base = div()
|
||||
.id(("gutter", row))
|
||||
.flex_none()
|
||||
.w(advance * self.gutter_columns() as f32)
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(colors.fg_subtle);
|
||||
let line = match cell {
|
||||
Cell::Line(line) => line,
|
||||
Cell::Removed => {
|
||||
return base
|
||||
.bg(colors.danger.opacity(0.08))
|
||||
.child(frame().w(advance * 2.0))
|
||||
.child(
|
||||
div()
|
||||
.w(advance * digits.max(1) as f32)
|
||||
.text_right()
|
||||
.text_color(colors.danger)
|
||||
.child("−"),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
Cell::Blank => return base.into_any_element(),
|
||||
};
|
||||
let current = self.buffer.line_of(self.primary().head) == line;
|
||||
let breakpoint = self.marks.breakpoints.contains(&line);
|
||||
let slot = match (breakpoint, self.worst(line)) {
|
||||
(true, _) => div()
|
||||
.size(advance * 1.1)
|
||||
.rounded_full()
|
||||
.bg(colors.danger)
|
||||
.into_any_element(),
|
||||
(false, Some(severity)) => Icon::new(severity.icon())
|
||||
.size(IconSize::Xs)
|
||||
.color(severity.color(&colors))
|
||||
.into_any_element(),
|
||||
(false, None) => div().into_any_element(),
|
||||
};
|
||||
let opens = self.frame.folds.iter().any(|(header, _)| *header == line);
|
||||
let folded = self.folded.contains(&line);
|
||||
let chevron = opens.then(|| Disclosure::new(("fold", line), !folded).into_any_element());
|
||||
let entity = cx.entity();
|
||||
let toggle = entity.clone();
|
||||
let git = self.git_color(line, cx);
|
||||
let number = self.number(line);
|
||||
base.child(
|
||||
frame()
|
||||
.id(("breakpoint", line))
|
||||
.w(advance * 2.0)
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
entity.update(cx, |editor, cx| editor.toggle_breakpoint(line, cx));
|
||||
})
|
||||
.child(slot),
|
||||
)
|
||||
.when(digits > 0, |gutter| {
|
||||
gutter.child(
|
||||
div()
|
||||
.w(advance * digits as f32)
|
||||
.text_right()
|
||||
.when(current, |number| number.text_color(colors.fg))
|
||||
.child(number),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
frame()
|
||||
.id(("chevron", line))
|
||||
.w(advance * 2.0)
|
||||
.when(opens, |slot| {
|
||||
slot.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
toggle.update(cx, |editor, cx| editor.toggle_fold(line, cx));
|
||||
})
|
||||
})
|
||||
.children(chevron),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w_0p5()
|
||||
.h_full()
|
||||
.when_some(git, |bar, color| bar.bg(color)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledText, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{symbols::SymbolKind, syntax};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
navigation::{fuzzy, marked},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A completion: what it inserts, its kind, a detail such as its type, and its documentation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CompletionItem {
|
||||
pub label: SharedString,
|
||||
pub kind: SymbolKind,
|
||||
pub detail: Option<SharedString>,
|
||||
pub docs: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl CompletionItem {
|
||||
pub fn new(label: impl Into<SharedString>, kind: SymbolKind) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
kind,
|
||||
detail: None,
|
||||
docs: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
|
||||
self.detail = Some(detail.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn docs(mut self, docs: impl Into<SharedString>) -> Self {
|
||||
self.docs = Some(docs.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The items that fit what was typed, best first, with the letters that matched.
|
||||
pub fn completions(
|
||||
items: &[CompletionItem],
|
||||
typed: &str,
|
||||
) -> Vec<(CompletionItem, Vec<Range<usize>>)> {
|
||||
let mut fits: Vec<(i32, CompletionItem, Vec<Range<usize>>)> = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if typed.is_empty() {
|
||||
return Some((0, item.clone(), Vec::new()));
|
||||
}
|
||||
fuzzy(typed, &item.label).map(|fit| (fit.score, item.clone(), fit.hits))
|
||||
})
|
||||
.collect();
|
||||
fits.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.label.len().cmp(&b.1.label.len())));
|
||||
fits.into_iter()
|
||||
.map(|(_, item, hits)| (item, hits))
|
||||
.collect()
|
||||
}
|
||||
|
||||
type OnPick = Rc<dyn Fn(&CompletionItem, &mut Window, &mut App)>;
|
||||
|
||||
/// Completions at the cursor: each with its kind's mark, the typed letters bold and its detail; the chosen one's documentation beside.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CompletionMenu {
|
||||
id: ElementId,
|
||||
items: Vec<CompletionItem>,
|
||||
typed: SharedString,
|
||||
selected: usize,
|
||||
on_pick: Option<OnPick>,
|
||||
}
|
||||
|
||||
impl CompletionMenu {
|
||||
pub fn new(id: impl Into<ElementId>, items: impl IntoIterator<Item = CompletionItem>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
items: items.into_iter().collect(),
|
||||
typed: SharedString::default(),
|
||||
selected: 0,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What was typed so far, which filters and marks the items.
|
||||
pub fn typed(mut self, typed: impl Into<SharedString>) -> Self {
|
||||
self.typed = typed.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The chosen item among those that fit.
|
||||
pub fn selected(mut self, selected: usize) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pick(
|
||||
mut self,
|
||||
handler: impl Fn(&CompletionItem, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CompletionMenu {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let fits = completions(&self.items, &self.typed);
|
||||
let chosen = self.selected.min(fits.len().saturating_sub(1));
|
||||
let docs = fits
|
||||
.get(chosen)
|
||||
.and_then(|(item, _)| item.docs.clone().map(|docs| (item.detail.clone(), docs)));
|
||||
let rows: Vec<AnyElement> = fits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (item, hits))| {
|
||||
let pick = self.on_pick.clone();
|
||||
let picked = item.clone();
|
||||
div()
|
||||
.id((self.id.clone(), format!("item-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.when(ix == chosen, |row| row.bg(colors.active))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.when_some(pick, |row, pick| {
|
||||
row.on_click(move |_, window, cx| {
|
||||
log::info!("completion: {}", picked.label);
|
||||
pick(&picked, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Icon::new(item.kind.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(item.kind.color(&colors)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child(marked(item.label.clone(), hits.clone(), cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_right()
|
||||
.text_color(colors.fg_subtle)
|
||||
.children(item.detail.clone().map(Ellipsis::new)),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
let empty = rows.is_empty();
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 2.75)
|
||||
.p_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.when(empty, |list| {
|
||||
list.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("No suggestions"),
|
||||
)
|
||||
})
|
||||
.children(rows),
|
||||
)
|
||||
.children(docs.map(|(detail, docs)| {
|
||||
div()
|
||||
.w(theme.label_width() * 2.5)
|
||||
.p_3()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.children(detail.map(|detail| {
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg)
|
||||
.child(detail)
|
||||
}))
|
||||
.child(div().text_color(colors.fg_muted).child(docs))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A callable's shape: its label and where each parameter sits in it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Signature {
|
||||
pub label: SharedString,
|
||||
pub params: Vec<Range<usize>>,
|
||||
pub docs: Option<SharedString>,
|
||||
}
|
||||
|
||||
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// The signature of the call being typed, the parameter at the cursor bold and underlined; arrows walk its overloads.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SignatureHelp {
|
||||
id: ElementId,
|
||||
signatures: Vec<Signature>,
|
||||
active: usize,
|
||||
param: usize,
|
||||
on_step: Option<OnStep>,
|
||||
}
|
||||
|
||||
impl SignatureHelp {
|
||||
pub fn new(id: impl Into<ElementId>, signatures: impl IntoIterator<Item = Signature>) -> Self {
|
||||
let signatures: Vec<Signature> = signatures.into_iter().collect();
|
||||
assert!(
|
||||
!signatures.is_empty(),
|
||||
"signature help shows at least one signature"
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
signatures,
|
||||
active: 0,
|
||||
param: 0,
|
||||
on_step: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The overload shown, and the parameter the cursor is in.
|
||||
pub fn at(mut self, overload: usize, param: usize) -> Self {
|
||||
self.active = overload;
|
||||
self.param = param;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the overload an arrow steps to.
|
||||
pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_step = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SignatureHelp {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = self.signatures.len();
|
||||
let active = self.active.min(count - 1);
|
||||
let signature = &self.signatures[active];
|
||||
let mut styles = syntax::colors(&signature.label, cx);
|
||||
if let Some(range) = signature.params.get(self.param) {
|
||||
styles.push((
|
||||
range.clone(),
|
||||
HighlightStyle {
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
underline: Some(gpui::UnderlineStyle {
|
||||
thickness: theme.underline_thickness(),
|
||||
color: Some(colors.fg),
|
||||
wavy: false,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
let styles = super::layout::stack(styles);
|
||||
let step = |by: usize, icon: IconName, key: &'static str| {
|
||||
let on_step = self.on_step.clone();
|
||||
let next = (active + by) % count;
|
||||
IconButton::new((self.id.clone(), key), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(count < 2)
|
||||
.when_some(on_step, |button, on_step| {
|
||||
button.on_click(move |_, window, cx| on_step(next, window, cx))
|
||||
})
|
||||
};
|
||||
div()
|
||||
.max_w(theme.label_width() * 5.0)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.when(count > 1, |row| {
|
||||
row.child(step(count - 1, IconName::ChevronUp, "previous"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("{}/{count}", active + 1)),
|
||||
)
|
||||
.child(step(1, IconName::ChevronDown, "next"))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(
|
||||
StyledText::new(signature.label.clone()).with_highlights(styles),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(
|
||||
signature
|
||||
.docs
|
||||
.clone()
|
||||
.map(|docs| div().text_color(colors.fg_muted).child(docs)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completions_fit_what_was_typed_best_first() {
|
||||
let items = [
|
||||
CompletionItem::new("render", SymbolKind::Method),
|
||||
CompletionItem::new("request_layout", SymbolKind::Method),
|
||||
CompletionItem::new("width", SymbolKind::Field),
|
||||
];
|
||||
let found = completions(&items, "re");
|
||||
let labels: Vec<&str> = found.iter().map(|(item, _)| &item.label[..]).collect();
|
||||
assert_eq!(labels, ["render", "request_layout"]);
|
||||
assert!(!found[0].1.is_empty(), "the typed letters are marked");
|
||||
assert_eq!(completions(&items, "").len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, HighlightStyle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div,
|
||||
};
|
||||
|
||||
use super::syntax;
|
||||
use crate::{
|
||||
primitives::{Icon, Severity},
|
||||
theme::{ActiveTheme, Elevation, IconSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnPress = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// What a hover over a symbol reads: its signature in code, a problem when there is one, its documentation, and links.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HoverInfo {
|
||||
id: ElementId,
|
||||
signature: SharedString,
|
||||
problem: Option<(Severity, SharedString)>,
|
||||
docs: Vec<SharedString>,
|
||||
links: Vec<(SharedString, OnPress)>,
|
||||
}
|
||||
|
||||
impl HoverInfo {
|
||||
pub fn new(id: impl Into<ElementId>, signature: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
signature: signature.into(),
|
||||
problem: None,
|
||||
docs: Vec::new(),
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn problem(mut self, severity: Severity, message: impl Into<SharedString>) -> Self {
|
||||
self.problem = Some((severity, message.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// A paragraph of documentation.
|
||||
pub fn docs(mut self, paragraph: impl Into<SharedString>) -> Self {
|
||||
self.docs.push(paragraph.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn link(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
handler: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.links.push((label.into(), Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with its `code` spans unquoted, and where they lie; an unpaired backtick stays as typed.
|
||||
pub(crate) fn code_spans(text: &str) -> (String, Vec<Range<usize>>) {
|
||||
if text.matches('`').count() % 2 == 1 {
|
||||
return (text.to_string(), Vec::new());
|
||||
}
|
||||
let (mut plain, mut spans) = (String::new(), Vec::new());
|
||||
for (ix, part) in text.split('`').enumerate() {
|
||||
let start = plain.len();
|
||||
plain.push_str(part);
|
||||
if ix % 2 == 1 {
|
||||
spans.push(start..plain.len());
|
||||
}
|
||||
}
|
||||
(plain, spans)
|
||||
}
|
||||
|
||||
fn prose(text: &str, colors: &Palette) -> StyledText {
|
||||
let (plain, spans) = code_spans(text);
|
||||
let code = HighlightStyle {
|
||||
color: Some(colors.fg),
|
||||
background_color: Some(colors.sunken),
|
||||
..Default::default()
|
||||
};
|
||||
StyledText::new(plain).with_highlights(spans.into_iter().map(|span| (span, code)))
|
||||
}
|
||||
|
||||
impl RenderOnce for HoverInfo {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let styles = syntax::colors(&self.signature, cx);
|
||||
let rule = || div().h_0().border_t_1().border_color(colors.border);
|
||||
let has_links = !self.links.is_empty();
|
||||
let links = self
|
||||
.links
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (label, handler))| {
|
||||
div()
|
||||
.id((self.id.clone(), format!("link-{ix}")))
|
||||
.text_color(colors.link)
|
||||
.cursor_pointer()
|
||||
.hover(|link| link.underline())
|
||||
.on_click(move |_, window, cx| handler(window, cx))
|
||||
.child(label)
|
||||
});
|
||||
div()
|
||||
.max_w(theme.label_width() * 5.0)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(self.problem.map(|(severity, message)| {
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(severity.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(severity.color(&colors)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_color(colors.fg)
|
||||
.child(prose(&message, &colors)),
|
||||
)
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(StyledText::new(self.signature.clone()).with_highlights(styles)),
|
||||
)
|
||||
.children((!self.docs.is_empty()).then(rule))
|
||||
.children(self.docs.into_iter().map(|paragraph| {
|
||||
div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(prose(¶graph, &colors))
|
||||
}))
|
||||
.children(has_links.then(rule))
|
||||
.child(div().flex().gap_3().children(links))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backticks_mark_code_unless_unpaired() {
|
||||
let plain = "The color for `name`, lifted by `lift`.";
|
||||
assert_eq!(
|
||||
code_spans(plain),
|
||||
(
|
||||
"The color for name, lifted by lift.".to_string(),
|
||||
vec![14..18, 30..34]
|
||||
)
|
||||
);
|
||||
assert_eq!(code_spans("a `b"), ("a `b".to_string(), Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EntityInputHandler, FontStyle, HighlightStyle, Pixels, Point,
|
||||
UTF16Selection, Window, point,
|
||||
};
|
||||
|
||||
use super::{
|
||||
cursor::Selection,
|
||||
layout::{Row, Shown},
|
||||
state::CodeEditor,
|
||||
};
|
||||
use crate::{
|
||||
forms::{from_utf16, to_utf16},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
|
||||
impl CodeEditor {
|
||||
/// Notes set into a line: inlay hints, and the ghost text's first line.
|
||||
pub(crate) fn notes(&self, line: usize, cx: &App) -> Vec<(usize, String, HighlightStyle)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let range = self.buffer.line_range(line);
|
||||
let hint = HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
background_color: Some(colors.hover),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let mut notes: Vec<(usize, String, HighlightStyle)> = self
|
||||
.marks
|
||||
.hints
|
||||
.iter()
|
||||
.filter(|hint| range.contains(&hint.offset) || hint.offset == range.end)
|
||||
.map(|note| (note.offset - range.start, note.text.to_string(), hint))
|
||||
.collect();
|
||||
if let Some(ghost) = &self.marks.ghost
|
||||
&& self.buffer.line_of(ghost.offset) == line
|
||||
{
|
||||
let first = ghost.text.split('\n').next().unwrap_or_default();
|
||||
let style = HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
font_style: Some(FontStyle::Italic),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
notes.push((ghost.offset - range.start, first.to_string(), style));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// A line's bytes as its row shows them: a multi-line ghost cuts the rest to follow its last line.
|
||||
pub(crate) fn row_range(&self, line: usize) -> Range<usize> {
|
||||
let range = self.buffer.line_range(line);
|
||||
match &self.marks.ghost {
|
||||
Some(ghost) if ghost.text.contains('\n') && range.contains(&ghost.offset) => {
|
||||
range.start..ghost.offset
|
||||
}
|
||||
_ => range,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_text(&self, line: usize) -> &str {
|
||||
&self.buffer.text()[self.row_range(line)]
|
||||
}
|
||||
|
||||
/// The offset under a window point, when a row of code is there.
|
||||
pub(crate) fn offset_at(&self, position: Point<Pixels>, cx: &App) -> Option<usize> {
|
||||
let metrics = self.metrics;
|
||||
if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO {
|
||||
return None;
|
||||
}
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let row = ((position.y - metrics.top + scrolled) / metrics.line).floor();
|
||||
let row = self.frame.rows.get(row.max(0.0) as usize)?;
|
||||
let Row::Line(line) = *row else {
|
||||
return None;
|
||||
};
|
||||
Some(self.offset_in(line, position.x, cx))
|
||||
}
|
||||
|
||||
/// The offset nearest window x on a line.
|
||||
pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> usize {
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let column = ((x - self.metrics.left) / self.metrics.advance)
|
||||
.round()
|
||||
.max(0.0) as usize;
|
||||
self.buffer.offset(line, shown.back(text, column))
|
||||
}
|
||||
|
||||
fn utf16_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
let text = self.buffer.text();
|
||||
to_utf16(text, range.start)..to_utf16(text, range.end)
|
||||
}
|
||||
|
||||
fn byte_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
let text = self.buffer.text();
|
||||
from_utf16(text, range.start)..from_utf16(text, range.end)
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityInputHandler for CodeEditor {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
actual_range: &mut Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = self.byte_range(&range_utf16);
|
||||
actual_range.replace(self.utf16_range(&range));
|
||||
Some(self.buffer.text()[range].to_string())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
let primary = self.primary();
|
||||
Some(UTF16Selection {
|
||||
range: self.utf16_range(&primary.range()),
|
||||
reversed: primary.head < primary.anchor,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
|
||||
self.marked.as_ref().map(|range| self.utf16_range(range))
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.marked = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let range = range_utf16
|
||||
.map(|range| self.byte_range(&range))
|
||||
.or(self.marked.clone());
|
||||
match range {
|
||||
Some(range) => {
|
||||
self.marked = None;
|
||||
self.apply(vec![(range, text.to_string())], true, cx);
|
||||
}
|
||||
None => self.type_text(text, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
selected_utf16: Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let range = range_utf16
|
||||
.map(|range| self.byte_range(&range))
|
||||
.or(self.marked.clone())
|
||||
.unwrap_or(self.primary().range());
|
||||
self.apply(vec![(range.clone(), text.to_string())], true, cx);
|
||||
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
|
||||
let caret = match selected_utf16 {
|
||||
Some(inner) => range.start + from_utf16(text, inner.end),
|
||||
None => range.start + text.len(),
|
||||
};
|
||||
self.set_selections(vec![Selection::caret(caret)], cx);
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let range = self.byte_range(&range_utf16);
|
||||
let line = self.buffer.line_of(range.start);
|
||||
let row = self
|
||||
.frame
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| *row == Row::Line(line))?;
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let column = shown.column(text, self.buffer.point(range.start).1.min(text.len()));
|
||||
let metrics = self.metrics;
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let origin = point(
|
||||
metrics.left + metrics.advance * column as f32,
|
||||
metrics.top + metrics.line * row as f32 - scrolled,
|
||||
);
|
||||
Some(Bounds::new(
|
||||
origin,
|
||||
gpui::size(metrics.advance, metrics.line),
|
||||
))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
position: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let offset = self.offset_at(position, cx)?;
|
||||
Some(to_utf16(self.buffer.text(), offset))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, Keystroke, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::{Badge, Tone},
|
||||
forms::HotkeyInput,
|
||||
navigation::fuzzy,
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::KbdCombo,
|
||||
};
|
||||
|
||||
/// Where a binding comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KeySource {
|
||||
Default,
|
||||
User,
|
||||
}
|
||||
|
||||
/// A command and the keys that run it, in gpui key syntax, when a context holds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Keybinding {
|
||||
pub command: SharedString,
|
||||
pub title: SharedString,
|
||||
pub keys: Option<SharedString>,
|
||||
pub when: Option<SharedString>,
|
||||
pub source: KeySource,
|
||||
}
|
||||
|
||||
/// Commands sharing keys in the same context: for each binding, the others its keys also run.
|
||||
pub fn conflicts(bindings: &[Keybinding], binding: &Keybinding) -> Vec<SharedString> {
|
||||
let Some(keys) = &binding.keys else {
|
||||
return Vec::new();
|
||||
};
|
||||
bindings
|
||||
.iter()
|
||||
.filter(|other| {
|
||||
other.command != binding.command
|
||||
&& other.keys.as_ref() == Some(keys)
|
||||
&& other.when == binding.when
|
||||
})
|
||||
.map(|other| other.title.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type OnCommand = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
type OnBind = Rc<dyn Fn(&SharedString, Option<Keystroke>, &mut Window, &mut App)>;
|
||||
|
||||
/// Every command with its keys, context and source; a query narrows them, keys that clash are marked, and a double press records new keys.
|
||||
#[derive(IntoElement)]
|
||||
pub struct KeybindingsEditor {
|
||||
id: ElementId,
|
||||
bindings: Vec<Keybinding>,
|
||||
query: SharedString,
|
||||
recording: Option<SharedString>,
|
||||
on_record: Option<OnCommand>,
|
||||
on_bind: Option<OnBind>,
|
||||
}
|
||||
|
||||
impl KeybindingsEditor {
|
||||
pub fn new(id: impl Into<ElementId>, bindings: impl IntoIterator<Item = Keybinding>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
bindings: bindings.into_iter().collect(),
|
||||
query: SharedString::default(),
|
||||
recording: None,
|
||||
on_record: None,
|
||||
on_bind: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrows to commands whose title, command or keys fit.
|
||||
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
|
||||
self.query = query.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The command whose keys are being recorded.
|
||||
pub fn recording(mut self, command: Option<SharedString>) -> Self {
|
||||
self.recording = command;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the command a double press chose to record.
|
||||
pub fn on_record(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_record = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the keys recorded for a command, or none when cleared.
|
||||
pub fn on_bind(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, Option<Keystroke>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_bind = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The four columns every row shares.
|
||||
fn columns<E: Styled>(row: E) -> E {
|
||||
row.flex().items_center().gap_3().px_2()
|
||||
}
|
||||
|
||||
impl RenderOnce for KeybindingsEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let query = self.query.trim().to_string();
|
||||
let fits = |binding: &Keybinding| {
|
||||
query.is_empty()
|
||||
|| [
|
||||
Some(&binding.title),
|
||||
Some(&binding.command),
|
||||
binding.keys.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|text| fuzzy(&query, text).is_some())
|
||||
};
|
||||
let widths = [2.25, 1.5, 1.5, 0.75].map(|share| theme.label_width() * share);
|
||||
let head = columns(div())
|
||||
.py_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(div().w(widths[0]).child("Command"))
|
||||
.child(div().w(widths[1]).child("Keybinding"))
|
||||
.child(div().w(widths[2]).child("When"))
|
||||
.child(div().w(widths[3]).child("Source"));
|
||||
let rows = self
|
||||
.bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, binding)| fits(binding))
|
||||
.map(|(ix, binding)| {
|
||||
let clashes = conflicts(&self.bindings, binding);
|
||||
let recording = self.recording.as_ref() == Some(&binding.command);
|
||||
let keys = if recording {
|
||||
let (bind, command) = (self.on_bind.clone(), binding.command.clone());
|
||||
HotkeyInput::new((self.id.clone(), format!("keys-{ix}")), None)
|
||||
.on_change(move |stroke, window, cx| {
|
||||
log::info!("keybindings: {command} = {stroke:?}");
|
||||
if let Some(bind) = &bind {
|
||||
bind(&command, stroke, window, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
match &binding.keys {
|
||||
Some(keys) => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(KbdCombo::new(keys))
|
||||
.when(!clashes.is_empty(), |cell| {
|
||||
let words = format!("Also runs {}", clashes.join(", "));
|
||||
cell.child(
|
||||
div()
|
||||
.id((self.id.clone(), format!("clash-{ix}")))
|
||||
.tooltip(Tooltip::text(words))
|
||||
.child(
|
||||
Icon::new(IconName::TriangleAlert)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.warning),
|
||||
),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("—")
|
||||
.into_any_element(),
|
||||
}
|
||||
};
|
||||
let record = self.on_record.clone();
|
||||
let command = binding.command.clone();
|
||||
columns(div().id((self.id.clone(), format!("row-{ix}"))))
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.when(recording, |row| row.bg(colors.active))
|
||||
.when(!recording, |row| row.hover(|row| row.bg(colors.hover)))
|
||||
.when_some(record, |row, record| {
|
||||
row.on_click(move |event, window, cx| {
|
||||
if event.click_count() == 2 {
|
||||
record(&command, window, cx)
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w(widths[0])
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(binding.title.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(binding.command.clone()),
|
||||
),
|
||||
)
|
||||
.child(div().w(widths[1]).child(keys))
|
||||
.child(
|
||||
div()
|
||||
.w(widths[2])
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.children(binding.when.clone()),
|
||||
)
|
||||
.child(div().w(widths[3]).flex().child(match binding.source {
|
||||
KeySource::Default => Badge::new("Default"),
|
||||
KeySource::User => Badge::new("User").tone(Tone::Info),
|
||||
}))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(head)
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn binding(command: &str, keys: &str, when: Option<&str>) -> Keybinding {
|
||||
Keybinding {
|
||||
command: command.to_string().into(),
|
||||
title: command.to_string().into(),
|
||||
keys: Some(keys.to_string().into()),
|
||||
when: when.map(|when| when.to_string().into()),
|
||||
source: KeySource::Default,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_clash_only_in_the_same_context() {
|
||||
let all = [
|
||||
binding("save", "secondary-s", None),
|
||||
binding("sort", "secondary-s", None),
|
||||
binding("split", "secondary-s", Some("Terminal")),
|
||||
];
|
||||
assert_eq!(conflicts(&all, &all[0]), ["sort"]);
|
||||
assert!(
|
||||
conflicts(&all, &all[2]).is_empty(),
|
||||
"another context does not clash"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use gpui::{App, Context, InteractiveElement, KeyBinding, Window, actions};
|
||||
|
||||
use super::{buffer::Buffer, cursor::Motion, state::CodeEditor};
|
||||
|
||||
actions!(
|
||||
ely_editor,
|
||||
[
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
SelectLeft,
|
||||
SelectRight,
|
||||
SelectUp,
|
||||
SelectDown,
|
||||
WordLeft,
|
||||
WordRight,
|
||||
SelectWordLeft,
|
||||
SelectWordRight,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
SelectLineStart,
|
||||
SelectLineEnd,
|
||||
DocumentStart,
|
||||
DocumentEnd,
|
||||
SelectDocumentStart,
|
||||
SelectDocumentEnd,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Backspace,
|
||||
Delete,
|
||||
DeleteWordLeft,
|
||||
DeleteLineLeft,
|
||||
Newline,
|
||||
Indent,
|
||||
Outdent,
|
||||
Undo,
|
||||
Redo,
|
||||
SelectAll,
|
||||
Copy,
|
||||
Cut,
|
||||
Paste,
|
||||
AddCursorAbove,
|
||||
AddCursorBelow,
|
||||
SelectNext,
|
||||
SingleCursor,
|
||||
ToggleComment,
|
||||
Fold,
|
||||
Unfold,
|
||||
]
|
||||
);
|
||||
|
||||
pub(crate) const CONTEXT: &str = "ElyEditor";
|
||||
|
||||
/// Binds the editor's keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
let word = if cfg!(target_os = "macos") {
|
||||
"alt"
|
||||
} else {
|
||||
"ctrl"
|
||||
};
|
||||
let context = Some(CONTEXT);
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("left", Left, context),
|
||||
KeyBinding::new("right", Right, context),
|
||||
KeyBinding::new("up", Up, context),
|
||||
KeyBinding::new("down", Down, context),
|
||||
KeyBinding::new("shift-left", SelectLeft, context),
|
||||
KeyBinding::new("shift-right", SelectRight, context),
|
||||
KeyBinding::new("shift-up", SelectUp, context),
|
||||
KeyBinding::new("shift-down", SelectDown, context),
|
||||
KeyBinding::new(&format!("{word}-left"), WordLeft, context),
|
||||
KeyBinding::new(&format!("{word}-right"), WordRight, context),
|
||||
KeyBinding::new(&format!("{word}-shift-left"), SelectWordLeft, context),
|
||||
KeyBinding::new(&format!("{word}-shift-right"), SelectWordRight, context),
|
||||
KeyBinding::new("home", LineStart, context),
|
||||
KeyBinding::new("end", LineEnd, context),
|
||||
KeyBinding::new("shift-home", SelectLineStart, context),
|
||||
KeyBinding::new("shift-end", SelectLineEnd, context),
|
||||
KeyBinding::new("pageup", PageUp, context),
|
||||
KeyBinding::new("pagedown", PageDown, context),
|
||||
KeyBinding::new("backspace", Backspace, context),
|
||||
KeyBinding::new("shift-backspace", Backspace, context),
|
||||
KeyBinding::new("delete", Delete, context),
|
||||
KeyBinding::new(&format!("{word}-backspace"), DeleteWordLeft, context),
|
||||
KeyBinding::new("enter", Newline, context),
|
||||
KeyBinding::new("tab", Indent, context),
|
||||
KeyBinding::new("shift-tab", Outdent, context),
|
||||
KeyBinding::new("secondary-z", Undo, context),
|
||||
KeyBinding::new("secondary-shift-z", Redo, context),
|
||||
KeyBinding::new("secondary-a", SelectAll, context),
|
||||
KeyBinding::new("secondary-c", Copy, context),
|
||||
KeyBinding::new("secondary-x", Cut, context),
|
||||
KeyBinding::new("secondary-v", Paste, context),
|
||||
KeyBinding::new("secondary-d", SelectNext, context),
|
||||
KeyBinding::new("escape", SingleCursor, context),
|
||||
KeyBinding::new("secondary-/", ToggleComment, context),
|
||||
KeyBinding::new("secondary-alt-[", Fold, context),
|
||||
KeyBinding::new("secondary-alt-]", Unfold, context),
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
bindings.extend([
|
||||
KeyBinding::new("cmd-left", LineStart, context),
|
||||
KeyBinding::new("cmd-right", LineEnd, context),
|
||||
KeyBinding::new("cmd-shift-left", SelectLineStart, context),
|
||||
KeyBinding::new("cmd-shift-right", SelectLineEnd, context),
|
||||
KeyBinding::new("cmd-up", DocumentStart, context),
|
||||
KeyBinding::new("cmd-down", DocumentEnd, context),
|
||||
KeyBinding::new("cmd-shift-up", SelectDocumentStart, context),
|
||||
KeyBinding::new("cmd-shift-down", SelectDocumentEnd, context),
|
||||
KeyBinding::new("cmd-backspace", DeleteLineLeft, context),
|
||||
KeyBinding::new("cmd-alt-up", AddCursorAbove, context),
|
||||
KeyBinding::new("cmd-alt-down", AddCursorBelow, context),
|
||||
]);
|
||||
} else {
|
||||
bindings.extend([
|
||||
KeyBinding::new("ctrl-home", DocumentStart, context),
|
||||
KeyBinding::new("ctrl-end", DocumentEnd, context),
|
||||
KeyBinding::new("ctrl-shift-home", SelectDocumentStart, context),
|
||||
KeyBinding::new("ctrl-shift-end", SelectDocumentEnd, context),
|
||||
KeyBinding::new("ctrl-alt-up", AddCursorAbove, context),
|
||||
KeyBinding::new("ctrl-alt-down", AddCursorBelow, context),
|
||||
KeyBinding::new("ctrl-y", Redo, context),
|
||||
]);
|
||||
}
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
/// Rows a page key moves.
|
||||
const PAGE: isize = 20;
|
||||
|
||||
fn line_left(buffer: &Buffer, at: usize) -> std::ops::Range<usize> {
|
||||
buffer.line_range(buffer.line_of(at)).start..at
|
||||
}
|
||||
|
||||
/// Wires every action to the editor that renders `root`.
|
||||
pub(crate) fn listen<E: InteractiveElement>(root: E, cx: &mut Context<CodeEditor>) -> E {
|
||||
macro_rules! on {
|
||||
($root:expr, $action:ty, $body:expr) => {
|
||||
$root.on_action(cx.listener(
|
||||
move |editor: &mut CodeEditor,
|
||||
_: &$action,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<CodeEditor>| {
|
||||
let run: fn(&mut CodeEditor, &mut Window, &mut Context<CodeEditor>) = $body;
|
||||
run(editor, window, cx)
|
||||
},
|
||||
))
|
||||
};
|
||||
}
|
||||
let root = on!(root, Left, |e, _, cx| e.motion(Motion::Left, false, cx));
|
||||
let root = on!(root, Right, |e, _, cx| e.motion(Motion::Right, false, cx));
|
||||
let root = on!(root, Up, |e, _, cx| e.motion(Motion::Up, false, cx));
|
||||
let root = on!(root, Down, |e, _, cx| e.motion(Motion::Down, false, cx));
|
||||
let root = on!(root, SelectLeft, |e, _, cx| e.motion(
|
||||
Motion::Left,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectRight, |e, _, cx| e.motion(
|
||||
Motion::Right,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectUp, |e, _, cx| e.motion(Motion::Up, true, cx));
|
||||
let root = on!(root, SelectDown, |e, _, cx| e.motion(
|
||||
Motion::Down,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, WordLeft, |e, _, cx| e.motion(
|
||||
Motion::WordLeft,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, WordRight, |e, _, cx| e.motion(
|
||||
Motion::WordRight,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectWordLeft, |e, _, cx| e.motion(
|
||||
Motion::WordLeft,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectWordRight, |e, _, cx| e.motion(
|
||||
Motion::WordRight,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, LineStart, |e, _, cx| e.motion(
|
||||
Motion::LineStart,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, LineEnd, |e, _, cx| e.motion(
|
||||
Motion::LineEnd,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectLineStart, |e, _, cx| e.motion(
|
||||
Motion::LineStart,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectLineEnd, |e, _, cx| e.motion(
|
||||
Motion::LineEnd,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, DocumentStart, |e, _, cx| e.motion(
|
||||
Motion::Start,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, DocumentEnd, |e, _, cx| e.motion(
|
||||
Motion::End,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectDocumentStart, |e, _, cx| e.motion(
|
||||
Motion::Start,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectDocumentEnd, |e, _, cx| e.motion(
|
||||
Motion::End,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, PageUp, |e, _, cx| e.motion(
|
||||
Motion::Page(-PAGE),
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, PageDown, |e, _, cx| e.motion(
|
||||
Motion::Page(PAGE),
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, Backspace, |e, _, cx| {
|
||||
e.delete_by(|buffer, at| buffer.previous(at)..at, cx)
|
||||
});
|
||||
let root = on!(root, Delete, |e, _, cx| e
|
||||
.delete_by(|buffer, at| at..buffer.next(at), cx));
|
||||
let root = on!(root, DeleteWordLeft, |e, _, cx| {
|
||||
e.delete_by(|buffer, at| buffer.word_start(at)..at, cx)
|
||||
});
|
||||
let root = on!(root, DeleteLineLeft, |e, _, cx| e.delete_by(line_left, cx));
|
||||
let root = on!(root, Newline, |e, _, cx| e.newline(cx));
|
||||
let root = on!(root, Indent, |e, _, cx| e.indent(cx));
|
||||
let root = on!(root, Outdent, |e, _, cx| e.outdent(cx));
|
||||
let root = on!(root, Undo, |e, _, cx| e.undo(cx));
|
||||
let root = on!(root, Redo, |e, _, cx| e.redo(cx));
|
||||
let root = on!(root, SelectAll, |e, _, cx| e.select_all(cx));
|
||||
let root = on!(root, Copy, |e, _, cx| e.copy(cx));
|
||||
let root = on!(root, Cut, |e, _, cx| e.cut(cx));
|
||||
let root = on!(root, Paste, |e, _, cx| e.paste(cx));
|
||||
let root = on!(root, AddCursorAbove, |e, _, cx| e.add_cursor(false, cx));
|
||||
let root = on!(root, AddCursorBelow, |e, _, cx| e.add_cursor(true, cx));
|
||||
let root = on!(root, SelectNext, |e, _, cx| e.select_next(cx));
|
||||
let root = on!(root, SingleCursor, |e, window, cx| {
|
||||
if e.marks.chat.is_some() {
|
||||
e.set_chat(None, window, cx)
|
||||
} else {
|
||||
e.single_cursor(cx)
|
||||
}
|
||||
});
|
||||
let root = on!(root, ToggleComment, |e, _, cx| e.toggle_comment(cx));
|
||||
let root = on!(root, Fold, |e, _, cx| {
|
||||
let line = e.buffer.line_of(e.primary().head);
|
||||
let header = super::syntax::folds(&e.buffer)
|
||||
.into_iter()
|
||||
.filter(|(header, end)| *header <= line && line <= *end)
|
||||
.map(|(header, _)| header)
|
||||
.next_back();
|
||||
if let Some(header) = header.filter(|header| !e.folded.contains(header)) {
|
||||
e.toggle_fold(header, cx);
|
||||
}
|
||||
});
|
||||
on!(root, Unfold, |e, _, cx| {
|
||||
let line = e.buffer.line_of(e.primary().head);
|
||||
if e.folded.contains(&line) {
|
||||
e.toggle_fold(line, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::{collections::BTreeSet, ops::Range};
|
||||
|
||||
use gpui::{HighlightStyle, SharedString};
|
||||
|
||||
use super::{buffer::Buffer, state::Marks};
|
||||
|
||||
/// Rows an inline chat takes below its line.
|
||||
pub(crate) const CHAT_ROWS: usize = 3;
|
||||
|
||||
/// One row the editor draws, all the same height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Row {
|
||||
Line(usize),
|
||||
/// A code lens, by its place among the lenses.
|
||||
Lens(usize),
|
||||
/// A removed line of a diff hunk: the hunk, then the line within it.
|
||||
Removed(usize, usize),
|
||||
/// A line of ghost text after its first.
|
||||
Ghost(usize),
|
||||
/// The inline chat's rows; the first draws it.
|
||||
Chat(usize),
|
||||
}
|
||||
|
||||
/// Which lines folds hide.
|
||||
pub(crate) fn hidden(
|
||||
lines: usize,
|
||||
folds: &[(usize, usize)],
|
||||
folded: &BTreeSet<usize>,
|
||||
) -> Vec<bool> {
|
||||
let mut out = vec![false; lines];
|
||||
for (header, end) in folds.iter().filter(|(header, _)| folded.contains(header)) {
|
||||
for hidden in &mut out[header + 1..=*end] {
|
||||
*hidden = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The rows in order: lenses and removed lines above their line, ghost lines and the chat below it, nothing a fold hides.
|
||||
pub(crate) fn rows(buffer: &Buffer, hidden: &[bool], marks: &Marks) -> Vec<Row> {
|
||||
let ghost = marks.ghost.as_ref().map(|ghost| {
|
||||
let extra = ghost.text.split('\n').count() - 1;
|
||||
(buffer.line_of(ghost.offset), extra)
|
||||
});
|
||||
let mut out = Vec::with_capacity(buffer.lines());
|
||||
for line in (0..buffer.lines()).filter(|line| !hidden[*line]) {
|
||||
out.extend(
|
||||
marks
|
||||
.lenses
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, lens)| lens.line == line)
|
||||
.map(|(ix, _)| Row::Lens(ix)),
|
||||
);
|
||||
for (ix, hunk) in marks
|
||||
.hunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, hunk)| hunk.line == line)
|
||||
{
|
||||
out.extend((0..hunk.removed.len()).map(|at| Row::Removed(ix, at)));
|
||||
}
|
||||
out.push(Row::Line(line));
|
||||
if let Some((at, extra)) = ghost
|
||||
&& at == line
|
||||
{
|
||||
out.extend((1..=extra).map(Row::Ghost));
|
||||
}
|
||||
if marks.chat == Some(line) {
|
||||
out.extend((0..CHAT_ROWS).map(Row::Chat));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The headers of the blocks around `line`, outermost first, at most `limit` of them.
|
||||
pub(crate) fn around(folds: &[(usize, usize)], line: usize, limit: usize) -> Vec<usize> {
|
||||
let mut headers: Vec<usize> = folds
|
||||
.iter()
|
||||
.filter(|(header, end)| *header < line && line <= *end)
|
||||
.map(|(header, _)| *header)
|
||||
.collect();
|
||||
headers.sort_unstable();
|
||||
headers.truncate(limit);
|
||||
headers
|
||||
}
|
||||
|
||||
/// Styles laid over one another in order, later ones on top, as runs that do not overlap.
|
||||
pub(crate) fn stack(
|
||||
spans: Vec<(Range<usize>, HighlightStyle)>,
|
||||
) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let mut edges: Vec<usize> = spans
|
||||
.iter()
|
||||
.flat_map(|(range, _)| [range.start, range.end])
|
||||
.collect();
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
edges
|
||||
.windows(2)
|
||||
.filter_map(|pair| {
|
||||
let (start, end) = (pair[0], pair[1]);
|
||||
spans
|
||||
.iter()
|
||||
.filter(|(range, _)| range.start <= start && end <= range.end)
|
||||
.fold(None, |below: Option<HighlightStyle>, (_, style)| {
|
||||
Some(below.map_or(*style, |below| below.highlight(*style)))
|
||||
})
|
||||
.map(|style| (start..end, style))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A line as drawn: its text with notes set in, their styles, and where each note went.
|
||||
pub(crate) struct Shown {
|
||||
pub text: SharedString,
|
||||
pub styles: Vec<(Range<usize>, HighlightStyle)>,
|
||||
/// Byte in the line, then bytes and characters set in before it.
|
||||
inserts: Vec<(usize, usize, usize)>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// `line` with `notes` set in, each at a byte of the line and styled; `styles` color the line's own bytes.
|
||||
pub fn new(
|
||||
line: &str,
|
||||
mut notes: Vec<(usize, String, HighlightStyle)>,
|
||||
styles: Vec<(Range<usize>, HighlightStyle)>,
|
||||
) -> Self {
|
||||
notes.sort_by_key(|(at, ..)| *at);
|
||||
let mut text = String::with_capacity(line.len());
|
||||
let mut inserts = Vec::with_capacity(notes.len());
|
||||
let mut own = Vec::with_capacity(notes.len());
|
||||
let mut last = 0;
|
||||
for (at, note, style) in notes {
|
||||
text.push_str(&line[last..at]);
|
||||
own.push((text.len()..text.len() + note.len(), style));
|
||||
text.push_str(¬e);
|
||||
inserts.push((at, note.len(), note.chars().count()));
|
||||
last = at;
|
||||
}
|
||||
text.push_str(&line[last..]);
|
||||
let mut shown = Self {
|
||||
text: text.into(),
|
||||
styles: Vec::new(),
|
||||
inserts,
|
||||
};
|
||||
let mapped: Vec<(Range<usize>, HighlightStyle)> = styles
|
||||
.into_iter()
|
||||
.map(|(range, style)| {
|
||||
(
|
||||
shown.byte(range.start, false)..shown.byte(range.end, true),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
shown.styles = stack(mapped.into_iter().chain(own).collect());
|
||||
shown
|
||||
}
|
||||
|
||||
/// Where a byte of the line lands; a range's end lands before a note set in at it.
|
||||
fn byte(&self, at: usize, end: bool) -> usize {
|
||||
at + self
|
||||
.inserts
|
||||
.iter()
|
||||
.filter(|(place, ..)| if end { *place < at } else { *place <= at })
|
||||
.map(|(_, bytes, _)| bytes)
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// Where a caret at column `column` of `line` draws, in columns; it sits before a note set in at it.
|
||||
pub fn column(&self, line: &str, column: usize) -> usize {
|
||||
let byte = line
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(line.len(), |(at, _)| at);
|
||||
column
|
||||
+ self
|
||||
.inserts
|
||||
.iter()
|
||||
.filter(|(place, ..)| *place < byte)
|
||||
.map(|(.., chars)| chars)
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// The column of the line under drawn column `shown`, skipping notes.
|
||||
pub fn back(&self, line: &str, shown: usize) -> usize {
|
||||
let mut column = shown;
|
||||
for (place, _, chars) in &self.inserts {
|
||||
let at = line[..*place].chars().count();
|
||||
let drawn = self.column(line, at);
|
||||
if shown > drawn {
|
||||
column = column.saturating_sub((shown - drawn).min(*chars));
|
||||
}
|
||||
}
|
||||
column.min(line.chars().count())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::editor::decor::{CodeLens, DiffHunk, GhostText};
|
||||
|
||||
#[test]
|
||||
fn rows_skip_folds_and_add_what_lies_around_lines() {
|
||||
let buffer = Buffer::new("a\n b\n c\nd\ne");
|
||||
let marks = Marks {
|
||||
lenses: vec![CodeLens {
|
||||
line: 3,
|
||||
items: vec!["Run".into()],
|
||||
}],
|
||||
hunks: vec![DiffHunk {
|
||||
line: 4,
|
||||
added: 1,
|
||||
removed: vec!["old".into()],
|
||||
}],
|
||||
ghost: Some(GhostText {
|
||||
offset: 0,
|
||||
text: "x\ny".into(),
|
||||
}),
|
||||
..Marks::default()
|
||||
};
|
||||
let hide = hidden(5, &[(0, 2)], &BTreeSet::from([0]));
|
||||
assert_eq!(
|
||||
rows(&buffer, &hide, &marks),
|
||||
[
|
||||
Row::Line(0),
|
||||
Row::Ghost(1),
|
||||
Row::Lens(0),
|
||||
Row::Line(3),
|
||||
Row::Removed(0, 0),
|
||||
Row::Line(4)
|
||||
]
|
||||
);
|
||||
assert_eq!(around(&[(0, 4), (1, 2)], 2, 3), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_styles_sit_on_top_and_runs_do_not_overlap() {
|
||||
let red = gpui::hsla(0.0, 1.0, 0.5, 1.0);
|
||||
let blue = gpui::hsla(0.6, 1.0, 0.5, 1.0);
|
||||
let runs = stack(vec![(0..6, red.into()), (2..4, blue.into())]);
|
||||
let ranges: Vec<_> = runs.iter().map(|(range, _)| range.clone()).collect();
|
||||
assert_eq!(ranges, [0..2, 2..4, 4..6]);
|
||||
assert_eq!(
|
||||
runs[1].1.color,
|
||||
Some(blue),
|
||||
"the later span wins where both lie"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_shift_what_follows_them_but_not_a_caret_at_them() {
|
||||
let style = HighlightStyle::default();
|
||||
let shown = Shown::new(
|
||||
"let x = 1;",
|
||||
vec![(5, ": i32".into(), style)],
|
||||
vec![(4..5, style)],
|
||||
);
|
||||
assert_eq!(shown.text.as_ref(), "let x: i32 = 1;");
|
||||
assert_eq!(
|
||||
shown.column("let x = 1;", 5),
|
||||
5,
|
||||
"a caret at the note stays before it"
|
||||
);
|
||||
assert_eq!(shown.column("let x = 1;", 6), 11);
|
||||
assert_eq!(shown.back("let x = 1;", 11), 6);
|
||||
assert_eq!(
|
||||
shown.back("let x = 1;", 8),
|
||||
5,
|
||||
"inside the note falls back to it"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, ParentElement, Stateful, Styled, StyledText, UnderlineStyle,
|
||||
Window, canvas, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
cursor::Selection,
|
||||
gutter::Cell,
|
||||
layout::{Shown, stack},
|
||||
state::{CodeEditor, CursorShape},
|
||||
syntax,
|
||||
};
|
||||
use crate::{
|
||||
charts::tint,
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
|
||||
impl CodeEditor {
|
||||
/// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair.
|
||||
fn styles(&self, line: usize, cx: &Context<Self>) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let range = self.row_range(line);
|
||||
let local = |span: &Range<usize>| {
|
||||
span.start.max(range.start) - range.start..span.end.min(range.end) - range.start
|
||||
};
|
||||
let touches = |span: &Range<usize>| {
|
||||
span.start < range.end.max(range.start + 1) && range.start < span.end
|
||||
};
|
||||
let mut spans = syntax::colors(self.row_text(line), cx);
|
||||
let brackets = self
|
||||
.frame
|
||||
.brackets
|
||||
.iter()
|
||||
.filter(|bracket| range.contains(&bracket.offset));
|
||||
for bracket in brackets {
|
||||
let at = bracket.offset - range.start;
|
||||
let color = if self.options.rainbow {
|
||||
tint(colors, bracket.depth % 3)
|
||||
} else {
|
||||
colors.syntax.punctuation
|
||||
};
|
||||
spans.retain(|(span, _)| *span != (at..at + 1));
|
||||
spans.push((at..at + 1, color.into()));
|
||||
}
|
||||
for span in self.frame.occurrences.iter().filter(|span| touches(span)) {
|
||||
spans.push((
|
||||
local(span),
|
||||
HighlightStyle {
|
||||
background_color: Some(colors.selection.opacity(0.45)),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
for (span, color) in self
|
||||
.marks
|
||||
.backgrounds
|
||||
.iter()
|
||||
.filter(|(span, _)| touches(span))
|
||||
{
|
||||
spans.push((
|
||||
local(span),
|
||||
HighlightStyle {
|
||||
background_color: Some(*color),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
for diagnostic in self
|
||||
.marks
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| touches(&diagnostic.range))
|
||||
{
|
||||
spans.push((
|
||||
local(&diagnostic.range),
|
||||
HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: cx.theme().underline_thickness(),
|
||||
color: Some(diagnostic.severity.color(colors)),
|
||||
wavy: true,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some((open, close)) = self.frame.matched {
|
||||
for at in [open, close].into_iter().filter(|at| range.contains(at)) {
|
||||
let at = at - range.start;
|
||||
spans.push((
|
||||
at..at + 1,
|
||||
HighlightStyle {
|
||||
background_color: Some(colors.active),
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
stack(spans)
|
||||
}
|
||||
|
||||
/// The columns guides stand at on a line: every tab stop inside its indent, or the next line's when it is blank.
|
||||
fn guides(&self, line: usize) -> Vec<usize> {
|
||||
let indent = (line..self.buffer.lines())
|
||||
.find(|at| !self.buffer.line(*at).trim().is_empty())
|
||||
.map_or(0, |at| self.buffer.indent(at));
|
||||
(0..indent).step_by(self.options.tab).collect()
|
||||
}
|
||||
|
||||
/// Dots where the line has spaces and arrows for tabs, spaces elsewhere, so they fall on the text's columns.
|
||||
fn whitespace(&self, line: usize, shown: &Shown, cx: &Context<Self>) -> String {
|
||||
let text = self.row_text(line);
|
||||
let notes = self.notes(line, cx);
|
||||
let mut out = String::new();
|
||||
let mut note = notes.iter().peekable();
|
||||
for (at, ch) in text.char_indices() {
|
||||
while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) {
|
||||
out.extend(std::iter::repeat_n(' ', words.chars().count()));
|
||||
}
|
||||
out.push(match ch {
|
||||
' ' => '·',
|
||||
'\t' => '→',
|
||||
_ => ' ',
|
||||
});
|
||||
}
|
||||
debug_assert!(out.chars().count() <= shown.text.chars().count());
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn line_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
row: usize,
|
||||
line: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (advance, height) = (self.frame.advance, self.frame.line);
|
||||
let text = self.row_text(line);
|
||||
let range = self.row_range(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
|
||||
let x = |column: usize| advance * (column + 1) as f32;
|
||||
let focused = self.focus.is_focused(window);
|
||||
let primary = self.primary();
|
||||
let current = primary.is_empty() && self.buffer.line_of(primary.head) == line;
|
||||
let column_of =
|
||||
|offset: usize| shown.column(text, self.buffer.point(offset).1.min(text.len()));
|
||||
let selections = self.selections.iter().filter_map(|selection| {
|
||||
let span = selection.range();
|
||||
if span.is_empty() || span.end < range.start || span.start > range.end {
|
||||
return None;
|
||||
}
|
||||
let start = if span.start <= range.start {
|
||||
0
|
||||
} else {
|
||||
column_of(span.start)
|
||||
};
|
||||
let past = span.end > range.end;
|
||||
let end = if past {
|
||||
column_of(range.end) + 1
|
||||
} else {
|
||||
column_of(span.end)
|
||||
};
|
||||
(end > start).then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(start))
|
||||
.w(advance * (end - start) as f32)
|
||||
.h(height)
|
||||
.bg(if focused {
|
||||
colors.selection
|
||||
} else {
|
||||
colors.selection.opacity(0.5)
|
||||
})
|
||||
})
|
||||
});
|
||||
let active = self
|
||||
.frame
|
||||
.folds
|
||||
.iter()
|
||||
.filter(|(header, end)| {
|
||||
let at = self.buffer.line_of(primary.head);
|
||||
*header < at && at <= *end
|
||||
})
|
||||
.map(|(header, _)| self.buffer.indent(*header))
|
||||
.next_back();
|
||||
let guides = if self.options.guides {
|
||||
self.guides(line)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let rulers = self.options.rulers.iter().map(|column| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(*column))
|
||||
.h(height)
|
||||
.w_0()
|
||||
.border_l_1()
|
||||
.border_color(colors.border.opacity(0.6))
|
||||
});
|
||||
let guides = guides.into_iter().map(|column| {
|
||||
let strong = active == Some(column);
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(column))
|
||||
.h(height)
|
||||
.w_0()
|
||||
.border_l_1()
|
||||
.border_color(if strong {
|
||||
colors.border_strong
|
||||
} else {
|
||||
colors.border.opacity(0.6)
|
||||
})
|
||||
});
|
||||
let carets = self
|
||||
.selections
|
||||
.iter()
|
||||
.filter(|selection| self.buffer.line_of(selection.head) == line)
|
||||
.filter(|_| focused && self.caret_on)
|
||||
.map(|selection| {
|
||||
let left = x(column_of(selection.head));
|
||||
let caret = theme.caret_width().to_pixels(window.rem_size());
|
||||
let mark = div().absolute().left(left);
|
||||
match self.options.cursor {
|
||||
CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus),
|
||||
CursorShape::Block => mark
|
||||
.top_0()
|
||||
.w(advance)
|
||||
.h(height)
|
||||
.bg(colors.focus.opacity(0.35)),
|
||||
CursorShape::Underline => mark
|
||||
.top(height - caret * 1.5)
|
||||
.w(advance)
|
||||
.h(caret * 1.5)
|
||||
.bg(colors.focus),
|
||||
}
|
||||
});
|
||||
let dots = self.options.whitespace.then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(0))
|
||||
.h(height)
|
||||
.line_height(height)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.fg_subtle.opacity(0.6))
|
||||
.child(self.whitespace(line, &shown, cx))
|
||||
});
|
||||
let folded = self.folded.contains(&line).then(|| {
|
||||
let entity = cx.entity();
|
||||
div()
|
||||
.id(("unfold", line))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(shown.text.chars().count() + 1))
|
||||
.h(height)
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.hover)
|
||||
.text_color(colors.fg_muted)
|
||||
.line_height(height * 0.8)
|
||||
.child("⋯"),
|
||||
)
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
entity.update(cx, |editor, cx| editor.toggle_fold(line, cx));
|
||||
})
|
||||
});
|
||||
let added = self
|
||||
.marks
|
||||
.hunks
|
||||
.iter()
|
||||
.any(|hunk| (hunk.line..hunk.line + hunk.added).contains(&line));
|
||||
let entity = cx.entity();
|
||||
let left = x(0);
|
||||
let code = div()
|
||||
.id(("code", row))
|
||||
.relative()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.when(current && focused, |code| code.bg(colors.hover))
|
||||
.when(added, |code| code.bg(colors.success.opacity(0.08)))
|
||||
.children(selections)
|
||||
.children(guides)
|
||||
.children(rulers)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(left)
|
||||
.h(height)
|
||||
.line_height(height)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(
|
||||
StyledText::new(shown.text.clone()).with_highlights(shown.styles.clone()),
|
||||
),
|
||||
)
|
||||
.children(dots)
|
||||
.children(folded)
|
||||
.children(carets)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
let origin = bounds.origin.x + left;
|
||||
entity.update(cx, |editor, _| {
|
||||
if editor.metrics.left != origin {
|
||||
editor.metrics.left = origin;
|
||||
}
|
||||
});
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |editor, event: &MouseDownEvent, window, cx| {
|
||||
editor.press(line, event, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| {
|
||||
editor.drag(line, event, cx)
|
||||
}));
|
||||
base.child(self.gutter(Cell::Line(line), row, cx))
|
||||
.child(code)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A press on a line: one caret, a word on a double press, the line on a triple; Shift extends, Alt adds a caret.
|
||||
pub(crate) fn press(
|
||||
&mut self,
|
||||
line: usize,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus);
|
||||
let at = self.offset_in(line, event.position.x, cx);
|
||||
let chosen = match event.click_count {
|
||||
2 => {
|
||||
let word = self.buffer.word_at(at);
|
||||
vec![Selection {
|
||||
anchor: word.start,
|
||||
head: word.end,
|
||||
goal: None,
|
||||
}]
|
||||
}
|
||||
3.. => {
|
||||
let range = self.buffer.line_range(line);
|
||||
vec![Selection {
|
||||
anchor: range.start,
|
||||
head: self.buffer.next(range.end),
|
||||
goal: None,
|
||||
}]
|
||||
}
|
||||
_ if event.modifiers.shift => {
|
||||
let mut all = self.selections.clone();
|
||||
let primary = all.last_mut().expect("an editor keeps a cursor");
|
||||
primary.head = at;
|
||||
primary.goal = None;
|
||||
all
|
||||
}
|
||||
_ if event.modifiers.alt => {
|
||||
let mut all = self.selections.clone();
|
||||
all.push(Selection::caret(at));
|
||||
all
|
||||
}
|
||||
_ => {
|
||||
self.dragging = true;
|
||||
vec![Selection::caret(at)]
|
||||
}
|
||||
};
|
||||
self.set_selections(chosen, cx);
|
||||
}
|
||||
|
||||
/// A drag over a line stretches the primary selection to where the pointer is.
|
||||
pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context<Self>) {
|
||||
if !self.dragging || !event.dragging() {
|
||||
return;
|
||||
}
|
||||
let at = self.offset_in(line, event.position.x, cx);
|
||||
let mut all = self.selections.clone();
|
||||
let primary = all.last_mut().expect("an editor keeps a cursor");
|
||||
if primary.head == at {
|
||||
return;
|
||||
}
|
||||
primary.head = at;
|
||||
primary.goal = None;
|
||||
self.set_selections(all, cx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
mod buffer;
|
||||
mod chat;
|
||||
mod console;
|
||||
mod cursor;
|
||||
mod decor;
|
||||
mod edit;
|
||||
mod extensions;
|
||||
mod filters;
|
||||
mod find;
|
||||
mod gutter;
|
||||
mod hints;
|
||||
mod hover;
|
||||
mod ime;
|
||||
mod keybindings;
|
||||
mod keys;
|
||||
mod layout;
|
||||
mod line;
|
||||
mod moves;
|
||||
mod preview;
|
||||
mod problems;
|
||||
mod refactor;
|
||||
mod row;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod status;
|
||||
mod symbols;
|
||||
mod syntax;
|
||||
mod tasks;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod view;
|
||||
mod welcome;
|
||||
|
||||
pub use buffer::Buffer;
|
||||
pub use chat::InlineChat;
|
||||
pub use console::{ConsoleEntry, DebugConsole, OutputPanel};
|
||||
pub use cursor::Selection;
|
||||
pub use decor::{
|
||||
CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint, ReadOnlyBanner, VimMode,
|
||||
VimModeIndicator,
|
||||
};
|
||||
pub use extensions::{Extension, ExtensionAction, ExtensionState, ExtensionsPanel};
|
||||
pub use filters::{SearchFilters, passes};
|
||||
pub use find::{FindOptions, FindWidget, find_all};
|
||||
pub use hints::{CompletionItem, CompletionMenu, Signature, SignatureHelp, completions};
|
||||
pub use hover::HoverInfo;
|
||||
pub use keybindings::{KeySource, Keybinding, KeybindingsEditor, conflicts};
|
||||
pub(crate) use keys::bind_keys;
|
||||
pub use preview::ThemePreview;
|
||||
pub use problems::{Problem, ProblemsPanel};
|
||||
pub use refactor::{ActionKind, CodeAction, CodeActionMenu, RenameInput};
|
||||
pub use search::{FileHits, Hit, ReferencesPanel, SearchPanel, SearchResultItem};
|
||||
pub use settings::{Setting, SettingValue, SettingsEditor, settings_json};
|
||||
pub use state::{CodeEditor, CursorShape, EditorEvent, LineNumbers};
|
||||
pub use status::{
|
||||
BranchIndicator, CursorPosition, Encoding, IndentSettings, LanguageMode, LineEnding, LspState,
|
||||
LspStatus, NotificationBell,
|
||||
};
|
||||
pub use symbols::{Call, CallHierarchy, GoToSymbol, Symbol, SymbolKind, SymbolOutline};
|
||||
pub use tasks::{Task, TaskRunner, TaskState};
|
||||
pub use welcome::{Project, ProjectSwitcher, RecentProjects, WelcomePage};
|
||||
@@ -0,0 +1,173 @@
|
||||
use gpui::{Context, ScrollStrategy};
|
||||
|
||||
use super::{
|
||||
cursor::{Motion, Selection, merged, moved},
|
||||
layout::{Row, hidden, rows},
|
||||
state::CodeEditor,
|
||||
syntax::folds,
|
||||
};
|
||||
|
||||
impl CodeEditor {
|
||||
/// Moves every cursor. Up and down step over folded lines.
|
||||
pub(crate) fn motion(&mut self, motion: Motion, extend: bool, cx: &mut Context<Self>) {
|
||||
let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded);
|
||||
let vertical = matches!(motion, Motion::Up | Motion::Down | Motion::Page(_));
|
||||
let moved: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let mut next = moved(&self.buffer, *selection, motion, extend);
|
||||
while vertical && hide[self.buffer.line_of(next.head)] {
|
||||
let again = moved(&self.buffer, next, motion, extend);
|
||||
if again.head == next.head {
|
||||
break;
|
||||
}
|
||||
next = again;
|
||||
}
|
||||
next
|
||||
})
|
||||
.collect();
|
||||
let up = matches!(
|
||||
motion,
|
||||
Motion::Up | Motion::Start | Motion::LineStart | Motion::WordLeft | Motion::Left
|
||||
) || matches!(motion, Motion::Page(lines) if lines < 0);
|
||||
self.set_selections(moved, cx);
|
||||
self.reveal(!up, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn select_all(&mut self, cx: &mut Context<Self>) {
|
||||
let all = 0..self.buffer.len();
|
||||
self.select([all], cx);
|
||||
}
|
||||
|
||||
/// A new cursor a line above or below the primary one, at its column.
|
||||
pub(crate) fn add_cursor(&mut self, below: bool, cx: &mut Context<Self>) {
|
||||
let primary = self.primary();
|
||||
let motion = if below { Motion::Down } else { Motion::Up };
|
||||
let next = moved(&self.buffer, Selection::caret(primary.head), motion, false);
|
||||
if next.head == primary.head {
|
||||
return;
|
||||
}
|
||||
let mut all = self.selections.clone();
|
||||
all.push(Selection { goal: None, ..next });
|
||||
self.set_selections(all, cx);
|
||||
self.reveal(below, cx);
|
||||
}
|
||||
|
||||
/// Selects the word at the primary cursor, then each next place its text appears.
|
||||
pub(crate) fn select_next(&mut self, cx: &mut Context<Self>) {
|
||||
let primary = self.primary();
|
||||
if primary.is_empty() {
|
||||
let word = self.buffer.word_at(primary.head);
|
||||
let mut all = self.selections.clone();
|
||||
all.pop();
|
||||
all.push(Selection {
|
||||
anchor: word.start,
|
||||
head: word.end,
|
||||
goal: None,
|
||||
});
|
||||
return self.set_selections(all, cx);
|
||||
}
|
||||
let needle = self.buffer.text()[primary.range()].to_string();
|
||||
let text = self.buffer.text();
|
||||
let found = text[primary.range().end..]
|
||||
.find(&needle)
|
||||
.map(|at| primary.range().end + at)
|
||||
.or_else(|| text.find(&needle));
|
||||
let Some(start) = found else {
|
||||
return;
|
||||
};
|
||||
let range = start..start + needle.len();
|
||||
if self
|
||||
.selections
|
||||
.iter()
|
||||
.any(|selection| selection.range() == range)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut all = self.selections.clone();
|
||||
all.push(Selection {
|
||||
anchor: range.start,
|
||||
head: range.end,
|
||||
goal: None,
|
||||
});
|
||||
self.set_selections(all, cx);
|
||||
self.reveal(true, cx);
|
||||
}
|
||||
|
||||
/// Escape: one cursor again, where the primary one was, and no ghost text.
|
||||
pub(crate) fn single_cursor(&mut self, cx: &mut Context<Self>) {
|
||||
let primary = self.primary();
|
||||
self.marks.ghost = None;
|
||||
self.set_selections(vec![Selection::caret(primary.head)], cx);
|
||||
}
|
||||
|
||||
/// Folds the block that opens on a line, or opens it again; cursors inside move to its header.
|
||||
pub(crate) fn toggle_fold(&mut self, line: usize, cx: &mut Context<Self>) {
|
||||
if !self.folded.remove(&line) {
|
||||
let Some((_, end)) = folds(&self.buffer)
|
||||
.into_iter()
|
||||
.find(|(header, _)| *header == line)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let header_end = self.buffer.line_range(line).end;
|
||||
let kept: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
if (line + 1..=end).contains(&self.buffer.line_of(selection.head)) {
|
||||
Selection::caret(header_end)
|
||||
} else {
|
||||
*selection
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.selections = merged(kept);
|
||||
self.folded.insert(line);
|
||||
}
|
||||
log::info!("code editor: folded {:?}", self.folded);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Opens every fold that hides a cursor.
|
||||
pub(crate) fn unfold_cursors(&mut self) {
|
||||
if self.folded.is_empty() {
|
||||
return;
|
||||
}
|
||||
let folds = folds(&self.buffer);
|
||||
let lines: Vec<usize> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| self.buffer.line_of(selection.head))
|
||||
.collect();
|
||||
self.folded.retain(|header| {
|
||||
let end = folds
|
||||
.iter()
|
||||
.find(|(start, _)| start == header)
|
||||
.map(|(_, end)| *end);
|
||||
end.is_some_and(|end| !lines.iter().any(|line| (header + 1..=end).contains(line)))
|
||||
});
|
||||
}
|
||||
|
||||
/// The row the primary cursor draws on.
|
||||
pub(crate) fn primary_row(&self) -> usize {
|
||||
let line = self.buffer.line_of(self.primary().head);
|
||||
let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded);
|
||||
rows(&self.buffer, &hide, &self.marks)
|
||||
.iter()
|
||||
.position(|row| *row == Row::Line(line))
|
||||
.expect("the primary cursor sits on a shown line")
|
||||
}
|
||||
|
||||
/// Scrolls the primary cursor into view: from below when moving down, from above when moving up.
|
||||
pub(crate) fn reveal(&mut self, down: bool, cx: &mut Context<Self>) {
|
||||
let strategy = if down {
|
||||
ScrollStrategy::Bottom
|
||||
} else {
|
||||
ScrollStrategy::Top
|
||||
};
|
||||
self.scroll.scroll_to_item(self.primary_row(), strategy);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnPick = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A theme at a glance: a small editor painted in its own palette, whatever the app's theme; a press picks it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ThemePreview {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
palette: Palette,
|
||||
selected: bool,
|
||||
on_pick: Option<OnPick>,
|
||||
}
|
||||
|
||||
impl ThemePreview {
|
||||
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, palette: Palette) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
palette,
|
||||
selected: false,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pick(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ThemePreview {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let ring = theme.colors.focus;
|
||||
let shown = self.palette;
|
||||
let syntax = shown.syntax;
|
||||
let line = |parts: Vec<(&'static str, gpui::Hsla)>, indent: usize| {
|
||||
div()
|
||||
.flex()
|
||||
.pl(theme.text_size(TextSize::Xs) * indent as f32)
|
||||
.children(
|
||||
parts
|
||||
.into_iter()
|
||||
.map(|(words, color)| div().text_color(color).child(words)),
|
||||
)
|
||||
};
|
||||
let code = div()
|
||||
.flex_1()
|
||||
.p_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(line(
|
||||
vec![
|
||||
("fn ", syntax.keyword),
|
||||
("paint", syntax.function),
|
||||
("(", syntax.punctuation),
|
||||
("mode", syntax.variable),
|
||||
(": ", syntax.punctuation),
|
||||
("Mode", syntax.type_name),
|
||||
(") {", syntax.punctuation),
|
||||
],
|
||||
0,
|
||||
))
|
||||
.child(line(vec![("// quiet by default", syntax.comment)], 2))
|
||||
.child(line(
|
||||
vec![
|
||||
("let ", syntax.keyword),
|
||||
("gap", syntax.variable),
|
||||
(" = ", syntax.operator),
|
||||
("12", syntax.number),
|
||||
(";", syntax.punctuation),
|
||||
],
|
||||
2,
|
||||
))
|
||||
.child(line(
|
||||
vec![
|
||||
("draw", syntax.function),
|
||||
("(", syntax.punctuation),
|
||||
("\"surface\"", syntax.string),
|
||||
(", ", syntax.punctuation),
|
||||
("true", syntax.constant),
|
||||
(");", syntax.punctuation),
|
||||
],
|
||||
2,
|
||||
))
|
||||
.child(line(vec![("}", syntax.punctuation)], 0));
|
||||
let pick = self.on_pick;
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.cursor_pointer()
|
||||
.when_some(pick, |card, pick| {
|
||||
card.on_click(move |_, window, cx| pick(window, cx))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.h(theme.label_width() * 1.1)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_2()
|
||||
.border_color(if self.selected { ring } else { shown.border })
|
||||
.bg(shown.bg)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(shown.surface)
|
||||
.border_b_1()
|
||||
.border_color(shown.border)
|
||||
.children(
|
||||
[shown.danger, shown.warning, shown.success]
|
||||
.map(|dot| div().size_1p5().rounded_full().bg(dot)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.child(
|
||||
div()
|
||||
.w_6()
|
||||
.bg(shown.sunken)
|
||||
.border_r_1()
|
||||
.border_color(shown.border),
|
||||
)
|
||||
.child(code),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.h_3()
|
||||
.bg(shown.surface)
|
||||
.border_t_1()
|
||||
.border_color(shown.border)
|
||||
.child(div().w_4().h_1().rounded_full().bg(shown.accent)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(if self.selected {
|
||||
FontWeight::SEMIBOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.text_color(theme.colors.fg)
|
||||
.when(self.selected, |label| {
|
||||
label.child(Icon::new(IconName::Check).size(IconSize::Sm).color(ring))
|
||||
})
|
||||
.child(self.name),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ToggleButton, ToggleItem},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A problem the tools found: where, how bad, what, and which tool said so.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Problem {
|
||||
pub path: SharedString,
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
pub severity: Severity,
|
||||
pub message: SharedString,
|
||||
pub source: Option<SharedString>,
|
||||
}
|
||||
|
||||
/// How bad a severity is, worst highest.
|
||||
pub(crate) fn weight(severity: Severity) -> u8 {
|
||||
match severity {
|
||||
Severity::Danger => 3,
|
||||
Severity::Warning => 2,
|
||||
Severity::Info => 1,
|
||||
Severity::Success => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Problems by file, worst first within each, and each file in the order its worst problem ranks.
|
||||
pub(crate) fn grouped(
|
||||
problems: &[Problem],
|
||||
shown: &[Severity],
|
||||
) -> Vec<(SharedString, Vec<Problem>)> {
|
||||
let mut files: Vec<(SharedString, Vec<Problem>)> = Vec::new();
|
||||
for problem in problems
|
||||
.iter()
|
||||
.filter(|problem| shown.contains(&problem.severity))
|
||||
{
|
||||
match files.iter_mut().find(|(path, _)| *path == problem.path) {
|
||||
Some((_, list)) => list.push(problem.clone()),
|
||||
None => files.push((problem.path.clone(), vec![problem.clone()])),
|
||||
}
|
||||
}
|
||||
for (_, list) in &mut files {
|
||||
list.sort_by(|a, b| {
|
||||
weight(b.severity)
|
||||
.cmp(&weight(a.severity))
|
||||
.then(a.line.cmp(&b.line))
|
||||
});
|
||||
}
|
||||
files.sort_by_key(|(_, list)| {
|
||||
std::cmp::Reverse(list.first().map_or(0, |problem| weight(problem.severity)))
|
||||
});
|
||||
files
|
||||
}
|
||||
|
||||
type OnOpen = Rc<dyn Fn(&Problem, &mut Window, &mut App)>;
|
||||
type OnShown = Rc<dyn Fn(Vec<Severity>, &mut Window, &mut App)>;
|
||||
|
||||
/// Every problem in the workspace, grouped by file: toggles by severity with their counts, and a press opens one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProblemsPanel {
|
||||
id: ElementId,
|
||||
problems: Vec<Problem>,
|
||||
shown: Vec<Severity>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_shown: Option<OnShown>,
|
||||
}
|
||||
|
||||
impl ProblemsPanel {
|
||||
pub fn new(id: impl Into<ElementId>, problems: impl IntoIterator<Item = Problem>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
problems: problems.into_iter().collect(),
|
||||
shown: vec![Severity::Danger, Severity::Warning, Severity::Info],
|
||||
on_open: None,
|
||||
on_shown: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The severities listed; the rest are counted but hidden.
|
||||
pub fn shown(mut self, severities: impl IntoIterator<Item = Severity>) -> Self {
|
||||
self.shown = severities.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(&Problem, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_shown(
|
||||
mut self,
|
||||
handler: impl Fn(Vec<Severity>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_shown = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProblemsPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = |severity: Severity| {
|
||||
self.problems
|
||||
.iter()
|
||||
.filter(|problem| problem.severity == severity)
|
||||
.count()
|
||||
};
|
||||
let filters = [
|
||||
(Severity::Danger, "errors", "Error"),
|
||||
(Severity::Warning, "warnings", "Warning"),
|
||||
(Severity::Info, "notes", "Note"),
|
||||
]
|
||||
.map(|(severity, key, words)| {
|
||||
let (shown, on_shown) = (self.shown.clone(), self.on_shown.clone());
|
||||
let found = count(severity);
|
||||
let label = format!("{found} {words}{}", if found == 1 { "" } else { "s" });
|
||||
ToggleButton::new(
|
||||
(self.id.clone(), key),
|
||||
ToggleItem::new(key).icon(severity.icon()).label(label),
|
||||
self.shown.contains(&severity),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |on, window, cx| {
|
||||
let mut next: Vec<Severity> = shown
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|kept| *kept != severity)
|
||||
.collect();
|
||||
if on {
|
||||
next.push(severity);
|
||||
}
|
||||
log::info!("problems: showing {next:?}");
|
||||
if let Some(on_shown) = &on_shown {
|
||||
on_shown(next, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
let files = grouped(&self.problems, &self.shown);
|
||||
let mut rows = Vec::new();
|
||||
for (file_ix, (path, list)) in files.iter().enumerate() {
|
||||
let (name, dir) = path
|
||||
.rsplit_once('/')
|
||||
.map_or((path.to_string(), String::new()), |(dir, name)| {
|
||||
(name.to_string(), dir.to_string())
|
||||
});
|
||||
rows.push(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_1()
|
||||
.pt_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
Icon::new(IconName::FileText)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(div().text_color(colors.fg).child(name))
|
||||
.child(div().text_color(colors.fg_subtle).child(dir))
|
||||
.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.rounded_full()
|
||||
.bg(colors.hover)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(list.len().to_string()),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
for (ix, problem) in list.iter().enumerate() {
|
||||
let (open, picked) = (self.on_open.clone(), problem.clone());
|
||||
rows.push(
|
||||
div()
|
||||
.id((self.id.clone(), format!("problem-{file_ix}-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.pl_6()
|
||||
.pr_2()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when_some(open, |row, open| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| {
|
||||
window.prevent_default()
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("problems: open {}:{}", picked.path, picked.line + 1);
|
||||
open(&picked, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Icon::new(problem.severity.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(problem.severity.color(&colors)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg)
|
||||
.child(Ellipsis::new(problem.message.clone())),
|
||||
)
|
||||
.children(
|
||||
problem
|
||||
.source
|
||||
.clone()
|
||||
.map(|source| div().text_color(colors.fg_subtle).child(source)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child(format!("{}:{}", problem.line + 1, problem.column + 1)),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let empty = rows.is_empty();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(div().flex().gap_1().children(filters))
|
||||
.when(empty, |panel| {
|
||||
panel.child(
|
||||
div()
|
||||
.py_4()
|
||||
.text_center()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("No problems shown."),
|
||||
)
|
||||
})
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn problem(path: &str, line: usize, severity: Severity) -> Problem {
|
||||
Problem {
|
||||
path: path.to_string().into(),
|
||||
line,
|
||||
column: 0,
|
||||
severity,
|
||||
message: "m".into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_worse_problems_lead_and_filters_hide() {
|
||||
let problems = [
|
||||
problem("a.rs", 9, Severity::Warning),
|
||||
problem("b.rs", 3, Severity::Danger),
|
||||
problem("a.rs", 2, Severity::Info),
|
||||
];
|
||||
let all = grouped(
|
||||
&problems,
|
||||
&[Severity::Danger, Severity::Warning, Severity::Info],
|
||||
);
|
||||
assert_eq!(all[0].0.as_ref(), "b.rs");
|
||||
assert_eq!(all[1].1[0].line, 9, "the warning before the note");
|
||||
let only = grouped(&problems, &[Severity::Info]);
|
||||
assert_eq!(only.len(), 1);
|
||||
assert_eq!(only[0].1.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{Enter, Input, TextInput},
|
||||
menus::{Menu, MenuItem, OverflowMenu},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// What a code action does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ActionKind {
|
||||
QuickFix,
|
||||
Refactor,
|
||||
Source,
|
||||
}
|
||||
|
||||
/// A change the tools offer at the cursor; a preferred one leads its group.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CodeAction {
|
||||
pub title: SharedString,
|
||||
pub kind: ActionKind,
|
||||
pub preferred: bool,
|
||||
}
|
||||
|
||||
impl CodeAction {
|
||||
pub fn new(title: impl Into<SharedString>, kind: ActionKind) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
kind,
|
||||
preferred: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preferred(mut self) -> Self {
|
||||
self.preferred = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
type OnAction = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// A lightbulb that opens the actions at the cursor: fixes, refactors and source actions, each group led by its preferred one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CodeActionMenu {
|
||||
id: ElementId,
|
||||
actions: Vec<CodeAction>,
|
||||
on_pick: Option<OnAction>,
|
||||
}
|
||||
|
||||
impl CodeActionMenu {
|
||||
pub fn new(id: impl Into<ElementId>, actions: impl IntoIterator<Item = CodeAction>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
actions: actions.into_iter().collect(),
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the chosen action's place among the actions given.
|
||||
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CodeActionMenu {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let groups = [
|
||||
(ActionKind::QuickFix, "Quick fixes"),
|
||||
(ActionKind::Refactor, "Refactor"),
|
||||
(ActionKind::Source, "Source"),
|
||||
];
|
||||
let menu = groups.into_iter().fold(Menu::new(), |menu, (kind, title)| {
|
||||
let mut found: Vec<(usize, &CodeAction)> = self
|
||||
.actions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, action)| action.kind == kind)
|
||||
.collect();
|
||||
if found.is_empty() {
|
||||
return menu;
|
||||
}
|
||||
found.sort_by_key(|(_, action)| !action.preferred);
|
||||
let items = found.into_iter().map(|(ix, action)| {
|
||||
let pick = self.on_pick.clone();
|
||||
let item = MenuItem::new(action.title.clone());
|
||||
let item = if action.preferred {
|
||||
item.icon(IconName::Sparkles)
|
||||
} else {
|
||||
item
|
||||
};
|
||||
item.on_click(move |window, cx| {
|
||||
log::info!("code action: {ix}");
|
||||
if let Some(pick) = &pick {
|
||||
pick(ix, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
menu.group(title, items)
|
||||
});
|
||||
OverflowMenu::new(self.id, menu)
|
||||
.icon(IconName::Lightbulb)
|
||||
.tooltip("Show code actions")
|
||||
}
|
||||
}
|
||||
|
||||
type OnName = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
|
||||
type OnCancel = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A small box over a symbol to rename it: Enter renames everywhere, Escape keeps the name.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RenameInput {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_rename: Option<OnName>,
|
||||
on_cancel: Option<OnCancel>,
|
||||
}
|
||||
|
||||
impl RenameInput {
|
||||
/// `field` holds the new name; seed it with the current one.
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_rename: None,
|
||||
on_cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_rename(
|
||||
mut self,
|
||||
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_rename = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_cancel = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RenameInput {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (field, rename, cancel) = (self.field.clone(), self.on_rename, self.on_cancel);
|
||||
div()
|
||||
.id(self.id)
|
||||
.w(theme.label_width() * 2.5)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.focus)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
let name = field.read(cx).text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("rename: {name}");
|
||||
if let Some(rename) = &rename {
|
||||
rename(name.into(), window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event: &KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
if let Some(cancel) = &cancel {
|
||||
cancel(window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.child(Input::new(&self.field).size(ControlSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.px_0p5()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Enter to rename · Escape to cancel"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Stateful,
|
||||
Styled, StyledText, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
chat::InlineChat,
|
||||
gutter::Cell,
|
||||
layout::{CHAT_ROWS, Row},
|
||||
state::{CodeEditor, EditorEvent},
|
||||
syntax,
|
||||
};
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
impl CodeEditor {
|
||||
/// Row `ix` as the list draws it.
|
||||
pub(crate) fn row(&self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let base = div()
|
||||
.id(("editor-row", ix))
|
||||
.w_full()
|
||||
.h(self.frame.line)
|
||||
.flex();
|
||||
match self.frame.rows[ix] {
|
||||
Row::Line(line) => self.line_row(base, ix, line, window, cx),
|
||||
Row::Lens(at) => self.lens_row(base, ix, at, cx),
|
||||
Row::Removed(hunk, at) => self.removed_row(base, ix, hunk, at, cx),
|
||||
Row::Ghost(at) => self.ghost_row(base, ix, at, cx),
|
||||
Row::Chat(at) => self.chat_row(base, ix, at, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn lens_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let gutter = self.gutter(Cell::Blank, ix, cx);
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let lens = &self.marks.lenses[at];
|
||||
let indent = self.buffer.indent(lens.line);
|
||||
let items = lens.items.iter().enumerate().map(|(item, words)| {
|
||||
let line = lens.line;
|
||||
div()
|
||||
.id(("lens", item))
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.text_color(colors.link))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
log::info!("code lens: line {line}, item {item}");
|
||||
cx.emit(EditorEvent::Lens(line, item));
|
||||
}))
|
||||
.child(words.clone())
|
||||
});
|
||||
let items: Vec<AnyElement> = items
|
||||
.enumerate()
|
||||
.flat_map(|(place, item)| {
|
||||
let gap = (place > 0).then(|| div().child("·").into_any_element());
|
||||
gap.into_iter().chain([item.into_any_element()])
|
||||
})
|
||||
.collect();
|
||||
base.child(gutter)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl(self.frame.advance * (indent + 1) as f32)
|
||||
.font_family(theme.font_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.children(items),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn removed_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
hunk: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let text = self.marks.hunks[hunk].removed[at].clone();
|
||||
base.child(self.gutter(Cell::Removed, ix, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.pl(self.frame.advance)
|
||||
.bg(colors.danger.opacity(0.08))
|
||||
.line_height(self.frame.line)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn ghost_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let ghost = self
|
||||
.marks
|
||||
.ghost
|
||||
.as_ref()
|
||||
.expect("a ghost row has its ghost");
|
||||
let lines: Vec<&str> = ghost.text.split('\n').collect();
|
||||
let text = lines[at].to_string();
|
||||
let rest = (at + 1 == lines.len()).then(|| {
|
||||
let line = self.buffer.line_of(ghost.offset);
|
||||
let rest =
|
||||
self.buffer.text()[ghost.offset..self.buffer.line_range(line).end].to_string();
|
||||
let styles = syntax::colors(&rest, cx);
|
||||
StyledText::new(rest).with_highlights(styles)
|
||||
});
|
||||
base.child(self.gutter(Cell::Blank, ix, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.pl(self.frame.advance)
|
||||
.line_height(self.frame.line)
|
||||
.whitespace_nowrap()
|
||||
.child(div().italic().text_color(colors.fg_subtle).child(text))
|
||||
.children(rest),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chat_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let gutter = self.gutter(Cell::Blank, ix, cx);
|
||||
let theme = cx.theme();
|
||||
let row = base.child(gutter);
|
||||
if at > 0 {
|
||||
return row.into_any_element();
|
||||
}
|
||||
let asked = cx.entity();
|
||||
let closed = cx.entity();
|
||||
let field = self.chat_field.clone();
|
||||
row.child(
|
||||
div().relative().flex_1().h_full().child(
|
||||
InlineChat::new(("inline-chat", ix), &self.chat_field)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(self.frame.advance)
|
||||
.w(self.frame.advance * 72.0)
|
||||
.h(self.frame.line * CHAT_ROWS as f32)
|
||||
.font_family(theme.font_family.clone())
|
||||
.on_ask(move |prompt, _, cx| {
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
asked.update(cx, |_, cx| cx.emit(EditorEvent::Asked(prompt)));
|
||||
})
|
||||
.on_close(move |window, cx| {
|
||||
closed.update(cx, |editor, cx| editor.set_chat(None, window, cx));
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledText, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A line that matched: its number, its text, and where in it the matches lie.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Hit {
|
||||
pub line: usize,
|
||||
pub text: SharedString,
|
||||
pub ranges: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
/// A file and the lines in it that matched.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FileHits {
|
||||
pub path: SharedString,
|
||||
pub hits: Vec<Hit>,
|
||||
}
|
||||
|
||||
/// Characters kept before the first match when a line is long.
|
||||
const LEAD: usize = 24;
|
||||
|
||||
/// A hit's text trimmed to start near its first match, with its ranges moved along.
|
||||
fn trimmed(hit: &Hit) -> (String, Vec<Range<usize>>) {
|
||||
let first = hit.ranges.first().map_or(0, |range| range.start);
|
||||
let before = hit.text[..first].chars().count();
|
||||
let text = hit.text.trim_end();
|
||||
if before <= LEAD {
|
||||
let start = text.len() - text.trim_start().len();
|
||||
let start = start.min(first);
|
||||
let ranges = hit
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|range| range.start - start..range.end - start)
|
||||
.collect();
|
||||
return (text[start..].to_string(), ranges);
|
||||
}
|
||||
let cut = hit.text[..first]
|
||||
.char_indices()
|
||||
.nth(before - LEAD)
|
||||
.map_or(first, |(at, _)| at);
|
||||
let shown = format!("…{}", &text[cut..]);
|
||||
let shift = '…'.len_utf8();
|
||||
let ranges = hit
|
||||
.ranges
|
||||
.iter()
|
||||
.filter(|range| range.start >= cut)
|
||||
.map(|range| range.start - cut + shift..range.end - cut + shift)
|
||||
.collect();
|
||||
(shown, ranges)
|
||||
}
|
||||
|
||||
type OnOpen = Rc<dyn Fn(&SharedString, usize, &mut Window, &mut App)>;
|
||||
type OnPath = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// One matched line: its number, and its text trimmed near the first match with the matches marked; a replacement shows struck out beside the new text.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchResultItem {
|
||||
id: ElementId,
|
||||
path: SharedString,
|
||||
hit: Hit,
|
||||
replacement: Option<SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
}
|
||||
|
||||
impl SearchResultItem {
|
||||
pub fn new(id: impl Into<ElementId>, path: impl Into<SharedString>, hit: Hit) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
path: path.into(),
|
||||
hit,
|
||||
replacement: None,
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Previews replacing each match with this.
|
||||
pub fn replacement(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.replacement = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchResultItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (text, ranges) = trimmed(&self.hit);
|
||||
let (text, styles) = match &self.replacement {
|
||||
None => {
|
||||
let style = HighlightStyle {
|
||||
background_color: Some(colors.warning.opacity(0.25)),
|
||||
font_weight: Some(FontWeight::SEMIBOLD),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
(
|
||||
text,
|
||||
ranges
|
||||
.into_iter()
|
||||
.map(|range| (range, style))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
Some(new) => {
|
||||
let (mut out, mut styles, mut last) = (String::new(), Vec::new(), 0);
|
||||
let old = HighlightStyle {
|
||||
background_color: Some(colors.danger.opacity(0.18)),
|
||||
strikethrough: Some(gpui::StrikethroughStyle {
|
||||
thickness: theme.underline_thickness(),
|
||||
color: Some(colors.danger),
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let added = HighlightStyle {
|
||||
background_color: Some(colors.success.opacity(0.18)),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
for range in ranges {
|
||||
out.push_str(&text[last..range.start]);
|
||||
styles.push((out.len()..out.len() + range.len(), old));
|
||||
out.push_str(&text[range.clone()]);
|
||||
styles.push((out.len()..out.len() + new.len(), added));
|
||||
out.push_str(new);
|
||||
last = range.end;
|
||||
}
|
||||
out.push_str(&text[last..]);
|
||||
(out, styles)
|
||||
}
|
||||
};
|
||||
let (path, line) = (self.path.clone(), self.hit.line);
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when_some(self.on_open, |row, open| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("search result: {path}:{}", line + 1);
|
||||
open(&path, line, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(theme.label_width() * 0.4)
|
||||
.text_right()
|
||||
.text_color(colors.fg_subtle)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child((self.hit.line + 1).to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg_muted)
|
||||
.child(StyledText::new(text).with_highlights(styles)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Results grouped by file, each with its count and a chevron that folds it; the totals lead.
|
||||
fn results(
|
||||
id: &ElementId,
|
||||
files: &[FileHits],
|
||||
folded: &[SharedString],
|
||||
replacement: Option<&SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_fold: Option<OnPath>,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mut out = Vec::new();
|
||||
for (file_ix, file) in files.iter().enumerate() {
|
||||
let open = !folded.contains(&file.path);
|
||||
let (name, dir) = match file.path.rsplit_once('/') {
|
||||
Some((dir, name)) => (name.to_string(), dir.to_string()),
|
||||
None => (file.path.to_string(), String::new()),
|
||||
};
|
||||
let fold = on_fold.clone();
|
||||
let path = file.path.clone();
|
||||
out.push(
|
||||
div()
|
||||
.id((id.clone(), format!("file-{file_ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when_some(fold, |row, fold| {
|
||||
row.on_click(move |_, window, cx| fold(&path, window, cx))
|
||||
})
|
||||
.child(Disclosure::new(
|
||||
(id.clone(), format!("fold-{file_ix}")),
|
||||
open,
|
||||
))
|
||||
.child(
|
||||
Icon::new(IconName::FileText)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(div().text_color(colors.fg).child(name))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(dir),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.rounded_full()
|
||||
.bg(colors.hover)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(file.hits.len().to_string()),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
for (hit_ix, hit) in file.hits.iter().enumerate() {
|
||||
let item = SearchResultItem::new(
|
||||
(id.clone(), format!("hit-{file_ix}-{hit_ix}")),
|
||||
file.path.clone(),
|
||||
hit.clone(),
|
||||
);
|
||||
let item = match replacement {
|
||||
Some(text) => item.replacement(text.clone()),
|
||||
None => item,
|
||||
};
|
||||
let item = match &on_open {
|
||||
Some(open) => {
|
||||
let open = open.clone();
|
||||
item.on_open(move |path, line, window, cx| open(path, line, window, cx))
|
||||
}
|
||||
None => item,
|
||||
};
|
||||
out.push(div().pl_5().child(item).into_any_element());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Results across files, folding by file, with the totals above; a replacement previews on each line.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchPanel {
|
||||
id: ElementId,
|
||||
files: Vec<FileHits>,
|
||||
folded: Vec<SharedString>,
|
||||
replacement: Option<SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_fold: Option<OnPath>,
|
||||
}
|
||||
|
||||
impl SearchPanel {
|
||||
pub fn new(id: impl Into<ElementId>, files: impl IntoIterator<Item = FileHits>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
files: files.into_iter().collect(),
|
||||
folded: Vec::new(),
|
||||
replacement: None,
|
||||
on_open: None,
|
||||
on_fold: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths whose results are folded away.
|
||||
pub fn folded(mut self, paths: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.folded = paths.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn replacement(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.replacement = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets a path whose chevron was pressed.
|
||||
pub fn on_fold(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_fold = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let hits: usize = self
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.hits.iter().map(|hit| hit.ranges.len()).sum::<usize>())
|
||||
.sum();
|
||||
let summary = format!("{hits} results in {} files", self.files.len());
|
||||
let rows = results(
|
||||
&self.id,
|
||||
&self.files,
|
||||
&self.folded,
|
||||
self.replacement.as_ref(),
|
||||
self.on_open,
|
||||
self.on_fold,
|
||||
cx,
|
||||
);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.pb_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(summary),
|
||||
)
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a symbol is used, grouped by file, under its name and count.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReferencesPanel {
|
||||
id: ElementId,
|
||||
symbol: SharedString,
|
||||
files: Vec<FileHits>,
|
||||
on_open: Option<OnOpen>,
|
||||
}
|
||||
|
||||
impl ReferencesPanel {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
symbol: impl Into<SharedString>,
|
||||
files: impl IntoIterator<Item = FileHits>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
symbol: symbol.into(),
|
||||
files: files.into_iter().collect(),
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReferencesPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count: usize = self.files.iter().map(|file| file.hits.len()).sum();
|
||||
let rows = results(&self.id, &self.files, &[], None, self.on_open, None, cx);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.pb_1()
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg)
|
||||
.child(self.symbol),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("{count} references")),
|
||||
),
|
||||
)
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn long_lines_start_near_their_first_match() {
|
||||
let needle = 40..46;
|
||||
let hit = Hit {
|
||||
line: 0,
|
||||
text: format!("{}needle end", "x".repeat(40)).into(),
|
||||
ranges: vec![needle],
|
||||
};
|
||||
let (text, ranges) = trimmed(&hit);
|
||||
assert!(text.starts_with('…'));
|
||||
assert_eq!(&text[ranges[0].clone()], "needle");
|
||||
let needle = 8..14;
|
||||
let short = Hit {
|
||||
line: 0,
|
||||
text: " let needle = 1;".into(),
|
||||
ranges: vec![needle],
|
||||
};
|
||||
let (text, ranges) = trimmed(&short);
|
||||
assert_eq!(text, "let needle = 1;", "leading spaces go");
|
||||
assert_eq!(&text[ranges[0].clone()], "needle");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton, SegmentedControl},
|
||||
forms::{Choice, JsonInput, NumberInput, Select, Switch, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A setting's value and the form it takes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SettingValue {
|
||||
Bool(bool),
|
||||
Number {
|
||||
value: f64,
|
||||
min: f64,
|
||||
max: f64,
|
||||
step: f64,
|
||||
},
|
||||
Choice {
|
||||
value: SharedString,
|
||||
choices: Vec<SharedString>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SettingValue {
|
||||
fn json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
SettingValue::Bool(on) => (*on).into(),
|
||||
SettingValue::Number { value, .. } => serde_json::Number::from_f64(*value)
|
||||
.map(serde_json::Value::Number)
|
||||
.expect("a setting's number is finite"),
|
||||
SettingValue::Choice { value, .. } => value.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One setting: its dotted key, title, what it does, its value and the value it starts from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Setting {
|
||||
pub key: SharedString,
|
||||
pub title: SharedString,
|
||||
pub description: SharedString,
|
||||
pub value: SettingValue,
|
||||
pub default: SettingValue,
|
||||
}
|
||||
|
||||
impl Setting {
|
||||
pub fn modified(&self) -> bool {
|
||||
self.value != self.default
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings changed from their defaults, as a settings file holds them.
|
||||
pub fn settings_json(groups: &[(SharedString, Vec<Setting>)]) -> String {
|
||||
let map: serde_json::Map<String, serde_json::Value> = groups
|
||||
.iter()
|
||||
.flat_map(|(_, settings)| settings)
|
||||
.filter(|setting| setting.modified())
|
||||
.map(|setting| (setting.key.to_string(), setting.value.json()))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("settings serialize")
|
||||
}
|
||||
|
||||
type OnChange = Rc<dyn Fn(&SharedString, SettingValue, &mut Window, &mut App)>;
|
||||
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Settings two ways: as rows of controls by section, each changed one marked with a way back, or as the JSON a settings file holds.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SettingsEditor {
|
||||
id: ElementId,
|
||||
groups: Vec<(SharedString, Vec<Setting>)>,
|
||||
section: usize,
|
||||
json: Option<Entity<TextInput>>,
|
||||
on_change: Option<OnChange>,
|
||||
on_section: Option<OnIndex>,
|
||||
on_json: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl SettingsEditor {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
groups: impl IntoIterator<Item = (impl Into<SharedString>, Vec<Setting>)>,
|
||||
) -> Self {
|
||||
let groups: Vec<(SharedString, Vec<Setting>)> = groups
|
||||
.into_iter()
|
||||
.map(|(title, settings)| (title.into(), settings))
|
||||
.collect();
|
||||
assert!(!groups.is_empty(), "settings come in at least one section");
|
||||
Self {
|
||||
id: id.into(),
|
||||
groups,
|
||||
section: 0,
|
||||
json: None,
|
||||
on_change: None,
|
||||
on_section: None,
|
||||
on_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn section(mut self, section: usize) -> Self {
|
||||
self.section = section;
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the JSON, held in `field`; seed it with `settings_json`.
|
||||
pub fn json(mut self, field: &Entity<TextInput>) -> Self {
|
||||
self.json = Some(field.clone());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, SettingValue, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_section(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_section = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets whether the JSON view is wanted.
|
||||
pub fn on_json(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_json = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SettingsEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mode = SegmentedControl::new(
|
||||
(self.id.clone(), "mode"),
|
||||
if self.json.is_some() { "json" } else { "ui" },
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("ui", "Settings", None)
|
||||
.segment("json", "JSON", None);
|
||||
let mode = match self.on_json.clone() {
|
||||
Some(on_json) => {
|
||||
mode.on_change(move |key, window, cx| on_json(key.as_ref() == "json", window, cx))
|
||||
}
|
||||
None => mode,
|
||||
};
|
||||
let header = div().flex().items_center().justify_between().child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child("Settings"),
|
||||
);
|
||||
let header = header.child(mode);
|
||||
if let Some(field) = self.json {
|
||||
return div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(header)
|
||||
.child(JsonInput::new(&field))
|
||||
.into_any_element();
|
||||
}
|
||||
let section = self.section.min(self.groups.len() - 1);
|
||||
let nav = self
|
||||
.groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (title, settings))| {
|
||||
let pick = self.on_section.clone();
|
||||
let changed = settings.iter().filter(|setting| setting.modified()).count();
|
||||
div()
|
||||
.id((self.id.clone(), format!("section-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.when(ix == section, |row| {
|
||||
row.bg(colors.active).text_color(colors.fg)
|
||||
})
|
||||
.when(ix != section, |row| {
|
||||
row.text_color(colors.fg_muted)
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
})
|
||||
.when_some(pick, |row, pick| {
|
||||
row.on_click(move |_, window, cx| pick(ix, window, cx))
|
||||
})
|
||||
.child(title.clone())
|
||||
.when(changed > 0, |row| {
|
||||
row.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.accent)
|
||||
.child(changed.to_string()),
|
||||
)
|
||||
})
|
||||
});
|
||||
let rows = self.groups[section]
|
||||
.1
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, setting)| {
|
||||
let id = ElementId::from((self.id.clone(), format!("{section}-{ix}")));
|
||||
let (change, key) = (self.on_change.clone(), setting.key.clone());
|
||||
let tell = move |value: SettingValue, window: &mut Window, cx: &mut App| {
|
||||
log::info!("settings: {key} = {value:?}");
|
||||
if let Some(change) = &change {
|
||||
change(&key, value, window, cx);
|
||||
}
|
||||
};
|
||||
let tell = Rc::new(tell);
|
||||
let control = match &setting.value {
|
||||
SettingValue::Bool(on) => {
|
||||
let tell = tell.clone();
|
||||
Switch::new(id.clone(), *on)
|
||||
.on_change(move |on, window, cx| {
|
||||
tell(SettingValue::Bool(on), window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
SettingValue::Number {
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
} => {
|
||||
let (tell, min, max, step) = (tell.clone(), *min, *max, *step);
|
||||
div()
|
||||
.w(theme.label_width())
|
||||
.child(
|
||||
NumberInput::new(id.clone(), *value)
|
||||
.range(min, max)
|
||||
.step(step)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, window, cx| {
|
||||
tell(
|
||||
SettingValue::Number {
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
SettingValue::Choice { value, choices } => {
|
||||
let (tell, all) = (tell.clone(), choices.clone());
|
||||
div()
|
||||
.w(theme.label_width() * 1.5)
|
||||
.child(
|
||||
Select::new(
|
||||
id.clone(),
|
||||
choices
|
||||
.iter()
|
||||
.map(|choice| Choice::new(choice.clone(), choice.clone())),
|
||||
)
|
||||
.selected(value.clone())
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(
|
||||
move |picked, window, cx| {
|
||||
let value = SettingValue::Choice {
|
||||
value: picked.clone(),
|
||||
choices: all.clone(),
|
||||
};
|
||||
tell(value, window, cx)
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let reset = setting.modified().then(|| {
|
||||
let (tell, default) = (tell.clone(), setting.default.clone());
|
||||
IconButton::new(
|
||||
(self.id.clone(), format!("reset-{section}-{ix}")),
|
||||
IconName::RotateCcw,
|
||||
)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Reset to default")
|
||||
.on_click(move |_, window, cx| tell(default.clone(), window, cx))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.py_3()
|
||||
.pl_3()
|
||||
.border_l_2()
|
||||
.border_color(if setting.modified() {
|
||||
colors.accent
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(setting.title.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(setting.key.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(setting.description.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.children(reset)
|
||||
.child(control),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(header)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 1.25)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.children(nav),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().flex().flex_col().children(rows)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_holds_only_what_changed() {
|
||||
let groups = vec![(
|
||||
SharedString::from("Editor"),
|
||||
vec![
|
||||
Setting {
|
||||
key: "editor.fontSize".into(),
|
||||
title: "Font size".into(),
|
||||
description: "".into(),
|
||||
value: SettingValue::Number {
|
||||
value: 15.0,
|
||||
min: 8.0,
|
||||
max: 32.0,
|
||||
step: 1.0,
|
||||
},
|
||||
default: SettingValue::Number {
|
||||
value: 14.0,
|
||||
min: 8.0,
|
||||
max: 32.0,
|
||||
step: 1.0,
|
||||
},
|
||||
},
|
||||
Setting {
|
||||
key: "editor.minimap".into(),
|
||||
title: "Minimap".into(),
|
||||
description: "".into(),
|
||||
value: SettingValue::Bool(true),
|
||||
default: SettingValue::Bool(true),
|
||||
},
|
||||
],
|
||||
)];
|
||||
let json: serde_json::Value = serde_json::from_str(&settings_json(&groups)).expect("json");
|
||||
assert_eq!(json, serde_json::json!({ "editor.fontSize": 15.0 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use std::{collections::BTreeSet, ops::Range, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, ScrollStrategy,
|
||||
SharedString, Subscription, Task, UniformListScrollHandle, Window,
|
||||
};
|
||||
|
||||
use super::{
|
||||
buffer::Buffer,
|
||||
cursor::{Selection, merged},
|
||||
decor::{CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint},
|
||||
view::Frame,
|
||||
};
|
||||
use crate::{
|
||||
forms::{History, InputEvent, TextInput},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
|
||||
/// How the gutter numbers lines.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LineNumbers {
|
||||
#[default]
|
||||
Absolute,
|
||||
/// Distances from the cursor's line, which shows its own number.
|
||||
Relative,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// How the cursor draws.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CursorShape {
|
||||
#[default]
|
||||
Line,
|
||||
Block,
|
||||
Underline,
|
||||
}
|
||||
|
||||
/// What the editor tells its owner.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum EditorEvent {
|
||||
Changed,
|
||||
/// A press on a code lens: its line and the item's place.
|
||||
Lens(usize, usize),
|
||||
/// The inline chat's prompt, sent.
|
||||
Asked(SharedString),
|
||||
ChatClosed,
|
||||
/// The read-only banner's action.
|
||||
Unlock,
|
||||
}
|
||||
|
||||
/// The editor's look and manners.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Options {
|
||||
pub language: SharedString,
|
||||
pub numbers: LineNumbers,
|
||||
pub cursor: CursorShape,
|
||||
pub whitespace: bool,
|
||||
pub guides: bool,
|
||||
pub rulers: Vec<usize>,
|
||||
pub rainbow: bool,
|
||||
pub minimap: bool,
|
||||
pub sticky: bool,
|
||||
pub read_only: bool,
|
||||
pub tab: usize,
|
||||
}
|
||||
|
||||
/// What the owner lays over the code.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Marks {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub backgrounds: Vec<(Range<usize>, gpui::Hsla)>,
|
||||
pub hints: Vec<InlayHint>,
|
||||
pub lenses: Vec<CodeLens>,
|
||||
pub ghost: Option<GhostText>,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
pub git: Vec<(usize, GitMark)>,
|
||||
pub breakpoints: BTreeSet<usize>,
|
||||
pub chat: Option<usize>,
|
||||
}
|
||||
|
||||
/// The text and cursors one undo step returns to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Snapshot {
|
||||
pub text: String,
|
||||
pub selections: Vec<Selection>,
|
||||
}
|
||||
|
||||
/// Where the code sat when last painted, in window pixels: its left edge, the top of the first row, a character's width and a row's height.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Metrics {
|
||||
pub left: Pixels,
|
||||
pub top: Pixels,
|
||||
pub advance: Pixels,
|
||||
pub line: Pixels,
|
||||
}
|
||||
|
||||
const BLINK: Duration = Duration::from_millis(530);
|
||||
|
||||
/// Code to read and edit: syntax colors, many cursors, folds, a gutter, a minimap, and what tools lay over it.
|
||||
pub struct CodeEditor {
|
||||
pub(crate) buffer: Buffer,
|
||||
pub(crate) selections: Vec<Selection>,
|
||||
pub(crate) history: History<Snapshot>,
|
||||
pub(crate) focus: FocusHandle,
|
||||
pub(crate) scroll: UniformListScrollHandle,
|
||||
pub(crate) folded: BTreeSet<usize>,
|
||||
pub(crate) options: Options,
|
||||
pub(crate) marks: Marks,
|
||||
pub(crate) marked: Option<Range<usize>>,
|
||||
pub(crate) metrics: Metrics,
|
||||
pub(crate) dragging: bool,
|
||||
pub(crate) caret_on: bool,
|
||||
pub(crate) chat_field: gpui::Entity<TextInput>,
|
||||
pub(crate) frame: Frame,
|
||||
blink_epoch: u64,
|
||||
_blink: Option<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<EditorEvent> for CodeEditor {}
|
||||
|
||||
impl Focusable for CodeEditor {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
pub fn new(text: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus = cx.focus_handle().tab_stop(true);
|
||||
let chat_field =
|
||||
cx.new(|cx| TextInput::new(window, cx).placeholder("Ask to change this code"));
|
||||
let subscriptions = vec![
|
||||
cx.on_focus(&focus, window, |editor, _, cx| editor.restart_blink(cx)),
|
||||
cx.on_blur(&focus, window, |editor, _, cx| {
|
||||
editor.dragging = false;
|
||||
editor._blink = None;
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&chat_field, |_, field, event, cx| {
|
||||
if *event != InputEvent::Submit {
|
||||
return;
|
||||
}
|
||||
let prompt = field.read(cx).text().trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("code editor: asked {prompt:?}");
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
cx.emit(EditorEvent::Asked(prompt.into()));
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
buffer: Buffer::new(text),
|
||||
selections: vec![Selection::caret(0)],
|
||||
history: History::default(),
|
||||
focus,
|
||||
scroll: UniformListScrollHandle::new(),
|
||||
folded: BTreeSet::new(),
|
||||
options: Options {
|
||||
language: "Plain text".into(),
|
||||
numbers: LineNumbers::Absolute,
|
||||
cursor: CursorShape::Line,
|
||||
whitespace: false,
|
||||
guides: true,
|
||||
rulers: Vec::new(),
|
||||
rainbow: false,
|
||||
minimap: false,
|
||||
sticky: false,
|
||||
read_only: false,
|
||||
tab: 4,
|
||||
},
|
||||
marks: Marks::default(),
|
||||
marked: None,
|
||||
metrics: Metrics::default(),
|
||||
dragging: false,
|
||||
caret_on: true,
|
||||
chat_field,
|
||||
frame: Frame::default(),
|
||||
blink_epoch: 0,
|
||||
_blink: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.options.language = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_numbers(mut self, numbers: LineNumbers) -> Self {
|
||||
self.options.numbers = numbers;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor_shape(mut self, shape: CursorShape) -> Self {
|
||||
self.options.cursor = shape;
|
||||
self
|
||||
}
|
||||
|
||||
/// Dots for spaces and arrows for tabs.
|
||||
pub fn show_whitespace(mut self) -> Self {
|
||||
self.options.whitespace = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hide_indent_guides(mut self) -> Self {
|
||||
self.options.guides = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Thin lines at these columns.
|
||||
pub fn rulers(mut self, columns: impl IntoIterator<Item = usize>) -> Self {
|
||||
self.options.rulers = columns.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Brackets colored by how deep they sit.
|
||||
pub fn rainbow_brackets(mut self) -> Self {
|
||||
self.options.rainbow = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn minimap(mut self) -> Self {
|
||||
self.options.minimap = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// The lines that open the blocks around the top line stay pinned above it.
|
||||
pub fn sticky_scroll(mut self) -> Self {
|
||||
self.options.sticky = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn read_only(mut self) -> Self {
|
||||
self.options.read_only = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Spaces a Tab inserts and an indent guide spans.
|
||||
pub fn tab_size(mut self, size: usize) -> Self {
|
||||
assert!(size > 0, "a tab spans at least one column");
|
||||
self.options.tab = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
self.buffer.text()
|
||||
}
|
||||
|
||||
pub fn selections(&self) -> &[Selection] {
|
||||
&self.selections
|
||||
}
|
||||
|
||||
/// The last cursor placed, which scrolling follows.
|
||||
pub fn primary(&self) -> Selection {
|
||||
*self.selections.last().expect("an editor keeps a cursor")
|
||||
}
|
||||
|
||||
/// Line and column of the primary cursor, both from one.
|
||||
pub fn position(&self) -> (usize, usize) {
|
||||
let (line, column) = self.buffer.point(self.primary().head);
|
||||
(line + 1, column + 1)
|
||||
}
|
||||
|
||||
pub fn language_name(&self) -> &SharedString {
|
||||
&self.options.language
|
||||
}
|
||||
|
||||
/// The line the inline chat sits under, while it is open.
|
||||
pub fn chat_line(&self) -> Option<usize> {
|
||||
self.marks.chat
|
||||
}
|
||||
|
||||
pub fn is_read_only(&self) -> bool {
|
||||
self.options.read_only
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
let before = self.snapshot();
|
||||
self.buffer = Buffer::new(text);
|
||||
self.selections = vec![Selection::caret(0)];
|
||||
self.folded.clear();
|
||||
self.commit(before, false, cx);
|
||||
}
|
||||
|
||||
pub fn set_cursor_shape(&mut self, shape: CursorShape, cx: &mut Context<Self>) {
|
||||
self.options.cursor = shape;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_line_numbers(&mut self, numbers: LineNumbers, cx: &mut Context<Self>) {
|
||||
self.options.numbers = numbers;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
|
||||
log::info!("code editor: read only {read_only}");
|
||||
self.options.read_only = read_only;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Selects these ranges, the last one primary.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<usize>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let chosen: Vec<Selection> = ranges
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
assert!(
|
||||
range.end <= self.buffer.len()
|
||||
&& self.buffer.text().is_char_boundary(range.start)
|
||||
&& self.buffer.text().is_char_boundary(range.end),
|
||||
"a selection covers whole characters of the text"
|
||||
);
|
||||
Selection {
|
||||
anchor: range.start,
|
||||
head: range.end,
|
||||
goal: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if merged(chosen.clone()) == self.selections {
|
||||
return;
|
||||
}
|
||||
self.set_selections(chosen, cx);
|
||||
self.scroll
|
||||
.scroll_to_item(self.primary_row(), ScrollStrategy::Center);
|
||||
}
|
||||
|
||||
pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
|
||||
if self.marks.diagnostics == diagnostics {
|
||||
return;
|
||||
}
|
||||
self.marks.diagnostics = diagnostics;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_backgrounds(
|
||||
&mut self,
|
||||
ranges: Vec<(Range<usize>, gpui::Hsla)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.marks.backgrounds == ranges {
|
||||
return;
|
||||
}
|
||||
self.marks.backgrounds = ranges;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_inlay_hints(&mut self, hints: Vec<InlayHint>, cx: &mut Context<Self>) {
|
||||
self.marks.hints = hints;
|
||||
self.marks.hints.sort_by_key(|hint| hint.offset);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_code_lenses(&mut self, lenses: Vec<CodeLens>, cx: &mut Context<Self>) {
|
||||
self.marks.lenses = lenses;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Suggested text at an offset, in grey until Tab takes it.
|
||||
pub fn set_ghost_text(&mut self, ghost: Option<GhostText>, cx: &mut Context<Self>) {
|
||||
self.marks.ghost = ghost;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_diff(&mut self, hunks: Vec<DiffHunk>, cx: &mut Context<Self>) {
|
||||
self.marks.hunks = hunks;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_git_marks(&mut self, marks: Vec<(usize, GitMark)>, cx: &mut Context<Self>) {
|
||||
self.marks.git = marks;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_breakpoint(&mut self, line: usize, cx: &mut Context<Self>) {
|
||||
if !self.marks.breakpoints.remove(&line) {
|
||||
self.marks.breakpoints.insert(line);
|
||||
}
|
||||
log::info!("code editor: breakpoints {:?}", self.marks.breakpoints);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn breakpoints(&self) -> &BTreeSet<usize> {
|
||||
&self.marks.breakpoints
|
||||
}
|
||||
|
||||
/// Opens the inline chat below a line, or closes it.
|
||||
pub fn set_chat(&mut self, line: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.marks.chat = line;
|
||||
if line.is_some() {
|
||||
let focus = self.chat_field.read(cx).focus().clone();
|
||||
window.focus(&focus);
|
||||
} else {
|
||||
window.focus(&self.focus);
|
||||
cx.emit(EditorEvent::ChatClosed);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Replaces ranges of the text at once, as one undo step; read-only text refuses.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
edits: impl IntoIterator<Item = (Range<usize>, String)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits: Vec<(Range<usize>, String)> = edits.into_iter().collect();
|
||||
assert!(
|
||||
edits.iter().all(|(range, _)| range.end <= self.buffer.len()
|
||||
&& self.buffer.text().is_char_boundary(range.start)
|
||||
&& self.buffer.text().is_char_boundary(range.end)),
|
||||
"an edit covers whole characters of the text"
|
||||
);
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_selections(&mut self, selections: Vec<Selection>, cx: &mut Context<Self>) {
|
||||
self.selections = merged(selections);
|
||||
self.unfold_cursors();
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
text: self.buffer.text().to_string(),
|
||||
selections: self.selections.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context<Self>) {
|
||||
self.buffer = Buffer::new(snapshot.text);
|
||||
self.selections = snapshot.selections;
|
||||
self.marked = None;
|
||||
cx.emit(EditorEvent::Changed);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Makes `before` one undo step when the text moved on from it.
|
||||
pub(crate) fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context<Self>) {
|
||||
if before.text != self.buffer.text() {
|
||||
self.history.record(before, typing);
|
||||
self.marks.ghost = None;
|
||||
self.folded.retain(|line| *line < self.buffer.lines());
|
||||
cx.emit(EditorEvent::Changed);
|
||||
}
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Shows the cursors now and restarts their blink; still under reduced motion.
|
||||
pub(crate) fn restart_blink(&mut self, cx: &mut Context<Self>) {
|
||||
self.caret_on = true;
|
||||
self.blink_epoch += 1;
|
||||
let epoch = self.blink_epoch;
|
||||
let still = cx.theme().reduced_motion;
|
||||
self._blink = (!still).then(|| {
|
||||
cx.spawn(async move |editor, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(BLINK).await;
|
||||
let alive = editor.update(cx, |editor, cx| {
|
||||
if editor.blink_epoch == epoch {
|
||||
editor.caret_on = !editor.caret_on;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if alive.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn editable(&self) -> bool {
|
||||
!self.options.read_only
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion::Spinner,
|
||||
primitives::{Icon, IconName},
|
||||
shell::StatusBarItem,
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
};
|
||||
|
||||
type OnPress = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
fn item(
|
||||
id: ElementId,
|
||||
label: impl Into<SharedString>,
|
||||
tooltip: &'static str,
|
||||
press: Option<OnPress>,
|
||||
) -> StatusBarItem {
|
||||
let item = StatusBarItem::new(id).label(label).tooltip(tooltip);
|
||||
match press {
|
||||
Some(press) => item.on_click(move |_, window, cx| press(window, cx)),
|
||||
None => item,
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! pressable {
|
||||
($name:ident) => {
|
||||
impl $name {
|
||||
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.press = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The cursor's line and column, and how many characters are selected when some are.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CursorPosition {
|
||||
id: ElementId,
|
||||
line: usize,
|
||||
column: usize,
|
||||
selected: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl CursorPosition {
|
||||
/// `line` and `column` count from one.
|
||||
pub fn new(id: impl Into<ElementId>, line: usize, column: usize) -> Self {
|
||||
assert!(line > 0 && column > 0, "lines and columns count from one");
|
||||
Self {
|
||||
id: id.into(),
|
||||
line,
|
||||
column,
|
||||
selected: 0,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self, characters: usize) -> Self {
|
||||
self.selected = characters;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(CursorPosition);
|
||||
|
||||
impl RenderOnce for CursorPosition {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let mut label = format!("Ln {}, Col {}", self.line, self.column);
|
||||
if self.selected > 0 {
|
||||
label.push_str(&format!(" ({} selected)", self.selected));
|
||||
}
|
||||
item(self.id, label, "Go to line", self.press)
|
||||
}
|
||||
}
|
||||
|
||||
/// The language a file is read as.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LanguageMode {
|
||||
id: ElementId,
|
||||
language: SharedString,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl LanguageMode {
|
||||
pub fn new(id: impl Into<ElementId>, language: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
language: language.into(),
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(LanguageMode);
|
||||
|
||||
impl RenderOnce for LanguageMode {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
item(
|
||||
self.id,
|
||||
self.language,
|
||||
"Select the language mode",
|
||||
self.press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The text encoding a file is saved in.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Encoding {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(Encoding);
|
||||
|
||||
impl RenderOnce for Encoding {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
item(
|
||||
self.id,
|
||||
self.name,
|
||||
"Reopen or save with an encoding",
|
||||
self.press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a file ends its lines.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LineEnding {
|
||||
id: ElementId,
|
||||
crlf: bool,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl LineEnding {
|
||||
/// `crlf` for carriage return and line feed, else line feed alone.
|
||||
pub fn new(id: impl Into<ElementId>, crlf: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
crlf,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(LineEnding);
|
||||
|
||||
impl RenderOnce for LineEnding {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
item(
|
||||
self.id,
|
||||
if self.crlf { "CRLF" } else { "LF" },
|
||||
"Change the line ending",
|
||||
self.press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a file indents: spaces or tabs, and how wide.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IndentSettings {
|
||||
id: ElementId,
|
||||
spaces: bool,
|
||||
size: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl IndentSettings {
|
||||
pub fn new(id: impl Into<ElementId>, spaces: bool, size: usize) -> Self {
|
||||
assert!(size > 0, "an indent is at least one column");
|
||||
Self {
|
||||
id: id.into(),
|
||||
spaces,
|
||||
size,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(IndentSettings);
|
||||
|
||||
impl RenderOnce for IndentSettings {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let label = if self.spaces {
|
||||
format!("Spaces: {}", self.size)
|
||||
} else {
|
||||
format!("Tab Size: {}", self.size)
|
||||
};
|
||||
item(self.id, label, "Change the indentation", self.press)
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch checked out, a mark when the tree has changes, and how far it is ahead of and behind its upstream.
|
||||
#[derive(IntoElement)]
|
||||
pub struct BranchIndicator {
|
||||
id: ElementId,
|
||||
branch: SharedString,
|
||||
dirty: bool,
|
||||
ahead: usize,
|
||||
behind: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl BranchIndicator {
|
||||
pub fn new(id: impl Into<ElementId>, branch: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
branch: branch.into(),
|
||||
dirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirty(mut self, dirty: bool) -> Self {
|
||||
self.dirty = dirty;
|
||||
self
|
||||
}
|
||||
|
||||
/// Commits not yet pushed, and commits not yet pulled.
|
||||
pub fn sync(mut self, ahead: usize, behind: usize) -> Self {
|
||||
self.ahead = ahead;
|
||||
self.behind = behind;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(BranchIndicator);
|
||||
|
||||
impl RenderOnce for BranchIndicator {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let mut label = format!("{}{}", self.branch, if self.dirty { "*" } else { "" });
|
||||
if self.ahead > 0 || self.behind > 0 {
|
||||
label.push_str(&format!(" ↑{} ↓{}", self.ahead, self.behind));
|
||||
}
|
||||
item(self.id, label, "Checkout a branch", self.press).icon(IconName::GitBranch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a language server stands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LspState {
|
||||
Starting,
|
||||
Ready,
|
||||
/// Working on something it names.
|
||||
Busy(SharedString),
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// A language server's name and state: a spinner while it starts or works, a check when ready, a mark when it failed.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LspStatus {
|
||||
id: ElementId,
|
||||
server: SharedString,
|
||||
state: LspState,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl LspStatus {
|
||||
pub fn new(id: impl Into<ElementId>, server: impl Into<SharedString>, state: LspState) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
server: server.into(),
|
||||
state,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(LspStatus);
|
||||
|
||||
impl RenderOnce for LspStatus {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (lead, label, tooltip) = match &self.state {
|
||||
LspState::Starting => (
|
||||
Spinner::new((self.id.clone(), "spin"))
|
||||
.size(IconSize::Xs)
|
||||
.into_any_element(),
|
||||
format!("{} starting", self.server),
|
||||
"Starting",
|
||||
),
|
||||
LspState::Busy(task) => (
|
||||
Spinner::new((self.id.clone(), "spin"))
|
||||
.size(IconSize::Xs)
|
||||
.into_any_element(),
|
||||
format!("{} · {task}", self.server),
|
||||
"Working",
|
||||
),
|
||||
LspState::Ready => (
|
||||
Icon::new(IconName::CircleCheck)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_muted)
|
||||
.into_any_element(),
|
||||
self.server.to_string(),
|
||||
"Ready",
|
||||
),
|
||||
LspState::Failed(why) => (
|
||||
Icon::new(IconName::CircleAlert)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.danger)
|
||||
.into_any_element(),
|
||||
format!("{} · {why}", self.server),
|
||||
"Failed",
|
||||
),
|
||||
};
|
||||
item(self.id, label, tooltip, self.press).leading(lead)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bell with how many notifications wait, quiet when there are none.
|
||||
#[derive(IntoElement)]
|
||||
pub struct NotificationBell {
|
||||
id: ElementId,
|
||||
count: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl NotificationBell {
|
||||
pub fn new(id: impl Into<ElementId>, count: usize) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
count,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(NotificationBell);
|
||||
|
||||
impl RenderOnce for NotificationBell {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let bell = div()
|
||||
.relative()
|
||||
.child(
|
||||
Icon::new(IconName::Bell)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.children((self.count > 0).then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_neg_1()
|
||||
.right_neg_1p5()
|
||||
.px_0p5()
|
||||
.rounded_full()
|
||||
.bg(colors.accent)
|
||||
.text_color(colors.on_accent)
|
||||
.text_size(theme.text_size(TextSize::Xs) * 0.75)
|
||||
.line_height(theme.text_size(TextSize::Xs) * 0.9)
|
||||
.child(self.count.min(99).to_string())
|
||||
}));
|
||||
let tooltip = if self.count == 0 {
|
||||
"No new notifications"
|
||||
} else {
|
||||
"Notifications"
|
||||
};
|
||||
let item = StatusBarItem::new(self.id).tooltip(tooltip).leading(bell);
|
||||
match self.press {
|
||||
Some(press) => item.on_click(move |_, window, cx| press(window, cx)),
|
||||
None => item,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::SegmentedControl,
|
||||
lists::{Tree, TreeNode},
|
||||
navigation::{Group, Palette, Row, fuzzy, marked, query_field},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Palette as Colors},
|
||||
};
|
||||
|
||||
/// What a symbol in code is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SymbolKind {
|
||||
Module,
|
||||
Struct,
|
||||
Enum,
|
||||
Trait,
|
||||
Function,
|
||||
Method,
|
||||
Field,
|
||||
Variable,
|
||||
Constant,
|
||||
Keyword,
|
||||
Snippet,
|
||||
}
|
||||
|
||||
impl SymbolKind {
|
||||
pub fn icon(self) -> IconName {
|
||||
match self {
|
||||
SymbolKind::Module => IconName::Package,
|
||||
SymbolKind::Struct => IconName::Braces,
|
||||
SymbolKind::Enum => IconName::ListOrdered,
|
||||
SymbolKind::Trait => IconName::Shapes,
|
||||
SymbolKind::Function | SymbolKind::Method => IconName::SquareFunction,
|
||||
SymbolKind::Field => IconName::Tag,
|
||||
SymbolKind::Variable => IconName::Variable,
|
||||
SymbolKind::Constant => IconName::Hash,
|
||||
SymbolKind::Keyword => IconName::Key,
|
||||
SymbolKind::Snippet => IconName::Code,
|
||||
}
|
||||
}
|
||||
|
||||
/// The syntax color its name takes.
|
||||
pub fn color(self, colors: &Colors) -> Hsla {
|
||||
let syntax = &colors.syntax;
|
||||
match self {
|
||||
SymbolKind::Module | SymbolKind::Snippet => colors.fg_muted,
|
||||
SymbolKind::Struct | SymbolKind::Enum | SymbolKind::Trait => syntax.type_name,
|
||||
SymbolKind::Function | SymbolKind::Method => syntax.function,
|
||||
SymbolKind::Field => syntax.property,
|
||||
SymbolKind::Variable => syntax.variable,
|
||||
SymbolKind::Constant => syntax.constant,
|
||||
SymbolKind::Keyword => syntax.keyword,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
SymbolKind::Module => "module",
|
||||
SymbolKind::Struct => "struct",
|
||||
SymbolKind::Enum => "enum",
|
||||
SymbolKind::Trait => "trait",
|
||||
SymbolKind::Function => "function",
|
||||
SymbolKind::Method => "method",
|
||||
SymbolKind::Field => "field",
|
||||
SymbolKind::Variable => "variable",
|
||||
SymbolKind::Constant => "constant",
|
||||
SymbolKind::Keyword => "keyword",
|
||||
SymbolKind::Snippet => "snippet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol in a file: its name, kind, the line it starts on, a detail such as its type, and what it holds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Symbol {
|
||||
pub name: SharedString,
|
||||
pub kind: SymbolKind,
|
||||
pub line: usize,
|
||||
pub detail: Option<SharedString>,
|
||||
pub children: Vec<Symbol>,
|
||||
}
|
||||
|
||||
impl Symbol {
|
||||
pub fn new(name: impl Into<SharedString>, kind: SymbolKind, line: usize) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
kind,
|
||||
line,
|
||||
detail: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
|
||||
self.detail = Some(detail.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = Symbol>) -> Self {
|
||||
self.children = children.into_iter().collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
type OnLine = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
fn key(symbol: &Symbol) -> SharedString {
|
||||
format!("{}:{}", symbol.line, symbol.name).into()
|
||||
}
|
||||
|
||||
fn node(symbol: &Symbol) -> TreeNode {
|
||||
let tree = TreeNode::new(key(symbol), symbol.name.clone()).icon(symbol.kind.icon());
|
||||
let tree = match &symbol.detail {
|
||||
Some(detail) => tree.note(detail.clone()),
|
||||
None => tree,
|
||||
};
|
||||
if symbol.children.is_empty() {
|
||||
return tree;
|
||||
}
|
||||
tree.children(symbol.children.iter().map(node))
|
||||
}
|
||||
|
||||
/// Each symbol with the path of those holding it, outermost first.
|
||||
pub(crate) fn flattened(symbols: &[Symbol]) -> Vec<(Vec<SharedString>, &Symbol)> {
|
||||
fn walk<'a>(
|
||||
symbols: &'a [Symbol],
|
||||
path: &mut Vec<SharedString>,
|
||||
out: &mut Vec<(Vec<SharedString>, &'a Symbol)>,
|
||||
) {
|
||||
for symbol in symbols {
|
||||
out.push((path.clone(), symbol));
|
||||
path.push(symbol.name.clone());
|
||||
walk(&symbol.children, path, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(symbols, &mut Vec::new(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The innermost symbol at or above a line.
|
||||
pub(crate) fn enclosing(symbols: &[Symbol], line: usize) -> Option<&Symbol> {
|
||||
let mut best = None;
|
||||
for (_, symbol) in flattened(symbols) {
|
||||
if symbol.line <= line && best.is_none_or(|best: &Symbol| symbol.line >= best.line) {
|
||||
best = Some(symbol);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// A file's symbols as a tree filling its box, the one around the cursor chosen; a press goes to its line.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SymbolOutline {
|
||||
id: ElementId,
|
||||
symbols: Vec<Symbol>,
|
||||
cursor: Option<usize>,
|
||||
on_pick: Option<OnLine>,
|
||||
}
|
||||
|
||||
impl SymbolOutline {
|
||||
pub fn new(id: impl Into<ElementId>, symbols: impl IntoIterator<Item = Symbol>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
symbols: symbols.into_iter().collect(),
|
||||
cursor: None,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's line, whose symbol is chosen.
|
||||
pub fn cursor(mut self, line: usize) -> Self {
|
||||
self.cursor = Some(line);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SymbolOutline {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let open: Vec<SharedString> = flattened(&self.symbols)
|
||||
.into_iter()
|
||||
.filter(|(_, symbol)| !symbol.children.is_empty())
|
||||
.map(|(_, symbol)| key(symbol))
|
||||
.collect();
|
||||
let chosen = self
|
||||
.cursor
|
||||
.and_then(|line| enclosing(&self.symbols, line))
|
||||
.map(key);
|
||||
let lines: Vec<(SharedString, usize)> = flattened(&self.symbols)
|
||||
.into_iter()
|
||||
.map(|(_, symbol)| (key(symbol), symbol.line))
|
||||
.collect();
|
||||
let tree = Tree::new(self.id, self.symbols.iter().map(node))
|
||||
.size_full()
|
||||
.open(open)
|
||||
.selected(chosen);
|
||||
match self.on_pick {
|
||||
Some(on_pick) => tree.on_select(move |keys, window, cx| {
|
||||
let Some(picked) = keys.first() else {
|
||||
return;
|
||||
};
|
||||
let line = lines
|
||||
.iter()
|
||||
.find(|(key, _)| key == picked)
|
||||
.map(|(_, line)| *line)
|
||||
.expect("a picked symbol is in the outline");
|
||||
log::info!("symbol outline: line {line}");
|
||||
on_pick(line, window, cx);
|
||||
}),
|
||||
None => tree,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
type OnFlip = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A palette of a file's symbols by name, each with its kind and what holds it; `@` style jumping.
|
||||
#[derive(IntoElement)]
|
||||
pub struct GoToSymbol {
|
||||
id: ElementId,
|
||||
symbols: Vec<Symbol>,
|
||||
on_pick: Option<OnLine>,
|
||||
on_close: OnClose,
|
||||
}
|
||||
|
||||
impl GoToSymbol {
|
||||
/// Render it while open; `on_close` runs on Escape, a click outside, or a pick.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
symbols: impl IntoIterator<Item = Symbol>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
symbols: symbols.into_iter().collect(),
|
||||
on_pick: None,
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for GoToSymbol {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = query_field(&self.id, "Go to a symbol", window, cx);
|
||||
let query = input.read(cx).text().trim().to_string();
|
||||
let colors = cx.theme().colors.clone();
|
||||
let mut found: Vec<(i32, Row)> = flattened(&self.symbols)
|
||||
.into_iter()
|
||||
.filter_map(|(path, symbol)| {
|
||||
let fit = if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fuzzy(&query, &symbol.name)?)
|
||||
};
|
||||
let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits));
|
||||
let detail = (!path.is_empty()).then(|| {
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(path.join(" › "))
|
||||
.into_any_element()
|
||||
});
|
||||
let row = Row {
|
||||
value: symbol.line.to_string().into(),
|
||||
icon: Some(symbol.kind.icon()),
|
||||
label: marked(symbol.name.clone(), hits, cx),
|
||||
detail,
|
||||
end: Some(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(symbol.kind.word())
|
||||
.into_any_element(),
|
||||
),
|
||||
};
|
||||
Some((score, row))
|
||||
})
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
found.sort_by(|(a, _), (b, _)| b.cmp(a));
|
||||
}
|
||||
let on_pick = self.on_pick.map(|on_pick| {
|
||||
Rc::new(
|
||||
move |value: &SharedString, window: &mut Window, cx: &mut App| {
|
||||
let line: usize = value.parse().expect("a symbol row carries its line");
|
||||
on_pick(line, window, cx);
|
||||
},
|
||||
) as Rc<dyn Fn(&SharedString, &mut Window, &mut App)>
|
||||
});
|
||||
Palette {
|
||||
id: self.id,
|
||||
input,
|
||||
groups: vec![Group {
|
||||
title: None,
|
||||
rows: found.into_iter().map(|(_, row)| row).collect(),
|
||||
}],
|
||||
start: 0,
|
||||
empty: "No matching symbols".into(),
|
||||
on_pick,
|
||||
on_close: self.on_close,
|
||||
}
|
||||
.overlay(window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A call in a hierarchy: who, of what kind, where, and the calls under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Call {
|
||||
pub name: SharedString,
|
||||
pub kind: SymbolKind,
|
||||
pub place: SharedString,
|
||||
pub calls: Vec<Call>,
|
||||
}
|
||||
|
||||
/// Who calls a function or whom it calls, or a type's supertypes and subtypes, as a tree filling its box that flips between the two directions.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CallHierarchy {
|
||||
id: ElementId,
|
||||
root: Call,
|
||||
labels: (SharedString, SharedString),
|
||||
incoming: bool,
|
||||
on_flip: Option<OnFlip>,
|
||||
}
|
||||
|
||||
impl CallHierarchy {
|
||||
/// Calls to or from `root`; `incoming` picks which the tree shows.
|
||||
pub fn new(id: impl Into<ElementId>, root: Call, incoming: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
root,
|
||||
labels: ("Incoming".into(), "Outgoing".into()),
|
||||
incoming,
|
||||
on_flip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For types: supertypes and subtypes instead of calls.
|
||||
pub fn types(mut self) -> Self {
|
||||
self.labels = ("Supertypes".into(), "Subtypes".into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets whether the incoming side is chosen.
|
||||
pub fn on_flip(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_flip = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn call_node(call: &Call, path: &str) -> TreeNode {
|
||||
let key = format!("{path}/{}", call.name);
|
||||
let node = TreeNode::new(key.clone(), call.name.clone())
|
||||
.icon(call.kind.icon())
|
||||
.note(call.place.clone());
|
||||
if call.calls.is_empty() {
|
||||
return node;
|
||||
}
|
||||
node.children(call.calls.iter().map(|inner| call_node(inner, &key)))
|
||||
}
|
||||
|
||||
fn keys(call: &Call, path: &str, out: &mut Vec<SharedString>) {
|
||||
let key = format!("{path}/{}", call.name);
|
||||
for inner in &call.calls {
|
||||
keys(inner, &key, out);
|
||||
}
|
||||
out.push(key.into());
|
||||
}
|
||||
|
||||
impl RenderOnce for CallHierarchy {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (incoming, outgoing) = self.labels;
|
||||
let side = SegmentedControl::new(
|
||||
(self.id.clone(), "side"),
|
||||
if self.incoming { "in" } else { "out" },
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("in", incoming, None)
|
||||
.segment("out", outgoing, None);
|
||||
let side = match self.on_flip {
|
||||
Some(on_flip) => {
|
||||
side.on_change(move |key, window, cx| on_flip(key.as_ref() == "in", window, cx))
|
||||
}
|
||||
None => side,
|
||||
};
|
||||
let mut open = Vec::new();
|
||||
keys(&self.root, "", &mut open);
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(crate::theme::TextSize::Sm))
|
||||
.child(side)
|
||||
.child(
|
||||
Tree::new((self.id, "calls"), [call_node(&self.root, "")])
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.open(open),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_innermost_symbol_above_a_line_holds_it() {
|
||||
let symbols = vec![
|
||||
Symbol::new("Editor", SymbolKind::Struct, 2),
|
||||
Symbol::new("impl Editor", SymbolKind::Module, 10).children([
|
||||
Symbol::new("new", SymbolKind::Method, 11),
|
||||
Symbol::new("render", SymbolKind::Method, 20),
|
||||
]),
|
||||
];
|
||||
assert_eq!(
|
||||
enclosing(&symbols, 25).map(|symbol| &symbol.name[..]),
|
||||
Some("render")
|
||||
);
|
||||
assert_eq!(
|
||||
enclosing(&symbols, 5).map(|symbol| &symbol.name[..]),
|
||||
Some("Editor")
|
||||
);
|
||||
assert_eq!(enclosing(&symbols, 0), None);
|
||||
let paths: Vec<usize> = flattened(&symbols)
|
||||
.iter()
|
||||
.map(|(path, _)| path.len())
|
||||
.collect();
|
||||
assert_eq!(paths, [0, 0, 1, 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, HighlightStyle};
|
||||
|
||||
use super::buffer::Buffer;
|
||||
use crate::forms::{Kind, code_highlights, lex};
|
||||
|
||||
/// Each line that opens a block, and the last line the block holds, read from indentation.
|
||||
pub(crate) fn folds(buffer: &Buffer) -> Vec<(usize, usize)> {
|
||||
let blank = |line: usize| buffer.line(line).trim().is_empty();
|
||||
let mut out = Vec::new();
|
||||
for line in 0..buffer.lines() {
|
||||
if blank(line) {
|
||||
continue;
|
||||
}
|
||||
let indent = buffer.indent(line);
|
||||
let mut end = None;
|
||||
for next in line + 1..buffer.lines() {
|
||||
if blank(next) {
|
||||
continue;
|
||||
}
|
||||
if buffer.indent(next) <= indent {
|
||||
break;
|
||||
}
|
||||
end = Some(next);
|
||||
}
|
||||
if let Some(end) = end {
|
||||
out.push((line, end));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A bracket in the code, how deep it sits, and where its partner is when it has one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Bracket {
|
||||
pub offset: usize,
|
||||
pub depth: usize,
|
||||
pub partner: Option<usize>,
|
||||
}
|
||||
|
||||
fn opener(close: char) -> char {
|
||||
match close {
|
||||
')' => '(',
|
||||
']' => '[',
|
||||
_ => '{',
|
||||
}
|
||||
}
|
||||
|
||||
/// Every bracket outside strings and comments; a closer takes its opener's depth.
|
||||
pub(crate) fn brackets(buffer: &Buffer) -> Vec<Bracket> {
|
||||
let mut out: Vec<Bracket> = Vec::new();
|
||||
let mut open: Vec<(usize, char)> = Vec::new();
|
||||
for line in 0..buffer.lines() {
|
||||
let start = buffer.line_range(line).start;
|
||||
let text = buffer.line(line);
|
||||
for (range, kind) in lex(text) {
|
||||
if kind != Kind::Mark {
|
||||
continue;
|
||||
}
|
||||
let Some(ch) = text[range.clone()].chars().next() else {
|
||||
continue;
|
||||
};
|
||||
let offset = start + range.start;
|
||||
match ch {
|
||||
'(' | '[' | '{' => {
|
||||
open.push((out.len(), ch));
|
||||
out.push(Bracket {
|
||||
offset,
|
||||
depth: open.len() - 1,
|
||||
partner: None,
|
||||
});
|
||||
}
|
||||
')' | ']' | '}' => match open.last() {
|
||||
Some((ix, wanted)) if *wanted == opener(ch) => {
|
||||
let ix = *ix;
|
||||
open.pop();
|
||||
let partner = out[ix].offset;
|
||||
out[ix].partner = Some(offset);
|
||||
out.push(Bracket {
|
||||
offset,
|
||||
depth: open.len(),
|
||||
partner: Some(partner),
|
||||
});
|
||||
}
|
||||
_ => out.push(Bracket {
|
||||
offset,
|
||||
depth: open.len(),
|
||||
partner: None,
|
||||
}),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pair beside a caret: the bracket just after it, or else the one just before.
|
||||
pub(crate) fn matched(brackets: &[Bracket], caret: usize) -> Option<(usize, usize)> {
|
||||
let at = |offset: usize| {
|
||||
brackets
|
||||
.binary_search_by_key(&offset, |bracket| bracket.offset)
|
||||
.ok()
|
||||
.map(|ix| brackets[ix])
|
||||
};
|
||||
let bracket = at(caret).or_else(|| caret.checked_sub(1).and_then(at))?;
|
||||
Some((bracket.offset, bracket.partner?))
|
||||
}
|
||||
|
||||
/// A line's syntax colors as highlight styles.
|
||||
pub(crate) fn colors(text: &str, cx: &App) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
code_highlights(text, cx)
|
||||
.into_iter()
|
||||
.map(|(range, highlight)| {
|
||||
(
|
||||
range,
|
||||
HighlightStyle {
|
||||
color: Some(highlight.color),
|
||||
background_color: highlight.background,
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where `needle` appears in the lines of `range`, as offsets, whole words only when it is a word.
|
||||
pub(crate) fn occurrences(buffer: &Buffer, needle: &str, lines: Range<usize>) -> Vec<Range<usize>> {
|
||||
if needle.is_empty() || needle.contains('\n') {
|
||||
return Vec::new();
|
||||
}
|
||||
let word = needle.chars().all(|ch| ch.is_alphanumeric() || ch == '_');
|
||||
let boundary = |ch: Option<char>| ch.is_none_or(|ch| !(ch.is_alphanumeric() || ch == '_'));
|
||||
let mut out = Vec::new();
|
||||
for line in lines.start..lines.end.min(buffer.lines()) {
|
||||
let start = buffer.line_range(line).start;
|
||||
let text = buffer.line(line);
|
||||
for (at, _) in text.match_indices(needle) {
|
||||
let end = at + needle.len();
|
||||
let whole =
|
||||
boundary(text[..at].chars().next_back()) && boundary(text[end..].chars().next());
|
||||
if !word || whole {
|
||||
out.push(start + at..start + end);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_fold_by_indentation_and_skip_blank_lines() {
|
||||
let buffer = Buffer::new("fn a() {\n one();\n\n two();\n}\nfn b() {}");
|
||||
assert_eq!(folds(&buffer), [(0, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brackets_pair_by_depth_outside_strings_and_comments() {
|
||||
let buffer = Buffer::new("f(a[0], \"(\") // )\n{ }");
|
||||
let found = brackets(&buffer);
|
||||
let offsets: Vec<usize> = found.iter().map(|bracket| bracket.offset).collect();
|
||||
assert_eq!(
|
||||
offsets,
|
||||
[1, 3, 5, 11, 18, 20],
|
||||
"the quoted and the commented ones do not count"
|
||||
);
|
||||
assert_eq!(found[1].depth, 1);
|
||||
assert_eq!(found[3].partner, Some(1));
|
||||
assert_eq!(matched(&found, 12), Some((11, 1)), "just before the caret");
|
||||
assert_eq!(matched(&found, 18), Some((18, 20)), "just after the caret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrences_of_a_word_skip_longer_words() {
|
||||
let buffer = Buffer::new("let total = total_cost + total;");
|
||||
let found = occurrences(&buffer, "total", 0..1);
|
||||
assert_eq!(found, [4..9, 25..30]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
motion::Spinner,
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// Where a task's last run stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TaskState {
|
||||
Idle,
|
||||
Running,
|
||||
Passed(Duration),
|
||||
Failed(i32),
|
||||
}
|
||||
|
||||
/// A task to run: its name, group, command, and where and with what it runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Task {
|
||||
pub name: SharedString,
|
||||
pub group: SharedString,
|
||||
pub command: SharedString,
|
||||
pub cwd: SharedString,
|
||||
pub env: Vec<(SharedString, SharedString)>,
|
||||
pub state: TaskState,
|
||||
}
|
||||
|
||||
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
type OnOpen = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
|
||||
|
||||
/// Tasks by group, each with how its last run went and a run or stop button; one opens to show its configuration.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TaskRunner {
|
||||
id: ElementId,
|
||||
tasks: Vec<Task>,
|
||||
open: Option<usize>,
|
||||
on_run: Option<OnIndex>,
|
||||
on_stop: Option<OnIndex>,
|
||||
on_open: Option<OnOpen>,
|
||||
}
|
||||
|
||||
impl TaskRunner {
|
||||
pub fn new(id: impl Into<ElementId>, tasks: impl IntoIterator<Item = Task>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
tasks: tasks.into_iter().collect(),
|
||||
open: None,
|
||||
on_run: None,
|
||||
on_stop: None,
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The task whose run configuration shows.
|
||||
pub fn open(mut self, task: Option<usize>) -> Self {
|
||||
self.open = task;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_run(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_run = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_stop(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_stop = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the task to open, or none to close it.
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(Option<usize>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TaskRunner {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mut groups: Vec<SharedString> = Vec::new();
|
||||
for task in &self.tasks {
|
||||
if !groups.contains(&task.group) {
|
||||
groups.push(task.group.clone());
|
||||
}
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for group in groups {
|
||||
rows.push(
|
||||
div()
|
||||
.px_2()
|
||||
.pt_2()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(group.to_uppercase())
|
||||
.into_any_element(),
|
||||
);
|
||||
for (ix, task) in self
|
||||
.tasks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, task)| task.group == group)
|
||||
{
|
||||
let running = task.state == TaskState::Running;
|
||||
let status = match task.state {
|
||||
TaskState::Idle => Icon::new(IconName::Circle)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle)
|
||||
.into_any_element(),
|
||||
TaskState::Running => Spinner::new((self.id.clone(), format!("spinner-{ix}")))
|
||||
.size(IconSize::Sm)
|
||||
.into_any_element(),
|
||||
TaskState::Passed(_) => Icon::new(IconName::CircleCheck)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.success)
|
||||
.into_any_element(),
|
||||
TaskState::Failed(_) => Icon::new(IconName::CircleX)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.danger)
|
||||
.into_any_element(),
|
||||
};
|
||||
let outcome = match task.state {
|
||||
TaskState::Idle => SharedString::default(),
|
||||
TaskState::Running => "Running".into(),
|
||||
TaskState::Passed(took) => {
|
||||
format::duration(took.as_secs().max(1), format::DurationStyle::Compact)
|
||||
.into()
|
||||
}
|
||||
TaskState::Failed(code) => format!("Exit {code}").into(),
|
||||
};
|
||||
let (action, icon, words) = if running {
|
||||
(self.on_stop.clone(), IconName::CircleStop, "Stop")
|
||||
} else {
|
||||
(self.on_run.clone(), IconName::Play, "Run")
|
||||
};
|
||||
let opened = self.open == Some(ix);
|
||||
let toggle = self.on_open.clone();
|
||||
rows.push(
|
||||
div()
|
||||
.id((self.id.clone(), format!("task-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.cursor_pointer()
|
||||
.when_some(toggle, |row, toggle| {
|
||||
row.on_click(move |_, window, cx| {
|
||||
toggle((!opened).then_some(ix), window, cx)
|
||||
})
|
||||
})
|
||||
.child(Disclosure::new(
|
||||
(self.id.clone(), format!("open-{ix}")),
|
||||
opened,
|
||||
))
|
||||
.child(status)
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(task.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(Ellipsis::new(task.command.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(if matches!(task.state, TaskState::Failed(_)) {
|
||||
colors.danger
|
||||
} else {
|
||||
colors.fg_muted
|
||||
})
|
||||
.child(outcome),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), format!("run-{ix}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.when_some(action, |button, action| {
|
||||
button.on_click(move |_, window, cx| {
|
||||
log::info!("task runner: {words} {ix}");
|
||||
action(ix, window, cx)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
if opened {
|
||||
let field = |label: &'static str, value: SharedString| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 0.75)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg)
|
||||
.child(value),
|
||||
)
|
||||
};
|
||||
let env = task
|
||||
.env
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
rows.push(
|
||||
div()
|
||||
.ml_8()
|
||||
.mb_1()
|
||||
.p_3()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(field("Command", task.command.clone()))
|
||||
.child(field("Directory", task.cwd.clone()))
|
||||
.child(field(
|
||||
"Environment",
|
||||
if env.is_empty() {
|
||||
"—".into()
|
||||
} else {
|
||||
env.into()
|
||||
},
|
||||
))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{CodeEditor, GhostText, layout::Row};
|
||||
use crate::theme::Theme;
|
||||
|
||||
fn editor<'a>(
|
||||
text: &str,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<CodeEditor>, &'a mut VisualTestContext) {
|
||||
cx.update(Theme::init);
|
||||
let text = text.to_string();
|
||||
cx.add_window_view(move |window, cx| CodeEditor::new(text, window, cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typed_pairs_close_and_one_undo_takes_back_the_burst(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("let x = ", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let end = 8..8;
|
||||
editor.select([end], cx);
|
||||
for ch in ["(", "1", ")"] {
|
||||
editor.type_text(ch, cx);
|
||||
}
|
||||
assert_eq!(
|
||||
editor.text(),
|
||||
"let x = (1)",
|
||||
"the typed close steps over its pair"
|
||||
);
|
||||
editor.undo(cx);
|
||||
assert_eq!(editor.text(), "let x = ");
|
||||
editor.redo(cx);
|
||||
assert_eq!(editor.text(), "let x = (1)");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn select_next_takes_the_word_then_its_next_match(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("a foo b foo c", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let inside = 3..3;
|
||||
editor.select([inside], cx);
|
||||
editor.select_next(cx);
|
||||
editor.select_next(cx);
|
||||
let ranges: Vec<_> = editor.selections().iter().map(|s| s.range()).collect();
|
||||
assert_eq!(ranges, [2..5, 8..11]);
|
||||
editor.type_text("bar", cx);
|
||||
assert_eq!(editor.text(), "a bar b bar c", "every cursor types");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn toggling_comments_twice_restores_the_lines(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("fn a() {\n b();\n}", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.select_all(cx);
|
||||
editor.toggle_comment(cx);
|
||||
assert_eq!(editor.text(), "// fn a() {\n// b();\n// }");
|
||||
editor.toggle_comment(cx);
|
||||
assert_eq!(editor.text(), "fn a() {\n b();\n}");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_multi_line_ghost_moves_the_rest_of_its_line_below_it(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("call(x)\nnext", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_ghost_text(
|
||||
Some(GhostText {
|
||||
offset: 5,
|
||||
text: "a,\n b, ".into(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
editor.row_text(0),
|
||||
"call(",
|
||||
"the row stops where the ghost starts"
|
||||
);
|
||||
assert_eq!(editor.row_text(1), "next");
|
||||
let caret = 5..5;
|
||||
editor.select([caret], cx);
|
||||
editor.indent(cx);
|
||||
assert_eq!(
|
||||
editor.text(),
|
||||
"call(a,\n b, x)\nnext",
|
||||
"Tab takes the ghost"
|
||||
);
|
||||
assert_eq!(editor.row_text(0), "call(a,", "a taken ghost cuts nothing");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_selection_inside_a_fold_opens_it(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("fn a() {\n b();\n}\nc", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.toggle_fold(0, cx);
|
||||
assert!(!editor.frame_shows(1), "the body folds away");
|
||||
let body = 13..13;
|
||||
editor.select([body], cx);
|
||||
assert!(editor.frame_shows(1), "a cursor inside unfolds it");
|
||||
});
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Whether line `line` has a row now.
|
||||
fn frame_shows(&self, line: usize) -> bool {
|
||||
let hide = super::layout::hidden(
|
||||
self.buffer.lines(),
|
||||
&super::syntax::folds(&self.buffer),
|
||||
&self.folded,
|
||||
);
|
||||
super::layout::rows(&self.buffer, &hide, &self.marks).contains(&Row::Line(line))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
forms::Checkbox,
|
||||
navigation::{Group, Palette, Row, fuzzy, marked, query_field},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, KbdCombo},
|
||||
};
|
||||
|
||||
/// A project opened before: its name, where it lives, its branch, when it was last opened, and whether it is pinned.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Project {
|
||||
pub name: SharedString,
|
||||
pub path: SharedString,
|
||||
pub branch: Option<SharedString>,
|
||||
pub opened: SharedString,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
/// Pinned projects first, then the rest in the order given.
|
||||
pub(crate) fn ordered(projects: &[Project]) -> Vec<(usize, &Project)> {
|
||||
let mut all: Vec<(usize, &Project)> = projects.iter().enumerate().collect();
|
||||
all.sort_by_key(|(_, project)| !project.pinned);
|
||||
all
|
||||
}
|
||||
|
||||
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// Projects opened before, pinned ones first: each with its path, branch and when; a press opens one, the pin keeps it on top.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RecentProjects {
|
||||
id: ElementId,
|
||||
projects: Vec<Project>,
|
||||
on_open: Option<OnIndex>,
|
||||
on_pin: Option<OnIndex>,
|
||||
on_remove: Option<OnIndex>,
|
||||
}
|
||||
|
||||
impl RecentProjects {
|
||||
pub fn new(id: impl Into<ElementId>, projects: impl IntoIterator<Item = Project>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
projects: projects.into_iter().collect(),
|
||||
on_open: None,
|
||||
on_pin: None,
|
||||
on_remove: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pin(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pin = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_remove = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RecentProjects {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let rows = ordered(&self.projects).into_iter().map(|(ix, project)| {
|
||||
let (open, pin, remove) = (
|
||||
self.on_open.clone(),
|
||||
self.on_pin.clone(),
|
||||
self.on_remove.clone(),
|
||||
);
|
||||
let button = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
action: Option<OnIndex>| {
|
||||
IconButton::new((self.id.clone(), format!("{key}-{ix}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.when_some(action, move |button, action| {
|
||||
button.on_click(move |_, window, cx| action(ix, window, cx))
|
||||
})
|
||||
};
|
||||
div()
|
||||
.id((self.id.clone(), format!("project-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.when_some(open, |row, open| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("recent projects: open {ix}");
|
||||
open(ix, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconName::Folder)
|
||||
.size(IconSize::Md)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(project.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(Ellipsis::new(project.path.clone())),
|
||||
),
|
||||
)
|
||||
.children(project.branch.clone().map(|branch| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::GitBranch)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(branch)
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(project.opened.clone()),
|
||||
)
|
||||
.child(button(
|
||||
"pin",
|
||||
if project.pinned {
|
||||
IconName::PinOff
|
||||
} else {
|
||||
IconName::Pin
|
||||
},
|
||||
if project.pinned { "Unpin" } else { "Pin" },
|
||||
pin,
|
||||
))
|
||||
.child(button("remove", IconName::X, "Remove from recent", remove))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A palette of projects by name or path, pinned ones first; a pick opens it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProjectSwitcher {
|
||||
id: ElementId,
|
||||
projects: Vec<Project>,
|
||||
on_open: Option<OnIndex>,
|
||||
on_close: OnClose,
|
||||
}
|
||||
|
||||
impl ProjectSwitcher {
|
||||
/// Render it while open; `on_close` runs on Escape, a click outside, or a pick.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
projects: impl IntoIterator<Item = Project>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
projects: projects.into_iter().collect(),
|
||||
on_open: None,
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProjectSwitcher {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = query_field(&self.id, "Switch to a project", window, cx);
|
||||
let query = input.read(cx).text().trim().to_string();
|
||||
let colors = cx.theme().colors.clone();
|
||||
let mut found: Vec<(i32, Row)> = ordered(&self.projects)
|
||||
.into_iter()
|
||||
.filter_map(|(ix, project)| {
|
||||
let fit = if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fuzzy(&query, &project.name).or_else(|| fuzzy(&query, &project.path))?)
|
||||
};
|
||||
let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits));
|
||||
let hits = if hits.iter().all(|hit| hit.end <= project.name.len()) {
|
||||
hits
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some((
|
||||
score,
|
||||
Row {
|
||||
value: ix.to_string().into(),
|
||||
icon: Some(if project.pinned {
|
||||
IconName::Pin
|
||||
} else {
|
||||
IconName::Folder
|
||||
}),
|
||||
label: marked(project.name.clone(), hits, cx),
|
||||
detail: Some(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(project.path.clone())
|
||||
.into_any_element(),
|
||||
),
|
||||
end: Some(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(project.opened.clone())
|
||||
.into_any_element(),
|
||||
),
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
found.sort_by(|(a, _), (b, _)| b.cmp(a));
|
||||
}
|
||||
let on_pick = self.on_open.map(|open| {
|
||||
Rc::new(
|
||||
move |value: &SharedString, window: &mut Window, cx: &mut App| {
|
||||
open(
|
||||
value.parse().expect("a project row carries its place"),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
) as Rc<dyn Fn(&SharedString, &mut Window, &mut App)>
|
||||
});
|
||||
Palette {
|
||||
id: self.id,
|
||||
input,
|
||||
groups: vec![Group {
|
||||
title: None,
|
||||
rows: found.into_iter().map(|(_, row)| row).collect(),
|
||||
}],
|
||||
start: 0,
|
||||
empty: "No matching projects".into(),
|
||||
on_pick,
|
||||
on_close: self.on_close,
|
||||
}
|
||||
.overlay(window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A way to start from the welcome page: its icon, words and keys.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StartAction {
|
||||
pub icon: IconName,
|
||||
pub label: SharedString,
|
||||
pub keys: Option<SharedString>,
|
||||
}
|
||||
|
||||
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// The first page an editor shows: ways to start, recent projects, and whether to show it at startup.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WelcomePage {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
tagline: SharedString,
|
||||
actions: Vec<StartAction>,
|
||||
recent: Option<AnyElement>,
|
||||
show_at_start: bool,
|
||||
on_action: Option<OnIndex>,
|
||||
on_show: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl WelcomePage {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
tagline: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
tagline: tagline.into(),
|
||||
actions: Vec::new(),
|
||||
recent: None,
|
||||
show_at_start: true,
|
||||
on_action: None,
|
||||
on_show: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action(
|
||||
mut self,
|
||||
icon: IconName,
|
||||
label: impl Into<SharedString>,
|
||||
keys: Option<&str>,
|
||||
) -> Self {
|
||||
self.actions.push(StartAction {
|
||||
icon,
|
||||
label: label.into(),
|
||||
keys: keys.map(|keys| keys.to_string().into()),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Recent projects, usually a `RecentProjects`.
|
||||
pub fn recent(mut self, recent: impl IntoElement) -> Self {
|
||||
self.recent = Some(recent.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_at_start(mut self, show: bool) -> Self {
|
||||
self.show_at_start = show;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_action(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_action = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_show(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_show = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WelcomePage {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let heading = |words: &'static str| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(words)
|
||||
};
|
||||
let actions = self.actions.iter().enumerate().map(|(ix, action)| {
|
||||
let pick = self.on_action.clone();
|
||||
div()
|
||||
.id((self.id.clone(), format!("action-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.text_color(colors.link)
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.when_some(pick, |row, pick| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| pick(ix, window, cx))
|
||||
})
|
||||
.child(Icon::new(action.icon).size(IconSize::Sm).color(colors.link))
|
||||
.child(div().flex_1().child(action.label.clone()))
|
||||
.children(action.keys.as_ref().map(|keys| KbdCombo::new(keys)))
|
||||
});
|
||||
let show = Checkbox::new((self.id.clone(), "show"), self.show_at_start)
|
||||
.label("Show this page at startup");
|
||||
let show = match self.on_show {
|
||||
Some(on_show) => show.on_change(move |on, window, cx| on_show(on, window, cx)),
|
||||
None => show,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_8()
|
||||
.p_8()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xxl))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(self.title),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.tagline),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_12()
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 2.5)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(heading("Start"))
|
||||
.children(actions),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(heading("Recent"))
|
||||
.children(self.recent),
|
||||
),
|
||||
)
|
||||
.child(show)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pinned_projects_lead_and_keep_their_places() {
|
||||
let project = |name: &str, pinned: bool| Project {
|
||||
name: name.to_string().into(),
|
||||
path: "~".into(),
|
||||
branch: None,
|
||||
opened: "now".into(),
|
||||
pinned,
|
||||
};
|
||||
let projects = [project("a", false), project("b", true), project("c", false)];
|
||||
let order: Vec<usize> = ordered(&projects).into_iter().map(|(ix, _)| ix).collect();
|
||||
assert_eq!(order, [1, 0, 2]);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -54,6 +54,7 @@ pub use check::{CheckState, Checkbox, CheckboxGroup};
|
||||
pub use checked::{EmailInput, UrlInput, is_email, is_url};
|
||||
pub use chips::ChoiceChips;
|
||||
pub use code::{CodeInput, JsonInput, code_highlights, json_highlights};
|
||||
pub(crate) use code::{Kind, lex};
|
||||
pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop};
|
||||
pub use combobox::Combobox;
|
||||
pub use date::{
|
||||
@@ -94,7 +95,7 @@ pub use structure::{
|
||||
};
|
||||
pub use switch::Switch;
|
||||
pub use tags::TagInput;
|
||||
pub(crate) use text::{Down, Enter, Up, bind_keys};
|
||||
pub(crate) use text::{Down, Enter, History, Up, bind_keys, from_utf16, to_utf16};
|
||||
pub use text::{Highlight, InputEvent, TextInput};
|
||||
pub use transfer::TransferList;
|
||||
pub use unit::UnitInput;
|
||||
|
||||
+19
-10
@@ -93,17 +93,26 @@ pub(crate) struct Snapshot {
|
||||
/// Typing within this pause joins one undo step.
|
||||
const JOIN: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Undo and redo as whole-text snapshots.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct History {
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
/// Undo and redo as whole snapshots of what an editor holds.
|
||||
pub(crate) struct History<S> {
|
||||
undo: Vec<S>,
|
||||
redo: Vec<S>,
|
||||
typing_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
impl<S> Default for History<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
typing_since: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> History<S> {
|
||||
/// Records the state before an edit. Typing in one burst makes one step.
|
||||
pub fn record(&mut self, before: Snapshot, typing: bool) {
|
||||
pub fn record(&mut self, before: S, typing: bool) {
|
||||
let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN);
|
||||
if !joins {
|
||||
self.undo.push(before);
|
||||
@@ -112,14 +121,14 @@ impl History {
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, current: Snapshot) -> Option<Snapshot> {
|
||||
pub fn undo(&mut self, current: S) -> Option<S> {
|
||||
let previous = self.undo.pop()?;
|
||||
self.redo.push(current);
|
||||
self.typing_since = None;
|
||||
Some(previous)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, current: Snapshot) -> Option<Snapshot> {
|
||||
pub fn redo(&mut self, current: S) -> Option<S> {
|
||||
let next = self.redo.pop()?;
|
||||
self.undo.push(current);
|
||||
self.typing_since = None;
|
||||
@@ -175,7 +184,7 @@ mod tests {
|
||||
text: text.into(),
|
||||
selection: text.len()..text.len(),
|
||||
};
|
||||
let mut history = History::default();
|
||||
let mut history = History::<Snapshot>::default();
|
||||
history.record(snap(""), true);
|
||||
history.record(snap("a"), true);
|
||||
history.record(snap("ab"), false);
|
||||
|
||||
@@ -11,7 +11,8 @@ use gpui::{
|
||||
};
|
||||
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Submit, Up, bind_keys};
|
||||
use edit::{History, Snapshot};
|
||||
use edit::Snapshot;
|
||||
pub(crate) use edit::{History, from_utf16, to_utf16};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
@@ -64,7 +65,7 @@ pub struct TextInput {
|
||||
max_len: Option<usize>,
|
||||
highlighter: Option<Highlighter>,
|
||||
disabled: bool,
|
||||
history: History,
|
||||
history: History<Snapshot>,
|
||||
pub(crate) layout: Option<Layout>,
|
||||
pub(crate) scroll: Point<Pixels>,
|
||||
selecting: bool,
|
||||
|
||||
@@ -3,6 +3,7 @@ mod assets;
|
||||
pub mod buttons;
|
||||
pub mod charts;
|
||||
pub mod data_display;
|
||||
pub mod editor;
|
||||
pub mod feedback;
|
||||
pub mod finance;
|
||||
pub mod forms;
|
||||
@@ -38,4 +39,5 @@ pub fn init(cx: &mut App) {
|
||||
KeyBinding::new("shift-tab", FocusPrev, None),
|
||||
]);
|
||||
forms::bind_keys(cx);
|
||||
editor::bind_keys(cx);
|
||||
}
|
||||
+16
-2
@@ -104,6 +104,8 @@ impl RenderOnce for DropdownMenu {
|
||||
pub struct OverflowMenu {
|
||||
id: ElementId,
|
||||
menu: Menu,
|
||||
icon: IconName,
|
||||
tooltip: SharedString,
|
||||
}
|
||||
|
||||
impl OverflowMenu {
|
||||
@@ -111,17 +113,29 @@ impl OverflowMenu {
|
||||
Self {
|
||||
id: id.into(),
|
||||
menu,
|
||||
icon: IconName::Ellipsis,
|
||||
tooltip: "More".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = icon;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = tooltip.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for OverflowMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = state(&self.id, window, cx);
|
||||
let click = toggle(&state, &self.menu, cx);
|
||||
let button = IconButton::new((self.id.clone(), "button"), IconName::Ellipsis)
|
||||
let button = IconButton::new((self.id.clone(), "button"), self.icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip("More")
|
||||
.tooltip(self.tooltip)
|
||||
.on_click(move |event, window, cx| click(event, window, cx));
|
||||
host(self.id, button, &self.menu, &state, None, window, cx)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ mod nav;
|
||||
mod overflow;
|
||||
mod pages;
|
||||
mod palette;
|
||||
pub(crate) use palette::{fuzzy, marked};
|
||||
pub(crate) use palette::{Group, Palette, Row, fuzzy, marked, query_field};
|
||||
mod steps;
|
||||
mod tabs;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
|
||||
@@ -58,12 +58,14 @@ icons! {
|
||||
Bookmark => "bookmark",
|
||||
Bot => "bot",
|
||||
Box => "box",
|
||||
Braces => "braces",
|
||||
Brush => "brush",
|
||||
Bug => "bug",
|
||||
CalendarDays => "calendar-days",
|
||||
CalendarRange => "calendar-range",
|
||||
Calendar => "calendar",
|
||||
Camera => "camera",
|
||||
CaseSensitive => "case-sensitive",
|
||||
ChartArea => "chart-area",
|
||||
ChartBar => "chart-bar",
|
||||
ChartCandlestick => "chart-candlestick",
|
||||
@@ -207,6 +209,7 @@ icons! {
|
||||
Quote => "quote",
|
||||
Redo2 => "redo-2",
|
||||
RefreshCw => "refresh-cw",
|
||||
Regex => "regex",
|
||||
Reply => "reply",
|
||||
Rocket => "rocket",
|
||||
RotateCcw => "rotate-ccw",
|
||||
@@ -231,6 +234,7 @@ icons! {
|
||||
Spline => "spline",
|
||||
SquareCheck => "square-check",
|
||||
Square => "square",
|
||||
SquareFunction => "square-function",
|
||||
Star => "star",
|
||||
StepForward => "step-forward",
|
||||
Strikethrough => "strikethrough",
|
||||
@@ -252,12 +256,14 @@ icons! {
|
||||
Upload => "upload",
|
||||
User => "user",
|
||||
Users => "users",
|
||||
Variable => "variable",
|
||||
VideoOff => "video-off",
|
||||
Video => "video",
|
||||
Volume2 => "volume-2",
|
||||
VolumeX => "volume-x",
|
||||
Wallet => "wallet",
|
||||
WandSparkles => "wand-sparkles",
|
||||
WholeWord => "whole-word",
|
||||
WifiOff => "wifi-off",
|
||||
Wifi => "wifi",
|
||||
Wrench => "wrench",
|
||||
|
||||
@@ -167,6 +167,7 @@ type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
#[derive(IntoElement)]
|
||||
pub struct StatusBarItem {
|
||||
id: ElementId,
|
||||
leading: Option<AnyElement>,
|
||||
icon: Option<IconName>,
|
||||
label: Option<SharedString>,
|
||||
tooltip: Option<SharedString>,
|
||||
@@ -177,6 +178,7 @@ impl StatusBarItem {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
leading: None,
|
||||
icon: None,
|
||||
label: None,
|
||||
tooltip: None,
|
||||
@@ -184,6 +186,12 @@ impl StatusBarItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawn before the icon, such as a mark with a count.
|
||||
pub fn leading(mut self, leading: impl IntoElement) -> Self {
|
||||
self.leading = Some(leading.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
@@ -220,6 +228,7 @@ impl RenderOnce for StatusBarItem {
|
||||
.h_full()
|
||||
.px_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.children(self.leading)
|
||||
.when_some(self.icon, |item, icon| {
|
||||
item.child(
|
||||
Icon::new(icon)
|
||||
|
||||
Reference in new issue
Block a user