From 49a72e5ccb52ac328fbf82def89e7034f4ce97ea Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:33:26 +1300 Subject: [PATCH 1/2] Quick info (Ctrl+G I, gi) shows what the name under the caret is Asks the file's language server for textDocument/hover and shows the answer in a box under the caret's line, or above it when there's more room there: the signature coloured with its grammar, then the doc comment wrapped. LspHover reads every shape the protocol allows. The box never takes focus. Its keys are a LayeredScope over whatever had them, so Esc, Enter and the scroll keys are its own, and any other key closes it and carries on to the editor. A click, scroll or resize that moves the caret's cell closes it on the next iteration. The scope stack gains Top so the host can tell whether the box's scope is the one receiving keys before popping it. The code-row colouring the chat used is now CodeBlockColours, shared by both. Closes #591 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 5 +- src/TuiCode.Abstractions/CommandIds.cs | 7 + src/TuiCode.Abstractions/CommandMnemonics.cs | 1 + src/TuiCode.Abstractions/IInputScopeStack.cs | 3 + src/TuiCode.Editor/CodeEditor.cs | 3 + src/TuiCode.Editor/EditorTab.cs | 6 + src/TuiCode.Workbench/Assistant/ChatView.cs | 55 +-- .../Controls/CodeBlockColours.cs | 70 ++++ .../Languages/LanguageServer.cs | 5 + src/TuiCode.Workbench/Languages/LspHover.cs | 89 +++++ src/TuiCode.Workbench/Menus/CommandMenu.cs | 4 +- src/TuiCode.Workbench/Navigation/QuickInfo.cs | 116 ++++++ .../Navigation/QuickInfoView.cs | 136 +++++++ .../Services/InputScopeStack.cs | 2 + .../Services/LayeredScope.cs | 6 +- src/TuiCode.Workbench/Workbench.cs | 3 +- src/TuiCode.Workbench/WorkbenchHost.cs | 87 +++- tests/TuiCode.Tests/CloseDirtyTabHostTests.cs | 2 + tests/TuiCode.Tests/FakeLanguageServer.cs | 31 ++ tests/TuiCode.Tests/LspHoverTests.cs | 69 ++++ tests/TuiCode.Tests/QuickInfoHostTests.cs | 373 ++++++++++++++++++ tests/TuiCode.Tests/QuickInfoTests.cs | 85 ++++ .../TuiCode.Tests/ReloadFromDiskHostTests.cs | 2 + tests/TuiCode.Tests/SaveConflictHostTests.cs | 2 + 24 files changed, 1106 insertions(+), 56 deletions(-) create mode 100644 src/TuiCode.Workbench/Controls/CodeBlockColours.cs create mode 100644 src/TuiCode.Workbench/Languages/LspHover.cs create mode 100644 src/TuiCode.Workbench/Navigation/QuickInfo.cs create mode 100644 src/TuiCode.Workbench/Navigation/QuickInfoView.cs create mode 100644 tests/TuiCode.Tests/LspHoverTests.cs create mode 100644 tests/TuiCode.Tests/QuickInfoHostTests.cs create mode 100644 tests/TuiCode.Tests/QuickInfoTests.cs diff --git a/AGENTS.md b/AGENTS.md index 39a85aa8..659922ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,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. @@ -428,7 +428,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. @@ -446,6 +446,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 cee7da1c..44707920 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -97,6 +97,13 @@ public static class CommandIds public const string DefinitionPickerDown = "definitionPicker.action.down"; public const string DefinitionPickerPageUp = "definitionPicker.action.pageUp"; public const string DefinitionPickerPageDown = "definitionPicker.action.pageDown"; + 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 be893cba..53fd4586 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 37d5a173..d1ad6972 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 eb3446c2..f5ff768f 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 ff48e73c..780101c7 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 151d3cc5..9cde1094 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 00000000..50fb3bd3 --- /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 6bb6fb9a..309b609c 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 00000000..45149628 --- /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 3ebb39bc..4d70fa9d 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 00000000..0750fc28 --- /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 00000000..84de4dbd --- /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 72585941..fef402c0 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 d7d529cd..42b126fb 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 6078b5d5..1ca2be9f 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); @@ -1422,7 +1434,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() => @@ -1600,6 +1612,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) { @@ -1771,6 +1853,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 7677c823..67ac4e25 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 b9c49437..fa892d73 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/LspHoverTests.cs b/tests/TuiCode.Tests/LspHoverTests.cs new file mode 100644 index 00000000..508cf148 --- /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/QuickInfoHostTests.cs b/tests/TuiCode.Tests/QuickInfoHostTests.cs new file mode 100644 index 00000000..68c71adb --- /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 00000000..e39d3b70 --- /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 338adb0a..4a4a1657 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 ba2a67bc..39486079 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); } } From 4353ab1093e4d6f9b6094fc931c7dcd8c54168d5 Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:39:11 +1300 Subject: [PATCH 2/2] Quick info sits between Go to definition and Find usages in the Go menu Closes #591 Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/TuiCode.Tests/FindUsagesHostTests.cs | 4 ++-- tests/TuiCode.Tests/MenuBarHostTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/TuiCode.Tests/FindUsagesHostTests.cs b/tests/TuiCode.Tests/FindUsagesHostTests.cs index a32f069e..4b17caa2 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/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index cef24050..4c79d37e 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);