From 867244fa650d1f3a28a1d74149e72a16cb8d3b92 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 18:21:41 +1300 Subject: [PATCH] Land a jump into a freshly opened file two rows below its sticky lines A jump into a file that isn't open yet scrolled before the symbol scan had found the file's pins, so it allowed for none, and the pins then covered the two lines of context above the target. EditorTab now remembers a jump made while the pins aren't known and makes it again in the draw where the scan finishes, unless the view, the caret or the text has changed since. A file scanned in one frame lands right on its first draw. Restoring a remembered position (#514) doesn't count as a jump. GoToDefinitionHostTests expected the old landing, with no room for the `class Diff` pin. Closes #588 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/TuiCode.Editor/EditorTab.cs | 19 +- tests/TuiCode.Tests/EditorStickyLinesTests.cs | 18 ++ .../TuiCode.Tests/GoToDefinitionHostTests.cs | 5 +- tests/TuiCode.Tests/StickyJumpHostTests.cs | 176 ++++++++++++++++++ 5 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 tests/TuiCode.Tests/StickyJumpHostTests.cs diff --git a/AGENTS.md b/AGENTS.md index 0b7fd98..32b1c59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,7 @@ What follows is how TuiCode implements it today. - The first lines of the definitions enclosing the text are pinned over the editor's top rows as it scrolls, up to `StickyLines.Max` (3, innermost kept), and none on an editor under `StickyLines.MinHeight` (12) rows. `StickyLines` (pure) takes Go to symbol's definitions less fields and enum members: code encloses the lines indented deeper than its first line, plus a `{` and the closing line (`}`, `)`, `]`, `end`) at its own indent; a heading runs to the next heading of the same or a higher level. One with nothing inside it (a one-line property, an expression body) never sticks. A definition is pinned only once it has scrolled up past the row it would take, and while it still encloses the first line left showing below the pins (`StickyLines.At`). - `EditorTab` runs its own `SymbolScan`, in `StickyBudget` slices from the text view's draw, not `ScanSymbols()`: the `gs` picker renders from that scan's list and caches it per width, so advancing it elsewhere broke the picker. The previous pins stay until a scan finishes, and a scan under way finishes before the next starts, so a burst of edits can't starve it. Continuing on the next iteration is `AddTimeout(TimeSpan.Zero)`: `App.Invoke` runs inline, and a `SetNeedsDraw` inside a draw is cleared when it ends. - `CodeEditor` draws the pins over rows it has already drawn, through `DrawRow` with selection and highlights off, on `editor.lineHighlightBackground` or, since none of the bundled token themes set it, the text's background lifted 12% toward its foreground. The gutter numbers those rows the same way. Unwrapped they scroll sideways with the text; wrapped they show their first row and `…`. -- Every jump goes through `Reveal.TopRow` with the text view's `PinnedCountAt`, so it lands `Reveal.Margin` rows below the pins. A caret that moves under them, or that new pins grow over, scrolls the text down (`PinForCaret`); one a wheel scroll leaves there just loses the terminal cursor. A click on a pin is `EditorTab.PinnedLineClicked`, which the host jumps to as `gs` does, so Back returns. +- Every jump goes through `Reveal.TopRow` with the text view's `PinnedCountAt`, so it lands `Reveal.Margin` rows below the pins. A jump made before the file's pins are known, as into a file that's just opened, is made again when the scan finishes, in that draw, unless the view, the caret or the text has moved since (#588). A caret that moves under them, or that new pins grow over, scrolls the text down (`PinForCaret`); one a wheel scroll leaves there just loses the terminal cursor. A click on a pin is `EditorTab.PinnedLineClicked`, which the host jumps to as `gs` does, so Back returns. - Settings › Editor's *Show sticky lines* (`EditorSettings.StickyLines`, on by default) applies to open tabs on Save. Diff and document tabs don't have them. ## Syntax highlighting (#21) diff --git a/src/TuiCode.Editor/EditorTab.cs b/src/TuiCode.Editor/EditorTab.cs index 9dab455..ff48e73 100644 --- a/src/TuiCode.Editor/EditorTab.cs +++ b/src/TuiCode.Editor/EditorTab.cs @@ -25,6 +25,8 @@ public sealed class EditorTab : FrameView private SymbolScan? _stickyFrom; // Scrolling waits for the first draw: TG doesn't size the content to the text until then. private ViewPosition? _pendingView; + // A jump made before the pins were known, made again once they are unless the view or the text has moved since (#588). + private (int First, int Last, (System.Drawing.Point View, int Row, int Column, int Edits) At)? _revealOnPins; public IFileInfo File { get; private set; } public bool IsDirty => _dirty; @@ -210,6 +212,7 @@ private void RefreshSticky() _text.Sticky = StickyLines.None; _stickyScan = null; _stickyFrom = null; + _revealOnPins = null; return; } var stale = _stickyScan is not var (edits, grammar, _) || edits != _edits || !Equals(grammar, Grammar); @@ -218,6 +221,7 @@ private void RefreshSticky() if (_stickyScan?.Scan is not { } scan) { _text.Sticky = StickyLines.None; + _revealOnPins = null; return; } if (!scan.Advance(StickyBudget)) @@ -228,6 +232,8 @@ private void RefreshSticky() if (ReferenceEquals(scan, _stickyFrom)) return; _stickyFrom = scan; _text.Sticky = StickyLines.From(scan.Symbols, scan.Lines); + if (_revealOnPins is { } jump && jump.At == Where) Scroll(jump.First, jump.Last); + _revealOnPins = null; if (stale) DrawAgainSoon(); } @@ -376,7 +382,7 @@ private void ScrollToPendingView() if (_pendingView is not { } view || _text.Viewport.Height <= 0) return; _pendingView = null; _text.ScrollToLine(view.TopLine, view.LeftColumn); - if (CursorRow < _text.TopLine || CursorRow >= _text.TopLine + _text.Viewport.Height) RevealLines(CursorRow, CursorRow); + if (CursorRow < _text.TopLine || CursorRow >= _text.TopLine + _text.Viewport.Height) Scroll(CursorRow, CursorRow); } /// The cursor's column in UTF-16 chars, as language servers count them. @@ -405,6 +411,17 @@ public void MoveCursorToCharacter(int row, int character) public void RevealLines(int first, int last) { _pendingView = null; + Scroll(first, last); + _revealOnPins = PinsKnown ? null : (first, last, Where); + } + + private bool PinsKnown => !Settings.StickyLines || Grammar is null + || _stickyScan is (var edits, var grammar, { Done: true } scan) && edits == _edits && Equals(grammar, Grammar) && ReferenceEquals(scan, _stickyFrom); + + private (System.Drawing.Point View, int Row, int Column, int Edits) Where => (_text.Viewport.Location, CursorRow, CursorColumn, _edits); + + private void Scroll(int first, int last) + { if (WordWrap) { _text.RevealWrappedLines(first, last); diff --git a/tests/TuiCode.Tests/EditorStickyLinesTests.cs b/tests/TuiCode.Tests/EditorStickyLinesTests.cs index 932e144..4244fbc 100644 --- a/tests/TuiCode.Tests/EditorStickyLinesTests.cs +++ b/tests/TuiCode.Tests/EditorStickyLinesTests.cs @@ -207,6 +207,24 @@ public void With_word_wrap_a_long_pinned_line_shows_its_first_row_cut_with_an_el Assert.DoesNotContain("label", screen[2]); } + [Fact] + public void A_jump_into_a_file_scanned_in_one_frame_lands_below_the_pins_on_its_first_draw() + { + using var warm = Tab("Warm.cs", Code); + _fs.AddFile("/work/Widget.cs", new MockFileData(Code)); + using var tab = new EditorTab(_fs.FileInfo.New("/work/Widget.cs"), Syntax) { App = _app, Width = Width, Height = Height }; + tab.BeginInit(); + tab.EndInit(); + tab.Layout(); + + tab.MoveCursor(20, 0); + tab.RevealLines(20, 20); + Render(tab); + + Assert.Equal([Class, Run], tab.PinnedLines); + Assert.Equal(20 - 2 - Reveal.Margin, tab.TopRow); + } + private EditorTab Tab(string name, string content, int height = Height) { _fs.AddFile($"/work/{name}", new MockFileData(content)); diff --git a/tests/TuiCode.Tests/GoToDefinitionHostTests.cs b/tests/TuiCode.Tests/GoToDefinitionHostTests.cs index e32c169..aecf8bc 100644 --- a/tests/TuiCode.Tests/GoToDefinitionHostTests.cs +++ b/tests/TuiCode.Tests/GoToDefinitionHostTests.cs @@ -55,7 +55,7 @@ await HostSteps.Run(host, () => { languageShown = workbench.StatusBar.DisplayedText; Tab(workbench).MoveCursor(4, 26); }, () => host.App.InjectKey(Key.G.WithCtrl), () => host.App.InjectKey(Key.D), - () => Tab(workbench).File.Name == "Diff.cs", + () => Tab(workbench) is { File.Name: "Diff.cs", StickyScan.Done: true }, () => { jumped = Where(workbench); topRow = Tab(workbench).TopRow; host.App.InjectKey(Key.G.WithCtrl); }, () => host.App.InjectKey(Key.P), () => { back = Where(workbench); host.App.InjectKey(Key.G.WithCtrl); }, @@ -65,7 +65,8 @@ await HostSteps.Run(host, Assert.EndsWith("C# ● ready", languageShown, StringComparison.Ordinal); Assert.Equal(("Diff.cs", 42, 8), jumped); - Assert.Equal(42 - 2, topRow); + // Two rows below `class Diff`, pinned over the top row. + Assert.Equal(42 - 1 - 2, topRow); Assert.Equal(("Caller.cs", 4, 26), back); Assert.Equal(jumped, forward); Assert.True(_server.Current.Exited.IsCompleted); diff --git a/tests/TuiCode.Tests/StickyJumpHostTests.cs b/tests/TuiCode.Tests/StickyJumpHostTests.cs new file mode 100644 index 0000000..8c20f84 --- /dev/null +++ b/tests/TuiCode.Tests/StickyJumpHostTests.cs @@ -0,0 +1,176 @@ +using TuiCode.Abstractions; +using TuiCode.Editor; +using TuiCode.Explorer; +using TuiCode.Syntax; +using TuiCode.Workbench; +using TuiCode.Workbench.Configuration; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Services; + +namespace TuiCode.Tests; + +// A jump into a file whose pins aren't known yet (#588). Boots a TG Application — serialised (#77). +public class StickyJumpHostTests : StaticConfigurationTest +{ + private static readonly SyntaxHighlighter Syntax = new(GrammarBundle.Load()); + + private const int Target = 40; + private const int Pinned = 2; + + private static readonly string[] Lines = + [ + "public sealed class Widget", + "{", + " public void Run()", + " {", + .. Enumerable.Range(0, 100).Select(i => $" Step({i});"), + " }", + "}", + ]; + + private readonly MockFileSystem _fs = new(); + + private IFileInfo File => _fs.FileInfo.New("/work/Widget.cs"); + + public StickyJumpHostTests() => _fs.AddFile("/work/Widget.cs", new MockFileData(string.Join('\n', Lines))); + + [Fact] + public async Task A_command_line_line_lands_two_rows_below_the_pins_once_they_are_known() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + + await HostSteps.Run(host, + () => HostSteps.PinScreenSize(host, 100, 40), + () => host.OpenWhenRunning(new StartupTarget(_fs.DirectoryInfo.New("/work"), [new StartupFile(File, new FilePosition(Target + 1, 1))])), + () => Scanning(workbench), + () => Hurry(workbench), + () => PinsKnown(workbench)); + + Assert.Equal(Target, Tab(workbench)!.CursorRow); + Assert.Equal(Target - Pinned - Reveal.Margin, Tab(workbench)!.TopRow); + } + + [Fact] + public async Task A_find_result_in_a_file_that_is_not_open_lands_two_rows_below_the_pins() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + host.OpenWhenRunning(new StartupTarget(_fs.DirectoryInfo.New("/work"), [])); + var search = workbench.Sidebar.Search; + + await HostSteps.Run(host, + () => HostSteps.PinScreenSize(host, 100, 40), + () => host.App.InjectKey(Key.F.WithCtrl.WithShift), + () => { foreach (var c in $"Step({Target - 4})") host.App.InjectKey(new Key(c)); }, + () => search.Result.MatchCount == 1, + () => host.App.InjectKey(Key.Enter), + () => host.App.InjectKey(Key.Enter), + () => Scanning(workbench), + () => Hurry(workbench), + () => PinsKnown(workbench)); + + Assert.Equal(Target, Tab(workbench)!.CursorRow); + Assert.Equal(Target - Pinned - Reveal.Margin, Tab(workbench)!.TopRow); + } + + [Fact] + public async Task With_word_wrap_the_line_still_lands_two_rows_below_the_pins() + { + var lines = Lines.ToArray(); + lines[Target - 3] = " // " + string.Concat(Enumerable.Repeat("word ", 16)); + _fs.AddFile("/work/Widget.cs", new MockFileData(string.Join('\n', lines))); + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, new InMemorySettingsService { Editor = EditorSettings.Default with { WordWrap = true } }); + + await HostSteps.Run(host, + () => HostSteps.PinScreenSize(host, 100, 40), + () => host.OpenWhenRunning(new StartupTarget(_fs.DirectoryInfo.New("/work"), [new StartupFile(File, new FilePosition(Target + 1, 1))])), + () => Scanning(workbench), + () => Hurry(workbench), + () => PinsKnown(workbench)); + + Assert.True(Tab(workbench)!.WordWrap); + // The long line above takes two rows, so the target is a row further down the screen than its number. + Assert.Equal(Target + 1 - Pinned - Reveal.Margin, Tab(workbench)!.TopRow); + } + + public static TheoryData Moves => ["cursor", "type", "scroll"]; + + [Theory] + [MemberData(nameof(Moves))] + public async Task Moving_before_the_pins_are_known_keeps_the_view_where_it_is(string move) + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var top = -1; + + await HostSteps.Run(host, + () => HostSteps.PinScreenSize(host, 100, 40), + () => host.OpenWhenRunning(new StartupTarget(_fs.DirectoryInfo.New("/work"), [new StartupFile(File, new FilePosition(Target + 1, 1))])), + () => Scanning(workbench), + () => + { + var tab = Tab(workbench)!; + switch (move) + { + case "cursor": host.App.InjectKey(Key.CursorDown); break; + case "type": host.App.InjectKey(Key.Space); break; + default: tab.Code.Viewport = tab.Code.Viewport with { Y = tab.Code.Viewport.Y - 1 }; break; + } + }, + () => + { + top = Tab(workbench)!.TopRow; + Hurry(workbench); + }, + () => PinsKnown(workbench)); + + Assert.Equal(top, Tab(workbench)!.TopRow); + } + + [Fact] + public async Task An_open_at_a_remembered_position_does_not_scroll_when_the_pins_arrive() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + host.OpenWhenRunning(new StartupTarget(_fs.DirectoryInfo.New("/work"), [])); + + await HostSteps.Run(host, + () => HostSteps.PinScreenSize(host, 100, 40), + () => + { + workbench.OpenFile(File); + Tab(workbench)!.RestoreViewPosition(new ViewPosition(Target + 5, 0, Target - 2, 0)); + }, + () => Scanning(workbench), + () => Hurry(workbench), + () => PinsKnown(workbench)); + + Assert.Equal(Target - 2, Tab(workbench)!.TopRow); + } + + private static EditorTab? Tab(Workbench.Workbench workbench) => workbench.Editor.Group.ActiveTab; + + private static bool Scanning(Workbench.Workbench workbench) => Tab(workbench)?.StickyScan is { Done: false }; + + private static void Hurry(Workbench.Workbench workbench) => Tab(workbench)!.StickyBudget = TimeSpan.FromSeconds(1); + + private static bool PinsKnown(Workbench.Workbench workbench) => + Tab(workbench) is { StickyScan.Done: true, PinnedLines.Count: Pinned }; + + // One line a frame, so the pins aren't known until the test hurries the scan along. + private static Workbench.Workbench BuildWorkbench() + { + var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(Syntax), new StatusBarPart()); + workbench.Editor.Groups.EditorOpening += (_, tab) => tab.StickyBudget = TimeSpan.Zero; + return workbench; + } + + private static WorkbenchHost BuildHost(Workbench.Workbench workbench, ISettingsService? settings = null) + { + var commands = new CommandService(); + return new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + settings ?? new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI); + } +}