diff --git a/AGENTS.md b/AGENTS.md
index 3192c43..b3f5ee5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/TASKS.md b/TASKS.md
index 923bed9..ff6150b 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -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 |
diff --git a/assets/icons/braces.svg b/assets/icons/braces.svg
new file mode 100644
index 0000000..8ce7870
--- /dev/null
+++ b/assets/icons/braces.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/case-sensitive.svg b/assets/icons/case-sensitive.svg
new file mode 100644
index 0000000..981c2c1
--- /dev/null
+++ b/assets/icons/case-sensitive.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/regex.svg b/assets/icons/regex.svg
new file mode 100644
index 0000000..07a3b42
--- /dev/null
+++ b/assets/icons/regex.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/square-function.svg b/assets/icons/square-function.svg
new file mode 100644
index 0000000..621ee4d
--- /dev/null
+++ b/assets/icons/square-function.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/variable.svg b/assets/icons/variable.svg
new file mode 100644
index 0000000..fc4d5ff
--- /dev/null
+++ b/assets/icons/variable.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/whole-word.svg b/assets/icons/whole-word.svg
new file mode 100644
index 0000000..acb0b73
--- /dev/null
+++ b/assets/icons/whole-word.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/examples/gallery/pages/editor/code.rs b/examples/gallery/pages/editor/code.rs
new file mode 100644
index 0000000..61303c1
--- /dev/null
+++ b/examples/gallery/pages/editor/code.rs
@@ -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,
+ 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 = (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 {
+ 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)),
+ )
+}
diff --git a/examples/gallery/pages/editor/intel.rs b/examples/gallery/pages/editor/intel.rs
new file mode 100644
index 0000000..b454dea
--- /dev/null
+++ b/examples/gallery/pages/editor/intel.rs
@@ -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 {
+ 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"),
+ 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::, 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 {
+ vec![
+ Symbol::new("Palette", SymbolKind::Struct, 3).children([
+ Symbol::new("colors", SymbolKind::Field, 4).detail("HashMap"),
+ 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 = (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))
+ }))
+}
diff --git a/examples/gallery/pages/editor/mod.rs b/examples/gallery/pages/editor/mod.rs
new file mode 100644
index 0000000..00a72a9
--- /dev/null
+++ b/examples/gallery/pages/editor/mod.rs
@@ -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()
+}
diff --git a/examples/gallery/pages/editor/panels.rs b/examples/gallery/pages/editor/panels.rs
new file mode 100644
index 0000000..3af3f15
--- /dev/null
+++ b/examples/gallery/pages/editor/panels.rs
@@ -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 {
+ 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::, 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::() {
+ 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)
+ }))),
+ )
+}
diff --git a/examples/gallery/pages/editor/search.rs b/examples/gallery/pages/editor/search.rs
new file mode 100644
index 0000000..59405a2
--- /dev/null
+++ b/examples/gallery/pages/editor/search.rs
@@ -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 {
+ 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 {
+ 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::::new, window, cx);
+ let opened = keep("search-opened", || None::, 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 = 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))
+}
diff --git a/examples/gallery/pages/editor/settings.rs b/examples/gallery/pages/editor/settings.rs
new file mode 100644
index 0000000..69c137c
--- /dev/null
+++ b/examples/gallery/pages/editor/settings.rs
@@ -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)> {
+ 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::, 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)
+ }),
+ ),
+ )
+}
diff --git a/examples/gallery/pages/editor/start.rs b/examples/gallery/pages/editor/start.rs
new file mode 100644
index 0000000..5c7a9b6
--- /dev/null
+++ b/examples/gallery/pages/editor/start.rs
@@ -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 {
+ 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::, 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))
+ }))
+}
diff --git a/examples/gallery/pages/editor/status.rs b/examples/gallery/pages/editor/status.rs
new file mode 100644
index 0000000..be348b5
--- /dev/null
+++ b/examples/gallery/pages/editor/status.rs
@@ -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::, 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))
+}
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index 8cd0e38..5445654 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -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,
];
diff --git a/src/editor/buffer.rs b/src/editor/buffer.rs
new file mode 100644
index 0000000..a5ca6e7
--- /dev/null
+++ b/src/editor/buffer.rs
@@ -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,
+}
+
+fn starts(text: &str) -> Vec {
+ std::iter::once(0)
+ .chain(text.match_indices('\n').map(|(at, _)| at + 1))
+ .collect()
+}
+
+impl Buffer {
+ pub fn new(text: impl Into) -> 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 {
+ 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, 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 {
+ 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");
+ }
+}
diff --git a/src/editor/chat.rs b/src/editor/chat.rs
new file mode 100644
index 0000000..ddad0e0
--- /dev/null
+++ b/src/editor/chat.rs
@@ -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;
+type OnClose = Rc;
+
+/// 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,
+ working: bool,
+ on_ask: Option,
+ on_close: Option,
+}
+
+impl InlineChat {
+ pub fn new(id: impl Into, field: &Entity) -> 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"),
+ )
+ }
+}
diff --git a/src/editor/console.rs b/src/editor/console.rs
new file mode 100644
index 0000000..322662e
--- /dev/null
+++ b/src/editor/console.rs
@@ -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;
+type OnFlag = Rc;
+type OnRun = Rc;
+
+/// 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,
+ channel: SharedString,
+ lines: Vec,
+ follow: bool,
+ on_channel: Option,
+ on_clear: Option,
+ on_follow: Option,
+}
+
+impl OutputPanel {
+ pub fn new(
+ id: impl Into,
+ channels: impl IntoIterator- >,
+ channel: impl Into,
+ lines: impl IntoIterator
- >,
+ ) -> Self {
+ let channels: Vec = 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,
+ field: Entity,
+ on_eval: Option,
+}
+
+impl DebugConsole {
+ pub fn new(
+ id: impl Into,
+ field: &Entity,
+ entries: impl IntoIterator
- ,
+ ) -> 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);
+ }
+}
diff --git a/src/editor/cursor.rs b/src/editor/cursor.rs
new file mode 100644
index 0000000..294cde5
--- /dev/null
+++ b/src/editor/cursor.rs
@@ -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,
+}
+
+impl Selection {
+ pub fn caret(at: usize) -> Self {
+ Self {
+ anchor: at,
+ head: at,
+ goal: None,
+ }
+ }
+
+ pub fn range(&self) -> Range {
+ 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) -> Vec {
+ assert!(!all.is_empty(), "an editor keeps at least one cursor");
+ all.sort_by_key(|selection| selection.range().start);
+ let mut out: Vec = 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, String)>,
+) -> Vec {
+ let mut order: Vec = (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::>(),
+ [10, 2, 6]
+ );
+ }
+}
diff --git a/src/editor/decor.rs b/src/editor/decor.rs
new file mode 100644
index 0000000..f341b67
--- /dev/null
+++ b/src/editor/decor.rs
@@ -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,
+ 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,
+}
+
+/// 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,
+}
+
+/// 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;
+
+/// 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, message: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ message: message.into(),
+ action: None,
+ }
+ }
+
+ pub fn action(
+ mut self,
+ label: impl Into,
+ 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,
+}
+
+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) -> 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)
+ }))
+ }
+}
diff --git a/src/editor/edit.rs b/src/editor/edit.rs
new file mode 100644
index 0000000..6b6c049
--- /dev/null
+++ b/src/editor/edit.rs
@@ -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) {
+ 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, String)>,
+ typing: bool,
+ cx: &mut Context,
+ ) {
+ 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) {
+ 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 = 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,
+ cx: &mut Context,
+ ) {
+ 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) {
+ 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::>();
+ let closers: Vec = edits
+ .iter()
+ .map(|(_, text)| text.matches('\n').count() == 2)
+ .collect();
+ self.apply(edits, false, cx);
+ let placed: Vec = 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 {
+ let mut lines: Vec = 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) {
+ 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) {
+ 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, String)>,
+ kept: Vec,
+ cx: &mut Context,
+ ) {
+ let moves: Vec<(std::ops::Range, 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 = 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) {
+ let lines: Vec = 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) {
+ if let Some(previous) = self.history.undo(self.snapshot()) {
+ self.restore(previous, cx);
+ }
+ }
+
+ pub(crate) fn redo(&mut self, cx: &mut Context) {
+ 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, 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) {
+ let text: Vec = 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) {
+ let copied = self.copied();
+ let text: Vec = 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) {
+ 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, String)>) -> Vec<(Range, String)> {
+ edits.sort_by_key(|(range, _)| range.start);
+ let mut out: Vec<(Range, 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
+}
diff --git a/src/editor/extensions.rs b/src/editor/extensions.rs
new file mode 100644
index 0000000..2a2c6a2
--- /dev/null
+++ b/src/editor/extensions.rs
@@ -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;
+
+/// 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,
+ on_action: Option,
+}
+
+impl ExtensionsPanel {
+ pub fn new(id: impl Into, extensions: impl IntoIterator
- ) -> 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)
+ }
+}
diff --git a/src/editor/filters.rs b/src/editor/filters.rs
new file mode 100644
index 0000000..912a602
--- /dev/null
+++ b/src/editor/filters.rs
@@ -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 {
+ 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;
+
+/// 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,
+ exclude: Entity,
+ ignore: bool,
+ on_ignore: Option,
+}
+
+impl SearchFilters {
+ pub fn new(
+ id: impl Into,
+ include: &Entity,
+ exclude: &Entity,
+ ) -> 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", ""));
+ }
+}
diff --git a/src/editor/find.rs b/src/editor/find.rs
new file mode 100644
index 0000000..8392270
--- /dev/null
+++ b/src/editor/find.rs
@@ -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>, 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;
+type OnBool = Rc;
+type OnClose = Rc;
+
+/// 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,
+ replace: Option>,
+ options: FindOptions,
+ matches: Result<(Option, usize), SharedString>,
+ on_options: Option,
+ on_step: Option,
+ on_replace: Option,
+ on_toggle_replace: Option,
+ on_close: Option,
+}
+
+impl FindWidget {
+ /// `find` holds the query; `current` is the chosen match among `total`.
+ pub fn new(
+ id: impl Into,
+ find: &Entity,
+ current: Option,
+ 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) -> 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) -> 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,
+ 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"
+ );
+ }
+}
diff --git a/src/editor/gutter.rs b/src/editor/gutter.rs
new file mode 100644
index 0000000..008b896
--- /dev/null
+++ b/src/editor/gutter.rs
@@ -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 {
+ 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) -> Option {
+ 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) -> 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()
+ }
+}
diff --git a/src/editor/hints.rs b/src/editor/hints.rs
new file mode 100644
index 0000000..0bf3449
--- /dev/null
+++ b/src/editor/hints.rs
@@ -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,
+ pub docs: Option,
+}
+
+impl CompletionItem {
+ pub fn new(label: impl Into, kind: SymbolKind) -> Self {
+ Self {
+ label: label.into(),
+ kind,
+ detail: None,
+ docs: None,
+ }
+ }
+
+ pub fn detail(mut self, detail: impl Into) -> Self {
+ self.detail = Some(detail.into());
+ self
+ }
+
+ pub fn docs(mut self, docs: impl Into) -> 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>)> {
+ let mut fits: Vec<(i32, CompletionItem, Vec>)> = 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;
+
+/// 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,
+ typed: SharedString,
+ selected: usize,
+ on_pick: Option,
+}
+
+impl CompletionMenu {
+ pub fn new(id: impl Into, items: impl IntoIterator
- ) -> 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) -> 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 = 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>,
+ pub docs: Option,
+}
+
+type OnStep = Rc;
+
+/// 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,
+ active: usize,
+ param: usize,
+ on_step: Option,
+}
+
+impl SignatureHelp {
+ pub fn new(id: impl Into, signatures: impl IntoIterator
- ) -> Self {
+ let signatures: Vec = 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);
+ }
+}
diff --git a/src/editor/hover.rs b/src/editor/hover.rs
new file mode 100644
index 0000000..5cbed7e
--- /dev/null
+++ b/src/editor/hover.rs
@@ -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;
+
+/// 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,
+ links: Vec<(SharedString, OnPress)>,
+}
+
+impl HoverInfo {
+ pub fn new(id: impl Into, signature: impl Into) -> 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) -> Self {
+ self.problem = Some((severity, message.into()));
+ self
+ }
+
+ /// A paragraph of documentation.
+ pub fn docs(mut self, paragraph: impl Into) -> Self {
+ self.docs.push(paragraph.into());
+ self
+ }
+
+ pub fn link(
+ mut self,
+ label: impl Into,
+ 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>) {
+ 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()));
+ }
+}
diff --git a/src/editor/ime.rs b/src/editor/ime.rs
new file mode 100644
index 0000000..a710af9
--- /dev/null
+++ b/src/editor/ime.rs
@@ -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 {
+ 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, cx: &App) -> Option {
+ 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) -> Range {
+ let text = self.buffer.text();
+ to_utf16(text, range.start)..to_utf16(text, range.end)
+ }
+
+ fn byte_range(&self, range: &Range) -> Range {
+ 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,
+ actual_range: &mut Option>,
+ _: &mut Window,
+ _: &mut Context,
+ ) -> Option {
+ 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,
+ ) -> Option {
+ 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) -> Option> {
+ self.marked.as_ref().map(|range| self.utf16_range(range))
+ }
+
+ fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) {
+ self.marked = None;
+ cx.notify();
+ }
+
+ fn replace_text_in_range(
+ &mut self,
+ range_utf16: Option>,
+ text: &str,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ 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>,
+ text: &str,
+ selected_utf16: Option>,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ 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,
+ _: Bounds,
+ _: &mut Window,
+ cx: &mut Context,
+ ) -> Option> {
+ 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,
+ _: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ let offset = self.offset_at(position, cx)?;
+ Some(to_utf16(self.buffer.text(), offset))
+ }
+}
diff --git a/src/editor/keybindings.rs b/src/editor/keybindings.rs
new file mode 100644
index 0000000..eafae0e
--- /dev/null
+++ b/src/editor/keybindings.rs
@@ -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,
+ pub when: Option,
+ 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 {
+ 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;
+type OnBind = Rc, &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,
+ query: SharedString,
+ recording: Option,
+ on_record: Option,
+ on_bind: Option,
+}
+
+impl KeybindingsEditor {
+ pub fn new(id: impl Into, bindings: impl IntoIterator
- ) -> 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) -> Self {
+ self.query = query.into();
+ self
+ }
+
+ /// The command whose keys are being recorded.
+ pub fn recording(mut self, command: Option) -> 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, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_bind = Some(Rc::new(handler));
+ self
+ }
+}
+
+/// The four columns every row shares.
+fn columns(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"
+ );
+ }
+}
diff --git a/src/editor/keys.rs b/src/editor/keys.rs
new file mode 100644
index 0000000..8330727
--- /dev/null
+++ b/src/editor/keys.rs
@@ -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 {
+ buffer.line_range(buffer.line_of(at)).start..at
+}
+
+/// Wires every action to the editor that renders `root`.
+pub(crate) fn listen(root: E, cx: &mut Context) -> 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| {
+ let run: fn(&mut CodeEditor, &mut Window, &mut Context) = $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);
+ }
+ })
+}
diff --git a/src/editor/layout.rs b/src/editor/layout.rs
new file mode 100644
index 0000000..0632dc0
--- /dev/null
+++ b/src/editor/layout.rs
@@ -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,
+) -> Vec {
+ 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
{
+ 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 {
+ let mut headers: Vec = 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, HighlightStyle)>,
+) -> Vec<(Range, HighlightStyle)> {
+ let mut edges: Vec = 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, (_, 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, 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, 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, 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::()
+ }
+
+ /// 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::()
+ }
+
+ /// 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"
+ );
+ }
+}
diff --git a/src/editor/line.rs b/src/editor/line.rs
new file mode 100644
index 0000000..c8803e2
--- /dev/null
+++ b/src/editor/line.rs
@@ -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) -> Vec<(Range, HighlightStyle)> {
+ let colors = &cx.theme().colors;
+ let range = self.row_range(line);
+ let local = |span: &Range| {
+ span.start.max(range.start) - range.start..span.end.min(range.end) - range.start
+ };
+ let touches = |span: &Range| {
+ 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 {
+ 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) -> 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,
+ row: usize,
+ line: usize,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> 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,
+ ) {
+ 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) {
+ 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);
+ }
+}
diff --git a/src/editor/mod.rs b/src/editor/mod.rs
new file mode 100644
index 0000000..19727e4
--- /dev/null
+++ b/src/editor/mod.rs
@@ -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};
diff --git a/src/editor/moves.rs b/src/editor/moves.rs
new file mode 100644
index 0000000..b339184
--- /dev/null
+++ b/src/editor/moves.rs
@@ -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) {
+ let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded);
+ let vertical = matches!(motion, Motion::Up | Motion::Down | Motion::Page(_));
+ let moved: Vec = 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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 = 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 = 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) {
+ let strategy = if down {
+ ScrollStrategy::Bottom
+ } else {
+ ScrollStrategy::Top
+ };
+ self.scroll.scroll_to_item(self.primary_row(), strategy);
+ cx.notify();
+ }
+}
diff --git a/src/editor/preview.rs b/src/editor/preview.rs
new file mode 100644
index 0000000..b1aebd8
--- /dev/null
+++ b/src/editor/preview.rs
@@ -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;
+
+/// 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,
+}
+
+impl ThemePreview {
+ pub fn new(id: impl Into