From b64de4e5b012d6caa16dbe00f797117110a5a106 Mon Sep 17 00:00:00 2001 From: Tig Date: Tue, 12 May 2026 06:35:48 -0600 Subject: [PATCH 1/4] perf: five scroll-performance optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Incremental UpdateContentSize — replace O(N) all-lines walk with incremental max-width tracking. Only recomputes affected lines on edit; full recompute only when the widest line shrinks or on Document/IndentationSize swap. 2. Incremental syntax highlighter prep — PrepareSyntaxHighlighter no longer re-highlights from line 0 every frame. Tracks how far tokenizer state has been prepared and resumes from there on forward scroll. Only resets on backward scroll or document edits above the prepared region. 3. Smarter visual-line cache invalidation — on edits that don't change newline count, only the edited line is invalidated; downstream entries are preserved. When newlines are inserted or removed, downstream entries are rekeyed (shifted) instead of dropped, keeping their built visual lines intact. 4. ASCII fast path in VisualLineBuilder — pure-ASCII lines (the common case for source code) skip GraphemeHelper.GetGraphemes entirely, avoiding iterator/allocation overhead per line build. Handles tabs inline. 5. Early-exit element draw loop — elements are ordered by visual column, so the draw loop skips elements before the visible start (continue) and breaks on the first element past the visible end (break), avoiding method-call overhead for off-screen elements on long lines. All three test projects pass (212 + 78 + 105 individually). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.Drawing.cs | 31 ++- src/Terminal.Gui.Editor/Editor.cs | 202 ++++++++++++++++-- .../Rendering/VisualLineBuilder.cs | 87 +++++++- 3 files changed, 296 insertions(+), 24 deletions(-) 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..845c4c03 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,119 @@ 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 = _maxVisualWidth; + var newMaxLine = _maxWidthLineNumber; + var needsFullRecompute = false; + + // 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 (newMaxLine == _maxWidthLineNumber && newMax < _maxVisualWidth) + { + // The old widest line got narrower — must do full recompute. + needsFullRecompute = true; + } + + if (needsFullRecompute) + { + _maxWidthDirty = true; + } + else + { + _maxVisualWidth = newMax; + _maxWidthLineNumber = newMaxLine; + } + + 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 +456,28 @@ 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 that point. + _highlighterPreparedUpToLine = -1; + } + } + private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) { if (_document is null || (_defaultVisualLineCache.Count == 0 && _drawVisualLineCache.Count == 0)) @@ -352,41 +485,72 @@ 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); + RekeyCache (_drawVisualLineCache, threshold, lineDelta); + + static void RekeyCache (Dictionary cache, int threshold, int lineDelta) { if (cache.Count == 0) { return; } + // Collect entries: invalidate the edited line itself, 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) + { + // The edited line — its content changed, must invalidate. + (toRemove ??= []).Add (kvp.Key); + } + else if (kvp.Key > threshold) { - (toRemove ??= []).Add (lineNumber); + if (lineDelta == 0) + { + // No newline change — downstream entries are still valid as-is. + // (Only the edited line itself needs invalidation, handled above.) + } + 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) From 744e5407061fb4101690745e9af06bd052f6613c Mon Sep 17 00:00:00 2001 From: Tig Date: Tue, 12 May 2026 06:50:24 -0600 Subject: [PATCH 2/4] chore: rename cleanup profile to 'TG.Text Full Cleanup' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in 'Full Cleanup' name was not resolvable by jb cleanupcode when defined only in team-shared .DotSettings — every Codex run hit 'Unable to find the code cleanup profile'. Rename to a unique custom name that resolves reliably from the .DotSettings file. Updated: .DotSettings, CLAUDE.md, .editorconfig, ci.yml, cleanup hook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/hooks/cleanup-cs.ps1 | 2 +- .editorconfig | 2 +- .github/workflows/ci.yml | 12 +++--- CLAUDE.md | 4 +- Terminal.Gui.Text.slnx.DotSettings | 67 ++++++++++++++++-------------- 5 files changed, 46 insertions(+), 41 deletions(-) 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 From 69c5efe0adeb6031f5fe512970308d0217e6cb76 Mon Sep 17 00:00:00 2001 From: Tig Date: Tue, 12 May 2026 07:03:37 -0600 Subject: [PATCH 3/4] fix: set DisableRealDriverIO in integration tests via ModuleInitializer The ANSI driver attempts real console I/O unless DisableRealDriverIO=1 is set. CI workflows set it in the environment, but local runs did not, causing the full integration test suite to hang when run on a real terminal. A ModuleInitializer now sets the env var at assembly load time so tests work identically locally and in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/ted/TedApp.cs | 2 +- .../Testing/TestEnvironment.cs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/Testing/TestEnvironment.cs 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/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"); + } +} From 2cd55f326fe37ef60671478cdfa28431457354ca Mon Sep 17 00:00:00 2001 From: Tig Date: Tue, 12 May 2026 07:10:56 -0600 Subject: [PATCH 4/4] fix: address 3 bugs from Codex review on PR #77 - InvalidateHighlighterState: set _highlighterPreparedUpToLine=0 (not -1) to avoid GetLineByNumber(0) crash and ensure ResetState() is called - RekeyCache: invalidate merged lines [threshold, threshold+removedNewlines] on newline deletion instead of rekeying stale content - UpdateMaxWidthIncremental: seed newMax=0 so shrinkage of the widest line is detectable without always falling through to full recompute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.cs | 46 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index 845c4c03..1a446c2e 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -396,9 +396,8 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) { // 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 = _maxVisualWidth; + var newMax = 0; var newMaxLine = _maxWidthLineNumber; - var needsFullRecompute = false; // Scan from firstAffected through the new lines that were inserted. var scanEnd = Math.Min (firstAffected.LineNumber + newlineCount, _document.LineCount); @@ -415,20 +414,17 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) } } - if (newMaxLine == _maxWidthLineNumber && newMax < _maxVisualWidth) + if (newMax >= _maxVisualWidth) { - // The old widest line got narrower — must do full recompute. - needsFullRecompute = true; - } - - if (needsFullRecompute) - { - _maxWidthDirty = true; + // The affected region has a line at least as wide — it's the new max. + _maxVisualWidth = newMax; + _maxWidthLineNumber = newMaxLine; } else { - _maxVisualWidth = newMax; - _maxWidthLineNumber = newMaxLine; + // 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; @@ -473,8 +469,11 @@ private void InvalidateHighlighterState (DocumentChangeEventArgs e) if (affectedLineIndex < _highlighterPreparedUpToLine) { - // Edit was before/within the prepared region — must re-prepare from that point. - _highlighterPreparedUpToLine = -1; + // 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; } } @@ -495,33 +494,36 @@ private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) var removedNewlines = removedText.Count (c => c == '\n'); var lineDelta = insertedNewlines - removedNewlines; - RekeyCache (_defaultVisualLineCache, threshold, lineDelta); - RekeyCache (_drawVisualLineCache, threshold, lineDelta); + RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines); + RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines); - static void RekeyCache (Dictionary cache, int threshold, int lineDelta) + static void RekeyCache (Dictionary cache, int threshold, int lineDelta, int removedNewlines) { if (cache.Count == 0) { return; } - // Collect entries: invalidate the edited line itself, rekey downstream. + // 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 (KeyValuePair kvp in cache) { - if (kvp.Key == threshold) + if (kvp.Key >= threshold && kvp.Key <= invalidateEnd) { - // The edited line — its content changed, must invalidate. + // The edited/merged line(s) — content changed, must invalidate. (toRemove ??= []).Add (kvp.Key); } - else if (kvp.Key > threshold) + else if (kvp.Key > invalidateEnd) { if (lineDelta == 0) { // No newline change — downstream entries are still valid as-is. - // (Only the edited line itself needs invalidation, handled above.) } else {