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); + } +}