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.
This commit is contained in:
1 parent
a894e5f973
commit
5545ce6458
59 files changed
+10820
-71
No files matched your search
@@ -107,6 +107,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`.
|
||||
- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size.
|
||||
- Tokens live in `src/theme`; the raw `px()` scan skips that folder only.
|
||||
- gpui 0.2.2 has no `(ElementId, usize)` id. A child id names its index: `(self.id.clone(), format!("row-{ix}"))`.
|
||||
- Clippy's `single_range_in_vec_init` rejects `[a..b]` and `vec![a..b]`; bind the range first.
|
||||
- A component built on `lists::Tree` fills its box; the host gives it a height. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks.
|
||||
- Editor rows read their text through `CodeEditor::row_text`: a multi-line ghost cuts its line, and the rest follows the ghost's last line.
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
@@ -229,3 +229,8 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T16d | 2 | PASS | |
|
||||
| T16e | 1 | FAIL | a smaller loss than expected read as a miss |
|
||||
| T16e | 2 | PASS | |
|
||||
| T17a | 1 | — | pending review |
|
||||
| T17b | 1 | — | pending review |
|
||||
| T17c | 1 | — | pending review |
|
||||
| T17d | 1 | — | pending review |
|
||||
| T17e | 1 | — | pending review |
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-braces"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1" />
|
||||
<path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-case-sensitive"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M22 9v7" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
<circle cx="18.5" cy="12.5" r="3.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-regex"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 3v10" />
|
||||
<path d="m12.67 5.5 8.66 5" />
|
||||
<path d="m12.67 10.5 8.66-5" />
|
||||
<path d="M9 17a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-square-function"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3" />
|
||||
<path d="M9 11.2h5.7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-variable"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 21s-4-3-4-9 4-9 4-9" />
|
||||
<path d="M16 3s4 3 4 9-4 9-4 9" />
|
||||
<line x1="15" x2="9" y1="9" y2="15" />
|
||||
<line x1="9" x2="15" y1="9" y2="15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-whole-word"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="7" cy="12" r="3" />
|
||||
<path d="M10 9v6" />
|
||||
<circle cx="17" cy="12" r="3" />
|
||||
<path d="M14 7v8" />
|
||||
<path d="M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1,309 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, SegmentedControl},
|
||||
editor::{
|
||||
CodeEditor, CodeLens, CursorShape, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint,
|
||||
LineNumbers, VimMode, VimModeIndicator,
|
||||
},
|
||||
navigation::{Breadcrumb, Crumb},
|
||||
primitives::Severity,
|
||||
theme::{ActiveTheme, Radius},
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, Div, Entity, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, row, section, set},
|
||||
};
|
||||
|
||||
/// The code the editors open with.
|
||||
pub(super) const SAMPLE: &str = r#"use std::collections::HashMap;
|
||||
|
||||
/// A palette of named colors, looked up by the component that paints.
|
||||
pub struct Palette {
|
||||
colors: HashMap<String, Color>,
|
||||
fallback: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Color {
|
||||
pub red: f32,
|
||||
pub green: f32,
|
||||
pub blue: f32,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
pub fn new(fallback: Color) -> Self {
|
||||
Self {
|
||||
colors: HashMap::new(),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, name: &str, color: Color) {
|
||||
self.colors.insert(name.to_string(), color);
|
||||
}
|
||||
|
||||
/// The color for `name`, blended toward white by `lift`.
|
||||
pub fn lookup(&self, name: &str, lift: f32) -> Color {
|
||||
let base = self.colors.get(name).copied().unwrap_or(self.fallback);
|
||||
match lift {
|
||||
lift if lift <= 0.0 => base,
|
||||
lift if lift >= 1.0 => Color { red: 1.0, green: 1.0, blue: 1.0 },
|
||||
lift => Color {
|
||||
red: base.red + (1.0 - base.red) * lift,
|
||||
green: base.green + (1.0 - base.green) * lift,
|
||||
blue: base.blue + (1.0 - base.blue) * lift,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut palette = Palette::new(Color { red: 0.1, green: 0.1, blue: 0.1 });
|
||||
palette.insert("accent", Color { red: 0.21, green: 0.45, blue: 0.73 });
|
||||
let tints: Vec<Color> = (0..5u8).map(|step| palette.lookup("accent", step as f32 / 4.0)).collect();
|
||||
let count: u32 = tints.len();
|
||||
let spare = 12;
|
||||
println!("{count} tints of accent, the last {:?}", tints.last());
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The byte where `needle` first starts in `text`.
|
||||
pub(super) fn at(text: &str, needle: &str) -> usize {
|
||||
text.find(needle)
|
||||
.unwrap_or_else(|| panic!("{needle:?} is in the sample"))
|
||||
}
|
||||
|
||||
/// The line `needle` first sits on in `text`.
|
||||
fn line_of(text: &str, needle: &str) -> usize {
|
||||
text[..at(text, needle)].matches('\n').count()
|
||||
}
|
||||
|
||||
/// A bordered box an editor fills.
|
||||
pub(super) fn framed(width: f32, height: f32, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.w(px(width))
|
||||
.h(px(height))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.overflow_hidden()
|
||||
}
|
||||
|
||||
fn main_editor(window: &mut Window, cx: &mut App) -> Entity<CodeEditor> {
|
||||
window.use_keyed_state("editor-main", cx, |window, cx| {
|
||||
let mut editor = CodeEditor::new(SAMPLE, window, cx)
|
||||
.language("Rust")
|
||||
.minimap()
|
||||
.sticky_scroll()
|
||||
.rainbow_brackets()
|
||||
.rulers([100]);
|
||||
let map = at(SAMPLE, "HashMap;");
|
||||
let len = at(SAMPLE, "tints.len();");
|
||||
let spare = at(SAMPLE, "spare");
|
||||
let step = at(SAMPLE, "step as f32");
|
||||
editor.set_diagnostics(
|
||||
vec![
|
||||
Diagnostic {
|
||||
range: map..map + 7,
|
||||
severity: Severity::Info,
|
||||
message: "a BTreeMap keeps a steady order".into(),
|
||||
},
|
||||
Diagnostic {
|
||||
range: spare..spare + 5,
|
||||
severity: Severity::Warning,
|
||||
message: "unused variable: `spare`".into(),
|
||||
},
|
||||
Diagnostic {
|
||||
range: len..len + 11,
|
||||
severity: Severity::Danger,
|
||||
message: "mismatched types: expected `u32`, found `usize`".into(),
|
||||
},
|
||||
],
|
||||
cx,
|
||||
);
|
||||
editor.set_inlay_hints(
|
||||
vec![
|
||||
InlayHint {
|
||||
offset: at(SAMPLE, "let base") + 8,
|
||||
text: ": Color".into(),
|
||||
},
|
||||
InlayHint {
|
||||
offset: at(SAMPLE, "\"accent\", step"),
|
||||
text: "name: ".into(),
|
||||
},
|
||||
InlayHint {
|
||||
offset: step,
|
||||
text: "lift: ".into(),
|
||||
},
|
||||
],
|
||||
cx,
|
||||
);
|
||||
editor.set_code_lenses(
|
||||
vec![CodeLens {
|
||||
line: line_of(SAMPLE, " pub fn lookup"),
|
||||
items: vec!["3 references".into(), "Run test".into(), "Debug".into()],
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
let main = line_of(SAMPLE, "fn main");
|
||||
editor.set_git_marks(
|
||||
vec![
|
||||
(main + 2, GitMark::Added),
|
||||
(main + 3, GitMark::Modified),
|
||||
(4, GitMark::Deleted),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
editor.toggle_breakpoint(main + 3, cx);
|
||||
editor
|
||||
})
|
||||
}
|
||||
|
||||
pub fn editor(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let editor = main_editor(window, cx);
|
||||
let (line, column) = editor.read(cx).position();
|
||||
let crumbs = Breadcrumb::new(
|
||||
"editor-crumbs",
|
||||
[
|
||||
Crumb::new("src", "src"),
|
||||
Crumb::new("palette", "palette.rs"),
|
||||
Crumb::new("impl", "impl Palette"),
|
||||
Crumb::new("lookup", "lookup"),
|
||||
],
|
||||
);
|
||||
section(
|
||||
"CodeEditor / EditorGutter / LineNumbers / FoldingControl / Minimap / Ruler / IndentGuide / BracketMatcher / RainbowBrackets / MultiCursor / SelectionHighlight / StickyScroll / Breadcrumb / InlayHints / CodeLens / InlineDecoration",
|
||||
"Rust, colored as it reads. The gutter holds breakpoints, problems, numbers, folds and git marks, and the blocks around the top line stay pinned as it scrolls. Cmd-D selects the next match, Cmd-Alt-Down adds a cursor, Alt-click adds one anywhere, Cmd-/ comments, Tab indents.",
|
||||
cx,
|
||||
)
|
||||
.child(crumbs)
|
||||
.child(probe("editor-main", framed(840.0, 460.0, cx).child(editor)))
|
||||
.child(Caption::new(format!("Ln {line}, Col {column}")))
|
||||
}
|
||||
|
||||
pub fn looks(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let shape = keep("editor-shape", || CursorShape::Block, window, cx);
|
||||
let numbers = keep("editor-numbers", || LineNumbers::Relative, window, cx);
|
||||
let (now_shape, now_numbers) = (*shape.read(cx), *numbers.read(cx));
|
||||
let editor = window.use_keyed_state("editor-looks", cx, |window, cx| {
|
||||
CodeEditor::new(&SAMPLE[..at(SAMPLE, "impl Palette")], window, cx)
|
||||
.language("Rust")
|
||||
.show_whitespace()
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_cursor_shape(now_shape, cx);
|
||||
editor.set_line_numbers(now_numbers, cx);
|
||||
});
|
||||
let (pick_shape, pick_numbers) = (shape.clone(), numbers.clone());
|
||||
let shapes = SegmentedControl::new(
|
||||
"editor-shapes",
|
||||
match now_shape {
|
||||
CursorShape::Line => "line",
|
||||
CursorShape::Block => "block",
|
||||
CursorShape::Underline => "underline",
|
||||
},
|
||||
)
|
||||
.segment("line", "Line", None)
|
||||
.segment("block", "Block", None)
|
||||
.segment("underline", "Underline", None)
|
||||
.on_change(move |key, _, cx| {
|
||||
let shape = match key.as_ref() {
|
||||
"line" => CursorShape::Line,
|
||||
"underline" => CursorShape::Underline,
|
||||
_ => CursorShape::Block,
|
||||
};
|
||||
set(&pick_shape, shape, cx)
|
||||
});
|
||||
let counting = SegmentedControl::new(
|
||||
"editor-counting",
|
||||
match now_numbers {
|
||||
LineNumbers::Absolute => "absolute",
|
||||
LineNumbers::Relative => "relative",
|
||||
LineNumbers::Hidden => "hidden",
|
||||
},
|
||||
)
|
||||
.segment("absolute", "Absolute", None)
|
||||
.segment("relative", "Relative", None)
|
||||
.segment("hidden", "Hidden", None)
|
||||
.on_change(move |key, _, cx| {
|
||||
let numbers = match key.as_ref() {
|
||||
"absolute" => LineNumbers::Absolute,
|
||||
"hidden" => LineNumbers::Hidden,
|
||||
_ => LineNumbers::Relative,
|
||||
};
|
||||
set(&pick_numbers, numbers, cx)
|
||||
});
|
||||
section(
|
||||
"WhitespaceRenderer / CursorRenderer",
|
||||
"Dots for spaces, and the cursor as a line, a block or an underline; it blinks, and holds still under reduced motion. Numbers count from the top or from the cursor.",
|
||||
cx,
|
||||
)
|
||||
.child(row().gap_4().child(shapes).child(counting))
|
||||
.child(framed(840.0, 260.0, cx).child(editor))
|
||||
}
|
||||
|
||||
pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let text = &SAMPLE[at(SAMPLE, "impl Palette")..at(SAMPLE, "fn main")];
|
||||
let editor = window.use_keyed_state("editor-changes", cx, |window, cx| {
|
||||
let mut editor = CodeEditor::new(text, window, cx).language("Rust");
|
||||
editor.set_diff(
|
||||
vec![DiffHunk {
|
||||
line: line_of(text, " let base"),
|
||||
added: 1,
|
||||
removed: vec![" let base = self.colors[name];".into()],
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
let end = at(text, " match lift");
|
||||
editor.set_ghost_text(
|
||||
Some(GhostText {
|
||||
offset: end,
|
||||
text: " // a lift past one stays white\n".into(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
let cursor = end..end;
|
||||
editor.select([cursor], cx);
|
||||
editor
|
||||
});
|
||||
let readonly = window.use_keyed_state("editor-readonly", cx, |window, cx| {
|
||||
CodeEditor::new(&SAMPLE[..at(SAMPLE, "impl Palette")], window, cx)
|
||||
.language("Rust")
|
||||
.read_only()
|
||||
});
|
||||
let open = editor.read(cx).chat_line().is_some();
|
||||
let chat_editor = editor.clone();
|
||||
let toggle = Button::new(
|
||||
"editor-chat",
|
||||
if open {
|
||||
"Close inline chat"
|
||||
} else {
|
||||
"Open inline chat"
|
||||
},
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
chat_editor.update(cx, |editor, cx| {
|
||||
let next = editor.chat_line().is_none().then_some(3);
|
||||
editor.set_chat(next, window, cx)
|
||||
})
|
||||
});
|
||||
section(
|
||||
"InlineDiff / GhostText / InlineChat / ReadOnlyBanner / VimModeIndicator",
|
||||
"A changed line under the one it replaced; a suggestion in grey that Tab takes; a prompt between lines; a banner over code that cannot change here; and the mode of a modal editor.",
|
||||
cx,
|
||||
)
|
||||
.child(row().child(toggle))
|
||||
.child(framed(840.0, 300.0, cx).child(editor))
|
||||
.child(framed(840.0, 180.0, cx).child(readonly))
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(VimModeIndicator::new(VimMode::Normal))
|
||||
.child(VimModeIndicator::new(VimMode::Insert))
|
||||
.child(VimModeIndicator::new(VimMode::Visual).pending("d2"))
|
||||
.child(VimModeIndicator::new(VimMode::Replace)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::Button,
|
||||
editor::{
|
||||
ActionKind, Call, CallHierarchy, CodeAction, CodeActionMenu, CompletionItem,
|
||||
CompletionMenu, FileHits, GoToSymbol, Hit, HoverInfo, ReferencesPanel, RenameInput,
|
||||
Signature, SignatureHelp, Symbol, SymbolKind, SymbolOutline, completions,
|
||||
},
|
||||
forms::TextInput,
|
||||
primitives::Severity,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::{keep, row, section, set};
|
||||
|
||||
fn items() -> Vec<CompletionItem> {
|
||||
vec![
|
||||
CompletionItem::new("lookup", SymbolKind::Method)
|
||||
.detail("fn(&self, &str, f32) -> Color")
|
||||
.docs("The color for a name, blended toward white by lift."),
|
||||
CompletionItem::new("insert", SymbolKind::Method).detail("fn(&mut self, &str, Color)"),
|
||||
CompletionItem::new("colors", SymbolKind::Field).detail("HashMap<String, Color>"),
|
||||
CompletionItem::new("fallback", SymbolKind::Field).detail("Color"),
|
||||
CompletionItem::new("len", SymbolKind::Method).detail("fn(&self) -> usize"),
|
||||
CompletionItem::new("Palette", SymbolKind::Struct).detail("struct Palette"),
|
||||
CompletionItem::new("loop", SymbolKind::Keyword),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn hints(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let overload = keep("intel-overload", || 0usize, window, cx);
|
||||
let told = keep("intel-told", || None::<SharedString>, window, cx);
|
||||
let (over, said) = (*overload.read(cx), told.read(cx).clone());
|
||||
let chosen = completions(&items(), "lo")
|
||||
.iter()
|
||||
.position(|(item, _)| item.label.as_ref() == "lookup")
|
||||
.expect("lookup fits lo");
|
||||
let param = |label: &str, text: &str| {
|
||||
let start = label.find(text).expect("the parameter is in the label");
|
||||
start..start + text.len()
|
||||
};
|
||||
let (long, short) = (
|
||||
"fn lookup(&self, name: &str, lift: f32) -> Color",
|
||||
"fn lookup(&self, name: &str) -> Color",
|
||||
);
|
||||
let (pick, step, tell) = (told.clone(), overload.clone(), told.clone());
|
||||
let signatures = [
|
||||
Signature {
|
||||
label: long.into(),
|
||||
params: vec![param(long, "name: &str"), param(long, "lift: f32")],
|
||||
docs: Some("The color for a name, blended toward white by lift.".into()),
|
||||
},
|
||||
Signature {
|
||||
label: short.into(),
|
||||
params: vec![param(short, "name: &str")],
|
||||
docs: Some("The color for a name as it is.".into()),
|
||||
},
|
||||
];
|
||||
section(
|
||||
"CompletionMenu / SignatureHelp / HoverInfo",
|
||||
"Completions as you type, the typed letters marked and the chosen one's documentation beside; the call's signature with the parameter at the cursor; and what a hover over a symbol reads.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.items_start()
|
||||
.gap_8()
|
||||
.child(
|
||||
CompletionMenu::new("intel-menu", items())
|
||||
.typed("lo")
|
||||
.selected(chosen)
|
||||
.on_pick(move |item, _, cx| set(&pick, Some(format!("Picked {}.", item.label).into()), cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
SignatureHelp::new("intel-signature", signatures)
|
||||
.at(over, 1)
|
||||
.on_step(move |next, _, cx| set(&step, next, cx)),
|
||||
)
|
||||
.child(
|
||||
HoverInfo::new("intel-hover", "pub fn lookup(&self, name: &str, lift: f32) -> Color")
|
||||
.problem(Severity::Warning, "`lift` outside 0..=1 is clamped")
|
||||
.docs("The color for `name`, blended toward white by `lift`.")
|
||||
.docs("Unknown names read as the fallback color.")
|
||||
.link("Go to definition", move |_, cx| set(&tell, Some("Went to palette.rs:23.".into()), cx))
|
||||
.link("Find references", |_, _| {}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(said.map(Caption::new))
|
||||
}
|
||||
|
||||
fn symbols() -> Vec<Symbol> {
|
||||
vec![
|
||||
Symbol::new("Palette", SymbolKind::Struct, 3).children([
|
||||
Symbol::new("colors", SymbolKind::Field, 4).detail("HashMap<String, Color>"),
|
||||
Symbol::new("fallback", SymbolKind::Field, 5).detail("Color"),
|
||||
]),
|
||||
Symbol::new("Color", SymbolKind::Struct, 9),
|
||||
Symbol::new("impl Palette", SymbolKind::Module, 15).children([
|
||||
Symbol::new("new", SymbolKind::Method, 16).detail("fn(Color) -> Self"),
|
||||
Symbol::new("insert", SymbolKind::Method, 23),
|
||||
Symbol::new("lookup", SymbolKind::Method, 28).detail("fn(&self, &str, f32) -> Color"),
|
||||
]),
|
||||
Symbol::new("main", SymbolKind::Function, 42),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn navigate(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let cursor = keep("intel-cursor", || 30usize, window, cx);
|
||||
let palette = keep("intel-goto", || false, window, cx);
|
||||
let incoming = keep("intel-incoming", || true, window, cx);
|
||||
let rename_open = keep("intel-rename", || false, window, cx);
|
||||
let field = window.use_keyed_state("intel-rename-field", cx, |window, cx| {
|
||||
let mut field = TextInput::new(window, cx);
|
||||
field.set_text("lookup", cx);
|
||||
field
|
||||
});
|
||||
let (line, open, inbound, renaming) = (
|
||||
*cursor.read(cx),
|
||||
*palette.read(cx),
|
||||
*incoming.read(cx),
|
||||
*rename_open.read(cx),
|
||||
);
|
||||
let (pick, go, close, flip) = (
|
||||
cursor.clone(),
|
||||
cursor.clone(),
|
||||
palette.clone(),
|
||||
incoming.clone(),
|
||||
);
|
||||
let opener = palette.clone();
|
||||
let (renamer, done, cancel) = (
|
||||
rename_open.clone(),
|
||||
rename_open.clone(),
|
||||
rename_open.clone(),
|
||||
);
|
||||
let hit = |line: usize, text: &str| {
|
||||
let start = text.find("lookup").expect("the line names lookup");
|
||||
let name = start..start + "lookup".len();
|
||||
Hit {
|
||||
line,
|
||||
text: text.to_string().into(),
|
||||
ranges: vec![name],
|
||||
}
|
||||
};
|
||||
let references = [
|
||||
FileHits {
|
||||
path: "src/palette.rs".into(),
|
||||
hits: vec![hit(
|
||||
27,
|
||||
" pub fn lookup(&self, name: &str, lift: f32) -> Color {",
|
||||
)],
|
||||
},
|
||||
FileHits {
|
||||
path: "src/main.rs".into(),
|
||||
hits: vec![
|
||||
hit(
|
||||
44,
|
||||
" let tints: Vec<Color> = (0..5u8).map(|step| palette.lookup(\"accent\", step as f32 / 4.0)).collect();",
|
||||
),
|
||||
hit(51, " let text = palette.lookup(\"fg\", 0.0);"),
|
||||
],
|
||||
},
|
||||
];
|
||||
let callers = Call {
|
||||
name: "lookup".into(),
|
||||
kind: SymbolKind::Method,
|
||||
place: "palette.rs:28".into(),
|
||||
calls: vec![
|
||||
Call {
|
||||
name: "main".into(),
|
||||
kind: SymbolKind::Function,
|
||||
place: "main.rs:44".into(),
|
||||
calls: Vec::new(),
|
||||
},
|
||||
Call {
|
||||
name: "paint_theme".into(),
|
||||
kind: SymbolKind::Function,
|
||||
place: "theme.rs:12".into(),
|
||||
calls: vec![Call {
|
||||
name: "render".into(),
|
||||
kind: SymbolKind::Method,
|
||||
place: "app.rs:88".into(),
|
||||
calls: Vec::new(),
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
let fixes = [
|
||||
CodeAction::new("Change type of `count` to `usize`", ActionKind::QuickFix).preferred(),
|
||||
CodeAction::new("Convert with `u32::try_from`", ActionKind::QuickFix),
|
||||
CodeAction::new("Extract into function", ActionKind::Refactor),
|
||||
CodeAction::new("Inline variable", ActionKind::Refactor),
|
||||
CodeAction::new("Organize imports", ActionKind::Source),
|
||||
];
|
||||
section(
|
||||
"CodeActionMenu / RenameInput / PeekView / ReferencesPanel / SymbolOutline / GoToSymbol / CallHierarchy / TypeHierarchy",
|
||||
"Fixes and refactors behind a lightbulb; renaming in place; where a symbol is used; a file's symbols as a tree that follows the cursor, or as a palette to jump by name; and who calls a function.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(CodeActionMenu::new("intel-actions", fixes))
|
||||
.child(Button::new("intel-goto-open", "Go to symbol…").on_click(move |_, _, cx| set(&opener, true, cx)))
|
||||
.child(Button::new("intel-rename-open", "Rename lookup").on_click(move |_, _, cx| set(&renamer, true, cx)))
|
||||
.child(Caption::new(format!("Cursor on line {}.", line + 1))),
|
||||
)
|
||||
.children(renaming.then(|| {
|
||||
RenameInput::new("intel-rename-box", &field)
|
||||
.on_rename(move |_, _, cx| set(&done, false, cx))
|
||||
.on_cancel(move |_, cx| set(&cancel, false, cx))
|
||||
}))
|
||||
.child(
|
||||
row()
|
||||
.items_start()
|
||||
.gap_8()
|
||||
.child(
|
||||
div().w(px(300.)).h(px(240.)).child(
|
||||
SymbolOutline::new("intel-outline", symbols())
|
||||
.cursor(line)
|
||||
.on_pick(move |line, _, cx| set(&pick, line, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(300.)).h(px(240.)).child(
|
||||
CallHierarchy::new("intel-calls", callers, inbound)
|
||||
.on_flip(move |incoming, _, cx| set(&flip, incoming, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().w(px(840.)).child(ReferencesPanel::new("intel-references", "lookup", references)))
|
||||
.children(open.then(|| {
|
||||
GoToSymbol::new("intel-goto-palette", symbols(), move |_, cx| set(&close, false, cx))
|
||||
.on_pick(move |line, _, cx| set(&go, line, cx))
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
mod code;
|
||||
mod intel;
|
||||
mod panels;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod start;
|
||||
mod status;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 17,
|
||||
slug: "editor",
|
||||
title: "Editor",
|
||||
summary: "A code editor with the gutter, folds and cursors it needs, the help a language server gives, search, the panels around it and its status row.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::Rest,
|
||||
Step::DownAt("editor-main", 118.0, 104.0),
|
||||
Step::UpAt("editor-main", 118.0, 104.0),
|
||||
Step::Key("cmd-d"),
|
||||
Step::Key("cmd-d"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("cursors"),
|
||||
Step::Key("escape"),
|
||||
Step::Key("cmd-/"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("commented"),
|
||||
Step::Key("cmd-z"),
|
||||
Step::Wait(300),
|
||||
Step::DownAt("status", 249.0, 11.0),
|
||||
Step::UpAt("status", 249.0, 11.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("status"),
|
||||
Step::Rest,
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(code::editor(window, cx))
|
||||
.child(code::looks(window, cx))
|
||||
.child(code::changes(window, cx))
|
||||
.child(intel::hints(window, cx))
|
||||
.child(intel::navigate(window, cx))
|
||||
.child(search::find(window, cx))
|
||||
.child(search::panel(window, cx))
|
||||
.child(panels::problems_and_output(window, cx))
|
||||
.child(panels::console_and_extensions(window, cx))
|
||||
.child(settings::settings_and_keys(window, cx))
|
||||
.child(start::start(window, cx))
|
||||
.child(status::status(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use ely_gpui_component::{
|
||||
editor::{
|
||||
ConsoleEntry, DebugConsole, Extension, ExtensionAction, ExtensionState, ExtensionsPanel,
|
||||
OutputPanel, Problem, ProblemsPanel,
|
||||
},
|
||||
forms::TextInput,
|
||||
primitives::Severity,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::{keep, section, set};
|
||||
|
||||
fn problems() -> Vec<Problem> {
|
||||
let problem = |path: &str, line, column, severity, message: &str, source: &str| Problem {
|
||||
path: path.to_string().into(),
|
||||
line,
|
||||
column,
|
||||
severity,
|
||||
message: message.to_string().into(),
|
||||
source: Some(source.to_string().into()),
|
||||
};
|
||||
vec![
|
||||
problem(
|
||||
"src/main.rs",
|
||||
45,
|
||||
21,
|
||||
Severity::Danger,
|
||||
"mismatched types: expected `u32`, found `usize`",
|
||||
"rustc",
|
||||
),
|
||||
problem(
|
||||
"src/main.rs",
|
||||
46,
|
||||
8,
|
||||
Severity::Warning,
|
||||
"unused variable: `spare`",
|
||||
"rustc",
|
||||
),
|
||||
problem(
|
||||
"src/palette.rs",
|
||||
0,
|
||||
23,
|
||||
Severity::Info,
|
||||
"a BTreeMap keeps a steady order",
|
||||
"clippy",
|
||||
),
|
||||
problem(
|
||||
"src/theme/tokens.rs",
|
||||
112,
|
||||
4,
|
||||
Severity::Warning,
|
||||
"this function has too many arguments (8/7)",
|
||||
"clippy",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn problems_and_output(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let shown = keep(
|
||||
"panels-shown",
|
||||
|| vec![Severity::Danger, Severity::Warning, Severity::Info],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let channel = keep("panels-channel", || SharedString::from("Build"), window, cx);
|
||||
let follow = keep("panels-follow", || true, window, cx);
|
||||
let cleared = keep("panels-cleared", || false, window, cx);
|
||||
let opened = keep("panels-problem", || None::<SharedString>, window, cx);
|
||||
let (now_shown, now_channel, now_follow, now_cleared, said) = (
|
||||
shown.read(cx).clone(),
|
||||
channel.read(cx).clone(),
|
||||
*follow.read(cx),
|
||||
*cleared.read(cx),
|
||||
opened.read(cx).clone(),
|
||||
);
|
||||
let lines: Vec<&str> = if now_cleared {
|
||||
Vec::new()
|
||||
} else if now_channel.as_ref() == "Build" {
|
||||
vec![
|
||||
" Compiling ely-gpui-component v0.1.0",
|
||||
"warning: unused variable: `spare`",
|
||||
"error[E0308]: mismatched types",
|
||||
" --> src/main.rs:46:22",
|
||||
"error: could not compile `palette` due to 1 previous error",
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"[info] rust-analyzer started",
|
||||
"[debug] indexing 214 files",
|
||||
"[info] indexed in 1.2 s",
|
||||
]
|
||||
};
|
||||
let (show, pick, keep_up, clear, open) = (
|
||||
shown.clone(),
|
||||
channel.clone(),
|
||||
follow.clone(),
|
||||
cleared.clone(),
|
||||
opened.clone(),
|
||||
);
|
||||
let reset = cleared.clone();
|
||||
section(
|
||||
"ProblemsPanel / DiagnosticsList / OutputPanel",
|
||||
"Every problem by file, worst first, with toggles by severity; and a tool's log by channel, levels tinted, following the newest line.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_8()
|
||||
.child(
|
||||
div().flex_1().child(
|
||||
ProblemsPanel::new("panels-problems", problems())
|
||||
.shown(now_shown)
|
||||
.on_shown(move |next, _, cx| set(&show, next, cx))
|
||||
.on_open(move |problem, _, cx| {
|
||||
set(&open, Some(format!("Opened {}:{}.", problem.path, problem.line + 1).into()), cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(400.)).h(px(220.)).child(
|
||||
OutputPanel::new("panels-output", ["Build", "Language server"], now_channel, lines)
|
||||
.follow(now_follow)
|
||||
.on_channel(move |name, _, cx| {
|
||||
set(&pick, name.clone(), cx);
|
||||
set(&reset, false, cx);
|
||||
})
|
||||
.on_follow(move |on, _, cx| set(&keep_up, on, cx))
|
||||
.on_clear(move |_, cx| set(&clear, true, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(said.map(Caption::new))
|
||||
}
|
||||
|
||||
pub fn console_and_extensions(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let field = window.use_keyed_state("panels-console-field", cx, TextInput::new);
|
||||
let entries = keep(
|
||||
"panels-console",
|
||||
|| {
|
||||
vec![
|
||||
ConsoleEntry::Input("tints.len()".into()),
|
||||
ConsoleEntry::Value("5".into(), "usize".into()),
|
||||
ConsoleEntry::Input("palette.lookup(\"accent\", 0.5).red".into()),
|
||||
ConsoleEntry::Value("0.605".into(), "f32".into()),
|
||||
ConsoleEntry::Input("spare + missing".into()),
|
||||
ConsoleEntry::Error("cannot find value `missing` in this scope".into()),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let states = keep(
|
||||
"panels-extensions",
|
||||
|| {
|
||||
vec![
|
||||
ExtensionState::Installed,
|
||||
ExtensionState::Available,
|
||||
ExtensionState::Outdated,
|
||||
ExtensionState::Disabled,
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let now_entries = entries.read(cx).clone();
|
||||
let now_states = states.read(cx).clone();
|
||||
let extension = |ix: usize,
|
||||
id: &str,
|
||||
name: &str,
|
||||
publisher: &str,
|
||||
description: &str,
|
||||
version: &str,
|
||||
installs: u64,
|
||||
rating: f32| Extension {
|
||||
id: id.to_string().into(),
|
||||
name: name.to_string().into(),
|
||||
publisher: publisher.to_string().into(),
|
||||
description: description.to_string().into(),
|
||||
version: version.to_string().into(),
|
||||
installs,
|
||||
rating,
|
||||
state: now_states[ix],
|
||||
};
|
||||
let extensions = vec![
|
||||
extension(
|
||||
0,
|
||||
"rust",
|
||||
"Rust",
|
||||
"Ely Tools",
|
||||
"Syntax, completions and checks for Rust.",
|
||||
"1.4.2",
|
||||
2_410_000,
|
||||
4.8,
|
||||
),
|
||||
extension(
|
||||
1,
|
||||
"toml",
|
||||
"Even Better TOML",
|
||||
"Tamasfe",
|
||||
"TOML with schemas and formatting.",
|
||||
"0.19.2",
|
||||
830_000,
|
||||
4.6,
|
||||
),
|
||||
extension(
|
||||
2,
|
||||
"lens",
|
||||
"Git Lens",
|
||||
"Line Labs",
|
||||
"Who changed each line, and when.",
|
||||
"14.1.0",
|
||||
5_900_000,
|
||||
4.4,
|
||||
),
|
||||
extension(
|
||||
3,
|
||||
"vim",
|
||||
"Vim",
|
||||
"Modal Folk",
|
||||
"Modal editing with the keys you know.",
|
||||
"1.27.3",
|
||||
1_200_000,
|
||||
4.2,
|
||||
),
|
||||
];
|
||||
let (eval, act) = (entries.clone(), states.clone());
|
||||
section(
|
||||
"DebugConsole / ExtensionsPanel / PluginList",
|
||||
"An expression prompt beside a paused program; and extensions with their reach, rating, and the one action each needs next.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_8()
|
||||
.child(
|
||||
div().w(px(380.)).child(DebugConsole::new("panels-console", &field, now_entries).on_eval(move |text, _, cx| {
|
||||
let mut next = eval.read(cx).clone();
|
||||
next.push(ConsoleEntry::Input(text.clone()));
|
||||
next.push(match text.trim().parse::<i64>() {
|
||||
Ok(value) => ConsoleEntry::Value(value.to_string().into(), "i64".into()),
|
||||
Err(_) => ConsoleEntry::Error(format!("cannot find value `{}` in this scope", text.trim()).into()),
|
||||
});
|
||||
set(&eval, next, cx)
|
||||
})),
|
||||
)
|
||||
.child(div().flex_1().child(ExtensionsPanel::new("panels-extensions", extensions).on_action(move |id, action, _, cx| {
|
||||
let ix = ["rust", "toml", "lens", "vim"].iter().position(|known| *known == id.as_ref()).expect("a listed extension");
|
||||
let mut next = act.read(cx).clone();
|
||||
next[ix] = match action {
|
||||
ExtensionAction::Install | ExtensionAction::Enable | ExtensionAction::Update => ExtensionState::Installed,
|
||||
ExtensionAction::Uninstall => ExtensionState::Available,
|
||||
};
|
||||
set(&act, next, cx)
|
||||
}))),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use ely_gpui_component::{
|
||||
editor::{
|
||||
CodeEditor, FileHits, FindOptions, FindWidget, Hit, SearchFilters, SearchPanel, find_all,
|
||||
passes,
|
||||
},
|
||||
forms::TextInput,
|
||||
theme::ActiveTheme,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::code::{SAMPLE, framed};
|
||||
use crate::ui::{keep, section, set};
|
||||
|
||||
fn field(
|
||||
key: &'static str,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> gpui::Entity<TextInput> {
|
||||
let seed = text.to_string();
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
let mut field = TextInput::new(window, cx);
|
||||
field.set_text(seed, cx);
|
||||
field
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let editor = window.use_keyed_state("search-editor", cx, |window, cx| {
|
||||
CodeEditor::new(SAMPLE, window, cx).language("Rust")
|
||||
});
|
||||
let query = field("search-find", "lift", window, cx);
|
||||
let replacement = field("search-replace", "blend", window, cx);
|
||||
let options = keep("search-options", FindOptions::default, window, cx);
|
||||
let current = keep("search-current", || 0usize, window, cx);
|
||||
let replacing = keep("search-replacing", || true, window, cx);
|
||||
let (now, at, showing) = (*options.read(cx), *current.read(cx), *replacing.read(cx));
|
||||
let text = editor.read(cx).text().to_string();
|
||||
let found = find_all(&text, query.read(cx).text(), now);
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (widget, current_range) = match &found {
|
||||
Ok(ranges) => {
|
||||
let at = if ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(at.min(ranges.len() - 1))
|
||||
};
|
||||
let marks: Vec<_> = ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, range)| {
|
||||
let wash = if Some(ix) == at {
|
||||
colors.warning.opacity(0.45)
|
||||
} else {
|
||||
colors.warning.opacity(0.2)
|
||||
};
|
||||
(range.clone(), wash)
|
||||
})
|
||||
.collect();
|
||||
editor.update(cx, |editor, cx| editor.set_backgrounds(marks, cx));
|
||||
(
|
||||
FindWidget::new("search-widget", &query, at, ranges.len()),
|
||||
at.map(|ix| ranges[ix].clone()),
|
||||
)
|
||||
}
|
||||
Err(message) => {
|
||||
editor.update(cx, |editor, cx| editor.set_backgrounds(Vec::new(), cx));
|
||||
(
|
||||
FindWidget::new("search-widget", &query, None, 0)
|
||||
.error(message.lines().last().unwrap_or("bad pattern").to_string()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some(range) = current_range.clone() {
|
||||
editor.update(cx, |editor, cx| editor.select([range], cx));
|
||||
}
|
||||
let total = found.as_ref().map_or(0, Vec::len);
|
||||
let (set_options, step, toggle) = (options.clone(), current.clone(), replacing.clone());
|
||||
let (swap, swap_editor, swap_query, swap_with) = (
|
||||
current.clone(),
|
||||
editor.clone(),
|
||||
query.clone(),
|
||||
replacement.clone(),
|
||||
);
|
||||
let widget = widget
|
||||
.options(now)
|
||||
.on_options(move |next, _, cx| set(&set_options, next, cx))
|
||||
.on_step(move |forward, _, cx| {
|
||||
let at = *step.read(cx);
|
||||
let next = if total == 0 {
|
||||
0
|
||||
} else if forward {
|
||||
(at + 1) % total
|
||||
} else {
|
||||
(at + total - 1) % total
|
||||
};
|
||||
set(&step, next, cx)
|
||||
})
|
||||
.on_toggle_replace(move |show, _, cx| set(&toggle, show, cx))
|
||||
.on_replace(move |all, _, cx| {
|
||||
let text = swap_editor.read(cx).text().to_string();
|
||||
let Ok(ranges) = find_all(&text, swap_query.read(cx).text(), now) else {
|
||||
return;
|
||||
};
|
||||
let with = swap_with.read(cx).text().to_string();
|
||||
let chosen: Vec<_> = if all {
|
||||
ranges
|
||||
.into_iter()
|
||||
.map(|range| (range, with.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
let at = (*swap.read(cx)).min(ranges.len().saturating_sub(1));
|
||||
ranges
|
||||
.into_iter()
|
||||
.nth(at)
|
||||
.map(|range| (range, with.clone()))
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
swap_editor.update(cx, |editor, cx| editor.edit(chosen, cx));
|
||||
});
|
||||
let widget = if showing {
|
||||
widget.replace(&replacement)
|
||||
} else {
|
||||
widget
|
||||
};
|
||||
section(
|
||||
"FindWidget / ReplaceWidget",
|
||||
"Find over code by case, whole word or pattern; the current match glows among the rest, and the arrows step between them. The replace row swaps one or every match.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
framed(840.0, 320.0, cx)
|
||||
.relative()
|
||||
.child(editor)
|
||||
.child(div().absolute().top_1().right_4().child(widget)),
|
||||
)
|
||||
}
|
||||
|
||||
fn results() -> Vec<FileHits> {
|
||||
let hit = |line: usize, text: &str, needle: &str| {
|
||||
let start = text.find(needle).expect("the needle is in the line");
|
||||
let found = start..start + needle.len();
|
||||
Hit {
|
||||
line,
|
||||
text: text.to_string().into(),
|
||||
ranges: vec![found],
|
||||
}
|
||||
};
|
||||
vec![
|
||||
FileHits {
|
||||
path: "src/palette.rs".into(),
|
||||
hits: vec![
|
||||
hit(
|
||||
27,
|
||||
" /// The color for `name`, blended toward white by `lift`.",
|
||||
"lift",
|
||||
),
|
||||
hit(
|
||||
28,
|
||||
" pub fn lookup(&self, name: &str, lift: f32) -> Color {",
|
||||
"lift",
|
||||
),
|
||||
hit(30, " match lift {", "lift"),
|
||||
],
|
||||
},
|
||||
FileHits {
|
||||
path: "src/theme/tokens.rs".into(),
|
||||
hits: vec![hit(
|
||||
112,
|
||||
" pub fn surface_lift(&self, level: u8) -> f32 {",
|
||||
"lift",
|
||||
)],
|
||||
},
|
||||
FileHits {
|
||||
path: "tests/palette.rs".into(),
|
||||
hits: vec![hit(
|
||||
8,
|
||||
" assert_eq!(palette.lookup(\"accent\", 1.0), WHITE, \"a full lift is white\");",
|
||||
"lift",
|
||||
)],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn panel(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let include = field("search-include", "src/**", window, cx);
|
||||
let exclude = field("search-exclude", "", window, cx);
|
||||
let ignore = keep("search-ignore", || true, window, cx);
|
||||
let folded = keep("search-folded", Vec::<SharedString>::new, window, cx);
|
||||
let opened = keep("search-opened", || None::<SharedString>, window, cx);
|
||||
let (now_ignore, now_folded, said) = (
|
||||
*ignore.read(cx),
|
||||
folded.read(cx).clone(),
|
||||
opened.read(cx).clone(),
|
||||
);
|
||||
let (include_text, exclude_text) = (
|
||||
include.read(cx).text().to_string(),
|
||||
exclude.read(cx).text().to_string(),
|
||||
);
|
||||
let shown: Vec<FileHits> = results()
|
||||
.into_iter()
|
||||
.filter(|file| passes(&file.path, &include_text, &exclude_text))
|
||||
.collect();
|
||||
let (flip, fold, open) = (ignore.clone(), folded.clone(), opened.clone());
|
||||
section(
|
||||
"SearchPanel / SearchResultItem / SearchFilters",
|
||||
"Matches across files, each file folding away, each line trimmed near its match; globs choose which files count.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_8()
|
||||
.child(
|
||||
div().w(px(280.)).child(
|
||||
SearchFilters::new("search-filters", &include, &exclude)
|
||||
.ignore(now_ignore)
|
||||
.on_ignore(move |on, _, cx| set(&flip, on, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().child(
|
||||
SearchPanel::new("search-panel", shown)
|
||||
.folded(now_folded)
|
||||
.on_fold(move |path, _, cx| {
|
||||
let mut now = fold.read(cx).clone();
|
||||
match now.iter().position(|folded| folded == path) {
|
||||
Some(ix) => drop(now.remove(ix)),
|
||||
None => now.push(path.clone()),
|
||||
}
|
||||
set(&fold, now, cx)
|
||||
})
|
||||
.on_open(move |path, line, _, cx| set(&open, Some(format!("Opened {path}:{}.", line + 1).into()), cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(said.map(Caption::new))
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use ely_gpui_component::{
|
||||
editor::{
|
||||
KeySource, Keybinding, KeybindingsEditor, Setting, SettingValue, SettingsEditor,
|
||||
settings_json,
|
||||
},
|
||||
forms::{TextInput, json_highlights},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::{keep, section, set};
|
||||
|
||||
fn settings() -> Vec<(SharedString, Vec<Setting>)> {
|
||||
let number = |value, min, max| SettingValue::Number {
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step: 1.0,
|
||||
};
|
||||
let choice = |value: &str, choices: &[&str]| SettingValue::Choice {
|
||||
value: value.to_string().into(),
|
||||
choices: choices
|
||||
.iter()
|
||||
.map(|choice| SharedString::from(choice.to_string()))
|
||||
.collect(),
|
||||
};
|
||||
let setting = |key: &str, title: &str, description: &str, value: SettingValue| Setting {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
description: description.to_string().into(),
|
||||
default: value.clone(),
|
||||
value,
|
||||
};
|
||||
vec![
|
||||
(
|
||||
"Editor".into(),
|
||||
vec![
|
||||
setting(
|
||||
"editor.fontSize",
|
||||
"Font size",
|
||||
"Code text size in pixels.",
|
||||
number(14.0, 8.0, 32.0),
|
||||
),
|
||||
setting(
|
||||
"editor.minimap",
|
||||
"Minimap",
|
||||
"A narrow map of the file beside the code.",
|
||||
SettingValue::Bool(true),
|
||||
),
|
||||
setting(
|
||||
"editor.cursorStyle",
|
||||
"Cursor style",
|
||||
"How the cursor draws.",
|
||||
choice("line", &["line", "block", "underline"]),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Files".into(),
|
||||
vec![
|
||||
setting(
|
||||
"files.autoSave",
|
||||
"Auto save",
|
||||
"When files save themselves.",
|
||||
choice("afterDelay", &["off", "afterDelay", "onFocusChange"]),
|
||||
),
|
||||
setting(
|
||||
"files.trimTrailingWhitespace",
|
||||
"Trim trailing whitespace",
|
||||
"Drops spaces at line ends on save.",
|
||||
SettingValue::Bool(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn settings_and_keys(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let groups = keep("panels-settings", settings, window, cx);
|
||||
let section_now = keep("panels-section", || 0usize, window, cx);
|
||||
let json = keep("panels-json", || false, window, cx);
|
||||
let json_field = window.use_keyed_state("panels-json-field", cx, |window, cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(6, 12)
|
||||
.highlighter(json_highlights)
|
||||
});
|
||||
let (now_groups, now_section, now_json) = (
|
||||
groups.read(cx).clone(),
|
||||
*section_now.read(cx),
|
||||
*json.read(cx),
|
||||
);
|
||||
let (change, pick, flip, seed, source) = (
|
||||
groups.clone(),
|
||||
section_now.clone(),
|
||||
json.clone(),
|
||||
json_field.clone(),
|
||||
groups.clone(),
|
||||
);
|
||||
let recording = keep("panels-recording", || None::<SharedString>, window, cx);
|
||||
let bindings = keep(
|
||||
"panels-bindings",
|
||||
|| {
|
||||
let binding =
|
||||
|command: &str, title: &str, keys: Option<&str>, when: Option<&str>, source| {
|
||||
Keybinding {
|
||||
command: command.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
keys: keys.map(|keys| keys.to_string().into()),
|
||||
when: when.map(|when| when.to_string().into()),
|
||||
source,
|
||||
}
|
||||
};
|
||||
vec![
|
||||
binding(
|
||||
"editor.save",
|
||||
"Save",
|
||||
Some("secondary-s"),
|
||||
None,
|
||||
KeySource::Default,
|
||||
),
|
||||
binding(
|
||||
"editor.sortLines",
|
||||
"Sort lines",
|
||||
Some("secondary-s"),
|
||||
None,
|
||||
KeySource::User,
|
||||
),
|
||||
binding(
|
||||
"editor.comment",
|
||||
"Toggle comment",
|
||||
Some("secondary-/"),
|
||||
Some("editorFocus"),
|
||||
KeySource::Default,
|
||||
),
|
||||
binding(
|
||||
"editor.selectNext",
|
||||
"Select next match",
|
||||
Some("secondary-d"),
|
||||
Some("editorFocus"),
|
||||
KeySource::Default,
|
||||
),
|
||||
binding(
|
||||
"terminal.split",
|
||||
"Split terminal",
|
||||
Some("secondary-s"),
|
||||
Some("terminalFocus"),
|
||||
KeySource::User,
|
||||
),
|
||||
binding(
|
||||
"editor.fold",
|
||||
"Fold",
|
||||
None,
|
||||
Some("editorFocus"),
|
||||
KeySource::Default,
|
||||
),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (now_recording, now_bindings) = (recording.read(cx).clone(), bindings.read(cx).clone());
|
||||
let (record, bind, done) = (recording.clone(), bindings.clone(), recording.clone());
|
||||
let editor = SettingsEditor::new("panels-settings-editor", now_groups.clone())
|
||||
.section(now_section)
|
||||
.on_section(move |ix, _, cx| set(&pick, ix, cx))
|
||||
.on_json(move |on, _, cx| {
|
||||
if on {
|
||||
let text = settings_json(source.read(cx));
|
||||
seed.update(cx, |field, cx| field.set_text(text, cx));
|
||||
}
|
||||
set(&flip, on, cx)
|
||||
})
|
||||
.on_change(move |key, value, _, cx| {
|
||||
let mut next = change.read(cx).clone();
|
||||
for (_, list) in &mut next {
|
||||
for setting in list.iter_mut().filter(|setting| setting.key == *key) {
|
||||
setting.value = value.clone();
|
||||
}
|
||||
}
|
||||
set(&change, next, cx)
|
||||
});
|
||||
let editor = if now_json {
|
||||
editor.json(&json_field)
|
||||
} else {
|
||||
editor
|
||||
};
|
||||
section(
|
||||
"SettingsEditor / KeybindingsEditor",
|
||||
"Settings as controls by section, each change marked with a way back, or as the JSON a settings file holds; and every command's keys, clashes marked. Double-press a command to record new keys.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w(px(840.)).child(editor))
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
KeybindingsEditor::new("panels-keys", now_bindings)
|
||||
.recording(now_recording)
|
||||
.on_record(move |command, _, cx| set(&record, Some(command.clone()), cx))
|
||||
.on_bind(move |command, stroke, _, cx| {
|
||||
let mut next = bind.read(cx).clone();
|
||||
if let Some(binding) = next.iter_mut().find(|binding| binding.command == *command) {
|
||||
binding.keys = stroke.map(|stroke| stroke.unparse().into());
|
||||
binding.source = KeySource::User;
|
||||
}
|
||||
set(&bind, next, cx);
|
||||
set(&done, None, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::Button,
|
||||
editor::{
|
||||
Project, ProjectSwitcher, RecentProjects, Task, TaskRunner, TaskState, ThemePreview,
|
||||
WelcomePage,
|
||||
},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, Mode, Palette, Theme},
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::{keep, row, section, set};
|
||||
|
||||
fn projects() -> Vec<Project> {
|
||||
let project = |name: &str, path: &str, branch: Option<&str>, opened: &str, pinned| Project {
|
||||
name: name.to_string().into(),
|
||||
path: path.to_string().into(),
|
||||
branch: branch.map(|branch| branch.to_string().into()),
|
||||
opened: opened.to_string().into(),
|
||||
pinned,
|
||||
};
|
||||
vec![
|
||||
project(
|
||||
"ely-gpui-component",
|
||||
"~/Documents/GitHub/Ely-GPUI-Components",
|
||||
Some("main"),
|
||||
"now",
|
||||
true,
|
||||
),
|
||||
project(
|
||||
"palette",
|
||||
"~/Code/palette",
|
||||
Some("feature/lift"),
|
||||
"2 h ago",
|
||||
false,
|
||||
),
|
||||
project("notes", "~/Code/notes", None, "yesterday", false),
|
||||
project("zed", "~/Code/zed", Some("main"), "3 days ago", false),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn start(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let switching = keep("panels-switcher", || false, window, cx);
|
||||
let opened = keep("panels-opened", || None::<SharedString>, window, cx);
|
||||
let show = keep("panels-welcome", || true, window, cx);
|
||||
let tasks = keep(
|
||||
"panels-tasks",
|
||||
|| {
|
||||
vec![
|
||||
TaskState::Passed(Duration::from_secs(14)),
|
||||
TaskState::Running,
|
||||
TaskState::Failed(101),
|
||||
TaskState::Idle,
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let open_task = keep("panels-task-open", || Some(2usize), window, cx);
|
||||
let (now_switching, said, now_show, now_tasks, now_open) = (
|
||||
*switching.read(cx),
|
||||
opened.read(cx).clone(),
|
||||
*show.read(cx),
|
||||
tasks.read(cx).clone(),
|
||||
*open_task.read(cx),
|
||||
);
|
||||
let mode = cx.theme().mode();
|
||||
let task = |name: &str, group: &str, command: &str, state| Task {
|
||||
name: name.to_string().into(),
|
||||
group: group.to_string().into(),
|
||||
command: command.to_string().into(),
|
||||
cwd: "~/Documents/GitHub/Ely-GPUI-Components".into(),
|
||||
env: vec![("RUST_LOG".into(), "info".into())],
|
||||
state,
|
||||
};
|
||||
let task_list = vec![
|
||||
task("check", "Build", "cargo check --all-targets", now_tasks[0]),
|
||||
task(
|
||||
"gallery",
|
||||
"Run",
|
||||
"cargo run --example gallery",
|
||||
now_tasks[1],
|
||||
),
|
||||
task(
|
||||
"test",
|
||||
"Test",
|
||||
"cargo test --lib --features test-support",
|
||||
now_tasks[2],
|
||||
),
|
||||
task(
|
||||
"clippy",
|
||||
"Test",
|
||||
"cargo clippy --all-targets -- -D warnings",
|
||||
now_tasks[3],
|
||||
),
|
||||
];
|
||||
let (open, pin, close, pick, start_run, stop_run, fold) = (
|
||||
opened.clone(),
|
||||
opened.clone(),
|
||||
switching.clone(),
|
||||
opened.clone(),
|
||||
tasks.clone(),
|
||||
tasks.clone(),
|
||||
open_task.clone(),
|
||||
);
|
||||
let (opener, toggle) = (switching.clone(), show.clone());
|
||||
let recent = RecentProjects::new("panels-recent", projects())
|
||||
.on_open(move |ix, _, cx| {
|
||||
set(
|
||||
&open,
|
||||
Some(format!("Opened {}.", projects()[ix].name).into()),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_pin(move |ix, _, cx| {
|
||||
set(
|
||||
&pin,
|
||||
Some(format!("Pinned {}.", projects()[ix].name).into()),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
section(
|
||||
"WelcomePage / RecentProjects / ProjectSwitcher / ThemePreview / TaskRunner / RunConfiguration",
|
||||
"Where an editor starts: ways in, recent projects and a switcher; each theme painted in its own colors; and tasks with how their last run went, one open to its configuration.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
WelcomePage::new("panels-welcome-page", "Ely Editor", "Quiet tools for careful code.")
|
||||
.action(IconName::FilePlus, "New file", Some("secondary-n"))
|
||||
.action(IconName::FolderOpen, "Open folder", Some("secondary-o"))
|
||||
.action(IconName::GitBranch, "Clone repository", None)
|
||||
.recent(recent)
|
||||
.show_at_start(now_show)
|
||||
.on_show(move |on, _, cx| set(&toggle, on, cx)),
|
||||
),
|
||||
)
|
||||
.child(row().child(Button::new("panels-switch", "Switch project…").on_click(move |_, _, cx| set(&opener, true, cx))))
|
||||
.children(said.map(Caption::new))
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(div().w(px(260.)).child(ThemePreview::new("panels-light", "Ely Light", Palette::light(false)).selected(mode == Mode::Light).on_pick(|_, cx| Theme::set_mode(Mode::Light, cx))))
|
||||
.child(div().w(px(260.)).child(ThemePreview::new("panels-dark", "Ely Dark", Palette::dark(false)).selected(mode == Mode::Dark).on_pick(|_, cx| Theme::set_mode(Mode::Dark, cx)))),
|
||||
)
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
TaskRunner::new("panels-tasks", task_list)
|
||||
.open(now_open)
|
||||
.on_open(move |ix, _, cx| set(&fold, ix, cx))
|
||||
.on_run(move |ix, _, cx| {
|
||||
let mut next = start_run.read(cx).clone();
|
||||
next[ix] = TaskState::Running;
|
||||
set(&start_run, next, cx)
|
||||
})
|
||||
.on_stop(move |ix, _, cx| {
|
||||
let mut next = stop_run.read(cx).clone();
|
||||
next[ix] = TaskState::Idle;
|
||||
set(&stop_run, next, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.children(now_switching.then(|| {
|
||||
ProjectSwitcher::new("panels-switcher-palette", projects(), move |_, cx| set(&close, false, cx))
|
||||
.on_open(move |ix, _, cx| set(&pick, Some(format!("Switched to {}.", projects()[ix].name).into()), cx))
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use ely_gpui_component::{
|
||||
editor::{
|
||||
BranchIndicator, CursorPosition, Encoding, IndentSettings, LanguageMode, LineEnding,
|
||||
LspState, LspStatus, NotificationBell,
|
||||
},
|
||||
shell::StatusBar,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, section, set},
|
||||
};
|
||||
|
||||
const LANGUAGES: [&str; 3] = ["Rust", "TOML", "Markdown"];
|
||||
|
||||
fn lsp(step: usize) -> LspState {
|
||||
match step % 4 {
|
||||
0 => LspState::Starting,
|
||||
1 => LspState::Busy("indexing 214 files".into()),
|
||||
2 => LspState::Ready,
|
||||
_ => LspState::Failed("the server exited with code 101".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let language = keep("status-language", || 0usize, window, cx);
|
||||
let crlf = keep("status-crlf", || false, window, cx);
|
||||
let spaces = keep("status-spaces", || true, window, cx);
|
||||
let server = keep("status-server", || 1usize, window, cx);
|
||||
let unread = keep("status-unread", || 3usize, window, cx);
|
||||
let said = keep("status-said", || None::<SharedString>, window, cx);
|
||||
let (now_language, now_crlf, now_spaces, now_server, now_unread, told) = (
|
||||
*language.read(cx),
|
||||
*crlf.read(cx),
|
||||
*spaces.read(cx),
|
||||
*server.read(cx),
|
||||
*unread.read(cx),
|
||||
said.read(cx).clone(),
|
||||
);
|
||||
let (go, sync, encode) = (said.clone(), said.clone(), said.clone());
|
||||
let bar = StatusBar::new()
|
||||
.left(
|
||||
BranchIndicator::new("status-branch", "feature/editor")
|
||||
.dirty(true)
|
||||
.sync(2, 1)
|
||||
.on_click(move |_, cx| set(&sync, Some("Pulled 1 commit, pushed 2.".into()), cx)),
|
||||
)
|
||||
.left(
|
||||
LspStatus::new("status-lsp", "rust-analyzer", lsp(now_server))
|
||||
.on_click(move |_, cx| set(&server, now_server + 1, cx)),
|
||||
)
|
||||
.right(
|
||||
CursorPosition::new("status-cursor", 48, 27)
|
||||
.selected(6)
|
||||
.on_click(move |_, cx| set(&go, Some("Go to line…".into()), cx)),
|
||||
)
|
||||
.right(
|
||||
IndentSettings::new("status-indent", now_spaces, 4)
|
||||
.on_click(move |_, cx| set(&spaces, !now_spaces, cx)),
|
||||
)
|
||||
.right(
|
||||
Encoding::new("status-encoding", "UTF-8")
|
||||
.on_click(move |_, cx| set(&encode, Some("Reopen with encoding…".into()), cx)),
|
||||
)
|
||||
.right(
|
||||
LineEnding::new("status-ending", now_crlf)
|
||||
.on_click(move |_, cx| set(&crlf, !now_crlf, cx)),
|
||||
)
|
||||
.right(
|
||||
LanguageMode::new("status-language", LANGUAGES[now_language])
|
||||
.on_click(move |_, cx| set(&language, (now_language + 1) % LANGUAGES.len(), cx)),
|
||||
)
|
||||
.right(
|
||||
NotificationBell::new("status-bell", now_unread)
|
||||
.on_click(move |_, cx| set(&unread, 0, cx)),
|
||||
);
|
||||
section(
|
||||
"CursorPosition / LanguageMode / Encoding / LineEnding / IndentSettings / BranchIndicator / LspStatus / NotificationBell",
|
||||
"The quiet row under the code. Each item says one thing and opens the one control that changes it: press the language, the line ending or the indent to switch them, the server to step through its states, the bell to read.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("status", div().w(px(840.)).child(bar)))
|
||||
.children(told.map(Caption::new))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod buttons;
|
||||
mod charts;
|
||||
mod data;
|
||||
mod editor;
|
||||
mod feedback;
|
||||
mod finance;
|
||||
mod forms;
|
||||
@@ -47,6 +48,7 @@ pub const ALL: &[Page] = &[
|
||||
tables::PAGE,
|
||||
charts::PAGE,
|
||||
finance::PAGE,
|
||||
editor::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
use std::ops::Range;
|
||||
|
||||
/// Source text and where each line starts, in bytes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Buffer {
|
||||
text: String,
|
||||
starts: Vec<usize>,
|
||||
}
|
||||
|
||||
fn starts(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
.chain(text.match_indices('\n').map(|(at, _)| at + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
let starts = starts(&text);
|
||||
Self { text, starts }
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.text.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
/// How many lines it holds; an empty buffer holds one.
|
||||
pub fn lines(&self) -> usize {
|
||||
self.starts.len()
|
||||
}
|
||||
|
||||
/// A line's bytes, without its line break.
|
||||
pub fn line_range(&self, line: usize) -> Range<usize> {
|
||||
let start = self.starts[line];
|
||||
let end = self
|
||||
.starts
|
||||
.get(line + 1)
|
||||
.map_or(self.text.len(), |next| next - 1);
|
||||
start..end
|
||||
}
|
||||
|
||||
pub fn line(&self, line: usize) -> &str {
|
||||
&self.text[self.line_range(line)]
|
||||
}
|
||||
|
||||
/// The line holding `offset`.
|
||||
pub fn line_of(&self, offset: usize) -> usize {
|
||||
self.starts.partition_point(|start| *start <= offset) - 1
|
||||
}
|
||||
|
||||
/// An offset's line, and its column in characters.
|
||||
pub fn point(&self, offset: usize) -> (usize, usize) {
|
||||
let line = self.line_of(offset);
|
||||
let column = self.text[self.starts[line]..offset].chars().count();
|
||||
(line, column)
|
||||
}
|
||||
|
||||
/// The offset at a line and a column in characters, held to the line's end.
|
||||
pub fn offset(&self, line: usize, column: usize) -> usize {
|
||||
let range = self.line_range(line.min(self.lines() - 1));
|
||||
self.text[range.clone()]
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(range.end, |(at, _)| range.start + at)
|
||||
}
|
||||
|
||||
/// The character after `offset`, or the end.
|
||||
pub fn next(&self, offset: usize) -> usize {
|
||||
self.text[offset..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(offset, |ch| offset + ch.len_utf8())
|
||||
}
|
||||
|
||||
/// The character before `offset`, or the start.
|
||||
pub fn previous(&self, offset: usize) -> usize {
|
||||
self.text[..offset]
|
||||
.chars()
|
||||
.next_back()
|
||||
.map_or(offset, |ch| offset - ch.len_utf8())
|
||||
}
|
||||
|
||||
/// How far a line is indented, in characters.
|
||||
pub fn indent(&self, line: usize) -> usize {
|
||||
self.line(line)
|
||||
.chars()
|
||||
.take_while(|ch| *ch == ' ' || *ch == '\t')
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(crate) fn replace(&mut self, range: Range<usize>, text: &str) {
|
||||
assert!(
|
||||
range.start <= range.end
|
||||
&& range.end <= self.text.len()
|
||||
&& self.text.is_char_boundary(range.start)
|
||||
&& self.text.is_char_boundary(range.end),
|
||||
"an edit covers whole characters inside the text: {range:?} of {}",
|
||||
self.text.len()
|
||||
);
|
||||
self.text.replace_range(range, text);
|
||||
self.starts = starts(&self.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of character a word motion sees.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Class {
|
||||
Space,
|
||||
Word,
|
||||
Mark,
|
||||
}
|
||||
|
||||
fn class(ch: char) -> Class {
|
||||
if ch.is_alphanumeric() || ch == '_' {
|
||||
Class::Word
|
||||
} else if ch.is_whitespace() {
|
||||
Class::Space
|
||||
} else {
|
||||
Class::Mark
|
||||
}
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
/// The start of the word before `offset`, over any spaces first; a line start stops it.
|
||||
pub fn word_start(&self, offset: usize) -> usize {
|
||||
let line_start = self.starts[self.line_of(offset)];
|
||||
if offset == line_start {
|
||||
return self.previous(offset);
|
||||
}
|
||||
let mut chars = self.text[line_start..offset]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.peekable();
|
||||
while chars
|
||||
.next_if(|(_, ch)| class(*ch) == Class::Space)
|
||||
.is_some()
|
||||
{}
|
||||
let Some(&(_, first)) = chars.peek() else {
|
||||
return line_start;
|
||||
};
|
||||
let kind = class(first);
|
||||
let mut at = offset;
|
||||
for (ix, ch) in chars {
|
||||
if class(ch) != kind {
|
||||
break;
|
||||
}
|
||||
at = line_start + ix;
|
||||
}
|
||||
at.min(offset)
|
||||
}
|
||||
|
||||
/// The end of the word after `offset`, over any spaces first; a line end stops it.
|
||||
pub fn word_end(&self, offset: usize) -> usize {
|
||||
let line_end = self.line_range(self.line_of(offset)).end;
|
||||
if offset == line_end {
|
||||
return self.next(offset);
|
||||
}
|
||||
let mut chars = self.text[offset..line_end].char_indices().peekable();
|
||||
while chars
|
||||
.next_if(|(_, ch)| class(*ch) == Class::Space)
|
||||
.is_some()
|
||||
{}
|
||||
let Some(&(_, first)) = chars.peek() else {
|
||||
return line_end;
|
||||
};
|
||||
let kind = class(first);
|
||||
let mut at = line_end;
|
||||
for (ix, ch) in chars {
|
||||
if class(ch) != kind {
|
||||
at = offset + ix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The word around `offset`, or the run of marks or spaces it sits in.
|
||||
pub fn word_at(&self, offset: usize) -> Range<usize> {
|
||||
let range = self.line_range(self.line_of(offset));
|
||||
let line = &self.text[range.clone()];
|
||||
let at = offset - range.start;
|
||||
let kind = line[at..]
|
||||
.chars()
|
||||
.next()
|
||||
.or_else(|| line[..at].chars().next_back())
|
||||
.map(class);
|
||||
let Some(kind) = kind else {
|
||||
return offset..offset;
|
||||
};
|
||||
let start = line[..at]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.take_while(|(_, ch)| class(*ch) == kind)
|
||||
.last()
|
||||
.map_or(at, |(ix, _)| ix);
|
||||
let end = line[at..]
|
||||
.char_indices()
|
||||
.find(|(_, ch)| class(*ch) != kind)
|
||||
.map_or(line.len(), |(ix, _)| at + ix);
|
||||
range.start + start..range.start + end
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_split_at_breaks_and_points_count_characters() {
|
||||
let buffer = Buffer::new("fn main() {\n let π = 3.14;\n}");
|
||||
assert_eq!(buffer.lines(), 3);
|
||||
assert_eq!(buffer.line(1), " let π = 3.14;");
|
||||
let pi = buffer.text().find('π').expect("pi");
|
||||
assert_eq!(buffer.point(pi), (1, 8));
|
||||
assert_eq!(
|
||||
buffer.point(pi + 'π'.len_utf8()),
|
||||
(1, 9),
|
||||
"a column is a character"
|
||||
);
|
||||
assert_eq!(buffer.offset(1, 9), pi + 'π'.len_utf8());
|
||||
assert_eq!(buffer.offset(0, 99), 11, "held to the line's end");
|
||||
assert_eq!(Buffer::new("").lines(), 1);
|
||||
assert_eq!(buffer.indent(1), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_keep_line_starts_in_step() {
|
||||
let mut buffer = Buffer::new("a\nb");
|
||||
buffer.replace(1..1, "\nc");
|
||||
assert_eq!(buffer.text(), "a\nc\nb");
|
||||
assert_eq!(buffer.lines(), 3);
|
||||
assert_eq!(buffer.line(2), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_stop_at_their_class_and_at_line_edges() {
|
||||
let buffer = Buffer::new("let total = price * 2;\nnext");
|
||||
assert_eq!(buffer.word_start(9), 4, "back to the start of total");
|
||||
assert_eq!(buffer.word_start(4), 0, "over the space to let");
|
||||
assert_eq!(buffer.word_end(4), 9);
|
||||
assert_eq!(buffer.word_end(9), 11, "over the space, then the mark");
|
||||
assert_eq!(buffer.word_end(22), 23, "a line end steps to the next line");
|
||||
assert_eq!(buffer.word_at(6), 4..9);
|
||||
assert_eq!(buffer.word_at(10), 10..11, "a mark is its own run");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Div, ElementId, Entity, IntoElement, ParentElement, Refineable, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Input, TextInput},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnAsk = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A prompt between lines of code: ask for a change, send it, or close it. A working state shows while an answer comes.
|
||||
#[derive(IntoElement)]
|
||||
pub struct InlineChat {
|
||||
base: Div,
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
working: bool,
|
||||
on_ask: Option<OnAsk>,
|
||||
on_close: Option<OnClose>,
|
||||
}
|
||||
|
||||
impl InlineChat {
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
base: div(),
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
working: false,
|
||||
on_ask: None,
|
||||
on_close: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An answer is on its way.
|
||||
pub fn working(mut self, working: bool) -> Self {
|
||||
self.working = working;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ask(
|
||||
mut self,
|
||||
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_ask = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_close = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for InlineChat {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for InlineChat {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let asked = self.field.read(cx).text().trim().to_string();
|
||||
let (field, ask) = (self.field.clone(), self.on_ask.clone());
|
||||
let send = move |window: &mut Window, cx: &mut App| {
|
||||
let prompt = field.read(cx).text().trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("inline chat: asked {prompt:?}");
|
||||
if let Some(ask) = &ask {
|
||||
ask(prompt.into(), window, cx);
|
||||
}
|
||||
};
|
||||
let mut chat = div();
|
||||
chat.style().refine(self.base.style());
|
||||
chat.flex()
|
||||
.flex_col()
|
||||
.justify_center()
|
||||
.gap_1p5()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border_strong)
|
||||
.bg(colors.surface)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::Sparkles)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.accent),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&self.field).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
Button::new(
|
||||
(self.id.clone(), "send"),
|
||||
if self.working { "Working" } else { "Send" },
|
||||
)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.loading(self.working)
|
||||
.disabled(asked.is_empty() || self.working)
|
||||
.on_click(move |_, window, cx| send(window, cx)),
|
||||
)
|
||||
.children(self.on_close.map(|close| {
|
||||
IconButton::new((self.id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close")
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Enter sends · Escape closes"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
forms::{Choice, Enter, Input, Select, TextInput},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// The color a log line reads in, from the level it opens with.
|
||||
pub(crate) fn tone(line: &str, colors: &Palette) -> Hsla {
|
||||
let lower = line.trim_start().to_ascii_lowercase();
|
||||
if lower.starts_with("error") || lower.starts_with("[error") {
|
||||
colors.danger
|
||||
} else if lower.starts_with("warn") || lower.starts_with("[warn") {
|
||||
colors.warning
|
||||
} else if lower.starts_with("debug") || lower.starts_with("[debug") {
|
||||
colors.fg_subtle
|
||||
} else {
|
||||
colors.fg_muted
|
||||
}
|
||||
}
|
||||
|
||||
type OnText = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
type OnRun = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A tool's log by channel: pick the channel, clear it, and keep the newest line in view or let it stay put.
|
||||
#[derive(IntoElement)]
|
||||
pub struct OutputPanel {
|
||||
id: ElementId,
|
||||
channels: Vec<SharedString>,
|
||||
channel: SharedString,
|
||||
lines: Vec<SharedString>,
|
||||
follow: bool,
|
||||
on_channel: Option<OnText>,
|
||||
on_clear: Option<OnRun>,
|
||||
on_follow: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl OutputPanel {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
channels: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||
channel: impl Into<SharedString>,
|
||||
lines: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||
) -> Self {
|
||||
let channels: Vec<SharedString> = channels.into_iter().map(Into::into).collect();
|
||||
let channel = channel.into();
|
||||
assert!(
|
||||
channels.contains(&channel),
|
||||
"the shown channel is one of the channels"
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
channels,
|
||||
channel,
|
||||
lines: lines.into_iter().map(Into::into).collect(),
|
||||
follow: true,
|
||||
on_channel: None,
|
||||
on_clear: None,
|
||||
on_follow: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the newest line stays in view.
|
||||
pub fn follow(mut self, follow: bool) -> Self {
|
||||
self.follow = follow;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_channel(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_channel = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_clear = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_follow(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_follow = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for OutputPanel {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let scroll = window.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| {
|
||||
gpui::ScrollHandle::new()
|
||||
});
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let scroll = scroll.read(cx).clone();
|
||||
if self.follow {
|
||||
scroll.scroll_to_bottom();
|
||||
}
|
||||
let channel = Select::new(
|
||||
(self.id.clone(), "channel"),
|
||||
self.channels
|
||||
.iter()
|
||||
.map(|name| Choice::new(name.clone(), name.clone())),
|
||||
)
|
||||
.selected(self.channel.clone())
|
||||
.size(ControlSize::Sm);
|
||||
let channel = match self.on_channel {
|
||||
Some(on_channel) => {
|
||||
channel.on_change(move |name, window, cx| on_channel(name, window, cx))
|
||||
}
|
||||
None => channel,
|
||||
};
|
||||
let follow = ToggleButton::new(
|
||||
(self.id.clone(), "follow"),
|
||||
ToggleItem::new("follow")
|
||||
.icon(IconName::ArrowDownWideNarrow)
|
||||
.tooltip("Keep the newest line in view"),
|
||||
self.follow,
|
||||
)
|
||||
.size(ControlSize::Sm);
|
||||
let follow = match self.on_follow {
|
||||
Some(on_follow) => follow.on_toggle(move |on, window, cx| on_follow(on, window, cx)),
|
||||
None => follow,
|
||||
};
|
||||
let lines = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| div().text_color(tone(line, &colors)).child(line.clone()));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().w(theme.label_width() * 1.5).child(channel))
|
||||
.child(div().flex_1())
|
||||
.child(follow)
|
||||
.children(self.on_clear.map(|clear| {
|
||||
IconButton::new((self.id.clone(), "clear"), IconName::Eraser)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Clear")
|
||||
.on_click(move |_, window, cx| clear(window, cx))
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id((self.id.clone(), "lines"))
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&scroll)
|
||||
.p_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.children(lines),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a debug console shows: what was typed, what came back, a value with its type, or an error.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConsoleEntry {
|
||||
Input(SharedString),
|
||||
Output(SharedString),
|
||||
Value(SharedString, SharedString),
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// A read-eval-print prompt beside a paused program: entries above, the prompt below; Enter evaluates.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DebugConsole {
|
||||
id: ElementId,
|
||||
entries: Vec<ConsoleEntry>,
|
||||
field: Entity<TextInput>,
|
||||
on_eval: Option<OnText>,
|
||||
}
|
||||
|
||||
impl DebugConsole {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
entries: impl IntoIterator<Item = ConsoleEntry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
entries: entries.into_iter().collect(),
|
||||
field: field.clone(),
|
||||
on_eval: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_eval(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_eval = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for DebugConsole {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let syntax = colors.syntax;
|
||||
let prompt = || {
|
||||
div()
|
||||
.flex_none()
|
||||
.w(theme.icon_size(IconSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("›")
|
||||
};
|
||||
let entries = self.entries.iter().map(|entry| {
|
||||
let row = div().flex().items_start().gap_1p5();
|
||||
match entry {
|
||||
ConsoleEntry::Input(text) => row
|
||||
.child(prompt())
|
||||
.child(div().text_color(colors.fg).child(text.clone())),
|
||||
ConsoleEntry::Output(text) => row
|
||||
.pl(theme.icon_size(IconSize::Sm))
|
||||
.child(div().text_color(colors.fg_muted).child(text.clone())),
|
||||
ConsoleEntry::Value(text, kind) => {
|
||||
let ink = match kind.as_ref() {
|
||||
"string" | "&str" | "String" => syntax.string,
|
||||
"bool" => syntax.constant,
|
||||
_ if kind.starts_with(['i', 'u', 'f']) => syntax.number,
|
||||
_ => syntax.variable,
|
||||
};
|
||||
row.pl(theme.icon_size(IconSize::Sm))
|
||||
.child(div().text_color(ink).child(text.clone()))
|
||||
.child(div().text_color(colors.fg_subtle).child(kind.clone()))
|
||||
}
|
||||
ConsoleEntry::Error(text) => row
|
||||
.child(
|
||||
Icon::new(IconName::CircleX)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.danger),
|
||||
)
|
||||
.child(div().text_color(colors.danger).child(text.clone())),
|
||||
}
|
||||
});
|
||||
let (field, eval) = (self.field.clone(), self.on_eval);
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.children(entries)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
let text = field.read(cx).text().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("debug console: {text}");
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
if let Some(eval) = &eval {
|
||||
eval(&text.into(), window, cx);
|
||||
}
|
||||
})
|
||||
.child(prompt())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&self.field).size(ControlSize::Sm)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_take_their_levels_color() {
|
||||
let colors = Palette::light(false);
|
||||
assert_eq!(tone("ERROR: build failed", &colors), colors.danger);
|
||||
assert_eq!(tone(" warning: unused import", &colors), colors.warning);
|
||||
assert_eq!(tone("[debug] tick", &colors), colors.fg_subtle);
|
||||
assert_eq!(tone("Compiling ely v0.1.0", &colors), colors.fg_muted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use super::buffer::Buffer;
|
||||
|
||||
/// A cursor and the text it holds: `head` moves, `anchor` stays; `goal` keeps the column a vertical move aims for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
pub anchor: usize,
|
||||
pub head: usize,
|
||||
pub goal: Option<usize>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn caret(at: usize) -> Self {
|
||||
Self {
|
||||
anchor: at,
|
||||
head: at,
|
||||
goal: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Range<usize> {
|
||||
self.anchor.min(self.head)..self.anchor.max(self.head)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.anchor == self.head
|
||||
}
|
||||
|
||||
/// Moves the head, keeping the anchor when `extend`, or collapsing onto the head.
|
||||
fn to(self, head: usize, extend: bool) -> Self {
|
||||
Self {
|
||||
anchor: if extend { self.anchor } else { head },
|
||||
head,
|
||||
goal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted by where they start, with overlapping selections joined into one.
|
||||
pub(crate) fn merged(mut all: Vec<Selection>) -> Vec<Selection> {
|
||||
assert!(!all.is_empty(), "an editor keeps at least one cursor");
|
||||
all.sort_by_key(|selection| selection.range().start);
|
||||
let mut out: Vec<Selection> = Vec::with_capacity(all.len());
|
||||
for next in all {
|
||||
match out.last_mut() {
|
||||
Some(last)
|
||||
if next.range().start < last.range().end
|
||||
|| (next.range() == last.range() && next.is_empty()) =>
|
||||
{
|
||||
let range = last.range().start..last.range().end.max(next.range().end);
|
||||
let forward = last.head >= last.anchor;
|
||||
*last = Selection {
|
||||
anchor: if forward { range.start } else { range.end },
|
||||
head: if forward { range.end } else { range.start },
|
||||
goal: None,
|
||||
};
|
||||
}
|
||||
_ => out.push(next),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a cursor goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Motion {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
WordLeft,
|
||||
WordRight,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
Start,
|
||||
End,
|
||||
/// Down or up a page of this many lines.
|
||||
Page(isize),
|
||||
}
|
||||
|
||||
/// A selection after a motion. Left and Right close a selection to its edge first.
|
||||
pub(crate) fn moved(
|
||||
buffer: &Buffer,
|
||||
selection: Selection,
|
||||
motion: Motion,
|
||||
extend: bool,
|
||||
) -> Selection {
|
||||
let head = selection.head;
|
||||
if !extend && !selection.is_empty() {
|
||||
match motion {
|
||||
Motion::Left => return Selection::caret(selection.range().start),
|
||||
Motion::Right => return Selection::caret(selection.range().end),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let vertical = |lines: isize| {
|
||||
let (line, column) = buffer.point(head);
|
||||
let goal = selection.goal.unwrap_or(column);
|
||||
let target = line as isize + lines;
|
||||
let offset = if target < 0 {
|
||||
0
|
||||
} else if target >= buffer.lines() as isize {
|
||||
buffer.len()
|
||||
} else {
|
||||
buffer.offset(target as usize, goal)
|
||||
};
|
||||
Selection {
|
||||
goal: Some(goal),
|
||||
..selection.to(offset, extend)
|
||||
}
|
||||
};
|
||||
match motion {
|
||||
Motion::Left => selection.to(buffer.previous(head), extend),
|
||||
Motion::Right => selection.to(buffer.next(head), extend),
|
||||
Motion::Up => vertical(-1),
|
||||
Motion::Down => vertical(1),
|
||||
Motion::Page(lines) => vertical(lines),
|
||||
Motion::WordLeft => selection.to(buffer.word_start(head), extend),
|
||||
Motion::WordRight => selection.to(buffer.word_end(head), extend),
|
||||
Motion::LineStart => {
|
||||
let line = buffer.line_of(head);
|
||||
let start = buffer.line_range(line).start;
|
||||
let text = buffer.line_range(line).start + buffer.indent(line);
|
||||
selection.to(if head == text { start } else { text }, extend)
|
||||
}
|
||||
Motion::LineEnd => selection.to(buffer.line_range(buffer.line_of(head)).end, extend),
|
||||
Motion::Start => selection.to(0, extend),
|
||||
Motion::End => selection.to(buffer.len(), extend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces each selection's range with its text, all at once, and returns a caret after each insert.
|
||||
pub(crate) fn replace_each(
|
||||
buffer: &mut Buffer,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
) -> Vec<Selection> {
|
||||
let mut order: Vec<usize> = (0..edits.len()).collect();
|
||||
order.sort_by_key(|ix| edits[*ix].0.start);
|
||||
assert!(
|
||||
order
|
||||
.windows(2)
|
||||
.all(|pair| edits[pair[0]].0.end <= edits[pair[1]].0.start),
|
||||
"edits do not overlap"
|
||||
);
|
||||
let mut carets = vec![Selection::caret(0); edits.len()];
|
||||
let mut shift: isize = 0;
|
||||
for ix in &order {
|
||||
let (range, text) = &edits[*ix];
|
||||
let start = (range.start as isize + shift) as usize;
|
||||
carets[*ix] = Selection::caret(start + text.len());
|
||||
shift += text.len() as isize - range.len() as isize;
|
||||
}
|
||||
for ix in order.iter().rev() {
|
||||
let (range, text) = &edits[*ix];
|
||||
buffer.replace(range.clone(), text);
|
||||
}
|
||||
carets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overlapping_selections_join_and_carets_on_one_spot_become_one() {
|
||||
let joined = merged(vec![
|
||||
Selection {
|
||||
anchor: 4,
|
||||
head: 8,
|
||||
goal: None,
|
||||
},
|
||||
Selection {
|
||||
anchor: 6,
|
||||
head: 10,
|
||||
goal: None,
|
||||
},
|
||||
Selection::caret(12),
|
||||
Selection::caret(12),
|
||||
]);
|
||||
assert_eq!(joined.len(), 2);
|
||||
assert_eq!(joined[0].range(), 4..10);
|
||||
assert_eq!(joined[1], Selection::caret(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_moves_keep_their_column_through_short_lines() {
|
||||
let buffer = Buffer::new("abcdef\nab\nabcdef");
|
||||
let start = Selection::caret(5);
|
||||
let down = moved(&buffer, start, Motion::Down, false);
|
||||
assert_eq!(
|
||||
buffer.point(down.head),
|
||||
(1, 2),
|
||||
"held to the short line's end"
|
||||
);
|
||||
let again = moved(&buffer, down, Motion::Down, false);
|
||||
assert_eq!(
|
||||
buffer.point(again.head),
|
||||
(2, 5),
|
||||
"back to the column it aimed for"
|
||||
);
|
||||
let selected = moved(&buffer, start, Motion::End, true);
|
||||
assert_eq!(selected.range(), 5..buffer.len());
|
||||
assert_eq!(
|
||||
moved(&buffer, selected, Motion::Left, false),
|
||||
Selection::caret(5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_toggles_between_the_text_and_the_line_start() {
|
||||
let buffer = Buffer::new(" let x;");
|
||||
let first = moved(&buffer, Selection::caret(9), Motion::LineStart, false);
|
||||
assert_eq!(first.head, 4);
|
||||
assert_eq!(moved(&buffer, first, Motion::LineStart, false).head, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_at_many_cursors_land_where_each_one_was() {
|
||||
let mut buffer = Buffer::new("a1\nb2\nc3");
|
||||
let carets = replace_each(
|
||||
&mut buffer,
|
||||
vec![(7..7, "!".into()), (1..1, "!".into()), (4..4, "!".into())],
|
||||
);
|
||||
assert_eq!(buffer.text(), "a!1\nb!2\nc!3");
|
||||
assert_eq!(
|
||||
carets.iter().map(|caret| caret.head).collect::<Vec<_>>(),
|
||||
[10, 2, 6]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A problem in a span of code: squiggled under it and marked in the gutter.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
pub range: Range<usize>,
|
||||
pub severity: Severity,
|
||||
pub message: SharedString,
|
||||
}
|
||||
|
||||
/// A note set into the code at an offset, such as an inferred type or a parameter name.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct InlayHint {
|
||||
pub offset: usize,
|
||||
pub text: SharedString,
|
||||
}
|
||||
|
||||
/// Actions over a line, such as "3 references" or "Run test", in a quiet row above it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CodeLens {
|
||||
pub line: usize,
|
||||
pub items: Vec<SharedString>,
|
||||
}
|
||||
|
||||
/// A suggestion shown in grey at an offset; its first line continues the line, the rest follow below.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GhostText {
|
||||
pub offset: usize,
|
||||
pub text: SharedString,
|
||||
}
|
||||
|
||||
/// A change against the saved text: lines added from `line` on, and the lines they replaced, shown above them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiffHunk {
|
||||
pub line: usize,
|
||||
pub added: usize,
|
||||
pub removed: Vec<SharedString>,
|
||||
}
|
||||
|
||||
/// A line's state against version control.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GitMark {
|
||||
Added,
|
||||
Modified,
|
||||
/// Lines were removed just below this one.
|
||||
Deleted,
|
||||
}
|
||||
|
||||
type OnUnlock = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A strip above code that cannot be changed here, why, and a way to change it anyway.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReadOnlyBanner {
|
||||
id: ElementId,
|
||||
message: SharedString,
|
||||
action: Option<(SharedString, OnUnlock)>,
|
||||
}
|
||||
|
||||
impl ReadOnlyBanner {
|
||||
pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
message: message.into(),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
handler: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.action = Some((label.into(), Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReadOnlyBanner {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.bg(colors.warning.opacity(0.1))
|
||||
.border_b_1()
|
||||
.border_color(colors.warning.opacity(0.3))
|
||||
.font_family(theme.font_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(
|
||||
Icon::new(IconName::Lock)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.warning),
|
||||
)
|
||||
.child(div().flex_1().child(self.message))
|
||||
.children(self.action.map(|(label, handler)| {
|
||||
Button::new(self.id, label)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| handler(window, cx))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which mode a modal editor is in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Replace,
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
fn word(self) -> &'static str {
|
||||
match self {
|
||||
VimMode::Normal => "NORMAL",
|
||||
VimMode::Insert => "INSERT",
|
||||
VimMode::Visual => "VISUAL",
|
||||
VimMode::Replace => "REPLACE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The modal editing mode as a small tag, tinted by mode, and a pending command after it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VimModeIndicator {
|
||||
mode: VimMode,
|
||||
pending: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl VimModeIndicator {
|
||||
pub fn new(mode: VimMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys typed toward a command, such as `d2`.
|
||||
pub fn pending(mut self, keys: impl Into<SharedString>) -> Self {
|
||||
self.pending = Some(keys.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VimModeIndicator {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let ink = match self.mode {
|
||||
VimMode::Normal => colors.fg_muted,
|
||||
VimMode::Insert => colors.success,
|
||||
VimMode::Visual => colors.warning,
|
||||
VimMode::Replace => colors.danger,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(ink.opacity(0.14))
|
||||
.text_color(ink)
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child(self.mode.word()),
|
||||
)
|
||||
.children(self.pending.map(|keys| {
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg_muted)
|
||||
.child(keys)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{ClipboardItem, Context};
|
||||
|
||||
use super::{
|
||||
buffer::Buffer,
|
||||
cursor::{Motion, Selection, merged, replace_each},
|
||||
state::CodeEditor,
|
||||
};
|
||||
|
||||
/// Pairs typing an opener closes at once.
|
||||
const PAIRS: [(char, char); 5] = [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')];
|
||||
|
||||
impl CodeEditor {
|
||||
/// Replaces each selection with `text`; typing in one burst makes one undo step.
|
||||
pub(crate) fn insert(&mut self, text: &str, typing: bool, cx: &mut Context<Self>) {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), text.to_string()))
|
||||
.collect();
|
||||
self.apply(edits, typing, cx);
|
||||
}
|
||||
|
||||
/// Replaces ranges all at once and leaves a caret after each insert.
|
||||
pub(crate) fn apply(
|
||||
&mut self,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
typing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.editable() {
|
||||
log::debug!("code editor: read only, edit dropped");
|
||||
return;
|
||||
}
|
||||
let before = self.snapshot();
|
||||
let carets = replace_each(&mut self.buffer, joined(edits));
|
||||
self.selections = merged(carets);
|
||||
self.marked = None;
|
||||
self.commit(before, typing, cx);
|
||||
self.reveal(true, cx);
|
||||
}
|
||||
|
||||
/// Types text at each cursor; an opener brings its closer, and a closer already there is stepped over.
|
||||
pub(crate) fn type_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
let mut chars = text.chars();
|
||||
let (Some(ch), None) = (chars.next(), chars.next()) else {
|
||||
return self.insert(text, true, cx);
|
||||
};
|
||||
let next = |caret: usize| self.buffer.text()[caret..].chars().next();
|
||||
let all_empty = self.selections.iter().all(Selection::is_empty);
|
||||
if all_empty
|
||||
&& PAIRS.iter().any(|(_, close)| *close == ch)
|
||||
&& self
|
||||
.selections
|
||||
.iter()
|
||||
.all(|selection| next(selection.head) == Some(ch))
|
||||
{
|
||||
return self.motion(Motion::Right, false, cx);
|
||||
}
|
||||
let Some((open, close)) = PAIRS.iter().find(|(open, _)| *open == ch) else {
|
||||
return self.insert(text, true, cx);
|
||||
};
|
||||
let free = |caret: usize| {
|
||||
next(caret).is_none_or(|after| after.is_whitespace() || ")]}".contains(after))
|
||||
};
|
||||
if !all_empty || !self.selections.iter().all(|selection| free(selection.head)) {
|
||||
return self.insert(text, true, cx);
|
||||
}
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), format!("{open}{close}")))
|
||||
.collect();
|
||||
self.apply(edits, true, cx);
|
||||
let back: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|caret| Selection::caret(caret.head - close.len_utf8()))
|
||||
.collect();
|
||||
self.set_selections(back, cx);
|
||||
}
|
||||
|
||||
/// Deletes each selection, or what `reach` finds from each bare caret.
|
||||
pub(crate) fn delete_by(
|
||||
&mut self,
|
||||
reach: fn(&Buffer, usize) -> Range<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
reach(&self.buffer, selection.head)
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
(range, String::new())
|
||||
})
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
/// A new line at each cursor, indented like its own; one step deeper after an opener, with the closer on a line of its own.
|
||||
pub(crate) fn newline(&mut self, cx: &mut Context<Self>) {
|
||||
let tab = self.options.tab;
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let at = selection.range().start;
|
||||
let line = self.buffer.line_of(at);
|
||||
let indent: String = self
|
||||
.buffer
|
||||
.line(line)
|
||||
.chars()
|
||||
.take(self.buffer.indent(line))
|
||||
.collect();
|
||||
let before = self.buffer.text()[..at].trim_end().chars().next_back();
|
||||
let after = self.buffer.text()[selection.range().end..].chars().next();
|
||||
let opens = before.is_some_and(|ch| "{[(:".contains(ch));
|
||||
let text = match (opens, after) {
|
||||
(true, Some(close)) if "}])".contains(close) => {
|
||||
format!("\n{indent}{}\n{indent}", " ".repeat(tab))
|
||||
}
|
||||
(true, _) => format!("\n{indent}{}", " ".repeat(tab)),
|
||||
_ => format!("\n{indent}"),
|
||||
};
|
||||
(selection.range(), text)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let closers: Vec<bool> = edits
|
||||
.iter()
|
||||
.map(|(_, text)| text.matches('\n').count() == 2)
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
let placed: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.zip(closers)
|
||||
.map(|(caret, split)| {
|
||||
if split {
|
||||
let line = self.buffer.line_of(caret.head);
|
||||
Selection::caret(self.buffer.line_range(line - 1).end)
|
||||
} else {
|
||||
*caret
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.set_selections(placed, cx);
|
||||
}
|
||||
|
||||
/// The lines the selections touch, each once.
|
||||
fn touched(&self) -> Vec<usize> {
|
||||
let mut lines: Vec<usize> = self
|
||||
.selections
|
||||
.iter()
|
||||
.flat_map(|selection| {
|
||||
let range = selection.range();
|
||||
let last = if range.end > range.start && self.buffer.point(range.end).1 == 0 {
|
||||
range.end - 1
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
self.buffer.line_of(range.start)..=self.buffer.line_of(last)
|
||||
})
|
||||
.collect();
|
||||
lines.dedup();
|
||||
lines
|
||||
}
|
||||
|
||||
/// Tab: takes ghost text when there is some, indents touched lines when a selection spans lines, or fills to the next tab stop.
|
||||
pub(crate) fn indent(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(ghost) = self.marks.ghost.take() {
|
||||
log::info!("code editor: ghost text taken");
|
||||
self.set_selections(vec![Selection::caret(ghost.offset)], cx);
|
||||
return self.insert(&ghost.text, false, cx);
|
||||
}
|
||||
let tab = self.options.tab;
|
||||
let spans_lines = self.selections.iter().any(|selection| {
|
||||
self.buffer.line_of(selection.range().start)
|
||||
!= self.buffer.line_of(selection.range().end)
|
||||
});
|
||||
if !spans_lines {
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let column = self.buffer.point(selection.range().start).1;
|
||||
(selection.range(), " ".repeat(tab - column % tab))
|
||||
})
|
||||
.collect();
|
||||
return self.apply(edits, false, cx);
|
||||
}
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter(|line| !self.buffer.line(*line).trim().is_empty())
|
||||
.map(|line| {
|
||||
let start = self.buffer.line_range(line).start;
|
||||
(start..start, " ".repeat(tab))
|
||||
})
|
||||
.collect();
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
/// Shift-Tab: takes one tab stop of indent off each touched line.
|
||||
pub(crate) fn outdent(&mut self, cx: &mut Context<Self>) {
|
||||
let tab = self.options.tab;
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter_map(|line| {
|
||||
let start = self.buffer.line_range(line).start;
|
||||
let cut = self.buffer.indent(line).min(tab);
|
||||
(cut > 0).then(|| (start..start + cut, String::new()))
|
||||
})
|
||||
.collect();
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
/// Applies line-start edits and keeps each selection over the same text.
|
||||
fn shift_lines(
|
||||
&mut self,
|
||||
edits: Vec<(Range<usize>, String)>,
|
||||
kept: Vec<Selection>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let moves: Vec<(std::ops::Range<usize>, isize)> = edits
|
||||
.iter()
|
||||
.map(|(range, text)| (range.clone(), text.len() as isize - range.len() as isize))
|
||||
.collect();
|
||||
let shift = |offset: usize| -> usize {
|
||||
let mut moved = offset as isize;
|
||||
for (range, delta) in &moves {
|
||||
if range.start < offset {
|
||||
if offset < range.end {
|
||||
moved -= (offset - range.start) as isize;
|
||||
} else {
|
||||
moved += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
moved.max(0) as usize
|
||||
};
|
||||
let kept: Vec<Selection> = kept
|
||||
.into_iter()
|
||||
.map(|selection| Selection {
|
||||
anchor: shift(selection.anchor),
|
||||
head: shift(selection.head),
|
||||
goal: None,
|
||||
})
|
||||
.collect();
|
||||
self.apply(edits, false, cx);
|
||||
self.set_selections(kept, cx);
|
||||
}
|
||||
|
||||
/// Comments the touched lines with `//`, or takes the comment off when every one has it.
|
||||
pub(crate) fn toggle_comment(&mut self, cx: &mut Context<Self>) {
|
||||
let lines: Vec<usize> = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
.filter(|line| !self.buffer.line(*line).trim().is_empty())
|
||||
.collect();
|
||||
let commented = |line: usize| self.buffer.line(line).trim_start().starts_with("//");
|
||||
let kept = self.selections.clone();
|
||||
let edits = if !lines.is_empty() && lines.iter().all(|line| commented(*line)) {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let start = self.buffer.line_range(*line).start + self.buffer.indent(*line);
|
||||
let text = &self.buffer.text()[start..];
|
||||
let cut = if text.starts_with("// ") { 3 } else { 2 };
|
||||
(start..start + cut, String::new())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let column = lines
|
||||
.iter()
|
||||
.map(|line| self.buffer.indent(*line))
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let at = self.buffer.offset(*line, column);
|
||||
(at..at, "// ".to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
self.shift_lines(edits, kept, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each selection's text, a line apiece; the whole line for a bare caret.
|
||||
fn copied(&self) -> Vec<(Range<usize>, String)> {
|
||||
self.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
let line = self.buffer.line_of(selection.head);
|
||||
let range = self.buffer.line_range(line);
|
||||
range.start..self.buffer.next(range.end)
|
||||
} else {
|
||||
selection.range()
|
||||
};
|
||||
(range.clone(), self.buffer.text()[range].to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn copy(&mut self, cx: &mut Context<Self>) {
|
||||
let text: Vec<String> = self.copied().into_iter().map(|(_, text)| text).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
}
|
||||
|
||||
pub(crate) fn cut(&mut self, cx: &mut Context<Self>) {
|
||||
let copied = self.copied();
|
||||
let text: Vec<String> = copied.iter().map(|(_, text)| text.clone()).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
self.apply(
|
||||
copied
|
||||
.into_iter()
|
||||
.map(|(range, _)| (range, String::new()))
|
||||
.collect(),
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pastes one line at each cursor when the counts match, else the whole text at every one.
|
||||
pub(crate) fn paste(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let edits = if lines.len() == self.selections.len() && lines.len() > 1 {
|
||||
self.selections
|
||||
.iter()
|
||||
.zip(lines)
|
||||
.map(|(selection, line)| (selection.range(), line.to_string()))
|
||||
.collect()
|
||||
} else {
|
||||
self.selections
|
||||
.iter()
|
||||
.map(|selection| (selection.range(), text.clone()))
|
||||
.collect()
|
||||
};
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits sorted, with overlapping ranges joined; the first text of a joined run is kept.
|
||||
fn joined(mut edits: Vec<(Range<usize>, String)>) -> Vec<(Range<usize>, String)> {
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
let mut out: Vec<(Range<usize>, String)> = Vec::with_capacity(edits.len());
|
||||
for (range, text) in edits {
|
||||
match out.last_mut() {
|
||||
Some((last, _)) if range.start < last.end => last.end = last.end.max(range.end),
|
||||
_ => out.push((range, text)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
charts::compact,
|
||||
data_display::Avatar,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// Where an extension stands here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtensionState {
|
||||
Available,
|
||||
Installing,
|
||||
Installed,
|
||||
Disabled,
|
||||
/// Installed, with a newer version out.
|
||||
Outdated,
|
||||
}
|
||||
|
||||
/// An extension in the marketplace: who makes it, what it does, how many use it and how they rate it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Extension {
|
||||
pub id: SharedString,
|
||||
pub name: SharedString,
|
||||
pub publisher: SharedString,
|
||||
pub description: SharedString,
|
||||
pub version: SharedString,
|
||||
pub installs: u64,
|
||||
pub rating: f32,
|
||||
pub state: ExtensionState,
|
||||
}
|
||||
|
||||
/// What a press on an extension's button asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtensionAction {
|
||||
Install,
|
||||
Uninstall,
|
||||
Enable,
|
||||
Update,
|
||||
}
|
||||
|
||||
type OnAction = Rc<dyn Fn(&SharedString, ExtensionAction, &mut Window, &mut App)>;
|
||||
|
||||
/// Extensions, each with its mark, name, maker, what it does, its reach and rating, and the one action it needs next.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ExtensionsPanel {
|
||||
id: ElementId,
|
||||
extensions: Vec<Extension>,
|
||||
on_action: Option<OnAction>,
|
||||
}
|
||||
|
||||
impl ExtensionsPanel {
|
||||
pub fn new(id: impl Into<ElementId>, extensions: impl IntoIterator<Item = Extension>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
extensions: extensions.into_iter().collect(),
|
||||
on_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_action(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, ExtensionAction, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_action = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ExtensionsPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let rows = self.extensions.iter().enumerate().map(|(ix, extension)| {
|
||||
let action = match extension.state {
|
||||
ExtensionState::Available => {
|
||||
Some((ExtensionAction::Install, "Install", ButtonVariant::Primary))
|
||||
}
|
||||
ExtensionState::Installing => None,
|
||||
ExtensionState::Installed => Some((
|
||||
ExtensionAction::Uninstall,
|
||||
"Uninstall",
|
||||
ButtonVariant::Ghost,
|
||||
)),
|
||||
ExtensionState::Disabled => {
|
||||
Some((ExtensionAction::Enable, "Enable", ButtonVariant::Secondary))
|
||||
}
|
||||
ExtensionState::Outdated => {
|
||||
Some((ExtensionAction::Update, "Update", ButtonVariant::Primary))
|
||||
}
|
||||
};
|
||||
let button = match action {
|
||||
Some((action, label, variant)) => {
|
||||
let (on_action, id) = (self.on_action.clone(), extension.id.clone());
|
||||
Button::new((self.id.clone(), format!("action-{ix}")), label)
|
||||
.variant(variant)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("extensions: {action:?} {id}");
|
||||
if let Some(on_action) = &on_action {
|
||||
on_action(&id, action, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
None => Button::new((self.id.clone(), format!("action-{ix}")), "Installing")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.size(ControlSize::Sm)
|
||||
.loading(true),
|
||||
};
|
||||
let muted = extension.state == ExtensionState::Disabled;
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.px_2()
|
||||
.py_2p5()
|
||||
.border_b_1()
|
||||
.border_color(colors.border.opacity(0.6))
|
||||
.when(muted, |row| row.opacity(0.6))
|
||||
.child(
|
||||
Avatar::new(
|
||||
(self.id.clone(), format!("avatar-{ix}")),
|
||||
extension.name.clone(),
|
||||
)
|
||||
.size(AvatarSize::Lg),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(extension.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("v{}", extension.version)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(Ellipsis::new(extension.description.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(extension.publisher.clone())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::Download)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(compact(extension.installs as f64)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::Star)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.warning),
|
||||
)
|
||||
.child(format!("{:.1}", extension.rating)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(button)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
|
||||
use crate::{
|
||||
buttons::{ToggleButton, ToggleItem},
|
||||
forms::{Input, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
/// Whether a path passes comma-separated globs: some include pattern when there are any, and no exclude one.
|
||||
pub fn passes(path: &str, include: &str, exclude: &str) -> bool {
|
||||
let patterns = |list: &str| -> Vec<regex::Regex> {
|
||||
list.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|glob| !glob.is_empty())
|
||||
.map(|glob| {
|
||||
let mut pattern = String::from("(^|/)");
|
||||
let mut chars = glob.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'*' if chars.peek() == Some(&'*') => {
|
||||
chars.next();
|
||||
pattern.push_str(".*");
|
||||
}
|
||||
'*' => pattern.push_str("[^/]*"),
|
||||
'?' => pattern.push_str("[^/]"),
|
||||
other => pattern.push_str(®ex::escape(&other.to_string())),
|
||||
}
|
||||
}
|
||||
pattern.push('$');
|
||||
regex::Regex::new(&pattern).expect("an escaped glob compiles")
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (include, exclude) = (patterns(include), patterns(exclude));
|
||||
(include.is_empty() || include.iter().any(|glob| glob.is_match(path)))
|
||||
&& !exclude.iter().any(|glob| glob.is_match(path))
|
||||
}
|
||||
|
||||
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Which files a search reads: globs to include and to exclude, and whether ignored files stay out.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchFilters {
|
||||
id: ElementId,
|
||||
include: Entity<TextInput>,
|
||||
exclude: Entity<TextInput>,
|
||||
ignore: bool,
|
||||
on_ignore: Option<OnBool>,
|
||||
}
|
||||
|
||||
impl SearchFilters {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
include: &Entity<TextInput>,
|
||||
exclude: &Entity<TextInput>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
include: include.clone(),
|
||||
exclude: exclude.clone(),
|
||||
ignore: true,
|
||||
on_ignore: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether files the project ignores stay out.
|
||||
pub fn ignore(mut self, ignore: bool) -> Self {
|
||||
self.ignore = ignore;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ignore(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_ignore = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchFilters {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let label = |words: &'static str| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(words)
|
||||
};
|
||||
let ignore = ToggleButton::new(
|
||||
(self.id.clone(), "ignore"),
|
||||
ToggleItem::new("ignore")
|
||||
.icon(IconName::EyeOff)
|
||||
.tooltip("Leave out ignored files"),
|
||||
self.ignore,
|
||||
)
|
||||
.size(ControlSize::Sm);
|
||||
let ignore = match self.on_ignore {
|
||||
Some(on_ignore) => ignore.on_toggle(move |on, window, cx| on_ignore(on, window, cx)),
|
||||
None => ignore,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.child(label("Files to include"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&self.include).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(ignore),
|
||||
)
|
||||
.child(label("Files to exclude"))
|
||||
.child(Input::new(&self.exclude).size(ControlSize::Sm))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn globs_take_includes_then_drop_excludes() {
|
||||
assert!(passes("src/editor/view.rs", "src/**/*.rs", ""));
|
||||
assert!(!passes("examples/main.rs", "src/**", ""));
|
||||
assert!(!passes(
|
||||
"src/editor/tests.rs",
|
||||
"*.rs",
|
||||
"tests.rs, target/**"
|
||||
));
|
||||
assert!(passes("README.md", "", "*.rs"));
|
||||
assert!(!passes("src/a.rs", "src/?.md", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
|
||||
div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
forms::{Input, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// How a search matches: case exactly, whole words only, the query as a regular expression.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FindOptions {
|
||||
pub case: bool,
|
||||
pub word: bool,
|
||||
pub regex: bool,
|
||||
}
|
||||
|
||||
/// Where `query` appears in `text` under `options`; a pattern that does not compile is an error to show.
|
||||
pub fn find_all(
|
||||
text: &str,
|
||||
query: &str,
|
||||
options: FindOptions,
|
||||
) -> Result<Vec<Range<usize>>, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let pattern = if options.regex {
|
||||
query.to_string()
|
||||
} else {
|
||||
regex::escape(query)
|
||||
};
|
||||
let pattern = if options.word {
|
||||
format!(r"\b(?:{pattern})\b")
|
||||
} else {
|
||||
pattern
|
||||
};
|
||||
let found = regex::RegexBuilder::new(&pattern)
|
||||
.case_insensitive(!options.case)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(found
|
||||
.find_iter(text)
|
||||
.filter(|hit| !hit.is_empty())
|
||||
.map(|hit| hit.range())
|
||||
.collect())
|
||||
}
|
||||
|
||||
type OnOptions = Rc<dyn Fn(FindOptions, &mut Window, &mut App)>;
|
||||
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A find box over code: the query with its options, where the current match stands among all, steps between them, and a replace row that folds away.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FindWidget {
|
||||
id: ElementId,
|
||||
find: Entity<TextInput>,
|
||||
replace: Option<Entity<TextInput>>,
|
||||
options: FindOptions,
|
||||
matches: Result<(Option<usize>, usize), SharedString>,
|
||||
on_options: Option<OnOptions>,
|
||||
on_step: Option<OnBool>,
|
||||
on_replace: Option<OnBool>,
|
||||
on_toggle_replace: Option<OnBool>,
|
||||
on_close: Option<OnClose>,
|
||||
}
|
||||
|
||||
impl FindWidget {
|
||||
/// `find` holds the query; `current` is the chosen match among `total`.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
find: &Entity<TextInput>,
|
||||
current: Option<usize>,
|
||||
total: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
find: find.clone(),
|
||||
replace: None,
|
||||
options: FindOptions::default(),
|
||||
matches: Ok((current, total)),
|
||||
on_options: None,
|
||||
on_step: None,
|
||||
on_replace: None,
|
||||
on_toggle_replace: None,
|
||||
on_close: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The query failed to compile; its message shows in place of a count.
|
||||
pub fn error(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.matches = Err(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn options(mut self, options: FindOptions) -> Self {
|
||||
self.options = options;
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the replace row, its text in `field`.
|
||||
pub fn replace(mut self, field: &Entity<TextInput>) -> Self {
|
||||
self.replace = Some(field.clone());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_options(
|
||||
mut self,
|
||||
handler: impl Fn(FindOptions, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_options = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets `true` for the next match, `false` for the previous.
|
||||
pub fn on_step(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_step = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets `true` to replace every match, `false` for the current one.
|
||||
pub fn on_replace(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_replace = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets whether the replace row should show.
|
||||
pub fn on_toggle_replace(
|
||||
mut self,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle_replace = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_close = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FindWidget {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let options = self.options;
|
||||
let toggle = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
on: bool,
|
||||
set: fn(&mut FindOptions, bool)| {
|
||||
let on_options = self.on_options.clone();
|
||||
ToggleButton::new(
|
||||
(self.id.clone(), key),
|
||||
ToggleItem::new(key).icon(icon).tooltip(words),
|
||||
on,
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |on, window, cx| {
|
||||
let mut next = options;
|
||||
set(&mut next, on);
|
||||
log::info!("find: {next:?}");
|
||||
if let Some(on_options) = &on_options {
|
||||
on_options(next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let (count, missing) = match &self.matches {
|
||||
Ok((_, 0)) => ("No results".to_string(), true),
|
||||
Ok((Some(current), total)) => (format!("{} of {total}", current + 1), false),
|
||||
Ok((None, total)) => (format!("{total} found"), false),
|
||||
Err(message) => (message.to_string(), true),
|
||||
};
|
||||
let found = matches!(self.matches, Ok((_, total)) if total > 0);
|
||||
let icon_button = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
enabled: bool,
|
||||
action: Option<OnBool>,
|
||||
value: bool| {
|
||||
IconButton::new((self.id.clone(), key), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.disabled(!enabled)
|
||||
.when_some(action, move |button, action| {
|
||||
button.on_click(move |_, window, cx| action(value, window, cx))
|
||||
})
|
||||
};
|
||||
let showing = self.replace.is_some();
|
||||
let row = || div().flex().items_center().gap_1();
|
||||
div()
|
||||
.w(theme.label_width() * 2.75)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
row()
|
||||
.child(icon_button(
|
||||
"fold",
|
||||
if showing {
|
||||
IconName::ChevronDown
|
||||
} else {
|
||||
IconName::ChevronRight
|
||||
},
|
||||
"Toggle replace",
|
||||
true,
|
||||
self.on_toggle_replace.clone(),
|
||||
!showing,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Input::new(&self.find).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(toggle(
|
||||
"case",
|
||||
IconName::CaseSensitive,
|
||||
"Match case",
|
||||
options.case,
|
||||
|next, on| next.case = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"word",
|
||||
IconName::WholeWord,
|
||||
"Whole word",
|
||||
options.word,
|
||||
|next, on| next.word = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"regex",
|
||||
IconName::Regex,
|
||||
"Regular expression",
|
||||
options.regex,
|
||||
|next, on| next.regex = on,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 0.5)
|
||||
.text_center()
|
||||
.text_color(if missing {
|
||||
colors.danger
|
||||
} else {
|
||||
colors.fg_muted
|
||||
})
|
||||
.child(count),
|
||||
)
|
||||
.child(icon_button(
|
||||
"previous",
|
||||
IconName::ArrowUp,
|
||||
"Previous match",
|
||||
found,
|
||||
self.on_step.clone(),
|
||||
false,
|
||||
))
|
||||
.child(icon_button(
|
||||
"next",
|
||||
IconName::ArrowDown,
|
||||
"Next match",
|
||||
found,
|
||||
self.on_step.clone(),
|
||||
true,
|
||||
))
|
||||
.children(self.on_close.clone().map(|close| {
|
||||
IconButton::new((self.id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close")
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})),
|
||||
)
|
||||
.children(self.replace.as_ref().map(|field| {
|
||||
let (one, all) = (self.on_replace.clone(), self.on_replace.clone());
|
||||
row()
|
||||
.pl(theme.control_height(ControlSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Input::new(field).size(ControlSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "replace"), "Replace")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(one, |button, one| {
|
||||
button.on_click(move |_, window, cx| one(false, window, cx))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "all"), "Replace all")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!found)
|
||||
.when_some(all, |button, all| {
|
||||
button.on_click(move |_, window, cx| all(true, window, cx))
|
||||
}),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_by_case_word_and_pattern() {
|
||||
let text = "Let total = total_cost + TOTAL;";
|
||||
let plain = FindOptions::default();
|
||||
assert_eq!(
|
||||
find_all(text, "total", plain).map(|found| found.len()),
|
||||
Ok(3)
|
||||
);
|
||||
let case = FindOptions {
|
||||
case: true,
|
||||
..plain
|
||||
};
|
||||
assert_eq!(
|
||||
find_all(text, "total", case).map(|found| found.len()),
|
||||
Ok(2)
|
||||
);
|
||||
let word = FindOptions {
|
||||
word: true,
|
||||
..plain
|
||||
};
|
||||
assert_eq!(find_all(text, "total", word), Ok(vec![4..9, 25..30]));
|
||||
let pattern = FindOptions {
|
||||
regex: true,
|
||||
..plain
|
||||
};
|
||||
let word = 12..22;
|
||||
assert_eq!(find_all(text, r"total_\w+", pattern), Ok(vec![word]));
|
||||
assert!(
|
||||
find_all(text, "(", pattern).is_err(),
|
||||
"a broken pattern says so"
|
||||
);
|
||||
assert_eq!(
|
||||
find_all(text, "(", plain),
|
||||
Ok(Vec::new()),
|
||||
"and plain text is escaped"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
SharedString, Styled, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
decor::GitMark,
|
||||
state::{CodeEditor, LineNumbers},
|
||||
};
|
||||
use crate::{
|
||||
primitives::{Disclosure, Icon, Severity},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
/// What a gutter cell shows beside a row.
|
||||
pub(crate) enum Cell {
|
||||
Line(usize),
|
||||
/// A removed line of a diff.
|
||||
Removed,
|
||||
Blank,
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Columns the gutter spans: a slot for a breakpoint or a problem, the number, a fold chevron and the git bar.
|
||||
pub(crate) fn gutter_columns(&self) -> usize {
|
||||
let digits = self.buffer.lines().to_string().len().max(2);
|
||||
match self.options.numbers {
|
||||
LineNumbers::Hidden => 5,
|
||||
_ => digits + 6,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(&self, line: usize) -> SharedString {
|
||||
let current = self.buffer.line_of(self.primary().head);
|
||||
match self.options.numbers {
|
||||
LineNumbers::Hidden => SharedString::default(),
|
||||
LineNumbers::Relative if line != current => line.abs_diff(current).to_string().into(),
|
||||
_ => (line + 1).to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The worst problem on a line, if any.
|
||||
fn worst(&self, line: usize) -> Option<Severity> {
|
||||
let range = self.buffer.line_range(line);
|
||||
self.marks
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| {
|
||||
diagnostic.range.start <= range.end && range.start <= diagnostic.range.end
|
||||
})
|
||||
.map(|diagnostic| diagnostic.severity)
|
||||
.max_by_key(|severity| match severity {
|
||||
Severity::Danger => 3,
|
||||
Severity::Warning => 2,
|
||||
Severity::Info => 1,
|
||||
Severity::Success => 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_color(&self, line: usize, cx: &Context<Self>) -> Option<Hsla> {
|
||||
let colors = &cx.theme().colors;
|
||||
let added = self
|
||||
.marks
|
||||
.hunks
|
||||
.iter()
|
||||
.any(|hunk| (hunk.line..hunk.line + hunk.added).contains(&line));
|
||||
if added {
|
||||
return Some(colors.success);
|
||||
}
|
||||
self.marks
|
||||
.git
|
||||
.iter()
|
||||
.find(|(at, _)| *at == line)
|
||||
.map(|(_, mark)| match mark {
|
||||
GitMark::Added => colors.success,
|
||||
GitMark::Modified => colors.info,
|
||||
GitMark::Deleted => colors.danger,
|
||||
})
|
||||
}
|
||||
|
||||
/// The gutter cell for a row.
|
||||
pub(crate) fn gutter(&self, cell: Cell, row: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let advance = self.frame.advance;
|
||||
let digits = self.gutter_columns() - 6;
|
||||
let frame = || {
|
||||
div()
|
||||
.flex_none()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
};
|
||||
let base = div()
|
||||
.id(("gutter", row))
|
||||
.flex_none()
|
||||
.w(advance * self.gutter_columns() as f32)
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(colors.fg_subtle);
|
||||
let line = match cell {
|
||||
Cell::Line(line) => line,
|
||||
Cell::Removed => {
|
||||
return base
|
||||
.bg(colors.danger.opacity(0.08))
|
||||
.child(frame().w(advance * 2.0))
|
||||
.child(
|
||||
div()
|
||||
.w(advance * digits.max(1) as f32)
|
||||
.text_right()
|
||||
.text_color(colors.danger)
|
||||
.child("−"),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
Cell::Blank => return base.into_any_element(),
|
||||
};
|
||||
let current = self.buffer.line_of(self.primary().head) == line;
|
||||
let breakpoint = self.marks.breakpoints.contains(&line);
|
||||
let slot = match (breakpoint, self.worst(line)) {
|
||||
(true, _) => div()
|
||||
.size(advance * 1.1)
|
||||
.rounded_full()
|
||||
.bg(colors.danger)
|
||||
.into_any_element(),
|
||||
(false, Some(severity)) => Icon::new(severity.icon())
|
||||
.size(IconSize::Xs)
|
||||
.color(severity.color(&colors))
|
||||
.into_any_element(),
|
||||
(false, None) => div().into_any_element(),
|
||||
};
|
||||
let opens = self.frame.folds.iter().any(|(header, _)| *header == line);
|
||||
let folded = self.folded.contains(&line);
|
||||
let chevron = opens.then(|| Disclosure::new(("fold", line), !folded).into_any_element());
|
||||
let entity = cx.entity();
|
||||
let toggle = entity.clone();
|
||||
let git = self.git_color(line, cx);
|
||||
let number = self.number(line);
|
||||
base.child(
|
||||
frame()
|
||||
.id(("breakpoint", line))
|
||||
.w(advance * 2.0)
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
entity.update(cx, |editor, cx| editor.toggle_breakpoint(line, cx));
|
||||
})
|
||||
.child(slot),
|
||||
)
|
||||
.when(digits > 0, |gutter| {
|
||||
gutter.child(
|
||||
div()
|
||||
.w(advance * digits as f32)
|
||||
.text_right()
|
||||
.when(current, |number| number.text_color(colors.fg))
|
||||
.child(number),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
frame()
|
||||
.id(("chevron", line))
|
||||
.w(advance * 2.0)
|
||||
.when(opens, |slot| {
|
||||
slot.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
toggle.update(cx, |editor, cx| editor.toggle_fold(line, cx));
|
||||
})
|
||||
})
|
||||
.children(chevron),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w_0p5()
|
||||
.h_full()
|
||||
.when_some(git, |bar, color| bar.bg(color)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledText, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{symbols::SymbolKind, syntax};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
navigation::{fuzzy, marked},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A completion: what it inserts, its kind, a detail such as its type, and its documentation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CompletionItem {
|
||||
pub label: SharedString,
|
||||
pub kind: SymbolKind,
|
||||
pub detail: Option<SharedString>,
|
||||
pub docs: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl CompletionItem {
|
||||
pub fn new(label: impl Into<SharedString>, kind: SymbolKind) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
kind,
|
||||
detail: None,
|
||||
docs: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
|
||||
self.detail = Some(detail.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn docs(mut self, docs: impl Into<SharedString>) -> Self {
|
||||
self.docs = Some(docs.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The items that fit what was typed, best first, with the letters that matched.
|
||||
pub fn completions(
|
||||
items: &[CompletionItem],
|
||||
typed: &str,
|
||||
) -> Vec<(CompletionItem, Vec<Range<usize>>)> {
|
||||
let mut fits: Vec<(i32, CompletionItem, Vec<Range<usize>>)> = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if typed.is_empty() {
|
||||
return Some((0, item.clone(), Vec::new()));
|
||||
}
|
||||
fuzzy(typed, &item.label).map(|fit| (fit.score, item.clone(), fit.hits))
|
||||
})
|
||||
.collect();
|
||||
fits.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.label.len().cmp(&b.1.label.len())));
|
||||
fits.into_iter()
|
||||
.map(|(_, item, hits)| (item, hits))
|
||||
.collect()
|
||||
}
|
||||
|
||||
type OnPick = Rc<dyn Fn(&CompletionItem, &mut Window, &mut App)>;
|
||||
|
||||
/// Completions at the cursor: each with its kind's mark, the typed letters bold and its detail; the chosen one's documentation beside.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CompletionMenu {
|
||||
id: ElementId,
|
||||
items: Vec<CompletionItem>,
|
||||
typed: SharedString,
|
||||
selected: usize,
|
||||
on_pick: Option<OnPick>,
|
||||
}
|
||||
|
||||
impl CompletionMenu {
|
||||
pub fn new(id: impl Into<ElementId>, items: impl IntoIterator<Item = CompletionItem>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
items: items.into_iter().collect(),
|
||||
typed: SharedString::default(),
|
||||
selected: 0,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What was typed so far, which filters and marks the items.
|
||||
pub fn typed(mut self, typed: impl Into<SharedString>) -> Self {
|
||||
self.typed = typed.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The chosen item among those that fit.
|
||||
pub fn selected(mut self, selected: usize) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pick(
|
||||
mut self,
|
||||
handler: impl Fn(&CompletionItem, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CompletionMenu {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let fits = completions(&self.items, &self.typed);
|
||||
let chosen = self.selected.min(fits.len().saturating_sub(1));
|
||||
let docs = fits
|
||||
.get(chosen)
|
||||
.and_then(|(item, _)| item.docs.clone().map(|docs| (item.detail.clone(), docs)));
|
||||
let rows: Vec<AnyElement> = fits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (item, hits))| {
|
||||
let pick = self.on_pick.clone();
|
||||
let picked = item.clone();
|
||||
div()
|
||||
.id((self.id.clone(), format!("item-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.when(ix == chosen, |row| row.bg(colors.active))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.when_some(pick, |row, pick| {
|
||||
row.on_click(move |_, window, cx| {
|
||||
log::info!("completion: {}", picked.label);
|
||||
pick(&picked, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Icon::new(item.kind.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(item.kind.color(&colors)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child(marked(item.label.clone(), hits.clone(), cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_right()
|
||||
.text_color(colors.fg_subtle)
|
||||
.children(item.detail.clone().map(Ellipsis::new)),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
let empty = rows.is_empty();
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 2.75)
|
||||
.p_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.when(empty, |list| {
|
||||
list.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("No suggestions"),
|
||||
)
|
||||
})
|
||||
.children(rows),
|
||||
)
|
||||
.children(docs.map(|(detail, docs)| {
|
||||
div()
|
||||
.w(theme.label_width() * 2.5)
|
||||
.p_3()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.children(detail.map(|detail| {
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg)
|
||||
.child(detail)
|
||||
}))
|
||||
.child(div().text_color(colors.fg_muted).child(docs))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A callable's shape: its label and where each parameter sits in it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Signature {
|
||||
pub label: SharedString,
|
||||
pub params: Vec<Range<usize>>,
|
||||
pub docs: Option<SharedString>,
|
||||
}
|
||||
|
||||
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// The signature of the call being typed, the parameter at the cursor bold and underlined; arrows walk its overloads.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SignatureHelp {
|
||||
id: ElementId,
|
||||
signatures: Vec<Signature>,
|
||||
active: usize,
|
||||
param: usize,
|
||||
on_step: Option<OnStep>,
|
||||
}
|
||||
|
||||
impl SignatureHelp {
|
||||
pub fn new(id: impl Into<ElementId>, signatures: impl IntoIterator<Item = Signature>) -> Self {
|
||||
let signatures: Vec<Signature> = signatures.into_iter().collect();
|
||||
assert!(
|
||||
!signatures.is_empty(),
|
||||
"signature help shows at least one signature"
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
signatures,
|
||||
active: 0,
|
||||
param: 0,
|
||||
on_step: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The overload shown, and the parameter the cursor is in.
|
||||
pub fn at(mut self, overload: usize, param: usize) -> Self {
|
||||
self.active = overload;
|
||||
self.param = param;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the overload an arrow steps to.
|
||||
pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_step = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SignatureHelp {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = self.signatures.len();
|
||||
let active = self.active.min(count - 1);
|
||||
let signature = &self.signatures[active];
|
||||
let mut styles = syntax::colors(&signature.label, cx);
|
||||
if let Some(range) = signature.params.get(self.param) {
|
||||
styles.push((
|
||||
range.clone(),
|
||||
HighlightStyle {
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
underline: Some(gpui::UnderlineStyle {
|
||||
thickness: theme.underline_thickness(),
|
||||
color: Some(colors.fg),
|
||||
wavy: false,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
let styles = super::layout::stack(styles);
|
||||
let step = |by: usize, icon: IconName, key: &'static str| {
|
||||
let on_step = self.on_step.clone();
|
||||
let next = (active + by) % count;
|
||||
IconButton::new((self.id.clone(), key), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(count < 2)
|
||||
.when_some(on_step, |button, on_step| {
|
||||
button.on_click(move |_, window, cx| on_step(next, window, cx))
|
||||
})
|
||||
};
|
||||
div()
|
||||
.max_w(theme.label_width() * 5.0)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.when(count > 1, |row| {
|
||||
row.child(step(count - 1, IconName::ChevronUp, "previous"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("{}/{count}", active + 1)),
|
||||
)
|
||||
.child(step(1, IconName::ChevronDown, "next"))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(
|
||||
StyledText::new(signature.label.clone()).with_highlights(styles),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(
|
||||
signature
|
||||
.docs
|
||||
.clone()
|
||||
.map(|docs| div().text_color(colors.fg_muted).child(docs)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completions_fit_what_was_typed_best_first() {
|
||||
let items = [
|
||||
CompletionItem::new("render", SymbolKind::Method),
|
||||
CompletionItem::new("request_layout", SymbolKind::Method),
|
||||
CompletionItem::new("width", SymbolKind::Field),
|
||||
];
|
||||
let found = completions(&items, "re");
|
||||
let labels: Vec<&str> = found.iter().map(|(item, _)| &item.label[..]).collect();
|
||||
assert_eq!(labels, ["render", "request_layout"]);
|
||||
assert!(!found[0].1.is_empty(), "the typed letters are marked");
|
||||
assert_eq!(completions(&items, "").len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, HighlightStyle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div,
|
||||
};
|
||||
|
||||
use super::syntax;
|
||||
use crate::{
|
||||
primitives::{Icon, Severity},
|
||||
theme::{ActiveTheme, Elevation, IconSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnPress = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// What a hover over a symbol reads: its signature in code, a problem when there is one, its documentation, and links.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HoverInfo {
|
||||
id: ElementId,
|
||||
signature: SharedString,
|
||||
problem: Option<(Severity, SharedString)>,
|
||||
docs: Vec<SharedString>,
|
||||
links: Vec<(SharedString, OnPress)>,
|
||||
}
|
||||
|
||||
impl HoverInfo {
|
||||
pub fn new(id: impl Into<ElementId>, signature: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
signature: signature.into(),
|
||||
problem: None,
|
||||
docs: Vec::new(),
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn problem(mut self, severity: Severity, message: impl Into<SharedString>) -> Self {
|
||||
self.problem = Some((severity, message.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// A paragraph of documentation.
|
||||
pub fn docs(mut self, paragraph: impl Into<SharedString>) -> Self {
|
||||
self.docs.push(paragraph.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn link(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
handler: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.links.push((label.into(), Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with its `code` spans unquoted, and where they lie; an unpaired backtick stays as typed.
|
||||
pub(crate) fn code_spans(text: &str) -> (String, Vec<Range<usize>>) {
|
||||
if text.matches('`').count() % 2 == 1 {
|
||||
return (text.to_string(), Vec::new());
|
||||
}
|
||||
let (mut plain, mut spans) = (String::new(), Vec::new());
|
||||
for (ix, part) in text.split('`').enumerate() {
|
||||
let start = plain.len();
|
||||
plain.push_str(part);
|
||||
if ix % 2 == 1 {
|
||||
spans.push(start..plain.len());
|
||||
}
|
||||
}
|
||||
(plain, spans)
|
||||
}
|
||||
|
||||
fn prose(text: &str, colors: &Palette) -> StyledText {
|
||||
let (plain, spans) = code_spans(text);
|
||||
let code = HighlightStyle {
|
||||
color: Some(colors.fg),
|
||||
background_color: Some(colors.sunken),
|
||||
..Default::default()
|
||||
};
|
||||
StyledText::new(plain).with_highlights(spans.into_iter().map(|span| (span, code)))
|
||||
}
|
||||
|
||||
impl RenderOnce for HoverInfo {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let styles = syntax::colors(&self.signature, cx);
|
||||
let rule = || div().h_0().border_t_1().border_color(colors.border);
|
||||
let has_links = !self.links.is_empty();
|
||||
let links = self
|
||||
.links
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (label, handler))| {
|
||||
div()
|
||||
.id((self.id.clone(), format!("link-{ix}")))
|
||||
.text_color(colors.link)
|
||||
.cursor_pointer()
|
||||
.hover(|link| link.underline())
|
||||
.on_click(move |_, window, cx| handler(window, cx))
|
||||
.child(label)
|
||||
});
|
||||
div()
|
||||
.max_w(theme.label_width() * 5.0)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(self.problem.map(|(severity, message)| {
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(severity.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(severity.color(&colors)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_color(colors.fg)
|
||||
.child(prose(&message, &colors)),
|
||||
)
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(StyledText::new(self.signature.clone()).with_highlights(styles)),
|
||||
)
|
||||
.children((!self.docs.is_empty()).then(rule))
|
||||
.children(self.docs.into_iter().map(|paragraph| {
|
||||
div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(prose(¶graph, &colors))
|
||||
}))
|
||||
.children(has_links.then(rule))
|
||||
.child(div().flex().gap_3().children(links))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backticks_mark_code_unless_unpaired() {
|
||||
let plain = "The color for `name`, lifted by `lift`.";
|
||||
assert_eq!(
|
||||
code_spans(plain),
|
||||
(
|
||||
"The color for name, lifted by lift.".to_string(),
|
||||
vec![14..18, 30..34]
|
||||
)
|
||||
);
|
||||
assert_eq!(code_spans("a `b"), ("a `b".to_string(), Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EntityInputHandler, FontStyle, HighlightStyle, Pixels, Point,
|
||||
UTF16Selection, Window, point,
|
||||
};
|
||||
|
||||
use super::{
|
||||
cursor::Selection,
|
||||
layout::{Row, Shown},
|
||||
state::CodeEditor,
|
||||
};
|
||||
use crate::{
|
||||
forms::{from_utf16, to_utf16},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
|
||||
impl CodeEditor {
|
||||
/// Notes set into a line: inlay hints, and the ghost text's first line.
|
||||
pub(crate) fn notes(&self, line: usize, cx: &App) -> Vec<(usize, String, HighlightStyle)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let range = self.buffer.line_range(line);
|
||||
let hint = HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
background_color: Some(colors.hover),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let mut notes: Vec<(usize, String, HighlightStyle)> = self
|
||||
.marks
|
||||
.hints
|
||||
.iter()
|
||||
.filter(|hint| range.contains(&hint.offset) || hint.offset == range.end)
|
||||
.map(|note| (note.offset - range.start, note.text.to_string(), hint))
|
||||
.collect();
|
||||
if let Some(ghost) = &self.marks.ghost
|
||||
&& self.buffer.line_of(ghost.offset) == line
|
||||
{
|
||||
let first = ghost.text.split('\n').next().unwrap_or_default();
|
||||
let style = HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
font_style: Some(FontStyle::Italic),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
notes.push((ghost.offset - range.start, first.to_string(), style));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// A line's bytes as its row shows them: a multi-line ghost cuts the rest to follow its last line.
|
||||
pub(crate) fn row_range(&self, line: usize) -> Range<usize> {
|
||||
let range = self.buffer.line_range(line);
|
||||
match &self.marks.ghost {
|
||||
Some(ghost) if ghost.text.contains('\n') && range.contains(&ghost.offset) => {
|
||||
range.start..ghost.offset
|
||||
}
|
||||
_ => range,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_text(&self, line: usize) -> &str {
|
||||
&self.buffer.text()[self.row_range(line)]
|
||||
}
|
||||
|
||||
/// The offset under a window point, when a row of code is there.
|
||||
pub(crate) fn offset_at(&self, position: Point<Pixels>, cx: &App) -> Option<usize> {
|
||||
let metrics = self.metrics;
|
||||
if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO {
|
||||
return None;
|
||||
}
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let row = ((position.y - metrics.top + scrolled) / metrics.line).floor();
|
||||
let row = self.frame.rows.get(row.max(0.0) as usize)?;
|
||||
let Row::Line(line) = *row else {
|
||||
return None;
|
||||
};
|
||||
Some(self.offset_in(line, position.x, cx))
|
||||
}
|
||||
|
||||
/// The offset nearest window x on a line.
|
||||
pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> usize {
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let column = ((x - self.metrics.left) / self.metrics.advance)
|
||||
.round()
|
||||
.max(0.0) as usize;
|
||||
self.buffer.offset(line, shown.back(text, column))
|
||||
}
|
||||
|
||||
fn utf16_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
let text = self.buffer.text();
|
||||
to_utf16(text, range.start)..to_utf16(text, range.end)
|
||||
}
|
||||
|
||||
fn byte_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
let text = self.buffer.text();
|
||||
from_utf16(text, range.start)..from_utf16(text, range.end)
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityInputHandler for CodeEditor {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
actual_range: &mut Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = self.byte_range(&range_utf16);
|
||||
actual_range.replace(self.utf16_range(&range));
|
||||
Some(self.buffer.text()[range].to_string())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
let primary = self.primary();
|
||||
Some(UTF16Selection {
|
||||
range: self.utf16_range(&primary.range()),
|
||||
reversed: primary.head < primary.anchor,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
|
||||
self.marked.as_ref().map(|range| self.utf16_range(range))
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.marked = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let range = range_utf16
|
||||
.map(|range| self.byte_range(&range))
|
||||
.or(self.marked.clone());
|
||||
match range {
|
||||
Some(range) => {
|
||||
self.marked = None;
|
||||
self.apply(vec![(range, text.to_string())], true, cx);
|
||||
}
|
||||
None => self.type_text(text, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
selected_utf16: Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let range = range_utf16
|
||||
.map(|range| self.byte_range(&range))
|
||||
.or(self.marked.clone())
|
||||
.unwrap_or(self.primary().range());
|
||||
self.apply(vec![(range.clone(), text.to_string())], true, cx);
|
||||
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
|
||||
let caret = match selected_utf16 {
|
||||
Some(inner) => range.start + from_utf16(text, inner.end),
|
||||
None => range.start + text.len(),
|
||||
};
|
||||
self.set_selections(vec![Selection::caret(caret)], cx);
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let range = self.byte_range(&range_utf16);
|
||||
let line = self.buffer.line_of(range.start);
|
||||
let row = self
|
||||
.frame
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| *row == Row::Line(line))?;
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let column = shown.column(text, self.buffer.point(range.start).1.min(text.len()));
|
||||
let metrics = self.metrics;
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let origin = point(
|
||||
metrics.left + metrics.advance * column as f32,
|
||||
metrics.top + metrics.line * row as f32 - scrolled,
|
||||
);
|
||||
Some(Bounds::new(
|
||||
origin,
|
||||
gpui::size(metrics.advance, metrics.line),
|
||||
))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
position: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let offset = self.offset_at(position, cx)?;
|
||||
Some(to_utf16(self.buffer.text(), offset))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, Keystroke, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::{Badge, Tone},
|
||||
forms::HotkeyInput,
|
||||
navigation::fuzzy,
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::KbdCombo,
|
||||
};
|
||||
|
||||
/// Where a binding comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KeySource {
|
||||
Default,
|
||||
User,
|
||||
}
|
||||
|
||||
/// A command and the keys that run it, in gpui key syntax, when a context holds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Keybinding {
|
||||
pub command: SharedString,
|
||||
pub title: SharedString,
|
||||
pub keys: Option<SharedString>,
|
||||
pub when: Option<SharedString>,
|
||||
pub source: KeySource,
|
||||
}
|
||||
|
||||
/// Commands sharing keys in the same context: for each binding, the others its keys also run.
|
||||
pub fn conflicts(bindings: &[Keybinding], binding: &Keybinding) -> Vec<SharedString> {
|
||||
let Some(keys) = &binding.keys else {
|
||||
return Vec::new();
|
||||
};
|
||||
bindings
|
||||
.iter()
|
||||
.filter(|other| {
|
||||
other.command != binding.command
|
||||
&& other.keys.as_ref() == Some(keys)
|
||||
&& other.when == binding.when
|
||||
})
|
||||
.map(|other| other.title.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type OnCommand = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
type OnBind = Rc<dyn Fn(&SharedString, Option<Keystroke>, &mut Window, &mut App)>;
|
||||
|
||||
/// Every command with its keys, context and source; a query narrows them, keys that clash are marked, and a double press records new keys.
|
||||
#[derive(IntoElement)]
|
||||
pub struct KeybindingsEditor {
|
||||
id: ElementId,
|
||||
bindings: Vec<Keybinding>,
|
||||
query: SharedString,
|
||||
recording: Option<SharedString>,
|
||||
on_record: Option<OnCommand>,
|
||||
on_bind: Option<OnBind>,
|
||||
}
|
||||
|
||||
impl KeybindingsEditor {
|
||||
pub fn new(id: impl Into<ElementId>, bindings: impl IntoIterator<Item = Keybinding>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
bindings: bindings.into_iter().collect(),
|
||||
query: SharedString::default(),
|
||||
recording: None,
|
||||
on_record: None,
|
||||
on_bind: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrows to commands whose title, command or keys fit.
|
||||
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
|
||||
self.query = query.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The command whose keys are being recorded.
|
||||
pub fn recording(mut self, command: Option<SharedString>) -> Self {
|
||||
self.recording = command;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the command a double press chose to record.
|
||||
pub fn on_record(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_record = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the keys recorded for a command, or none when cleared.
|
||||
pub fn on_bind(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, Option<Keystroke>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_bind = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The four columns every row shares.
|
||||
fn columns<E: Styled>(row: E) -> E {
|
||||
row.flex().items_center().gap_3().px_2()
|
||||
}
|
||||
|
||||
impl RenderOnce for KeybindingsEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let query = self.query.trim().to_string();
|
||||
let fits = |binding: &Keybinding| {
|
||||
query.is_empty()
|
||||
|| [
|
||||
Some(&binding.title),
|
||||
Some(&binding.command),
|
||||
binding.keys.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|text| fuzzy(&query, text).is_some())
|
||||
};
|
||||
let widths = [2.25, 1.5, 1.5, 0.75].map(|share| theme.label_width() * share);
|
||||
let head = columns(div())
|
||||
.py_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(div().w(widths[0]).child("Command"))
|
||||
.child(div().w(widths[1]).child("Keybinding"))
|
||||
.child(div().w(widths[2]).child("When"))
|
||||
.child(div().w(widths[3]).child("Source"));
|
||||
let rows = self
|
||||
.bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, binding)| fits(binding))
|
||||
.map(|(ix, binding)| {
|
||||
let clashes = conflicts(&self.bindings, binding);
|
||||
let recording = self.recording.as_ref() == Some(&binding.command);
|
||||
let keys = if recording {
|
||||
let (bind, command) = (self.on_bind.clone(), binding.command.clone());
|
||||
HotkeyInput::new((self.id.clone(), format!("keys-{ix}")), None)
|
||||
.on_change(move |stroke, window, cx| {
|
||||
log::info!("keybindings: {command} = {stroke:?}");
|
||||
if let Some(bind) = &bind {
|
||||
bind(&command, stroke, window, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
match &binding.keys {
|
||||
Some(keys) => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(KbdCombo::new(keys))
|
||||
.when(!clashes.is_empty(), |cell| {
|
||||
let words = format!("Also runs {}", clashes.join(", "));
|
||||
cell.child(
|
||||
div()
|
||||
.id((self.id.clone(), format!("clash-{ix}")))
|
||||
.tooltip(Tooltip::text(words))
|
||||
.child(
|
||||
Icon::new(IconName::TriangleAlert)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.warning),
|
||||
),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("—")
|
||||
.into_any_element(),
|
||||
}
|
||||
};
|
||||
let record = self.on_record.clone();
|
||||
let command = binding.command.clone();
|
||||
columns(div().id((self.id.clone(), format!("row-{ix}"))))
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.when(recording, |row| row.bg(colors.active))
|
||||
.when(!recording, |row| row.hover(|row| row.bg(colors.hover)))
|
||||
.when_some(record, |row, record| {
|
||||
row.on_click(move |event, window, cx| {
|
||||
if event.click_count() == 2 {
|
||||
record(&command, window, cx)
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w(widths[0])
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(binding.title.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(binding.command.clone()),
|
||||
),
|
||||
)
|
||||
.child(div().w(widths[1]).child(keys))
|
||||
.child(
|
||||
div()
|
||||
.w(widths[2])
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.children(binding.when.clone()),
|
||||
)
|
||||
.child(div().w(widths[3]).flex().child(match binding.source {
|
||||
KeySource::Default => Badge::new("Default"),
|
||||
KeySource::User => Badge::new("User").tone(Tone::Info),
|
||||
}))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(head)
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn binding(command: &str, keys: &str, when: Option<&str>) -> Keybinding {
|
||||
Keybinding {
|
||||
command: command.to_string().into(),
|
||||
title: command.to_string().into(),
|
||||
keys: Some(keys.to_string().into()),
|
||||
when: when.map(|when| when.to_string().into()),
|
||||
source: KeySource::Default,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_clash_only_in_the_same_context() {
|
||||
let all = [
|
||||
binding("save", "secondary-s", None),
|
||||
binding("sort", "secondary-s", None),
|
||||
binding("split", "secondary-s", Some("Terminal")),
|
||||
];
|
||||
assert_eq!(conflicts(&all, &all[0]), ["sort"]);
|
||||
assert!(
|
||||
conflicts(&all, &all[2]).is_empty(),
|
||||
"another context does not clash"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use gpui::{App, Context, InteractiveElement, KeyBinding, Window, actions};
|
||||
|
||||
use super::{buffer::Buffer, cursor::Motion, state::CodeEditor};
|
||||
|
||||
actions!(
|
||||
ely_editor,
|
||||
[
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
SelectLeft,
|
||||
SelectRight,
|
||||
SelectUp,
|
||||
SelectDown,
|
||||
WordLeft,
|
||||
WordRight,
|
||||
SelectWordLeft,
|
||||
SelectWordRight,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
SelectLineStart,
|
||||
SelectLineEnd,
|
||||
DocumentStart,
|
||||
DocumentEnd,
|
||||
SelectDocumentStart,
|
||||
SelectDocumentEnd,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Backspace,
|
||||
Delete,
|
||||
DeleteWordLeft,
|
||||
DeleteLineLeft,
|
||||
Newline,
|
||||
Indent,
|
||||
Outdent,
|
||||
Undo,
|
||||
Redo,
|
||||
SelectAll,
|
||||
Copy,
|
||||
Cut,
|
||||
Paste,
|
||||
AddCursorAbove,
|
||||
AddCursorBelow,
|
||||
SelectNext,
|
||||
SingleCursor,
|
||||
ToggleComment,
|
||||
Fold,
|
||||
Unfold,
|
||||
]
|
||||
);
|
||||
|
||||
pub(crate) const CONTEXT: &str = "ElyEditor";
|
||||
|
||||
/// Binds the editor's keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
let word = if cfg!(target_os = "macos") {
|
||||
"alt"
|
||||
} else {
|
||||
"ctrl"
|
||||
};
|
||||
let context = Some(CONTEXT);
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("left", Left, context),
|
||||
KeyBinding::new("right", Right, context),
|
||||
KeyBinding::new("up", Up, context),
|
||||
KeyBinding::new("down", Down, context),
|
||||
KeyBinding::new("shift-left", SelectLeft, context),
|
||||
KeyBinding::new("shift-right", SelectRight, context),
|
||||
KeyBinding::new("shift-up", SelectUp, context),
|
||||
KeyBinding::new("shift-down", SelectDown, context),
|
||||
KeyBinding::new(&format!("{word}-left"), WordLeft, context),
|
||||
KeyBinding::new(&format!("{word}-right"), WordRight, context),
|
||||
KeyBinding::new(&format!("{word}-shift-left"), SelectWordLeft, context),
|
||||
KeyBinding::new(&format!("{word}-shift-right"), SelectWordRight, context),
|
||||
KeyBinding::new("home", LineStart, context),
|
||||
KeyBinding::new("end", LineEnd, context),
|
||||
KeyBinding::new("shift-home", SelectLineStart, context),
|
||||
KeyBinding::new("shift-end", SelectLineEnd, context),
|
||||
KeyBinding::new("pageup", PageUp, context),
|
||||
KeyBinding::new("pagedown", PageDown, context),
|
||||
KeyBinding::new("backspace", Backspace, context),
|
||||
KeyBinding::new("shift-backspace", Backspace, context),
|
||||
KeyBinding::new("delete", Delete, context),
|
||||
KeyBinding::new(&format!("{word}-backspace"), DeleteWordLeft, context),
|
||||
KeyBinding::new("enter", Newline, context),
|
||||
KeyBinding::new("tab", Indent, context),
|
||||
KeyBinding::new("shift-tab", Outdent, context),
|
||||
KeyBinding::new("secondary-z", Undo, context),
|
||||
KeyBinding::new("secondary-shift-z", Redo, context),
|
||||
KeyBinding::new("secondary-a", SelectAll, context),
|
||||
KeyBinding::new("secondary-c", Copy, context),
|
||||
KeyBinding::new("secondary-x", Cut, context),
|
||||
KeyBinding::new("secondary-v", Paste, context),
|
||||
KeyBinding::new("secondary-d", SelectNext, context),
|
||||
KeyBinding::new("escape", SingleCursor, context),
|
||||
KeyBinding::new("secondary-/", ToggleComment, context),
|
||||
KeyBinding::new("secondary-alt-[", Fold, context),
|
||||
KeyBinding::new("secondary-alt-]", Unfold, context),
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
bindings.extend([
|
||||
KeyBinding::new("cmd-left", LineStart, context),
|
||||
KeyBinding::new("cmd-right", LineEnd, context),
|
||||
KeyBinding::new("cmd-shift-left", SelectLineStart, context),
|
||||
KeyBinding::new("cmd-shift-right", SelectLineEnd, context),
|
||||
KeyBinding::new("cmd-up", DocumentStart, context),
|
||||
KeyBinding::new("cmd-down", DocumentEnd, context),
|
||||
KeyBinding::new("cmd-shift-up", SelectDocumentStart, context),
|
||||
KeyBinding::new("cmd-shift-down", SelectDocumentEnd, context),
|
||||
KeyBinding::new("cmd-backspace", DeleteLineLeft, context),
|
||||
KeyBinding::new("cmd-alt-up", AddCursorAbove, context),
|
||||
KeyBinding::new("cmd-alt-down", AddCursorBelow, context),
|
||||
]);
|
||||
} else {
|
||||
bindings.extend([
|
||||
KeyBinding::new("ctrl-home", DocumentStart, context),
|
||||
KeyBinding::new("ctrl-end", DocumentEnd, context),
|
||||
KeyBinding::new("ctrl-shift-home", SelectDocumentStart, context),
|
||||
KeyBinding::new("ctrl-shift-end", SelectDocumentEnd, context),
|
||||
KeyBinding::new("ctrl-alt-up", AddCursorAbove, context),
|
||||
KeyBinding::new("ctrl-alt-down", AddCursorBelow, context),
|
||||
KeyBinding::new("ctrl-y", Redo, context),
|
||||
]);
|
||||
}
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
/// Rows a page key moves.
|
||||
const PAGE: isize = 20;
|
||||
|
||||
fn line_left(buffer: &Buffer, at: usize) -> std::ops::Range<usize> {
|
||||
buffer.line_range(buffer.line_of(at)).start..at
|
||||
}
|
||||
|
||||
/// Wires every action to the editor that renders `root`.
|
||||
pub(crate) fn listen<E: InteractiveElement>(root: E, cx: &mut Context<CodeEditor>) -> E {
|
||||
macro_rules! on {
|
||||
($root:expr, $action:ty, $body:expr) => {
|
||||
$root.on_action(cx.listener(
|
||||
move |editor: &mut CodeEditor,
|
||||
_: &$action,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<CodeEditor>| {
|
||||
let run: fn(&mut CodeEditor, &mut Window, &mut Context<CodeEditor>) = $body;
|
||||
run(editor, window, cx)
|
||||
},
|
||||
))
|
||||
};
|
||||
}
|
||||
let root = on!(root, Left, |e, _, cx| e.motion(Motion::Left, false, cx));
|
||||
let root = on!(root, Right, |e, _, cx| e.motion(Motion::Right, false, cx));
|
||||
let root = on!(root, Up, |e, _, cx| e.motion(Motion::Up, false, cx));
|
||||
let root = on!(root, Down, |e, _, cx| e.motion(Motion::Down, false, cx));
|
||||
let root = on!(root, SelectLeft, |e, _, cx| e.motion(
|
||||
Motion::Left,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectRight, |e, _, cx| e.motion(
|
||||
Motion::Right,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectUp, |e, _, cx| e.motion(Motion::Up, true, cx));
|
||||
let root = on!(root, SelectDown, |e, _, cx| e.motion(
|
||||
Motion::Down,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, WordLeft, |e, _, cx| e.motion(
|
||||
Motion::WordLeft,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, WordRight, |e, _, cx| e.motion(
|
||||
Motion::WordRight,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectWordLeft, |e, _, cx| e.motion(
|
||||
Motion::WordLeft,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectWordRight, |e, _, cx| e.motion(
|
||||
Motion::WordRight,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, LineStart, |e, _, cx| e.motion(
|
||||
Motion::LineStart,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, LineEnd, |e, _, cx| e.motion(
|
||||
Motion::LineEnd,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectLineStart, |e, _, cx| e.motion(
|
||||
Motion::LineStart,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectLineEnd, |e, _, cx| e.motion(
|
||||
Motion::LineEnd,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, DocumentStart, |e, _, cx| e.motion(
|
||||
Motion::Start,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, DocumentEnd, |e, _, cx| e.motion(
|
||||
Motion::End,
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectDocumentStart, |e, _, cx| e.motion(
|
||||
Motion::Start,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, SelectDocumentEnd, |e, _, cx| e.motion(
|
||||
Motion::End,
|
||||
true,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, PageUp, |e, _, cx| e.motion(
|
||||
Motion::Page(-PAGE),
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, PageDown, |e, _, cx| e.motion(
|
||||
Motion::Page(PAGE),
|
||||
false,
|
||||
cx
|
||||
));
|
||||
let root = on!(root, Backspace, |e, _, cx| {
|
||||
e.delete_by(|buffer, at| buffer.previous(at)..at, cx)
|
||||
});
|
||||
let root = on!(root, Delete, |e, _, cx| e
|
||||
.delete_by(|buffer, at| at..buffer.next(at), cx));
|
||||
let root = on!(root, DeleteWordLeft, |e, _, cx| {
|
||||
e.delete_by(|buffer, at| buffer.word_start(at)..at, cx)
|
||||
});
|
||||
let root = on!(root, DeleteLineLeft, |e, _, cx| e.delete_by(line_left, cx));
|
||||
let root = on!(root, Newline, |e, _, cx| e.newline(cx));
|
||||
let root = on!(root, Indent, |e, _, cx| e.indent(cx));
|
||||
let root = on!(root, Outdent, |e, _, cx| e.outdent(cx));
|
||||
let root = on!(root, Undo, |e, _, cx| e.undo(cx));
|
||||
let root = on!(root, Redo, |e, _, cx| e.redo(cx));
|
||||
let root = on!(root, SelectAll, |e, _, cx| e.select_all(cx));
|
||||
let root = on!(root, Copy, |e, _, cx| e.copy(cx));
|
||||
let root = on!(root, Cut, |e, _, cx| e.cut(cx));
|
||||
let root = on!(root, Paste, |e, _, cx| e.paste(cx));
|
||||
let root = on!(root, AddCursorAbove, |e, _, cx| e.add_cursor(false, cx));
|
||||
let root = on!(root, AddCursorBelow, |e, _, cx| e.add_cursor(true, cx));
|
||||
let root = on!(root, SelectNext, |e, _, cx| e.select_next(cx));
|
||||
let root = on!(root, SingleCursor, |e, window, cx| {
|
||||
if e.marks.chat.is_some() {
|
||||
e.set_chat(None, window, cx)
|
||||
} else {
|
||||
e.single_cursor(cx)
|
||||
}
|
||||
});
|
||||
let root = on!(root, ToggleComment, |e, _, cx| e.toggle_comment(cx));
|
||||
let root = on!(root, Fold, |e, _, cx| {
|
||||
let line = e.buffer.line_of(e.primary().head);
|
||||
let header = super::syntax::folds(&e.buffer)
|
||||
.into_iter()
|
||||
.filter(|(header, end)| *header <= line && line <= *end)
|
||||
.map(|(header, _)| header)
|
||||
.next_back();
|
||||
if let Some(header) = header.filter(|header| !e.folded.contains(header)) {
|
||||
e.toggle_fold(header, cx);
|
||||
}
|
||||
});
|
||||
on!(root, Unfold, |e, _, cx| {
|
||||
let line = e.buffer.line_of(e.primary().head);
|
||||
if e.folded.contains(&line) {
|
||||
e.toggle_fold(line, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::{collections::BTreeSet, ops::Range};
|
||||
|
||||
use gpui::{HighlightStyle, SharedString};
|
||||
|
||||
use super::{buffer::Buffer, state::Marks};
|
||||
|
||||
/// Rows an inline chat takes below its line.
|
||||
pub(crate) const CHAT_ROWS: usize = 3;
|
||||
|
||||
/// One row the editor draws, all the same height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Row {
|
||||
Line(usize),
|
||||
/// A code lens, by its place among the lenses.
|
||||
Lens(usize),
|
||||
/// A removed line of a diff hunk: the hunk, then the line within it.
|
||||
Removed(usize, usize),
|
||||
/// A line of ghost text after its first.
|
||||
Ghost(usize),
|
||||
/// The inline chat's rows; the first draws it.
|
||||
Chat(usize),
|
||||
}
|
||||
|
||||
/// Which lines folds hide.
|
||||
pub(crate) fn hidden(
|
||||
lines: usize,
|
||||
folds: &[(usize, usize)],
|
||||
folded: &BTreeSet<usize>,
|
||||
) -> Vec<bool> {
|
||||
let mut out = vec![false; lines];
|
||||
for (header, end) in folds.iter().filter(|(header, _)| folded.contains(header)) {
|
||||
for hidden in &mut out[header + 1..=*end] {
|
||||
*hidden = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The rows in order: lenses and removed lines above their line, ghost lines and the chat below it, nothing a fold hides.
|
||||
pub(crate) fn rows(buffer: &Buffer, hidden: &[bool], marks: &Marks) -> Vec<Row> {
|
||||
let ghost = marks.ghost.as_ref().map(|ghost| {
|
||||
let extra = ghost.text.split('\n').count() - 1;
|
||||
(buffer.line_of(ghost.offset), extra)
|
||||
});
|
||||
let mut out = Vec::with_capacity(buffer.lines());
|
||||
for line in (0..buffer.lines()).filter(|line| !hidden[*line]) {
|
||||
out.extend(
|
||||
marks
|
||||
.lenses
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, lens)| lens.line == line)
|
||||
.map(|(ix, _)| Row::Lens(ix)),
|
||||
);
|
||||
for (ix, hunk) in marks
|
||||
.hunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, hunk)| hunk.line == line)
|
||||
{
|
||||
out.extend((0..hunk.removed.len()).map(|at| Row::Removed(ix, at)));
|
||||
}
|
||||
out.push(Row::Line(line));
|
||||
if let Some((at, extra)) = ghost
|
||||
&& at == line
|
||||
{
|
||||
out.extend((1..=extra).map(Row::Ghost));
|
||||
}
|
||||
if marks.chat == Some(line) {
|
||||
out.extend((0..CHAT_ROWS).map(Row::Chat));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The headers of the blocks around `line`, outermost first, at most `limit` of them.
|
||||
pub(crate) fn around(folds: &[(usize, usize)], line: usize, limit: usize) -> Vec<usize> {
|
||||
let mut headers: Vec<usize> = folds
|
||||
.iter()
|
||||
.filter(|(header, end)| *header < line && line <= *end)
|
||||
.map(|(header, _)| *header)
|
||||
.collect();
|
||||
headers.sort_unstable();
|
||||
headers.truncate(limit);
|
||||
headers
|
||||
}
|
||||
|
||||
/// Styles laid over one another in order, later ones on top, as runs that do not overlap.
|
||||
pub(crate) fn stack(
|
||||
spans: Vec<(Range<usize>, HighlightStyle)>,
|
||||
) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let mut edges: Vec<usize> = spans
|
||||
.iter()
|
||||
.flat_map(|(range, _)| [range.start, range.end])
|
||||
.collect();
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
edges
|
||||
.windows(2)
|
||||
.filter_map(|pair| {
|
||||
let (start, end) = (pair[0], pair[1]);
|
||||
spans
|
||||
.iter()
|
||||
.filter(|(range, _)| range.start <= start && end <= range.end)
|
||||
.fold(None, |below: Option<HighlightStyle>, (_, style)| {
|
||||
Some(below.map_or(*style, |below| below.highlight(*style)))
|
||||
})
|
||||
.map(|style| (start..end, style))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A line as drawn: its text with notes set in, their styles, and where each note went.
|
||||
pub(crate) struct Shown {
|
||||
pub text: SharedString,
|
||||
pub styles: Vec<(Range<usize>, HighlightStyle)>,
|
||||
/// Byte in the line, then bytes and characters set in before it.
|
||||
inserts: Vec<(usize, usize, usize)>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// `line` with `notes` set in, each at a byte of the line and styled; `styles` color the line's own bytes.
|
||||
pub fn new(
|
||||
line: &str,
|
||||
mut notes: Vec<(usize, String, HighlightStyle)>,
|
||||
styles: Vec<(Range<usize>, HighlightStyle)>,
|
||||
) -> Self {
|
||||
notes.sort_by_key(|(at, ..)| *at);
|
||||
let mut text = String::with_capacity(line.len());
|
||||
let mut inserts = Vec::with_capacity(notes.len());
|
||||
let mut own = Vec::with_capacity(notes.len());
|
||||
let mut last = 0;
|
||||
for (at, note, style) in notes {
|
||||
text.push_str(&line[last..at]);
|
||||
own.push((text.len()..text.len() + note.len(), style));
|
||||
text.push_str(¬e);
|
||||
inserts.push((at, note.len(), note.chars().count()));
|
||||
last = at;
|
||||
}
|
||||
text.push_str(&line[last..]);
|
||||
let mut shown = Self {
|
||||
text: text.into(),
|
||||
styles: Vec::new(),
|
||||
inserts,
|
||||
};
|
||||
let mapped: Vec<(Range<usize>, HighlightStyle)> = styles
|
||||
.into_iter()
|
||||
.map(|(range, style)| {
|
||||
(
|
||||
shown.byte(range.start, false)..shown.byte(range.end, true),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
shown.styles = stack(mapped.into_iter().chain(own).collect());
|
||||
shown
|
||||
}
|
||||
|
||||
/// Where a byte of the line lands; a range's end lands before a note set in at it.
|
||||
fn byte(&self, at: usize, end: bool) -> usize {
|
||||
at + self
|
||||
.inserts
|
||||
.iter()
|
||||
.filter(|(place, ..)| if end { *place < at } else { *place <= at })
|
||||
.map(|(_, bytes, _)| bytes)
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// Where a caret at column `column` of `line` draws, in columns; it sits before a note set in at it.
|
||||
pub fn column(&self, line: &str, column: usize) -> usize {
|
||||
let byte = line
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(line.len(), |(at, _)| at);
|
||||
column
|
||||
+ self
|
||||
.inserts
|
||||
.iter()
|
||||
.filter(|(place, ..)| *place < byte)
|
||||
.map(|(.., chars)| chars)
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// The column of the line under drawn column `shown`, skipping notes.
|
||||
pub fn back(&self, line: &str, shown: usize) -> usize {
|
||||
let mut column = shown;
|
||||
for (place, _, chars) in &self.inserts {
|
||||
let at = line[..*place].chars().count();
|
||||
let drawn = self.column(line, at);
|
||||
if shown > drawn {
|
||||
column = column.saturating_sub((shown - drawn).min(*chars));
|
||||
}
|
||||
}
|
||||
column.min(line.chars().count())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::editor::decor::{CodeLens, DiffHunk, GhostText};
|
||||
|
||||
#[test]
|
||||
fn rows_skip_folds_and_add_what_lies_around_lines() {
|
||||
let buffer = Buffer::new("a\n b\n c\nd\ne");
|
||||
let marks = Marks {
|
||||
lenses: vec![CodeLens {
|
||||
line: 3,
|
||||
items: vec!["Run".into()],
|
||||
}],
|
||||
hunks: vec![DiffHunk {
|
||||
line: 4,
|
||||
added: 1,
|
||||
removed: vec!["old".into()],
|
||||
}],
|
||||
ghost: Some(GhostText {
|
||||
offset: 0,
|
||||
text: "x\ny".into(),
|
||||
}),
|
||||
..Marks::default()
|
||||
};
|
||||
let hide = hidden(5, &[(0, 2)], &BTreeSet::from([0]));
|
||||
assert_eq!(
|
||||
rows(&buffer, &hide, &marks),
|
||||
[
|
||||
Row::Line(0),
|
||||
Row::Ghost(1),
|
||||
Row::Lens(0),
|
||||
Row::Line(3),
|
||||
Row::Removed(0, 0),
|
||||
Row::Line(4)
|
||||
]
|
||||
);
|
||||
assert_eq!(around(&[(0, 4), (1, 2)], 2, 3), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_styles_sit_on_top_and_runs_do_not_overlap() {
|
||||
let red = gpui::hsla(0.0, 1.0, 0.5, 1.0);
|
||||
let blue = gpui::hsla(0.6, 1.0, 0.5, 1.0);
|
||||
let runs = stack(vec![(0..6, red.into()), (2..4, blue.into())]);
|
||||
let ranges: Vec<_> = runs.iter().map(|(range, _)| range.clone()).collect();
|
||||
assert_eq!(ranges, [0..2, 2..4, 4..6]);
|
||||
assert_eq!(
|
||||
runs[1].1.color,
|
||||
Some(blue),
|
||||
"the later span wins where both lie"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_shift_what_follows_them_but_not_a_caret_at_them() {
|
||||
let style = HighlightStyle::default();
|
||||
let shown = Shown::new(
|
||||
"let x = 1;",
|
||||
vec![(5, ": i32".into(), style)],
|
||||
vec![(4..5, style)],
|
||||
);
|
||||
assert_eq!(shown.text.as_ref(), "let x: i32 = 1;");
|
||||
assert_eq!(
|
||||
shown.column("let x = 1;", 5),
|
||||
5,
|
||||
"a caret at the note stays before it"
|
||||
);
|
||||
assert_eq!(shown.column("let x = 1;", 6), 11);
|
||||
assert_eq!(shown.back("let x = 1;", 11), 6);
|
||||
assert_eq!(
|
||||
shown.back("let x = 1;", 8),
|
||||
5,
|
||||
"inside the note falls back to it"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, ParentElement, Stateful, Styled, StyledText, UnderlineStyle,
|
||||
Window, canvas, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
cursor::Selection,
|
||||
gutter::Cell,
|
||||
layout::{Shown, stack},
|
||||
state::{CodeEditor, CursorShape},
|
||||
syntax,
|
||||
};
|
||||
use crate::{
|
||||
charts::tint,
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
|
||||
impl CodeEditor {
|
||||
/// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair.
|
||||
fn styles(&self, line: usize, cx: &Context<Self>) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let range = self.row_range(line);
|
||||
let local = |span: &Range<usize>| {
|
||||
span.start.max(range.start) - range.start..span.end.min(range.end) - range.start
|
||||
};
|
||||
let touches = |span: &Range<usize>| {
|
||||
span.start < range.end.max(range.start + 1) && range.start < span.end
|
||||
};
|
||||
let mut spans = syntax::colors(self.row_text(line), cx);
|
||||
let brackets = self
|
||||
.frame
|
||||
.brackets
|
||||
.iter()
|
||||
.filter(|bracket| range.contains(&bracket.offset));
|
||||
for bracket in brackets {
|
||||
let at = bracket.offset - range.start;
|
||||
let color = if self.options.rainbow {
|
||||
tint(colors, bracket.depth % 3)
|
||||
} else {
|
||||
colors.syntax.punctuation
|
||||
};
|
||||
spans.retain(|(span, _)| *span != (at..at + 1));
|
||||
spans.push((at..at + 1, color.into()));
|
||||
}
|
||||
for span in self.frame.occurrences.iter().filter(|span| touches(span)) {
|
||||
spans.push((
|
||||
local(span),
|
||||
HighlightStyle {
|
||||
background_color: Some(colors.selection.opacity(0.45)),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
for (span, color) in self
|
||||
.marks
|
||||
.backgrounds
|
||||
.iter()
|
||||
.filter(|(span, _)| touches(span))
|
||||
{
|
||||
spans.push((
|
||||
local(span),
|
||||
HighlightStyle {
|
||||
background_color: Some(*color),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
for diagnostic in self
|
||||
.marks
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| touches(&diagnostic.range))
|
||||
{
|
||||
spans.push((
|
||||
local(&diagnostic.range),
|
||||
HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: cx.theme().underline_thickness(),
|
||||
color: Some(diagnostic.severity.color(colors)),
|
||||
wavy: true,
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some((open, close)) = self.frame.matched {
|
||||
for at in [open, close].into_iter().filter(|at| range.contains(at)) {
|
||||
let at = at - range.start;
|
||||
spans.push((
|
||||
at..at + 1,
|
||||
HighlightStyle {
|
||||
background_color: Some(colors.active),
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
stack(spans)
|
||||
}
|
||||
|
||||
/// The columns guides stand at on a line: every tab stop inside its indent, or the next line's when it is blank.
|
||||
fn guides(&self, line: usize) -> Vec<usize> {
|
||||
let indent = (line..self.buffer.lines())
|
||||
.find(|at| !self.buffer.line(*at).trim().is_empty())
|
||||
.map_or(0, |at| self.buffer.indent(at));
|
||||
(0..indent).step_by(self.options.tab).collect()
|
||||
}
|
||||
|
||||
/// Dots where the line has spaces and arrows for tabs, spaces elsewhere, so they fall on the text's columns.
|
||||
fn whitespace(&self, line: usize, shown: &Shown, cx: &Context<Self>) -> String {
|
||||
let text = self.row_text(line);
|
||||
let notes = self.notes(line, cx);
|
||||
let mut out = String::new();
|
||||
let mut note = notes.iter().peekable();
|
||||
for (at, ch) in text.char_indices() {
|
||||
while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) {
|
||||
out.extend(std::iter::repeat_n(' ', words.chars().count()));
|
||||
}
|
||||
out.push(match ch {
|
||||
' ' => '·',
|
||||
'\t' => '→',
|
||||
_ => ' ',
|
||||
});
|
||||
}
|
||||
debug_assert!(out.chars().count() <= shown.text.chars().count());
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn line_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
row: usize,
|
||||
line: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (advance, height) = (self.frame.advance, self.frame.line);
|
||||
let text = self.row_text(line);
|
||||
let range = self.row_range(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
|
||||
let x = |column: usize| advance * (column + 1) as f32;
|
||||
let focused = self.focus.is_focused(window);
|
||||
let primary = self.primary();
|
||||
let current = primary.is_empty() && self.buffer.line_of(primary.head) == line;
|
||||
let column_of =
|
||||
|offset: usize| shown.column(text, self.buffer.point(offset).1.min(text.len()));
|
||||
let selections = self.selections.iter().filter_map(|selection| {
|
||||
let span = selection.range();
|
||||
if span.is_empty() || span.end < range.start || span.start > range.end {
|
||||
return None;
|
||||
}
|
||||
let start = if span.start <= range.start {
|
||||
0
|
||||
} else {
|
||||
column_of(span.start)
|
||||
};
|
||||
let past = span.end > range.end;
|
||||
let end = if past {
|
||||
column_of(range.end) + 1
|
||||
} else {
|
||||
column_of(span.end)
|
||||
};
|
||||
(end > start).then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(start))
|
||||
.w(advance * (end - start) as f32)
|
||||
.h(height)
|
||||
.bg(if focused {
|
||||
colors.selection
|
||||
} else {
|
||||
colors.selection.opacity(0.5)
|
||||
})
|
||||
})
|
||||
});
|
||||
let active = self
|
||||
.frame
|
||||
.folds
|
||||
.iter()
|
||||
.filter(|(header, end)| {
|
||||
let at = self.buffer.line_of(primary.head);
|
||||
*header < at && at <= *end
|
||||
})
|
||||
.map(|(header, _)| self.buffer.indent(*header))
|
||||
.next_back();
|
||||
let guides = if self.options.guides {
|
||||
self.guides(line)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let rulers = self.options.rulers.iter().map(|column| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(*column))
|
||||
.h(height)
|
||||
.w_0()
|
||||
.border_l_1()
|
||||
.border_color(colors.border.opacity(0.6))
|
||||
});
|
||||
let guides = guides.into_iter().map(|column| {
|
||||
let strong = active == Some(column);
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(column))
|
||||
.h(height)
|
||||
.w_0()
|
||||
.border_l_1()
|
||||
.border_color(if strong {
|
||||
colors.border_strong
|
||||
} else {
|
||||
colors.border.opacity(0.6)
|
||||
})
|
||||
});
|
||||
let carets = self
|
||||
.selections
|
||||
.iter()
|
||||
.filter(|selection| self.buffer.line_of(selection.head) == line)
|
||||
.filter(|_| focused && self.caret_on)
|
||||
.map(|selection| {
|
||||
let left = x(column_of(selection.head));
|
||||
let caret = theme.caret_width().to_pixels(window.rem_size());
|
||||
let mark = div().absolute().left(left);
|
||||
match self.options.cursor {
|
||||
CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus),
|
||||
CursorShape::Block => mark
|
||||
.top_0()
|
||||
.w(advance)
|
||||
.h(height)
|
||||
.bg(colors.focus.opacity(0.35)),
|
||||
CursorShape::Underline => mark
|
||||
.top(height - caret * 1.5)
|
||||
.w(advance)
|
||||
.h(caret * 1.5)
|
||||
.bg(colors.focus),
|
||||
}
|
||||
});
|
||||
let dots = self.options.whitespace.then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(0))
|
||||
.h(height)
|
||||
.line_height(height)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.fg_subtle.opacity(0.6))
|
||||
.child(self.whitespace(line, &shown, cx))
|
||||
});
|
||||
let folded = self.folded.contains(&line).then(|| {
|
||||
let entity = cx.entity();
|
||||
div()
|
||||
.id(("unfold", line))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(x(shown.text.chars().count() + 1))
|
||||
.h(height)
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.hover)
|
||||
.text_color(colors.fg_muted)
|
||||
.line_height(height * 0.8)
|
||||
.child("⋯"),
|
||||
)
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
entity.update(cx, |editor, cx| editor.toggle_fold(line, cx));
|
||||
})
|
||||
});
|
||||
let added = self
|
||||
.marks
|
||||
.hunks
|
||||
.iter()
|
||||
.any(|hunk| (hunk.line..hunk.line + hunk.added).contains(&line));
|
||||
let entity = cx.entity();
|
||||
let left = x(0);
|
||||
let code = div()
|
||||
.id(("code", row))
|
||||
.relative()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.when(current && focused, |code| code.bg(colors.hover))
|
||||
.when(added, |code| code.bg(colors.success.opacity(0.08)))
|
||||
.children(selections)
|
||||
.children(guides)
|
||||
.children(rulers)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(left)
|
||||
.h(height)
|
||||
.line_height(height)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(
|
||||
StyledText::new(shown.text.clone()).with_highlights(shown.styles.clone()),
|
||||
),
|
||||
)
|
||||
.children(dots)
|
||||
.children(folded)
|
||||
.children(carets)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
let origin = bounds.origin.x + left;
|
||||
entity.update(cx, |editor, _| {
|
||||
if editor.metrics.left != origin {
|
||||
editor.metrics.left = origin;
|
||||
}
|
||||
});
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |editor, event: &MouseDownEvent, window, cx| {
|
||||
editor.press(line, event, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| {
|
||||
editor.drag(line, event, cx)
|
||||
}));
|
||||
base.child(self.gutter(Cell::Line(line), row, cx))
|
||||
.child(code)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A press on a line: one caret, a word on a double press, the line on a triple; Shift extends, Alt adds a caret.
|
||||
pub(crate) fn press(
|
||||
&mut self,
|
||||
line: usize,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus);
|
||||
let at = self.offset_in(line, event.position.x, cx);
|
||||
let chosen = match event.click_count {
|
||||
2 => {
|
||||
let word = self.buffer.word_at(at);
|
||||
vec![Selection {
|
||||
anchor: word.start,
|
||||
head: word.end,
|
||||
goal: None,
|
||||
}]
|
||||
}
|
||||
3.. => {
|
||||
let range = self.buffer.line_range(line);
|
||||
vec![Selection {
|
||||
anchor: range.start,
|
||||
head: self.buffer.next(range.end),
|
||||
goal: None,
|
||||
}]
|
||||
}
|
||||
_ if event.modifiers.shift => {
|
||||
let mut all = self.selections.clone();
|
||||
let primary = all.last_mut().expect("an editor keeps a cursor");
|
||||
primary.head = at;
|
||||
primary.goal = None;
|
||||
all
|
||||
}
|
||||
_ if event.modifiers.alt => {
|
||||
let mut all = self.selections.clone();
|
||||
all.push(Selection::caret(at));
|
||||
all
|
||||
}
|
||||
_ => {
|
||||
self.dragging = true;
|
||||
vec![Selection::caret(at)]
|
||||
}
|
||||
};
|
||||
self.set_selections(chosen, cx);
|
||||
}
|
||||
|
||||
/// A drag over a line stretches the primary selection to where the pointer is.
|
||||
pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context<Self>) {
|
||||
if !self.dragging || !event.dragging() {
|
||||
return;
|
||||
}
|
||||
let at = self.offset_in(line, event.position.x, cx);
|
||||
let mut all = self.selections.clone();
|
||||
let primary = all.last_mut().expect("an editor keeps a cursor");
|
||||
if primary.head == at {
|
||||
return;
|
||||
}
|
||||
primary.head = at;
|
||||
primary.goal = None;
|
||||
self.set_selections(all, cx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
mod buffer;
|
||||
mod chat;
|
||||
mod console;
|
||||
mod cursor;
|
||||
mod decor;
|
||||
mod edit;
|
||||
mod extensions;
|
||||
mod filters;
|
||||
mod find;
|
||||
mod gutter;
|
||||
mod hints;
|
||||
mod hover;
|
||||
mod ime;
|
||||
mod keybindings;
|
||||
mod keys;
|
||||
mod layout;
|
||||
mod line;
|
||||
mod moves;
|
||||
mod preview;
|
||||
mod problems;
|
||||
mod refactor;
|
||||
mod row;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod status;
|
||||
mod symbols;
|
||||
mod syntax;
|
||||
mod tasks;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod view;
|
||||
mod welcome;
|
||||
|
||||
pub use buffer::Buffer;
|
||||
pub use chat::InlineChat;
|
||||
pub use console::{ConsoleEntry, DebugConsole, OutputPanel};
|
||||
pub use cursor::Selection;
|
||||
pub use decor::{
|
||||
CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint, ReadOnlyBanner, VimMode,
|
||||
VimModeIndicator,
|
||||
};
|
||||
pub use extensions::{Extension, ExtensionAction, ExtensionState, ExtensionsPanel};
|
||||
pub use filters::{SearchFilters, passes};
|
||||
pub use find::{FindOptions, FindWidget, find_all};
|
||||
pub use hints::{CompletionItem, CompletionMenu, Signature, SignatureHelp, completions};
|
||||
pub use hover::HoverInfo;
|
||||
pub use keybindings::{KeySource, Keybinding, KeybindingsEditor, conflicts};
|
||||
pub(crate) use keys::bind_keys;
|
||||
pub use preview::ThemePreview;
|
||||
pub use problems::{Problem, ProblemsPanel};
|
||||
pub use refactor::{ActionKind, CodeAction, CodeActionMenu, RenameInput};
|
||||
pub use search::{FileHits, Hit, ReferencesPanel, SearchPanel, SearchResultItem};
|
||||
pub use settings::{Setting, SettingValue, SettingsEditor, settings_json};
|
||||
pub use state::{CodeEditor, CursorShape, EditorEvent, LineNumbers};
|
||||
pub use status::{
|
||||
BranchIndicator, CursorPosition, Encoding, IndentSettings, LanguageMode, LineEnding, LspState,
|
||||
LspStatus, NotificationBell,
|
||||
};
|
||||
pub use symbols::{Call, CallHierarchy, GoToSymbol, Symbol, SymbolKind, SymbolOutline};
|
||||
pub use tasks::{Task, TaskRunner, TaskState};
|
||||
pub use welcome::{Project, ProjectSwitcher, RecentProjects, WelcomePage};
|
||||
@@ -0,0 +1,173 @@
|
||||
use gpui::{Context, ScrollStrategy};
|
||||
|
||||
use super::{
|
||||
cursor::{Motion, Selection, merged, moved},
|
||||
layout::{Row, hidden, rows},
|
||||
state::CodeEditor,
|
||||
syntax::folds,
|
||||
};
|
||||
|
||||
impl CodeEditor {
|
||||
/// Moves every cursor. Up and down step over folded lines.
|
||||
pub(crate) fn motion(&mut self, motion: Motion, extend: bool, cx: &mut Context<Self>) {
|
||||
let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded);
|
||||
let vertical = matches!(motion, Motion::Up | Motion::Down | Motion::Page(_));
|
||||
let moved: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let mut next = moved(&self.buffer, *selection, motion, extend);
|
||||
while vertical && hide[self.buffer.line_of(next.head)] {
|
||||
let again = moved(&self.buffer, next, motion, extend);
|
||||
if again.head == next.head {
|
||||
break;
|
||||
}
|
||||
next = again;
|
||||
}
|
||||
next
|
||||
})
|
||||
.collect();
|
||||
let up = matches!(
|
||||
motion,
|
||||
Motion::Up | Motion::Start | Motion::LineStart | Motion::WordLeft | Motion::Left
|
||||
) || matches!(motion, Motion::Page(lines) if lines < 0);
|
||||
self.set_selections(moved, cx);
|
||||
self.reveal(!up, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn select_all(&mut self, cx: &mut Context<Self>) {
|
||||
let all = 0..self.buffer.len();
|
||||
self.select([all], cx);
|
||||
}
|
||||
|
||||
/// A new cursor a line above or below the primary one, at its column.
|
||||
pub(crate) fn add_cursor(&mut self, below: bool, cx: &mut Context<Self>) {
|
||||
let primary = self.primary();
|
||||
let motion = if below { Motion::Down } else { Motion::Up };
|
||||
let next = moved(&self.buffer, Selection::caret(primary.head), motion, false);
|
||||
if next.head == primary.head {
|
||||
return;
|
||||
}
|
||||
let mut all = self.selections.clone();
|
||||
all.push(Selection { goal: None, ..next });
|
||||
self.set_selections(all, cx);
|
||||
self.reveal(below, cx);
|
||||
}
|
||||
|
||||
/// Selects the word at the primary cursor, then each next place its text appears.
|
||||
pub(crate) fn select_next(&mut self, cx: &mut Context<Self>) {
|
||||
let primary = self.primary();
|
||||
if primary.is_empty() {
|
||||
let word = self.buffer.word_at(primary.head);
|
||||
let mut all = self.selections.clone();
|
||||
all.pop();
|
||||
all.push(Selection {
|
||||
anchor: word.start,
|
||||
head: word.end,
|
||||
goal: None,
|
||||
});
|
||||
return self.set_selections(all, cx);
|
||||
}
|
||||
let needle = self.buffer.text()[primary.range()].to_string();
|
||||
let text = self.buffer.text();
|
||||
let found = text[primary.range().end..]
|
||||
.find(&needle)
|
||||
.map(|at| primary.range().end + at)
|
||||
.or_else(|| text.find(&needle));
|
||||
let Some(start) = found else {
|
||||
return;
|
||||
};
|
||||
let range = start..start + needle.len();
|
||||
if self
|
||||
.selections
|
||||
.iter()
|
||||
.any(|selection| selection.range() == range)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut all = self.selections.clone();
|
||||
all.push(Selection {
|
||||
anchor: range.start,
|
||||
head: range.end,
|
||||
goal: None,
|
||||
});
|
||||
self.set_selections(all, cx);
|
||||
self.reveal(true, cx);
|
||||
}
|
||||
|
||||
/// Escape: one cursor again, where the primary one was, and no ghost text.
|
||||
pub(crate) fn single_cursor(&mut self, cx: &mut Context<Self>) {
|
||||
let primary = self.primary();
|
||||
self.marks.ghost = None;
|
||||
self.set_selections(vec![Selection::caret(primary.head)], cx);
|
||||
}
|
||||
|
||||
/// Folds the block that opens on a line, or opens it again; cursors inside move to its header.
|
||||
pub(crate) fn toggle_fold(&mut self, line: usize, cx: &mut Context<Self>) {
|
||||
if !self.folded.remove(&line) {
|
||||
let Some((_, end)) = folds(&self.buffer)
|
||||
.into_iter()
|
||||
.find(|(header, _)| *header == line)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let header_end = self.buffer.line_range(line).end;
|
||||
let kept: Vec<Selection> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
if (line + 1..=end).contains(&self.buffer.line_of(selection.head)) {
|
||||
Selection::caret(header_end)
|
||||
} else {
|
||||
*selection
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.selections = merged(kept);
|
||||
self.folded.insert(line);
|
||||
}
|
||||
log::info!("code editor: folded {:?}", self.folded);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Opens every fold that hides a cursor.
|
||||
pub(crate) fn unfold_cursors(&mut self) {
|
||||
if self.folded.is_empty() {
|
||||
return;
|
||||
}
|
||||
let folds = folds(&self.buffer);
|
||||
let lines: Vec<usize> = self
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| self.buffer.line_of(selection.head))
|
||||
.collect();
|
||||
self.folded.retain(|header| {
|
||||
let end = folds
|
||||
.iter()
|
||||
.find(|(start, _)| start == header)
|
||||
.map(|(_, end)| *end);
|
||||
end.is_some_and(|end| !lines.iter().any(|line| (header + 1..=end).contains(line)))
|
||||
});
|
||||
}
|
||||
|
||||
/// The row the primary cursor draws on.
|
||||
pub(crate) fn primary_row(&self) -> usize {
|
||||
let line = self.buffer.line_of(self.primary().head);
|
||||
let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded);
|
||||
rows(&self.buffer, &hide, &self.marks)
|
||||
.iter()
|
||||
.position(|row| *row == Row::Line(line))
|
||||
.expect("the primary cursor sits on a shown line")
|
||||
}
|
||||
|
||||
/// Scrolls the primary cursor into view: from below when moving down, from above when moving up.
|
||||
pub(crate) fn reveal(&mut self, down: bool, cx: &mut Context<Self>) {
|
||||
let strategy = if down {
|
||||
ScrollStrategy::Bottom
|
||||
} else {
|
||||
ScrollStrategy::Top
|
||||
};
|
||||
self.scroll.scroll_to_item(self.primary_row(), strategy);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnPick = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A theme at a glance: a small editor painted in its own palette, whatever the app's theme; a press picks it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ThemePreview {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
palette: Palette,
|
||||
selected: bool,
|
||||
on_pick: Option<OnPick>,
|
||||
}
|
||||
|
||||
impl ThemePreview {
|
||||
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, palette: Palette) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
palette,
|
||||
selected: false,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pick(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ThemePreview {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let ring = theme.colors.focus;
|
||||
let shown = self.palette;
|
||||
let syntax = shown.syntax;
|
||||
let line = |parts: Vec<(&'static str, gpui::Hsla)>, indent: usize| {
|
||||
div()
|
||||
.flex()
|
||||
.pl(theme.text_size(TextSize::Xs) * indent as f32)
|
||||
.children(
|
||||
parts
|
||||
.into_iter()
|
||||
.map(|(words, color)| div().text_color(color).child(words)),
|
||||
)
|
||||
};
|
||||
let code = div()
|
||||
.flex_1()
|
||||
.p_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(line(
|
||||
vec![
|
||||
("fn ", syntax.keyword),
|
||||
("paint", syntax.function),
|
||||
("(", syntax.punctuation),
|
||||
("mode", syntax.variable),
|
||||
(": ", syntax.punctuation),
|
||||
("Mode", syntax.type_name),
|
||||
(") {", syntax.punctuation),
|
||||
],
|
||||
0,
|
||||
))
|
||||
.child(line(vec![("// quiet by default", syntax.comment)], 2))
|
||||
.child(line(
|
||||
vec![
|
||||
("let ", syntax.keyword),
|
||||
("gap", syntax.variable),
|
||||
(" = ", syntax.operator),
|
||||
("12", syntax.number),
|
||||
(";", syntax.punctuation),
|
||||
],
|
||||
2,
|
||||
))
|
||||
.child(line(
|
||||
vec![
|
||||
("draw", syntax.function),
|
||||
("(", syntax.punctuation),
|
||||
("\"surface\"", syntax.string),
|
||||
(", ", syntax.punctuation),
|
||||
("true", syntax.constant),
|
||||
(");", syntax.punctuation),
|
||||
],
|
||||
2,
|
||||
))
|
||||
.child(line(vec![("}", syntax.punctuation)], 0));
|
||||
let pick = self.on_pick;
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.cursor_pointer()
|
||||
.when_some(pick, |card, pick| {
|
||||
card.on_click(move |_, window, cx| pick(window, cx))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.h(theme.label_width() * 1.1)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_2()
|
||||
.border_color(if self.selected { ring } else { shown.border })
|
||||
.bg(shown.bg)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(shown.surface)
|
||||
.border_b_1()
|
||||
.border_color(shown.border)
|
||||
.children(
|
||||
[shown.danger, shown.warning, shown.success]
|
||||
.map(|dot| div().size_1p5().rounded_full().bg(dot)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.child(
|
||||
div()
|
||||
.w_6()
|
||||
.bg(shown.sunken)
|
||||
.border_r_1()
|
||||
.border_color(shown.border),
|
||||
)
|
||||
.child(code),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.h_3()
|
||||
.bg(shown.surface)
|
||||
.border_t_1()
|
||||
.border_color(shown.border)
|
||||
.child(div().w_4().h_1().rounded_full().bg(shown.accent)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(if self.selected {
|
||||
FontWeight::SEMIBOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.text_color(theme.colors.fg)
|
||||
.when(self.selected, |label| {
|
||||
label.child(Icon::new(IconName::Check).size(IconSize::Sm).color(ring))
|
||||
})
|
||||
.child(self.name),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ToggleButton, ToggleItem},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A problem the tools found: where, how bad, what, and which tool said so.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Problem {
|
||||
pub path: SharedString,
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
pub severity: Severity,
|
||||
pub message: SharedString,
|
||||
pub source: Option<SharedString>,
|
||||
}
|
||||
|
||||
/// How bad a severity is, worst highest.
|
||||
pub(crate) fn weight(severity: Severity) -> u8 {
|
||||
match severity {
|
||||
Severity::Danger => 3,
|
||||
Severity::Warning => 2,
|
||||
Severity::Info => 1,
|
||||
Severity::Success => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Problems by file, worst first within each, and each file in the order its worst problem ranks.
|
||||
pub(crate) fn grouped(
|
||||
problems: &[Problem],
|
||||
shown: &[Severity],
|
||||
) -> Vec<(SharedString, Vec<Problem>)> {
|
||||
let mut files: Vec<(SharedString, Vec<Problem>)> = Vec::new();
|
||||
for problem in problems
|
||||
.iter()
|
||||
.filter(|problem| shown.contains(&problem.severity))
|
||||
{
|
||||
match files.iter_mut().find(|(path, _)| *path == problem.path) {
|
||||
Some((_, list)) => list.push(problem.clone()),
|
||||
None => files.push((problem.path.clone(), vec![problem.clone()])),
|
||||
}
|
||||
}
|
||||
for (_, list) in &mut files {
|
||||
list.sort_by(|a, b| {
|
||||
weight(b.severity)
|
||||
.cmp(&weight(a.severity))
|
||||
.then(a.line.cmp(&b.line))
|
||||
});
|
||||
}
|
||||
files.sort_by_key(|(_, list)| {
|
||||
std::cmp::Reverse(list.first().map_or(0, |problem| weight(problem.severity)))
|
||||
});
|
||||
files
|
||||
}
|
||||
|
||||
type OnOpen = Rc<dyn Fn(&Problem, &mut Window, &mut App)>;
|
||||
type OnShown = Rc<dyn Fn(Vec<Severity>, &mut Window, &mut App)>;
|
||||
|
||||
/// Every problem in the workspace, grouped by file: toggles by severity with their counts, and a press opens one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProblemsPanel {
|
||||
id: ElementId,
|
||||
problems: Vec<Problem>,
|
||||
shown: Vec<Severity>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_shown: Option<OnShown>,
|
||||
}
|
||||
|
||||
impl ProblemsPanel {
|
||||
pub fn new(id: impl Into<ElementId>, problems: impl IntoIterator<Item = Problem>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
problems: problems.into_iter().collect(),
|
||||
shown: vec![Severity::Danger, Severity::Warning, Severity::Info],
|
||||
on_open: None,
|
||||
on_shown: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The severities listed; the rest are counted but hidden.
|
||||
pub fn shown(mut self, severities: impl IntoIterator<Item = Severity>) -> Self {
|
||||
self.shown = severities.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(&Problem, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_shown(
|
||||
mut self,
|
||||
handler: impl Fn(Vec<Severity>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_shown = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProblemsPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = |severity: Severity| {
|
||||
self.problems
|
||||
.iter()
|
||||
.filter(|problem| problem.severity == severity)
|
||||
.count()
|
||||
};
|
||||
let filters = [
|
||||
(Severity::Danger, "errors", "Error"),
|
||||
(Severity::Warning, "warnings", "Warning"),
|
||||
(Severity::Info, "notes", "Note"),
|
||||
]
|
||||
.map(|(severity, key, words)| {
|
||||
let (shown, on_shown) = (self.shown.clone(), self.on_shown.clone());
|
||||
let found = count(severity);
|
||||
let label = format!("{found} {words}{}", if found == 1 { "" } else { "s" });
|
||||
ToggleButton::new(
|
||||
(self.id.clone(), key),
|
||||
ToggleItem::new(key).icon(severity.icon()).label(label),
|
||||
self.shown.contains(&severity),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |on, window, cx| {
|
||||
let mut next: Vec<Severity> = shown
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|kept| *kept != severity)
|
||||
.collect();
|
||||
if on {
|
||||
next.push(severity);
|
||||
}
|
||||
log::info!("problems: showing {next:?}");
|
||||
if let Some(on_shown) = &on_shown {
|
||||
on_shown(next, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
let files = grouped(&self.problems, &self.shown);
|
||||
let mut rows = Vec::new();
|
||||
for (file_ix, (path, list)) in files.iter().enumerate() {
|
||||
let (name, dir) = path
|
||||
.rsplit_once('/')
|
||||
.map_or((path.to_string(), String::new()), |(dir, name)| {
|
||||
(name.to_string(), dir.to_string())
|
||||
});
|
||||
rows.push(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_1()
|
||||
.pt_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
Icon::new(IconName::FileText)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(div().text_color(colors.fg).child(name))
|
||||
.child(div().text_color(colors.fg_subtle).child(dir))
|
||||
.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.rounded_full()
|
||||
.bg(colors.hover)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(list.len().to_string()),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
for (ix, problem) in list.iter().enumerate() {
|
||||
let (open, picked) = (self.on_open.clone(), problem.clone());
|
||||
rows.push(
|
||||
div()
|
||||
.id((self.id.clone(), format!("problem-{file_ix}-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.pl_6()
|
||||
.pr_2()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when_some(open, |row, open| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| {
|
||||
window.prevent_default()
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("problems: open {}:{}", picked.path, picked.line + 1);
|
||||
open(&picked, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Icon::new(problem.severity.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(problem.severity.color(&colors)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg)
|
||||
.child(Ellipsis::new(problem.message.clone())),
|
||||
)
|
||||
.children(
|
||||
problem
|
||||
.source
|
||||
.clone()
|
||||
.map(|source| div().text_color(colors.fg_subtle).child(source)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child(format!("{}:{}", problem.line + 1, problem.column + 1)),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let empty = rows.is_empty();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(div().flex().gap_1().children(filters))
|
||||
.when(empty, |panel| {
|
||||
panel.child(
|
||||
div()
|
||||
.py_4()
|
||||
.text_center()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("No problems shown."),
|
||||
)
|
||||
})
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn problem(path: &str, line: usize, severity: Severity) -> Problem {
|
||||
Problem {
|
||||
path: path.to_string().into(),
|
||||
line,
|
||||
column: 0,
|
||||
severity,
|
||||
message: "m".into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_worse_problems_lead_and_filters_hide() {
|
||||
let problems = [
|
||||
problem("a.rs", 9, Severity::Warning),
|
||||
problem("b.rs", 3, Severity::Danger),
|
||||
problem("a.rs", 2, Severity::Info),
|
||||
];
|
||||
let all = grouped(
|
||||
&problems,
|
||||
&[Severity::Danger, Severity::Warning, Severity::Info],
|
||||
);
|
||||
assert_eq!(all[0].0.as_ref(), "b.rs");
|
||||
assert_eq!(all[1].1[0].line, 9, "the warning before the note");
|
||||
let only = grouped(&problems, &[Severity::Info]);
|
||||
assert_eq!(only.len(), 1);
|
||||
assert_eq!(only[0].1.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{Enter, Input, TextInput},
|
||||
menus::{Menu, MenuItem, OverflowMenu},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// What a code action does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ActionKind {
|
||||
QuickFix,
|
||||
Refactor,
|
||||
Source,
|
||||
}
|
||||
|
||||
/// A change the tools offer at the cursor; a preferred one leads its group.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CodeAction {
|
||||
pub title: SharedString,
|
||||
pub kind: ActionKind,
|
||||
pub preferred: bool,
|
||||
}
|
||||
|
||||
impl CodeAction {
|
||||
pub fn new(title: impl Into<SharedString>, kind: ActionKind) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
kind,
|
||||
preferred: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preferred(mut self) -> Self {
|
||||
self.preferred = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
type OnAction = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// A lightbulb that opens the actions at the cursor: fixes, refactors and source actions, each group led by its preferred one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CodeActionMenu {
|
||||
id: ElementId,
|
||||
actions: Vec<CodeAction>,
|
||||
on_pick: Option<OnAction>,
|
||||
}
|
||||
|
||||
impl CodeActionMenu {
|
||||
pub fn new(id: impl Into<ElementId>, actions: impl IntoIterator<Item = CodeAction>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
actions: actions.into_iter().collect(),
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the chosen action's place among the actions given.
|
||||
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CodeActionMenu {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let groups = [
|
||||
(ActionKind::QuickFix, "Quick fixes"),
|
||||
(ActionKind::Refactor, "Refactor"),
|
||||
(ActionKind::Source, "Source"),
|
||||
];
|
||||
let menu = groups.into_iter().fold(Menu::new(), |menu, (kind, title)| {
|
||||
let mut found: Vec<(usize, &CodeAction)> = self
|
||||
.actions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, action)| action.kind == kind)
|
||||
.collect();
|
||||
if found.is_empty() {
|
||||
return menu;
|
||||
}
|
||||
found.sort_by_key(|(_, action)| !action.preferred);
|
||||
let items = found.into_iter().map(|(ix, action)| {
|
||||
let pick = self.on_pick.clone();
|
||||
let item = MenuItem::new(action.title.clone());
|
||||
let item = if action.preferred {
|
||||
item.icon(IconName::Sparkles)
|
||||
} else {
|
||||
item
|
||||
};
|
||||
item.on_click(move |window, cx| {
|
||||
log::info!("code action: {ix}");
|
||||
if let Some(pick) = &pick {
|
||||
pick(ix, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
menu.group(title, items)
|
||||
});
|
||||
OverflowMenu::new(self.id, menu)
|
||||
.icon(IconName::Lightbulb)
|
||||
.tooltip("Show code actions")
|
||||
}
|
||||
}
|
||||
|
||||
type OnName = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
|
||||
type OnCancel = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A small box over a symbol to rename it: Enter renames everywhere, Escape keeps the name.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RenameInput {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_rename: Option<OnName>,
|
||||
on_cancel: Option<OnCancel>,
|
||||
}
|
||||
|
||||
impl RenameInput {
|
||||
/// `field` holds the new name; seed it with the current one.
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_rename: None,
|
||||
on_cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_rename(
|
||||
mut self,
|
||||
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_rename = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_cancel = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RenameInput {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (field, rename, cancel) = (self.field.clone(), self.on_rename, self.on_cancel);
|
||||
div()
|
||||
.id(self.id)
|
||||
.w(theme.label_width() * 2.5)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.focus)
|
||||
.bg(colors.overlay)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
let name = field.read(cx).text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("rename: {name}");
|
||||
if let Some(rename) = &rename {
|
||||
rename(name.into(), window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down(move |event: &KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
if let Some(cancel) = &cancel {
|
||||
cancel(window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.child(Input::new(&self.field).size(ControlSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.px_0p5()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Enter to rename · Escape to cancel"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Stateful,
|
||||
Styled, StyledText, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
chat::InlineChat,
|
||||
gutter::Cell,
|
||||
layout::{CHAT_ROWS, Row},
|
||||
state::{CodeEditor, EditorEvent},
|
||||
syntax,
|
||||
};
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
impl CodeEditor {
|
||||
/// Row `ix` as the list draws it.
|
||||
pub(crate) fn row(&self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let base = div()
|
||||
.id(("editor-row", ix))
|
||||
.w_full()
|
||||
.h(self.frame.line)
|
||||
.flex();
|
||||
match self.frame.rows[ix] {
|
||||
Row::Line(line) => self.line_row(base, ix, line, window, cx),
|
||||
Row::Lens(at) => self.lens_row(base, ix, at, cx),
|
||||
Row::Removed(hunk, at) => self.removed_row(base, ix, hunk, at, cx),
|
||||
Row::Ghost(at) => self.ghost_row(base, ix, at, cx),
|
||||
Row::Chat(at) => self.chat_row(base, ix, at, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn lens_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let gutter = self.gutter(Cell::Blank, ix, cx);
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let lens = &self.marks.lenses[at];
|
||||
let indent = self.buffer.indent(lens.line);
|
||||
let items = lens.items.iter().enumerate().map(|(item, words)| {
|
||||
let line = lens.line;
|
||||
div()
|
||||
.id(("lens", item))
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.text_color(colors.link))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
log::info!("code lens: line {line}, item {item}");
|
||||
cx.emit(EditorEvent::Lens(line, item));
|
||||
}))
|
||||
.child(words.clone())
|
||||
});
|
||||
let items: Vec<AnyElement> = items
|
||||
.enumerate()
|
||||
.flat_map(|(place, item)| {
|
||||
let gap = (place > 0).then(|| div().child("·").into_any_element());
|
||||
gap.into_iter().chain([item.into_any_element()])
|
||||
})
|
||||
.collect();
|
||||
base.child(gutter)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl(self.frame.advance * (indent + 1) as f32)
|
||||
.font_family(theme.font_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.children(items),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn removed_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
hunk: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let text = self.marks.hunks[hunk].removed[at].clone();
|
||||
base.child(self.gutter(Cell::Removed, ix, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.pl(self.frame.advance)
|
||||
.bg(colors.danger.opacity(0.08))
|
||||
.line_height(self.frame.line)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn ghost_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let ghost = self
|
||||
.marks
|
||||
.ghost
|
||||
.as_ref()
|
||||
.expect("a ghost row has its ghost");
|
||||
let lines: Vec<&str> = ghost.text.split('\n').collect();
|
||||
let text = lines[at].to_string();
|
||||
let rest = (at + 1 == lines.len()).then(|| {
|
||||
let line = self.buffer.line_of(ghost.offset);
|
||||
let rest =
|
||||
self.buffer.text()[ghost.offset..self.buffer.line_range(line).end].to_string();
|
||||
let styles = syntax::colors(&rest, cx);
|
||||
StyledText::new(rest).with_highlights(styles)
|
||||
});
|
||||
base.child(self.gutter(Cell::Blank, ix, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.pl(self.frame.advance)
|
||||
.line_height(self.frame.line)
|
||||
.whitespace_nowrap()
|
||||
.child(div().italic().text_color(colors.fg_subtle).child(text))
|
||||
.children(rest),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chat_row(
|
||||
&self,
|
||||
base: Stateful<gpui::Div>,
|
||||
ix: usize,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let gutter = self.gutter(Cell::Blank, ix, cx);
|
||||
let theme = cx.theme();
|
||||
let row = base.child(gutter);
|
||||
if at > 0 {
|
||||
return row.into_any_element();
|
||||
}
|
||||
let asked = cx.entity();
|
||||
let closed = cx.entity();
|
||||
let field = self.chat_field.clone();
|
||||
row.child(
|
||||
div().relative().flex_1().h_full().child(
|
||||
InlineChat::new(("inline-chat", ix), &self.chat_field)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left(self.frame.advance)
|
||||
.w(self.frame.advance * 72.0)
|
||||
.h(self.frame.line * CHAT_ROWS as f32)
|
||||
.font_family(theme.font_family.clone())
|
||||
.on_ask(move |prompt, _, cx| {
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
asked.update(cx, |_, cx| cx.emit(EditorEvent::Asked(prompt)));
|
||||
})
|
||||
.on_close(move |window, cx| {
|
||||
closed.update(cx, |editor, cx| editor.set_chat(None, window, cx));
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledText, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A line that matched: its number, its text, and where in it the matches lie.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Hit {
|
||||
pub line: usize,
|
||||
pub text: SharedString,
|
||||
pub ranges: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
/// A file and the lines in it that matched.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FileHits {
|
||||
pub path: SharedString,
|
||||
pub hits: Vec<Hit>,
|
||||
}
|
||||
|
||||
/// Characters kept before the first match when a line is long.
|
||||
const LEAD: usize = 24;
|
||||
|
||||
/// A hit's text trimmed to start near its first match, with its ranges moved along.
|
||||
fn trimmed(hit: &Hit) -> (String, Vec<Range<usize>>) {
|
||||
let first = hit.ranges.first().map_or(0, |range| range.start);
|
||||
let before = hit.text[..first].chars().count();
|
||||
let text = hit.text.trim_end();
|
||||
if before <= LEAD {
|
||||
let start = text.len() - text.trim_start().len();
|
||||
let start = start.min(first);
|
||||
let ranges = hit
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|range| range.start - start..range.end - start)
|
||||
.collect();
|
||||
return (text[start..].to_string(), ranges);
|
||||
}
|
||||
let cut = hit.text[..first]
|
||||
.char_indices()
|
||||
.nth(before - LEAD)
|
||||
.map_or(first, |(at, _)| at);
|
||||
let shown = format!("…{}", &text[cut..]);
|
||||
let shift = '…'.len_utf8();
|
||||
let ranges = hit
|
||||
.ranges
|
||||
.iter()
|
||||
.filter(|range| range.start >= cut)
|
||||
.map(|range| range.start - cut + shift..range.end - cut + shift)
|
||||
.collect();
|
||||
(shown, ranges)
|
||||
}
|
||||
|
||||
type OnOpen = Rc<dyn Fn(&SharedString, usize, &mut Window, &mut App)>;
|
||||
type OnPath = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// One matched line: its number, and its text trimmed near the first match with the matches marked; a replacement shows struck out beside the new text.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchResultItem {
|
||||
id: ElementId,
|
||||
path: SharedString,
|
||||
hit: Hit,
|
||||
replacement: Option<SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
}
|
||||
|
||||
impl SearchResultItem {
|
||||
pub fn new(id: impl Into<ElementId>, path: impl Into<SharedString>, hit: Hit) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
path: path.into(),
|
||||
hit,
|
||||
replacement: None,
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Previews replacing each match with this.
|
||||
pub fn replacement(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.replacement = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchResultItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (text, ranges) = trimmed(&self.hit);
|
||||
let (text, styles) = match &self.replacement {
|
||||
None => {
|
||||
let style = HighlightStyle {
|
||||
background_color: Some(colors.warning.opacity(0.25)),
|
||||
font_weight: Some(FontWeight::SEMIBOLD),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
(
|
||||
text,
|
||||
ranges
|
||||
.into_iter()
|
||||
.map(|range| (range, style))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
Some(new) => {
|
||||
let (mut out, mut styles, mut last) = (String::new(), Vec::new(), 0);
|
||||
let old = HighlightStyle {
|
||||
background_color: Some(colors.danger.opacity(0.18)),
|
||||
strikethrough: Some(gpui::StrikethroughStyle {
|
||||
thickness: theme.underline_thickness(),
|
||||
color: Some(colors.danger),
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let added = HighlightStyle {
|
||||
background_color: Some(colors.success.opacity(0.18)),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
for range in ranges {
|
||||
out.push_str(&text[last..range.start]);
|
||||
styles.push((out.len()..out.len() + range.len(), old));
|
||||
out.push_str(&text[range.clone()]);
|
||||
styles.push((out.len()..out.len() + new.len(), added));
|
||||
out.push_str(new);
|
||||
last = range.end;
|
||||
}
|
||||
out.push_str(&text[last..]);
|
||||
(out, styles)
|
||||
}
|
||||
};
|
||||
let (path, line) = (self.path.clone(), self.hit.line);
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when_some(self.on_open, |row, open| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("search result: {path}:{}", line + 1);
|
||||
open(&path, line, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(theme.label_width() * 0.4)
|
||||
.text_right()
|
||||
.text_color(colors.fg_subtle)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.child((self.hit.line + 1).to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg_muted)
|
||||
.child(StyledText::new(text).with_highlights(styles)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Results grouped by file, each with its count and a chevron that folds it; the totals lead.
|
||||
fn results(
|
||||
id: &ElementId,
|
||||
files: &[FileHits],
|
||||
folded: &[SharedString],
|
||||
replacement: Option<&SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_fold: Option<OnPath>,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mut out = Vec::new();
|
||||
for (file_ix, file) in files.iter().enumerate() {
|
||||
let open = !folded.contains(&file.path);
|
||||
let (name, dir) = match file.path.rsplit_once('/') {
|
||||
Some((dir, name)) => (name.to_string(), dir.to_string()),
|
||||
None => (file.path.to_string(), String::new()),
|
||||
};
|
||||
let fold = on_fold.clone();
|
||||
let path = file.path.clone();
|
||||
out.push(
|
||||
div()
|
||||
.id((id.clone(), format!("file-{file_ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when_some(fold, |row, fold| {
|
||||
row.on_click(move |_, window, cx| fold(&path, window, cx))
|
||||
})
|
||||
.child(Disclosure::new(
|
||||
(id.clone(), format!("fold-{file_ix}")),
|
||||
open,
|
||||
))
|
||||
.child(
|
||||
Icon::new(IconName::FileText)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(div().text_color(colors.fg).child(name))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(dir),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.rounded_full()
|
||||
.bg(colors.hover)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(file.hits.len().to_string()),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
for (hit_ix, hit) in file.hits.iter().enumerate() {
|
||||
let item = SearchResultItem::new(
|
||||
(id.clone(), format!("hit-{file_ix}-{hit_ix}")),
|
||||
file.path.clone(),
|
||||
hit.clone(),
|
||||
);
|
||||
let item = match replacement {
|
||||
Some(text) => item.replacement(text.clone()),
|
||||
None => item,
|
||||
};
|
||||
let item = match &on_open {
|
||||
Some(open) => {
|
||||
let open = open.clone();
|
||||
item.on_open(move |path, line, window, cx| open(path, line, window, cx))
|
||||
}
|
||||
None => item,
|
||||
};
|
||||
out.push(div().pl_5().child(item).into_any_element());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Results across files, folding by file, with the totals above; a replacement previews on each line.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchPanel {
|
||||
id: ElementId,
|
||||
files: Vec<FileHits>,
|
||||
folded: Vec<SharedString>,
|
||||
replacement: Option<SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_fold: Option<OnPath>,
|
||||
}
|
||||
|
||||
impl SearchPanel {
|
||||
pub fn new(id: impl Into<ElementId>, files: impl IntoIterator<Item = FileHits>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
files: files.into_iter().collect(),
|
||||
folded: Vec::new(),
|
||||
replacement: None,
|
||||
on_open: None,
|
||||
on_fold: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths whose results are folded away.
|
||||
pub fn folded(mut self, paths: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.folded = paths.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn replacement(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.replacement = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets a path whose chevron was pressed.
|
||||
pub fn on_fold(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_fold = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let hits: usize = self
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.hits.iter().map(|hit| hit.ranges.len()).sum::<usize>())
|
||||
.sum();
|
||||
let summary = format!("{hits} results in {} files", self.files.len());
|
||||
let rows = results(
|
||||
&self.id,
|
||||
&self.files,
|
||||
&self.folded,
|
||||
self.replacement.as_ref(),
|
||||
self.on_open,
|
||||
self.on_fold,
|
||||
cx,
|
||||
);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.pb_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(summary),
|
||||
)
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a symbol is used, grouped by file, under its name and count.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReferencesPanel {
|
||||
id: ElementId,
|
||||
symbol: SharedString,
|
||||
files: Vec<FileHits>,
|
||||
on_open: Option<OnOpen>,
|
||||
}
|
||||
|
||||
impl ReferencesPanel {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
symbol: impl Into<SharedString>,
|
||||
files: impl IntoIterator<Item = FileHits>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
symbol: symbol.into(),
|
||||
files: files.into_iter().collect(),
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReferencesPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count: usize = self.files.iter().map(|file| file.hits.len()).sum();
|
||||
let rows = results(&self.id, &self.files, &[], None, self.on_open, None, cx);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.pb_1()
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg)
|
||||
.child(self.symbol),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("{count} references")),
|
||||
),
|
||||
)
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn long_lines_start_near_their_first_match() {
|
||||
let needle = 40..46;
|
||||
let hit = Hit {
|
||||
line: 0,
|
||||
text: format!("{}needle end", "x".repeat(40)).into(),
|
||||
ranges: vec![needle],
|
||||
};
|
||||
let (text, ranges) = trimmed(&hit);
|
||||
assert!(text.starts_with('…'));
|
||||
assert_eq!(&text[ranges[0].clone()], "needle");
|
||||
let needle = 8..14;
|
||||
let short = Hit {
|
||||
line: 0,
|
||||
text: " let needle = 1;".into(),
|
||||
ranges: vec![needle],
|
||||
};
|
||||
let (text, ranges) = trimmed(&short);
|
||||
assert_eq!(text, "let needle = 1;", "leading spaces go");
|
||||
assert_eq!(&text[ranges[0].clone()], "needle");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton, SegmentedControl},
|
||||
forms::{Choice, JsonInput, NumberInput, Select, Switch, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A setting's value and the form it takes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SettingValue {
|
||||
Bool(bool),
|
||||
Number {
|
||||
value: f64,
|
||||
min: f64,
|
||||
max: f64,
|
||||
step: f64,
|
||||
},
|
||||
Choice {
|
||||
value: SharedString,
|
||||
choices: Vec<SharedString>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SettingValue {
|
||||
fn json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
SettingValue::Bool(on) => (*on).into(),
|
||||
SettingValue::Number { value, .. } => serde_json::Number::from_f64(*value)
|
||||
.map(serde_json::Value::Number)
|
||||
.expect("a setting's number is finite"),
|
||||
SettingValue::Choice { value, .. } => value.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One setting: its dotted key, title, what it does, its value and the value it starts from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Setting {
|
||||
pub key: SharedString,
|
||||
pub title: SharedString,
|
||||
pub description: SharedString,
|
||||
pub value: SettingValue,
|
||||
pub default: SettingValue,
|
||||
}
|
||||
|
||||
impl Setting {
|
||||
pub fn modified(&self) -> bool {
|
||||
self.value != self.default
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings changed from their defaults, as a settings file holds them.
|
||||
pub fn settings_json(groups: &[(SharedString, Vec<Setting>)]) -> String {
|
||||
let map: serde_json::Map<String, serde_json::Value> = groups
|
||||
.iter()
|
||||
.flat_map(|(_, settings)| settings)
|
||||
.filter(|setting| setting.modified())
|
||||
.map(|setting| (setting.key.to_string(), setting.value.json()))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("settings serialize")
|
||||
}
|
||||
|
||||
type OnChange = Rc<dyn Fn(&SharedString, SettingValue, &mut Window, &mut App)>;
|
||||
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Settings two ways: as rows of controls by section, each changed one marked with a way back, or as the JSON a settings file holds.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SettingsEditor {
|
||||
id: ElementId,
|
||||
groups: Vec<(SharedString, Vec<Setting>)>,
|
||||
section: usize,
|
||||
json: Option<Entity<TextInput>>,
|
||||
on_change: Option<OnChange>,
|
||||
on_section: Option<OnIndex>,
|
||||
on_json: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl SettingsEditor {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
groups: impl IntoIterator<Item = (impl Into<SharedString>, Vec<Setting>)>,
|
||||
) -> Self {
|
||||
let groups: Vec<(SharedString, Vec<Setting>)> = groups
|
||||
.into_iter()
|
||||
.map(|(title, settings)| (title.into(), settings))
|
||||
.collect();
|
||||
assert!(!groups.is_empty(), "settings come in at least one section");
|
||||
Self {
|
||||
id: id.into(),
|
||||
groups,
|
||||
section: 0,
|
||||
json: None,
|
||||
on_change: None,
|
||||
on_section: None,
|
||||
on_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn section(mut self, section: usize) -> Self {
|
||||
self.section = section;
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the JSON, held in `field`; seed it with `settings_json`.
|
||||
pub fn json(mut self, field: &Entity<TextInput>) -> Self {
|
||||
self.json = Some(field.clone());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, SettingValue, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_section(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_section = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets whether the JSON view is wanted.
|
||||
pub fn on_json(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_json = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SettingsEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mode = SegmentedControl::new(
|
||||
(self.id.clone(), "mode"),
|
||||
if self.json.is_some() { "json" } else { "ui" },
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("ui", "Settings", None)
|
||||
.segment("json", "JSON", None);
|
||||
let mode = match self.on_json.clone() {
|
||||
Some(on_json) => {
|
||||
mode.on_change(move |key, window, cx| on_json(key.as_ref() == "json", window, cx))
|
||||
}
|
||||
None => mode,
|
||||
};
|
||||
let header = div().flex().items_center().justify_between().child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child("Settings"),
|
||||
);
|
||||
let header = header.child(mode);
|
||||
if let Some(field) = self.json {
|
||||
return div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(header)
|
||||
.child(JsonInput::new(&field))
|
||||
.into_any_element();
|
||||
}
|
||||
let section = self.section.min(self.groups.len() - 1);
|
||||
let nav = self
|
||||
.groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (title, settings))| {
|
||||
let pick = self.on_section.clone();
|
||||
let changed = settings.iter().filter(|setting| setting.modified()).count();
|
||||
div()
|
||||
.id((self.id.clone(), format!("section-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.when(ix == section, |row| {
|
||||
row.bg(colors.active).text_color(colors.fg)
|
||||
})
|
||||
.when(ix != section, |row| {
|
||||
row.text_color(colors.fg_muted)
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
})
|
||||
.when_some(pick, |row, pick| {
|
||||
row.on_click(move |_, window, cx| pick(ix, window, cx))
|
||||
})
|
||||
.child(title.clone())
|
||||
.when(changed > 0, |row| {
|
||||
row.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.accent)
|
||||
.child(changed.to_string()),
|
||||
)
|
||||
})
|
||||
});
|
||||
let rows = self.groups[section]
|
||||
.1
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, setting)| {
|
||||
let id = ElementId::from((self.id.clone(), format!("{section}-{ix}")));
|
||||
let (change, key) = (self.on_change.clone(), setting.key.clone());
|
||||
let tell = move |value: SettingValue, window: &mut Window, cx: &mut App| {
|
||||
log::info!("settings: {key} = {value:?}");
|
||||
if let Some(change) = &change {
|
||||
change(&key, value, window, cx);
|
||||
}
|
||||
};
|
||||
let tell = Rc::new(tell);
|
||||
let control = match &setting.value {
|
||||
SettingValue::Bool(on) => {
|
||||
let tell = tell.clone();
|
||||
Switch::new(id.clone(), *on)
|
||||
.on_change(move |on, window, cx| {
|
||||
tell(SettingValue::Bool(on), window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
SettingValue::Number {
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
} => {
|
||||
let (tell, min, max, step) = (tell.clone(), *min, *max, *step);
|
||||
div()
|
||||
.w(theme.label_width())
|
||||
.child(
|
||||
NumberInput::new(id.clone(), *value)
|
||||
.range(min, max)
|
||||
.step(step)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, window, cx| {
|
||||
tell(
|
||||
SettingValue::Number {
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
SettingValue::Choice { value, choices } => {
|
||||
let (tell, all) = (tell.clone(), choices.clone());
|
||||
div()
|
||||
.w(theme.label_width() * 1.5)
|
||||
.child(
|
||||
Select::new(
|
||||
id.clone(),
|
||||
choices
|
||||
.iter()
|
||||
.map(|choice| Choice::new(choice.clone(), choice.clone())),
|
||||
)
|
||||
.selected(value.clone())
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(
|
||||
move |picked, window, cx| {
|
||||
let value = SettingValue::Choice {
|
||||
value: picked.clone(),
|
||||
choices: all.clone(),
|
||||
};
|
||||
tell(value, window, cx)
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let reset = setting.modified().then(|| {
|
||||
let (tell, default) = (tell.clone(), setting.default.clone());
|
||||
IconButton::new(
|
||||
(self.id.clone(), format!("reset-{section}-{ix}")),
|
||||
IconName::RotateCcw,
|
||||
)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Reset to default")
|
||||
.on_click(move |_, window, cx| tell(default.clone(), window, cx))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.py_3()
|
||||
.pl_3()
|
||||
.border_l_2()
|
||||
.border_color(if setting.modified() {
|
||||
colors.accent
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(setting.title.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(setting.key.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(setting.description.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.children(reset)
|
||||
.child(control),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(header)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 1.25)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.children(nav),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().flex().flex_col().children(rows)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_holds_only_what_changed() {
|
||||
let groups = vec![(
|
||||
SharedString::from("Editor"),
|
||||
vec![
|
||||
Setting {
|
||||
key: "editor.fontSize".into(),
|
||||
title: "Font size".into(),
|
||||
description: "".into(),
|
||||
value: SettingValue::Number {
|
||||
value: 15.0,
|
||||
min: 8.0,
|
||||
max: 32.0,
|
||||
step: 1.0,
|
||||
},
|
||||
default: SettingValue::Number {
|
||||
value: 14.0,
|
||||
min: 8.0,
|
||||
max: 32.0,
|
||||
step: 1.0,
|
||||
},
|
||||
},
|
||||
Setting {
|
||||
key: "editor.minimap".into(),
|
||||
title: "Minimap".into(),
|
||||
description: "".into(),
|
||||
value: SettingValue::Bool(true),
|
||||
default: SettingValue::Bool(true),
|
||||
},
|
||||
],
|
||||
)];
|
||||
let json: serde_json::Value = serde_json::from_str(&settings_json(&groups)).expect("json");
|
||||
assert_eq!(json, serde_json::json!({ "editor.fontSize": 15.0 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use std::{collections::BTreeSet, ops::Range, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, ScrollStrategy,
|
||||
SharedString, Subscription, Task, UniformListScrollHandle, Window,
|
||||
};
|
||||
|
||||
use super::{
|
||||
buffer::Buffer,
|
||||
cursor::{Selection, merged},
|
||||
decor::{CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint},
|
||||
view::Frame,
|
||||
};
|
||||
use crate::{
|
||||
forms::{History, InputEvent, TextInput},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
|
||||
/// How the gutter numbers lines.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LineNumbers {
|
||||
#[default]
|
||||
Absolute,
|
||||
/// Distances from the cursor's line, which shows its own number.
|
||||
Relative,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// How the cursor draws.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CursorShape {
|
||||
#[default]
|
||||
Line,
|
||||
Block,
|
||||
Underline,
|
||||
}
|
||||
|
||||
/// What the editor tells its owner.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum EditorEvent {
|
||||
Changed,
|
||||
/// A press on a code lens: its line and the item's place.
|
||||
Lens(usize, usize),
|
||||
/// The inline chat's prompt, sent.
|
||||
Asked(SharedString),
|
||||
ChatClosed,
|
||||
/// The read-only banner's action.
|
||||
Unlock,
|
||||
}
|
||||
|
||||
/// The editor's look and manners.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Options {
|
||||
pub language: SharedString,
|
||||
pub numbers: LineNumbers,
|
||||
pub cursor: CursorShape,
|
||||
pub whitespace: bool,
|
||||
pub guides: bool,
|
||||
pub rulers: Vec<usize>,
|
||||
pub rainbow: bool,
|
||||
pub minimap: bool,
|
||||
pub sticky: bool,
|
||||
pub read_only: bool,
|
||||
pub tab: usize,
|
||||
}
|
||||
|
||||
/// What the owner lays over the code.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Marks {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub backgrounds: Vec<(Range<usize>, gpui::Hsla)>,
|
||||
pub hints: Vec<InlayHint>,
|
||||
pub lenses: Vec<CodeLens>,
|
||||
pub ghost: Option<GhostText>,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
pub git: Vec<(usize, GitMark)>,
|
||||
pub breakpoints: BTreeSet<usize>,
|
||||
pub chat: Option<usize>,
|
||||
}
|
||||
|
||||
/// The text and cursors one undo step returns to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Snapshot {
|
||||
pub text: String,
|
||||
pub selections: Vec<Selection>,
|
||||
}
|
||||
|
||||
/// Where the code sat when last painted, in window pixels: its left edge, the top of the first row, a character's width and a row's height.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Metrics {
|
||||
pub left: Pixels,
|
||||
pub top: Pixels,
|
||||
pub advance: Pixels,
|
||||
pub line: Pixels,
|
||||
}
|
||||
|
||||
const BLINK: Duration = Duration::from_millis(530);
|
||||
|
||||
/// Code to read and edit: syntax colors, many cursors, folds, a gutter, a minimap, and what tools lay over it.
|
||||
pub struct CodeEditor {
|
||||
pub(crate) buffer: Buffer,
|
||||
pub(crate) selections: Vec<Selection>,
|
||||
pub(crate) history: History<Snapshot>,
|
||||
pub(crate) focus: FocusHandle,
|
||||
pub(crate) scroll: UniformListScrollHandle,
|
||||
pub(crate) folded: BTreeSet<usize>,
|
||||
pub(crate) options: Options,
|
||||
pub(crate) marks: Marks,
|
||||
pub(crate) marked: Option<Range<usize>>,
|
||||
pub(crate) metrics: Metrics,
|
||||
pub(crate) dragging: bool,
|
||||
pub(crate) caret_on: bool,
|
||||
pub(crate) chat_field: gpui::Entity<TextInput>,
|
||||
pub(crate) frame: Frame,
|
||||
blink_epoch: u64,
|
||||
_blink: Option<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<EditorEvent> for CodeEditor {}
|
||||
|
||||
impl Focusable for CodeEditor {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
pub fn new(text: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus = cx.focus_handle().tab_stop(true);
|
||||
let chat_field =
|
||||
cx.new(|cx| TextInput::new(window, cx).placeholder("Ask to change this code"));
|
||||
let subscriptions = vec![
|
||||
cx.on_focus(&focus, window, |editor, _, cx| editor.restart_blink(cx)),
|
||||
cx.on_blur(&focus, window, |editor, _, cx| {
|
||||
editor.dragging = false;
|
||||
editor._blink = None;
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&chat_field, |_, field, event, cx| {
|
||||
if *event != InputEvent::Submit {
|
||||
return;
|
||||
}
|
||||
let prompt = field.read(cx).text().trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("code editor: asked {prompt:?}");
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
cx.emit(EditorEvent::Asked(prompt.into()));
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
buffer: Buffer::new(text),
|
||||
selections: vec![Selection::caret(0)],
|
||||
history: History::default(),
|
||||
focus,
|
||||
scroll: UniformListScrollHandle::new(),
|
||||
folded: BTreeSet::new(),
|
||||
options: Options {
|
||||
language: "Plain text".into(),
|
||||
numbers: LineNumbers::Absolute,
|
||||
cursor: CursorShape::Line,
|
||||
whitespace: false,
|
||||
guides: true,
|
||||
rulers: Vec::new(),
|
||||
rainbow: false,
|
||||
minimap: false,
|
||||
sticky: false,
|
||||
read_only: false,
|
||||
tab: 4,
|
||||
},
|
||||
marks: Marks::default(),
|
||||
marked: None,
|
||||
metrics: Metrics::default(),
|
||||
dragging: false,
|
||||
caret_on: true,
|
||||
chat_field,
|
||||
frame: Frame::default(),
|
||||
blink_epoch: 0,
|
||||
_blink: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.options.language = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_numbers(mut self, numbers: LineNumbers) -> Self {
|
||||
self.options.numbers = numbers;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor_shape(mut self, shape: CursorShape) -> Self {
|
||||
self.options.cursor = shape;
|
||||
self
|
||||
}
|
||||
|
||||
/// Dots for spaces and arrows for tabs.
|
||||
pub fn show_whitespace(mut self) -> Self {
|
||||
self.options.whitespace = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hide_indent_guides(mut self) -> Self {
|
||||
self.options.guides = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Thin lines at these columns.
|
||||
pub fn rulers(mut self, columns: impl IntoIterator<Item = usize>) -> Self {
|
||||
self.options.rulers = columns.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Brackets colored by how deep they sit.
|
||||
pub fn rainbow_brackets(mut self) -> Self {
|
||||
self.options.rainbow = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn minimap(mut self) -> Self {
|
||||
self.options.minimap = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// The lines that open the blocks around the top line stay pinned above it.
|
||||
pub fn sticky_scroll(mut self) -> Self {
|
||||
self.options.sticky = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn read_only(mut self) -> Self {
|
||||
self.options.read_only = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Spaces a Tab inserts and an indent guide spans.
|
||||
pub fn tab_size(mut self, size: usize) -> Self {
|
||||
assert!(size > 0, "a tab spans at least one column");
|
||||
self.options.tab = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
self.buffer.text()
|
||||
}
|
||||
|
||||
pub fn selections(&self) -> &[Selection] {
|
||||
&self.selections
|
||||
}
|
||||
|
||||
/// The last cursor placed, which scrolling follows.
|
||||
pub fn primary(&self) -> Selection {
|
||||
*self.selections.last().expect("an editor keeps a cursor")
|
||||
}
|
||||
|
||||
/// Line and column of the primary cursor, both from one.
|
||||
pub fn position(&self) -> (usize, usize) {
|
||||
let (line, column) = self.buffer.point(self.primary().head);
|
||||
(line + 1, column + 1)
|
||||
}
|
||||
|
||||
pub fn language_name(&self) -> &SharedString {
|
||||
&self.options.language
|
||||
}
|
||||
|
||||
/// The line the inline chat sits under, while it is open.
|
||||
pub fn chat_line(&self) -> Option<usize> {
|
||||
self.marks.chat
|
||||
}
|
||||
|
||||
pub fn is_read_only(&self) -> bool {
|
||||
self.options.read_only
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
let before = self.snapshot();
|
||||
self.buffer = Buffer::new(text);
|
||||
self.selections = vec![Selection::caret(0)];
|
||||
self.folded.clear();
|
||||
self.commit(before, false, cx);
|
||||
}
|
||||
|
||||
pub fn set_cursor_shape(&mut self, shape: CursorShape, cx: &mut Context<Self>) {
|
||||
self.options.cursor = shape;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_line_numbers(&mut self, numbers: LineNumbers, cx: &mut Context<Self>) {
|
||||
self.options.numbers = numbers;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
|
||||
log::info!("code editor: read only {read_only}");
|
||||
self.options.read_only = read_only;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Selects these ranges, the last one primary.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<usize>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let chosen: Vec<Selection> = ranges
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
assert!(
|
||||
range.end <= self.buffer.len()
|
||||
&& self.buffer.text().is_char_boundary(range.start)
|
||||
&& self.buffer.text().is_char_boundary(range.end),
|
||||
"a selection covers whole characters of the text"
|
||||
);
|
||||
Selection {
|
||||
anchor: range.start,
|
||||
head: range.end,
|
||||
goal: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if merged(chosen.clone()) == self.selections {
|
||||
return;
|
||||
}
|
||||
self.set_selections(chosen, cx);
|
||||
self.scroll
|
||||
.scroll_to_item(self.primary_row(), ScrollStrategy::Center);
|
||||
}
|
||||
|
||||
pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
|
||||
if self.marks.diagnostics == diagnostics {
|
||||
return;
|
||||
}
|
||||
self.marks.diagnostics = diagnostics;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_backgrounds(
|
||||
&mut self,
|
||||
ranges: Vec<(Range<usize>, gpui::Hsla)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.marks.backgrounds == ranges {
|
||||
return;
|
||||
}
|
||||
self.marks.backgrounds = ranges;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_inlay_hints(&mut self, hints: Vec<InlayHint>, cx: &mut Context<Self>) {
|
||||
self.marks.hints = hints;
|
||||
self.marks.hints.sort_by_key(|hint| hint.offset);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_code_lenses(&mut self, lenses: Vec<CodeLens>, cx: &mut Context<Self>) {
|
||||
self.marks.lenses = lenses;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Suggested text at an offset, in grey until Tab takes it.
|
||||
pub fn set_ghost_text(&mut self, ghost: Option<GhostText>, cx: &mut Context<Self>) {
|
||||
self.marks.ghost = ghost;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_diff(&mut self, hunks: Vec<DiffHunk>, cx: &mut Context<Self>) {
|
||||
self.marks.hunks = hunks;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_git_marks(&mut self, marks: Vec<(usize, GitMark)>, cx: &mut Context<Self>) {
|
||||
self.marks.git = marks;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_breakpoint(&mut self, line: usize, cx: &mut Context<Self>) {
|
||||
if !self.marks.breakpoints.remove(&line) {
|
||||
self.marks.breakpoints.insert(line);
|
||||
}
|
||||
log::info!("code editor: breakpoints {:?}", self.marks.breakpoints);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn breakpoints(&self) -> &BTreeSet<usize> {
|
||||
&self.marks.breakpoints
|
||||
}
|
||||
|
||||
/// Opens the inline chat below a line, or closes it.
|
||||
pub fn set_chat(&mut self, line: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.marks.chat = line;
|
||||
if line.is_some() {
|
||||
let focus = self.chat_field.read(cx).focus().clone();
|
||||
window.focus(&focus);
|
||||
} else {
|
||||
window.focus(&self.focus);
|
||||
cx.emit(EditorEvent::ChatClosed);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Replaces ranges of the text at once, as one undo step; read-only text refuses.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
edits: impl IntoIterator<Item = (Range<usize>, String)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits: Vec<(Range<usize>, String)> = edits.into_iter().collect();
|
||||
assert!(
|
||||
edits.iter().all(|(range, _)| range.end <= self.buffer.len()
|
||||
&& self.buffer.text().is_char_boundary(range.start)
|
||||
&& self.buffer.text().is_char_boundary(range.end)),
|
||||
"an edit covers whole characters of the text"
|
||||
);
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_selections(&mut self, selections: Vec<Selection>, cx: &mut Context<Self>) {
|
||||
self.selections = merged(selections);
|
||||
self.unfold_cursors();
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
text: self.buffer.text().to_string(),
|
||||
selections: self.selections.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context<Self>) {
|
||||
self.buffer = Buffer::new(snapshot.text);
|
||||
self.selections = snapshot.selections;
|
||||
self.marked = None;
|
||||
cx.emit(EditorEvent::Changed);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Makes `before` one undo step when the text moved on from it.
|
||||
pub(crate) fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context<Self>) {
|
||||
if before.text != self.buffer.text() {
|
||||
self.history.record(before, typing);
|
||||
self.marks.ghost = None;
|
||||
self.folded.retain(|line| *line < self.buffer.lines());
|
||||
cx.emit(EditorEvent::Changed);
|
||||
}
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Shows the cursors now and restarts their blink; still under reduced motion.
|
||||
pub(crate) fn restart_blink(&mut self, cx: &mut Context<Self>) {
|
||||
self.caret_on = true;
|
||||
self.blink_epoch += 1;
|
||||
let epoch = self.blink_epoch;
|
||||
let still = cx.theme().reduced_motion;
|
||||
self._blink = (!still).then(|| {
|
||||
cx.spawn(async move |editor, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(BLINK).await;
|
||||
let alive = editor.update(cx, |editor, cx| {
|
||||
if editor.blink_epoch == epoch {
|
||||
editor.caret_on = !editor.caret_on;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if alive.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn editable(&self) -> bool {
|
||||
!self.options.read_only
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion::Spinner,
|
||||
primitives::{Icon, IconName},
|
||||
shell::StatusBarItem,
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
};
|
||||
|
||||
type OnPress = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
fn item(
|
||||
id: ElementId,
|
||||
label: impl Into<SharedString>,
|
||||
tooltip: &'static str,
|
||||
press: Option<OnPress>,
|
||||
) -> StatusBarItem {
|
||||
let item = StatusBarItem::new(id).label(label).tooltip(tooltip);
|
||||
match press {
|
||||
Some(press) => item.on_click(move |_, window, cx| press(window, cx)),
|
||||
None => item,
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! pressable {
|
||||
($name:ident) => {
|
||||
impl $name {
|
||||
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.press = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The cursor's line and column, and how many characters are selected when some are.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CursorPosition {
|
||||
id: ElementId,
|
||||
line: usize,
|
||||
column: usize,
|
||||
selected: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl CursorPosition {
|
||||
/// `line` and `column` count from one.
|
||||
pub fn new(id: impl Into<ElementId>, line: usize, column: usize) -> Self {
|
||||
assert!(line > 0 && column > 0, "lines and columns count from one");
|
||||
Self {
|
||||
id: id.into(),
|
||||
line,
|
||||
column,
|
||||
selected: 0,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self, characters: usize) -> Self {
|
||||
self.selected = characters;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(CursorPosition);
|
||||
|
||||
impl RenderOnce for CursorPosition {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let mut label = format!("Ln {}, Col {}", self.line, self.column);
|
||||
if self.selected > 0 {
|
||||
label.push_str(&format!(" ({} selected)", self.selected));
|
||||
}
|
||||
item(self.id, label, "Go to line", self.press)
|
||||
}
|
||||
}
|
||||
|
||||
/// The language a file is read as.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LanguageMode {
|
||||
id: ElementId,
|
||||
language: SharedString,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl LanguageMode {
|
||||
pub fn new(id: impl Into<ElementId>, language: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
language: language.into(),
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(LanguageMode);
|
||||
|
||||
impl RenderOnce for LanguageMode {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
item(
|
||||
self.id,
|
||||
self.language,
|
||||
"Select the language mode",
|
||||
self.press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The text encoding a file is saved in.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Encoding {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(Encoding);
|
||||
|
||||
impl RenderOnce for Encoding {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
item(
|
||||
self.id,
|
||||
self.name,
|
||||
"Reopen or save with an encoding",
|
||||
self.press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a file ends its lines.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LineEnding {
|
||||
id: ElementId,
|
||||
crlf: bool,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl LineEnding {
|
||||
/// `crlf` for carriage return and line feed, else line feed alone.
|
||||
pub fn new(id: impl Into<ElementId>, crlf: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
crlf,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(LineEnding);
|
||||
|
||||
impl RenderOnce for LineEnding {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
item(
|
||||
self.id,
|
||||
if self.crlf { "CRLF" } else { "LF" },
|
||||
"Change the line ending",
|
||||
self.press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a file indents: spaces or tabs, and how wide.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IndentSettings {
|
||||
id: ElementId,
|
||||
spaces: bool,
|
||||
size: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl IndentSettings {
|
||||
pub fn new(id: impl Into<ElementId>, spaces: bool, size: usize) -> Self {
|
||||
assert!(size > 0, "an indent is at least one column");
|
||||
Self {
|
||||
id: id.into(),
|
||||
spaces,
|
||||
size,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(IndentSettings);
|
||||
|
||||
impl RenderOnce for IndentSettings {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let label = if self.spaces {
|
||||
format!("Spaces: {}", self.size)
|
||||
} else {
|
||||
format!("Tab Size: {}", self.size)
|
||||
};
|
||||
item(self.id, label, "Change the indentation", self.press)
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch checked out, a mark when the tree has changes, and how far it is ahead of and behind its upstream.
|
||||
#[derive(IntoElement)]
|
||||
pub struct BranchIndicator {
|
||||
id: ElementId,
|
||||
branch: SharedString,
|
||||
dirty: bool,
|
||||
ahead: usize,
|
||||
behind: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl BranchIndicator {
|
||||
pub fn new(id: impl Into<ElementId>, branch: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
branch: branch.into(),
|
||||
dirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirty(mut self, dirty: bool) -> Self {
|
||||
self.dirty = dirty;
|
||||
self
|
||||
}
|
||||
|
||||
/// Commits not yet pushed, and commits not yet pulled.
|
||||
pub fn sync(mut self, ahead: usize, behind: usize) -> Self {
|
||||
self.ahead = ahead;
|
||||
self.behind = behind;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(BranchIndicator);
|
||||
|
||||
impl RenderOnce for BranchIndicator {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let mut label = format!("{}{}", self.branch, if self.dirty { "*" } else { "" });
|
||||
if self.ahead > 0 || self.behind > 0 {
|
||||
label.push_str(&format!(" ↑{} ↓{}", self.ahead, self.behind));
|
||||
}
|
||||
item(self.id, label, "Checkout a branch", self.press).icon(IconName::GitBranch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a language server stands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LspState {
|
||||
Starting,
|
||||
Ready,
|
||||
/// Working on something it names.
|
||||
Busy(SharedString),
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// A language server's name and state: a spinner while it starts or works, a check when ready, a mark when it failed.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LspStatus {
|
||||
id: ElementId,
|
||||
server: SharedString,
|
||||
state: LspState,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl LspStatus {
|
||||
pub fn new(id: impl Into<ElementId>, server: impl Into<SharedString>, state: LspState) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
server: server.into(),
|
||||
state,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(LspStatus);
|
||||
|
||||
impl RenderOnce for LspStatus {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (lead, label, tooltip) = match &self.state {
|
||||
LspState::Starting => (
|
||||
Spinner::new((self.id.clone(), "spin"))
|
||||
.size(IconSize::Xs)
|
||||
.into_any_element(),
|
||||
format!("{} starting", self.server),
|
||||
"Starting",
|
||||
),
|
||||
LspState::Busy(task) => (
|
||||
Spinner::new((self.id.clone(), "spin"))
|
||||
.size(IconSize::Xs)
|
||||
.into_any_element(),
|
||||
format!("{} · {task}", self.server),
|
||||
"Working",
|
||||
),
|
||||
LspState::Ready => (
|
||||
Icon::new(IconName::CircleCheck)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_muted)
|
||||
.into_any_element(),
|
||||
self.server.to_string(),
|
||||
"Ready",
|
||||
),
|
||||
LspState::Failed(why) => (
|
||||
Icon::new(IconName::CircleAlert)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.danger)
|
||||
.into_any_element(),
|
||||
format!("{} · {why}", self.server),
|
||||
"Failed",
|
||||
),
|
||||
};
|
||||
item(self.id, label, tooltip, self.press).leading(lead)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bell with how many notifications wait, quiet when there are none.
|
||||
#[derive(IntoElement)]
|
||||
pub struct NotificationBell {
|
||||
id: ElementId,
|
||||
count: usize,
|
||||
press: Option<OnPress>,
|
||||
}
|
||||
|
||||
impl NotificationBell {
|
||||
pub fn new(id: impl Into<ElementId>, count: usize) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
count,
|
||||
press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressable!(NotificationBell);
|
||||
|
||||
impl RenderOnce for NotificationBell {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let bell = div()
|
||||
.relative()
|
||||
.child(
|
||||
Icon::new(IconName::Bell)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.children((self.count > 0).then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_neg_1()
|
||||
.right_neg_1p5()
|
||||
.px_0p5()
|
||||
.rounded_full()
|
||||
.bg(colors.accent)
|
||||
.text_color(colors.on_accent)
|
||||
.text_size(theme.text_size(TextSize::Xs) * 0.75)
|
||||
.line_height(theme.text_size(TextSize::Xs) * 0.9)
|
||||
.child(self.count.min(99).to_string())
|
||||
}));
|
||||
let tooltip = if self.count == 0 {
|
||||
"No new notifications"
|
||||
} else {
|
||||
"Notifications"
|
||||
};
|
||||
let item = StatusBarItem::new(self.id).tooltip(tooltip).leading(bell);
|
||||
match self.press {
|
||||
Some(press) => item.on_click(move |_, window, cx| press(window, cx)),
|
||||
None => item,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::SegmentedControl,
|
||||
lists::{Tree, TreeNode},
|
||||
navigation::{Group, Palette, Row, fuzzy, marked, query_field},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Palette as Colors},
|
||||
};
|
||||
|
||||
/// What a symbol in code is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SymbolKind {
|
||||
Module,
|
||||
Struct,
|
||||
Enum,
|
||||
Trait,
|
||||
Function,
|
||||
Method,
|
||||
Field,
|
||||
Variable,
|
||||
Constant,
|
||||
Keyword,
|
||||
Snippet,
|
||||
}
|
||||
|
||||
impl SymbolKind {
|
||||
pub fn icon(self) -> IconName {
|
||||
match self {
|
||||
SymbolKind::Module => IconName::Package,
|
||||
SymbolKind::Struct => IconName::Braces,
|
||||
SymbolKind::Enum => IconName::ListOrdered,
|
||||
SymbolKind::Trait => IconName::Shapes,
|
||||
SymbolKind::Function | SymbolKind::Method => IconName::SquareFunction,
|
||||
SymbolKind::Field => IconName::Tag,
|
||||
SymbolKind::Variable => IconName::Variable,
|
||||
SymbolKind::Constant => IconName::Hash,
|
||||
SymbolKind::Keyword => IconName::Key,
|
||||
SymbolKind::Snippet => IconName::Code,
|
||||
}
|
||||
}
|
||||
|
||||
/// The syntax color its name takes.
|
||||
pub fn color(self, colors: &Colors) -> Hsla {
|
||||
let syntax = &colors.syntax;
|
||||
match self {
|
||||
SymbolKind::Module | SymbolKind::Snippet => colors.fg_muted,
|
||||
SymbolKind::Struct | SymbolKind::Enum | SymbolKind::Trait => syntax.type_name,
|
||||
SymbolKind::Function | SymbolKind::Method => syntax.function,
|
||||
SymbolKind::Field => syntax.property,
|
||||
SymbolKind::Variable => syntax.variable,
|
||||
SymbolKind::Constant => syntax.constant,
|
||||
SymbolKind::Keyword => syntax.keyword,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
SymbolKind::Module => "module",
|
||||
SymbolKind::Struct => "struct",
|
||||
SymbolKind::Enum => "enum",
|
||||
SymbolKind::Trait => "trait",
|
||||
SymbolKind::Function => "function",
|
||||
SymbolKind::Method => "method",
|
||||
SymbolKind::Field => "field",
|
||||
SymbolKind::Variable => "variable",
|
||||
SymbolKind::Constant => "constant",
|
||||
SymbolKind::Keyword => "keyword",
|
||||
SymbolKind::Snippet => "snippet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol in a file: its name, kind, the line it starts on, a detail such as its type, and what it holds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Symbol {
|
||||
pub name: SharedString,
|
||||
pub kind: SymbolKind,
|
||||
pub line: usize,
|
||||
pub detail: Option<SharedString>,
|
||||
pub children: Vec<Symbol>,
|
||||
}
|
||||
|
||||
impl Symbol {
|
||||
pub fn new(name: impl Into<SharedString>, kind: SymbolKind, line: usize) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
kind,
|
||||
line,
|
||||
detail: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
|
||||
self.detail = Some(detail.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = Symbol>) -> Self {
|
||||
self.children = children.into_iter().collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
type OnLine = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
fn key(symbol: &Symbol) -> SharedString {
|
||||
format!("{}:{}", symbol.line, symbol.name).into()
|
||||
}
|
||||
|
||||
fn node(symbol: &Symbol) -> TreeNode {
|
||||
let tree = TreeNode::new(key(symbol), symbol.name.clone()).icon(symbol.kind.icon());
|
||||
let tree = match &symbol.detail {
|
||||
Some(detail) => tree.note(detail.clone()),
|
||||
None => tree,
|
||||
};
|
||||
if symbol.children.is_empty() {
|
||||
return tree;
|
||||
}
|
||||
tree.children(symbol.children.iter().map(node))
|
||||
}
|
||||
|
||||
/// Each symbol with the path of those holding it, outermost first.
|
||||
pub(crate) fn flattened(symbols: &[Symbol]) -> Vec<(Vec<SharedString>, &Symbol)> {
|
||||
fn walk<'a>(
|
||||
symbols: &'a [Symbol],
|
||||
path: &mut Vec<SharedString>,
|
||||
out: &mut Vec<(Vec<SharedString>, &'a Symbol)>,
|
||||
) {
|
||||
for symbol in symbols {
|
||||
out.push((path.clone(), symbol));
|
||||
path.push(symbol.name.clone());
|
||||
walk(&symbol.children, path, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(symbols, &mut Vec::new(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The innermost symbol at or above a line.
|
||||
pub(crate) fn enclosing(symbols: &[Symbol], line: usize) -> Option<&Symbol> {
|
||||
let mut best = None;
|
||||
for (_, symbol) in flattened(symbols) {
|
||||
if symbol.line <= line && best.is_none_or(|best: &Symbol| symbol.line >= best.line) {
|
||||
best = Some(symbol);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// A file's symbols as a tree filling its box, the one around the cursor chosen; a press goes to its line.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SymbolOutline {
|
||||
id: ElementId,
|
||||
symbols: Vec<Symbol>,
|
||||
cursor: Option<usize>,
|
||||
on_pick: Option<OnLine>,
|
||||
}
|
||||
|
||||
impl SymbolOutline {
|
||||
pub fn new(id: impl Into<ElementId>, symbols: impl IntoIterator<Item = Symbol>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
symbols: symbols.into_iter().collect(),
|
||||
cursor: None,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's line, whose symbol is chosen.
|
||||
pub fn cursor(mut self, line: usize) -> Self {
|
||||
self.cursor = Some(line);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SymbolOutline {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let open: Vec<SharedString> = flattened(&self.symbols)
|
||||
.into_iter()
|
||||
.filter(|(_, symbol)| !symbol.children.is_empty())
|
||||
.map(|(_, symbol)| key(symbol))
|
||||
.collect();
|
||||
let chosen = self
|
||||
.cursor
|
||||
.and_then(|line| enclosing(&self.symbols, line))
|
||||
.map(key);
|
||||
let lines: Vec<(SharedString, usize)> = flattened(&self.symbols)
|
||||
.into_iter()
|
||||
.map(|(_, symbol)| (key(symbol), symbol.line))
|
||||
.collect();
|
||||
let tree = Tree::new(self.id, self.symbols.iter().map(node))
|
||||
.size_full()
|
||||
.open(open)
|
||||
.selected(chosen);
|
||||
match self.on_pick {
|
||||
Some(on_pick) => tree.on_select(move |keys, window, cx| {
|
||||
let Some(picked) = keys.first() else {
|
||||
return;
|
||||
};
|
||||
let line = lines
|
||||
.iter()
|
||||
.find(|(key, _)| key == picked)
|
||||
.map(|(_, line)| *line)
|
||||
.expect("a picked symbol is in the outline");
|
||||
log::info!("symbol outline: line {line}");
|
||||
on_pick(line, window, cx);
|
||||
}),
|
||||
None => tree,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
type OnFlip = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A palette of a file's symbols by name, each with its kind and what holds it; `@` style jumping.
|
||||
#[derive(IntoElement)]
|
||||
pub struct GoToSymbol {
|
||||
id: ElementId,
|
||||
symbols: Vec<Symbol>,
|
||||
on_pick: Option<OnLine>,
|
||||
on_close: OnClose,
|
||||
}
|
||||
|
||||
impl GoToSymbol {
|
||||
/// Render it while open; `on_close` runs on Escape, a click outside, or a pick.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
symbols: impl IntoIterator<Item = Symbol>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
symbols: symbols.into_iter().collect(),
|
||||
on_pick: None,
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for GoToSymbol {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = query_field(&self.id, "Go to a symbol", window, cx);
|
||||
let query = input.read(cx).text().trim().to_string();
|
||||
let colors = cx.theme().colors.clone();
|
||||
let mut found: Vec<(i32, Row)> = flattened(&self.symbols)
|
||||
.into_iter()
|
||||
.filter_map(|(path, symbol)| {
|
||||
let fit = if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fuzzy(&query, &symbol.name)?)
|
||||
};
|
||||
let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits));
|
||||
let detail = (!path.is_empty()).then(|| {
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(path.join(" › "))
|
||||
.into_any_element()
|
||||
});
|
||||
let row = Row {
|
||||
value: symbol.line.to_string().into(),
|
||||
icon: Some(symbol.kind.icon()),
|
||||
label: marked(symbol.name.clone(), hits, cx),
|
||||
detail,
|
||||
end: Some(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(symbol.kind.word())
|
||||
.into_any_element(),
|
||||
),
|
||||
};
|
||||
Some((score, row))
|
||||
})
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
found.sort_by(|(a, _), (b, _)| b.cmp(a));
|
||||
}
|
||||
let on_pick = self.on_pick.map(|on_pick| {
|
||||
Rc::new(
|
||||
move |value: &SharedString, window: &mut Window, cx: &mut App| {
|
||||
let line: usize = value.parse().expect("a symbol row carries its line");
|
||||
on_pick(line, window, cx);
|
||||
},
|
||||
) as Rc<dyn Fn(&SharedString, &mut Window, &mut App)>
|
||||
});
|
||||
Palette {
|
||||
id: self.id,
|
||||
input,
|
||||
groups: vec![Group {
|
||||
title: None,
|
||||
rows: found.into_iter().map(|(_, row)| row).collect(),
|
||||
}],
|
||||
start: 0,
|
||||
empty: "No matching symbols".into(),
|
||||
on_pick,
|
||||
on_close: self.on_close,
|
||||
}
|
||||
.overlay(window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A call in a hierarchy: who, of what kind, where, and the calls under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Call {
|
||||
pub name: SharedString,
|
||||
pub kind: SymbolKind,
|
||||
pub place: SharedString,
|
||||
pub calls: Vec<Call>,
|
||||
}
|
||||
|
||||
/// Who calls a function or whom it calls, or a type's supertypes and subtypes, as a tree filling its box that flips between the two directions.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CallHierarchy {
|
||||
id: ElementId,
|
||||
root: Call,
|
||||
labels: (SharedString, SharedString),
|
||||
incoming: bool,
|
||||
on_flip: Option<OnFlip>,
|
||||
}
|
||||
|
||||
impl CallHierarchy {
|
||||
/// Calls to or from `root`; `incoming` picks which the tree shows.
|
||||
pub fn new(id: impl Into<ElementId>, root: Call, incoming: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
root,
|
||||
labels: ("Incoming".into(), "Outgoing".into()),
|
||||
incoming,
|
||||
on_flip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For types: supertypes and subtypes instead of calls.
|
||||
pub fn types(mut self) -> Self {
|
||||
self.labels = ("Supertypes".into(), "Subtypes".into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets whether the incoming side is chosen.
|
||||
pub fn on_flip(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_flip = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn call_node(call: &Call, path: &str) -> TreeNode {
|
||||
let key = format!("{path}/{}", call.name);
|
||||
let node = TreeNode::new(key.clone(), call.name.clone())
|
||||
.icon(call.kind.icon())
|
||||
.note(call.place.clone());
|
||||
if call.calls.is_empty() {
|
||||
return node;
|
||||
}
|
||||
node.children(call.calls.iter().map(|inner| call_node(inner, &key)))
|
||||
}
|
||||
|
||||
fn keys(call: &Call, path: &str, out: &mut Vec<SharedString>) {
|
||||
let key = format!("{path}/{}", call.name);
|
||||
for inner in &call.calls {
|
||||
keys(inner, &key, out);
|
||||
}
|
||||
out.push(key.into());
|
||||
}
|
||||
|
||||
impl RenderOnce for CallHierarchy {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (incoming, outgoing) = self.labels;
|
||||
let side = SegmentedControl::new(
|
||||
(self.id.clone(), "side"),
|
||||
if self.incoming { "in" } else { "out" },
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("in", incoming, None)
|
||||
.segment("out", outgoing, None);
|
||||
let side = match self.on_flip {
|
||||
Some(on_flip) => {
|
||||
side.on_change(move |key, window, cx| on_flip(key.as_ref() == "in", window, cx))
|
||||
}
|
||||
None => side,
|
||||
};
|
||||
let mut open = Vec::new();
|
||||
keys(&self.root, "", &mut open);
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(crate::theme::TextSize::Sm))
|
||||
.child(side)
|
||||
.child(
|
||||
Tree::new((self.id, "calls"), [call_node(&self.root, "")])
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.open(open),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_innermost_symbol_above_a_line_holds_it() {
|
||||
let symbols = vec![
|
||||
Symbol::new("Editor", SymbolKind::Struct, 2),
|
||||
Symbol::new("impl Editor", SymbolKind::Module, 10).children([
|
||||
Symbol::new("new", SymbolKind::Method, 11),
|
||||
Symbol::new("render", SymbolKind::Method, 20),
|
||||
]),
|
||||
];
|
||||
assert_eq!(
|
||||
enclosing(&symbols, 25).map(|symbol| &symbol.name[..]),
|
||||
Some("render")
|
||||
);
|
||||
assert_eq!(
|
||||
enclosing(&symbols, 5).map(|symbol| &symbol.name[..]),
|
||||
Some("Editor")
|
||||
);
|
||||
assert_eq!(enclosing(&symbols, 0), None);
|
||||
let paths: Vec<usize> = flattened(&symbols)
|
||||
.iter()
|
||||
.map(|(path, _)| path.len())
|
||||
.collect();
|
||||
assert_eq!(paths, [0, 0, 1, 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, HighlightStyle};
|
||||
|
||||
use super::buffer::Buffer;
|
||||
use crate::forms::{Kind, code_highlights, lex};
|
||||
|
||||
/// Each line that opens a block, and the last line the block holds, read from indentation.
|
||||
pub(crate) fn folds(buffer: &Buffer) -> Vec<(usize, usize)> {
|
||||
let blank = |line: usize| buffer.line(line).trim().is_empty();
|
||||
let mut out = Vec::new();
|
||||
for line in 0..buffer.lines() {
|
||||
if blank(line) {
|
||||
continue;
|
||||
}
|
||||
let indent = buffer.indent(line);
|
||||
let mut end = None;
|
||||
for next in line + 1..buffer.lines() {
|
||||
if blank(next) {
|
||||
continue;
|
||||
}
|
||||
if buffer.indent(next) <= indent {
|
||||
break;
|
||||
}
|
||||
end = Some(next);
|
||||
}
|
||||
if let Some(end) = end {
|
||||
out.push((line, end));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A bracket in the code, how deep it sits, and where its partner is when it has one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Bracket {
|
||||
pub offset: usize,
|
||||
pub depth: usize,
|
||||
pub partner: Option<usize>,
|
||||
}
|
||||
|
||||
fn opener(close: char) -> char {
|
||||
match close {
|
||||
')' => '(',
|
||||
']' => '[',
|
||||
_ => '{',
|
||||
}
|
||||
}
|
||||
|
||||
/// Every bracket outside strings and comments; a closer takes its opener's depth.
|
||||
pub(crate) fn brackets(buffer: &Buffer) -> Vec<Bracket> {
|
||||
let mut out: Vec<Bracket> = Vec::new();
|
||||
let mut open: Vec<(usize, char)> = Vec::new();
|
||||
for line in 0..buffer.lines() {
|
||||
let start = buffer.line_range(line).start;
|
||||
let text = buffer.line(line);
|
||||
for (range, kind) in lex(text) {
|
||||
if kind != Kind::Mark {
|
||||
continue;
|
||||
}
|
||||
let Some(ch) = text[range.clone()].chars().next() else {
|
||||
continue;
|
||||
};
|
||||
let offset = start + range.start;
|
||||
match ch {
|
||||
'(' | '[' | '{' => {
|
||||
open.push((out.len(), ch));
|
||||
out.push(Bracket {
|
||||
offset,
|
||||
depth: open.len() - 1,
|
||||
partner: None,
|
||||
});
|
||||
}
|
||||
')' | ']' | '}' => match open.last() {
|
||||
Some((ix, wanted)) if *wanted == opener(ch) => {
|
||||
let ix = *ix;
|
||||
open.pop();
|
||||
let partner = out[ix].offset;
|
||||
out[ix].partner = Some(offset);
|
||||
out.push(Bracket {
|
||||
offset,
|
||||
depth: open.len(),
|
||||
partner: Some(partner),
|
||||
});
|
||||
}
|
||||
_ => out.push(Bracket {
|
||||
offset,
|
||||
depth: open.len(),
|
||||
partner: None,
|
||||
}),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pair beside a caret: the bracket just after it, or else the one just before.
|
||||
pub(crate) fn matched(brackets: &[Bracket], caret: usize) -> Option<(usize, usize)> {
|
||||
let at = |offset: usize| {
|
||||
brackets
|
||||
.binary_search_by_key(&offset, |bracket| bracket.offset)
|
||||
.ok()
|
||||
.map(|ix| brackets[ix])
|
||||
};
|
||||
let bracket = at(caret).or_else(|| caret.checked_sub(1).and_then(at))?;
|
||||
Some((bracket.offset, bracket.partner?))
|
||||
}
|
||||
|
||||
/// A line's syntax colors as highlight styles.
|
||||
pub(crate) fn colors(text: &str, cx: &App) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
code_highlights(text, cx)
|
||||
.into_iter()
|
||||
.map(|(range, highlight)| {
|
||||
(
|
||||
range,
|
||||
HighlightStyle {
|
||||
color: Some(highlight.color),
|
||||
background_color: highlight.background,
|
||||
..HighlightStyle::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where `needle` appears in the lines of `range`, as offsets, whole words only when it is a word.
|
||||
pub(crate) fn occurrences(buffer: &Buffer, needle: &str, lines: Range<usize>) -> Vec<Range<usize>> {
|
||||
if needle.is_empty() || needle.contains('\n') {
|
||||
return Vec::new();
|
||||
}
|
||||
let word = needle.chars().all(|ch| ch.is_alphanumeric() || ch == '_');
|
||||
let boundary = |ch: Option<char>| ch.is_none_or(|ch| !(ch.is_alphanumeric() || ch == '_'));
|
||||
let mut out = Vec::new();
|
||||
for line in lines.start..lines.end.min(buffer.lines()) {
|
||||
let start = buffer.line_range(line).start;
|
||||
let text = buffer.line(line);
|
||||
for (at, _) in text.match_indices(needle) {
|
||||
let end = at + needle.len();
|
||||
let whole =
|
||||
boundary(text[..at].chars().next_back()) && boundary(text[end..].chars().next());
|
||||
if !word || whole {
|
||||
out.push(start + at..start + end);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_fold_by_indentation_and_skip_blank_lines() {
|
||||
let buffer = Buffer::new("fn a() {\n one();\n\n two();\n}\nfn b() {}");
|
||||
assert_eq!(folds(&buffer), [(0, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brackets_pair_by_depth_outside_strings_and_comments() {
|
||||
let buffer = Buffer::new("f(a[0], \"(\") // )\n{ }");
|
||||
let found = brackets(&buffer);
|
||||
let offsets: Vec<usize> = found.iter().map(|bracket| bracket.offset).collect();
|
||||
assert_eq!(
|
||||
offsets,
|
||||
[1, 3, 5, 11, 18, 20],
|
||||
"the quoted and the commented ones do not count"
|
||||
);
|
||||
assert_eq!(found[1].depth, 1);
|
||||
assert_eq!(found[3].partner, Some(1));
|
||||
assert_eq!(matched(&found, 12), Some((11, 1)), "just before the caret");
|
||||
assert_eq!(matched(&found, 18), Some((18, 20)), "just after the caret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrences_of_a_word_skip_longer_words() {
|
||||
let buffer = Buffer::new("let total = total_cost + total;");
|
||||
let found = occurrences(&buffer, "total", 0..1);
|
||||
assert_eq!(found, [4..9, 25..30]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
motion::Spinner,
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// Where a task's last run stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TaskState {
|
||||
Idle,
|
||||
Running,
|
||||
Passed(Duration),
|
||||
Failed(i32),
|
||||
}
|
||||
|
||||
/// A task to run: its name, group, command, and where and with what it runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Task {
|
||||
pub name: SharedString,
|
||||
pub group: SharedString,
|
||||
pub command: SharedString,
|
||||
pub cwd: SharedString,
|
||||
pub env: Vec<(SharedString, SharedString)>,
|
||||
pub state: TaskState,
|
||||
}
|
||||
|
||||
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
type OnOpen = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
|
||||
|
||||
/// Tasks by group, each with how its last run went and a run or stop button; one opens to show its configuration.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TaskRunner {
|
||||
id: ElementId,
|
||||
tasks: Vec<Task>,
|
||||
open: Option<usize>,
|
||||
on_run: Option<OnIndex>,
|
||||
on_stop: Option<OnIndex>,
|
||||
on_open: Option<OnOpen>,
|
||||
}
|
||||
|
||||
impl TaskRunner {
|
||||
pub fn new(id: impl Into<ElementId>, tasks: impl IntoIterator<Item = Task>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
tasks: tasks.into_iter().collect(),
|
||||
open: None,
|
||||
on_run: None,
|
||||
on_stop: None,
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The task whose run configuration shows.
|
||||
pub fn open(mut self, task: Option<usize>) -> Self {
|
||||
self.open = task;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_run(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_run = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_stop(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_stop = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the task to open, or none to close it.
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(Option<usize>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TaskRunner {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mut groups: Vec<SharedString> = Vec::new();
|
||||
for task in &self.tasks {
|
||||
if !groups.contains(&task.group) {
|
||||
groups.push(task.group.clone());
|
||||
}
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for group in groups {
|
||||
rows.push(
|
||||
div()
|
||||
.px_2()
|
||||
.pt_2()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(group.to_uppercase())
|
||||
.into_any_element(),
|
||||
);
|
||||
for (ix, task) in self
|
||||
.tasks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, task)| task.group == group)
|
||||
{
|
||||
let running = task.state == TaskState::Running;
|
||||
let status = match task.state {
|
||||
TaskState::Idle => Icon::new(IconName::Circle)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle)
|
||||
.into_any_element(),
|
||||
TaskState::Running => Spinner::new((self.id.clone(), format!("spinner-{ix}")))
|
||||
.size(IconSize::Sm)
|
||||
.into_any_element(),
|
||||
TaskState::Passed(_) => Icon::new(IconName::CircleCheck)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.success)
|
||||
.into_any_element(),
|
||||
TaskState::Failed(_) => Icon::new(IconName::CircleX)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.danger)
|
||||
.into_any_element(),
|
||||
};
|
||||
let outcome = match task.state {
|
||||
TaskState::Idle => SharedString::default(),
|
||||
TaskState::Running => "Running".into(),
|
||||
TaskState::Passed(took) => {
|
||||
format::duration(took.as_secs().max(1), format::DurationStyle::Compact)
|
||||
.into()
|
||||
}
|
||||
TaskState::Failed(code) => format!("Exit {code}").into(),
|
||||
};
|
||||
let (action, icon, words) = if running {
|
||||
(self.on_stop.clone(), IconName::CircleStop, "Stop")
|
||||
} else {
|
||||
(self.on_run.clone(), IconName::Play, "Run")
|
||||
};
|
||||
let opened = self.open == Some(ix);
|
||||
let toggle = self.on_open.clone();
|
||||
rows.push(
|
||||
div()
|
||||
.id((self.id.clone(), format!("task-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.cursor_pointer()
|
||||
.when_some(toggle, |row, toggle| {
|
||||
row.on_click(move |_, window, cx| {
|
||||
toggle((!opened).then_some(ix), window, cx)
|
||||
})
|
||||
})
|
||||
.child(Disclosure::new(
|
||||
(self.id.clone(), format!("open-{ix}")),
|
||||
opened,
|
||||
))
|
||||
.child(status)
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(task.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(Ellipsis::new(task.command.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(if matches!(task.state, TaskState::Failed(_)) {
|
||||
colors.danger
|
||||
} else {
|
||||
colors.fg_muted
|
||||
})
|
||||
.child(outcome),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), format!("run-{ix}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.when_some(action, |button, action| {
|
||||
button.on_click(move |_, window, cx| {
|
||||
log::info!("task runner: {words} {ix}");
|
||||
action(ix, window, cx)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
if opened {
|
||||
let field = |label: &'static str, value: SharedString| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 0.75)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(colors.fg)
|
||||
.child(value),
|
||||
)
|
||||
};
|
||||
let env = task
|
||||
.env
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
rows.push(
|
||||
div()
|
||||
.ml_8()
|
||||
.mb_1()
|
||||
.p_3()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(field("Command", task.command.clone()))
|
||||
.child(field("Directory", task.cwd.clone()))
|
||||
.child(field(
|
||||
"Environment",
|
||||
if env.is_empty() {
|
||||
"—".into()
|
||||
} else {
|
||||
env.into()
|
||||
},
|
||||
))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{CodeEditor, GhostText, layout::Row};
|
||||
use crate::theme::Theme;
|
||||
|
||||
fn editor<'a>(
|
||||
text: &str,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<CodeEditor>, &'a mut VisualTestContext) {
|
||||
cx.update(Theme::init);
|
||||
let text = text.to_string();
|
||||
cx.add_window_view(move |window, cx| CodeEditor::new(text, window, cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typed_pairs_close_and_one_undo_takes_back_the_burst(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("let x = ", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let end = 8..8;
|
||||
editor.select([end], cx);
|
||||
for ch in ["(", "1", ")"] {
|
||||
editor.type_text(ch, cx);
|
||||
}
|
||||
assert_eq!(
|
||||
editor.text(),
|
||||
"let x = (1)",
|
||||
"the typed close steps over its pair"
|
||||
);
|
||||
editor.undo(cx);
|
||||
assert_eq!(editor.text(), "let x = ");
|
||||
editor.redo(cx);
|
||||
assert_eq!(editor.text(), "let x = (1)");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn select_next_takes_the_word_then_its_next_match(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("a foo b foo c", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let inside = 3..3;
|
||||
editor.select([inside], cx);
|
||||
editor.select_next(cx);
|
||||
editor.select_next(cx);
|
||||
let ranges: Vec<_> = editor.selections().iter().map(|s| s.range()).collect();
|
||||
assert_eq!(ranges, [2..5, 8..11]);
|
||||
editor.type_text("bar", cx);
|
||||
assert_eq!(editor.text(), "a bar b bar c", "every cursor types");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn toggling_comments_twice_restores_the_lines(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("fn a() {\n b();\n}", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.select_all(cx);
|
||||
editor.toggle_comment(cx);
|
||||
assert_eq!(editor.text(), "// fn a() {\n// b();\n// }");
|
||||
editor.toggle_comment(cx);
|
||||
assert_eq!(editor.text(), "fn a() {\n b();\n}");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_multi_line_ghost_moves_the_rest_of_its_line_below_it(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("call(x)\nnext", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_ghost_text(
|
||||
Some(GhostText {
|
||||
offset: 5,
|
||||
text: "a,\n b, ".into(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
editor.row_text(0),
|
||||
"call(",
|
||||
"the row stops where the ghost starts"
|
||||
);
|
||||
assert_eq!(editor.row_text(1), "next");
|
||||
let caret = 5..5;
|
||||
editor.select([caret], cx);
|
||||
editor.indent(cx);
|
||||
assert_eq!(
|
||||
editor.text(),
|
||||
"call(a,\n b, x)\nnext",
|
||||
"Tab takes the ghost"
|
||||
);
|
||||
assert_eq!(editor.row_text(0), "call(a,", "a taken ghost cuts nothing");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_selection_inside_a_fold_opens_it(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("fn a() {\n b();\n}\nc", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.toggle_fold(0, cx);
|
||||
assert!(!editor.frame_shows(1), "the body folds away");
|
||||
let body = 13..13;
|
||||
editor.select([body], cx);
|
||||
assert!(editor.frame_shows(1), "a cursor inside unfolds it");
|
||||
});
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Whether line `line` has a row now.
|
||||
fn frame_shows(&self, line: usize) -> bool {
|
||||
let hide = super::layout::hidden(
|
||||
self.buffer.lines(),
|
||||
&super::syntax::folds(&self.buffer),
|
||||
&self.folded,
|
||||
);
|
||||
super::layout::rows(&self.buffer, &hide, &self.marks).contains(&Row::Line(line))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, ElementInputHandler, InteractiveElement, IntoElement, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, StyledText, Window,
|
||||
canvas, div, fill, font, point, size, uniform_list,
|
||||
};
|
||||
|
||||
use super::{
|
||||
decor::ReadOnlyBanner,
|
||||
keys::{CONTEXT, listen},
|
||||
layout::{Row, around, hidden, rows},
|
||||
state::{CodeEditor, EditorEvent},
|
||||
syntax::{self, Bracket, brackets, folds},
|
||||
};
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
/// What one render worked out, for the rows the list builds after it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Frame {
|
||||
pub rows: Vec<Row>,
|
||||
pub folds: Vec<(usize, usize)>,
|
||||
pub brackets: Vec<Bracket>,
|
||||
pub advance: Pixels,
|
||||
pub line: Pixels,
|
||||
pub matched: Option<(usize, usize)>,
|
||||
pub occurrences: Vec<Range<usize>>,
|
||||
/// The minimap's box, from its last paint.
|
||||
pub minimap: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
/// Lines of code a row's height holds, as leading.
|
||||
const LEADING: f32 = 1.6;
|
||||
/// Blocks the sticky header shows at most.
|
||||
const STICKY: usize = 3;
|
||||
/// Columns the minimap spans, in code characters.
|
||||
const MINIMAP: f32 = 12.0;
|
||||
|
||||
impl CodeEditor {
|
||||
/// The text the primary selection or its word gives, for highlighting where else it appears.
|
||||
fn needle(&self) -> Option<String> {
|
||||
let primary = self.primary();
|
||||
let range = if primary.is_empty() {
|
||||
let word = self.buffer.word_at(primary.head);
|
||||
let text = &self.buffer.text()[word.clone()];
|
||||
text.chars()
|
||||
.all(|ch| ch.is_alphanumeric() || ch == '_')
|
||||
.then_some(word)?
|
||||
} else {
|
||||
primary.range()
|
||||
};
|
||||
(!range.is_empty()).then(|| self.buffer.text()[range].to_string())
|
||||
}
|
||||
|
||||
/// The first line the list shows, after scrolling.
|
||||
fn top_line(&self) -> Option<usize> {
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let first = (scrolled / self.frame.line).floor().max(0.0) as usize;
|
||||
self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..]
|
||||
.iter()
|
||||
.find_map(|row| match row {
|
||||
Row::Line(line) => Some(*line),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn sticky(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let top = self.top_line()?;
|
||||
let headers = around(&self.frame.folds, top, STICKY);
|
||||
if headers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rows = headers.into_iter().map(|line| {
|
||||
let text = self.buffer.line(line).to_string();
|
||||
let styles = syntax::colors(&text, cx);
|
||||
let jump = cx.entity();
|
||||
div()
|
||||
.id(("sticky", line))
|
||||
.h(self.frame.line)
|
||||
.flex()
|
||||
.items_center()
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
jump.update(cx, |editor, cx| {
|
||||
let at = editor.buffer.line_range(line).start + editor.buffer.indent(line);
|
||||
let cursor = at..at;
|
||||
editor.select([cursor], cx);
|
||||
editor.reveal(false, cx);
|
||||
});
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(self.frame.advance * (self.gutter_columns() + 1) as f32),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_height(self.frame.line)
|
||||
.whitespace_nowrap()
|
||||
.text_color(colors.syntax.variable)
|
||||
.child(StyledText::new(text).with_highlights(styles)),
|
||||
)
|
||||
});
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bg(colors.surface)
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.children(rows)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn minimap_view(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (advance, line) = (self.frame.advance, self.frame.line);
|
||||
let tokens: Vec<Vec<(usize, usize, gpui::Hsla)>> = self
|
||||
.frame
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| match row {
|
||||
Row::Line(at) => {
|
||||
let text = self.buffer.line(*at);
|
||||
syntax::colors(text, cx)
|
||||
.into_iter()
|
||||
.filter_map(|(range, style)| {
|
||||
let start = text[..range.start].chars().count();
|
||||
let len = text[range].chars().count();
|
||||
style.color.map(|color| (start, len, color))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let scroll = self.scroll.clone();
|
||||
let entity = cx.entity();
|
||||
let (drag, press) = (cx.entity(), cx.entity());
|
||||
let shade = colors.hover;
|
||||
div()
|
||||
.id("minimap")
|
||||
.relative()
|
||||
.flex_none()
|
||||
.w(advance * MINIMAP)
|
||||
.h_full()
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
entity.update(cx, |editor, _| editor.frame.minimap = bounds);
|
||||
},
|
||||
move |bounds, _, window, _| {
|
||||
let rows = tokens.len().max(1) as f32;
|
||||
let tall = (line * 0.15).min(bounds.size.height / rows);
|
||||
let wide = advance * 0.12;
|
||||
let state = scroll.0.borrow();
|
||||
let viewport = state.base_handle.bounds().size.height;
|
||||
let scrolled = -state.base_handle.offset().y;
|
||||
let top = bounds.origin.y + scrolled / line * tall;
|
||||
let extent = size(bounds.size.width, viewport / line * tall);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(bounds.origin.x, top), extent),
|
||||
shade,
|
||||
));
|
||||
for (ix, spans) in tokens.iter().enumerate() {
|
||||
for (start, len, color) in spans {
|
||||
let origin = point(
|
||||
bounds.origin.x + wide * (*start + 2) as f32,
|
||||
bounds.origin.y + tall * ix as f32,
|
||||
);
|
||||
let extent = size(wide * *len as f32, tall * 0.7);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(origin, extent),
|
||||
color.opacity(0.7),
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| {
|
||||
press.update(cx, |editor, cx| editor.jump(event.position.y, cx));
|
||||
})
|
||||
.on_mouse_move(move |event: &MouseMoveEvent, _, cx| {
|
||||
if event.dragging() {
|
||||
drag.update(cx, |editor, cx| editor.jump(event.position.y, cx));
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Scrolls so the row under a minimap point sits in the middle of the view.
|
||||
fn jump(&mut self, y: Pixels, cx: &mut Context<Self>) {
|
||||
let bounds = self.frame.minimap;
|
||||
let rows = self.frame.rows.len().max(1) as f32;
|
||||
let tall = (self.frame.line * 0.15).min(bounds.size.height / rows);
|
||||
let row = ((y - bounds.origin.y) / tall).max(0.0);
|
||||
let state = self.scroll.0.borrow();
|
||||
let viewport = state.base_handle.bounds().size.height;
|
||||
let top = (self.frame.line * row - viewport / 2.0).max(Pixels::ZERO);
|
||||
state.base_handle.set_offset(point(Pixels::ZERO, -top));
|
||||
drop(state);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CodeEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let sticky = if self.options.sticky {
|
||||
self.sticky(cx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let size = theme.text_size(TextSize::Sm);
|
||||
let pixels = size.to_pixels(window.rem_size());
|
||||
let mono = window
|
||||
.text_system()
|
||||
.resolve_font(&font(theme.mono_family.clone()));
|
||||
let advance = window
|
||||
.text_system()
|
||||
.advance(mono, pixels, 'm')
|
||||
.expect("the code font has an m")
|
||||
.width;
|
||||
let line = (pixels * LEADING).round();
|
||||
let found = folds(&self.buffer);
|
||||
let hide = hidden(self.buffer.lines(), &found, &self.folded);
|
||||
let found_brackets = brackets(&self.buffer);
|
||||
let matched = syntax::matched(&found_brackets, self.primary().head);
|
||||
let occurrences = match self.needle() {
|
||||
Some(needle) if self.focus.is_focused(window) => {
|
||||
syntax::occurrences(&self.buffer, &needle, 0..self.buffer.lines())
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let minimap = self.frame.minimap;
|
||||
self.frame = Frame {
|
||||
rows: rows(&self.buffer, &hide, &self.marks),
|
||||
folds: found,
|
||||
brackets: found_brackets,
|
||||
advance,
|
||||
line,
|
||||
matched,
|
||||
occurrences,
|
||||
minimap,
|
||||
};
|
||||
self.metrics.advance = advance;
|
||||
self.metrics.line = line;
|
||||
let count = self.frame.rows.len();
|
||||
let list = uniform_list(
|
||||
"editor-rows",
|
||||
count,
|
||||
cx.processor(|editor, range: Range<usize>, window, cx| {
|
||||
range.map(|ix| editor.row(ix, window, cx)).collect()
|
||||
}),
|
||||
)
|
||||
.track_scroll(self.scroll.clone())
|
||||
.size_full();
|
||||
let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity());
|
||||
let unlock = cx.entity();
|
||||
let banner = self.options.read_only.then(|| {
|
||||
ReadOnlyBanner::new(
|
||||
("read-only", cx.entity_id().as_u64()),
|
||||
"This file is read-only.",
|
||||
)
|
||||
.action("Make editable", move |_, cx| {
|
||||
unlock.update(cx, |editor, cx| {
|
||||
editor.set_read_only(false, cx);
|
||||
cx.emit(EditorEvent::Unlock);
|
||||
})
|
||||
})
|
||||
});
|
||||
let body = div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.child(list)
|
||||
.children(sticky)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
top.update(cx, |editor, _| editor.metrics.top = bounds.origin.y);
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
);
|
||||
let root = div()
|
||||
.id(("code-editor", cx.entity_id().as_u64()))
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.bg(colors.surface)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(size)
|
||||
.text_color(colors.fg)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|editor, _, _, _| editor.dragging = false),
|
||||
)
|
||||
.on_mouse_up_out(
|
||||
MouseButton::Left,
|
||||
cx.listener(|editor, _, _, _| editor.dragging = false),
|
||||
)
|
||||
.children(banner)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.flex()
|
||||
.child(body)
|
||||
.children(self.options.minimap.then(|| self.minimap_view(cx))),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, cx| {
|
||||
window.handle_input(
|
||||
&focus,
|
||||
ElementInputHandler::new(bounds, input.clone()),
|
||||
cx,
|
||||
);
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
);
|
||||
listen(root, cx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
forms::Checkbox,
|
||||
navigation::{Group, Palette, Row, fuzzy, marked, query_field},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, KbdCombo},
|
||||
};
|
||||
|
||||
/// A project opened before: its name, where it lives, its branch, when it was last opened, and whether it is pinned.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Project {
|
||||
pub name: SharedString,
|
||||
pub path: SharedString,
|
||||
pub branch: Option<SharedString>,
|
||||
pub opened: SharedString,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
/// Pinned projects first, then the rest in the order given.
|
||||
pub(crate) fn ordered(projects: &[Project]) -> Vec<(usize, &Project)> {
|
||||
let mut all: Vec<(usize, &Project)> = projects.iter().enumerate().collect();
|
||||
all.sort_by_key(|(_, project)| !project.pinned);
|
||||
all
|
||||
}
|
||||
|
||||
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// Projects opened before, pinned ones first: each with its path, branch and when; a press opens one, the pin keeps it on top.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RecentProjects {
|
||||
id: ElementId,
|
||||
projects: Vec<Project>,
|
||||
on_open: Option<OnIndex>,
|
||||
on_pin: Option<OnIndex>,
|
||||
on_remove: Option<OnIndex>,
|
||||
}
|
||||
|
||||
impl RecentProjects {
|
||||
pub fn new(id: impl Into<ElementId>, projects: impl IntoIterator<Item = Project>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
projects: projects.into_iter().collect(),
|
||||
on_open: None,
|
||||
on_pin: None,
|
||||
on_remove: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_pin(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pin = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_remove = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RecentProjects {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let rows = ordered(&self.projects).into_iter().map(|(ix, project)| {
|
||||
let (open, pin, remove) = (
|
||||
self.on_open.clone(),
|
||||
self.on_pin.clone(),
|
||||
self.on_remove.clone(),
|
||||
);
|
||||
let button = |key: &'static str,
|
||||
icon: IconName,
|
||||
words: &'static str,
|
||||
action: Option<OnIndex>| {
|
||||
IconButton::new((self.id.clone(), format!("{key}-{ix}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.when_some(action, move |button, action| {
|
||||
button.on_click(move |_, window, cx| action(ix, window, cx))
|
||||
})
|
||||
};
|
||||
div()
|
||||
.id((self.id.clone(), format!("project-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.when_some(open, |row, open| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("recent projects: open {ix}");
|
||||
open(ix, window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconName::Folder)
|
||||
.size(IconSize::Md)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(project.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(Ellipsis::new(project.path.clone())),
|
||||
),
|
||||
)
|
||||
.children(project.branch.clone().map(|branch| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::GitBranch)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(branch)
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(project.opened.clone()),
|
||||
)
|
||||
.child(button(
|
||||
"pin",
|
||||
if project.pinned {
|
||||
IconName::PinOff
|
||||
} else {
|
||||
IconName::Pin
|
||||
},
|
||||
if project.pinned { "Unpin" } else { "Pin" },
|
||||
pin,
|
||||
))
|
||||
.child(button("remove", IconName::X, "Remove from recent", remove))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A palette of projects by name or path, pinned ones first; a pick opens it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProjectSwitcher {
|
||||
id: ElementId,
|
||||
projects: Vec<Project>,
|
||||
on_open: Option<OnIndex>,
|
||||
on_close: OnClose,
|
||||
}
|
||||
|
||||
impl ProjectSwitcher {
|
||||
/// Render it while open; `on_close` runs on Escape, a click outside, or a pick.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
projects: impl IntoIterator<Item = Project>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
projects: projects.into_iter().collect(),
|
||||
on_open: None,
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProjectSwitcher {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = query_field(&self.id, "Switch to a project", window, cx);
|
||||
let query = input.read(cx).text().trim().to_string();
|
||||
let colors = cx.theme().colors.clone();
|
||||
let mut found: Vec<(i32, Row)> = ordered(&self.projects)
|
||||
.into_iter()
|
||||
.filter_map(|(ix, project)| {
|
||||
let fit = if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fuzzy(&query, &project.name).or_else(|| fuzzy(&query, &project.path))?)
|
||||
};
|
||||
let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits));
|
||||
let hits = if hits.iter().all(|hit| hit.end <= project.name.len()) {
|
||||
hits
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some((
|
||||
score,
|
||||
Row {
|
||||
value: ix.to_string().into(),
|
||||
icon: Some(if project.pinned {
|
||||
IconName::Pin
|
||||
} else {
|
||||
IconName::Folder
|
||||
}),
|
||||
label: marked(project.name.clone(), hits, cx),
|
||||
detail: Some(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(project.path.clone())
|
||||
.into_any_element(),
|
||||
),
|
||||
end: Some(
|
||||
div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(project.opened.clone())
|
||||
.into_any_element(),
|
||||
),
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
found.sort_by(|(a, _), (b, _)| b.cmp(a));
|
||||
}
|
||||
let on_pick = self.on_open.map(|open| {
|
||||
Rc::new(
|
||||
move |value: &SharedString, window: &mut Window, cx: &mut App| {
|
||||
open(
|
||||
value.parse().expect("a project row carries its place"),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
) as Rc<dyn Fn(&SharedString, &mut Window, &mut App)>
|
||||
});
|
||||
Palette {
|
||||
id: self.id,
|
||||
input,
|
||||
groups: vec![Group {
|
||||
title: None,
|
||||
rows: found.into_iter().map(|(_, row)| row).collect(),
|
||||
}],
|
||||
start: 0,
|
||||
empty: "No matching projects".into(),
|
||||
on_pick,
|
||||
on_close: self.on_close,
|
||||
}
|
||||
.overlay(window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A way to start from the welcome page: its icon, words and keys.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StartAction {
|
||||
pub icon: IconName,
|
||||
pub label: SharedString,
|
||||
pub keys: Option<SharedString>,
|
||||
}
|
||||
|
||||
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// The first page an editor shows: ways to start, recent projects, and whether to show it at startup.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WelcomePage {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
tagline: SharedString,
|
||||
actions: Vec<StartAction>,
|
||||
recent: Option<AnyElement>,
|
||||
show_at_start: bool,
|
||||
on_action: Option<OnIndex>,
|
||||
on_show: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl WelcomePage {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
tagline: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
tagline: tagline.into(),
|
||||
actions: Vec::new(),
|
||||
recent: None,
|
||||
show_at_start: true,
|
||||
on_action: None,
|
||||
on_show: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action(
|
||||
mut self,
|
||||
icon: IconName,
|
||||
label: impl Into<SharedString>,
|
||||
keys: Option<&str>,
|
||||
) -> Self {
|
||||
self.actions.push(StartAction {
|
||||
icon,
|
||||
label: label.into(),
|
||||
keys: keys.map(|keys| keys.to_string().into()),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Recent projects, usually a `RecentProjects`.
|
||||
pub fn recent(mut self, recent: impl IntoElement) -> Self {
|
||||
self.recent = Some(recent.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_at_start(mut self, show: bool) -> Self {
|
||||
self.show_at_start = show;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_action(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_action = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_show(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_show = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WelcomePage {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let heading = |words: &'static str| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(words)
|
||||
};
|
||||
let actions = self.actions.iter().enumerate().map(|(ix, action)| {
|
||||
let pick = self.on_action.clone();
|
||||
div()
|
||||
.id((self.id.clone(), format!("action-{ix}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.text_color(colors.link)
|
||||
.hover(|row| row.bg(colors.hover))
|
||||
.when_some(pick, |row, pick| {
|
||||
row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| pick(ix, window, cx))
|
||||
})
|
||||
.child(Icon::new(action.icon).size(IconSize::Sm).color(colors.link))
|
||||
.child(div().flex_1().child(action.label.clone()))
|
||||
.children(action.keys.as_ref().map(|keys| KbdCombo::new(keys)))
|
||||
});
|
||||
let show = Checkbox::new((self.id.clone(), "show"), self.show_at_start)
|
||||
.label("Show this page at startup");
|
||||
let show = match self.on_show {
|
||||
Some(on_show) => show.on_change(move |on, window, cx| on_show(on, window, cx)),
|
||||
None => show,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_8()
|
||||
.p_8()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xxl))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(self.title),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.tagline),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_12()
|
||||
.child(
|
||||
div()
|
||||
.w(theme.label_width() * 2.5)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(heading("Start"))
|
||||
.children(actions),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(heading("Recent"))
|
||||
.children(self.recent),
|
||||
),
|
||||
)
|
||||
.child(show)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pinned_projects_lead_and_keep_their_places() {
|
||||
let project = |name: &str, pinned: bool| Project {
|
||||
name: name.to_string().into(),
|
||||
path: "~".into(),
|
||||
branch: None,
|
||||
opened: "now".into(),
|
||||
pinned,
|
||||
};
|
||||
let projects = [project("a", false), project("b", true), project("c", false)];
|
||||
let order: Vec<usize> = ordered(&projects).into_iter().map(|(ix, _)| ix).collect();
|
||||
assert_eq!(order, [1, 0, 2]);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -54,6 +54,7 @@ pub use check::{CheckState, Checkbox, CheckboxGroup};
|
||||
pub use checked::{EmailInput, UrlInput, is_email, is_url};
|
||||
pub use chips::ChoiceChips;
|
||||
pub use code::{CodeInput, JsonInput, code_highlights, json_highlights};
|
||||
pub(crate) use code::{Kind, lex};
|
||||
pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop};
|
||||
pub use combobox::Combobox;
|
||||
pub use date::{
|
||||
@@ -94,7 +95,7 @@ pub use structure::{
|
||||
};
|
||||
pub use switch::Switch;
|
||||
pub use tags::TagInput;
|
||||
pub(crate) use text::{Down, Enter, Up, bind_keys};
|
||||
pub(crate) use text::{Down, Enter, History, Up, bind_keys, from_utf16, to_utf16};
|
||||
pub use text::{Highlight, InputEvent, TextInput};
|
||||
pub use transfer::TransferList;
|
||||
pub use unit::UnitInput;
|
||||
|
||||
+19
-10
@@ -93,17 +93,26 @@ pub(crate) struct Snapshot {
|
||||
/// Typing within this pause joins one undo step.
|
||||
const JOIN: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Undo and redo as whole-text snapshots.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct History {
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
/// Undo and redo as whole snapshots of what an editor holds.
|
||||
pub(crate) struct History<S> {
|
||||
undo: Vec<S>,
|
||||
redo: Vec<S>,
|
||||
typing_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
impl<S> Default for History<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
typing_since: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> History<S> {
|
||||
/// Records the state before an edit. Typing in one burst makes one step.
|
||||
pub fn record(&mut self, before: Snapshot, typing: bool) {
|
||||
pub fn record(&mut self, before: S, typing: bool) {
|
||||
let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN);
|
||||
if !joins {
|
||||
self.undo.push(before);
|
||||
@@ -112,14 +121,14 @@ impl History {
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, current: Snapshot) -> Option<Snapshot> {
|
||||
pub fn undo(&mut self, current: S) -> Option<S> {
|
||||
let previous = self.undo.pop()?;
|
||||
self.redo.push(current);
|
||||
self.typing_since = None;
|
||||
Some(previous)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, current: Snapshot) -> Option<Snapshot> {
|
||||
pub fn redo(&mut self, current: S) -> Option<S> {
|
||||
let next = self.redo.pop()?;
|
||||
self.undo.push(current);
|
||||
self.typing_since = None;
|
||||
@@ -175,7 +184,7 @@ mod tests {
|
||||
text: text.into(),
|
||||
selection: text.len()..text.len(),
|
||||
};
|
||||
let mut history = History::default();
|
||||
let mut history = History::<Snapshot>::default();
|
||||
history.record(snap(""), true);
|
||||
history.record(snap("a"), true);
|
||||
history.record(snap("ab"), false);
|
||||
|
||||
@@ -11,7 +11,8 @@ use gpui::{
|
||||
};
|
||||
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Submit, Up, bind_keys};
|
||||
use edit::{History, Snapshot};
|
||||
use edit::Snapshot;
|
||||
pub(crate) use edit::{History, from_utf16, to_utf16};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
@@ -64,7 +65,7 @@ pub struct TextInput {
|
||||
max_len: Option<usize>,
|
||||
highlighter: Option<Highlighter>,
|
||||
disabled: bool,
|
||||
history: History,
|
||||
history: History<Snapshot>,
|
||||
pub(crate) layout: Option<Layout>,
|
||||
pub(crate) scroll: Point<Pixels>,
|
||||
selecting: bool,
|
||||
|
||||
@@ -3,6 +3,7 @@ mod assets;
|
||||
pub mod buttons;
|
||||
pub mod charts;
|
||||
pub mod data_display;
|
||||
pub mod editor;
|
||||
pub mod feedback;
|
||||
pub mod finance;
|
||||
pub mod forms;
|
||||
@@ -38,4 +39,5 @@ pub fn init(cx: &mut App) {
|
||||
KeyBinding::new("shift-tab", FocusPrev, None),
|
||||
]);
|
||||
forms::bind_keys(cx);
|
||||
editor::bind_keys(cx);
|
||||
}
|
||||
+16
-2
@@ -104,6 +104,8 @@ impl RenderOnce for DropdownMenu {
|
||||
pub struct OverflowMenu {
|
||||
id: ElementId,
|
||||
menu: Menu,
|
||||
icon: IconName,
|
||||
tooltip: SharedString,
|
||||
}
|
||||
|
||||
impl OverflowMenu {
|
||||
@@ -111,17 +113,29 @@ impl OverflowMenu {
|
||||
Self {
|
||||
id: id.into(),
|
||||
menu,
|
||||
icon: IconName::Ellipsis,
|
||||
tooltip: "More".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = icon;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = tooltip.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for OverflowMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = state(&self.id, window, cx);
|
||||
let click = toggle(&state, &self.menu, cx);
|
||||
let button = IconButton::new((self.id.clone(), "button"), IconName::Ellipsis)
|
||||
let button = IconButton::new((self.id.clone(), "button"), self.icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip("More")
|
||||
.tooltip(self.tooltip)
|
||||
.on_click(move |event, window, cx| click(event, window, cx));
|
||||
host(self.id, button, &self.menu, &state, None, window, cx)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ mod nav;
|
||||
mod overflow;
|
||||
mod pages;
|
||||
mod palette;
|
||||
pub(crate) use palette::{fuzzy, marked};
|
||||
pub(crate) use palette::{Group, Palette, Row, fuzzy, marked, query_field};
|
||||
mod steps;
|
||||
mod tabs;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
|
||||
@@ -58,12 +58,14 @@ icons! {
|
||||
Bookmark => "bookmark",
|
||||
Bot => "bot",
|
||||
Box => "box",
|
||||
Braces => "braces",
|
||||
Brush => "brush",
|
||||
Bug => "bug",
|
||||
CalendarDays => "calendar-days",
|
||||
CalendarRange => "calendar-range",
|
||||
Calendar => "calendar",
|
||||
Camera => "camera",
|
||||
CaseSensitive => "case-sensitive",
|
||||
ChartArea => "chart-area",
|
||||
ChartBar => "chart-bar",
|
||||
ChartCandlestick => "chart-candlestick",
|
||||
@@ -207,6 +209,7 @@ icons! {
|
||||
Quote => "quote",
|
||||
Redo2 => "redo-2",
|
||||
RefreshCw => "refresh-cw",
|
||||
Regex => "regex",
|
||||
Reply => "reply",
|
||||
Rocket => "rocket",
|
||||
RotateCcw => "rotate-ccw",
|
||||
@@ -231,6 +234,7 @@ icons! {
|
||||
Spline => "spline",
|
||||
SquareCheck => "square-check",
|
||||
Square => "square",
|
||||
SquareFunction => "square-function",
|
||||
Star => "star",
|
||||
StepForward => "step-forward",
|
||||
Strikethrough => "strikethrough",
|
||||
@@ -252,12 +256,14 @@ icons! {
|
||||
Upload => "upload",
|
||||
User => "user",
|
||||
Users => "users",
|
||||
Variable => "variable",
|
||||
VideoOff => "video-off",
|
||||
Video => "video",
|
||||
Volume2 => "volume-2",
|
||||
VolumeX => "volume-x",
|
||||
Wallet => "wallet",
|
||||
WandSparkles => "wand-sparkles",
|
||||
WholeWord => "whole-word",
|
||||
WifiOff => "wifi-off",
|
||||
Wifi => "wifi",
|
||||
Wrench => "wrench",
|
||||
|
||||
@@ -167,6 +167,7 @@ type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
#[derive(IntoElement)]
|
||||
pub struct StatusBarItem {
|
||||
id: ElementId,
|
||||
leading: Option<AnyElement>,
|
||||
icon: Option<IconName>,
|
||||
label: Option<SharedString>,
|
||||
tooltip: Option<SharedString>,
|
||||
@@ -177,6 +178,7 @@ impl StatusBarItem {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
leading: None,
|
||||
icon: None,
|
||||
label: None,
|
||||
tooltip: None,
|
||||
@@ -184,6 +186,12 @@ impl StatusBarItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawn before the icon, such as a mark with a count.
|
||||
pub fn leading(mut self, leading: impl IntoElement) -> Self {
|
||||
self.leading = Some(leading.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
@@ -220,6 +228,7 @@ impl RenderOnce for StatusBarItem {
|
||||
.h_full()
|
||||
.px_1p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.children(self.leading)
|
||||
.when_some(self.icon, |item, icon| {
|
||||
item.child(
|
||||
Icon::new(icon)
|
||||
|
||||
+55
-55
@@ -273,73 +273,73 @@
|
||||
|
||||
## T17a Editor · Core — `src/editor`
|
||||
|
||||
- [ ] CodeEditor — highlight, multi-cursor, selection, undo, redo
|
||||
- [ ] EditorGutter
|
||||
- [ ] LineNumbers — absolute, relative
|
||||
- [ ] FoldingControl
|
||||
- [ ] Minimap
|
||||
- [ ] Ruler / IndentGuide
|
||||
- [ ] BracketMatcher / RainbowBrackets
|
||||
- [ ] WhitespaceRenderer
|
||||
- [ ] CursorRenderer — block, line, underline, blink
|
||||
- [ ] SelectionHighlight
|
||||
- [ ] MultiCursor
|
||||
- [ ] VimModeIndicator
|
||||
- [ ] StickyScroll
|
||||
- [ ] Breadcrumb → `navigation::Breadcrumb`
|
||||
- [ ] InlayHints
|
||||
- [ ] CodeLens
|
||||
- [ ] InlineDecoration
|
||||
- [ ] GhostText
|
||||
- [ ] InlineChat
|
||||
- [ ] InlineDiff
|
||||
- [ ] ReadOnlyBanner
|
||||
- [x] CodeEditor — highlight, multi-cursor, selection, undo, redo
|
||||
- [x] EditorGutter → `editor::CodeEditor`, its gutter: breakpoints, problems, numbers, folds, git marks
|
||||
- [x] LineNumbers — absolute, relative → `CodeEditor::line_numbers` with `LineNumbers`
|
||||
- [x] FoldingControl → `CodeEditor`, gutter folds
|
||||
- [x] Minimap → `CodeEditor::minimap`
|
||||
- [x] Ruler / IndentGuide → `CodeEditor::rulers`; guides on unless `hide_indent_guides`
|
||||
- [x] BracketMatcher / RainbowBrackets → `CodeEditor::rainbow_brackets`; the matched pair always marked
|
||||
- [x] WhitespaceRenderer → `CodeEditor::show_whitespace`
|
||||
- [x] CursorRenderer — block, line, underline, blink → `CodeEditor::cursor_shape` with `CursorShape`
|
||||
- [x] SelectionHighlight → `CodeEditor`, the word at the cursor marked while focused
|
||||
- [x] MultiCursor → `CodeEditor`: Cmd-D, Cmd-Alt-Up and Down, Alt-click
|
||||
- [x] VimModeIndicator
|
||||
- [x] StickyScroll → `CodeEditor::sticky_scroll`
|
||||
- [x] Breadcrumb → `navigation::Breadcrumb`
|
||||
- [x] InlayHints → `CodeEditor::set_inlay_hints`
|
||||
- [x] CodeLens → `CodeEditor::set_code_lenses`
|
||||
- [x] InlineDecoration → `CodeEditor::set_diagnostics` and `set_backgrounds`
|
||||
- [x] GhostText → `CodeEditor::set_ghost_text`
|
||||
- [x] InlineChat → `CodeEditor::set_chat`
|
||||
- [x] InlineDiff → `CodeEditor::set_diff`
|
||||
- [x] ReadOnlyBanner
|
||||
|
||||
## T17b Editor · Intelligence
|
||||
|
||||
- [ ] CompletionMenu
|
||||
- [ ] SignatureHelp
|
||||
- [ ] HoverInfo
|
||||
- [ ] QuickFix / CodeActionMenu
|
||||
- [ ] RenameInput
|
||||
- [ ] PeekView → `overlays::Peek`
|
||||
- [ ] ReferencesPanel
|
||||
- [ ] SymbolOutline
|
||||
- [ ] GoToSymbol
|
||||
- [ ] CallHierarchy / TypeHierarchy
|
||||
- [x] CompletionMenu
|
||||
- [x] SignatureHelp
|
||||
- [x] HoverInfo
|
||||
- [x] QuickFix / CodeActionMenu → `editor::CodeActionMenu`
|
||||
- [x] RenameInput
|
||||
- [x] PeekView → `overlays::Peek`
|
||||
- [x] ReferencesPanel
|
||||
- [x] SymbolOutline
|
||||
- [x] GoToSymbol
|
||||
- [x] CallHierarchy / TypeHierarchy → `editor::CallHierarchy`, `types` for supertypes and subtypes
|
||||
|
||||
## T17c Editor · Search
|
||||
|
||||
- [ ] FindWidget
|
||||
- [ ] ReplaceWidget → `editor::FindWidget`
|
||||
- [ ] SearchPanel
|
||||
- [ ] SearchResultItem
|
||||
- [ ] SearchFilters
|
||||
- [x] FindWidget
|
||||
- [x] ReplaceWidget → `editor::FindWidget::replace`
|
||||
- [x] SearchPanel
|
||||
- [x] SearchResultItem
|
||||
- [x] SearchFilters
|
||||
|
||||
## T17d Editor · Panels
|
||||
|
||||
- [ ] ProblemsPanel / DiagnosticsList
|
||||
- [ ] OutputPanel
|
||||
- [ ] DebugConsole
|
||||
- [ ] ExtensionsPanel / PluginList
|
||||
- [ ] SettingsEditor — UI and JSON
|
||||
- [ ] KeybindingsEditor
|
||||
- [ ] ThemePreview
|
||||
- [ ] WelcomePage
|
||||
- [ ] RecentProjects
|
||||
- [ ] ProjectSwitcher
|
||||
- [ ] TaskRunner / RunConfiguration
|
||||
- [x] ProblemsPanel / DiagnosticsList → `editor::ProblemsPanel`
|
||||
- [x] OutputPanel
|
||||
- [x] DebugConsole
|
||||
- [x] ExtensionsPanel / PluginList → `editor::ExtensionsPanel`
|
||||
- [x] SettingsEditor — UI and JSON
|
||||
- [x] KeybindingsEditor
|
||||
- [x] ThemePreview
|
||||
- [x] WelcomePage
|
||||
- [x] RecentProjects
|
||||
- [x] ProjectSwitcher
|
||||
- [x] TaskRunner / RunConfiguration → `editor::TaskRunner`, a task open to its configuration
|
||||
|
||||
## T17e Editor · Status items
|
||||
|
||||
- [ ] CursorPosition
|
||||
- [ ] LanguageMode
|
||||
- [ ] Encoding
|
||||
- [ ] LineEnding
|
||||
- [ ] IndentSettings
|
||||
- [ ] BranchIndicator
|
||||
- [ ] LspStatus
|
||||
- [ ] NotificationBell
|
||||
- [x] CursorPosition
|
||||
- [x] LanguageMode
|
||||
- [x] Encoding
|
||||
- [x] LineEnding
|
||||
- [x] IndentSettings
|
||||
- [x] BranchIndicator
|
||||
- [x] LspStatus
|
||||
- [x] NotificationBell
|
||||
|
||||
## T18 Terminal — `src/terminal`
|
||||
|
||||
|
||||
Reference in new issue
Block a user