Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 18 additions & 1 deletion src/TuiCode.Editor/EditorTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -218,6 +221,7 @@ private void RefreshSticky()
if (_stickyScan?.Scan is not { } scan)
{
_text.Sticky = StickyLines.None;
_revealOnPins = null;
return;
}
if (!scan.Advance(StickyBudget))
Expand All @@ -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();
}

Expand Down Expand Up @@ -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);
}

/// <summary>The cursor's column in UTF-16 chars, as language servers count them.</summary>
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions tests/TuiCode.Tests/EditorStickyLinesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
5 changes: 3 additions & 2 deletions tests/TuiCode.Tests/GoToDefinitionHostTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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); },
Expand All @@ -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);
Expand Down
176 changes: 176 additions & 0 deletions tests/TuiCode.Tests/StickyJumpHostTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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);
}
}
Loading