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