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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 00:43:41 -04:00
1 parent c229a096ca
commit d05754ac19
59 files changed
+10820 -71

No files matched your search

+254
View File
@@ -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");
}
}
+137
View File
@@ -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"),
)
}
}
+308
View File
@@ -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);
}
}
+231
View File
@@ -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]
);
}
}
+195
View File
@@ -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)
}))
}
}
+378
View File
@@ -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
}
+208
View File
@@ -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)
}
}
+141
View File
@@ -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(&regex::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", ""));
}
}
+358
View File
@@ -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"
);
}
}
+183
View File
@@ -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()
}
}
+362
View File
@@ -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);
}
}
+166
View File
@@ -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(&paragraph, &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()));
}
}
+216
View File
@@ -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))
}
}
+267
View File
@@ -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"
);
}
}
+287
View File
@@ -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);
}
})
}
+273
View File
@@ -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(&note);
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"
);
}
}
+409
View File
@@ -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);
}
}
+62
View File
@@ -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};
+173
View File
@@ -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();
}
}
+184
View File
@@ -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),
)
}
}
+286
View File
@@ -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);
}
}
+195
View File
@@ -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"),
)
}
}
+177
View File
@@ -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()
}
}
+455
View File
@@ -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");
}
}
+412
View File
@@ -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 }));
}
}
+481
View File
@@ -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
}
}
+383
View File
@@ -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,
}
}
}
+447
View File
@@ -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]);
}
}
+183
View File
@@ -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]);
}
}
+269
View File
@@ -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)
}
}
+114
View File
@@ -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))
}
}
+353
View File
@@ -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)
}
}
+470
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+3 -2
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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)
}
+1 -1
View File
@@ -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"))]
+6
View File
@@ -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",
+9
View File
@@ -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)