diff --git a/.claude/hooks/cleanup-cs.ps1 b/.claude/hooks/cleanup-cs.ps1 index 0b3e1101..94ee20eb 100644 --- a/.claude/hooks/cleanup-cs.ps1 +++ b/.claude/hooks/cleanup-cs.ps1 @@ -36,7 +36,7 @@ if (-not $changed) { exit 0 } # --include narrows the work to changed files. $includes = ($changed | ForEach-Object { "--include=$_" }) -join ' ' if ($includes) { - & dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile="Built-in: Full Cleanup" $includes.Split(' ') --no-build *> $null + & dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile="TG.Text Full Cleanup" $includes.Split(' ') --no-build *> $null } # Surface the net effect so the agent sees its own drift. diff --git a/.editorconfig b/.editorconfig index d1f412ba..db997d2b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -61,7 +61,7 @@ csharp_space_between_square_brackets = false # discoverability, but don't block the build. Enforcement happens in two other places: # # 1. `dotnet format Terminal.Gui.Text.slnx --verify-no-changes` in CI, and -# 2. `dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile="Full Cleanup"` in CI +# 2. `dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile="TG.Text Full Cleanup"` in CI # (and the Stop hook in .claude/settings.json before each agent turn ends). # # Both tools auto-fix what they can and surface a clean diff. The `:warning` posture diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c42ade9e..fc1d8ef8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,17 +52,17 @@ jobs: - name: Verify code style — ReSharper cleanupcode if: matrix.os == 'ubuntu-latest' run: | - # Use the built-in profile name (jb cleanupcode does not always discover custom - # profile names from team-shared .DotSettings files reliably). Our .DotSettings - # still ships its own profile + style settings (var, expression-bodied, etc.) for - # Rider users; the CLI just runs the default-shaped cleanup using those settings. + # Use our custom profile name defined in the team-shared .DotSettings file. + # Previous attempts used "Built-in: Full Cleanup" which worked but didn't pick + # up our tweaks (CSUseAutoProperty OFF, etc.). The custom-named profile resolves + # reliably from the .DotSettings file. dotnet jb cleanupcode Terminal.Gui.Text.slnx \ - --profile="Built-in: Full Cleanup" \ + --profile="TG.Text Full Cleanup" \ --no-build \ --exclude="third_party/**/*" \ || true if ! git diff --exit-code; then - echo "::error::ReSharper code cleanup found style drift. Run 'dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile=\"Built-in: Full Cleanup\"' locally and commit the result." + echo "::error::ReSharper code cleanup found style drift. Run 'dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile=\"TG.Text Full Cleanup\"' locally and commit the result." exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index 048feb5d..68f176ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,10 +84,10 @@ The fork is **hard** — re-syncs are manual and deliberate, triggered only by u Adopts Terminal.Gui's house style. Three enforcement layers: 1. **`.editorconfig` + `dotnet format`** — formatting, var, expression-bodied, collection expressions, modern syntax preferences. CI runs `dotnet format --verify-no-changes`. -2. **`Terminal.Gui.Text.slnx.DotSettings` + `dotnet jb cleanupcode`** — ReSharper-driven cleanup ("Full Cleanup" profile). Catches what `dotnet format` misses (XML doc spacing, using sorting, name qualifier removal, expression-bodied conversions). CI runs `dotnet jb cleanupcode` and fails on any diff. +2. **`Terminal.Gui.Text.slnx.DotSettings` + `dotnet jb cleanupcode`** — ReSharper-driven cleanup ("TG.Text Full Cleanup" profile). Catches what `dotnet format` misses (XML doc spacing, using sorting, name qualifier removal, expression-bodied conversions). CI runs `dotnet jb cleanupcode` and fails on any diff. 3. **A Stop hook in `.claude/settings.json`** that runs both tools on .cs files modified during the session before the agent reports done. Output is suppressed unless the cleanup actually changed something. -**Before declaring work complete, an agent must run `dotnet tool restore && dotnet format Terminal.Gui.Text.slnx --exclude third_party/ && dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile="Full Cleanup"` (the Stop hook does this automatically). If the cleanup adjusts files, those changes are part of the work — re-stage and continue.** +**Before declaring work complete, an agent must run `dotnet tool restore && dotnet format Terminal.Gui.Text.slnx --exclude third_party/ && dotnet jb cleanupcode Terminal.Gui.Text.slnx --profile="TG.Text Full Cleanup"` (the Stop hook does this automatically). If the cleanup adjusts files, those changes are part of the work — re-stage and continue.** ### Formatting and spacing diff --git a/Terminal.Gui.Text.slnx.DotSettings b/Terminal.Gui.Text.slnx.DotSettings index ca5c603b..5c7d8148 100644 --- a/Terminal.Gui.Text.slnx.DotSettings +++ b/Terminal.Gui.Text.slnx.DotSettings @@ -13,40 +13,45 @@ --> - - + + + + + + + - Full Cleanup - Full Cleanup + TG.Text Full Cleanup + TG.Text Full Cleanup - True - gui-cs/Text house cleanup. Keep aligned with CLAUDE.md. - Full Cleanup + True + gui-cs/Text house cleanup. Keep aligned with CLAUDE.md. + TG.Text Full Cleanup - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - False - True - False - True - True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + False + True + False + True + True diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 725d03cd..d48d40a3 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -246,7 +246,7 @@ private Key KeyFor (Command command) private void ShowAboutDialog () { Dialog dialog = new () - { Title = "About ted", Buttons = [new Button { Title = Strings.btnOk, IsDefault = true }] }; + { Title = "About ted", Buttons = [new Button { Title = Strings.btnOk, IsDefault = true }] }; dialog.Border.Settings &= ~BorderSettings.Title; diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index 2428df3e..d27c4ddb 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -65,6 +65,11 @@ private void DrawVisibleLines (Rectangle viewport, Attribute normal, Attribute s } } + // Syntax highlighter state optimization: tracks how far we've prepared so incremental + // scrolling doesn't re-highlight from line 0 every frame. + private int _highlighterPreparedUpToLine = -1; + private ISyntaxHighlighter? _highlighterPreparedInstance; + private void PrepareSyntaxHighlighter (ISyntaxHighlighter? syntaxHighlighter, int firstVisibleLineIndex) { if (syntaxHighlighter is null || _document is null) @@ -72,15 +77,25 @@ private void PrepareSyntaxHighlighter (ISyntaxHighlighter? syntaxHighlighter, in return; } - syntaxHighlighter.ResetState (); + // If the highlighter instance changed or viewport scrolled backward, reset from scratch. + if (!ReferenceEquals (syntaxHighlighter, _highlighterPreparedInstance) + || firstVisibleLineIndex < _highlighterPreparedUpToLine) + { + syntaxHighlighter.ResetState (); + _highlighterPreparedInstance = syntaxHighlighter; + _highlighterPreparedUpToLine = 0; + } - for (var lineIndex = 0; lineIndex < firstVisibleLineIndex && lineIndex < _document.LineCount; lineIndex++) + // Incrementally highlight from where we left off to the first visible line. + for (var lineIndex = _highlighterPreparedUpToLine; lineIndex < firstVisibleLineIndex && lineIndex < _document.LineCount; lineIndex++) { DocumentLine line = _document.GetLineByNumber (lineIndex + 1); #pragma warning disable CS0618 // Type or member is obsolete — see note in OnDrawingContent. syntaxHighlighter.Highlight (_document.GetText (line), SyntaxLanguage); #pragma warning restore CS0618 // Type or member is obsolete } + + _highlighterPreparedUpToLine = firstVisibleLineIndex; } private void DrawVisualLine ( @@ -106,6 +121,18 @@ private void DrawVisualLine ( foreach (CellVisualLineElement element in visualLine.Elements) { + // Elements are ordered by visual column. Once we pass the visible end, + // all remaining elements are off-screen — skip them entirely. + if (element.VisualColumn >= visibleEnd) + { + break; + } + + if (element.VisualEndColumn <= visibleStart) + { + continue; + } + element.Draw (this, 0, row, visibleStart, visibleEnd); } } diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index c5f67a1e..1a446c2e 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -34,6 +34,14 @@ public partial class Editor : View private readonly Dictionary _drawVisualLineCache = []; private readonly VisualLineBuilder _visualLineBuilder = new (); + // Incremental max-width tracking: avoids the O(N) all-lines walk that UpdateContentSize + // used to do on every edit. _maxVisualWidth is the widest visual line seen; _maxWidthLineNumber + // tracks which line holds it so we can detect when that line is edited. _maxWidthDirty forces + // a full recompute (e.g. on Document swap or IndentationSize change). + private int _maxVisualWidth; + private int _maxWidthLineNumber; + private bool _maxWidthDirty = true; + private TextAnchor? _caretAnchor; private TextDocument? _document; private Gutter? _gutter; @@ -84,6 +92,7 @@ public TextDocument? Document _lastKnownCaretOffset = caretOffset; _selectionAnchor = null; ClearVisualLineCaches (); + _maxWidthDirty = true; _virtualCaretColumn = GetCaretColumn (); UpdateContentSize (); @@ -211,6 +220,7 @@ public int IndentationSize field = value; ClearVisualLineCaches (); + _maxWidthDirty = true; _virtualCaretColumn = GetCaretColumn (); UpdateContentSize (); EnsureCaretVisible (); @@ -299,6 +309,8 @@ private void OnDocumentChanged (object? sender, DocumentChangeEventArgs e) // line numbers downstream may also have shifted, so clear everything from that line on. // Cheap: usually one or a handful of entries; correctness > saving a few cache hits. InvalidateVisualLineCaches (e); + InvalidateHighlighterState (e); + UpdateMaxWidthIncremental (e); UpdateContentSize (); UpdateLineNumberPadding (); @@ -323,20 +335,115 @@ private void UpdateContentSize () return; } - var maxWidth = 0; + if (_maxWidthDirty) + { + RecomputeMaxWidth (); + } + + // +1 column lets the caret sit just past the end-of-line. + SetContentSize (new Size (_maxVisualWidth + 1, _document.LineCount)); + } + + /// Full O(N) recompute — only called on Document swap, IndentationSize change, etc. + private void RecomputeMaxWidth () + { + _maxVisualWidth = 0; + _maxWidthLineNumber = 0; + + if (_document is null) + { + _maxWidthDirty = false; + + return; + } foreach (DocumentLine line in _document.Lines) { var width = GetOrBuildDefaultVisualLine (line).VisualLength; - if (width > maxWidth) + if (width > _maxVisualWidth) { - maxWidth = width; + _maxVisualWidth = width; + _maxWidthLineNumber = line.LineNumber; } } - // +1 column lets the caret sit just past the end-of-line. - SetContentSize (new Size (maxWidth + 1, _document.LineCount)); + _maxWidthDirty = false; + } + + /// + /// Incrementally updates max width after a document change. Only recomputes the affected + /// lines. If the edited line was the widest, does a full recompute since the max may have shrunk. + /// + private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) + { + if (_document is null) + { + return; + } + + // Find which lines are affected by the change. + DocumentLine firstAffected = _document.GetLineByOffset (Math.Min (e.Offset, _document.TextLength)); + var insertedText = e.InsertedText?.Text ?? ""; + var newlineCount = insertedText.Count (c => c == '\n'); + var removedText = e.RemovedText?.Text ?? ""; + var removedNewlines = removedText.Count (c => c == '\n'); + + // If the widest line was deleted or its content changed, we must recompute. + if (_maxWidthLineNumber >= firstAffected.LineNumber + && (_maxWidthLineNumber <= firstAffected.LineNumber + Math.Max (removedNewlines, 0) + || removedNewlines > 0)) + { + // The max-holder was touched or lines were removed — check affected lines first, + // and only fall back to full recompute if the old max shrank. + var newMax = 0; + var newMaxLine = _maxWidthLineNumber; + + // Scan from firstAffected through the new lines that were inserted. + var scanEnd = Math.Min (firstAffected.LineNumber + newlineCount, _document.LineCount); + + for (var lineNum = firstAffected.LineNumber; lineNum <= scanEnd; lineNum++) + { + DocumentLine line = _document.GetLineByNumber (lineNum); + var width = GetOrBuildDefaultVisualLine (line).VisualLength; + + if (width >= newMax) + { + newMax = width; + newMaxLine = lineNum; + } + } + + if (newMax >= _maxVisualWidth) + { + // The affected region has a line at least as wide — it's the new max. + _maxVisualWidth = newMax; + _maxWidthLineNumber = newMaxLine; + } + else + { + // The old widest line shrank and no scanned line is as wide — some unscanned + // line may be the new widest. Fall back to full recompute. + _maxWidthDirty = true; + } + + return; + } + + // The change didn't touch the widest line. Just check affected lines for a new max. + var endLine = Math.Min (firstAffected.LineNumber + newlineCount, _document.LineCount); + + for (var lineNum = firstAffected.LineNumber; lineNum <= endLine; lineNum++) + { + DocumentLine line = _document.GetLineByNumber (lineNum); + var width = GetOrBuildDefaultVisualLine (line).VisualLength; + + if (width > _maxVisualWidth) + { + _maxVisualWidth = width; + _maxWidthLineNumber = lineNum; + } + } } private void ClearVisualLineCaches () @@ -345,6 +452,31 @@ private void ClearVisualLineCaches () _drawVisualLineCache.Clear (); } + /// + /// Resets the incremental highlighter state when a document change occurs at or before + /// the prepared-up-to line. Edits after the prepared region don't affect tokenizer state + /// for lines that have already been processed. + /// + private void InvalidateHighlighterState (DocumentChangeEventArgs e) + { + if (_highlighterPreparedUpToLine < 0 || _document is null) + { + return; + } + + DocumentLine affectedLine = _document.GetLineByOffset (Math.Min (e.Offset, _document.TextLength)); + var affectedLineIndex = affectedLine.LineNumber - 1; + + if (affectedLineIndex < _highlighterPreparedUpToLine) + { + // Edit was before/within the prepared region — must re-prepare from line 0. + // Setting to 0 (not -1) so PrepareSyntaxHighlighter's comparison triggers a + // ResetState() call on the next draw frame. + _highlighterPreparedUpToLine = 0; + _highlighterPreparedInstance = null; + } + } + private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) { if (_document is null || (_defaultVisualLineCache.Count == 0 && _drawVisualLineCache.Count == 0)) @@ -352,41 +484,75 @@ private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) return; } - // The change starts at e.Offset; anything before that line is unaffected. The cheapest - // sound invalidation is "drop entries for line numbers ≥ the changed line's number" — - // newline insert/delete renumbers everything downstream, so per-line keys past the edit - // are stale even if their content is untouched. DocumentLine firstAffected = _document.GetLineByOffset (Math.Min (e.Offset, _document.TextLength)); var threshold = firstAffected.LineNumber; - RemoveFromCache (_defaultVisualLineCache, threshold); - RemoveFromCache (_drawVisualLineCache, threshold); + // Count net newline delta: downstream line numbers shift by this amount. + var insertedText = e.InsertedText?.Text ?? ""; + var insertedNewlines = insertedText.Count (c => c == '\n'); + var removedText = e.RemovedText?.Text ?? ""; + var removedNewlines = removedText.Count (c => c == '\n'); + var lineDelta = insertedNewlines - removedNewlines; - static void RemoveFromCache (Dictionary cache, int threshold) + RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines); + RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines); + + static void RekeyCache (Dictionary cache, int threshold, int lineDelta, int removedNewlines) { if (cache.Count == 0) { return; } + // On newline removal, lines in [threshold, threshold + removedNewlines] were merged + // and their cached content is stale — invalidate, don't rekey. + var invalidateEnd = threshold + removedNewlines; + + // Collect entries: invalidate the edited line(s), rekey downstream. + List>? toRekey = null; List? toRemove = null; - foreach (var lineNumber in cache.Keys) + foreach (KeyValuePair kvp in cache) { - if (lineNumber >= threshold) + if (kvp.Key >= threshold && kvp.Key <= invalidateEnd) { - (toRemove ??= []).Add (lineNumber); + // The edited/merged line(s) — content changed, must invalidate. + (toRemove ??= []).Add (kvp.Key); + } + else if (kvp.Key > invalidateEnd) + { + if (lineDelta == 0) + { + // No newline change — downstream entries are still valid as-is. + } + else + { + // Line numbers shifted — remove old key, re-add with shifted key. + (toRemove ??= []).Add (kvp.Key); + (toRekey ??= []).Add (kvp); + } } } - if (toRemove is null) + if (toRemove is not null) { - return; + foreach (var key in toRemove) + { + cache.Remove (key); + } } - foreach (var lineNumber in toRemove) + if (toRekey is not null) { - cache.Remove (lineNumber); + foreach (KeyValuePair kvp in toRekey) + { + var newKey = kvp.Key + lineDelta; + + if (newKey > 0) + { + cache[newKey] = kvp.Value; + } + } } } } diff --git a/src/Terminal.Gui.Editor/Rendering/VisualLineBuilder.cs b/src/Terminal.Gui.Editor/Rendering/VisualLineBuilder.cs index a3471f4c..df1eae41 100644 --- a/src/Terminal.Gui.Editor/Rendering/VisualLineBuilder.cs +++ b/src/Terminal.Gui.Editor/Rendering/VisualLineBuilder.cs @@ -11,6 +11,80 @@ public CellVisualLine Build (DocumentLine documentLine, VisualLineBuildContext c { var text = context.Document.GetText (documentLine); CellVisualLine visualLine = new (documentLine); + + if (IsAsciiOnly (text)) + { + BuildAsciiFastPath (documentLine, context, text, visualLine); + } + else + { + BuildGraphemePath (documentLine, context, text, visualLine); + } + + foreach (IVisualLineTransformer transformer in context.LineTransformers) + { + transformer.Transform (visualLine); + } + + return visualLine; + } + + /// + /// Fast path for pure-ASCII lines. Avoids GraphemeHelper.GetGraphemes + /// allocation — each byte is one grapheme, one column (tabs expand as usual). + /// + private static void BuildAsciiFastPath ( + DocumentLine documentLine, + VisualLineBuildContext context, + string text, + CellVisualLine visualLine) + { + var segmentIndex = 0; + var segmentEnd = GetSegmentEnd (context.StyledSegments, segmentIndex); + var visualColumn = 0; + + for (var i = 0; i < text.Length; i++) + { + while (context.StyledSegments is { Count: > 0 } + && i >= segmentEnd + && segmentIndex + 1 < context.StyledSegments.Count) + { + segmentIndex++; + segmentEnd += context.StyledSegments[segmentIndex].Text.Length; + } + + Attribute attribute = GetAttribute (context, segmentIndex); + var documentOffset = documentLine.Offset + i; + + if (context.HasSelection + && documentOffset < context.SelectionEnd + && documentOffset + 1 > context.SelectionStart) + { + attribute = context.SelectedAttribute; + } + + if (text[i] == '\t') + { + var width = GetTabExpansionWidth (visualColumn, context.IndentationSize); + visualLine.AddElement ( + new TabElement (documentOffset, visualColumn, width, context.ShowTabs, attribute)); + visualColumn += width; + } + else + { + TextRunElement element = new (documentOffset, 1, visualColumn, text.Substring (i, 1), attribute); + visualLine.AddElement (element); + visualColumn += 1; + } + } + } + + private static void BuildGraphemePath ( + DocumentLine documentLine, + VisualLineBuildContext context, + string text, + CellVisualLine visualLine) + { var logicalColumn = 0; var visualColumn = 0; var segmentIndex = 0; @@ -53,13 +127,20 @@ public CellVisualLine Build (DocumentLine documentLine, VisualLineBuildContext c logicalColumn += documentLength; } + } - foreach (IVisualLineTransformer transformer in context.LineTransformers) + /// Checks if all characters are ASCII (no multi-byte graphemes, no surrogates). + private static bool IsAsciiOnly (string text) + { + foreach (var c in text) { - transformer.Transform (visualLine); + if (c > 127) + { + return false; + } } - return visualLine; + return true; } public static int GetTabExpansionWidth (int visualColumn, int indentationSize) diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TestEnvironment.cs b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TestEnvironment.cs new file mode 100644 index 00000000..c905bc0a --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TestEnvironment.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace Terminal.Gui.Editor.IntegrationTests.Testing; + +/// +/// Sets DisableRealDriverIO=1 before any test runs so the ANSI driver does not attempt +/// real console I/O. Without this, the full integration test suite hangs on local machines +/// (the env var is set in CI via the workflow YAML but was missing for local runs). +/// +internal static class TestEnvironment +{ + [ModuleInitializer] + internal static void Init () + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + } +}