From 9e9d2f03d53bd0d606eb5007eec7cd5d55937c5c Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 21 May 2026 17:16:01 -0600 Subject: [PATCH 1/7] Use Terminal.Gui resource strings Add a reusable editor tab settings view and update ted to compose it with its bespoke config settings. Replace matching hardcoded dialog/menu labels with Terminal.Gui resource strings and preserve cancel-before-default button ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 13 +++- examples/ted/EditorSettingsDialog.cs | 58 +++----------- examples/ted/TedApp.FileOperations.cs | 6 +- examples/ted/TedApp.cs | 2 +- src/Terminal.Gui.Editor/EditorMenuBar.cs | 46 ++++++------ .../EditorTabSettingsTab.cs | 75 +++++++++++++++++++ src/Terminal.Gui.Editor/FindReplaceDialog.cs | 3 +- .../TedFileMenuShortcutTests.cs | 9 ++- .../TedSettingsPersistenceTests.cs | 10 +-- .../FileMenu_Shortcuts_Snapshot.ans | 12 +-- 10 files changed, 140 insertions(+), 94 deletions(-) create mode 100644 src/Terminal.Gui.Editor/EditorTabSettingsTab.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3d807107..bc13340b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,15 +84,22 @@ The boundary matters: anything that takes a dependency on `Terminal.Gui` types b See `specs/00-plan.md` §6 for the planned pipeline and full `Editor` public API sketch. -## AvaloniaEdit fork policy +## AvaloniaEdit fork policy — do not format lifted code -Code is lifted from AvaloniaEdit into the relevant `src/Terminal.Gui.Editor/` subfolders (`Document/`, `Utils/` so far; `Folding/`, `Search/`, `Indentation/`, `Highlighting/` to follow). The pinned upstream commit and per-file modification log live in `third_party/AvaloniaEdit/UPSTREAM.md` (with the upstream MIT `LICENSE` alongside). +Code is lifted from AvaloniaEdit into the relevant `src/Terminal.Gui.Editor/` subfolders (`Document/`, `Utils/`, `Folding/`, `Search/`, `Indentation/`, `Highlighting/`, and supporting lifted helpers). The pinned upstream commit and per-file modification log live in `third_party/AvaloniaEdit/UPSTREAM.md` (with the upstream MIT `LICENSE` alongside). + +**Do not run broad formatting or cleanup over lifted files.** Treat any file containing +`// Adapted for Terminal.Gui from AvaloniaEdit ...` as upstream-owned formatting, even when it +lives outside `third_party/`. `dotnet format`, ReSharper cleanup, Rider "cleanup on save", and +"format document" can create huge no-op diffs in these files; those diffs make future upstream +re-syncs painful and must be reverted before finishing work. For lifted files: - **Preserve original formatting and copyright headers.** House-style reformatting defeats the merge story. - Add the line `// Adapted for Terminal.Gui from AvaloniaEdit ` under the original header. - Targeted edits only: strip `using Avalonia.*`, remove `Dispatcher.UIThread.VerifyAccess ()` calls, replace `IBrush`/`Avalonia.Media.Color` with `Terminal.Gui.Color`, drop typeface/font-size from `HighlightingColor`. +- If a formatter or cleanup tool touches lifted files as a side effect, immediately revert those unrelated formatting-only hunks. Keep only the narrow semantic changes required by the task. - Log every modification in `third_party/AvaloniaEdit/UPSTREAM.md` along with the pinned upstream commit. The fork is **hard** — re-syncs are manual and deliberate, triggered only by upstream fixes we want. @@ -105,7 +112,7 @@ Adopts Terminal.Gui's house style. Three enforcement layers: 2. **`Terminal.Gui.Editor.sln.DotSettings` + `dotnet jb cleanupcode`** — ReSharper-driven cleanup ("TG.Editor 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. The file is named `*.sln.DotSettings` (not `*.slnx.DotSettings`) even though the solution is `.slnx`: ReSharper/Rider/`jb` resolve the team-shared layer using the `.sln.` infix (the IDE writes its companion `.sln.DotSettings.user`). The cleanup profile **must** be stored in the modern single-string serialized-`` format — ReSharper/`jb` 2024+ silently ignore the old per-task `…/=Name/CSReformatCode/@EntryIndexedValue` key layout, which makes the profile vanish from the IDE and unresolvable by `jb` (and CI's `|| true` then makes the cleanup gate a silent no-op). Edit the profile only via Rider → Settings | Code Cleanup → "Save to: This Solution Team-Shared". 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.Editor.slnx --exclude third_party/ && dotnet jb cleanupcode Terminal.Gui.Editor.slnx --profile="TG.Editor 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.Editor.slnx --exclude third_party/ && dotnet jb cleanupcode Terminal.Gui.Editor.slnx --profile="TG.Editor Full Cleanup"` (the Stop hook does this automatically). Then inspect the diff. If cleanup adjusted lifted AvaloniaEdit files (files with the `Adapted for Terminal.Gui from AvaloniaEdit` marker), revert those formatting-only changes before reporting done. Cleanup changes in non-lifted files are part of the work — re-stage and continue.** ### Formatting and spacing diff --git a/examples/ted/EditorSettingsDialog.cs b/examples/ted/EditorSettingsDialog.cs index 7cf3437a..3d506e5e 100644 --- a/examples/ted/EditorSettingsDialog.cs +++ b/examples/ted/EditorSettingsDialog.cs @@ -1,17 +1,17 @@ using Terminal.Gui.Editor; using Terminal.Gui.Resources; -using Terminal.Gui.Text.Indentation; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; namespace Ted; +/// +/// Settings dialog for the editor clet. Provides tabs for Config and Tab Settings. +/// internal sealed class EditorSettingsDialog : Dialog { private readonly CheckBox _autoCompleteCheck; - private readonly NumericUpDown _indentSize; - private readonly CheckBox _convertTabsCheck; - private readonly CheckBox _autoIndentCheck; + private readonly EditorTabSettingsTab _tabSettingsTab; internal EditorSettingsDialog (Editor editor) { @@ -20,44 +20,7 @@ internal EditorSettingsDialog (Editor editor) Height = 13; // --- Tab Settings tab --- - View tabSettingsTab = new () - { - Title = "_Tab Settings" - }; - - View label = new Label { Text = "_Indent size:" }; - _indentSize = new NumericUpDown - { - X = Pos.Right (label) + 1, - Value = editor.IndentationSize - }; - _indentSize.ValueChanging += (_, e) => - { - if (e.NewValue < 1) - { - e.Handled = true; - } - }; - - _convertTabsCheck = new CheckBox - { - Y = Pos.Bottom (_indentSize), - Title = "Con_vert Tabs to Spaces", - Value = editor.ConvertTabsToSpaces ? CheckState.Checked : CheckState.UnChecked - }; - - _autoIndentCheck = new CheckBox - { - Y = Pos.Bottom (_convertTabsCheck), - Title = "_Auto Indent", - Value = editor.IndentationStrategy is not null ? CheckState.Checked : CheckState.UnChecked - }; - - tabSettingsTab.Add ( - label, - _indentSize, - _convertTabsCheck, - _autoIndentCheck); + _tabSettingsTab = new EditorTabSettingsTab (editor); _autoCompleteCheck = new CheckBox { @@ -77,7 +40,7 @@ internal EditorSettingsDialog (Editor editor) Tabs tabs = new (); tabs.InsertTab (0, configTab); - tabs.InsertTab (1, tabSettingsTab); + tabs.InsertTab (1, _tabSettingsTab); Button okBtn = new () { @@ -104,13 +67,12 @@ internal EditorSettingsDialog (Editor editor) internal bool WasAccepted { get; private set; } + /// + /// Applies the accepted settings to the editor. Call only when is true. + /// internal void ApplyTo (Editor editor) { - editor.IndentationSize = Math.Max (1, _indentSize.Value); - editor.ConvertTabsToSpaces = _convertTabsCheck.Value == CheckState.Checked; - editor.IndentationStrategy = _autoIndentCheck.Value == CheckState.Checked - ? new DefaultIndentationStrategy () - : null; + _tabSettingsTab.ApplyTo (editor); editor.CompletionProvider = _autoCompleteCheck.Value == CheckState.Checked ? new WordCompletionProvider () : null; diff --git a/examples/ted/TedApp.FileOperations.cs b/examples/ted/TedApp.FileOperations.cs index 0da782c1..43a6f64b 100644 --- a/examples/ted/TedApp.FileOperations.cs +++ b/examples/ted/TedApp.FileOperations.cs @@ -263,7 +263,7 @@ internal void SetDocument (string text, string? filePath) /// Creates the used by . internal SaveDialog CreateSaveDialog () { - return new SaveDialog { Title = "Save File As", AllowsMultipleSelection = false, OpenMode = OpenMode.File }; + return new SaveDialog { Title = Strings.fdSaveAs, AllowsMultipleSelection = false, OpenMode = OpenMode.File }; } private string? ShowDefaultSaveDialog () @@ -292,8 +292,8 @@ private SaveChangesChoice ShowDefaultSaveChangesDialog () "Save changes?", "The document has unsaved changes. Save before quitting?", Strings.btnCancel, - "Do_n't Save", - Strings.btnSave); + Strings.btnNo, + Strings.btnYes); return result switch { diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index cc81f31a..cedf462b 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -126,7 +126,7 @@ public TedApp (bool readOnly = false, string? configPath = null) Menu.ViewMenu.PopoverMenu!.Root!.Add (_previewMarkdownMenuItem); Menu.Add (new MenuBarItem ("_Options", [new MenuItem ("_Settings...", string.Empty, ShowSettingsDialog)])); - Menu.Add (new MenuBarItem ("_Help", + Menu.Add (new MenuBarItem (Strings.menuHelp, [new MenuItem ("_About", "About ted", ShowAboutDialog)])); _fileNameShortcut = new Shortcut (Key.Empty, "", Open) { diff --git a/src/Terminal.Gui.Editor/EditorMenuBar.cs b/src/Terminal.Gui.Editor/EditorMenuBar.cs index 53e8eb55..02875bc8 100644 --- a/src/Terminal.Gui.Editor/EditorMenuBar.cs +++ b/src/Terminal.Gui.Editor/EditorMenuBar.cs @@ -192,37 +192,37 @@ private View[] CreateFileMenuItems () [ new MenuItem { - Title = "_New", - HelpText = "New file", + Title = Strings.cmdNew, + HelpText = Strings.cmdNew_Help, Key = Key.N.WithCtrl, Action = OnNew }, new MenuItem { - Title = "_Open...", - HelpText = "Open file", + Title = Strings.cmdOpen, + HelpText = Strings.cmdOpen_Help, Key = Key.O.WithCtrl, Action = OnOpen }, new MenuItem { - Title = "_Save", - HelpText = "Save file", + Title = Strings.cmdSave, + HelpText = Strings.cmdSave_Help, Key = Key.S.WithCtrl, Action = OnSave }, new MenuItem { - Title = "Save _As...", - HelpText = "Save file as", + Title = Strings.cmdSaveAs, + HelpText = Strings.cmdSaveAs_Help, Key = Key.S.WithCtrl.WithShift, Action = OnSaveAs }, new Line (), new MenuItem { - Title = "_Quit", - HelpText = "Quit", + Title = Strings.cmdQuit, + HelpText = Strings.cmdQuit_Help, Key = Application.GetDefaultKey (Command.Quit), Action = OnQuit } @@ -235,7 +235,7 @@ private View[] CreateEditMenuItems () [ new MenuItem { - Title = "_Find...", + Title = Strings.cmdFind, HelpText = "Find text in the current document", Key = KeyFor (Command.Find), Action = () => ActiveEditor.InvokeCommand (Command.Find) @@ -250,44 +250,44 @@ private View[] CreateEditMenuItems () new Line (), new MenuItem { - Title = "_Undo", - HelpText = "Undo", + Title = Strings.cmdUndo, + HelpText = Strings.cmdUndo_Help, Key = KeyFor (Command.Undo), Action = () => ActiveEditor.InvokeCommand (Command.Undo) }, new MenuItem { - Title = "_Redo", - HelpText = "Redo", + Title = Strings.cmdRedo, + HelpText = Strings.cmdRedo_Help, Key = KeyFor (Command.Redo), Action = () => ActiveEditor.InvokeCommand (Command.Redo) }, new Line (), new MenuItem { - Title = "Cu_t", - HelpText = "Cut", + Title = Strings.cmdCut, + HelpText = Strings.cmdCut_Help, Key = KeyFor (Command.Cut), Action = () => ActiveEditor.InvokeCommand (Command.Cut) }, new MenuItem { - Title = "_Copy", - HelpText = "Copy", + Title = Strings.cmdCopy, + HelpText = Strings.cmdCopy_Help, Key = KeyFor (Command.Copy), Action = () => ActiveEditor.InvokeCommand (Command.Copy) }, new MenuItem { - Title = "_Paste", - HelpText = "Paste", + Title = Strings.cmdPaste, + HelpText = Strings.cmdPaste_Help, Key = KeyFor (Command.Paste), Action = () => ActiveEditor.InvokeCommand (Command.Paste) }, new MenuItem { - Title = "Select _All", - HelpText = "Select all", + Title = Strings.cmdSelectAll, + HelpText = Strings.cmdSelectAll_Help, Key = KeyFor (Command.SelectAll), Action = () => ActiveEditor.InvokeCommand (Command.SelectAll) } diff --git a/src/Terminal.Gui.Editor/EditorTabSettingsTab.cs b/src/Terminal.Gui.Editor/EditorTabSettingsTab.cs new file mode 100644 index 00000000..09538de7 --- /dev/null +++ b/src/Terminal.Gui.Editor/EditorTabSettingsTab.cs @@ -0,0 +1,75 @@ +using Terminal.Gui.Text.Indentation; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Editor; + +/// +/// Settings tab for editor indentation and tab behavior. +/// +public sealed class EditorTabSettingsTab : View +{ + private readonly CheckBox _autoIndentCheck; + private readonly CheckBox _convertTabsCheck; + private readonly NumericUpDown _indentSize; + + /// + /// Initializes a new from the current editor settings. + /// + /// The editor whose tab settings should be displayed. + public EditorTabSettingsTab (Editor editor) + { + ArgumentNullException.ThrowIfNull (editor); + + Title = "_Tab Settings"; + + View label = new Label { Text = "_Indent size:" }; + _indentSize = new NumericUpDown + { + X = Pos.Right (label) + 1, + Value = editor.IndentationSize + }; + _indentSize.ValueChanging += (_, e) => + { + if (e.NewValue < 1) + { + e.Handled = true; + } + }; + + _convertTabsCheck = new CheckBox + { + Y = Pos.Bottom (_indentSize), + Title = "Con_vert Tabs to Spaces", + Value = editor.ConvertTabsToSpaces ? CheckState.Checked : CheckState.UnChecked + }; + + _autoIndentCheck = new CheckBox + { + Y = Pos.Bottom (_convertTabsCheck), + Title = "_Auto Indent", + Value = editor.IndentationStrategy is not null ? CheckState.Checked : CheckState.UnChecked + }; + + Add ( + label, + _indentSize, + _convertTabsCheck, + _autoIndentCheck); + } + + /// + /// Applies the accepted tab settings to the editor. + /// + /// The editor to update. + public void ApplyTo (Editor editor) + { + ArgumentNullException.ThrowIfNull (editor); + + editor.IndentationSize = Math.Max (1, _indentSize.Value); + editor.ConvertTabsToSpaces = _convertTabsCheck.Value == CheckState.Checked; + editor.IndentationStrategy = _autoIndentCheck.Value == CheckState.Checked + ? new DefaultIndentationStrategy () + : null; + } +} diff --git a/src/Terminal.Gui.Editor/FindReplaceDialog.cs b/src/Terminal.Gui.Editor/FindReplaceDialog.cs index 78a13f89..f6d8e658 100644 --- a/src/Terminal.Gui.Editor/FindReplaceDialog.cs +++ b/src/Terminal.Gui.Editor/FindReplaceDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui.Document.Search; +using Terminal.Gui.Resources; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; @@ -64,7 +65,7 @@ public FindReplaceDialog (Editor editor, bool selectReplaceTab) _regexCheckBox.Y = Pos.Bottom (tabs); _statusLabel.Y = Pos.Bottom (tabs) + 1; - AddButton (new Button { Text = "_Close" }); + AddButton (new Button { Text = Strings.cmdClose }); Add (tabs, _matchCaseCheckBox, _wholeWordCheckBox, _regexCheckBox, _statusLabel); if (selectReplaceTab) diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedFileMenuShortcutTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedFileMenuShortcutTests.cs index f077a877..1310ee6e 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedFileMenuShortcutTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedFileMenuShortcutTests.cs @@ -4,6 +4,7 @@ using Ted; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; +using Terminal.Gui.Resources; using Terminal.Gui.Testing; using Terminal.Gui.Views; using Xunit; @@ -48,7 +49,7 @@ public async Task FileMenu_Shortcuts_Snapshot () } [Fact] - public void SaveAs_Dialog_Title_Is_Save_File_As_InPortugueseCulture () + public void SaveAs_Dialog_Title_Uses_Resource_InPortugueseCulture () { CultureInfo originalCulture = CultureInfo.CurrentCulture; CultureInfo originalUiCulture = CultureInfo.CurrentUICulture; @@ -61,7 +62,7 @@ public void SaveAs_Dialog_Title_Is_Save_File_As_InPortugueseCulture () using TedApp ted = new (); using SaveDialog dialog = ted.CreateSaveDialog (); - Assert.Equal ("Save File As", dialog.Title); + Assert.Equal (Strings.fdSaveAs, dialog.Title); } finally { @@ -71,7 +72,7 @@ public void SaveAs_Dialog_Title_Is_Save_File_As_InPortugueseCulture () } [Fact] - public void SaveAs_Dialog_Title_Is_Save_File_As () + public void SaveAs_Dialog_Title_Uses_Resource () { // Verify that CreateSaveDialog (used by ShowDefaultSaveDialog) produces a dialog // with the expected title. This catches regressions if someone removes or changes @@ -79,7 +80,7 @@ public void SaveAs_Dialog_Title_Is_Save_File_As () using TedApp ted = new (); using SaveDialog dialog = ted.CreateSaveDialog (); - Assert.Equal ("Save File As", dialog.Title); + Assert.Equal (Strings.fdSaveAs, dialog.Title); Assert.False (dialog.AllowsMultipleSelection); Assert.Equal (OpenMode.File, dialog.OpenMode); } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs index f7b55b4b..7e2ee0e4 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs @@ -266,16 +266,16 @@ public void SaveViewSettings_Preserves_Other_TopLevel_Keys_And_Nests_Under_AppSe } [Fact] - public void SettingsDialog_IndentSize_Rejects_Zero () + public void EditorTabSettingsTab_IndentSize_Rejects_Zero () { TedApp app = new (); - EditorSettingsDialog dialog = new (app.Editor); + EditorTabSettingsTab tab = new (app.Editor); // Access _indentSize via reflection (it's private) FieldInfo? indentSizeField = - typeof (EditorSettingsDialog).GetField ("_indentSize", BindingFlags.Instance | BindingFlags.NonPublic); + typeof (EditorTabSettingsTab).GetField ("_indentSize", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull (indentSizeField); - NumericUpDown indentControl = (NumericUpDown)indentSizeField.GetValue (dialog)!; + NumericUpDown indentControl = (NumericUpDown)indentSizeField.GetValue (tab)!; var valueBefore = indentControl.Value; Assert.True (valueBefore >= 1); @@ -284,7 +284,7 @@ public void SettingsDialog_IndentSize_Rejects_Zero () indentControl.Value = 0; Assert.Equal (valueBefore, indentControl.Value); - dialog.ApplyTo (app.Editor); + tab.ApplyTo (app.Editor); Assert.True (app.Editor.IndentationSize >= 1); } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/FileMenu_Shortcuts_Snapshot.ans b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/FileMenu_Shortcuts_Snapshot.ans index b2ed6f12..e5db76b7 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/FileMenu_Shortcuts_Snapshot.ans +++ b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/FileMenu_Shortcuts_Snapshot.ans @@ -1,10 +1,10 @@  File  Edit View Options Help  - New New file Ctrl+N  - Open... Open file Ctrl+O  - Save Save file Ctrl+S  - Save As... Save file as Ctrl+Shift+S  -──────────────────────────────────────── - Quit Quit Esc  + New New file Ctrl+N  + Open... Open a file Ctrl+O  + Save Save file Ctrl+S  + Save As... Save file as Ctrl+Shift+S  +────────────────────────────────────────── + Exit Exit the application Esc  From f6373e3b2b21b0061c6574b14faac9bc76cc5b5d Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 21 May 2026 17:26:00 -0600 Subject: [PATCH 2/7] Harden style checks for lifted code Update the brittle edit-menu assertion to follow Terminal.Gui resource labels, and make CI/local cleanup consistently exclude AvaloniaEdit-lifted folders from broad formatting passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/hooks/cleanup-cs.ps1 | 42 +++++++++++++++++-- .github/workflows/ci.yml | 19 +++++++-- CLAUDE.md | 4 +- .../TedAppTests.cs | 5 ++- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.claude/hooks/cleanup-cs.ps1 b/.claude/hooks/cleanup-cs.ps1 index 2afce2f1..60093d3c 100644 --- a/.claude/hooks/cleanup-cs.ps1 +++ b/.claude/hooks/cleanup-cs.ps1 @@ -7,7 +7,8 @@ # 2. dotnet jb cleanupcode (slower, ReSharper-driven; catches what dotnet format misses — # var preferences, expression-bodied members, XML doc spacing, using sorting) # -# Skips itself entirely if the working tree has no modified .cs files outside third_party/. +# Skips itself entirely if the working tree has no modified .cs files outside third_party/ +# and lifted AvaloniaEdit folders. $ErrorActionPreference = 'Stop' @@ -15,10 +16,33 @@ $repo = & git rev-parse --show-toplevel 2>$null if (-not $repo) { exit 0 } Set-Location $repo +$liftedPrefixes = @( + 'src/Terminal.Gui.Editor/Document/', + 'src/Terminal.Gui.Editor/Extensions/', + 'src/Terminal.Gui.Editor/Folding/', + 'src/Terminal.Gui.Editor/Highlighting/', + 'src/Terminal.Gui.Editor/Indentation/', + 'src/Terminal.Gui.Editor/Search/', + 'src/Terminal.Gui.Editor/Utils/' +) + +function Test-IsLiftedPath ([string] $path) { + $normalized = $path -replace '\\', '/' + if ($normalized -like 'third_party/*') { return $true } + + foreach ($prefix in $liftedPrefixes) { + if ($normalized.StartsWith($prefix, [StringComparison]::Ordinal)) { + return $true + } + } + + return $false +} + # Modified .cs files (staged + unstaged + untracked), excluding lifted upstream code. $changed = & git status --porcelain | ForEach-Object { ($_ -replace '^...', '').Trim('"') } | - Where-Object { $_ -like '*.cs' -and $_ -notlike 'third_party/*' } + Where-Object { $_ -like '*.cs' -and -not (Test-IsLiftedPath $_) } if (-not $changed) { exit 0 } @@ -26,8 +50,18 @@ if (-not $changed) { exit 0 } & dotnet tool restore --tool-manifest .config/dotnet-tools.json *> $null # dotnet format (whitespace + style + analyzers) on the whole solution. Single pass is faster -# than per-file invocations because the workspace loads once. -& dotnet format Terminal.Gui.Editor.slnx --no-restore --exclude third_party/ *> $null +# than per-file invocations because the workspace loads once. Exclude lifted code so the +# formatter cannot create upstream-merge-hostile churn as a side effect. +& dotnet format Terminal.Gui.Editor.slnx ` + --no-restore ` + --exclude third_party/ ` + --exclude src/Terminal.Gui.Editor/Document/ ` + --exclude src/Terminal.Gui.Editor/Extensions/ ` + --exclude src/Terminal.Gui.Editor/Folding/ ` + --exclude src/Terminal.Gui.Editor/Highlighting/ ` + --exclude src/Terminal.Gui.Editor/Indentation/ ` + --exclude src/Terminal.Gui.Editor/Search/ ` + --exclude src/Terminal.Gui.Editor/Utils/ *> $null # ReSharper code cleanup. Uses the built-in profile name because jb cleanupcode does not # always discover custom profile names from team-shared .DotSettings files reliably; the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7db6a41..5288aad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,10 +44,22 @@ jobs: # Style gate runs once (Linux). dotnet format catches whitespace/analyzer drift; # jb cleanupcode catches what dotnet format misses (var, expression-bodied members, # XML doc spacing, using sorting). Both must produce a clean working tree. + # + # Keep lifted AvaloniaEdit code out of broad format/cleanup passes. Those files preserve + # upstream formatting for manual re-syncs; formatting-only churn there is not actionable + # style drift. Keep this exclude list in sync with .claude/hooks/cleanup-cs.ps1. - name: Verify code style — dotnet format if: matrix.os == 'ubuntu-latest' run: | - dotnet format Terminal.Gui.Editor.slnx --no-restore --exclude third_party/ --verify-no-changes + dotnet format Terminal.Gui.Editor.slnx --no-restore --verify-no-changes \ + --exclude third_party/ \ + --exclude src/Terminal.Gui.Editor/Document/ \ + --exclude src/Terminal.Gui.Editor/Extensions/ \ + --exclude src/Terminal.Gui.Editor/Folding/ \ + --exclude src/Terminal.Gui.Editor/Highlighting/ \ + --exclude src/Terminal.Gui.Editor/Indentation/ \ + --exclude src/Terminal.Gui.Editor/Search/ \ + --exclude src/Terminal.Gui.Editor/Utils/ - name: Verify code style — ReSharper cleanupcode if: matrix.os == 'ubuntu-latest' @@ -59,10 +71,10 @@ jobs: dotnet jb cleanupcode Terminal.Gui.Editor.slnx \ --profile="TG.Editor Full Cleanup" \ --no-build \ - --exclude="third_party/**/*;src/Terminal.Gui.Editor/Document/**/*;src/Terminal.Gui.Editor/Utils/**/*;src/Terminal.Gui.Editor/Search/**/*" \ + --exclude="third_party/**/*;src/Terminal.Gui.Editor/Document/**/*;src/Terminal.Gui.Editor/Extensions/**/*;src/Terminal.Gui.Editor/Folding/**/*;src/Terminal.Gui.Editor/Highlighting/**/*;src/Terminal.Gui.Editor/Indentation/**/*;src/Terminal.Gui.Editor/Search/**/*;src/Terminal.Gui.Editor/Utils/**/*" \ || true if ! git diff --exit-code; then - echo "::error::ReSharper code cleanup found style drift. Run 'dotnet jb cleanupcode Terminal.Gui.Editor.slnx --profile=\"TG.Editor Full Cleanup\" --no-build --exclude=\"third_party/**/*;src/Terminal.Gui.Editor/Document/**/*;src/Terminal.Gui.Editor/Utils/**/*;src/Terminal.Gui.Editor/Search/**/*\"' locally and commit the result." + echo "::error::ReSharper code cleanup found style drift outside lifted AvaloniaEdit code. Run '.claude/hooks/cleanup-cs.ps1' locally and commit the result." exit 1 fi @@ -81,4 +93,3 @@ jobs: # NOTE: Performance smoke tests and the BenchmarkDotNet baseline compare live in a # dedicated workflow: .github/workflows/perf.yml (ubuntu-latest only). They were split # out so this CI workflow stays purely correctness-focused and fast across all three OSes. - diff --git a/CLAUDE.md b/CLAUDE.md index bc13340b..078bedf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ Run a single test by passing xUnit.v3 filter args after `--`: dotnet run --project tests/Terminal.Gui.Editor.Tests -- -method "*MyTestName*" ``` -CI verifies formatting with `dotnet format Terminal.Gui.Editor.slnx --verify-no-changes --exclude third_party/`. Run the same locally before pushing if you've touched C# files outside `third_party/`. +CI verifies formatting with `dotnet format Terminal.Gui.Editor.slnx --verify-no-changes` while excluding `third_party/` and AvaloniaEdit-lifted folders. Run `.claude/hooks/cleanup-cs.ps1` locally before pushing if you've touched C# files outside lifted code. ### Verifying the *look* (ANSI snapshots) — MANDATORY for render changes @@ -112,7 +112,7 @@ Adopts Terminal.Gui's house style. Three enforcement layers: 2. **`Terminal.Gui.Editor.sln.DotSettings` + `dotnet jb cleanupcode`** — ReSharper-driven cleanup ("TG.Editor 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. The file is named `*.sln.DotSettings` (not `*.slnx.DotSettings`) even though the solution is `.slnx`: ReSharper/Rider/`jb` resolve the team-shared layer using the `.sln.` infix (the IDE writes its companion `.sln.DotSettings.user`). The cleanup profile **must** be stored in the modern single-string serialized-`` format — ReSharper/`jb` 2024+ silently ignore the old per-task `…/=Name/CSReformatCode/@EntryIndexedValue` key layout, which makes the profile vanish from the IDE and unresolvable by `jb` (and CI's `|| true` then makes the cleanup gate a silent no-op). Edit the profile only via Rider → Settings | Code Cleanup → "Save to: This Solution Team-Shared". 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.Editor.slnx --exclude third_party/ && dotnet jb cleanupcode Terminal.Gui.Editor.slnx --profile="TG.Editor Full Cleanup"` (the Stop hook does this automatically). Then inspect the diff. If cleanup adjusted lifted AvaloniaEdit files (files with the `Adapted for Terminal.Gui from AvaloniaEdit` marker), revert those formatting-only changes before reporting done. Cleanup changes in non-lifted files are part of the work — re-stage and continue.** +**Before declaring work complete, an agent must run `.claude/hooks/cleanup-cs.ps1` (the Stop hook does this automatically). Then inspect the diff. If cleanup adjusted lifted AvaloniaEdit files (files with the `Adapted for Terminal.Gui from AvaloniaEdit` marker), revert those formatting-only changes before reporting done and fix the cleanup exclude list. Cleanup changes in non-lifted files are part of the work — re-stage and continue.** ### Formatting and spacing diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index 4e81a413..386bedd4 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -8,6 +8,7 @@ using Terminal.Gui.Configuration; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; +using Terminal.Gui.Resources; using Terminal.Gui.Testing; using Terminal.Gui.Text.Indentation; using Xunit; @@ -730,14 +731,14 @@ public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace () { await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); - DriverAssert.ContentsDoesNotContain (fx.Driver, "Find..."); + DriverAssert.ContentsDoesNotContain (fx.Driver, Strings.cmdFind.Replace ("_", string.Empty)); DriverAssert.ContentsDoesNotContain (fx.Driver, "Replace..."); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; fx.Injector.InjectKey (Key.E.WithAlt, options); fx.Render (); - DriverAssert.ContentsContains (fx.Driver, "Find..."); + DriverAssert.ContentsContains (fx.Driver, Strings.cmdFind.Replace ("_", string.Empty)); DriverAssert.ContentsContains (fx.Driver, "Replace..."); } From 0d4f2600b972cd4b1307d8b8542e83ad578c16e8 Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 21 May 2026 17:30:39 -0600 Subject: [PATCH 3/7] Resolve develop rebase conflict Restore the System.Text using and settings-dialog control flow after rebasing over current develop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/ted/TedApp.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index cedf462b..5227f648 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -1,4 +1,4 @@ -// This is a demo of ted, the Terminal.Gui.Editor example using System.Text; +using System.Text; using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Document; @@ -304,10 +304,7 @@ private void ShowSettingsDialog () EditorSettingsDialog dialog = new (Editor); App.Run (dialog); - - - - Saveif (dialog.WasAccepted) + if (dialog.WasAccepted) { dialog.ApplyTo (Editor); SaveViewSettings (); From 4a3410a54151bed87041d62b90de32863bee0b93 Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 22 May 2026 14:57:48 +0100 Subject: [PATCH 4/7] Fixes #215. TextChanged event semantics are confusing --- .../Document/TextDocument.cs | 13 ++++++ .../EditorTextChangedIntegrationTests.cs | 44 +++++++++++++++++++ .../TextDocumentTextChangedTests.cs | 36 +++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs create mode 100644 tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs diff --git a/src/Terminal.Gui.Editor/Document/TextDocument.cs b/src/Terminal.Gui.Editor/Document/TextDocument.cs index f91959c8..c4d5aa7c 100644 --- a/src/Terminal.Gui.Editor/Document/TextDocument.cs +++ b/src/Terminal.Gui.Editor/Document/TextDocument.cs @@ -366,10 +366,23 @@ public string Text VerifyAccess(); if (value == null) throw new ArgumentNullException(nameof(value)); + if (IsSameText(value)) + return; Replace(0, _rope.Length, value); } } + private bool IsSameText(string value) + { + if (value.Length != _rope.Length) + return false; + + if (_cachedText?.Target is string cachedText && cachedText == value) + return true; + + return _rope.GetMemory(0, value.Length).Span.SequenceEqual(value.AsSpan()); + } + #nullable enable /// /// Streams the document to using without materializing the diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs new file mode 100644 index 00000000..f448d2c8 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs @@ -0,0 +1,44 @@ +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +public class EditorTextChangedIntegrationTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + [Fact] + public async Task TextChanged_Fires_When_Typing_In_Editor () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello")); + fx.Top.Editor.SetFocus (); + + var textChangedCount = 0; + fx.Top.Editor.Document!.TextChanged += (_, _) => textChangedCount++; + + fx.Injector.InjectKey (Key.A, Direct); + fx.Injector.InjectKey (Key.B, Direct); + + Assert.Equal (2, textChangedCount); + Assert.Equal ("abhello", fx.Top.Editor.Document.Text); + } + + [Fact] + public async Task TextChanged_Fires_When_Pasting_Text () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello")); + fx.Top.Editor.SetFocus (); + + var textChangedCount = 0; + fx.Top.Editor.Document!.TextChanged += (_, _) => textChangedCount++; + + fx.App.Clipboard?.TrySetClipboardData (" world"); + fx.Top.Editor.CaretOffset = 5; + fx.Injector.InjectKey (Key.V.WithCtrl, Direct); + + Assert.Equal (1, textChangedCount); + Assert.Equal ("hello world", fx.Top.Editor.Document.Text); + } +} diff --git a/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs b/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs new file mode 100644 index 00000000..79ac76a0 --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs @@ -0,0 +1,36 @@ +// CoPilot - gpt-5.4 + +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +public class TextDocumentTextChangedTests +{ + [Fact] + public void TextChanged_Fires_When_Text_Is_Changed () + { + TextDocument document = new ("alpha"); + var fired = false; + + document.TextChanged += (_, _) => fired = true; + + document.Text = "beta"; + + Assert.True (fired); + Assert.Equal ("beta", document.Text); + } + + [Fact] + public void TextChanged_Does_Not_Fire_When_Text_Is_Unchanged () + { + TextDocument document = new ("alpha"); + var textChangedCount = 0; + + document.TextChanged += (_, _) => textChangedCount++; + + document.Text = "alpha"; + + Assert.Equal (0, textChangedCount); + } +} From 88a90923466652be82588e5a4de6517c5cdda65f Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 22 May 2026 08:53:46 -0600 Subject: [PATCH 5/7] feat: include AOT binaries of ted in release artifacts Add NativeAOT publish properties to ted.csproj and extend the release workflow to build platform-specific AOT binaries (linux-x64, osx-arm64, win-x64), upload them as artifacts, and attach them to the GitHub Release on tag pushes. Closes #219 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 89 ++++++++++++++++++++++++++++++++++- examples/ted/ted.csproj | 8 ++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 596a1eda..0d3ec782 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,7 +65,13 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-arm64 + - os: windows-latest + rid: win-x64 runs-on: ${{ matrix.os }} env: VERSION: ${{ needs.resolve-version.outputs.version }} @@ -93,6 +99,22 @@ jobs: - name: Terminal.Gui.Editor.IntegrationTests run: dotnet run --project tests/Terminal.Gui.Editor.IntegrationTests --no-build -c Release + - name: Publish ted AOT + run: > + dotnet publish examples/ted -c Release + -r ${{ matrix.rid }} + --self-contained + -p:PublishAot=true + -p:Version=${{ env.VERSION }} + -o publish/${{ matrix.rid }} + + - name: Upload ted artifact + uses: actions/upload-artifact@v4 + with: + name: ted-${{ matrix.rid }} + path: publish/${{ matrix.rid }}/ + retention-days: 30 + pack-and-publish: needs: [resolve-version, build-and-test] runs-on: ubuntu-latest @@ -124,6 +146,71 @@ jobs: --source https://api.nuget.org/v3/index.json --skip-duplicate + # Attach ted AOT binaries to the GitHub Release (tag pushes only). + # The Release is created by finalize-release.yml before the tag push triggers this workflow. + release-assets: + if: github.ref_type == 'tag' + needs: [resolve-version, build-and-test] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.resolve-version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v5 + + - name: Download all ted artifacts + uses: actions/download-artifact@v4 + with: + pattern: ted-* + path: artifacts/ + + - name: Package release archives + shell: bash + run: | + set -euo pipefail + mkdir -p dist + + package_unix() { + local rid="$1" + local src="artifacts/ted-${rid}" + if [ ! -d "$src" ]; then + echo "::warning::No artifact for ${rid}, skipping" + return 0 + fi + chmod +x "$src/ted" 2>/dev/null || true + tar -czf "dist/ted-${VERSION}-${rid}.tar.gz" -C "$src" . + echo "Created dist/ted-${VERSION}-${rid}.tar.gz" + } + + package_windows() { + local rid="$1" + local src="artifacts/ted-${rid}" + if [ ! -d "$src" ]; then + echo "::warning::No artifact for ${rid}, skipping" + return 0 + fi + (cd "$src" && zip -r "../../dist/ted-${VERSION}-${rid}.zip" .) + echo "Created dist/ted-${VERSION}-${rid}.zip" + } + + package_unix osx-arm64 + package_unix linux-x64 + package_windows win-x64 + + ls -la dist/ + + - name: Upload to GitHub Release + shell: bash + run: | + TAG="${GITHUB_REF_NAME}" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + echo "::warning::Release $TAG not found; creating one." + gh release create "$TAG" --title "ted $TAG" --generate-notes dist/* + fi + # Notify downstream repos (gui-cs/clet) so they can rebuild against the new Editor version. # Uses a PAT stored as CLET_DISPATCH_TOKEN with `repo` scope on gui-cs/clet. notify-downstream: diff --git a/examples/ted/ted.csproj b/examples/ted/ted.csproj index ed9fa8af..06d5c66b 100644 --- a/examples/ted/ted.csproj +++ b/examples/ted/ted.csproj @@ -5,6 +5,14 @@ Ted ted false + + + true + false + true + false + false + true From 2bc35520f9a789980f7b7a1f88b0d14d04021e38 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 22 May 2026 10:05:23 -0600 Subject: [PATCH 6/7] Clarify TextDocument text change events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Terminal.Gui.Editor/Document/IDocument.cs | 15 +++++- .../Document/TextDocument.cs | 42 +++++++++------- .../EditorTextChangedIntegrationTests.cs | 44 ----------------- .../TextDocumentTextChangedTests.cs | 48 +++++++++---------- third_party/AvaloniaEdit/UPSTREAM.md | 3 +- 5 files changed, 65 insertions(+), 87 deletions(-) delete mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs diff --git a/src/Terminal.Gui.Editor/Document/IDocument.cs b/src/Terminal.Gui.Editor/Document/IDocument.cs index df2ac106..5556818f 100644 --- a/src/Terminal.Gui.Editor/Document/IDocument.cs +++ b/src/Terminal.Gui.Editor/Document/IDocument.cs @@ -47,15 +47,28 @@ public interface IDocument : ITextSource, IServiceProvider /// This event is called directly after a change is applied to the document. /// /// + /// + /// This is a per-edit notification and is raised once for each applied document + /// edit. Use to observe the completion of a whole + /// update group. + /// + /// /// It is invalid to modify the document within this event handler. /// Aborting the event handler (by throwing an exception) is likely to cause corruption of data structures /// that listen to the Changing and Changed events. + /// /// event EventHandler TextChanged; /// /// This event is called after a group of changes is completed. /// + /// + /// + /// This is raised after finishes an update group + /// and may represent multiple individual document edits. + /// + /// /// event EventHandler ChangeCompleted; @@ -318,4 +331,4 @@ public virtual TextChangeEventArgs Invert() return new TextChangeEventArgs(Offset, InsertedText, RemovedText); } } -} \ No newline at end of file +} diff --git a/src/Terminal.Gui.Editor/Document/TextDocument.cs b/src/Terminal.Gui.Editor/Document/TextDocument.cs index c4d5aa7c..bc9271ca 100644 --- a/src/Terminal.Gui.Editor/Document/TextDocument.cs +++ b/src/Terminal.Gui.Editor/Document/TextDocument.cs @@ -366,23 +366,10 @@ public string Text VerifyAccess(); if (value == null) throw new ArgumentNullException(nameof(value)); - if (IsSameText(value)) - return; Replace(0, _rope.Length, value); } } - private bool IsSameText(string value) - { - if (value.Length != _rope.Length) - return false; - - if (_cachedText?.Target is string cachedText && cachedText == value) - return true; - - return _rope.GetMemory(0, value.Length).Span.SequenceEqual(value.AsSpan()); - } - #nullable enable /// /// Streams the document to using without materializing the @@ -429,7 +416,21 @@ public async Task SaveAsync (Stream stream, IProgress? pro /// /// This event is called after a group of changes is completed. /// - /// + /// + /// + /// This is a change-completed notification. It is raised from + /// after one or more document edits have completed, and it may represent multiple + /// calls to , , or + /// within the same update group. + /// + /// + /// To observe each individual edit with change details, use . + /// Consumers working through can use + /// for per-edit notifications and + /// for this completed-group notification. + /// + /// + /// public event EventHandler TextChanged; event EventHandler IDocument.ChangeCompleted @@ -483,7 +484,7 @@ public int TextLength /// /// EndUpdate() /// - /// event is raised + /// Completed-group event is raised /// event is raised (for the Text, TextLength, LineCount properties, in that order) /// End of change group (on undo stack) /// event is raised @@ -511,9 +512,16 @@ event EventHandler IDocument.TextChanging } /// - /// Is raised after the document has changed. + /// Is raised after an individual document edit has been applied. /// - /// + /// + /// + /// This event is raised once for each applied edit and carries the + /// for that edit. In contrast, + /// is raised once after a whole update group completes. + /// + /// + /// public event EventHandler Changed; private event EventHandler TextChangedInternal; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs deleted file mode 100644 index f448d2c8..00000000 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTextChangedIntegrationTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Terminal.Gui.Editor.IntegrationTests.Testing; -using Terminal.Gui.Input; -using Terminal.Gui.Testing; -using Xunit; - -namespace Terminal.Gui.Editor.IntegrationTests; - -public class EditorTextChangedIntegrationTests -{ - private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; - - [Fact] - public async Task TextChanged_Fires_When_Typing_In_Editor () - { - await using AppFixture fx = new (() => new EditorTestHost ("hello")); - fx.Top.Editor.SetFocus (); - - var textChangedCount = 0; - fx.Top.Editor.Document!.TextChanged += (_, _) => textChangedCount++; - - fx.Injector.InjectKey (Key.A, Direct); - fx.Injector.InjectKey (Key.B, Direct); - - Assert.Equal (2, textChangedCount); - Assert.Equal ("abhello", fx.Top.Editor.Document.Text); - } - - [Fact] - public async Task TextChanged_Fires_When_Pasting_Text () - { - await using AppFixture fx = new (() => new EditorTestHost ("hello")); - fx.Top.Editor.SetFocus (); - - var textChangedCount = 0; - fx.Top.Editor.Document!.TextChanged += (_, _) => textChangedCount++; - - fx.App.Clipboard?.TrySetClipboardData (" world"); - fx.Top.Editor.CaretOffset = 5; - fx.Injector.InjectKey (Key.V.WithCtrl, Direct); - - Assert.Equal (1, textChangedCount); - Assert.Equal ("hello world", fx.Top.Editor.Document.Text); - } -} diff --git a/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs b/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs index 79ac76a0..21a258c3 100644 --- a/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs +++ b/tests/Terminal.Gui.Editor.Tests/TextDocumentTextChangedTests.cs @@ -1,4 +1,4 @@ -// CoPilot - gpt-5.4 +// CoPilot - gpt-5.5 using Terminal.Gui.Document; using Xunit; @@ -8,29 +8,29 @@ namespace Terminal.Gui.Editor.Tests; public class TextDocumentTextChangedTests { [Fact] - public void TextChanged_Fires_When_Text_Is_Changed () + public void TextChanged_And_ChangeCompleted_Fire_Once_For_Update_Group () { - TextDocument document = new ("alpha"); - var fired = false; - - document.TextChanged += (_, _) => fired = true; - - document.Text = "beta"; - - Assert.True (fired); - Assert.Equal ("beta", document.Text); - } - - [Fact] - public void TextChanged_Does_Not_Fire_When_Text_Is_Unchanged () - { - TextDocument document = new ("alpha"); - var textChangedCount = 0; - - document.TextChanged += (_, _) => textChangedCount++; - - document.Text = "alpha"; - - Assert.Equal (0, textChangedCount); + TextDocument document = new (); + IDocument idocument = document; + var changedCount = 0; + var interfaceTextChangedCount = 0; + var publicTextChangedCount = 0; + var changeCompletedCount = 0; + + document.Changed += (_, _) => changedCount++; + idocument.TextChanged += (_, _) => interfaceTextChangedCount++; + document.TextChanged += (_, _) => publicTextChangedCount++; + idocument.ChangeCompleted += (_, _) => changeCompletedCount++; + + document.BeginUpdate (); + document.Insert (0, "a"); + document.Insert (1, "b"); + document.EndUpdate (); + + Assert.Equal (2, changedCount); + Assert.Equal (2, interfaceTextChangedCount); + Assert.Equal (1, publicTextChangedCount); + Assert.Equal (1, changeCompletedCount); + Assert.Equal ("ab", document.Text); } } diff --git a/third_party/AvaloniaEdit/UPSTREAM.md b/third_party/AvaloniaEdit/UPSTREAM.md index 523f1191..bd60cd29 100644 --- a/third_party/AvaloniaEdit/UPSTREAM.md +++ b/third_party/AvaloniaEdit/UPSTREAM.md @@ -78,10 +78,11 @@ Each lifted file carries `// Adapted for Terminal.Gui from AvaloniaEdit d7a6b63` | File | Modification | |---|---| | All `Document/*.cs`, `Utils/*.cs`, `Search/*.cs` | `namespace AvaloniaEdit.Document` → `namespace Terminal.Gui.Document`; `namespace AvaloniaEdit.Utils` → `namespace Terminal.Gui.Document.Utils`; `namespace AvaloniaEdit.Search` → `namespace Terminal.Gui.Document.Search`; `using AvaloniaEdit.Document` / `using AvaloniaEdit.Utils` rewritten to match. | +| `Document/IDocument.cs` | **Documentation clarification**: expanded `TextChanged` / `ChangeCompleted` XML docs to spell out AvaloniaEdit's inherited per-edit vs completed-update-group event semantics. | | All `Indentation/*.cs` | `namespace AvaloniaEdit.Indentation` → `namespace Terminal.Gui.Text.Indentation`; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. | | `Indentation/DefaultIndentationStrategy.cs` | Replaced `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` (modern pattern). Replaced `var previousLine = line.PreviousLine;` with `DocumentLine? previousLine = line.PreviousLine;` (house style: explicit type for non-built-in). Null-check replaced with pattern match (`is null`). | | `Document/DocumentLineTree.cs` | Stripped `using Avalonia.Threading;` and the five `Dispatcher.UIThread.VerifyAccess()` call sites (commented out with rationale). The document is no longer thread-affined — that's a UI concern, owned by `Terminal.Gui.Editor`. | -| `Document/TextDocument.cs` | **Fork addition (file-io, DEC-009).** Added streaming `LoadAsync(Stream, ...)`, `SaveAsync(Stream, ...)`, and `Encoding` metadata so the rope-backed document can load/save large files without materializing the whole file as one `string`. `VerifyAccess()` now lazily claims a document whose owner was deliberately released after async load/save handoff. | +| `Document/TextDocument.cs` | **Fork addition (file-io, DEC-009).** Added streaming `LoadAsync(Stream, ...)`, `SaveAsync(Stream, ...)`, and `Encoding` metadata so the rope-backed document can load/save large files without materializing the whole file as one `string`. `VerifyAccess()` now lazily claims a document whose owner was deliberately released after async load/save handoff. **Documentation clarification**: expanded `TextChanged` / `Changed` XML docs to spell out AvaloniaEdit's inherited per-edit vs completed-update-group event semantics. | | `Document/TextSegmentCollection.cs` | Same `Avalonia.Threading` strip + one `VerifyAccess()` site stripped. | | `Search/ISearchStrategy.cs` | Namespace transform only. No Avalonia references upstream. | | `Search/RegexSearchStrategy.cs` | Namespace transform; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. No Avalonia references upstream. Contains both `RegexSearchStrategy` and `SearchResult` (kept as a single file matching upstream layout). Added `#nullable disable` directive after the "Adapted for" line — upstream predates nullable reference types (`IEquatable.Equals` override, `SearchResult.Data` auto-property, and `FindAll().FirstOrDefault()` all trip CS warnings under nullable enable; suppressing per-file matches the fork policy of "minimal targeted edits to lifted source"). **Correctness deviation**: `Equals(ISearchStrategy)` now includes `_matchWholeWords` in the comparison. Upstream omits it, so two strategies that differ only by whole-word matching compare equal — breaks consumer caching/dedup. Surfaced in Copilot review of PR #76. **Perf deviation** (gui-cs/Text#82): `FindAll` now drives the regex engine via `Regex.Match(text, startat)` + `NextMatch()` from `offset` instead of `_searchPattern.Matches(text)` over the whole document followed by post-filtering. Upstream re-scans the prefix `[0, offset)` on every call — wasted work for incremental advancing search (one FindNext per F3 keystroke). The .NET regex engine preserves `RegexOptions.Multiline` `^` / `$` semantics across `startat` (anchoring at the start position only when it is 0 or follows a newline). Worth mirroring upstream at AvaloniaEdit. | From fe858728c538c15618dfd54d77a7a4e06079424a Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 22 May 2026 10:30:18 -0600 Subject: [PATCH 7/7] Include XML documentation file in NuGet package Enable GenerateDocumentationFile in the Editor csproj so the XML doc file ships alongside the DLL in lib/net10.0/. This allows DocFX and other documentation tools to extract API summaries from the package. Suppress CS1574/CS1591/CS1723 doc-comment warnings that surface from lifted AvaloniaEdit code and cross-assembly cref references. Add API documentation link to README.md. Closes #222 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 602d46b9..deb7cd01 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A reusable text-editing `View` for [Terminal.Gui](https://github.com/gui-cs/Terminal.Gui). Drop it into your TUI app and you get the editing experience users already expect (caret movement, selection, clipboard, undo/redo, search & replace, folding, syntax highlighting, word wrap) without writing any of it yourself. -Ships as a single NuGet package: **[`Terminal.Gui.Editor`](https://www.nuget.org/packages/Terminal.Gui.Editor)**. +Ships as a single NuGet package: **[`Terminal.Gui.Editor`](https://www.nuget.org/packages/Terminal.Gui.Editor)**. API documentation is published on the [Terminal.Gui DocFX site](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Editor.html). ## What this is, and what it isn't diff --git a/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj b/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj index e9a5a313..e69e06f1 100644 --- a/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj +++ b/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj @@ -3,6 +3,11 @@ Terminal.Gui.Editor Terminal.Gui.Editor + true + + $(NoWarn);CS1574;CS1591;CS1723 Terminal.Gui.Editor Full-featured text editor View for Terminal.Gui — rope-backed document model, multi-caret, folding, search, undo/redo, optional TextMate highlighting. Adapted from AvaloniaEdit.