diff --git a/AGENTS.md b/AGENTS.md index 0b61e54..cbacbf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ What follows is how TuiCode implements it today. - A leader key (`Ctrl+Space` by default, rebindable like any shortcut → `CommandIds.ShowMnemonics`) opens `MnemonicView`: a modal launcher that lists the mnemonics of the commands in scope and enabled where it opened (`WorkbenchHost.MnemonicsInScope`, captured before the modal scope is pushed) and fires the command the instant the typed prefix is unambiguous — no Enter. Esc cancels, Backspace edits. - Mnemonics are **hard-coded design decisions, not user-configurable** — only the leader key is rebindable. The table lives in `CommandMnemonics` (Abstractions), keyed by command id, next to `CommandIds`. Commands with no entry (`ShowActions`, `ShowMnemonics` itself) simply don't appear. -- **Invariant: no complete mnemonic is a prefix of another** (guarded by `CommandMnemonicsTests.No_mnemonic_is_a_prefix_of_another`). That's what lets the auto-execute rule work without a terminating key. Mnemonics come in families under a shared first key with room to grow (`cf` close-file, `cot`/`cat` close other and all tabs, with `cs` reserved; `ts`/`tg`/`tc` toggle; `nf`/`nc`/`nt` and `pf`/`pc`/`pt` new/next and previous; `fs`/`fe`/`ft`/`fr`/`fu`/`f1`…`f9` focus; `gl`/`gs`/`gd`/`gp`/`gn` go to, `gu` find usages, `gb` git blame and `gh` git history; `cg` change grammar and `cc` create comment, the two `c`s that aren't close; `ff`/`fg` and `rf`/`rg` find/replace in the active file vs. globally — a trailing `f` means the active file, as in `sf`/`cf`). **A mnemonic is the command label's initials** — `fg` *Find globally*, `nt` *Next tab*, `rc` *Revert change*. Where the initials read badly or are taken, change the label rather than inventing letters ("Find globally", not "Search in files"); the standing exceptions are the trailing-`f` family, `q`/`?`, `tui`, `f1`…`f9`, and `ts`/`tg`/`tww`/`tc`/`tv`/`tmc`/`tmw`/`tfi`, which keep *Toggle*'s initials (#526). `mu`/`md` move and `du`/`dd` duplicate lines, `il`/`ol` indent and outdent them, and `tlc` toggles their line comment; `aa`/`ab` add a cursor above/below. `q`/`?` are the only single-key ones. `tui` (About, #107), `opr` (#184), `or` (#357), `pro` (#185), `gs` (#137), `cc` (#188, #189), `cot` and `cat` (#518) have no default keybinding. `ts` (toggle sidebar) flips visibility unconditionally, then settles focus against the *new* state — a freshly shown sidebar takes focus, a freshly hidden one that held focus hands it back to the editor. `fs` (focus sidebar) just shows-and-focuses. The split (#85) keeps `ts` honest from the palette/leader, where focus has already left the sidebar before the command runs — the old handler gated the *hide* on the sidebar being focused, so it never hid from those entry points. +- **Invariant: no complete mnemonic is a prefix of another** (guarded by `CommandMnemonicsTests.No_mnemonic_is_a_prefix_of_another`). That's what lets the auto-execute rule work without a terminating key. Mnemonics come in families under a shared first key with room to grow (`cf` close-file, `cot`/`cat` close other and all tabs, with `cs` reserved; `ts`/`tg`/`tc` toggle; `nf`/`nc`/`nt` and `pf`/`pc`/`pt` new/next and previous; `fs`/`fe`/`ft`/`fr`/`fu`/`f1`…`f9` focus; `gl`/`gs`/`gd`/`gp`/`gn` go to, `gi` quick info and `gu` find usages, `gb` git blame and `gh` git history; `cg` change grammar and `cc` create comment, the two `c`s that aren't close; `ff`/`fg` and `rf`/`rg` find/replace in the active file vs. globally — a trailing `f` means the active file, as in `sf`/`cf`). **A mnemonic is the command label's initials** — `fg` *Find globally*, `nt` *Next tab*, `rc` *Revert change*. Where the initials read badly or are taken, change the label rather than inventing letters ("Find globally", not "Search in files"); the standing exceptions are the trailing-`f` family, `q`/`?`, `tui`, `f1`…`f9`, and `ts`/`tg`/`tww`/`tc`/`tv`/`tmc`/`tmw`/`tfi`, which keep *Toggle*'s initials (#526). `mu`/`md` move and `du`/`dd` duplicate lines, `il`/`ol` indent and outdent them, and `tlc` toggles their line comment; `aa`/`ab` add a cursor above/below. `q`/`?` are the only single-key ones. `tui` (About, #107), `opr` (#184), `or` (#357), `pro` (#185), `gs` (#137), `cc` (#188, #189), `cot` and `cat` (#518) have no default keybinding. `ts` (toggle sidebar) flips visibility unconditionally, then settles focus against the *new* state — a freshly shown sidebar takes focus, a freshly hidden one that held focus hands it back to the editor. `fs` (focus sidebar) just shows-and-focuses. The split (#85) keeps `ts` honest from the palette/leader, where focus has already left the sidebar before the command runs — the old handler gated the *hide* on the sidebar being focused, so it never hid from those entry points. - **Narrowing the list can't make a mnemonic fire early**, so there's no second, unfiltered copy of the table to keep in step (#285). `ResolveExact` fires only on a prefix that is itself a complete mnemonic, and the table is prefix-free, so a complete mnemonic has nothing under it that hiding could reveal. The guard is `MnemonicScopeHostTests.Narrowing_the_list_never_makes_a_mnemonic_fire_a_keystroke_earlier`, over the real commands; break the prefix-free invariant and it says so. A keystroke no *in-scope* mnemonic could complete is ignored by the same rejection `OnKey` always did, over the smaller set — so there's no abort path and no message, which is what makes an out-of-scope command seem not to exist. - The matching logic is the pure, TG-free `MnemonicResolver` (`Matching` for the hint list, `ResolveExact` for the fire-now decision) so it's unit-tested directly. `MnemonicView` is a thin capture-scope shell over it — it reads keys via `KeyCaptureScope` rather than a focused TextField precisely so a command that disposes the view executes on the app key-dispatch path, not inside a TextChanged callback. @@ -429,7 +429,7 @@ What follows is how TuiCode implements it today. ## Navigation - In-editor cursor navigation (Home/End/Ctrl+arrows + Shift selection variants) is Editor's commands on TextView's keys (`CodeEditor.TextViewKeys`), not workbench bindings. -- `Ctrl+G` is a chord family (#35): `Ctrl+G L` opens `GoToLineView` (1-based `line[:col]` input), `Ctrl+G P`/`Ctrl+G N` step back/forward through cursor history, `Ctrl+G D` goes to a definition, `Ctrl+G U` finds usages, `Ctrl+G B` blames the cursor's line (see *Git*), `Ctrl+G H` shows the file's history (see *File history*), and `Ctrl+G A` shows or hides AI assistance (`ai`). `EditorTab.MoveCursor(row, col)` writes through `CodeEditor.InsertionPoint`, which is a `Point` (Column, Row) — not an int offset. Out-of-range row/col clamp. +- `Ctrl+G` is a chord family (#35): `Ctrl+G L` opens `GoToLineView` (1-based `line[:col]` input), `Ctrl+G P`/`Ctrl+G N` step back/forward through cursor history, `Ctrl+G D` goes to a definition, `Ctrl+G I` shows quick info, `Ctrl+G U` finds usages, `Ctrl+G B` blames the cursor's line (see *Git*), `Ctrl+G H` shows the file's history (see *File history*), and `Ctrl+G A` shows or hides AI assistance (`ai`). `EditorTab.MoveCursor(row, col)` writes through `CodeEditor.InsertionPoint`, which is a `Point` (Column, Row) — not an int offset. Out-of-range row/col clamp. - **Every explicit jump ends in `EditorTab.RevealLines`** (#200): go to line, go to symbol, Back/Forward, a Find result, `tuicode file.cs:42` and `Enter` in a diff. It scrolls so the range's first line sits `Reveal.Margin` (2) rows below the top, or below the sticky lines, or not at all when the range is already in view with that margin at both ends — so a hop to something you can see doesn't move the screen. `Reveal` (TG-free, in `TuiCode.Editor`) is the whole of the calculation and owns the margin the diff pane reads too. It runs *after* the cursor move, since TG's `ScrollToMakeCursorVisible` would otherwise have the last word, and does nothing before the first layout (height 0). Ordinary movement — arrows, PgUp/PgDn, find-next, next-occurrence — keeps TG's minimal scrolling. The gutter follows for free, off `ViewportChanged`. - **A jump never carries a selection** (#360): `EditorTab.MoveCursor` drops extra cursors and the selection before it moves, and `Select` (Find results) starts from no selection, so the match is all that's selected. - Cursor location history (#35) is `CursorLocationHistory` (`Navigation/`) — a pure, TG-free back/forward stack, unit-tested directly like `MnemonicResolver`. `WorkbenchHost` feeds it: within-file moves arrive via `EditorGroup.CursorMoved` (re-raised from `CodeEditor.CursorMoved`, in file coordinates), file switches via `ActiveTabChanged`. The history's own heuristic decides what counts as a navigable **jump** vs. **drift**: a jump is a different file, a row delta ≥ threshold (default 10), or an explicit jump (Go-to-line passes `explicitJump: true` so even a short hop records). Drift (small same-file moves) just updates the current entry so a later jump's Back target is where the user actually was — typing and single-arrow steps never fill the stack. A jump truncates the forward/redo path (browser semantics). Driving the cursor on Back/Forward/Go-to-line sets `_suppressHistory` so those programmatic moves aren't re-recorded. No key interception is needed (the event is editor-scoped), so the explorer/settings/TreeView are untouched — the two concerns #35 flagged both dissolve. @@ -447,6 +447,7 @@ What follows is how TuiCode implements it today. - **Follow link** (`fl`, no default key, `Editor` scope, #466) is enabled only while the cursor is on a link (`Links.At`, pure): `http`, `https` or `mailto`, trailing punctuation and unbalanced closing brackets trimmed, and a Markdown `[text](url)` from anywhere in it. `SystemBrowser` launches `open`/`xdg-open` with its output redirected so it can't write over the screen, or the shell on Windows. Over SSH (`IEnvironment.IsOverSsh`) it copies the link instead, through the editor's own copy, so `Ctrl+V` pastes it. Tests swap `WorkbenchHost.Browser` for a fake. - The status bar's `Ln X, Col Y` (right-aligned, #120) is polled on `IApplication.Iteration` (`Workbench.ShowCursorPosition`), not driven by `CursorMoved`: that comes from keys and the mouse only, so programmatic moves (find, Go-to-line, multi-caret, line moves) go unreported. `StatusBarPart.SetPosition` no-ops when unchanged, so polling costs a tuple compare. Col counts the model's grapheme cells, so a tab is one column. - **Go to definition** (`Ctrl+G D`, `gd`, `Editor` scope, #454) asks the file's language server (see *Language servers*). One answer jumps there the way Go to line does, recorded as an explicit jump; several (a partial class) open `DefinitionPickerView`, the shape of Go to symbol's; none is a status-bar line. Places that aren't files on disk (metadata) are dropped. Only the latest request jumps, so a slow answer to an earlier press is ignored. The command stays enabled without a ready server, so the key and the palette can say what's missing; only the menu dims it until the server is ready (`IsAvailableFrom`), the one exception to the menu following the palette (`MenuBarHostTests`). +- **Quick info** (`Ctrl+G I`, `gi`, `Editor` scope, #591) asks the server for `textDocument/hover` and shows the answer in `QuickInfoView`, a box under the caret's line, or above it when there's more room there (`QuickInfo.Place`), at most `QuickInfo.MaxHeight` rows and never wider than the text. `LspHover` reads every shape the protocol allows: fenced code is the signature, coloured by the fence's grammar or else the file's (`CodeBlockColours`, shared with the chat), and the rest is prose, wrapped at `QuickInfo.ProseWidth` unless the code is wider, with markdown escapes, inline-code backticks and rules taken out. The box never takes focus: its keys are a `LayeredScope` over whatever had them, so `Esc` closes it, `Enter` closes it and runs Go to definition, the arrows and page keys scroll it, and any other key closes it and goes on to the editor; `F1` leaves it open and lists them (`QuickInfoKeys`). Its buttons sit on its bottom border and lose their labels, down to just the keys, when the box is too narrow for them (`FitButtons`). A click, a scroll or a resize that moves the caret's cell closes it on the next iteration, once its scope is on top again. Its rules otherwise follow `gd`'s: only the latest request shows, it's dimmed in the menu until the server is ready, and it says the same thing without one; an empty answer is `Nothing to show here`. - **Find usages** (`Ctrl+G U`, `gu`, `Editor` scope, #455) asks the server for `textDocument/references` without the declaration, and lists them in the sidebar's **Usages** tab (`UsagesView`), grouped by file by the pure `UsageTree`: files in path order, each usage its line number and trimmed text, cut with an ellipsis as it's drawn. Its header names the symbol after its type (`Usages of LineDiff.Hunks`), from the definition's `textDocument/documentSymbol` (`LspSymbols`), falling back to the word under the caret. Only the latest request fills the tab, a Find search never touches it, and `Enter` jumps as Go to definition does. `Ctrl+Shift+U` (`fu`, Focus usages) goes back to them without asking again. It's dimmed in the menu like `gd`, and says the same thing without a server. - macOS gotcha: by default Mission Control's "Move left/right a space" eats `Ctrl+Left`/`Ctrl+Right` before iTerm2 sees them. Disable in System Settings → Keyboard → Keyboard Shortcuts → Mission Control. diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index 06c0108..993d09d 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -89,6 +89,13 @@ public static class CommandIds public const string SymbolPickerPageDown = "symbolPicker.action.pageDown"; public const string GoToDefinition = "workbench.action.goToDefinition"; + public const string QuickInfo = "workbench.action.quickInfo"; + public const string QuickInfoClose = "quickInfo.action.close"; + public const string QuickInfoGoToDefinition = "quickInfo.action.goToDefinition"; + public const string QuickInfoUp = "quickInfo.action.up"; + public const string QuickInfoDown = "quickInfo.action.down"; + public const string QuickInfoPageUp = "quickInfo.action.pageUp"; + public const string QuickInfoPageDown = "quickInfo.action.pageDown"; public const string FindUsages = "workbench.action.findUsages"; public const string AiAssistance = "workbench.action.aiAssistance"; public const string AssistantSend = "assistant.action.send"; diff --git a/src/TuiCode.Abstractions/CommandMnemonics.cs b/src/TuiCode.Abstractions/CommandMnemonics.cs index be893cb..53fd458 100644 --- a/src/TuiCode.Abstractions/CommandMnemonics.cs +++ b/src/TuiCode.Abstractions/CommandMnemonics.cs @@ -96,6 +96,7 @@ private static IReadOnlyDictionary Build() [CommandIds.GoToSymbol] = "gs", [CommandIds.GoToMatchingBracket] = "gm", [CommandIds.GoToDefinition] = "gd", + [CommandIds.QuickInfo] = "gi", [CommandIds.FindUsages] = "gu", [CommandIds.AiAssistance] = "ai", [CommandIds.NavigateBack] = "gp", diff --git a/src/TuiCode.Abstractions/IInputScopeStack.cs b/src/TuiCode.Abstractions/IInputScopeStack.cs index 37d5a17..d1ad697 100644 --- a/src/TuiCode.Abstractions/IInputScopeStack.cs +++ b/src/TuiCode.Abstractions/IInputScopeStack.cs @@ -17,6 +17,9 @@ public interface IInputScopeStack /// void Pop(IKeybindingService scope); + /// The scope receiving keys, or null while the stack is empty. + IKeybindingService? Top { get; } + /// Delegate handling to the top-of-stack scope. KeyHandlingResult Handle(Key key); } diff --git a/src/TuiCode.Editor/CodeEditor.cs b/src/TuiCode.Editor/CodeEditor.cs index eb3446c..f5ff768 100644 --- a/src/TuiCode.Editor/CodeEditor.cs +++ b/src/TuiCode.Editor/CodeEditor.cs @@ -588,6 +588,9 @@ private int ColumnsBefore(string[] line, int column) return columns; } + /// The primary caret's screen cell, or null while it's out of view. + public Point? CaretOnScreen => ViewportPosition(new Caret(InsertionPoint)) is { } point ? ViewportToScreen(point) : null; + // Editor places the cursor only in its own draw, which this replaces. private void PlaceCursor() { diff --git a/src/TuiCode.Editor/EditorTab.cs b/src/TuiCode.Editor/EditorTab.cs index ff48e73..780101c 100644 --- a/src/TuiCode.Editor/EditorTab.cs +++ b/src/TuiCode.Editor/EditorTab.cs @@ -402,6 +402,12 @@ public void MoveCursorToCharacter(int row, int character) /// A screen row while wrapped. public int TopRow => _text.Viewport.Y; + /// The primary caret's screen cell, or null while it's out of view. + public System.Drawing.Point? CaretOnScreen => _text.CaretOnScreen; + + /// Where the text shows on screen, less the gutter and scroll bars. + public System.Drawing.Rectangle TextOnScreen => _text.ViewportToScreen(new System.Drawing.Rectangle(System.Drawing.Point.Empty, _text.Viewport.Size)); + public int VisibleRows => _text.Viewport.Height; /// diff --git a/src/TuiCode.Workbench/Assistant/ChatView.cs b/src/TuiCode.Workbench/Assistant/ChatView.cs index 151d3cc..9cde109 100644 --- a/src/TuiCode.Workbench/Assistant/ChatView.cs +++ b/src/TuiCode.Workbench/Assistant/ChatView.cs @@ -1,6 +1,5 @@ -using Terminal.Gui.Text; -using TuiCode.Editor; using TuiCode.Syntax; +using TuiCode.Workbench.Controls; using TuiCode.Workbench.Settings; using Attribute = Terminal.Gui.Drawing.Attribute; @@ -9,12 +8,8 @@ namespace TuiCode.Workbench.Assistant; /// The AI assistance tab's transcript: laid out to the width, scrolled, code fences coloured. public sealed class ChatView : View { - private static readonly TimeSpan SyntaxBudget = TimeSpan.FromMilliseconds(15); - private readonly Conversation _conversation; - private readonly SyntaxHighlighter? _syntax; - private readonly TokenPalette _palette = new(); - private readonly Dictionary _caches = []; + private readonly CodeBlockColours _code; private IReadOnlyList _lines = []; private IReadOnlyList _blocks = []; private (int Version, int Width) _laidOut = (-1, -1); @@ -22,7 +17,7 @@ public sealed class ChatView : View public ChatView(Conversation conversation, SyntaxHighlighter? syntax = null) { _conversation = conversation; - _syntax = syntax; + _code = new CodeBlockColours(syntax); CanFocus = true; ViewportSettings |= ViewportSettingsFlags.HasVerticalScrollBar; VerticalScrollBar.VisibilityMode = ScrollBarVisibilityMode.Auto; @@ -70,7 +65,7 @@ private void LayOut() if (_laidOut == (_conversation.Version, TextWidth)) return; _laidOut = (_conversation.Version, TextWidth); (_lines, _blocks) = _conversation.Render(TextWidth); - foreach (var index in _caches.Keys.Where(i => i >= _blocks.Count).ToList()) _caches.Remove(index); + _code.Keep(_blocks.Count); SetContentSize(new System.Drawing.Size(Math.Max(Viewport.Width, 1), Math.Max(_lines.Count, 1))); } @@ -101,7 +96,7 @@ protected override bool OnDrawingContent(DrawContext? context) LayOut(); var normal = GetAttributeForRole(VisualRole.Normal); var mine = RowStripes.Stripe(normal); - if (_syntax is not null) _palette.Sync(_syntax); + _code.BeginDraw(); var lexing = false; for (var row = 0; row < Viewport.Height; row++) { @@ -138,45 +133,9 @@ protected override bool OnDrawingContent(DrawContext? context) return true; } - // Lexes the block through this line within the budget; true while it still has more to lex. private bool DrawCode(ChatLine line, int row, Attribute normal) { - var tokens = TokensFor(line.Block, line.Line, out var pending); - var chars = line.Offset; - var token = 0; - var col = 0; - foreach (var grapheme in GraphemeHelper.GetGraphemes(line.Text)) - { - var attribute = normal; - if (tokens is { Length: > 0 }) - { - while (token + 2 < tokens.Length && tokens[token + 2] <= chars) token += 2; - attribute = _palette.Apply(normal, tokens[token + 1]); - } - chars += grapheme.Length; - SetAttribute(attribute); - AddStr(col, row, grapheme); - col += Math.Max(grapheme.GetColumns(), 1); - } - return pending; - } - - private int[]? TokensFor(int block, int line, out bool pending) - { - pending = false; - if (_syntax is null || block < 0 || block >= _blocks.Count) return null; - if (!_caches.TryGetValue(block, out var cache)) - _caches[block] = cache = _syntax.CreateCache(Language(_blocks[block].Language)); - if (cache is null) return null; - cache.Update(_blocks[block].Lines); - pending = !cache.TokenizeThrough(line, SyntaxBudget); - return cache.TokensFor(line); - } - - private SyntaxLanguage? Language(string fence) - { - if (_syntax is null || fence.Length == 0) return null; - var name = fence.Split(' ', 2)[0]; - return _syntax.LanguageById(name) ?? _syntax.LanguageForFile("file." + name); + var block = line.Block >= 0 && line.Block < _blocks.Count ? _blocks[line.Block] : null; + return _code.Draw(this, block is null ? -1 : line.Block, () => _code.ForFence(block!.Language), block?.Lines ?? [], line.Line, line.Text, line.Offset, 0, row, normal); } } diff --git a/src/TuiCode.Workbench/Controls/CodeBlockColours.cs b/src/TuiCode.Workbench/Controls/CodeBlockColours.cs new file mode 100644 index 0000000..50fb3bd --- /dev/null +++ b/src/TuiCode.Workbench/Controls/CodeBlockColours.cs @@ -0,0 +1,70 @@ +using Terminal.Gui.Text; +using TuiCode.Editor; +using TuiCode.Syntax; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace TuiCode.Workbench.Controls; + +/// Colours the rows of code blocks shown outside an editor, lexing each block lazily within a frame's budget. +internal sealed class CodeBlockColours(SyntaxHighlighter? syntax) +{ + private static readonly TimeSpan SyntaxBudget = TimeSpan.FromMilliseconds(15); + + private readonly TokenPalette _palette = new(); + private readonly Dictionary _caches = []; + + public SyntaxHighlighter? Syntax => syntax; + + public void BeginDraw() + { + if (syntax is not null) _palette.Sync(syntax); + } + + /// Drops the caches of blocks from on, after the blocks were laid out again. + public void Keep(int count) + { + foreach (var index in _caches.Keys.Where(i => i >= count).ToList()) _caches.Remove(index); + } + + /// The grammar a fence names, by id or as an extension. + public SyntaxLanguage? ForFence(string fence) + { + if (syntax is null || fence.Length == 0) return null; + var name = fence.Split(' ', 2)[0]; + return syntax.LanguageById(name) ?? syntax.LanguageForFile("file." + name); + } + + /// Draws , chars into line of block ; true while the block has more to lex. + public bool Draw(View view, int block, Func language, IReadOnlyList lines, int line, string text, int offset, int col, int row, Attribute normal) + { + var tokens = TokensFor(block, language, lines, line, out var pending); + var chars = offset; + var token = 0; + foreach (var grapheme in GraphemeHelper.GetGraphemes(text)) + { + var attribute = normal; + if (tokens is { Length: > 0 }) + { + while (token + 2 < tokens.Length && tokens[token + 2] <= chars) token += 2; + attribute = _palette.Apply(normal, tokens[token + 1]); + } + chars += grapheme.Length; + view.SetAttribute(attribute); + view.AddStr(col, row, grapheme); + col += Math.Max(grapheme.GetColumns(), 1); + } + return pending; + } + + private int[]? TokensFor(int block, Func language, IReadOnlyList lines, int line, out bool pending) + { + pending = false; + if (syntax is null || block < 0) return null; + if (!_caches.TryGetValue(block, out var cache)) + _caches[block] = cache = syntax.CreateCache(language()); + if (cache is null) return null; + cache.Update(lines); + pending = !cache.TokenizeThrough(line, SyntaxBudget); + return cache.TokensFor(line); + } +} diff --git a/src/TuiCode.Workbench/Languages/LanguageServer.cs b/src/TuiCode.Workbench/Languages/LanguageServer.cs index 6bb6fb9..309b609 100644 --- a/src/TuiCode.Workbench/Languages/LanguageServer.cs +++ b/src/TuiCode.Workbench/Languages/LanguageServer.cs @@ -128,6 +128,7 @@ private JsonObject InitializeParams() ["synchronization"] = new JsonObject { ["didSave"] = true }, ["definition"] = new JsonObject { ["linkSupport"] = true }, ["documentSymbol"] = new JsonObject { ["hierarchicalDocumentSymbolSupport"] = true }, + ["hover"] = new JsonObject { ["contentFormat"] = new JsonArray("markdown", "plaintext") }, }, }, }; @@ -240,6 +241,10 @@ public bool IsOpen(string path) public async Task> DefinitionAsync(string path, int line, int character) => LspLocations.Parse(await RequestAsync("textDocument/definition", Position(path, line, character)).ConfigureAwait(false)); + /// What the server says the symbol at and UTF-16 is. + public async Task> HoverAsync(string path, int line, int character) => + LspHover.Parse(await RequestAsync("textDocument/hover", Position(path, line, character)).ConfigureAwait(false)); + /// Where the symbol at and UTF-16 is used, leaving out its declaration. public async Task> ReferencesAsync(string path, int line, int character) { diff --git a/src/TuiCode.Workbench/Languages/LspHover.cs b/src/TuiCode.Workbench/Languages/LspHover.cs new file mode 100644 index 0000000..4514962 --- /dev/null +++ b/src/TuiCode.Workbench/Languages/LspHover.cs @@ -0,0 +1,89 @@ +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace TuiCode.Workbench.Languages; + +/// A piece of a hover answer: code in (empty when the server didn't say), or prose when that's null. +public sealed record HoverPart(string? Language, string Text) +{ + public bool IsCode => Language is not null; +} + +public static partial class LspHover +{ + /// A hover result's contents, of any shape the protocol allows, as code and prose; empty when there's nothing to show. + public static IReadOnlyList Parse(JsonNode? result) + { + var parts = new List(); + Add(parts, result?["contents"]); + return [.. parts.Where(p => !string.IsNullOrWhiteSpace(p.Text))]; + } + + private static void Add(List parts, JsonNode? contents) + { + switch (contents) + { + case JsonValue value when value.TryGetValue(out var markdown): + Markdown(parts, markdown); + break; + case JsonArray array: + foreach (var item in array) Add(parts, item); + break; + case JsonObject markup when markup["kind"]?.GetValue() is { } kind: + var text = markup["value"]?.GetValue() ?? ""; + if (kind == "markdown") Markdown(parts, text); + else parts.Add(new HoverPart(null, text.ReplaceLineEndings("\n").Trim('\n'))); + break; + case JsonObject marked: + parts.Add(new HoverPart(marked["language"]?.GetValue() ?? "", (marked["value"]?.GetValue() ?? "").ReplaceLineEndings("\n").Trim('\n'))); + break; + } + } + + // Fenced code becomes code; the rest is prose, with escapes and rules taken out. + private static void Markdown(List parts, string markdown) + { + var prose = new StringBuilder(); + List? code = null; + var language = ""; + foreach (var line in markdown.ReplaceLineEndings("\n").Split('\n')) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("```", StringComparison.Ordinal)) + { + if (code is null) + { + Flush(parts, prose); + code = []; + language = trimmed.TrimStart('`').Trim(); + } + else + { + parts.Add(new HoverPart(language, string.Join('\n', code).Trim('\n'))); + code = null; + } + continue; + } + if (code is not null) code.Add(line); + else prose.Append(Rule().IsMatch(line) ? "" : Escape().Replace(InlineCode().Replace(line, "$2"), "$1")).Append('\n'); + } + if (code is not null) parts.Add(new HoverPart(language, string.Join('\n', code).Trim('\n'))); + Flush(parts, prose); + } + + private static void Flush(List parts, StringBuilder prose) + { + if (prose.Length > 0) parts.Add(new HoverPart(null, prose.ToString().Trim('\n'))); + prose.Clear(); + } + + [GeneratedRegex(@"^ {0,3}([-*_])( *\1){2,} *$")] + private static partial Regex Rule(); + + [GeneratedRegex(@"(`+)(.+?)\1")] + private static partial Regex InlineCode(); + + [GeneratedRegex(@"\\([!-/:-@\[-`{-~])")] + private static partial Regex Escape(); +} diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index 3ebb39b..4d70fa9 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -54,7 +54,7 @@ .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), [CommandIds.JoinGroups] = 'j', [CommandIds.ToggleFileHistory] = 'i', [CommandIds.AiAssistance] = 'a', - [CommandIds.GoToLine] = 'g', [CommandIds.GoToSymbol] = 's', [CommandIds.GoToMatchingBracket] = 'm', [CommandIds.GoToDefinition] = 'd', + [CommandIds.GoToLine] = 'g', [CommandIds.GoToSymbol] = 's', [CommandIds.GoToMatchingBracket] = 'm', [CommandIds.GoToDefinition] = 'd', [CommandIds.QuickInfo] = 'i', [CommandIds.FindUsages] = 'f', [CommandIds.FollowLink] = 'l', [CommandIds.NavigateBack] = 'p', [CommandIds.NavigateForward] = 'n', [CommandIds.NextEditor] = 't', [CommandIds.PreviousEditor] = 'r', @@ -117,7 +117,7 @@ public static readonly (string Title, string[] Ids)[] Layout = ]), ("_Go", [ - CommandIds.GoToLine, CommandIds.GoToSymbol, CommandIds.GoToMatchingBracket, CommandIds.GoToDefinition, CommandIds.FindUsages, CommandIds.FollowLink, + CommandIds.GoToLine, CommandIds.GoToSymbol, CommandIds.GoToMatchingBracket, CommandIds.GoToDefinition, CommandIds.QuickInfo, CommandIds.FindUsages, CommandIds.FollowLink, Separator, CommandIds.NavigateBack, CommandIds.NavigateForward, Separator, diff --git a/src/TuiCode.Workbench/Navigation/QuickInfo.cs b/src/TuiCode.Workbench/Navigation/QuickInfo.cs new file mode 100644 index 0000000..0750fc2 --- /dev/null +++ b/src/TuiCode.Workbench/Navigation/QuickInfo.cs @@ -0,0 +1,116 @@ +using System.Drawing; +using System.Globalization; +using Terminal.Gui.Text; +using TuiCode.Editor; +using TuiCode.Workbench.Languages; + +namespace TuiCode.Workbench.Navigation; + +/// A row of the box: prose, or row chars into line of code block . +public sealed record QuickInfoRow(string Text, int Block = -1, int Line = -1, int Offset = 0) +{ + public bool IsCode => Block >= 0; +} + +public sealed record QuickInfoBlock(string Language, IReadOnlyList Lines); + +/// Quick info's answer laid out for its box: the signature's code rows, then the doc comment wrapped. +public sealed record QuickInfo(IReadOnlyList Rows, IReadOnlyList Blocks) +{ + public const int MaxHeight = 12; + + // Prose wraps at a reading width unless the code is already wider. + public const int ProseWidth = 80; + + private const int TabWidth = 4; + + /// Border and a column of space either side. + public const int Frame = 4; + + public int Width => Rows.Count == 0 ? 0 : Rows.Max(r => r.Text.GetColumns()); + + public static QuickInfo Lay(IReadOnlyList parts, int maxWidth) + { + maxWidth = Math.Max(maxWidth, 1); + var code = parts.Where(p => p.IsCode).SelectMany(p => p.Text.Split('\n')).Select(l => Expand(l).GetColumns()).DefaultIfEmpty(0).Max(); + var proseWidth = Math.Min(maxWidth, Math.Max(code, ProseWidth)); + var rows = new List(); + var blocks = new List(); + foreach (var part in parts) + { + if (rows.Count > 0) rows.Add(new QuickInfoRow("")); + if (part.IsCode) + { + var lines = part.Text.Split('\n').Select(Expand).ToList(); + var block = blocks.Count; + blocks.Add(new QuickInfoBlock(part.Language!, lines)); + for (var line = 0; line < lines.Count; line++) WrapCode(rows, block, line, lines[line], maxWidth); + continue; + } + var blank = false; + foreach (var line in part.Text.Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) + { + if (!blank) rows.Add(new QuickInfoRow("")); + blank = true; + continue; + } + blank = false; + Wrap(rows, Expand(line).TrimEnd(), proseWidth); + } + } + return new QuickInfo(rows, blocks); + } + + /// + /// Where the box goes in : under the caret's row if it fits, else above when there's more room + /// there, as wide as asks and at most rows. All in screen cells. + /// + public static Rectangle Place(Rectangle editor, Point caret, int width, int height) + { + height = Math.Min(height, MaxHeight); + var below = editor.Bottom - caret.Y - 1; + var above = caret.Y - editor.Top; + var top = height <= below || below >= above ? caret.Y + 1 : caret.Y - Math.Min(height, above); + height = Math.Max(Math.Min(height, top > caret.Y ? below : above), 0); + width = Math.Min(width, editor.Width); + var left = Math.Clamp(caret.X, editor.Left, Math.Max(editor.Right - width, editor.Left)); + return new Rectangle(left, top, width, height); + } + + private static string Expand(string line) => line.Replace("\t", new string(' ', TabWidth)); + + private static void Wrap(List rows, string text, int width) + { + var graphemes = Graphemes(text); + var starts = WrapLayout.RowStarts(graphemes, width, TabWidth); + for (var row = 0; row < starts.Length; row++) + { + var end = row + 1 < starts.Length ? starts[row + 1] : graphemes.Count; + rows.Add(new QuickInfoRow(string.Concat(graphemes.Skip(starts[row]).Take(end - starts[row])).TrimEnd())); + } + } + + private static void WrapCode(List rows, int block, int line, string text, int width) + { + var graphemes = Graphemes(text); + var starts = WrapLayout.RowStarts(graphemes, width, TabWidth); + var offset = 0; + for (var row = 0; row < starts.Length; row++) + { + var end = row + 1 < starts.Length ? starts[row + 1] : graphemes.Count; + var piece = string.Concat(graphemes.Skip(starts[row]).Take(end - starts[row])); + rows.Add(new QuickInfoRow(piece.TrimEnd(), block, line, offset)); + offset += piece.Length; + } + } + + private static List Graphemes(string text) + { + var result = new List(text.Length); + var enumerator = StringInfo.GetTextElementEnumerator(text); + while (enumerator.MoveNext()) result.Add(enumerator.GetTextElement()); + return result; + } +} diff --git a/src/TuiCode.Workbench/Navigation/QuickInfoView.cs b/src/TuiCode.Workbench/Navigation/QuickInfoView.cs new file mode 100644 index 0000000..84de4db --- /dev/null +++ b/src/TuiCode.Workbench/Navigation/QuickInfoView.cs @@ -0,0 +1,136 @@ +using Terminal.Gui.Text; +using TuiCode.Abstractions; +using TuiCode.Syntax; +using TuiCode.Workbench.Controls; +using TuiCode.Workbench.Services; +using ButtonKind = MentalDesk.Tui.Dialogs.ButtonKind; + +namespace TuiCode.Workbench.Navigation; + +/// Quick info's box under the caret's line. It never takes focus: the host feeds it its keys and closes it. +public sealed class QuickInfoView : View +{ + private readonly QuickInfo _info; + private readonly Func _language; + private readonly CodeBlockColours _code; + private readonly ICommandService _scopeCommands; + private readonly IKeybindingService _scopeKeybindings; + private readonly Button _goTo; + private readonly Button _close; + + public IKeybindingService Scope => _scopeKeybindings; + + public event EventHandler? Cancelled; + + public event EventHandler? Submitted; + + /// Colours code whose fence names no grammar we have. + public QuickInfoView(QuickInfo info, SyntaxHighlighter? syntax, SyntaxLanguage? fileGrammar) + { + _info = info; + _code = new CodeBlockColours(syntax); + _language = fence => _code.ForFence(fence) ?? fileGrammar; + Title = "Quick info"; + BorderStyle = LineStyle.Single; + CanFocus = false; + ViewportSettings |= ViewportSettingsFlags.HasVerticalScrollBar; + VerticalScrollBar.VisibilityMode = ScrollBarVisibilityMode.Auto; + + _scopeCommands = new CommandService(); + _scopeKeybindings = new KeybindingService(_scopeCommands); + _scopeCommands.Register(CommandIds.QuickInfoClose, () => Cancelled?.Invoke(this, EventArgs.Empty)); + _scopeCommands.Register(CommandIds.QuickInfoGoToDefinition, () => Submitted?.Invoke(this, EventArgs.Empty)); + _scopeCommands.Register(CommandIds.QuickInfoUp, () => ScrollBy(-1)); + _scopeCommands.Register(CommandIds.QuickInfoDown, () => ScrollBy(1)); + _scopeCommands.Register(CommandIds.QuickInfoPageUp, () => ScrollBy(-PageHeight)); + _scopeCommands.Register(CommandIds.QuickInfoPageDown, () => ScrollBy(PageHeight)); + _scopeKeybindings.Bind("Esc", CommandIds.QuickInfoClose); + _scopeKeybindings.Bind("Enter", CommandIds.QuickInfoGoToDefinition); + _scopeKeybindings.Bind("CursorUp", CommandIds.QuickInfoUp); + _scopeKeybindings.Bind("CursorDown", CommandIds.QuickInfoDown); + _scopeKeybindings.Bind("PageUp", CommandIds.QuickInfoPageUp); + _scopeKeybindings.Bind("PageDown", CommandIds.QuickInfoPageDown); + + _goTo = Keyed(ButtonKind.Primary, CommandIds.QuickInfoGoToDefinition); + _close = Keyed(ButtonKind.Secondary, CommandIds.QuickInfoClose); + _close.X = Pos.AnchorEnd() - 2; + _close.Y = _goTo.Y = Pos.AnchorEnd(1); + FitButtons(int.MaxValue); + Border.GetOrCreateView().Add(_goTo, _close); + SetContentSize(new System.Drawing.Size(1, Math.Max(info.Rows.Count, 1))); + } + + /// The width both buttons and the border between and around them need. + public int ButtonsWidth => _goTo.Text.GetColumns() + _close.Text.GetColumns() + 6; + + /// The box's size for its content, border and padding, less the scroll bar it needs if it ends up shorter. + public (int Width, int Height) Wanted => (Math.Max(_info.Width + QuickInfo.Frame, ButtonsWidth), _info.Rows.Count + 2); + + internal int Top => Viewport.Y; + + internal IReadOnlyList Rows => [.. _info.Rows.Select(r => r.Text)]; + + internal Button GoToButton => _goTo; + + internal Button CloseButton => _close; + + private static readonly (string GoTo, string Close)[] Labels = [("Go to definition", "Close"), ("Definition", "Close"), ("", "")]; + + /// Shortens the buttons' labels, down to just their keys, until both fit a box wide. + public void FitButtons(int width) + { + foreach (var (goTo, close) in Labels) + { + _goTo.Text = $" {KeyedButton.Caption(_scopeKeybindings, CommandIds.QuickInfoGoToDefinition, goTo).TrimEnd()} "; + _close.Text = $" {KeyedButton.Caption(_scopeKeybindings, CommandIds.QuickInfoClose, close).TrimEnd()} "; + if (ButtonsWidth <= width) break; + } + _goTo.X = Pos.Left(_close) - _goTo.Text.GetColumns() - 2; + } + + private Button Keyed(ButtonKind kind, string commandId) + { + var button = KeyedButton.Of(kind, _scopeKeybindings, commandId, ""); + button.CanFocus = false; + button.Accepting += (_, e) => + { + e.Handled = true; + _scopeCommands.TryExecute(commandId); + }; + return button; + } + + private int PageHeight => Math.Max(Viewport.Height - 1, 1); + + private void ScrollBy(int rows) + { + var top = Math.Clamp(Viewport.Y + rows, 0, Math.Max(_info.Rows.Count - Viewport.Height, 0)); + if (top != Viewport.Y) Viewport = Viewport with { Y = top }; + } + + protected override bool OnDrawingContent(DrawContext? context) + { + var normal = GetAttributeForRole(VisualRole.Normal); + _code.BeginDraw(); + var lexing = false; + for (var row = 0; row < Viewport.Height; row++) + { + var index = Viewport.Y + row; + SetAttribute(normal); + Move(0, row); + AddStr(new string(' ', Viewport.Width)); + if (index >= _info.Rows.Count) continue; + var line = _info.Rows[index]; + if (!line.IsCode) + { + Move(1, row); + AddStr(line.Text); + continue; + } + var block = _info.Blocks[line.Block]; + lexing |= _code.Draw(this, line.Block, () => _language(block.Language), block.Lines, line.Line, line.Text, line.Offset, 1, row, normal); + } + if (lexing) App?.Invoke(SetNeedsDraw); + return true; + } +} diff --git a/src/TuiCode.Workbench/Services/InputScopeStack.cs b/src/TuiCode.Workbench/Services/InputScopeStack.cs index 7258594..fef402c 100644 --- a/src/TuiCode.Workbench/Services/InputScopeStack.cs +++ b/src/TuiCode.Workbench/Services/InputScopeStack.cs @@ -23,6 +23,8 @@ public void Pop(IKeybindingService scope) _stack.Pop(); } + public IKeybindingService? Top => _stack.Count > 0 ? _stack.Peek() : null; + public KeyHandlingResult Handle(Key key) => _stack.Count == 0 ? KeyHandlingResult.Pass : _stack.Peek().Handle(key); } diff --git a/src/TuiCode.Workbench/Services/LayeredScope.cs b/src/TuiCode.Workbench/Services/LayeredScope.cs index d7d529c..42b126f 100644 --- a/src/TuiCode.Workbench/Services/LayeredScope.cs +++ b/src/TuiCode.Workbench/Services/LayeredScope.cs @@ -13,8 +13,10 @@ internal sealed class LayeredScope : IKeybindingService private readonly IKeybindingService _own; private readonly IKeybindingService _below; private readonly Func _accepts; + private readonly Action? _passing; - public LayeredScope(IKeybindingService own, IKeybindingService below, Func accepts) + /// Called with each key the layer lets through, before the scope beneath sees it. + public LayeredScope(IKeybindingService own, IKeybindingService below, Func accepts, Action? passing = null) { ArgumentNullException.ThrowIfNull(own); ArgumentNullException.ThrowIfNull(below); @@ -22,6 +24,7 @@ public LayeredScope(IKeybindingService own, IKeybindingService below, Func Panel.History; /// A dialog is open over the workbench: every view the host adds beside the five parts is one. - public bool HasDialog => SubViews.Any(view => view != MenuBar && view != Sidebar && view != Editor && view != Panel && view != StatusBar); + public bool HasDialog => SubViews.Any(view => view != MenuBar && view != Sidebar && view != Editor && view != Panel && view != StatusBar && view is not QuickInfoView); /// The user has the History panel on (#534); it shows only while the active tab has history. public bool IsHistoryOn { get; private set; } diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 42da377..04569ec 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -103,6 +103,10 @@ public sealed class WorkbenchHost : IDisposable // Only the latest Go to definition jumps, however the answers come back. private int _definitionRequest; private int _usagesRequest; + private QuickInfoView? _activeQuickInfo; + private LayeredScope? _quickInfoScope; + private (EditorTab Tab, System.Drawing.Point? Caret, System.Drawing.Rectangle Text) _quickInfoAt; + private int _quickInfoRequest; private IFileSystem? _usagesFileSystem; private GrammarPickerView? _activeGrammarPicker; private DiagnosticsView? _activeDiagnostics; @@ -703,6 +707,7 @@ private void RegisterDefaultCommands() _commands.Register(CommandIds.GoToMatchingBracket, "Go to matching bracket", GoToMatchingBracket, CommandScope.Editor, FileOpen); // Enabled without a ready server, so the key can say what's missing; the menu dims it (IsAvailableFrom). _commands.Register(CommandIds.GoToDefinition, "Go to definition", GoToDefinition, CommandScope.Editor, FileOpen); + _commands.Register(CommandIds.QuickInfo, "Quick info", ShowQuickInfo, CommandScope.Editor, FileOpen); _commands.Register(CommandIds.FindUsages, "Find usages", FindUsages, CommandScope.Editor, FileOpen); // Global and ungated, so with no agent installed it can say so (#566). _commands.Register(CommandIds.AiAssistance, "AI assistance", AskAssistant); @@ -808,7 +813,7 @@ public void ApplyKeybindings(IEnumerable overrides) private KeyColumn? PlaceHelp() { Reconcile(); - var help = _focus.Region switch + var help = _activeQuickInfo is not null ? HelpSheet.Place("Quick info", QuickInfoKeys) : _focus.Region switch { FocusRegion.FindBar => _find.Help(FindOptionKeys()), FocusRegion.Diff => DiffHelp(), @@ -902,6 +907,12 @@ .. KeyRowOf(CommandIds.ToggleIgnoredFiles, "Ignored files on or off"), private static readonly KeyRow[] AssistantKeys = [new("↑ ↓ PgUp PgDn", "Scroll the chat")]; + private static readonly KeyRow[] QuickInfoKeys = + [ + new("Esc", "Close"), new("Enter", "Go to definition"), new("↑ ↓ PgUp PgDn", "Scroll"), + new("Any other key", "Close, then act in the editor"), + ]; + private static readonly KeyRow[] UsagesKeys = [new("Enter", "Go to the usage"), new("↑ ↓", "Step through usages"), new("→ ←", "Expand or collapse")]; /// A region's keys for F1: its fixed keys, then each command in its scope that's bound and can run now. @@ -1024,6 +1035,7 @@ private static void BindDefaults(IKeybindingService keybindings) keybindings.Bind("Ctrl+G H", CommandIds.ToggleFileHistory); keybindings.Bind("Ctrl+G A", CommandIds.AiAssistance); keybindings.Bind("Ctrl+G D", CommandIds.GoToDefinition); + keybindings.Bind("Ctrl+G I", CommandIds.QuickInfo); keybindings.Bind("Ctrl+G U", CommandIds.FindUsages); keybindings.Bind("Enter", CommandIds.AssistantSend); keybindings.Bind("Esc", CommandIds.AssistantStop); @@ -1414,7 +1426,7 @@ private bool IsAvailableFrom(string id, FocusRegion region) { var scope = _commands.ScopeOf(id); return (scope == CommandScope.Global || scope == FocusService.ScopeOf(region)) && _commands.IsEnabled(id) - && (id is not (CommandIds.GoToDefinition or CommandIds.FindUsages) || ActiveLanguageServer() is { State: LanguageServerState.Ready }); + && (id is not (CommandIds.GoToDefinition or CommandIds.QuickInfo or CommandIds.FindUsages) || ActiveLanguageServer() is { State: LanguageServerState.Ready }); } private LanguageServer? ActiveLanguageServer() => @@ -1581,6 +1593,76 @@ private void GoToDefinition() }), TaskScheduler.Default); } + /// Quick info (gi): what the language server says the name under the caret is, in a box under its line. + private void ShowQuickInfo() + { + if (_workbench.Editor.Group.ActiveTab is not { } tab || ReadyLanguageServer(tab) is not { } server) return; + var status = _workbench.StatusBar; + var (row, column) = (tab.CursorRow, tab.CursorColumn); + var request = ++_quickInfoRequest; + server.HoverAsync(tab.File.FullName, row, tab.CursorCharacter).ContinueWith(answer => _app.Invoke(() => + { + if (_disposed || request != _quickInfoRequest) return; + if (answer.Exception?.InnerException is { } error) status.SetMessage($"Quick info failed: {error.Message}"); + else if (answer.Result is []) status.SetMessage("Nothing to show here"); + else if (_workbench.Editor.Group.ActiveTab == tab && (tab.CursorRow, tab.CursorColumn) == (row, column)) OpenQuickInfo(tab, answer.Result); + }), TaskScheduler.Default); + } + + private void OpenQuickInfo(EditorTab tab, IReadOnlyList answer) + { + CloseQuickInfo(); + if (_workbench.HasDialog || _menu.IsOpen || _focus.Region != FocusRegion.Editor || tab.CaretOnScreen is not { } caret + || _scopes.Top is not { } below) return; + var text = tab.TextOnScreen; + var info = QuickInfo.Lay(answer, text.Width - QuickInfo.Frame - 1); + var view = new QuickInfoView(info, _workbench.Editor.Group.Syntax, tab.Grammar); + var (width, height) = view.Wanted; + var box = QuickInfo.Place(text, caret, width, height); + if (box.Height < height) box = QuickInfo.Place(text, caret, width + 1, height); + view.FitButtons(box.Width); + var origin = _workbench.ScreenToViewport(box.Location); + (view.X, view.Y, view.Width, view.Height) = (origin.X, origin.Y, box.Width, box.Height); + // A click on a button can come while a dialog over the box has the keys. + view.Cancelled += (_, _) => + { + if (ReferenceEquals(_scopes.Top, _quickInfoScope)) CloseQuickInfo(); + }; + view.Submitted += (_, _) => + { + if (!ReferenceEquals(_scopes.Top, _quickInfoScope)) return; + CloseQuickInfo(); + GoToDefinition(); + }; + _activeQuickInfo = view; + _quickInfoScope = new LayeredScope(view.Scope, below, _ => true, key => + { + if (!IsHelpKey(key)) CloseQuickInfo(); + }); + _quickInfoAt = (tab, caret, text); + _workbench.Add(view); + _scopes.Push(_quickInfoScope); + } + + private bool IsHelpKey(Key key) => + _keybindings.Bindings.Any(b => b.CommandId == CommandIds.ShowHelp && b.CanonicalId == KeyChord.Canonical([key])); + + // A click, a scroll or a resize moves what the box was pointing at. + private bool QuickInfoStillFits() => + _quickInfoAt.Tab == _workbench.Editor.Group.ActiveTab && _focus.Region == FocusRegion.Editor + && _quickInfoAt.Caret == _quickInfoAt.Tab.CaretOnScreen && _quickInfoAt.Text == _quickInfoAt.Tab.TextOnScreen; + + private void CloseQuickInfo() + { + if (_activeQuickInfo is not { } view) return; + _scopes.Pop(_quickInfoScope!); + _workbench.Remove(view); + view.Dispose(); + _activeQuickInfo = null; + _quickInfoScope = null; + _quickInfoAt = default; + } + /// The active file's language server, flushed of edits, or null once the status bar has said why there isn't one. private LanguageServer? ReadyLanguageServer(EditorTab tab) { @@ -1740,6 +1822,7 @@ private void OnIteration(object? sender, EventArgs e) // Picks up focus Terminal.Gui moved on its own, and any move that didn't land where it was asked to. Reconcile(); _activeSymbolPicker?.Advance(); + if (_activeQuickInfo is not null && ReferenceEquals(_scopes.Top, _quickInfoScope) && !QuickInfoStillFits()) CloseQuickInfo(); if (_workbench.Panel.ActiveTab == PanelTab.Assistant) ShowingAssistant(); // Some isEnabled checks touch the disk, so not on every iteration. if (Environment.TickCount64 - _menuShownAt >= 250) ShowAvailableMenus(); diff --git a/tests/TuiCode.Tests/CloseDirtyTabHostTests.cs b/tests/TuiCode.Tests/CloseDirtyTabHostTests.cs index 7677c82..67ac4e2 100644 --- a/tests/TuiCode.Tests/CloseDirtyTabHostTests.cs +++ b/tests/TuiCode.Tests/CloseDirtyTabHostTests.cs @@ -294,6 +294,8 @@ private sealed class CountingScopes : IInputScopeStack public void Pop(IKeybindingService scope) { _inner.Pop(scope); Depth--; } + public IKeybindingService? Top => _inner.Top; + public KeyHandlingResult Handle(Key key) => _inner.Handle(key); } } diff --git a/tests/TuiCode.Tests/FakeLanguageServer.cs b/tests/TuiCode.Tests/FakeLanguageServer.cs index b9c4943..fa892d7 100644 --- a/tests/TuiCode.Tests/FakeLanguageServer.cs +++ b/tests/TuiCode.Tests/FakeLanguageServer.cs @@ -36,6 +36,14 @@ internal sealed partial class FakeLanguageServer : ILanguageServerLauncher /// Holds every references request until it's set. public ManualResetEventSlim? HoldReferences { get; set; } + /// Holds hover requests on until it's set, while answering the rest. + public ManualResetEventSlim? HoldHover { get; set; } + + public string? HoldHoverOf { get; set; } + + /// What hover answers for a word; by default its declaration as C# and a line about it, or null for no declaration. + public Func? Hover { get; set; } + /// Files the server has loaded from the solution, by path. public ConcurrentDictionary Files { get; } = new(StringComparer.Ordinal); @@ -57,6 +65,15 @@ internal sealed partial class FakeLanguageServer : ILanguageServerLauncher if (Missing) return null; var server = new InProcessServer(); server.Connection.RequestHandler = (method, parameters) => Answer(server, method, parameters); + if (HoldHover is { } hold) + server.Connection.AsyncRequestHandler = (method, parameters) => + method == "textDocument/hover" && WordAt(parameters!) == HoldHoverOf + ? Task.Run(() => + { + hold.Wait(TimeSpan.FromSeconds(30)); + return Answer(server, method, parameters); + }) + : Task.FromResult(Answer(server, method, parameters)); server.Connection.NotificationReceived += (method, parameters) => OnNotification(server, method, parameters); server.Connection.Start(); Servers.Add(server); @@ -90,6 +107,7 @@ private void Progress(InProcessServer server, string kind) => "textDocument/definition" => Definitions(parameters!), "textDocument/references" => References(parameters!), "textDocument/documentSymbol" => Symbols(parameters!), + "textDocument/hover" => HoverAt(parameters!), _ => null, }; } @@ -168,6 +186,19 @@ private JsonArray References(JsonNode parameters) return found; } + private JsonNode? HoverAt(JsonNode parameters) + { + if (WordAt(parameters) is not { } word) return null; + var declaration = Sources().Values.SelectMany(Lines).Select(l => l.Trim()) + .FirstOrDefault(l => Regex.IsMatch(l, $@"\b(?:class|struct|interface|void|int|string)\s+{Regex.Escape(word)}\b")); + if (Hover is { } hover) return hover(word, declaration); + if (declaration is null) return null; + return new JsonObject + { + ["contents"] = new JsonObject { ["kind"] = "markdown", ["value"] = $"```csharp\n{declaration}\n```\n\nAbout {word}." }, + }; + } + private JsonArray Symbols(JsonNode parameters) { var lines = Lines(Sources().GetValueOrDefault(Path(parameters["textDocument"]!)) ?? ""); diff --git a/tests/TuiCode.Tests/FindUsagesHostTests.cs b/tests/TuiCode.Tests/FindUsagesHostTests.cs index a32f069..4b17caa 100644 --- a/tests/TuiCode.Tests/FindUsagesHostTests.cs +++ b/tests/TuiCode.Tests/FindUsagesHostTests.cs @@ -221,7 +221,7 @@ await HostSteps.Run(host, } [Fact] - public void Gu_is_on_Ctrl_G_U_in_the_editor_with_a_mnemonic_and_a_place_in_the_Go_menu_after_Go_to_definition() + public void Gu_is_on_Ctrl_G_U_in_the_editor_with_a_mnemonic_and_a_place_in_the_Go_menu_after_Quick_info() { using var workbench = BuildWorkbench(); var commands = new CommandService(); @@ -234,7 +234,7 @@ public void Gu_is_on_Ctrl_G_U_in_the_editor_with_a_mnemonic_and_a_place_in_the_G Assert.Equal("gu", CommandMnemonics.For(CommandIds.FindUsages)); Assert.Equal("Ctrl+G u", keybindings.Bindings.Single(b => b.CommandId == CommandIds.FindUsages).Display); var go = host.Menu.Items.Select(i => i.Id).ToList(); - Assert.Equal(go.IndexOf(CommandIds.GoToDefinition) + 1, go.IndexOf(CommandIds.FindUsages)); + Assert.Equal(go.IndexOf(CommandIds.QuickInfo) + 1, go.IndexOf(CommandIds.FindUsages)); } [Fact] diff --git a/tests/TuiCode.Tests/LspHoverTests.cs b/tests/TuiCode.Tests/LspHoverTests.cs new file mode 100644 index 0000000..508cf14 --- /dev/null +++ b/tests/TuiCode.Tests/LspHoverTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Nodes; +using TuiCode.Workbench.Languages; + +namespace TuiCode.Tests; + +public class LspHoverTests +{ + [Fact] + public void Parse_splits_markdown_into_its_fenced_code_and_the_prose_around_it() + { + var parts = Parse("""{"contents": {"kind": "markdown", "value": "```csharp\nstring Planner.Resolve(string request)\n```\n\nWorks out the steps.\n\nParameters:\n- ``request``: What was asked for."}}"""); + + Assert.Equal( + [ + new HoverPart("csharp", "string Planner.Resolve(string request)"), + new HoverPart(null, "Works out the steps.\n\nParameters:\n- request: What was asked for."), + ], parts); + } + + [Fact] + public void Parse_keeps_plaintext_markup_as_it_is() + { + var parts = Parse("""{"contents": {"kind": "plaintext", "value": "int x\n```not a fence``` \\_"}}"""); + + Assert.Equal([new HoverPart(null, "int x\n```not a fence``` \\_")], parts); + } + + [Fact] + public void Parse_reads_a_plain_string_as_markdown() + { + var parts = Parse("""{"contents": "```python\ndef f(): ...\n```\nDoes \\_things\\_."}"""); + + Assert.Equal([new HoverPart("python", "def f(): ..."), new HoverPart(null, "Does _things_.")], parts); + } + + [Fact] + public void Parse_reads_a_marked_string_as_code_in_its_language() + { + var parts = Parse("""{"contents": {"language": "typescript", "value": "function f(): void"}}"""); + + Assert.Equal([new HoverPart("typescript", "function f(): void")], parts); + } + + [Fact] + public void Parse_reads_each_of_an_array_of_marked_strings_in_turn() + { + var parts = Parse("""{"contents": [{"language": "python", "value": "(function) def f() -> None"}, "---", "Says hello."]}"""); + + Assert.Equal([new HoverPart("python", "(function) def f() -> None"), new HoverPart(null, "Says hello.")], parts); + } + + [Fact] + public void Parse_leaves_a_fence_without_a_language_as_code() + { + var parts = Parse("""{"contents": {"kind": "markdown", "value": "```\nlet x = 1\n```"}}"""); + + Assert.Equal([new HoverPart("", "let x = 1")], parts); + } + + [Theory] + [InlineData("null")] + [InlineData("""{"contents": ""}""")] + [InlineData("""{"contents": []}""")] + [InlineData("""{"contents": {"kind": "markdown", "value": " \n\n"}}""")] + public void Parse_gives_nothing_for_an_empty_answer(string json) => + Assert.Empty(Parse(json)); + + private static IReadOnlyList Parse(string json) => LspHover.Parse(JsonNode.Parse(json)); +} diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index cef2405..4c79d37 100644 --- a/tests/TuiCode.Tests/MenuBarHostTests.cs +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -380,7 +380,7 @@ .. Arrive(workbench, commands, context), () => !workbench.MenuBar.IsOpen()]); // The exceptions: the palette keeps the language server's commands to say why they can't run, the menu dims them until a server is ready (#454). - var offered = MenuIds().Where(id => id is not (CommandIds.GoToDefinition or CommandIds.FindUsages) && palette.Contains(commands.Registered.Single(c => c.Id == id).Title)); + var offered = MenuIds().Where(id => id is not (CommandIds.GoToDefinition or CommandIds.QuickInfo or CommandIds.FindUsages) && palette.Contains(commands.Registered.Single(c => c.Id == id).Title)); Assert.Equal(offered.Order(), undimmed.Order()); if (context == "pull request") Assert.Contains(CommandIds.SubmitReview, undimmed); if (context == "diff") Assert.Contains(CommandIds.RevertChange, undimmed); diff --git a/tests/TuiCode.Tests/QuickInfoHostTests.cs b/tests/TuiCode.Tests/QuickInfoHostTests.cs new file mode 100644 index 0000000..68c71ad --- /dev/null +++ b/tests/TuiCode.Tests/QuickInfoHostTests.cs @@ -0,0 +1,373 @@ +using System.Text.Json.Nodes; +using MentalDesk.Tui.Keys; +using TuiCode.Abstractions; +using TuiCode.Explorer; +using TuiCode.Syntax; +using TuiCode.Workbench; +using TuiCode.Workbench.Help; +using TuiCode.Workbench.Navigation; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Services; + +namespace TuiCode.Tests; + +// Drives `gi` / Ctrl+G I (#591) through the host against a fake language server. Boots a TG Application — serialised (#77). +public class QuickInfoHostTests : StaticConfigurationTest +{ + private const string Caller = """ + class Caller + { + void Go() + { + var hunks = Diff.Hunks(); + var widget = new Widget(); + var count = hunks.Count; + } + } + """; + + private static readonly string Diff = "class Diff\n{\n" + string.Concat(Enumerable.Repeat(" // padding\n", 40)) + " int Hunks() => 0;\n}\n"; + + private static readonly SyntaxHighlighter Syntax = new(GrammarBundle.Load()); + + private readonly MockFileSystem _fs = new(); + private readonly FakeLanguageServer _server = new(); + + public QuickInfoHostTests() + { + Add("/work/Caller.cs", Caller); + Add("/work/Diff.cs", Diff); + Add("/work/Widget.cs", "class Widget\n{\n}\n"); + _fs.AddFile("/work/README.md", new MockFileData("# Read me")); + } + + [Fact] + public async Task Ctrl_G_I_shows_the_signature_and_doc_under_the_line_and_Esc_closes_it_leaving_the_caret() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + QuickInfoView? box = null; + IReadOnlyList rows = []; + var caretY = 0; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { caretY = Tab(workbench).CaretOnScreen!.Value.Y; Execute(host); }, + () => (box = Box(workbench)) is not null, + () => { rows = box!.Rows; host.App.InjectKey(Key.Esc); }, + () => Box(workbench) is null); + + Assert.Equal("Quick info", box!.Title); + Assert.Equal(["int Hunks() => 0;", "", "About Hunks."], rows); + Assert.Equal(caretY + 1, box.FrameToScreen().Y); + Assert.Equal(("Caller.cs", 4, 26), Where(workbench)); + Assert.Contains("textDocument/hover", _server.Methods); + } + + [Fact] + public async Task Enter_closes_it_and_goes_to_the_definition_and_Back_returns() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + (string, int, int) jumped = default; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { Execute(host); }, + () => Box(workbench) is not null, + () => host.App.InjectKey(Key.Enter), + () => Tab(workbench).File.Name == "Diff.cs", + () => { jumped = Where(workbench); host.App.InjectKey(Key.G.WithCtrl); }, + () => host.App.InjectKey(Key.P), + () => Tab(workbench).File.Name == "Caller.cs"); + + Assert.Null(Box(workbench)); + Assert.Equal(("Diff.cs", 42, 8), jumped); + Assert.Equal(("Caller.cs", 4, 26), Where(workbench)); + } + + [Fact] + public async Task Its_buttons_do_what_their_keys_do() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + QuickInfoView? box = null; + string close = "", goTo = ""; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { Execute(host); }, + () => (box = Box(workbench)) is not null, + () => { close = box!.CloseButton.Text; box.CloseButton.InvokeCommand(Command.Accept); }, + () => Box(workbench) is null, + () => { Execute(host); }, + () => (box = Box(workbench)) is not null, + () => { goTo = box!.GoToButton.Text; box.GoToButton.InvokeCommand(Command.Accept); }, + () => Tab(workbench).File.Name == "Diff.cs"); + + Assert.Equal(" Esc Close ", close); + Assert.Equal(" Enter Go to definition ", goTo); + Assert.Null(Box(workbench)); + } + + [Fact] + public async Task Any_other_key_closes_it_and_acts_in_the_editor() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var typed = ""; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { Execute(host); }, + () => Box(workbench) is not null, + () => host.App.InjectKey(Key.X), + () => Box(workbench) is null, + () => { typed = Tab(workbench).Lines[4]; Tab(workbench).MoveCursor(5, 26); }, + () => { Execute(host); }, + () => Box(workbench) is not null, + () => host.App.InjectKey(Key.CursorRight), + () => Box(workbench) is null); + + Assert.Equal(" var hunks = Diff.Hxunks();", typed); + Assert.Equal(("Caller.cs", 5, 27), Where(workbench)); + } + + [Fact] + public async Task A_long_answer_scrolls_with_the_arrows_and_page_keys_without_moving_the_caret() + { + _server.Hover = (word, _) => new JsonObject { ["contents"] = string.Join("\n\n", Enumerable.Range(1, 30).Select(i => $"Line {i}")) }; + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + QuickInfoView? box = null; + int afterDown = -1, afterPageDown = -1, afterUp = -1; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { Execute(host); }, + () => (box = Box(workbench)) is not null, + () => host.App.InjectKey(Key.CursorDown), + () => { afterDown = box!.Top; host.App.InjectKey(Key.PageDown); }, + () => { afterPageDown = box!.Top; host.App.InjectKey(Key.CursorUp); }, + () => { afterUp = box!.Top; }); + + Assert.True(box!.Frame.Height <= QuickInfo.MaxHeight); + Assert.Equal(1, afterDown); + Assert.True(afterPageDown > afterDown); + Assert.Equal(afterPageDown - 1, afterUp); + Assert.Same(box, Box(workbench)); + Assert.Equal(("Caller.cs", 4, 26), Where(workbench)); + } + + [Fact] + public async Task Near_the_bottom_of_the_editor_it_opens_above_the_line() + { + using var workbench = BuildWorkbench(); + var commands = new CommandService(); + using var host = new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI, languageServers: _server); + HostSteps.PinScreenSize(host, 80, 14); + QuickInfoView? box = null; + var caretY = 0; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(5, 26); }, + () => { caretY = Tab(workbench).CaretOnScreen!.Value.Y; Execute(host); }, + () => (box = Box(workbench)) is not null); + + Assert.Equal(caretY, box!.FrameToScreen().Bottom); + } + + [Fact] + public async Task F1_while_it_is_open_lists_its_keys_and_leaves_it_open() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + KeysDialog? help = null; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { Execute(host); }, + () => Box(workbench) is not null, + () => host.App.InjectKey(Key.F1), + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => host.App.InjectKey(Key.Esc), + () => !workbench.SubViews.OfType().Any()); + + Assert.Equal("Quick info", help!.Sheet.Here!.Heading); + Assert.Equal(["Esc", "Enter", "↑ ↓ PgUp PgDn", "Any other key"], help.Sheet.Here.Rows().Select(r => r.Keys)); + Assert.NotNull(Box(workbench)); + } + + [Fact] + public async Task On_whitespace_it_says_there_is_nothing_to_show() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var shown = ""; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(3, 0); }, + () => { Execute(host); }, + () => workbench.StatusBar.Message == "Nothing to show here", + () => { shown = workbench.StatusBar.Message; }); + + Assert.Equal("Nothing to show here", shown); + Assert.Null(Box(workbench)); + } + + [Fact] + public async Task Only_the_latest_of_two_quick_presses_shows() + { + _server.HoldHover = new ManualResetEventSlim(); + _server.HoldHoverOf = "Hunks"; + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + IReadOnlyList rows = [], later = []; + var waited = 0; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => Ready(workbench), + () => { Tab(workbench).MoveCursor(4, 26); }, + () => { Execute(host); }, + () => ++waited > 10, + () => { Tab(workbench).MoveCursor(5, 30); }, + () => { Execute(host); }, + () => Box(workbench) is not null, + () => { rows = Box(workbench)!.Rows; _server.HoldHover.Set(); }, + () => _server.Received("textDocument/hover").Count() == 2 && ++waited > 30, + () => { later = Box(workbench)!.Rows; }); + + Assert.Equal("class Widget", rows[0]); + Assert.Equal(rows, later); + } + + [Fact] + public async Task While_loading_it_says_so_as_gd_does_and_the_menu_item_is_dimmed() + { + _server.HoldLoading = true; + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var pressed = ""; + var dimmed = false; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => workbench.StatusBar.DisplayedText.EndsWith("C# ◌ loading", StringComparison.Ordinal), + () => { Execute(host); }, + () => { pressed = workbench.StatusBar.Message; host.App.InjectKey(Key.G.WithAlt); }, + () => workbench.MenuBar.IsOpen(), + () => { dimmed = host.Menu.Items.Single(i => i.Id == CommandIds.QuickInfo).Item.Dimmed; host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen()); + + Assert.Equal("The C# language server is still loading", pressed); + Assert.True(dimmed); + Assert.Null(Box(workbench)); + } + + [Fact] + public async Task Without_the_server_installed_it_gives_gds_install_hint() + { + _server.Missing = true; + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var pressed = ""; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => { workbench.StatusBar.SetMessage("elsewhere"); Execute(host); }, + () => { pressed = workbench.StatusBar.Message; }); + + Assert.Equal("No C# language server. Install: dotnet tool install -g csharp-ls", pressed); + } + + [Fact] + public async Task A_file_without_a_server_says_there_is_none() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var pressed = ""; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "README.md"); }, + () => { Execute(host); }, + () => { pressed = workbench.StatusBar.Message; }); + + Assert.Equal("No language server for Markdown", pressed); + } + + [Fact] + public void Gi_is_on_Ctrl_G_I_in_the_editor_right_after_Go_to_definition_in_the_Go_menu() + { + using var workbench = BuildWorkbench(); + var commands = new CommandService(); + var keybindings = new KeybindingService(commands); + using var host = new WorkbenchHost(workbench, commands, keybindings, new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI); + + Assert.Equal("Quick info", commands.Registered.Single(c => c.Id == CommandIds.QuickInfo).Label); + Assert.Equal(CommandScope.Editor, commands.ScopeOf(CommandIds.QuickInfo)); + Assert.Equal("gi", CommandMnemonics.For(CommandIds.QuickInfo)); + Assert.Equal("Ctrl+G i", keybindings.Bindings.Single(b => b.CommandId == CommandIds.QuickInfo).Display); + var go = host.Menu.Items.Select(i => i.Id).ToList(); + Assert.Equal(go.IndexOf(CommandIds.GoToDefinition) + 1, go.IndexOf(CommandIds.QuickInfo)); + } + + private void Add(string path, string content) + { + _fs.AddFile(path, new MockFileData(content)); + _server.Files[_fs.Path.GetFullPath(path)] = content; + } + + private static bool Ready(Workbench.Workbench workbench) => + workbench.StatusBar.DisplayedText.EndsWith("C# ● ready", StringComparison.Ordinal); + + private static void Execute(WorkbenchHost host) + { + host.App.InjectKey(Key.G.WithCtrl); + host.App.InjectKey(Key.I); + } + + private static QuickInfoView? Box(Workbench.Workbench workbench) => workbench.SubViews.OfType().SingleOrDefault(); + + private static (string File, int Row, int Column) Where(Workbench.Workbench workbench) => + (Tab(workbench).File.Name, Tab(workbench).CursorRow, Tab(workbench).CursorColumn); + + private void OpenFile(Workbench.Workbench workbench, string name) => + workbench.OpenFile(_fs.FileInfo.New($"/work/{name}")); + + private static TuiCode.Editor.EditorTab Tab(Workbench.Workbench workbench) => workbench.Editor.Group.ActiveTab!; + + private Workbench.Workbench BuildWorkbench() + { + var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(Syntax), new StatusBarPart()); + workbench.Sidebar.Explorer.Open(_fs.DirectoryInfo.New("/work")); + return workbench; + } + + private WorkbenchHost BuildHost(Workbench.Workbench workbench) + { + var commands = new CommandService(); + var host = new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI, languageServers: _server); + HostSteps.PinScreenSize(host, 80, 25); + return host; + } +} diff --git a/tests/TuiCode.Tests/QuickInfoTests.cs b/tests/TuiCode.Tests/QuickInfoTests.cs new file mode 100644 index 0000000..e39d3b7 --- /dev/null +++ b/tests/TuiCode.Tests/QuickInfoTests.cs @@ -0,0 +1,85 @@ +using System.Drawing; +using TuiCode.Workbench.Languages; +using TuiCode.Workbench.Navigation; + +namespace TuiCode.Tests; + +public class QuickInfoTests +{ + private static readonly Rectangle Editor = new(10, 5, 100, 30); + + [Fact] + public void Lay_puts_the_code_first_then_a_blank_row_then_the_prose_wrapped() + { + var info = QuickInfo.Lay([new HoverPart("csharp", "void Go()"), new HoverPart(null, "One two three four five.")], 12); + + Assert.Equal(["void Go()", "", "One two", "three four", "five."], info.Rows.Select(r => r.Text)); + Assert.Equal([true, false, false, false, false], info.Rows.Select(r => r.IsCode)); + Assert.Equal("csharp", info.Blocks.Single().Language); + Assert.Equal(10, info.Width); + } + + [Fact] + public void Lay_wraps_a_long_signature_and_keeps_each_rows_place_in_its_line() + { + var info = QuickInfo.Lay([new HoverPart("csharp", "int Add(int a, int b)")], 12); + + Assert.Equal([("int Add(int", 0), ("a, int b)", 12)], info.Rows.Select(r => (r.Text, r.Offset))); + Assert.All(info.Rows, r => Assert.Equal((0, 0), (r.Block, r.Line))); + } + + [Fact] + public void Lay_wraps_prose_at_a_reading_width_unless_the_code_is_wider() + { + var prose = new HoverPart(null, string.Join(' ', Enumerable.Repeat("word", 40))); + + var narrow = QuickInfo.Lay([new HoverPart("csharp", "void Go()"), prose], 200); + var wide = QuickInfo.Lay([new HoverPart("csharp", new string('x', 120)), prose], 200); + + Assert.True(narrow.Width <= QuickInfo.ProseWidth); + Assert.Equal(120, wide.Width); + Assert.Contains(wide.Rows, r => !r.IsCode && r.Text.Length > QuickInfo.ProseWidth); + } + + [Fact] + public void Lay_keeps_one_blank_row_between_paragraphs() + { + var info = QuickInfo.Lay([new HoverPart(null, "One.\n\n\n\nTwo.")], 40); + + Assert.Equal(["One.", "", "Two."], info.Rows.Select(r => r.Text)); + } + + [Fact] + public void Place_puts_the_box_under_the_caret_when_it_fits() + { + Assert.Equal(new Rectangle(30, 9, 40, 8), QuickInfo.Place(Editor, new Point(30, 8), 40, 8)); + } + + [Fact] + public void Place_puts_the_box_above_the_caret_when_there_is_more_room_there() + { + Assert.Equal(new Rectangle(30, 22, 40, 8), QuickInfo.Place(Editor, new Point(30, 30), 40, 8)); + } + + [Fact] + public void Place_shortens_the_box_to_the_room_on_the_roomier_side() + { + var editor = new Rectangle(10, 5, 100, 10); + + Assert.Equal(new Rectangle(30, 10, 40, 5), QuickInfo.Place(editor, new Point(30, 9), 40, 8)); + Assert.Equal(new Rectangle(30, 5, 40, 6), QuickInfo.Place(editor, new Point(30, 11), 40, 8)); + } + + [Fact] + public void Place_makes_the_box_at_most_twelve_rows() + { + Assert.Equal(QuickInfo.MaxHeight, QuickInfo.Place(Editor, new Point(30, 5), 40, 50).Height); + } + + [Fact] + public void Place_keeps_the_box_inside_the_editor_and_never_wider_than_it() + { + Assert.Equal(new Rectangle(70, 9, 40, 8), QuickInfo.Place(Editor, new Point(100, 8), 40, 8)); + Assert.Equal(new Rectangle(10, 9, 100, 8), QuickInfo.Place(Editor, new Point(30, 8), 150, 8)); + } +} diff --git a/tests/TuiCode.Tests/ReloadFromDiskHostTests.cs b/tests/TuiCode.Tests/ReloadFromDiskHostTests.cs index 338adb0..4a4a165 100644 --- a/tests/TuiCode.Tests/ReloadFromDiskHostTests.cs +++ b/tests/TuiCode.Tests/ReloadFromDiskHostTests.cs @@ -212,6 +212,8 @@ private sealed class CountingScopes : IInputScopeStack public void Pop(IKeybindingService scope) { _inner.Pop(scope); Depth--; } + public IKeybindingService? Top => _inner.Top; + public KeyHandlingResult Handle(Key key) => _inner.Handle(key); } } diff --git a/tests/TuiCode.Tests/SaveConflictHostTests.cs b/tests/TuiCode.Tests/SaveConflictHostTests.cs index ba2a67b..3948607 100644 --- a/tests/TuiCode.Tests/SaveConflictHostTests.cs +++ b/tests/TuiCode.Tests/SaveConflictHostTests.cs @@ -273,6 +273,8 @@ private sealed class CountingScopes : IInputScopeStack public void Pop(IKeybindingService scope) { _inner.Pop(scope); Depth--; } + public IKeybindingService? Top => _inner.Top; + public KeyHandlingResult Handle(Key key) => _inner.Handle(key); } }