From 5545ce6458d61f9be6dda933ba5816e3a137c5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 00:43:41 -0400 Subject: [PATCH] T17 Editor: CodeEditor with gutter, folds, minimap, rulers, guides, brackets, whitespace, cursors, multi-cursor, sticky scroll, inlay hints, lenses, decorations, ghost text, inline chat and diff (T17a); completions, signatures, hover, code actions, rename, references, outline, go to symbol, call and type hierarchy (T17b); find and replace, search panel and filters (T17c); problems, output, debug console, extensions, settings with JSON, keybindings, theme previews, welcome, recent projects, switcher, task runner (T17d); status items (T17e) Shared: generic undo History, OverflowMenu icon and tooltip, StatusBarItem leading, six Lucide icons. --- AGENTS.md | 4 + TASKS.md | 5 + assets/icons/braces.svg | 16 + assets/icons/case-sensitive.svg | 18 + assets/icons/regex.svg | 18 + assets/icons/square-function.svg | 17 + assets/icons/variable.svg | 18 + assets/icons/whole-word.svg | 19 + examples/gallery/pages/editor/code.rs | 309 ++++++++++++++ examples/gallery/pages/editor/intel.rs | 241 +++++++++++ examples/gallery/pages/editor/mod.rs | 59 +++ examples/gallery/pages/editor/panels.rs | 259 ++++++++++++ examples/gallery/pages/editor/search.rs | 241 +++++++++++ examples/gallery/pages/editor/settings.rs | 208 ++++++++++ examples/gallery/pages/editor/start.rs | 170 ++++++++ examples/gallery/pages/editor/status.rs | 86 ++++ examples/gallery/pages/mod.rs | 2 + src/editor/buffer.rs | 254 ++++++++++++ src/editor/chat.rs | 137 ++++++ src/editor/console.rs | 308 ++++++++++++++ src/editor/cursor.rs | 231 +++++++++++ src/editor/decor.rs | 195 +++++++++ src/editor/edit.rs | 378 +++++++++++++++++ src/editor/extensions.rs | 208 ++++++++++ src/editor/filters.rs | 141 +++++++ src/editor/find.rs | 358 ++++++++++++++++ src/editor/gutter.rs | 183 ++++++++ src/editor/hints.rs | 362 ++++++++++++++++ src/editor/hover.rs | 166 ++++++++ src/editor/ime.rs | 216 ++++++++++ src/editor/keybindings.rs | 267 ++++++++++++ src/editor/keys.rs | 287 +++++++++++++ src/editor/layout.rs | 273 ++++++++++++ src/editor/line.rs | 409 ++++++++++++++++++ src/editor/mod.rs | 62 +++ src/editor/moves.rs | 173 ++++++++ src/editor/preview.rs | 184 +++++++++ src/editor/problems.rs | 286 +++++++++++++ src/editor/refactor.rs | 195 +++++++++ src/editor/row.rs | 177 ++++++++ src/editor/search.rs | 455 ++++++++++++++++++++ src/editor/settings.rs | 412 ++++++++++++++++++ src/editor/state.rs | 481 ++++++++++++++++++++++ src/editor/status.rs | 383 +++++++++++++++++ src/editor/symbols.rs | 447 ++++++++++++++++++++ src/editor/syntax.rs | 183 ++++++++ src/editor/tasks.rs | 269 ++++++++++++ src/editor/tests.rs | 114 +++++ src/editor/view.rs | 353 ++++++++++++++++ src/editor/welcome.rs | 470 +++++++++++++++++++++ src/forms/mod.rs | 3 +- src/forms/text/edit.rs | 29 +- src/forms/text/mod.rs | 5 +- src/lib.rs | 2 + src/menus/hosts.rs | 18 +- src/navigation/mod.rs | 2 +- src/primitives/icon.rs | 6 + src/shell/bars.rs | 9 + tasks/ch11-20.md | 110 ++--- 59 files changed, 10820 insertions(+), 71 deletions(-) create mode 100644 assets/icons/braces.svg create mode 100644 assets/icons/case-sensitive.svg create mode 100644 assets/icons/regex.svg create mode 100644 assets/icons/square-function.svg create mode 100644 assets/icons/variable.svg create mode 100644 assets/icons/whole-word.svg create mode 100644 examples/gallery/pages/editor/code.rs create mode 100644 examples/gallery/pages/editor/intel.rs create mode 100644 examples/gallery/pages/editor/mod.rs create mode 100644 examples/gallery/pages/editor/panels.rs create mode 100644 examples/gallery/pages/editor/search.rs create mode 100644 examples/gallery/pages/editor/settings.rs create mode 100644 examples/gallery/pages/editor/start.rs create mode 100644 examples/gallery/pages/editor/status.rs create mode 100644 src/editor/buffer.rs create mode 100644 src/editor/chat.rs create mode 100644 src/editor/console.rs create mode 100644 src/editor/cursor.rs create mode 100644 src/editor/decor.rs create mode 100644 src/editor/edit.rs create mode 100644 src/editor/extensions.rs create mode 100644 src/editor/filters.rs create mode 100644 src/editor/find.rs create mode 100644 src/editor/gutter.rs create mode 100644 src/editor/hints.rs create mode 100644 src/editor/hover.rs create mode 100644 src/editor/ime.rs create mode 100644 src/editor/keybindings.rs create mode 100644 src/editor/keys.rs create mode 100644 src/editor/layout.rs create mode 100644 src/editor/line.rs create mode 100644 src/editor/mod.rs create mode 100644 src/editor/moves.rs create mode 100644 src/editor/preview.rs create mode 100644 src/editor/problems.rs create mode 100644 src/editor/refactor.rs create mode 100644 src/editor/row.rs create mode 100644 src/editor/search.rs create mode 100644 src/editor/settings.rs create mode 100644 src/editor/state.rs create mode 100644 src/editor/status.rs create mode 100644 src/editor/symbols.rs create mode 100644 src/editor/syntax.rs create mode 100644 src/editor/tasks.rs create mode 100644 src/editor/tests.rs create mode 100644 src/editor/view.rs create mode 100644 src/editor/welcome.rs 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, name: impl Into, palette: Palette) -> Self { + Self { + id: id.into(), + name: name.into(), + palette, + selected: false, + on_pick: None, + } + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn on_pick(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ThemePreview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let ring = theme.colors.focus; + let shown = self.palette; + let syntax = shown.syntax; + let line = |parts: Vec<(&'static str, gpui::Hsla)>, indent: usize| { + div() + .flex() + .pl(theme.text_size(TextSize::Xs) * indent as f32) + .children( + parts + .into_iter() + .map(|(words, color)| div().text_color(color).child(words)), + ) + }; + let code = div() + .flex_1() + .p_2() + .flex() + .flex_col() + .gap_0p5() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .child(line( + vec![ + ("fn ", syntax.keyword), + ("paint", syntax.function), + ("(", syntax.punctuation), + ("mode", syntax.variable), + (": ", syntax.punctuation), + ("Mode", syntax.type_name), + (") {", syntax.punctuation), + ], + 0, + )) + .child(line(vec![("// quiet by default", syntax.comment)], 2)) + .child(line( + vec![ + ("let ", syntax.keyword), + ("gap", syntax.variable), + (" = ", syntax.operator), + ("12", syntax.number), + (";", syntax.punctuation), + ], + 2, + )) + .child(line( + vec![ + ("draw", syntax.function), + ("(", syntax.punctuation), + ("\"surface\"", syntax.string), + (", ", syntax.punctuation), + ("true", syntax.constant), + (");", syntax.punctuation), + ], + 2, + )) + .child(line(vec![("}", syntax.punctuation)], 0)); + let pick = self.on_pick; + div() + .id(self.id) + .flex() + .flex_col() + .gap_2() + .cursor_pointer() + .when_some(pick, |card, pick| { + card.on_click(move |_, window, cx| pick(window, cx)) + }) + .child( + div() + .h(theme.label_width() * 1.1) + .flex() + .flex_col() + .rounded(theme.radius(Radius::Md)) + .border_2() + .border_color(if self.selected { ring } else { shown.border }) + .bg(shown.bg) + .overflow_hidden() + .child( + div() + .flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .bg(shown.surface) + .border_b_1() + .border_color(shown.border) + .children( + [shown.danger, shown.warning, shown.success] + .map(|dot| div().size_1p5().rounded_full().bg(dot)), + ), + ) + .child( + div() + .flex_1() + .flex() + .child( + div() + .w_6() + .bg(shown.sunken) + .border_r_1() + .border_color(shown.border), + ) + .child(code), + ) + .child( + div() + .flex() + .items_center() + .px_2() + .h_3() + .bg(shown.surface) + .border_t_1() + .border_color(shown.border) + .child(div().w_4().h_1().rounded_full().bg(shown.accent)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(if self.selected { + FontWeight::SEMIBOLD + } else { + FontWeight::NORMAL + }) + .text_color(theme.colors.fg) + .when(self.selected, |label| { + label.child(Icon::new(IconName::Check).size(IconSize::Sm).color(ring)) + }) + .child(self.name), + ) + } +} diff --git a/src/editor/problems.rs b/src/editor/problems.rs new file mode 100644 index 0000000..322cc55 --- /dev/null +++ b/src/editor/problems.rs @@ -0,0 +1,286 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{ToggleButton, ToggleItem}, + primitives::{Icon, IconName, Severity}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// A problem the tools found: where, how bad, what, and which tool said so. +#[derive(Clone, Debug, PartialEq)] +pub struct Problem { + pub path: SharedString, + pub line: usize, + pub column: usize, + pub severity: Severity, + pub message: SharedString, + pub source: Option, +} + +/// How bad a severity is, worst highest. +pub(crate) fn weight(severity: Severity) -> u8 { + match severity { + Severity::Danger => 3, + Severity::Warning => 2, + Severity::Info => 1, + Severity::Success => 0, + } +} + +/// Problems by file, worst first within each, and each file in the order its worst problem ranks. +pub(crate) fn grouped( + problems: &[Problem], + shown: &[Severity], +) -> Vec<(SharedString, Vec)> { + let mut files: Vec<(SharedString, Vec)> = Vec::new(); + for problem in problems + .iter() + .filter(|problem| shown.contains(&problem.severity)) + { + match files.iter_mut().find(|(path, _)| *path == problem.path) { + Some((_, list)) => list.push(problem.clone()), + None => files.push((problem.path.clone(), vec![problem.clone()])), + } + } + for (_, list) in &mut files { + list.sort_by(|a, b| { + weight(b.severity) + .cmp(&weight(a.severity)) + .then(a.line.cmp(&b.line)) + }); + } + files.sort_by_key(|(_, list)| { + std::cmp::Reverse(list.first().map_or(0, |problem| weight(problem.severity))) + }); + files +} + +type OnOpen = Rc; +type OnShown = Rc, &mut Window, &mut App)>; + +/// Every problem in the workspace, grouped by file: toggles by severity with their counts, and a press opens one. +#[derive(IntoElement)] +pub struct ProblemsPanel { + id: ElementId, + problems: Vec, + shown: Vec, + on_open: Option, + on_shown: Option, +} + +impl ProblemsPanel { + pub fn new(id: impl Into, problems: impl IntoIterator) -> Self { + Self { + id: id.into(), + problems: problems.into_iter().collect(), + shown: vec![Severity::Danger, Severity::Warning, Severity::Info], + on_open: None, + on_shown: None, + } + } + + /// The severities listed; the rest are counted but hidden. + pub fn shown(mut self, severities: impl IntoIterator) -> Self { + self.shown = severities.into_iter().collect(); + self + } + + pub fn on_open(mut self, handler: impl Fn(&Problem, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + pub fn on_shown( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_shown = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ProblemsPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let count = |severity: Severity| { + self.problems + .iter() + .filter(|problem| problem.severity == severity) + .count() + }; + let filters = [ + (Severity::Danger, "errors", "Error"), + (Severity::Warning, "warnings", "Warning"), + (Severity::Info, "notes", "Note"), + ] + .map(|(severity, key, words)| { + let (shown, on_shown) = (self.shown.clone(), self.on_shown.clone()); + let found = count(severity); + let label = format!("{found} {words}{}", if found == 1 { "" } else { "s" }); + ToggleButton::new( + (self.id.clone(), key), + ToggleItem::new(key).icon(severity.icon()).label(label), + self.shown.contains(&severity), + ) + .size(ControlSize::Sm) + .on_toggle(move |on, window, cx| { + let mut next: Vec = shown + .iter() + .copied() + .filter(|kept| *kept != severity) + .collect(); + if on { + next.push(severity); + } + log::info!("problems: showing {next:?}"); + if let Some(on_shown) = &on_shown { + on_shown(next, window, cx); + } + }) + }); + let files = grouped(&self.problems, &self.shown); + let mut rows = Vec::new(); + for (file_ix, (path, list)) in files.iter().enumerate() { + let (name, dir) = path + .rsplit_once('/') + .map_or((path.to_string(), String::new()), |(dir, name)| { + (name.to_string(), dir.to_string()) + }); + rows.push( + div() + .flex() + .items_center() + .gap_1p5() + .px_1() + .pt_1() + .text_size(theme.text_size(TextSize::Sm)) + .child( + Icon::new(IconName::FileText) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child(div().text_color(colors.fg).child(name)) + .child(div().text_color(colors.fg_subtle).child(dir)) + .child( + div() + .px_1p5() + .rounded_full() + .bg(colors.hover) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(list.len().to_string()), + ) + .into_any_element(), + ); + for (ix, problem) in list.iter().enumerate() { + let (open, picked) = (self.on_open.clone(), problem.clone()); + rows.push( + div() + .id((self.id.clone(), format!("problem-{file_ix}-{ix}"))) + .flex() + .items_center() + .gap_2() + .pl_6() + .pr_2() + .py_0p5() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .text_size(theme.text_size(TextSize::Sm)) + .when_some(open, |row, open| { + row.on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + log::info!("problems: open {}:{}", picked.path, picked.line + 1); + open(&picked, window, cx) + }) + }) + .child( + Icon::new(problem.severity.icon()) + .size(IconSize::Sm) + .color(problem.severity.color(&colors)), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(Ellipsis::new(problem.message.clone())), + ) + .children( + problem + .source + .clone() + .map(|source| div().text_color(colors.fg_subtle).child(source)), + ) + .child( + div() + .text_color(colors.fg_subtle) + .font_family(theme.mono_family.clone()) + .child(format!("{}:{}", problem.line + 1, problem.column + 1)), + ) + .into_any_element(), + ); + } + } + let empty = rows.is_empty(); + div() + .flex() + .flex_col() + .gap_1() + .child(div().flex().gap_1().children(filters)) + .when(empty, |panel| { + panel.child( + div() + .py_4() + .text_center() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_subtle) + .child("No problems shown."), + ) + }) + .children(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn problem(path: &str, line: usize, severity: Severity) -> Problem { + Problem { + path: path.to_string().into(), + line, + column: 0, + severity, + message: "m".into(), + source: None, + } + } + + #[test] + fn files_with_worse_problems_lead_and_filters_hide() { + let problems = [ + problem("a.rs", 9, Severity::Warning), + problem("b.rs", 3, Severity::Danger), + problem("a.rs", 2, Severity::Info), + ]; + let all = grouped( + &problems, + &[Severity::Danger, Severity::Warning, Severity::Info], + ); + assert_eq!(all[0].0.as_ref(), "b.rs"); + assert_eq!(all[1].1[0].line, 9, "the warning before the note"); + let only = grouped(&problems, &[Severity::Info]); + assert_eq!(only.len(), 1); + assert_eq!(only[0].1.len(), 1); + } +} diff --git a/src/editor/refactor.rs b/src/editor/refactor.rs new file mode 100644 index 0000000..e560a1b --- /dev/null +++ b/src/editor/refactor.rs @@ -0,0 +1,195 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, ParentElement, + RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + forms::{Enter, Input, TextInput}, + menus::{Menu, MenuItem, OverflowMenu}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}, +}; + +/// What a code action does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionKind { + QuickFix, + Refactor, + Source, +} + +/// A change the tools offer at the cursor; a preferred one leads its group. +#[derive(Clone, Debug, PartialEq)] +pub struct CodeAction { + pub title: SharedString, + pub kind: ActionKind, + pub preferred: bool, +} + +impl CodeAction { + pub fn new(title: impl Into, kind: ActionKind) -> Self { + Self { + title: title.into(), + kind, + preferred: false, + } + } + + pub fn preferred(mut self) -> Self { + self.preferred = true; + self + } +} + +type OnAction = Rc; + +/// A lightbulb that opens the actions at the cursor: fixes, refactors and source actions, each group led by its preferred one. +#[derive(IntoElement)] +pub struct CodeActionMenu { + id: ElementId, + actions: Vec, + on_pick: Option, +} + +impl CodeActionMenu { + pub fn new(id: impl Into, actions: impl IntoIterator) -> Self { + Self { + id: id.into(), + actions: actions.into_iter().collect(), + on_pick: None, + } + } + + /// Gets the chosen action's place among the actions given. + pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CodeActionMenu { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let groups = [ + (ActionKind::QuickFix, "Quick fixes"), + (ActionKind::Refactor, "Refactor"), + (ActionKind::Source, "Source"), + ]; + let menu = groups.into_iter().fold(Menu::new(), |menu, (kind, title)| { + let mut found: Vec<(usize, &CodeAction)> = self + .actions + .iter() + .enumerate() + .filter(|(_, action)| action.kind == kind) + .collect(); + if found.is_empty() { + return menu; + } + found.sort_by_key(|(_, action)| !action.preferred); + let items = found.into_iter().map(|(ix, action)| { + let pick = self.on_pick.clone(); + let item = MenuItem::new(action.title.clone()); + let item = if action.preferred { + item.icon(IconName::Sparkles) + } else { + item + }; + item.on_click(move |window, cx| { + log::info!("code action: {ix}"); + if let Some(pick) = &pick { + pick(ix, window, cx); + } + }) + }); + menu.group(title, items) + }); + OverflowMenu::new(self.id, menu) + .icon(IconName::Lightbulb) + .tooltip("Show code actions") + } +} + +type OnName = Rc; +type OnCancel = Rc; + +/// A small box over a symbol to rename it: Enter renames everywhere, Escape keeps the name. +#[derive(IntoElement)] +pub struct RenameInput { + id: ElementId, + field: Entity, + on_rename: Option, + on_cancel: Option, +} + +impl RenameInput { + /// `field` holds the new name; seed it with the current one. + pub fn new(id: impl Into, field: &Entity) -> Self { + Self { + id: id.into(), + field: field.clone(), + on_rename: None, + on_cancel: None, + } + } + + pub fn on_rename( + mut self, + handler: impl Fn(SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_rename = Some(Rc::new(handler)); + self + } + + pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_cancel = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RenameInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (field, rename, cancel) = (self.field.clone(), self.on_rename, self.on_cancel); + div() + .id(self.id) + .w(theme.label_width() * 2.5) + .flex() + .flex_col() + .gap_1() + .p_1p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.focus) + .bg(colors.overlay) + .shadow(theme.elevation(Elevation::Floating)) + .capture_action(move |_: &Enter, window, cx| { + cx.stop_propagation(); + let name = field.read(cx).text().trim().to_string(); + if name.is_empty() { + return; + } + log::info!("rename: {name}"); + if let Some(rename) = &rename { + rename(name.into(), window, cx); + } + }) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + if let Some(cancel) = &cancel { + cancel(window, cx); + } + } + }) + .child(Input::new(&self.field).size(ControlSize::Sm)) + .child( + div() + .px_0p5() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child("Enter to rename · Escape to cancel"), + ) + } +} diff --git a/src/editor/row.rs b/src/editor/row.rs new file mode 100644 index 0000000..84232e5 --- /dev/null +++ b/src/editor/row.rs @@ -0,0 +1,177 @@ +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, Stateful, + Styled, StyledText, Window, div, prelude::*, +}; + +use super::{ + chat::InlineChat, + gutter::Cell, + layout::{CHAT_ROWS, Row}, + state::{CodeEditor, EditorEvent}, + syntax, +}; +use crate::theme::{ActiveTheme, TextSize}; + +impl CodeEditor { + /// Row `ix` as the list draws it. + pub(crate) fn row(&self, ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { + let base = div() + .id(("editor-row", ix)) + .w_full() + .h(self.frame.line) + .flex(); + match self.frame.rows[ix] { + Row::Line(line) => self.line_row(base, ix, line, window, cx), + Row::Lens(at) => self.lens_row(base, ix, at, cx), + Row::Removed(hunk, at) => self.removed_row(base, ix, hunk, at, cx), + Row::Ghost(at) => self.ghost_row(base, ix, at, cx), + Row::Chat(at) => self.chat_row(base, ix, at, cx), + } + } + + fn lens_row( + &self, + base: Stateful, + ix: usize, + at: usize, + cx: &mut Context, + ) -> AnyElement { + let gutter = self.gutter(Cell::Blank, ix, cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let lens = &self.marks.lenses[at]; + let indent = self.buffer.indent(lens.line); + let items = lens.items.iter().enumerate().map(|(item, words)| { + let line = lens.line; + div() + .id(("lens", item)) + .cursor_pointer() + .hover(|style| style.text_color(colors.link)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(cx.listener(move |_, _, _, cx| { + log::info!("code lens: line {line}, item {item}"); + cx.emit(EditorEvent::Lens(line, item)); + })) + .child(words.clone()) + }); + let items: Vec = items + .enumerate() + .flat_map(|(place, item)| { + let gap = (place > 0).then(|| div().child("·").into_any_element()); + gap.into_iter().chain([item.into_any_element()]) + }) + .collect(); + base.child(gutter) + .child( + div() + .flex_1() + .flex() + .items_center() + .gap_1p5() + .pl(self.frame.advance * (indent + 1) as f32) + .font_family(theme.font_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .children(items), + ) + .into_any_element() + } + + fn removed_row( + &self, + base: Stateful, + ix: usize, + hunk: usize, + at: usize, + cx: &mut Context, + ) -> AnyElement { + let colors = cx.theme().colors.clone(); + let text = self.marks.hunks[hunk].removed[at].clone(); + base.child(self.gutter(Cell::Removed, ix, cx)) + .child( + div() + .flex_1() + .h_full() + .pl(self.frame.advance) + .bg(colors.danger.opacity(0.08)) + .line_height(self.frame.line) + .whitespace_nowrap() + .text_color(colors.fg_muted) + .child(text), + ) + .into_any_element() + } + + fn ghost_row( + &self, + base: Stateful, + ix: usize, + at: usize, + cx: &mut Context, + ) -> AnyElement { + let colors = cx.theme().colors.clone(); + let ghost = self + .marks + .ghost + .as_ref() + .expect("a ghost row has its ghost"); + let lines: Vec<&str> = ghost.text.split('\n').collect(); + let text = lines[at].to_string(); + let rest = (at + 1 == lines.len()).then(|| { + let line = self.buffer.line_of(ghost.offset); + let rest = + self.buffer.text()[ghost.offset..self.buffer.line_range(line).end].to_string(); + let styles = syntax::colors(&rest, cx); + StyledText::new(rest).with_highlights(styles) + }); + base.child(self.gutter(Cell::Blank, ix, cx)) + .child( + div() + .flex_1() + .flex() + .pl(self.frame.advance) + .line_height(self.frame.line) + .whitespace_nowrap() + .child(div().italic().text_color(colors.fg_subtle).child(text)) + .children(rest), + ) + .into_any_element() + } + + fn chat_row( + &self, + base: Stateful, + ix: usize, + at: usize, + cx: &mut Context, + ) -> AnyElement { + let gutter = self.gutter(Cell::Blank, ix, cx); + let theme = cx.theme(); + let row = base.child(gutter); + if at > 0 { + return row.into_any_element(); + } + let asked = cx.entity(); + let closed = cx.entity(); + let field = self.chat_field.clone(); + row.child( + div().relative().flex_1().h_full().child( + InlineChat::new(("inline-chat", ix), &self.chat_field) + .absolute() + .top_0() + .left(self.frame.advance) + .w(self.frame.advance * 72.0) + .h(self.frame.line * CHAT_ROWS as f32) + .font_family(theme.font_family.clone()) + .on_ask(move |prompt, _, cx| { + field.update(cx, |field, cx| field.set_text("", cx)); + asked.update(cx, |_, cx| cx.emit(EditorEvent::Asked(prompt))); + }) + .on_close(move |window, cx| { + closed.update(cx, |editor, cx| editor.set_chat(None, window, cx)); + }), + ), + ) + .into_any_element() + } +} diff --git a/src/editor/search.rs b/src/editor/search.rs new file mode 100644 index 0000000..21a7701 --- /dev/null +++ b/src/editor/search.rs @@ -0,0 +1,455 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, + StyledText, Window, div, prelude::*, +}; + +use crate::{ + primitives::{Disclosure, Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, +}; + +/// A line that matched: its number, its text, and where in it the matches lie. +#[derive(Clone, Debug, PartialEq)] +pub struct Hit { + pub line: usize, + pub text: SharedString, + pub ranges: Vec>, +} + +/// A file and the lines in it that matched. +#[derive(Clone, Debug, PartialEq)] +pub struct FileHits { + pub path: SharedString, + pub hits: Vec, +} + +/// Characters kept before the first match when a line is long. +const LEAD: usize = 24; + +/// A hit's text trimmed to start near its first match, with its ranges moved along. +fn trimmed(hit: &Hit) -> (String, Vec>) { + let first = hit.ranges.first().map_or(0, |range| range.start); + let before = hit.text[..first].chars().count(); + let text = hit.text.trim_end(); + if before <= LEAD { + let start = text.len() - text.trim_start().len(); + let start = start.min(first); + let ranges = hit + .ranges + .iter() + .map(|range| range.start - start..range.end - start) + .collect(); + return (text[start..].to_string(), ranges); + } + let cut = hit.text[..first] + .char_indices() + .nth(before - LEAD) + .map_or(first, |(at, _)| at); + let shown = format!("…{}", &text[cut..]); + let shift = '…'.len_utf8(); + let ranges = hit + .ranges + .iter() + .filter(|range| range.start >= cut) + .map(|range| range.start - cut + shift..range.end - cut + shift) + .collect(); + (shown, ranges) +} + +type OnOpen = Rc; +type OnPath = Rc; + +/// One matched line: its number, and its text trimmed near the first match with the matches marked; a replacement shows struck out beside the new text. +#[derive(IntoElement)] +pub struct SearchResultItem { + id: ElementId, + path: SharedString, + hit: Hit, + replacement: Option, + on_open: Option, +} + +impl SearchResultItem { + pub fn new(id: impl Into, path: impl Into, hit: Hit) -> Self { + Self { + id: id.into(), + path: path.into(), + hit, + replacement: None, + on_open: None, + } + } + + /// Previews replacing each match with this. + pub fn replacement(mut self, text: impl Into) -> Self { + self.replacement = Some(text.into()); + self + } + + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SearchResultItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (text, ranges) = trimmed(&self.hit); + let (text, styles) = match &self.replacement { + None => { + let style = HighlightStyle { + background_color: Some(colors.warning.opacity(0.25)), + font_weight: Some(FontWeight::SEMIBOLD), + ..HighlightStyle::default() + }; + ( + text, + ranges + .into_iter() + .map(|range| (range, style)) + .collect::>(), + ) + } + Some(new) => { + let (mut out, mut styles, mut last) = (String::new(), Vec::new(), 0); + let old = HighlightStyle { + background_color: Some(colors.danger.opacity(0.18)), + strikethrough: Some(gpui::StrikethroughStyle { + thickness: theme.underline_thickness(), + color: Some(colors.danger), + }), + ..HighlightStyle::default() + }; + let added = HighlightStyle { + background_color: Some(colors.success.opacity(0.18)), + ..HighlightStyle::default() + }; + for range in ranges { + out.push_str(&text[last..range.start]); + styles.push((out.len()..out.len() + range.len(), old)); + out.push_str(&text[range.clone()]); + styles.push((out.len()..out.len() + new.len(), added)); + out.push_str(new); + last = range.end; + } + out.push_str(&text[last..]); + (out, styles) + } + }; + let (path, line) = (self.path.clone(), self.hit.line); + div() + .id(self.id) + .flex() + .items_center() + .gap_2() + .px_2() + .py_0p5() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .text_size(theme.text_size(TextSize::Sm)) + .when_some(self.on_open, |row, open| { + row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("search result: {path}:{}", line + 1); + open(&path, line, window, cx) + }) + }) + .child( + div() + .flex_none() + .w(theme.label_width() * 0.4) + .text_right() + .text_color(colors.fg_subtle) + .font_family(theme.mono_family.clone()) + .child((self.hit.line + 1).to_string()), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .font_family(theme.mono_family.clone()) + .text_color(colors.fg_muted) + .child(StyledText::new(text).with_highlights(styles)), + ) + } +} + +/// Results grouped by file, each with its count and a chevron that folds it; the totals lead. +fn results( + id: &ElementId, + files: &[FileHits], + folded: &[SharedString], + replacement: Option<&SharedString>, + on_open: Option, + on_fold: Option, + cx: &App, +) -> Vec { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let mut out = Vec::new(); + for (file_ix, file) in files.iter().enumerate() { + let open = !folded.contains(&file.path); + let (name, dir) = match file.path.rsplit_once('/') { + Some((dir, name)) => (name.to_string(), dir.to_string()), + None => (file.path.to_string(), String::new()), + }; + let fold = on_fold.clone(); + let path = file.path.clone(); + out.push( + div() + .id((id.clone(), format!("file-{file_ix}"))) + .flex() + .items_center() + .gap_1p5() + .px_1() + .py_0p5() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .text_size(theme.text_size(TextSize::Sm)) + .when_some(fold, |row, fold| { + row.on_click(move |_, window, cx| fold(&path, window, cx)) + }) + .child(Disclosure::new( + (id.clone(), format!("fold-{file_ix}")), + open, + )) + .child( + Icon::new(IconName::FileText) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child(div().text_color(colors.fg).child(name)) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_subtle) + .child(dir), + ) + .child( + div() + .px_1p5() + .rounded_full() + .bg(colors.hover) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(file.hits.len().to_string()), + ) + .into_any_element(), + ); + if !open { + continue; + } + for (hit_ix, hit) in file.hits.iter().enumerate() { + let item = SearchResultItem::new( + (id.clone(), format!("hit-{file_ix}-{hit_ix}")), + file.path.clone(), + hit.clone(), + ); + let item = match replacement { + Some(text) => item.replacement(text.clone()), + None => item, + }; + let item = match &on_open { + Some(open) => { + let open = open.clone(); + item.on_open(move |path, line, window, cx| open(path, line, window, cx)) + } + None => item, + }; + out.push(div().pl_5().child(item).into_any_element()); + } + } + out +} + +/// Results across files, folding by file, with the totals above; a replacement previews on each line. +#[derive(IntoElement)] +pub struct SearchPanel { + id: ElementId, + files: Vec, + folded: Vec, + replacement: Option, + on_open: Option, + on_fold: Option, +} + +impl SearchPanel { + pub fn new(id: impl Into, files: impl IntoIterator) -> Self { + Self { + id: id.into(), + files: files.into_iter().collect(), + folded: Vec::new(), + replacement: None, + on_open: None, + on_fold: None, + } + } + + /// Paths whose results are folded away. + pub fn folded(mut self, paths: impl IntoIterator>) -> Self { + self.folded = paths.into_iter().map(Into::into).collect(); + self + } + + pub fn replacement(mut self, text: impl Into) -> Self { + self.replacement = Some(text.into()); + self + } + + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + /// Gets a path whose chevron was pressed. + pub fn on_fold( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_fold = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SearchPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let hits: usize = self + .files + .iter() + .map(|file| file.hits.iter().map(|hit| hit.ranges.len()).sum::()) + .sum(); + let summary = format!("{hits} results in {} files", self.files.len()); + let rows = results( + &self.id, + &self.files, + &self.folded, + self.replacement.as_ref(), + self.on_open, + self.on_fold, + cx, + ); + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .px_1() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(summary), + ) + .children(rows) + } +} + +/// Where a symbol is used, grouped by file, under its name and count. +#[derive(IntoElement)] +pub struct ReferencesPanel { + id: ElementId, + symbol: SharedString, + files: Vec, + on_open: Option, +} + +impl ReferencesPanel { + pub fn new( + id: impl Into, + symbol: impl Into, + files: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + symbol: symbol.into(), + files: files.into_iter().collect(), + on_open: None, + } + } + + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ReferencesPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let count: usize = self.files.iter().map(|file| file.hits.len()).sum(); + let rows = results(&self.id, &self.files, &[], None, self.on_open, None, cx); + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .px_1() + .pb_1() + .child( + div() + .font_family(theme.mono_family.clone()) + .text_color(colors.fg) + .child(self.symbol), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(format!("{count} references")), + ), + ) + .children(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_lines_start_near_their_first_match() { + let needle = 40..46; + let hit = Hit { + line: 0, + text: format!("{}needle end", "x".repeat(40)).into(), + ranges: vec![needle], + }; + let (text, ranges) = trimmed(&hit); + assert!(text.starts_with('…')); + assert_eq!(&text[ranges[0].clone()], "needle"); + let needle = 8..14; + let short = Hit { + line: 0, + text: " let needle = 1;".into(), + ranges: vec![needle], + }; + let (text, ranges) = trimmed(&short); + assert_eq!(text, "let needle = 1;", "leading spaces go"); + assert_eq!(&text[ranges[0].clone()], "needle"); + } +} diff --git a/src/editor/settings.rs b/src/editor/settings.rs new file mode 100644 index 0000000..d513569 --- /dev/null +++ b/src/editor/settings.rs @@ -0,0 +1,412 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton, SegmentedControl}, + forms::{Choice, JsonInput, NumberInput, Select, Switch, TextInput}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, +}; + +/// A setting's value and the form it takes. +#[derive(Clone, Debug, PartialEq)] +pub enum SettingValue { + Bool(bool), + Number { + value: f64, + min: f64, + max: f64, + step: f64, + }, + Choice { + value: SharedString, + choices: Vec, + }, +} + +impl SettingValue { + fn json(&self) -> serde_json::Value { + match self { + SettingValue::Bool(on) => (*on).into(), + SettingValue::Number { value, .. } => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .expect("a setting's number is finite"), + SettingValue::Choice { value, .. } => value.to_string().into(), + } + } +} + +/// One setting: its dotted key, title, what it does, its value and the value it starts from. +#[derive(Clone, Debug, PartialEq)] +pub struct Setting { + pub key: SharedString, + pub title: SharedString, + pub description: SharedString, + pub value: SettingValue, + pub default: SettingValue, +} + +impl Setting { + pub fn modified(&self) -> bool { + self.value != self.default + } +} + +/// The settings changed from their defaults, as a settings file holds them. +pub fn settings_json(groups: &[(SharedString, Vec)]) -> String { + let map: serde_json::Map = groups + .iter() + .flat_map(|(_, settings)| settings) + .filter(|setting| setting.modified()) + .map(|setting| (setting.key.to_string(), setting.value.json())) + .collect(); + serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("settings serialize") +} + +type OnChange = Rc; +type OnIndex = Rc; +type OnFlag = Rc; + +/// Settings two ways: as rows of controls by section, each changed one marked with a way back, or as the JSON a settings file holds. +#[derive(IntoElement)] +pub struct SettingsEditor { + id: ElementId, + groups: Vec<(SharedString, Vec)>, + section: usize, + json: Option>, + on_change: Option, + on_section: Option, + on_json: Option, +} + +impl SettingsEditor { + pub fn new( + id: impl Into, + groups: impl IntoIterator, Vec)>, + ) -> Self { + let groups: Vec<(SharedString, Vec)> = groups + .into_iter() + .map(|(title, settings)| (title.into(), settings)) + .collect(); + assert!(!groups.is_empty(), "settings come in at least one section"); + Self { + id: id.into(), + groups, + section: 0, + json: None, + on_change: None, + on_section: None, + on_json: None, + } + } + + pub fn section(mut self, section: usize) -> Self { + self.section = section; + self + } + + /// Shows the JSON, held in `field`; seed it with `settings_json`. + pub fn json(mut self, field: &Entity) -> Self { + self.json = Some(field.clone()); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, SettingValue, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + pub fn on_section(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_section = Some(Rc::new(handler)); + self + } + + /// Gets whether the JSON view is wanted. + pub fn on_json(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_json = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SettingsEditor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let mode = SegmentedControl::new( + (self.id.clone(), "mode"), + if self.json.is_some() { "json" } else { "ui" }, + ) + .size(ControlSize::Sm) + .segment("ui", "Settings", None) + .segment("json", "JSON", None); + let mode = match self.on_json.clone() { + Some(on_json) => { + mode.on_change(move |key, window, cx| on_json(key.as_ref() == "json", window, cx)) + } + None => mode, + }; + let header = div().flex().items_center().justify_between().child( + div() + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::SEMIBOLD) + .child("Settings"), + ); + let header = header.child(mode); + if let Some(field) = self.json { + return div() + .flex() + .flex_col() + .gap_3() + .child(header) + .child(JsonInput::new(&field)) + .into_any_element(); + } + let section = self.section.min(self.groups.len() - 1); + let nav = self + .groups + .iter() + .enumerate() + .map(|(ix, (title, settings))| { + let pick = self.on_section.clone(); + let changed = settings.iter().filter(|setting| setting.modified()).count(); + div() + .id((self.id.clone(), format!("section-{ix}"))) + .flex() + .items_center() + .justify_between() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .when(ix == section, |row| { + row.bg(colors.active).text_color(colors.fg) + }) + .when(ix != section, |row| { + row.text_color(colors.fg_muted) + .hover(|row| row.bg(colors.hover)) + }) + .when_some(pick, |row, pick| { + row.on_click(move |_, window, cx| pick(ix, window, cx)) + }) + .child(title.clone()) + .when(changed > 0, |row| { + row.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.accent) + .child(changed.to_string()), + ) + }) + }); + let rows = self.groups[section] + .1 + .iter() + .enumerate() + .map(|(ix, setting)| { + let id = ElementId::from((self.id.clone(), format!("{section}-{ix}"))); + let (change, key) = (self.on_change.clone(), setting.key.clone()); + let tell = move |value: SettingValue, window: &mut Window, cx: &mut App| { + log::info!("settings: {key} = {value:?}"); + if let Some(change) = &change { + change(&key, value, window, cx); + } + }; + let tell = Rc::new(tell); + let control = match &setting.value { + SettingValue::Bool(on) => { + let tell = tell.clone(); + Switch::new(id.clone(), *on) + .on_change(move |on, window, cx| { + tell(SettingValue::Bool(on), window, cx) + }) + .into_any_element() + } + SettingValue::Number { + value, + min, + max, + step, + } => { + let (tell, min, max, step) = (tell.clone(), *min, *max, *step); + div() + .w(theme.label_width()) + .child( + NumberInput::new(id.clone(), *value) + .range(min, max) + .step(step) + .size(ControlSize::Sm) + .on_change(move |value, window, cx| { + tell( + SettingValue::Number { + value, + min, + max, + step, + }, + window, + cx, + ) + }), + ) + .into_any_element() + } + SettingValue::Choice { value, choices } => { + let (tell, all) = (tell.clone(), choices.clone()); + div() + .w(theme.label_width() * 1.5) + .child( + Select::new( + id.clone(), + choices + .iter() + .map(|choice| Choice::new(choice.clone(), choice.clone())), + ) + .selected(value.clone()) + .size(ControlSize::Sm) + .on_change( + move |picked, window, cx| { + let value = SettingValue::Choice { + value: picked.clone(), + choices: all.clone(), + }; + tell(value, window, cx) + }, + ), + ) + .into_any_element() + } + }; + let reset = setting.modified().then(|| { + let (tell, default) = (tell.clone(), setting.default.clone()); + IconButton::new( + (self.id.clone(), format!("reset-{section}-{ix}")), + IconName::RotateCcw, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Reset to default") + .on_click(move |_, window, cx| tell(default.clone(), window, cx)) + }); + div() + .flex() + .items_start() + .gap_3() + .py_3() + .pl_3() + .border_l_2() + .border_color(if setting.modified() { + colors.accent + } else { + gpui::transparent_black() + }) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(setting.title.clone()), + ) + .child( + div() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(setting.key.clone()), + ), + ) + .child( + div() + .text_color(colors.fg_muted) + .child(setting.description.clone()), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .children(reset) + .child(control), + ) + }); + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child(header) + .child( + div() + .flex() + .gap_6() + .child( + div() + .w(theme.label_width() * 1.25) + .flex() + .flex_col() + .gap_0p5() + .children(nav), + ) + .child(div().flex_1().min_w_0().flex().flex_col().children(rows)), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_holds_only_what_changed() { + let groups = vec![( + SharedString::from("Editor"), + vec![ + Setting { + key: "editor.fontSize".into(), + title: "Font size".into(), + description: "".into(), + value: SettingValue::Number { + value: 15.0, + min: 8.0, + max: 32.0, + step: 1.0, + }, + default: SettingValue::Number { + value: 14.0, + min: 8.0, + max: 32.0, + step: 1.0, + }, + }, + Setting { + key: "editor.minimap".into(), + title: "Minimap".into(), + description: "".into(), + value: SettingValue::Bool(true), + default: SettingValue::Bool(true), + }, + ], + )]; + let json: serde_json::Value = serde_json::from_str(&settings_json(&groups)).expect("json"); + assert_eq!(json, serde_json::json!({ "editor.fontSize": 15.0 })); + } +} diff --git a/src/editor/state.rs b/src/editor/state.rs new file mode 100644 index 0000000..8a729ac --- /dev/null +++ b/src/editor/state.rs @@ -0,0 +1,481 @@ +use std::{collections::BTreeSet, ops::Range, time::Duration}; + +use gpui::{ + App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, ScrollStrategy, + SharedString, Subscription, Task, UniformListScrollHandle, Window, +}; + +use super::{ + buffer::Buffer, + cursor::{Selection, merged}, + decor::{CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint}, + view::Frame, +}; +use crate::{ + forms::{History, InputEvent, TextInput}, + theme::ActiveTheme, +}; + +/// How the gutter numbers lines. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LineNumbers { + #[default] + Absolute, + /// Distances from the cursor's line, which shows its own number. + Relative, + Hidden, +} + +/// How the cursor draws. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CursorShape { + #[default] + Line, + Block, + Underline, +} + +/// What the editor tells its owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorEvent { + Changed, + /// A press on a code lens: its line and the item's place. + Lens(usize, usize), + /// The inline chat's prompt, sent. + Asked(SharedString), + ChatClosed, + /// The read-only banner's action. + Unlock, +} + +/// The editor's look and manners. +#[derive(Clone, Debug)] +pub(crate) struct Options { + pub language: SharedString, + pub numbers: LineNumbers, + pub cursor: CursorShape, + pub whitespace: bool, + pub guides: bool, + pub rulers: Vec, + pub rainbow: bool, + pub minimap: bool, + pub sticky: bool, + pub read_only: bool, + pub tab: usize, +} + +/// What the owner lays over the code. +#[derive(Clone, Debug, Default)] +pub(crate) struct Marks { + pub diagnostics: Vec, + pub backgrounds: Vec<(Range, gpui::Hsla)>, + pub hints: Vec, + pub lenses: Vec, + pub ghost: Option, + pub hunks: Vec, + pub git: Vec<(usize, GitMark)>, + pub breakpoints: BTreeSet, + pub chat: Option, +} + +/// The text and cursors one undo step returns to. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Snapshot { + pub text: String, + pub selections: Vec, +} + +/// Where the code sat when last painted, in window pixels: its left edge, the top of the first row, a character's width and a row's height. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Metrics { + pub left: Pixels, + pub top: Pixels, + pub advance: Pixels, + pub line: Pixels, +} + +const BLINK: Duration = Duration::from_millis(530); + +/// Code to read and edit: syntax colors, many cursors, folds, a gutter, a minimap, and what tools lay over it. +pub struct CodeEditor { + pub(crate) buffer: Buffer, + pub(crate) selections: Vec, + pub(crate) history: History, + pub(crate) focus: FocusHandle, + pub(crate) scroll: UniformListScrollHandle, + pub(crate) folded: BTreeSet, + pub(crate) options: Options, + pub(crate) marks: Marks, + pub(crate) marked: Option>, + pub(crate) metrics: Metrics, + pub(crate) dragging: bool, + pub(crate) caret_on: bool, + pub(crate) chat_field: gpui::Entity, + pub(crate) frame: Frame, + blink_epoch: u64, + _blink: Option>, + _subscriptions: Vec, +} + +impl EventEmitter for CodeEditor {} + +impl Focusable for CodeEditor { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl CodeEditor { + pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self { + let focus = cx.focus_handle().tab_stop(true); + let chat_field = + cx.new(|cx| TextInput::new(window, cx).placeholder("Ask to change this code")); + let subscriptions = vec![ + cx.on_focus(&focus, window, |editor, _, cx| editor.restart_blink(cx)), + cx.on_blur(&focus, window, |editor, _, cx| { + editor.dragging = false; + editor._blink = None; + cx.notify(); + }), + cx.subscribe(&chat_field, |_, field, event, cx| { + if *event != InputEvent::Submit { + return; + } + let prompt = field.read(cx).text().trim().to_string(); + if prompt.is_empty() { + return; + } + log::info!("code editor: asked {prompt:?}"); + field.update(cx, |field, cx| field.set_text("", cx)); + cx.emit(EditorEvent::Asked(prompt.into())); + }), + ]; + Self { + buffer: Buffer::new(text), + selections: vec![Selection::caret(0)], + history: History::default(), + focus, + scroll: UniformListScrollHandle::new(), + folded: BTreeSet::new(), + options: Options { + language: "Plain text".into(), + numbers: LineNumbers::Absolute, + cursor: CursorShape::Line, + whitespace: false, + guides: true, + rulers: Vec::new(), + rainbow: false, + minimap: false, + sticky: false, + read_only: false, + tab: 4, + }, + marks: Marks::default(), + marked: None, + metrics: Metrics::default(), + dragging: false, + caret_on: true, + chat_field, + frame: Frame::default(), + blink_epoch: 0, + _blink: None, + _subscriptions: subscriptions, + } + } + + pub fn language(mut self, name: impl Into) -> Self { + self.options.language = name.into(); + self + } + + pub fn line_numbers(mut self, numbers: LineNumbers) -> Self { + self.options.numbers = numbers; + self + } + + pub fn cursor_shape(mut self, shape: CursorShape) -> Self { + self.options.cursor = shape; + self + } + + /// Dots for spaces and arrows for tabs. + pub fn show_whitespace(mut self) -> Self { + self.options.whitespace = true; + self + } + + pub fn hide_indent_guides(mut self) -> Self { + self.options.guides = false; + self + } + + /// Thin lines at these columns. + pub fn rulers(mut self, columns: impl IntoIterator) -> Self { + self.options.rulers = columns.into_iter().collect(); + self + } + + /// Brackets colored by how deep they sit. + pub fn rainbow_brackets(mut self) -> Self { + self.options.rainbow = true; + self + } + + pub fn minimap(mut self) -> Self { + self.options.minimap = true; + self + } + + /// The lines that open the blocks around the top line stay pinned above it. + pub fn sticky_scroll(mut self) -> Self { + self.options.sticky = true; + self + } + + pub fn read_only(mut self) -> Self { + self.options.read_only = true; + self + } + + /// Spaces a Tab inserts and an indent guide spans. + pub fn tab_size(mut self, size: usize) -> Self { + assert!(size > 0, "a tab spans at least one column"); + self.options.tab = size; + self + } + + pub fn text(&self) -> &str { + self.buffer.text() + } + + pub fn selections(&self) -> &[Selection] { + &self.selections + } + + /// The last cursor placed, which scrolling follows. + pub fn primary(&self) -> Selection { + *self.selections.last().expect("an editor keeps a cursor") + } + + /// Line and column of the primary cursor, both from one. + pub fn position(&self) -> (usize, usize) { + let (line, column) = self.buffer.point(self.primary().head); + (line + 1, column + 1) + } + + pub fn language_name(&self) -> &SharedString { + &self.options.language + } + + /// The line the inline chat sits under, while it is open. + pub fn chat_line(&self) -> Option { + self.marks.chat + } + + pub fn is_read_only(&self) -> bool { + self.options.read_only + } + + pub fn set_text(&mut self, text: impl Into, cx: &mut Context) { + let before = self.snapshot(); + self.buffer = Buffer::new(text); + self.selections = vec![Selection::caret(0)]; + self.folded.clear(); + self.commit(before, false, cx); + } + + pub fn set_cursor_shape(&mut self, shape: CursorShape, cx: &mut Context) { + self.options.cursor = shape; + cx.notify(); + } + + pub fn set_line_numbers(&mut self, numbers: LineNumbers, cx: &mut Context) { + self.options.numbers = numbers; + cx.notify(); + } + + pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { + log::info!("code editor: read only {read_only}"); + self.options.read_only = read_only; + cx.notify(); + } + + /// Selects these ranges, the last one primary. + pub fn select( + &mut self, + ranges: impl IntoIterator>, + cx: &mut Context, + ) { + let chosen: Vec = ranges + .into_iter() + .map(|range| { + assert!( + range.end <= self.buffer.len() + && self.buffer.text().is_char_boundary(range.start) + && self.buffer.text().is_char_boundary(range.end), + "a selection covers whole characters of the text" + ); + Selection { + anchor: range.start, + head: range.end, + goal: None, + } + }) + .collect(); + if merged(chosen.clone()) == self.selections { + return; + } + self.set_selections(chosen, cx); + self.scroll + .scroll_to_item(self.primary_row(), ScrollStrategy::Center); + } + + pub fn set_diagnostics(&mut self, diagnostics: Vec, cx: &mut Context) { + if self.marks.diagnostics == diagnostics { + return; + } + self.marks.diagnostics = diagnostics; + cx.notify(); + } + + pub fn set_backgrounds( + &mut self, + ranges: Vec<(Range, gpui::Hsla)>, + cx: &mut Context, + ) { + if self.marks.backgrounds == ranges { + return; + } + self.marks.backgrounds = ranges; + cx.notify(); + } + + pub fn set_inlay_hints(&mut self, hints: Vec, cx: &mut Context) { + self.marks.hints = hints; + self.marks.hints.sort_by_key(|hint| hint.offset); + cx.notify(); + } + + pub fn set_code_lenses(&mut self, lenses: Vec, cx: &mut Context) { + self.marks.lenses = lenses; + cx.notify(); + } + + /// Suggested text at an offset, in grey until Tab takes it. + pub fn set_ghost_text(&mut self, ghost: Option, cx: &mut Context) { + self.marks.ghost = ghost; + cx.notify(); + } + + pub fn set_diff(&mut self, hunks: Vec, cx: &mut Context) { + self.marks.hunks = hunks; + cx.notify(); + } + + pub fn set_git_marks(&mut self, marks: Vec<(usize, GitMark)>, cx: &mut Context) { + self.marks.git = marks; + cx.notify(); + } + + pub fn toggle_breakpoint(&mut self, line: usize, cx: &mut Context) { + if !self.marks.breakpoints.remove(&line) { + self.marks.breakpoints.insert(line); + } + log::info!("code editor: breakpoints {:?}", self.marks.breakpoints); + cx.notify(); + } + + pub fn breakpoints(&self) -> &BTreeSet { + &self.marks.breakpoints + } + + /// Opens the inline chat below a line, or closes it. + pub fn set_chat(&mut self, line: Option, window: &mut Window, cx: &mut Context) { + self.marks.chat = line; + if line.is_some() { + let focus = self.chat_field.read(cx).focus().clone(); + window.focus(&focus); + } else { + window.focus(&self.focus); + cx.emit(EditorEvent::ChatClosed); + } + cx.notify(); + } + + /// Replaces ranges of the text at once, as one undo step; read-only text refuses. + pub fn edit( + &mut self, + edits: impl IntoIterator, String)>, + cx: &mut Context, + ) { + let edits: Vec<(Range, String)> = edits.into_iter().collect(); + assert!( + edits.iter().all(|(range, _)| range.end <= self.buffer.len() + && self.buffer.text().is_char_boundary(range.start) + && self.buffer.text().is_char_boundary(range.end)), + "an edit covers whole characters of the text" + ); + self.apply(edits, false, cx); + } + + pub(crate) fn set_selections(&mut self, selections: Vec, cx: &mut Context) { + self.selections = merged(selections); + self.unfold_cursors(); + self.restart_blink(cx); + } + + pub(crate) fn snapshot(&self) -> Snapshot { + Snapshot { + text: self.buffer.text().to_string(), + selections: self.selections.clone(), + } + } + + pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context) { + self.buffer = Buffer::new(snapshot.text); + self.selections = snapshot.selections; + self.marked = None; + cx.emit(EditorEvent::Changed); + self.restart_blink(cx); + } + + /// Makes `before` one undo step when the text moved on from it. + pub(crate) fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context) { + if before.text != self.buffer.text() { + self.history.record(before, typing); + self.marks.ghost = None; + self.folded.retain(|line| *line < self.buffer.lines()); + cx.emit(EditorEvent::Changed); + } + self.restart_blink(cx); + } + + /// Shows the cursors now and restarts their blink; still under reduced motion. + pub(crate) fn restart_blink(&mut self, cx: &mut Context) { + self.caret_on = true; + self.blink_epoch += 1; + let epoch = self.blink_epoch; + let still = cx.theme().reduced_motion; + self._blink = (!still).then(|| { + cx.spawn(async move |editor, cx| { + loop { + cx.background_executor().timer(BLINK).await; + let alive = editor.update(cx, |editor, cx| { + if editor.blink_epoch == epoch { + editor.caret_on = !editor.caret_on; + cx.notify(); + } + }); + if alive.is_err() { + return; + } + } + }) + }); + cx.notify(); + } + + pub(crate) fn editable(&self) -> bool { + !self.options.read_only + } +} diff --git a/src/editor/status.rs b/src/editor/status.rs new file mode 100644 index 0000000..f5400e6 --- /dev/null +++ b/src/editor/status.rs @@ -0,0 +1,383 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + motion::Spinner, + primitives::{Icon, IconName}, + shell::StatusBarItem, + theme::{ActiveTheme, IconSize, TextSize}, +}; + +type OnPress = Rc; + +fn item( + id: ElementId, + label: impl Into, + tooltip: &'static str, + press: Option, +) -> StatusBarItem { + let item = StatusBarItem::new(id).label(label).tooltip(tooltip); + match press { + Some(press) => item.on_click(move |_, window, cx| press(window, cx)), + None => item, + } +} + +macro_rules! pressable { + ($name:ident) => { + impl $name { + pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.press = Some(Rc::new(handler)); + self + } + } + }; +} + +/// The cursor's line and column, and how many characters are selected when some are. +#[derive(IntoElement)] +pub struct CursorPosition { + id: ElementId, + line: usize, + column: usize, + selected: usize, + press: Option, +} + +impl CursorPosition { + /// `line` and `column` count from one. + pub fn new(id: impl Into, line: usize, column: usize) -> Self { + assert!(line > 0 && column > 0, "lines and columns count from one"); + Self { + id: id.into(), + line, + column, + selected: 0, + press: None, + } + } + + pub fn selected(mut self, characters: usize) -> Self { + self.selected = characters; + self + } +} + +pressable!(CursorPosition); + +impl RenderOnce for CursorPosition { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let mut label = format!("Ln {}, Col {}", self.line, self.column); + if self.selected > 0 { + label.push_str(&format!(" ({} selected)", self.selected)); + } + item(self.id, label, "Go to line", self.press) + } +} + +/// The language a file is read as. +#[derive(IntoElement)] +pub struct LanguageMode { + id: ElementId, + language: SharedString, + press: Option, +} + +impl LanguageMode { + pub fn new(id: impl Into, language: impl Into) -> Self { + Self { + id: id.into(), + language: language.into(), + press: None, + } + } +} + +pressable!(LanguageMode); + +impl RenderOnce for LanguageMode { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + item( + self.id, + self.language, + "Select the language mode", + self.press, + ) + } +} + +/// The text encoding a file is saved in. +#[derive(IntoElement)] +pub struct Encoding { + id: ElementId, + name: SharedString, + press: Option, +} + +impl Encoding { + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + press: None, + } + } +} + +pressable!(Encoding); + +impl RenderOnce for Encoding { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + item( + self.id, + self.name, + "Reopen or save with an encoding", + self.press, + ) + } +} + +/// How a file ends its lines. +#[derive(IntoElement)] +pub struct LineEnding { + id: ElementId, + crlf: bool, + press: Option, +} + +impl LineEnding { + /// `crlf` for carriage return and line feed, else line feed alone. + pub fn new(id: impl Into, crlf: bool) -> Self { + Self { + id: id.into(), + crlf, + press: None, + } + } +} + +pressable!(LineEnding); + +impl RenderOnce for LineEnding { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + item( + self.id, + if self.crlf { "CRLF" } else { "LF" }, + "Change the line ending", + self.press, + ) + } +} + +/// How a file indents: spaces or tabs, and how wide. +#[derive(IntoElement)] +pub struct IndentSettings { + id: ElementId, + spaces: bool, + size: usize, + press: Option, +} + +impl IndentSettings { + pub fn new(id: impl Into, spaces: bool, size: usize) -> Self { + assert!(size > 0, "an indent is at least one column"); + Self { + id: id.into(), + spaces, + size, + press: None, + } + } +} + +pressable!(IndentSettings); + +impl RenderOnce for IndentSettings { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let label = if self.spaces { + format!("Spaces: {}", self.size) + } else { + format!("Tab Size: {}", self.size) + }; + item(self.id, label, "Change the indentation", self.press) + } +} + +/// The branch checked out, a mark when the tree has changes, and how far it is ahead of and behind its upstream. +#[derive(IntoElement)] +pub struct BranchIndicator { + id: ElementId, + branch: SharedString, + dirty: bool, + ahead: usize, + behind: usize, + press: Option, +} + +impl BranchIndicator { + pub fn new(id: impl Into, branch: impl Into) -> Self { + Self { + id: id.into(), + branch: branch.into(), + dirty: false, + ahead: 0, + behind: 0, + press: None, + } + } + + pub fn dirty(mut self, dirty: bool) -> Self { + self.dirty = dirty; + self + } + + /// Commits not yet pushed, and commits not yet pulled. + pub fn sync(mut self, ahead: usize, behind: usize) -> Self { + self.ahead = ahead; + self.behind = behind; + self + } +} + +pressable!(BranchIndicator); + +impl RenderOnce for BranchIndicator { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let mut label = format!("{}{}", self.branch, if self.dirty { "*" } else { "" }); + if self.ahead > 0 || self.behind > 0 { + label.push_str(&format!(" ↑{} ↓{}", self.ahead, self.behind)); + } + item(self.id, label, "Checkout a branch", self.press).icon(IconName::GitBranch) + } +} + +/// Where a language server stands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LspState { + Starting, + Ready, + /// Working on something it names. + Busy(SharedString), + Failed(SharedString), +} + +/// A language server's name and state: a spinner while it starts or works, a check when ready, a mark when it failed. +#[derive(IntoElement)] +pub struct LspStatus { + id: ElementId, + server: SharedString, + state: LspState, + press: Option, +} + +impl LspStatus { + pub fn new(id: impl Into, server: impl Into, state: LspState) -> Self { + Self { + id: id.into(), + server: server.into(), + state, + press: None, + } + } +} + +pressable!(LspStatus); + +impl RenderOnce for LspStatus { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.theme().colors.clone(); + let (lead, label, tooltip) = match &self.state { + LspState::Starting => ( + Spinner::new((self.id.clone(), "spin")) + .size(IconSize::Xs) + .into_any_element(), + format!("{} starting", self.server), + "Starting", + ), + LspState::Busy(task) => ( + Spinner::new((self.id.clone(), "spin")) + .size(IconSize::Xs) + .into_any_element(), + format!("{} · {task}", self.server), + "Working", + ), + LspState::Ready => ( + Icon::new(IconName::CircleCheck) + .size(IconSize::Xs) + .color(colors.fg_muted) + .into_any_element(), + self.server.to_string(), + "Ready", + ), + LspState::Failed(why) => ( + Icon::new(IconName::CircleAlert) + .size(IconSize::Xs) + .color(colors.danger) + .into_any_element(), + format!("{} · {why}", self.server), + "Failed", + ), + }; + item(self.id, label, tooltip, self.press).leading(lead) + } +} + +/// A bell with how many notifications wait, quiet when there are none. +#[derive(IntoElement)] +pub struct NotificationBell { + id: ElementId, + count: usize, + press: Option, +} + +impl NotificationBell { + pub fn new(id: impl Into, count: usize) -> Self { + Self { + id: id.into(), + count, + press: None, + } + } +} + +pressable!(NotificationBell); + +impl RenderOnce for NotificationBell { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let bell = div() + .relative() + .child( + Icon::new(IconName::Bell) + .size(IconSize::Xs) + .color(colors.fg_muted), + ) + .children((self.count > 0).then(|| { + div() + .absolute() + .top_neg_1() + .right_neg_1p5() + .px_0p5() + .rounded_full() + .bg(colors.accent) + .text_color(colors.on_accent) + .text_size(theme.text_size(TextSize::Xs) * 0.75) + .line_height(theme.text_size(TextSize::Xs) * 0.9) + .child(self.count.min(99).to_string()) + })); + let tooltip = if self.count == 0 { + "No new notifications" + } else { + "Notifications" + }; + let item = StatusBarItem::new(self.id).tooltip(tooltip).leading(bell); + match self.press { + Some(press) => item.on_click(move |_, window, cx| press(window, cx)), + None => item, + } + } +} diff --git a/src/editor/symbols.rs b/src/editor/symbols.rs new file mode 100644 index 0000000..6bad8d7 --- /dev/null +++ b/src/editor/symbols.rs @@ -0,0 +1,447 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::SegmentedControl, + lists::{Tree, TreeNode}, + navigation::{Group, Palette, Row, fuzzy, marked, query_field}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Palette as Colors}, +}; + +/// What a symbol in code is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SymbolKind { + Module, + Struct, + Enum, + Trait, + Function, + Method, + Field, + Variable, + Constant, + Keyword, + Snippet, +} + +impl SymbolKind { + pub fn icon(self) -> IconName { + match self { + SymbolKind::Module => IconName::Package, + SymbolKind::Struct => IconName::Braces, + SymbolKind::Enum => IconName::ListOrdered, + SymbolKind::Trait => IconName::Shapes, + SymbolKind::Function | SymbolKind::Method => IconName::SquareFunction, + SymbolKind::Field => IconName::Tag, + SymbolKind::Variable => IconName::Variable, + SymbolKind::Constant => IconName::Hash, + SymbolKind::Keyword => IconName::Key, + SymbolKind::Snippet => IconName::Code, + } + } + + /// The syntax color its name takes. + pub fn color(self, colors: &Colors) -> Hsla { + let syntax = &colors.syntax; + match self { + SymbolKind::Module | SymbolKind::Snippet => colors.fg_muted, + SymbolKind::Struct | SymbolKind::Enum | SymbolKind::Trait => syntax.type_name, + SymbolKind::Function | SymbolKind::Method => syntax.function, + SymbolKind::Field => syntax.property, + SymbolKind::Variable => syntax.variable, + SymbolKind::Constant => syntax.constant, + SymbolKind::Keyword => syntax.keyword, + } + } + + pub fn word(self) -> &'static str { + match self { + SymbolKind::Module => "module", + SymbolKind::Struct => "struct", + SymbolKind::Enum => "enum", + SymbolKind::Trait => "trait", + SymbolKind::Function => "function", + SymbolKind::Method => "method", + SymbolKind::Field => "field", + SymbolKind::Variable => "variable", + SymbolKind::Constant => "constant", + SymbolKind::Keyword => "keyword", + SymbolKind::Snippet => "snippet", + } + } +} + +/// A symbol in a file: its name, kind, the line it starts on, a detail such as its type, and what it holds. +#[derive(Clone, Debug, PartialEq)] +pub struct Symbol { + pub name: SharedString, + pub kind: SymbolKind, + pub line: usize, + pub detail: Option, + pub children: Vec, +} + +impl Symbol { + pub fn new(name: impl Into, kind: SymbolKind, line: usize) -> Self { + Self { + name: name.into(), + kind, + line, + detail: None, + children: Vec::new(), + } + } + + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.children = children.into_iter().collect(); + self + } +} + +type OnLine = Rc; + +fn key(symbol: &Symbol) -> SharedString { + format!("{}:{}", symbol.line, symbol.name).into() +} + +fn node(symbol: &Symbol) -> TreeNode { + let tree = TreeNode::new(key(symbol), symbol.name.clone()).icon(symbol.kind.icon()); + let tree = match &symbol.detail { + Some(detail) => tree.note(detail.clone()), + None => tree, + }; + if symbol.children.is_empty() { + return tree; + } + tree.children(symbol.children.iter().map(node)) +} + +/// Each symbol with the path of those holding it, outermost first. +pub(crate) fn flattened(symbols: &[Symbol]) -> Vec<(Vec, &Symbol)> { + fn walk<'a>( + symbols: &'a [Symbol], + path: &mut Vec, + out: &mut Vec<(Vec, &'a Symbol)>, + ) { + for symbol in symbols { + out.push((path.clone(), symbol)); + path.push(symbol.name.clone()); + walk(&symbol.children, path, out); + path.pop(); + } + } + let mut out = Vec::new(); + walk(symbols, &mut Vec::new(), &mut out); + out +} + +/// The innermost symbol at or above a line. +pub(crate) fn enclosing(symbols: &[Symbol], line: usize) -> Option<&Symbol> { + let mut best = None; + for (_, symbol) in flattened(symbols) { + if symbol.line <= line && best.is_none_or(|best: &Symbol| symbol.line >= best.line) { + best = Some(symbol); + } + } + best +} + +/// A file's symbols as a tree filling its box, the one around the cursor chosen; a press goes to its line. +#[derive(IntoElement)] +pub struct SymbolOutline { + id: ElementId, + symbols: Vec, + cursor: Option, + on_pick: Option, +} + +impl SymbolOutline { + pub fn new(id: impl Into, symbols: impl IntoIterator) -> Self { + Self { + id: id.into(), + symbols: symbols.into_iter().collect(), + cursor: None, + on_pick: None, + } + } + + /// The cursor's line, whose symbol is chosen. + pub fn cursor(mut self, line: usize) -> Self { + self.cursor = Some(line); + self + } + + pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SymbolOutline { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let open: Vec = flattened(&self.symbols) + .into_iter() + .filter(|(_, symbol)| !symbol.children.is_empty()) + .map(|(_, symbol)| key(symbol)) + .collect(); + let chosen = self + .cursor + .and_then(|line| enclosing(&self.symbols, line)) + .map(key); + let lines: Vec<(SharedString, usize)> = flattened(&self.symbols) + .into_iter() + .map(|(_, symbol)| (key(symbol), symbol.line)) + .collect(); + let tree = Tree::new(self.id, self.symbols.iter().map(node)) + .size_full() + .open(open) + .selected(chosen); + match self.on_pick { + Some(on_pick) => tree.on_select(move |keys, window, cx| { + let Some(picked) = keys.first() else { + return; + }; + let line = lines + .iter() + .find(|(key, _)| key == picked) + .map(|(_, line)| *line) + .expect("a picked symbol is in the outline"); + log::info!("symbol outline: line {line}"); + on_pick(line, window, cx); + }), + None => tree, + } + } +} + +type OnClose = Rc; +type OnFlip = Rc; + +/// A palette of a file's symbols by name, each with its kind and what holds it; `@` style jumping. +#[derive(IntoElement)] +pub struct GoToSymbol { + id: ElementId, + symbols: Vec, + on_pick: Option, + on_close: OnClose, +} + +impl GoToSymbol { + /// Render it while open; `on_close` runs on Escape, a click outside, or a pick. + pub fn new( + id: impl Into, + symbols: impl IntoIterator, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + symbols: symbols.into_iter().collect(), + on_pick: None, + on_close: Rc::new(on_close), + } + } + + pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for GoToSymbol { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let input = query_field(&self.id, "Go to a symbol", window, cx); + let query = input.read(cx).text().trim().to_string(); + let colors = cx.theme().colors.clone(); + let mut found: Vec<(i32, Row)> = flattened(&self.symbols) + .into_iter() + .filter_map(|(path, symbol)| { + let fit = if query.is_empty() { + None + } else { + Some(fuzzy(&query, &symbol.name)?) + }; + let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits)); + let detail = (!path.is_empty()).then(|| { + div() + .text_color(colors.fg_subtle) + .child(path.join(" › ")) + .into_any_element() + }); + let row = Row { + value: symbol.line.to_string().into(), + icon: Some(symbol.kind.icon()), + label: marked(symbol.name.clone(), hits, cx), + detail, + end: Some( + div() + .text_color(colors.fg_subtle) + .child(symbol.kind.word()) + .into_any_element(), + ), + }; + Some((score, row)) + }) + .collect(); + if !query.is_empty() { + found.sort_by(|(a, _), (b, _)| b.cmp(a)); + } + let on_pick = self.on_pick.map(|on_pick| { + Rc::new( + move |value: &SharedString, window: &mut Window, cx: &mut App| { + let line: usize = value.parse().expect("a symbol row carries its line"); + on_pick(line, window, cx); + }, + ) as Rc + }); + Palette { + id: self.id, + input, + groups: vec![Group { + title: None, + rows: found.into_iter().map(|(_, row)| row).collect(), + }], + start: 0, + empty: "No matching symbols".into(), + on_pick, + on_close: self.on_close, + } + .overlay(window, cx) + } +} + +/// A call in a hierarchy: who, of what kind, where, and the calls under it. +#[derive(Clone, Debug, PartialEq)] +pub struct Call { + pub name: SharedString, + pub kind: SymbolKind, + pub place: SharedString, + pub calls: Vec, +} + +/// Who calls a function or whom it calls, or a type's supertypes and subtypes, as a tree filling its box that flips between the two directions. +#[derive(IntoElement)] +pub struct CallHierarchy { + id: ElementId, + root: Call, + labels: (SharedString, SharedString), + incoming: bool, + on_flip: Option, +} + +impl CallHierarchy { + /// Calls to or from `root`; `incoming` picks which the tree shows. + pub fn new(id: impl Into, root: Call, incoming: bool) -> Self { + Self { + id: id.into(), + root, + labels: ("Incoming".into(), "Outgoing".into()), + incoming, + on_flip: None, + } + } + + /// For types: supertypes and subtypes instead of calls. + pub fn types(mut self) -> Self { + self.labels = ("Supertypes".into(), "Subtypes".into()); + self + } + + /// Gets whether the incoming side is chosen. + pub fn on_flip(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_flip = Some(Rc::new(handler)); + self + } +} + +fn call_node(call: &Call, path: &str) -> TreeNode { + let key = format!("{path}/{}", call.name); + let node = TreeNode::new(key.clone(), call.name.clone()) + .icon(call.kind.icon()) + .note(call.place.clone()); + if call.calls.is_empty() { + return node; + } + node.children(call.calls.iter().map(|inner| call_node(inner, &key))) +} + +fn keys(call: &Call, path: &str, out: &mut Vec) { + let key = format!("{path}/{}", call.name); + for inner in &call.calls { + keys(inner, &key, out); + } + out.push(key.into()); +} + +impl RenderOnce for CallHierarchy { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (incoming, outgoing) = self.labels; + let side = SegmentedControl::new( + (self.id.clone(), "side"), + if self.incoming { "in" } else { "out" }, + ) + .size(ControlSize::Sm) + .segment("in", incoming, None) + .segment("out", outgoing, None); + let side = match self.on_flip { + Some(on_flip) => { + side.on_change(move |key, window, cx| on_flip(key.as_ref() == "in", window, cx)) + } + None => side, + }; + let mut open = Vec::new(); + keys(&self.root, "", &mut open); + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(crate::theme::TextSize::Sm)) + .child(side) + .child( + Tree::new((self.id, "calls"), [call_node(&self.root, "")]) + .flex_1() + .min_h_0() + .open(open), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_innermost_symbol_above_a_line_holds_it() { + let symbols = vec![ + Symbol::new("Editor", SymbolKind::Struct, 2), + Symbol::new("impl Editor", SymbolKind::Module, 10).children([ + Symbol::new("new", SymbolKind::Method, 11), + Symbol::new("render", SymbolKind::Method, 20), + ]), + ]; + assert_eq!( + enclosing(&symbols, 25).map(|symbol| &symbol.name[..]), + Some("render") + ); + assert_eq!( + enclosing(&symbols, 5).map(|symbol| &symbol.name[..]), + Some("Editor") + ); + assert_eq!(enclosing(&symbols, 0), None); + let paths: Vec = flattened(&symbols) + .iter() + .map(|(path, _)| path.len()) + .collect(); + assert_eq!(paths, [0, 0, 1, 1]); + } +} diff --git a/src/editor/syntax.rs b/src/editor/syntax.rs new file mode 100644 index 0000000..bd8910c --- /dev/null +++ b/src/editor/syntax.rs @@ -0,0 +1,183 @@ +use std::ops::Range; + +use gpui::{App, HighlightStyle}; + +use super::buffer::Buffer; +use crate::forms::{Kind, code_highlights, lex}; + +/// Each line that opens a block, and the last line the block holds, read from indentation. +pub(crate) fn folds(buffer: &Buffer) -> Vec<(usize, usize)> { + let blank = |line: usize| buffer.line(line).trim().is_empty(); + let mut out = Vec::new(); + for line in 0..buffer.lines() { + if blank(line) { + continue; + } + let indent = buffer.indent(line); + let mut end = None; + for next in line + 1..buffer.lines() { + if blank(next) { + continue; + } + if buffer.indent(next) <= indent { + break; + } + end = Some(next); + } + if let Some(end) = end { + out.push((line, end)); + } + } + out +} + +/// A bracket in the code, how deep it sits, and where its partner is when it has one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Bracket { + pub offset: usize, + pub depth: usize, + pub partner: Option, +} + +fn opener(close: char) -> char { + match close { + ')' => '(', + ']' => '[', + _ => '{', + } +} + +/// Every bracket outside strings and comments; a closer takes its opener's depth. +pub(crate) fn brackets(buffer: &Buffer) -> Vec { + let mut out: Vec = Vec::new(); + let mut open: Vec<(usize, char)> = Vec::new(); + for line in 0..buffer.lines() { + let start = buffer.line_range(line).start; + let text = buffer.line(line); + for (range, kind) in lex(text) { + if kind != Kind::Mark { + continue; + } + let Some(ch) = text[range.clone()].chars().next() else { + continue; + }; + let offset = start + range.start; + match ch { + '(' | '[' | '{' => { + open.push((out.len(), ch)); + out.push(Bracket { + offset, + depth: open.len() - 1, + partner: None, + }); + } + ')' | ']' | '}' => match open.last() { + Some((ix, wanted)) if *wanted == opener(ch) => { + let ix = *ix; + open.pop(); + let partner = out[ix].offset; + out[ix].partner = Some(offset); + out.push(Bracket { + offset, + depth: open.len(), + partner: Some(partner), + }); + } + _ => out.push(Bracket { + offset, + depth: open.len(), + partner: None, + }), + }, + _ => {} + } + } + } + out +} + +/// The pair beside a caret: the bracket just after it, or else the one just before. +pub(crate) fn matched(brackets: &[Bracket], caret: usize) -> Option<(usize, usize)> { + let at = |offset: usize| { + brackets + .binary_search_by_key(&offset, |bracket| bracket.offset) + .ok() + .map(|ix| brackets[ix]) + }; + let bracket = at(caret).or_else(|| caret.checked_sub(1).and_then(at))?; + Some((bracket.offset, bracket.partner?)) +} + +/// A line's syntax colors as highlight styles. +pub(crate) fn colors(text: &str, cx: &App) -> Vec<(Range, HighlightStyle)> { + code_highlights(text, cx) + .into_iter() + .map(|(range, highlight)| { + ( + range, + HighlightStyle { + color: Some(highlight.color), + background_color: highlight.background, + ..HighlightStyle::default() + }, + ) + }) + .collect() +} + +/// Where `needle` appears in the lines of `range`, as offsets, whole words only when it is a word. +pub(crate) fn occurrences(buffer: &Buffer, needle: &str, lines: Range) -> Vec> { + if needle.is_empty() || needle.contains('\n') { + return Vec::new(); + } + let word = needle.chars().all(|ch| ch.is_alphanumeric() || ch == '_'); + let boundary = |ch: Option| ch.is_none_or(|ch| !(ch.is_alphanumeric() || ch == '_')); + let mut out = Vec::new(); + for line in lines.start..lines.end.min(buffer.lines()) { + let start = buffer.line_range(line).start; + let text = buffer.line(line); + for (at, _) in text.match_indices(needle) { + let end = at + needle.len(); + let whole = + boundary(text[..at].chars().next_back()) && boundary(text[end..].chars().next()); + if !word || whole { + out.push(start + at..start + end); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_fold_by_indentation_and_skip_blank_lines() { + let buffer = Buffer::new("fn a() {\n one();\n\n two();\n}\nfn b() {}"); + assert_eq!(folds(&buffer), [(0, 3)]); + } + + #[test] + fn brackets_pair_by_depth_outside_strings_and_comments() { + let buffer = Buffer::new("f(a[0], \"(\") // )\n{ }"); + let found = brackets(&buffer); + let offsets: Vec = found.iter().map(|bracket| bracket.offset).collect(); + assert_eq!( + offsets, + [1, 3, 5, 11, 18, 20], + "the quoted and the commented ones do not count" + ); + assert_eq!(found[1].depth, 1); + assert_eq!(found[3].partner, Some(1)); + assert_eq!(matched(&found, 12), Some((11, 1)), "just before the caret"); + assert_eq!(matched(&found, 18), Some((18, 20)), "just after the caret"); + } + + #[test] + fn occurrences_of_a_word_skip_longer_words() { + let buffer = Buffer::new("let total = total_cost + total;"); + let found = occurrences(&buffer, "total", 0..1); + assert_eq!(found, [4..9, 25..30]); + } +} diff --git a/src/editor/tasks.rs b/src/editor/tasks.rs new file mode 100644 index 0000000..5dedd4a --- /dev/null +++ b/src/editor/tasks.rs @@ -0,0 +1,269 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + motion::Spinner, + primitives::{Disclosure, Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{Ellipsis, format}, +}; + +/// Where a task's last run stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskState { + Idle, + Running, + Passed(Duration), + Failed(i32), +} + +/// A task to run: its name, group, command, and where and with what it runs. +#[derive(Clone, Debug, PartialEq)] +pub struct Task { + pub name: SharedString, + pub group: SharedString, + pub command: SharedString, + pub cwd: SharedString, + pub env: Vec<(SharedString, SharedString)>, + pub state: TaskState, +} + +type OnIndex = Rc; +type OnOpen = Rc, &mut Window, &mut App)>; + +/// Tasks by group, each with how its last run went and a run or stop button; one opens to show its configuration. +#[derive(IntoElement)] +pub struct TaskRunner { + id: ElementId, + tasks: Vec, + open: Option, + on_run: Option, + on_stop: Option, + on_open: Option, +} + +impl TaskRunner { + pub fn new(id: impl Into, tasks: impl IntoIterator) -> Self { + Self { + id: id.into(), + tasks: tasks.into_iter().collect(), + open: None, + on_run: None, + on_stop: None, + on_open: None, + } + } + + /// The task whose run configuration shows. + pub fn open(mut self, task: Option) -> Self { + self.open = task; + self + } + + pub fn on_run(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_run = Some(Rc::new(handler)); + self + } + + pub fn on_stop(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_stop = Some(Rc::new(handler)); + self + } + + /// Gets the task to open, or none to close it. + pub fn on_open( + mut self, + handler: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TaskRunner { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let mut groups: Vec = Vec::new(); + for task in &self.tasks { + if !groups.contains(&task.group) { + groups.push(task.group.clone()); + } + } + let mut rows = Vec::new(); + for group in groups { + rows.push( + div() + .px_2() + .pt_2() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_subtle) + .child(group.to_uppercase()) + .into_any_element(), + ); + for (ix, task) in self + .tasks + .iter() + .enumerate() + .filter(|(_, task)| task.group == group) + { + let running = task.state == TaskState::Running; + let status = match task.state { + TaskState::Idle => Icon::new(IconName::Circle) + .size(IconSize::Sm) + .color(colors.fg_subtle) + .into_any_element(), + TaskState::Running => Spinner::new((self.id.clone(), format!("spinner-{ix}"))) + .size(IconSize::Sm) + .into_any_element(), + TaskState::Passed(_) => Icon::new(IconName::CircleCheck) + .size(IconSize::Sm) + .color(colors.success) + .into_any_element(), + TaskState::Failed(_) => Icon::new(IconName::CircleX) + .size(IconSize::Sm) + .color(colors.danger) + .into_any_element(), + }; + let outcome = match task.state { + TaskState::Idle => SharedString::default(), + TaskState::Running => "Running".into(), + TaskState::Passed(took) => { + format::duration(took.as_secs().max(1), format::DurationStyle::Compact) + .into() + } + TaskState::Failed(code) => format!("Exit {code}").into(), + }; + let (action, icon, words) = if running { + (self.on_stop.clone(), IconName::CircleStop, "Stop") + } else { + (self.on_run.clone(), IconName::Play, "Run") + }; + let opened = self.open == Some(ix); + let toggle = self.on_open.clone(); + rows.push( + div() + .id((self.id.clone(), format!("task-{ix}"))) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Md)) + .hover(|row| row.bg(colors.hover)) + .cursor_pointer() + .when_some(toggle, |row, toggle| { + row.on_click(move |_, window, cx| { + toggle((!opened).then_some(ix), window, cx) + }) + }) + .child(Disclosure::new( + (self.id.clone(), format!("open-{ix}")), + opened, + )) + .child(status) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(task.name.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(Ellipsis::new(task.command.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if matches!(task.state, TaskState::Failed(_)) { + colors.danger + } else { + colors.fg_muted + }) + .child(outcome), + ) + .child( + IconButton::new((self.id.clone(), format!("run-{ix}")), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(words) + .when_some(action, |button, action| { + button.on_click(move |_, window, cx| { + log::info!("task runner: {words} {ix}"); + action(ix, window, cx) + }) + }), + ) + .into_any_element(), + ); + if opened { + let field = |label: &'static str, value: SharedString| { + div() + .flex() + .gap_3() + .child( + div() + .w(theme.label_width() * 0.75) + .text_color(colors.fg_subtle) + .child(label), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_family(theme.mono_family.clone()) + .text_color(colors.fg) + .child(value), + ) + }; + let env = task + .env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(" "); + rows.push( + div() + .ml_8() + .mb_1() + .p_3() + .flex() + .flex_col() + .gap_1p5() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .text_size(theme.text_size(TextSize::Xs)) + .child(field("Command", task.command.clone())) + .child(field("Directory", task.cwd.clone())) + .child(field( + "Environment", + if env.is_empty() { + "—".into() + } else { + env.into() + }, + )) + .into_any_element(), + ); + } + } + } + div() + .flex() + .flex_col() + .gap_0p5() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + } +} diff --git a/src/editor/tests.rs b/src/editor/tests.rs new file mode 100644 index 0000000..8295b6e --- /dev/null +++ b/src/editor/tests.rs @@ -0,0 +1,114 @@ +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use super::{CodeEditor, GhostText, layout::Row}; +use crate::theme::Theme; + +fn editor<'a>( + text: &str, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + cx.update(Theme::init); + let text = text.to_string(); + cx.add_window_view(move |window, cx| CodeEditor::new(text, window, cx)) +} + +#[gpui::test] +fn typed_pairs_close_and_one_undo_takes_back_the_burst(cx: &mut TestAppContext) { + let (editor, cx) = editor("let x = ", cx); + editor.update(cx, |editor, cx| { + let end = 8..8; + editor.select([end], cx); + for ch in ["(", "1", ")"] { + editor.type_text(ch, cx); + } + assert_eq!( + editor.text(), + "let x = (1)", + "the typed close steps over its pair" + ); + editor.undo(cx); + assert_eq!(editor.text(), "let x = "); + editor.redo(cx); + assert_eq!(editor.text(), "let x = (1)"); + }); +} + +#[gpui::test] +fn select_next_takes_the_word_then_its_next_match(cx: &mut TestAppContext) { + let (editor, cx) = editor("a foo b foo c", cx); + editor.update(cx, |editor, cx| { + let inside = 3..3; + editor.select([inside], cx); + editor.select_next(cx); + editor.select_next(cx); + let ranges: Vec<_> = editor.selections().iter().map(|s| s.range()).collect(); + assert_eq!(ranges, [2..5, 8..11]); + editor.type_text("bar", cx); + assert_eq!(editor.text(), "a bar b bar c", "every cursor types"); + }); +} + +#[gpui::test] +fn toggling_comments_twice_restores_the_lines(cx: &mut TestAppContext) { + let (editor, cx) = editor("fn a() {\n b();\n}", cx); + editor.update(cx, |editor, cx| { + editor.select_all(cx); + editor.toggle_comment(cx); + assert_eq!(editor.text(), "// fn a() {\n// b();\n// }"); + editor.toggle_comment(cx); + assert_eq!(editor.text(), "fn a() {\n b();\n}"); + }); +} + +#[gpui::test] +fn a_multi_line_ghost_moves_the_rest_of_its_line_below_it(cx: &mut TestAppContext) { + let (editor, cx) = editor("call(x)\nnext", cx); + editor.update(cx, |editor, cx| { + editor.set_ghost_text( + Some(GhostText { + offset: 5, + text: "a,\n b, ".into(), + }), + cx, + ); + assert_eq!( + editor.row_text(0), + "call(", + "the row stops where the ghost starts" + ); + assert_eq!(editor.row_text(1), "next"); + let caret = 5..5; + editor.select([caret], cx); + editor.indent(cx); + assert_eq!( + editor.text(), + "call(a,\n b, x)\nnext", + "Tab takes the ghost" + ); + assert_eq!(editor.row_text(0), "call(a,", "a taken ghost cuts nothing"); + }); +} + +#[gpui::test] +fn a_selection_inside_a_fold_opens_it(cx: &mut TestAppContext) { + let (editor, cx) = editor("fn a() {\n b();\n}\nc", cx); + editor.update(cx, |editor, cx| { + editor.toggle_fold(0, cx); + assert!(!editor.frame_shows(1), "the body folds away"); + let body = 13..13; + editor.select([body], cx); + assert!(editor.frame_shows(1), "a cursor inside unfolds it"); + }); +} + +impl CodeEditor { + /// Whether line `line` has a row now. + fn frame_shows(&self, line: usize) -> bool { + let hide = super::layout::hidden( + self.buffer.lines(), + &super::syntax::folds(&self.buffer), + &self.folded, + ); + super::layout::rows(&self.buffer, &hide, &self.marks).contains(&Row::Line(line)) + } +} diff --git a/src/editor/view.rs b/src/editor/view.rs new file mode 100644 index 0000000..3171388 --- /dev/null +++ b/src/editor/view.rs @@ -0,0 +1,353 @@ +use std::ops::Range; + +use gpui::{ + AnyElement, Bounds, Context, ElementInputHandler, InteractiveElement, IntoElement, MouseButton, + MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, StyledText, Window, + canvas, div, fill, font, point, size, uniform_list, +}; + +use super::{ + decor::ReadOnlyBanner, + keys::{CONTEXT, listen}, + layout::{Row, around, hidden, rows}, + state::{CodeEditor, EditorEvent}, + syntax::{self, Bracket, brackets, folds}, +}; +use crate::theme::{ActiveTheme, TextSize}; + +/// What one render worked out, for the rows the list builds after it. +#[derive(Default)] +pub(crate) struct Frame { + pub rows: Vec, + pub folds: Vec<(usize, usize)>, + pub brackets: Vec, + pub advance: Pixels, + pub line: Pixels, + pub matched: Option<(usize, usize)>, + pub occurrences: Vec>, + /// The minimap's box, from its last paint. + pub minimap: Bounds, +} + +/// Lines of code a row's height holds, as leading. +const LEADING: f32 = 1.6; +/// Blocks the sticky header shows at most. +const STICKY: usize = 3; +/// Columns the minimap spans, in code characters. +const MINIMAP: f32 = 12.0; + +impl CodeEditor { + /// The text the primary selection or its word gives, for highlighting where else it appears. + fn needle(&self) -> Option { + let primary = self.primary(); + let range = if primary.is_empty() { + let word = self.buffer.word_at(primary.head); + let text = &self.buffer.text()[word.clone()]; + text.chars() + .all(|ch| ch.is_alphanumeric() || ch == '_') + .then_some(word)? + } else { + primary.range() + }; + (!range.is_empty()).then(|| self.buffer.text()[range].to_string()) + } + + /// The first line the list shows, after scrolling. + fn top_line(&self) -> Option { + let scrolled = -self.scroll.0.borrow().base_handle.offset().y; + let first = (scrolled / self.frame.line).floor().max(0.0) as usize; + self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..] + .iter() + .find_map(|row| match row { + Row::Line(line) => Some(*line), + _ => None, + }) + } + + fn sticky(&self, cx: &mut Context) -> Option { + let colors = cx.theme().colors.clone(); + let top = self.top_line()?; + let headers = around(&self.frame.folds, top, STICKY); + if headers.is_empty() { + return None; + } + let rows = headers.into_iter().map(|line| { + let text = self.buffer.line(line).to_string(); + let styles = syntax::colors(&text, cx); + let jump = cx.entity(); + div() + .id(("sticky", line)) + .h(self.frame.line) + .flex() + .items_center() + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + window.prevent_default(); + jump.update(cx, |editor, cx| { + let at = editor.buffer.line_range(line).start + editor.buffer.indent(line); + let cursor = at..at; + editor.select([cursor], cx); + editor.reveal(false, cx); + }); + }) + .child( + div() + .flex_none() + .w(self.frame.advance * (self.gutter_columns() + 1) as f32), + ) + .child( + div() + .line_height(self.frame.line) + .whitespace_nowrap() + .text_color(colors.syntax.variable) + .child(StyledText::new(text).with_highlights(styles)), + ) + }); + Some( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bg(colors.surface) + .border_b_1() + .border_color(colors.border) + .children(rows) + .into_any_element(), + ) + } + + fn minimap_view(&self, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors.clone(); + let (advance, line) = (self.frame.advance, self.frame.line); + let tokens: Vec> = self + .frame + .rows + .iter() + .map(|row| match row { + Row::Line(at) => { + let text = self.buffer.line(*at); + syntax::colors(text, cx) + .into_iter() + .filter_map(|(range, style)| { + let start = text[..range.start].chars().count(); + let len = text[range].chars().count(); + style.color.map(|color| (start, len, color)) + }) + .collect() + } + _ => Vec::new(), + }) + .collect(); + let scroll = self.scroll.clone(); + let entity = cx.entity(); + let (drag, press) = (cx.entity(), cx.entity()); + let shade = colors.hover; + div() + .id("minimap") + .relative() + .flex_none() + .w(advance * MINIMAP) + .h_full() + .border_l_1() + .border_color(colors.border) + .cursor_pointer() + .child( + canvas( + move |bounds, _, cx| { + entity.update(cx, |editor, _| editor.frame.minimap = bounds); + }, + move |bounds, _, window, _| { + let rows = tokens.len().max(1) as f32; + let tall = (line * 0.15).min(bounds.size.height / rows); + let wide = advance * 0.12; + let state = scroll.0.borrow(); + let viewport = state.base_handle.bounds().size.height; + let scrolled = -state.base_handle.offset().y; + let top = bounds.origin.y + scrolled / line * tall; + let extent = size(bounds.size.width, viewport / line * tall); + window.paint_quad(fill( + Bounds::new(point(bounds.origin.x, top), extent), + shade, + )); + for (ix, spans) in tokens.iter().enumerate() { + for (start, len, color) in spans { + let origin = point( + bounds.origin.x + wide * (*start + 2) as f32, + bounds.origin.y + tall * ix as f32, + ); + let extent = size(wide * *len as f32, tall * 0.7); + window.paint_quad(fill( + Bounds::new(origin, extent), + color.opacity(0.7), + )); + } + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { + press.update(cx, |editor, cx| editor.jump(event.position.y, cx)); + }) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + if event.dragging() { + drag.update(cx, |editor, cx| editor.jump(event.position.y, cx)); + } + }) + .into_any_element() + } + + /// Scrolls so the row under a minimap point sits in the middle of the view. + fn jump(&mut self, y: Pixels, cx: &mut Context) { + let bounds = self.frame.minimap; + let rows = self.frame.rows.len().max(1) as f32; + let tall = (self.frame.line * 0.15).min(bounds.size.height / rows); + let row = ((y - bounds.origin.y) / tall).max(0.0); + let state = self.scroll.0.borrow(); + let viewport = state.base_handle.bounds().size.height; + let top = (self.frame.line * row - viewport / 2.0).max(Pixels::ZERO); + state.base_handle.set_offset(point(Pixels::ZERO, -top)); + drop(state); + cx.notify(); + } +} + +impl Render for CodeEditor { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let sticky = if self.options.sticky { + self.sticky(cx) + } else { + None + }; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let size = theme.text_size(TextSize::Sm); + let pixels = size.to_pixels(window.rem_size()); + let mono = window + .text_system() + .resolve_font(&font(theme.mono_family.clone())); + let advance = window + .text_system() + .advance(mono, pixels, 'm') + .expect("the code font has an m") + .width; + let line = (pixels * LEADING).round(); + let found = folds(&self.buffer); + let hide = hidden(self.buffer.lines(), &found, &self.folded); + let found_brackets = brackets(&self.buffer); + let matched = syntax::matched(&found_brackets, self.primary().head); + let occurrences = match self.needle() { + Some(needle) if self.focus.is_focused(window) => { + syntax::occurrences(&self.buffer, &needle, 0..self.buffer.lines()) + } + _ => Vec::new(), + }; + let minimap = self.frame.minimap; + self.frame = Frame { + rows: rows(&self.buffer, &hide, &self.marks), + folds: found, + brackets: found_brackets, + advance, + line, + matched, + occurrences, + minimap, + }; + self.metrics.advance = advance; + self.metrics.line = line; + let count = self.frame.rows.len(); + let list = uniform_list( + "editor-rows", + count, + cx.processor(|editor, range: Range, window, cx| { + range.map(|ix| editor.row(ix, window, cx)).collect() + }), + ) + .track_scroll(self.scroll.clone()) + .size_full(); + let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity()); + let unlock = cx.entity(); + let banner = self.options.read_only.then(|| { + ReadOnlyBanner::new( + ("read-only", cx.entity_id().as_u64()), + "This file is read-only.", + ) + .action("Make editable", move |_, cx| { + unlock.update(cx, |editor, cx| { + editor.set_read_only(false, cx); + cx.emit(EditorEvent::Unlock); + }) + }) + }); + let body = div() + .relative() + .flex_1() + .h_full() + .child(list) + .children(sticky) + .child( + canvas( + move |bounds, _, cx| { + top.update(cx, |editor, _| editor.metrics.top = bounds.origin.y); + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + let root = div() + .id(("code-editor", cx.entity_id().as_u64())) + .key_context(CONTEXT) + .track_focus(&self.focus) + .size_full() + .flex() + .flex_col() + .overflow_hidden() + .bg(colors.surface) + .font_family(theme.mono_family.clone()) + .text_size(size) + .text_color(colors.fg) + .on_mouse_up( + MouseButton::Left, + cx.listener(|editor, _, _, _| editor.dragging = false), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|editor, _, _, _| editor.dragging = false), + ) + .children(banner) + .child( + div() + .relative() + .flex_1() + .min_h_0() + .flex() + .child(body) + .children(self.options.minimap.then(|| self.minimap_view(cx))), + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + window.handle_input( + &focus, + ElementInputHandler::new(bounds, input.clone()), + cx, + ); + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + listen(root, cx) + } +} diff --git a/src/editor/welcome.rs b/src/editor/welcome.rs new file mode 100644 index 0000000..04eb766 --- /dev/null +++ b/src/editor/welcome.rs @@ -0,0 +1,470 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + forms::Checkbox, + navigation::{Group, Palette, Row, fuzzy, marked, query_field}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{Ellipsis, KbdCombo}, +}; + +/// A project opened before: its name, where it lives, its branch, when it was last opened, and whether it is pinned. +#[derive(Clone, Debug, PartialEq)] +pub struct Project { + pub name: SharedString, + pub path: SharedString, + pub branch: Option, + pub opened: SharedString, + pub pinned: bool, +} + +/// Pinned projects first, then the rest in the order given. +pub(crate) fn ordered(projects: &[Project]) -> Vec<(usize, &Project)> { + let mut all: Vec<(usize, &Project)> = projects.iter().enumerate().collect(); + all.sort_by_key(|(_, project)| !project.pinned); + all +} + +type OnIndex = Rc; + +/// Projects opened before, pinned ones first: each with its path, branch and when; a press opens one, the pin keeps it on top. +#[derive(IntoElement)] +pub struct RecentProjects { + id: ElementId, + projects: Vec, + on_open: Option, + on_pin: Option, + on_remove: Option, +} + +impl RecentProjects { + pub fn new(id: impl Into, projects: impl IntoIterator) -> Self { + Self { + id: id.into(), + projects: projects.into_iter().collect(), + on_open: None, + on_pin: None, + on_remove: None, + } + } + + pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + pub fn on_pin(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_pin = Some(Rc::new(handler)); + self + } + + pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RecentProjects { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rows = ordered(&self.projects).into_iter().map(|(ix, project)| { + let (open, pin, remove) = ( + self.on_open.clone(), + self.on_pin.clone(), + self.on_remove.clone(), + ); + let button = |key: &'static str, + icon: IconName, + words: &'static str, + action: Option| { + IconButton::new((self.id.clone(), format!("{key}-{ix}")), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(words) + .when_some(action, move |button, action| { + button.on_click(move |_, window, cx| action(ix, window, cx)) + }) + }; + div() + .id((self.id.clone(), format!("project-{ix}"))) + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Md)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .when_some(open, |row, open| { + row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("recent projects: open {ix}"); + open(ix, window, cx) + }) + }) + .child( + Icon::new(IconName::Folder) + .size(IconSize::Md) + .color(colors.fg_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(project.name.clone()), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(Ellipsis::new(project.path.clone())), + ), + ) + .children(project.branch.clone().map(|branch| { + div() + .flex() + .items_center() + .gap_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::GitBranch) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child(branch) + })) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(project.opened.clone()), + ) + .child(button( + "pin", + if project.pinned { + IconName::PinOff + } else { + IconName::Pin + }, + if project.pinned { "Unpin" } else { "Pin" }, + pin, + )) + .child(button("remove", IconName::X, "Remove from recent", remove)) + }); + div() + .flex() + .flex_col() + .gap_0p5() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + } +} + +type OnClose = Rc; + +/// A palette of projects by name or path, pinned ones first; a pick opens it. +#[derive(IntoElement)] +pub struct ProjectSwitcher { + id: ElementId, + projects: Vec, + on_open: Option, + on_close: OnClose, +} + +impl ProjectSwitcher { + /// Render it while open; `on_close` runs on Escape, a click outside, or a pick. + pub fn new( + id: impl Into, + projects: impl IntoIterator, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + projects: projects.into_iter().collect(), + on_open: None, + on_close: Rc::new(on_close), + } + } + + pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ProjectSwitcher { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let input = query_field(&self.id, "Switch to a project", window, cx); + let query = input.read(cx).text().trim().to_string(); + let colors = cx.theme().colors.clone(); + let mut found: Vec<(i32, Row)> = ordered(&self.projects) + .into_iter() + .filter_map(|(ix, project)| { + let fit = if query.is_empty() { + None + } else { + Some(fuzzy(&query, &project.name).or_else(|| fuzzy(&query, &project.path))?) + }; + let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits)); + let hits = if hits.iter().all(|hit| hit.end <= project.name.len()) { + hits + } else { + Vec::new() + }; + Some(( + score, + Row { + value: ix.to_string().into(), + icon: Some(if project.pinned { + IconName::Pin + } else { + IconName::Folder + }), + label: marked(project.name.clone(), hits, cx), + detail: Some( + div() + .text_color(colors.fg_subtle) + .child(project.path.clone()) + .into_any_element(), + ), + end: Some( + div() + .text_color(colors.fg_subtle) + .child(project.opened.clone()) + .into_any_element(), + ), + }, + )) + }) + .collect(); + if !query.is_empty() { + found.sort_by(|(a, _), (b, _)| b.cmp(a)); + } + let on_pick = self.on_open.map(|open| { + Rc::new( + move |value: &SharedString, window: &mut Window, cx: &mut App| { + open( + value.parse().expect("a project row carries its place"), + window, + cx, + ) + }, + ) as Rc + }); + Palette { + id: self.id, + input, + groups: vec![Group { + title: None, + rows: found.into_iter().map(|(_, row)| row).collect(), + }], + start: 0, + empty: "No matching projects".into(), + on_pick, + on_close: self.on_close, + } + .overlay(window, cx) + } +} + +/// A way to start from the welcome page: its icon, words and keys. +#[derive(Clone, Debug, PartialEq)] +pub struct StartAction { + pub icon: IconName, + pub label: SharedString, + pub keys: Option, +} + +type OnFlag = Rc; + +/// The first page an editor shows: ways to start, recent projects, and whether to show it at startup. +#[derive(IntoElement)] +pub struct WelcomePage { + id: ElementId, + title: SharedString, + tagline: SharedString, + actions: Vec, + recent: Option, + show_at_start: bool, + on_action: Option, + on_show: Option, +} + +impl WelcomePage { + pub fn new( + id: impl Into, + title: impl Into, + tagline: impl Into, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + tagline: tagline.into(), + actions: Vec::new(), + recent: None, + show_at_start: true, + on_action: None, + on_show: None, + } + } + + pub fn action( + mut self, + icon: IconName, + label: impl Into, + keys: Option<&str>, + ) -> Self { + self.actions.push(StartAction { + icon, + label: label.into(), + keys: keys.map(|keys| keys.to_string().into()), + }); + self + } + + /// Recent projects, usually a `RecentProjects`. + pub fn recent(mut self, recent: impl IntoElement) -> Self { + self.recent = Some(recent.into_any_element()); + self + } + + pub fn show_at_start(mut self, show: bool) -> Self { + self.show_at_start = show; + self + } + + pub fn on_action(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_action = Some(Rc::new(handler)); + self + } + + pub fn on_show(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_show = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for WelcomePage { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let heading = |words: &'static str| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_subtle) + .child(words) + }; + let actions = self.actions.iter().enumerate().map(|(ix, action)| { + let pick = self.on_action.clone(); + div() + .id((self.id.clone(), format!("action-{ix}"))) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Md)) + .cursor_pointer() + .text_color(colors.link) + .hover(|row| row.bg(colors.hover)) + .when_some(pick, |row, pick| { + row.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| pick(ix, window, cx)) + }) + .child(Icon::new(action.icon).size(IconSize::Sm).color(colors.link)) + .child(div().flex_1().child(action.label.clone())) + .children(action.keys.as_ref().map(|keys| KbdCombo::new(keys))) + }); + let show = Checkbox::new((self.id.clone(), "show"), self.show_at_start) + .label("Show this page at startup"); + let show = match self.on_show { + Some(on_show) => show.on_change(move |on, window, cx| on_show(on, window, cx)), + None => show, + }; + div() + .flex() + .flex_col() + .gap_8() + .p_8() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Xxl)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(self.title), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Md)) + .text_color(colors.fg_muted) + .child(self.tagline), + ), + ) + .child( + div() + .flex() + .gap_12() + .child( + div() + .w(theme.label_width() * 2.5) + .flex() + .flex_col() + .gap_1() + .child(heading("Start")) + .children(actions), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child(heading("Recent")) + .children(self.recent), + ), + ) + .child(show) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_projects_lead_and_keep_their_places() { + let project = |name: &str, pinned: bool| Project { + name: name.to_string().into(), + path: "~".into(), + branch: None, + opened: "now".into(), + pinned, + }; + let projects = [project("a", false), project("b", true), project("c", false)]; + let order: Vec = ordered(&projects).into_iter().map(|(ix, _)| ix).collect(); + assert_eq!(order, [1, 0, 2]); + } +} diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 8609231..0e920d6 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -54,6 +54,7 @@ pub use check::{CheckState, Checkbox, CheckboxGroup}; pub use checked::{EmailInput, UrlInput, is_email, is_url}; pub use chips::ChoiceChips; pub use code::{CodeInput, JsonInput, code_highlights, json_highlights}; +pub(crate) use code::{Kind, lex}; pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop}; pub use combobox::Combobox; pub use date::{ @@ -94,7 +95,7 @@ pub use structure::{ }; pub use switch::Switch; pub use tags::TagInput; -pub(crate) use text::{Down, Enter, Up, bind_keys}; +pub(crate) use text::{Down, Enter, History, Up, bind_keys, from_utf16, to_utf16}; pub use text::{Highlight, InputEvent, TextInput}; pub use transfer::TransferList; pub use unit::UnitInput; diff --git a/src/forms/text/edit.rs b/src/forms/text/edit.rs index 00a726e..0965aa8 100644 --- a/src/forms/text/edit.rs +++ b/src/forms/text/edit.rs @@ -93,17 +93,26 @@ pub(crate) struct Snapshot { /// Typing within this pause joins one undo step. const JOIN: Duration = Duration::from_millis(900); -/// Undo and redo as whole-text snapshots. -#[derive(Default)] -pub(crate) struct History { - undo: Vec, - redo: Vec, +/// Undo and redo as whole snapshots of what an editor holds. +pub(crate) struct History { + undo: Vec, + redo: Vec, typing_since: Option, } -impl History { +impl Default for History { + fn default() -> Self { + Self { + undo: Vec::new(), + redo: Vec::new(), + typing_since: None, + } + } +} + +impl History { /// Records the state before an edit. Typing in one burst makes one step. - pub fn record(&mut self, before: Snapshot, typing: bool) { + pub fn record(&mut self, before: S, typing: bool) { let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN); if !joins { self.undo.push(before); @@ -112,14 +121,14 @@ impl History { self.redo.clear(); } - pub fn undo(&mut self, current: Snapshot) -> Option { + pub fn undo(&mut self, current: S) -> Option { let previous = self.undo.pop()?; self.redo.push(current); self.typing_since = None; Some(previous) } - pub fn redo(&mut self, current: Snapshot) -> Option { + pub fn redo(&mut self, current: S) -> Option { let next = self.redo.pop()?; self.undo.push(current); self.typing_since = None; @@ -175,7 +184,7 @@ mod tests { text: text.into(), selection: text.len()..text.len(), }; - let mut history = History::default(); + let mut history = History::::default(); history.record(snap(""), true); history.record(snap("a"), true); history.record(snap("ab"), false); diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index e54fc61..004e4cb 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -11,7 +11,8 @@ use gpui::{ }; pub(crate) use actions::{Backspace, Down, Enter, Submit, Up, bind_keys}; -use edit::{History, Snapshot}; +use edit::Snapshot; +pub(crate) use edit::{History, from_utf16, to_utf16}; use crate::theme::ActiveTheme; @@ -64,7 +65,7 @@ pub struct TextInput { max_len: Option, highlighter: Option, disabled: bool, - history: History, + history: History, pub(crate) layout: Option, pub(crate) scroll: Point, selecting: bool, diff --git a/src/lib.rs b/src/lib.rs index db58ed9..6f8b384 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod assets; pub mod buttons; pub mod charts; pub mod data_display; +pub mod editor; pub mod feedback; pub mod finance; pub mod forms; @@ -38,4 +39,5 @@ pub fn init(cx: &mut App) { KeyBinding::new("shift-tab", FocusPrev, None), ]); forms::bind_keys(cx); + editor::bind_keys(cx); } diff --git a/src/menus/hosts.rs b/src/menus/hosts.rs index 86749b7..ad1f702 100644 --- a/src/menus/hosts.rs +++ b/src/menus/hosts.rs @@ -104,6 +104,8 @@ impl RenderOnce for DropdownMenu { pub struct OverflowMenu { id: ElementId, menu: Menu, + icon: IconName, + tooltip: SharedString, } impl OverflowMenu { @@ -111,17 +113,29 @@ impl OverflowMenu { Self { id: id.into(), menu, + icon: IconName::Ellipsis, + tooltip: "More".into(), } } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = icon; + self + } + + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = tooltip.into(); + self + } } impl RenderOnce for OverflowMenu { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = state(&self.id, window, cx); let click = toggle(&state, &self.menu, cx); - let button = IconButton::new((self.id.clone(), "button"), IconName::Ellipsis) + let button = IconButton::new((self.id.clone(), "button"), self.icon) .variant(ButtonVariant::Ghost) - .tooltip("More") + .tooltip(self.tooltip) .on_click(move |event, window, cx| click(event, window, cx)); host(self.id, button, &self.menu, &state, None, window, cx) } diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs index feebeb5..8ddcda4 100644 --- a/src/navigation/mod.rs +++ b/src/navigation/mod.rs @@ -7,7 +7,7 @@ mod nav; mod overflow; mod pages; mod palette; -pub(crate) use palette::{fuzzy, marked}; +pub(crate) use palette::{Group, Palette, Row, fuzzy, marked, query_field}; mod steps; mod tabs; #[cfg(all(test, feature = "test-support"))] diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 7e5adf2..0fc5ffd 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -58,12 +58,14 @@ icons! { Bookmark => "bookmark", Bot => "bot", Box => "box", + Braces => "braces", Brush => "brush", Bug => "bug", CalendarDays => "calendar-days", CalendarRange => "calendar-range", Calendar => "calendar", Camera => "camera", + CaseSensitive => "case-sensitive", ChartArea => "chart-area", ChartBar => "chart-bar", ChartCandlestick => "chart-candlestick", @@ -207,6 +209,7 @@ icons! { Quote => "quote", Redo2 => "redo-2", RefreshCw => "refresh-cw", + Regex => "regex", Reply => "reply", Rocket => "rocket", RotateCcw => "rotate-ccw", @@ -231,6 +234,7 @@ icons! { Spline => "spline", SquareCheck => "square-check", Square => "square", + SquareFunction => "square-function", Star => "star", StepForward => "step-forward", Strikethrough => "strikethrough", @@ -252,12 +256,14 @@ icons! { Upload => "upload", User => "user", Users => "users", + Variable => "variable", VideoOff => "video-off", Video => "video", Volume2 => "volume-2", VolumeX => "volume-x", Wallet => "wallet", WandSparkles => "wand-sparkles", + WholeWord => "whole-word", WifiOff => "wifi-off", Wifi => "wifi", Wrench => "wrench", diff --git a/src/shell/bars.rs b/src/shell/bars.rs index 33cce00..03e0f19 100644 --- a/src/shell/bars.rs +++ b/src/shell/bars.rs @@ -167,6 +167,7 @@ type ClickHandler = Box; #[derive(IntoElement)] pub struct StatusBarItem { id: ElementId, + leading: Option, icon: Option, label: Option, tooltip: Option, @@ -177,6 +178,7 @@ impl StatusBarItem { pub fn new(id: impl Into) -> Self { Self { id: id.into(), + leading: None, icon: None, label: None, tooltip: None, @@ -184,6 +186,12 @@ impl StatusBarItem { } } + /// Drawn before the icon, such as a mark with a count. + pub fn leading(mut self, leading: impl IntoElement) -> Self { + self.leading = Some(leading.into_any_element()); + self + } + pub fn icon(mut self, icon: IconName) -> Self { self.icon = Some(icon); self @@ -220,6 +228,7 @@ impl RenderOnce for StatusBarItem { .h_full() .px_1p5() .rounded(theme.radius(Radius::Sm)) + .children(self.leading) .when_some(self.icon, |item, icon| { item.child( Icon::new(icon) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 5c87036..92c9e24 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -273,73 +273,73 @@ ## T17a Editor · Core — `src/editor` -- [ ] CodeEditor — highlight, multi-cursor, selection, undo, redo -- [ ] EditorGutter -- [ ] LineNumbers — absolute, relative -- [ ] FoldingControl -- [ ] Minimap -- [ ] Ruler / IndentGuide -- [ ] BracketMatcher / RainbowBrackets -- [ ] WhitespaceRenderer -- [ ] CursorRenderer — block, line, underline, blink -- [ ] SelectionHighlight -- [ ] MultiCursor -- [ ] VimModeIndicator -- [ ] StickyScroll -- [ ] Breadcrumb → `navigation::Breadcrumb` -- [ ] InlayHints -- [ ] CodeLens -- [ ] InlineDecoration -- [ ] GhostText -- [ ] InlineChat -- [ ] InlineDiff -- [ ] ReadOnlyBanner +- [x] CodeEditor — highlight, multi-cursor, selection, undo, redo +- [x] EditorGutter → `editor::CodeEditor`, its gutter: breakpoints, problems, numbers, folds, git marks +- [x] LineNumbers — absolute, relative → `CodeEditor::line_numbers` with `LineNumbers` +- [x] FoldingControl → `CodeEditor`, gutter folds +- [x] Minimap → `CodeEditor::minimap` +- [x] Ruler / IndentGuide → `CodeEditor::rulers`; guides on unless `hide_indent_guides` +- [x] BracketMatcher / RainbowBrackets → `CodeEditor::rainbow_brackets`; the matched pair always marked +- [x] WhitespaceRenderer → `CodeEditor::show_whitespace` +- [x] CursorRenderer — block, line, underline, blink → `CodeEditor::cursor_shape` with `CursorShape` +- [x] SelectionHighlight → `CodeEditor`, the word at the cursor marked while focused +- [x] MultiCursor → `CodeEditor`: Cmd-D, Cmd-Alt-Up and Down, Alt-click +- [x] VimModeIndicator +- [x] StickyScroll → `CodeEditor::sticky_scroll` +- [x] Breadcrumb → `navigation::Breadcrumb` +- [x] InlayHints → `CodeEditor::set_inlay_hints` +- [x] CodeLens → `CodeEditor::set_code_lenses` +- [x] InlineDecoration → `CodeEditor::set_diagnostics` and `set_backgrounds` +- [x] GhostText → `CodeEditor::set_ghost_text` +- [x] InlineChat → `CodeEditor::set_chat` +- [x] InlineDiff → `CodeEditor::set_diff` +- [x] ReadOnlyBanner ## T17b Editor · Intelligence -- [ ] CompletionMenu -- [ ] SignatureHelp -- [ ] HoverInfo -- [ ] QuickFix / CodeActionMenu -- [ ] RenameInput -- [ ] PeekView → `overlays::Peek` -- [ ] ReferencesPanel -- [ ] SymbolOutline -- [ ] GoToSymbol -- [ ] CallHierarchy / TypeHierarchy +- [x] CompletionMenu +- [x] SignatureHelp +- [x] HoverInfo +- [x] QuickFix / CodeActionMenu → `editor::CodeActionMenu` +- [x] RenameInput +- [x] PeekView → `overlays::Peek` +- [x] ReferencesPanel +- [x] SymbolOutline +- [x] GoToSymbol +- [x] CallHierarchy / TypeHierarchy → `editor::CallHierarchy`, `types` for supertypes and subtypes ## T17c Editor · Search -- [ ] FindWidget -- [ ] ReplaceWidget → `editor::FindWidget` -- [ ] SearchPanel -- [ ] SearchResultItem -- [ ] SearchFilters +- [x] FindWidget +- [x] ReplaceWidget → `editor::FindWidget::replace` +- [x] SearchPanel +- [x] SearchResultItem +- [x] SearchFilters ## T17d Editor · Panels -- [ ] ProblemsPanel / DiagnosticsList -- [ ] OutputPanel -- [ ] DebugConsole -- [ ] ExtensionsPanel / PluginList -- [ ] SettingsEditor — UI and JSON -- [ ] KeybindingsEditor -- [ ] ThemePreview -- [ ] WelcomePage -- [ ] RecentProjects -- [ ] ProjectSwitcher -- [ ] TaskRunner / RunConfiguration +- [x] ProblemsPanel / DiagnosticsList → `editor::ProblemsPanel` +- [x] OutputPanel +- [x] DebugConsole +- [x] ExtensionsPanel / PluginList → `editor::ExtensionsPanel` +- [x] SettingsEditor — UI and JSON +- [x] KeybindingsEditor +- [x] ThemePreview +- [x] WelcomePage +- [x] RecentProjects +- [x] ProjectSwitcher +- [x] TaskRunner / RunConfiguration → `editor::TaskRunner`, a task open to its configuration ## T17e Editor · Status items -- [ ] CursorPosition -- [ ] LanguageMode -- [ ] Encoding -- [ ] LineEnding -- [ ] IndentSettings -- [ ] BranchIndicator -- [ ] LspStatus -- [ ] NotificationBell +- [x] CursorPosition +- [x] LanguageMode +- [x] Encoding +- [x] LineEnding +- [x] IndentSettings +- [x] BranchIndicator +- [x] LspStatus +- [x] NotificationBell ## T18 Terminal — `src/terminal`