From 97c957c104305e2eb2ab3ea22ea6017f80e2aae9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 13 May 2026 14:33:23 +0000
Subject: [PATCH 1/5] Initial plan
From a51bedea7b2bf19e89aedc1a97659ebabc047355 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 13 May 2026 14:53:17 +0000
Subject: [PATCH 2/5] Add TextViewEditor wrapper that wraps gui-cs/Editor with
a TextView-compatible API
Introduces a new Terminal.Gui.TextViewEditor project that provides a TextViewEditor
class wrapping the Terminal.Gui.Editor package's Editor view with an API compatible
with TextView. This eases migration from TextView to gui-cs/Editor.
Key features:
- Text property (get/set full document content)
- CurrentRow/CurrentColumn/InsertionPoint for cursor position
- ReadOnly, TabWidth properties
- IsSelecting, SelectedText, SelectedLength for selection
- Load(string path) and Load(Stream) for file loading
- SelectAll/ClearSelection methods
- ContentsChanged, CaretChanged, SelectionChanged events
- UnderlyingEditor property for advanced Editor access
Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/597eda9c-4689-4846-830e-4c7f9d2d04af
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
Directory.Packages.props | 1 +
.../Terminal.Gui.TextViewEditor.csproj | 18 ++
Terminal.Gui.TextViewEditor/TextViewEditor.cs | 262 ++++++++++++++++++
Terminal.sln | 29 ++
.../TextViewEditorBasicTests.cs | 237 ++++++++++++++++
.../TextViewEditorTests.csproj | 27 ++
nuget.config | 1 +
7 files changed, 575 insertions(+)
create mode 100644 Terminal.Gui.TextViewEditor/Terminal.Gui.TextViewEditor.csproj
create mode 100644 Terminal.Gui.TextViewEditor/TextViewEditor.cs
create mode 100644 Tests/TextViewEditorTests/TextViewEditorBasicTests.cs
create mode 100644 Tests/TextViewEditorTests/TextViewEditorTests.csproj
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 511ec4d415..779a90a181 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -47,6 +47,7 @@
+
diff --git a/Terminal.Gui.TextViewEditor/Terminal.Gui.TextViewEditor.csproj b/Terminal.Gui.TextViewEditor/Terminal.Gui.TextViewEditor.csproj
new file mode 100644
index 0000000000..05e0715849
--- /dev/null
+++ b/Terminal.Gui.TextViewEditor/Terminal.Gui.TextViewEditor.csproj
@@ -0,0 +1,18 @@
+
+
+ Terminal.Gui.TextViewEditor
+ net10.0
+ 14
+ enable
+ enable
+ Terminal.Gui.Views
+ Terminal.Gui.TextViewEditor
+ Wraps the Terminal.Gui.Editor view with a TextView-compatible API to ease porting.
+ true
+
+
+
+
+
+
+
diff --git a/Terminal.Gui.TextViewEditor/TextViewEditor.cs b/Terminal.Gui.TextViewEditor/TextViewEditor.cs
new file mode 100644
index 0000000000..9835e02b03
--- /dev/null
+++ b/Terminal.Gui.TextViewEditor/TextViewEditor.cs
@@ -0,0 +1,262 @@
+using System.Drawing;
+using Terminal.Gui.Document;
+using Terminal.Gui.ViewBase;
+
+namespace Terminal.Gui.Views;
+
+///
+/// A that wraps the Terminal.Gui.Editor package's
+/// view with an API that is compatible with
+/// , easing migration from to the
+/// gui-cs/Editor package.
+///
+///
+///
+/// delegates all text editing to the underlying
+/// while exposing properties and methods that match
+/// the API. This enables existing code that uses
+/// to migrate incrementally by swapping the type to with
+/// minimal source changes.
+///
+///
+/// Not all features are supported. Unsupported features will
+/// throw or no-op as documented on each member.
+///
+///
+public class TextViewEditor : View
+{
+ private readonly Terminal.Gui.Editor.Editor _editor = null!;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public TextViewEditor ()
+ {
+ CanFocus = true;
+
+ _editor = new Terminal.Gui.Editor.Editor
+ {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill ()
+ };
+
+ _editor.CaretChanged += (_, _) => CaretChanged?.Invoke (this, EventArgs.Empty);
+ _editor.SelectionChanged += (_, _) => SelectionChanged?.Invoke (this, EventArgs.Empty);
+
+ // Subscribe to document text changes
+ _editor.Document.Changed += (_, _) => OnContentsChanged ();
+
+ Add (_editor);
+ }
+
+ ///
+ /// Gets or sets the text content. This is the primary -compatible
+ /// text property.
+ ///
+ ///
+ /// Setting this property replaces the entire document content and resets the cursor
+ /// position to the beginning.
+ ///
+ public override string Text
+ {
+ get => _editor?.Document.Text ?? string.Empty;
+ set
+ {
+ if (_editor is null)
+ {
+ return;
+ }
+
+ _editor.Document.Text = value ?? string.Empty;
+ _editor.CaretOffset = 0;
+ OnTextChanged ();
+ }
+ }
+
+ ///
+ /// Gets the cursor column (zero-based offset within the current line).
+ ///
+ public int CurrentColumn
+ {
+ get
+ {
+ DocumentLine line = _editor.Document.GetLineByOffset (_editor.CaretOffset);
+
+ return _editor.CaretOffset - line.Offset;
+ }
+ }
+
+ ///
+ /// Gets the current cursor row (zero-based line number).
+ ///
+ public int CurrentRow
+ {
+ get
+ {
+ DocumentLine line = _editor.Document.GetLineByOffset (_editor.CaretOffset);
+
+ return line.LineNumber - 1; // DocumentLine uses 1-based line numbers
+ }
+ }
+
+ ///
+ /// Gets or sets the cursor position as a where
+ /// X is the column and Y is the row.
+ ///
+ public Point InsertionPoint
+ {
+ get => new (CurrentColumn, CurrentRow);
+ set
+ {
+ int row = Math.Max (0, Math.Min (value.Y, _editor.Document.LineCount - 1));
+ DocumentLine line = _editor.Document.GetLineByNumber (row + 1);
+ int col = Math.Max (0, Math.Min (value.X, line.Length));
+ _editor.CaretOffset = line.Offset + col;
+ }
+ }
+
+ ///
+ /// Gets the number of lines in the document.
+ ///
+ public int Lines => _editor.Document.LineCount;
+
+ ///
+ /// Gets or sets whether the editor is in read-only mode.
+ ///
+ public bool ReadOnly
+ {
+ get => _editor.ReadOnly;
+ set => _editor.ReadOnly = value;
+ }
+
+ ///
+ /// Gets or sets the tab width (indentation size). Equivalent to .
+ ///
+ public int TabWidth
+ {
+ get => _editor.IndentationSize;
+ set => _editor.IndentationSize = Math.Max (value, 1);
+ }
+
+ ///
+ /// Gets the selected text, or if no selection exists.
+ ///
+ public string SelectedText
+ {
+ get
+ {
+ if (!_editor.HasSelection)
+ {
+ return string.Empty;
+ }
+
+ return _editor.SelectedText ?? string.Empty;
+ }
+ }
+
+ ///
+ /// Gets whether the user currently has text selected.
+ ///
+ public bool IsSelecting => _editor.HasSelection;
+
+ ///
+ /// Gets the length of the current selection.
+ ///
+ public int SelectedLength => _editor.SelectionLength;
+
+ ///
+ /// Gets whether the text has been modified since last load or clear.
+ ///
+ public bool IsDirty => _editor.Document.UndoStack.CanUndo;
+
+ ///
+ /// Raised when the caret (cursor) position changes.
+ ///
+ public event EventHandler? CaretChanged;
+
+ ///
+ /// Raised when the selection changes.
+ ///
+ public event EventHandler? SelectionChanged;
+
+ ///
+ /// Raised when the contents of the editor are changed.
+ ///
+ public event EventHandler? ContentsChanged;
+
+ ///
+ /// Loads text from a file path.
+ ///
+ /// The file path to load.
+ /// if the file was loaded successfully.
+ public bool Load (string path)
+ {
+ if (!File.Exists (path))
+ {
+ return false;
+ }
+
+ string content = File.ReadAllText (path);
+ _editor.Document.Text = content;
+ _editor.CaretOffset = 0;
+ _editor.Document.UndoStack.MarkAsOriginalFile ();
+ OnTextChanged ();
+
+ return true;
+ }
+
+ ///
+ /// Loads text from a stream.
+ ///
+ /// The stream to load from.
+ public void Load (Stream stream)
+ {
+ using StreamReader reader = new (stream);
+ string content = reader.ReadToEnd ();
+ _editor.Document.Text = content;
+ _editor.CaretOffset = 0;
+ _editor.Document.UndoStack.MarkAsOriginalFile ();
+ OnTextChanged ();
+ }
+
+ ///
+ /// Selects all text in the editor.
+ ///
+ public void SelectAll ()
+ {
+ _editor.SelectAll ();
+ }
+
+ ///
+ /// Clears the current selection.
+ ///
+ public void ClearSelection ()
+ {
+ _editor.ClearSelection ();
+ }
+
+ ///
+ /// Gets the underlying instance for advanced usage.
+ ///
+ ///
+ /// Use this property to access Editor-specific features not exposed by the
+ /// -compatible API, such as folding, syntax highlighting,
+ /// multi-caret editing, and the rendering pipeline.
+ ///
+ public Terminal.Gui.Editor.Editor UnderlyingEditor => _editor;
+
+ ///
+ /// Gets the underlying for direct document manipulation.
+ ///
+ public TextDocument Document => _editor.Document;
+
+ ///
+ /// Raises the event.
+ ///
+ protected virtual void OnContentsChanged ()
+ {
+ ContentsChanged?.Invoke (this, EventArgs.Empty);
+ }
+}
diff --git a/Terminal.sln b/Terminal.sln
index de4c68a853..95765706a6 100644
--- a/Terminal.sln
+++ b/Terminal.sln
@@ -158,6 +158,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Inte
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceTests", "Tests\PerformanceTests\PerformanceTests.csproj", "{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.TextViewEditor", "Terminal.Gui.TextViewEditor\Terminal.Gui.TextViewEditor.csproj", "{048A9D63-B901-404A-8BF0-57DC66E9100F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextViewEditorTests", "Tests\TextViewEditorTests\TextViewEditorTests.csproj", "{9C89E569-2AB8-4970-9D68-793709940604}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -468,6 +472,30 @@ Global
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.Build.0 = Release|Any CPU
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.ActiveCfg = Release|Any CPU
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.Build.0 = Release|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Debug|x64.Build.0 = Debug|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Debug|x86.Build.0 = Debug|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Release|x64.ActiveCfg = Release|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Release|x64.Build.0 = Release|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Release|x86.ActiveCfg = Release|Any CPU
+ {048A9D63-B901-404A-8BF0-57DC66E9100F}.Release|x86.Build.0 = Release|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Debug|x64.Build.0 = Debug|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Debug|x86.Build.0 = Debug|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Release|x64.ActiveCfg = Release|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Release|x64.Build.0 = Release|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Release|x86.ActiveCfg = Release|Any CPU
+ {9C89E569-2AB8-4970-9D68-793709940604}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -499,6 +527,7 @@ Global
{70802F77-F259-44C6-9522-46FCE2FD754E} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
{3116547F-A8F2-4189-BC22-0B47C757164C} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2} = {A589126F-C71A-4FEE-B7EA-2DCA1ADF6A46}
+ {9C89E569-2AB8-4970-9D68-793709940604} = {A589126F-C71A-4FEE-B7EA-2DCA1ADF6A46}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9F8F8A4D-7B8D-4C2A-AC5E-CD7117F74C03}
diff --git a/Tests/TextViewEditorTests/TextViewEditorBasicTests.cs b/Tests/TextViewEditorTests/TextViewEditorBasicTests.cs
new file mode 100644
index 0000000000..cbeae9dd06
--- /dev/null
+++ b/Tests/TextViewEditorTests/TextViewEditorBasicTests.cs
@@ -0,0 +1,237 @@
+// Copilot
+
+using Terminal.Gui.Views;
+
+namespace TextViewEditorTests;
+
+///
+/// Unit tests for .
+///
+public class TextViewEditorBasicTests
+{
+ [Fact]
+ public void Constructor_Creates_Instance ()
+ {
+ TextViewEditor editor = new ();
+
+ Assert.NotNull (editor);
+ Assert.NotNull (editor.UnderlyingEditor);
+ Assert.NotNull (editor.Document);
+ }
+
+ [Fact]
+ public void Text_SetAndGet_RoundTrips ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello, World!";
+
+ Assert.Equal ("Hello, World!", editor.Text);
+ }
+
+ [Fact]
+ public void Text_SetNull_BecomesEmpty ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = null!;
+
+ Assert.Equal (string.Empty, editor.Text);
+ }
+
+ [Fact]
+ public void Lines_ReturnsCorrectCount ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Line1\nLine2\nLine3";
+
+ Assert.Equal (3, editor.Lines);
+ }
+
+ [Fact]
+ public void CurrentRow_AfterSetText_IsZero ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Line1\nLine2";
+
+ Assert.Equal (0, editor.CurrentRow);
+ }
+
+ [Fact]
+ public void CurrentColumn_AfterSetText_IsZero ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello";
+
+ Assert.Equal (0, editor.CurrentColumn);
+ }
+
+ [Fact]
+ public void InsertionPoint_SetAndGet ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Line1\nLine2\nLine3";
+ editor.InsertionPoint = new Point (3, 1);
+
+ Assert.Equal (1, editor.CurrentRow);
+ Assert.Equal (3, editor.CurrentColumn);
+ Assert.Equal (new Point (3, 1), editor.InsertionPoint);
+ }
+
+ [Fact]
+ public void InsertionPoint_ClampsToBounds ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "AB\nCD";
+
+ // Set beyond line length
+ editor.InsertionPoint = new Point (100, 0);
+ Assert.Equal (2, editor.CurrentColumn);
+
+ // Set beyond line count
+ editor.InsertionPoint = new Point (0, 100);
+ Assert.Equal (1, editor.CurrentRow);
+ }
+
+ [Fact]
+ public void ReadOnly_DefaultFalse ()
+ {
+ TextViewEditor editor = new ();
+
+ Assert.False (editor.ReadOnly);
+ }
+
+ [Fact]
+ public void ReadOnly_SetTrue ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.ReadOnly = true;
+
+ Assert.True (editor.ReadOnly);
+ }
+
+ [Fact]
+ public void TabWidth_DefaultIs4 ()
+ {
+ TextViewEditor editor = new ();
+
+ Assert.Equal (4, editor.TabWidth);
+ }
+
+ [Fact]
+ public void TabWidth_SetAndGet ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.TabWidth = 8;
+
+ Assert.Equal (8, editor.TabWidth);
+ }
+
+ [Fact]
+ public void TabWidth_MinimumIsOne ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.TabWidth = 0;
+
+ Assert.Equal (1, editor.TabWidth);
+ }
+
+ [Fact]
+ public void IsSelecting_DefaultFalse ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello";
+
+ Assert.False (editor.IsSelecting);
+ }
+
+ [Fact]
+ public void SelectedText_WhenNoSelection_ReturnsEmpty ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello";
+
+ Assert.Equal (string.Empty, editor.SelectedText);
+ }
+
+ [Fact]
+ public void SelectedLength_WhenNoSelection_ReturnsZero ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello";
+
+ Assert.Equal (0, editor.SelectedLength);
+ }
+
+ [Fact]
+ public void Load_Stream_LoadsContent ()
+ {
+ TextViewEditor editor = new ();
+
+ using MemoryStream stream = new (System.Text.Encoding.UTF8.GetBytes ("Stream content"));
+ editor.Load (stream);
+
+ Assert.Equal ("Stream content", editor.Text);
+ }
+
+ [Fact]
+ public void Load_NonExistentFile_ReturnsFalse ()
+ {
+ TextViewEditor editor = new ();
+
+ bool result = editor.Load ("/nonexistent/path/file.txt");
+
+ Assert.False (result);
+ }
+
+ [Fact]
+ public void ContentsChanged_RaisedOnTextChange ()
+ {
+ TextViewEditor editor = new ();
+ bool raised = false;
+ editor.ContentsChanged += (_, _) => raised = true;
+
+ editor.Text = "new content";
+
+ // ContentsChanged is raised via the document's Changed event
+ // When Text is set, it replaces the document content which fires Changed
+ Assert.True (raised);
+ }
+
+ [Fact]
+ public void SelectAll_SelectsEntireDocument ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello";
+ editor.SelectAll ();
+
+ Assert.True (editor.IsSelecting);
+ Assert.Equal ("Hello", editor.SelectedText);
+ Assert.Equal (5, editor.SelectedLength);
+ }
+
+ [Fact]
+ public void ClearSelection_RemovesSelection ()
+ {
+ TextViewEditor editor = new ();
+
+ editor.Text = "Hello";
+ editor.SelectAll ();
+ editor.ClearSelection ();
+
+ Assert.False (editor.IsSelecting);
+ Assert.Equal (string.Empty, editor.SelectedText);
+ }
+}
diff --git a/Tests/TextViewEditorTests/TextViewEditorTests.csproj b/Tests/TextViewEditorTests/TextViewEditorTests.csproj
new file mode 100644
index 0000000000..9e9dcfaef4
--- /dev/null
+++ b/Tests/TextViewEditorTests/TextViewEditorTests.csproj
@@ -0,0 +1,27 @@
+
+
+ Exe
+ true
+ true
+ enable
+ false
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nuget.config b/nuget.config
index 4dcc41f23c..147177efe2 100644
--- a/nuget.config
+++ b/nuget.config
@@ -14,6 +14,7 @@
+
From 19d19380bdbf1fdace54bc7b53c6069e688fd7a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 13 May 2026 14:54:32 +0000
Subject: [PATCH 3/5] Add clarifying comment for null! field initialization
pattern
Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/597eda9c-4689-4846-830e-4c7f9d2d04af
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
Terminal.Gui.TextViewEditor/TextViewEditor.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/Terminal.Gui.TextViewEditor/TextViewEditor.cs b/Terminal.Gui.TextViewEditor/TextViewEditor.cs
index 9835e02b03..b8e3337bcd 100644
--- a/Terminal.Gui.TextViewEditor/TextViewEditor.cs
+++ b/Terminal.Gui.TextViewEditor/TextViewEditor.cs
@@ -25,6 +25,8 @@ namespace Terminal.Gui.Views;
///
public class TextViewEditor : View
{
+ // Initialized to null! because the base View constructor calls the virtual Text setter
+ // (via SetupText) before this constructor body runs. The null guard in Text protects against this.
private readonly Terminal.Gui.Editor.Editor _editor = null!;
///
From 90fd1aa098d63c92c621d9021cd7ab8cf4b9d0fb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 13 May 2026 15:10:52 +0000
Subject: [PATCH 4/5] Add NotepadEditor scenario porting Notepad to use
TextViewEditor
Demonstrates that TextViewEditor is a drop-in replacement for TextView
in a real-world scenario. The NotepadEditor scenario mirrors the original
Notepad scenario but uses TextViewEditor instead of TextView for all
text editing functionality.
Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/8d6fc3c8-1415-477d-8663-fefae033d836
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
Examples/UICatalog/Scenarios/NotepadEditor.cs | 476 ++++++++++++++++++
Examples/UICatalog/UICatalog.csproj | 1 +
2 files changed, 477 insertions(+)
create mode 100644 Examples/UICatalog/Scenarios/NotepadEditor.cs
diff --git a/Examples/UICatalog/Scenarios/NotepadEditor.cs b/Examples/UICatalog/Scenarios/NotepadEditor.cs
new file mode 100644
index 0000000000..5333d17023
--- /dev/null
+++ b/Examples/UICatalog/Scenarios/NotepadEditor.cs
@@ -0,0 +1,476 @@
+// ReSharper disable AccessToDisposedClosure
+
+#nullable enable
+
+namespace UICatalog.Scenarios;
+
+[ScenarioMetadata ("NotepadEditor", "Multi-tab text editor using TextViewEditor (gui-cs/Editor wrapper).")]
+[ScenarioCategory ("Controls")]
+[ScenarioCategory ("Tabs")]
+[ScenarioCategory ("TextViewEditor")]
+public class NotepadEditor : Scenario
+{
+ private IApplication? _app;
+ private Tabs? _focusedTabs;
+ private string? _lastDirectory;
+ private int _numNewTabs = 1;
+ private Tabs? _tabs;
+ private Window? _topWindow;
+ public Shortcut? LenShortcut { get; private set; }
+
+ public override void Main ()
+ {
+ ConfigurationManager.Enable (ConfigLocations.All);
+ using IApplication app = Application.Create ();
+ app.Init ();
+ _app = app;
+
+ // Set initial directory to docfx/docs relative to the repository root
+ string? repoRoot = FindRepoRoot ();
+
+ if (repoRoot is { })
+ {
+ string docsPath = Path.Combine (repoRoot, "docfx", "docs");
+
+ if (Directory.Exists (docsPath))
+ {
+ _lastDirectory = docsPath;
+ }
+ }
+
+ _topWindow = new Window { BorderStyle = LineStyle.None };
+
+ // MenuBar
+ MenuBar menu = new ();
+
+ menu.Add (new MenuBarItem (Strings.menuFile,
+ [
+ new MenuItem { Title = Strings.cmdNew, Key = Key.N.WithCtrl.WithAlt, Action = New },
+ new MenuItem { Title = Strings.cmdOpen, Action = Open },
+ new MenuItem { Title = Strings.cmdSave, Action = Save },
+ new MenuItem { Title = "Save _As", Action = () => SaveAs () },
+ new MenuItem { Title = Strings.cmdClose, Action = Close },
+ new MenuItem { Title = Strings.cmdQuit, Action = Quit }
+ ]));
+
+ menu.Add (new MenuBarItem ("_About", [new MenuItem { Title = "_About", Action = () => MessageBox.Query (app, "NotepadEditor", "About NotepadEditor (using TextViewEditor)...", "Ok") }]));
+
+ _tabs = new Tabs { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) };
+
+ LenShortcut = new Shortcut (Key.Empty, "Len: ", null);
+
+ // StatusBar
+ StatusBar statusBar =
+ new ([
+ new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", Quit),
+ new Shortcut (Key.F2, "Open", Open),
+ new Shortcut (Key.F1, "New", New),
+ new Shortcut (Key.F3, "Save", Save),
+ new Shortcut (Key.F6, "Close", Close),
+ LenShortcut
+ ]) { AlignmentModes = AlignmentModes.IgnoreFirstOrLast };
+
+ _topWindow.Add (menu, _tabs, statusBar);
+
+ _focusedTabs = _tabs;
+ _tabs.ValueChanged += Tabs_ValueChanged;
+ _tabs.HasFocusChanging += (_, _) => _focusedTabs = _tabs;
+
+ _topWindow.IsModalChanged += (_, e) =>
+ {
+ if (e.Value)
+ {
+ // Only create the initial tab the first time the window becomes modal.
+ // IsModalChanged fires again after every nested modal dialog closes,
+ // so we guard against creating duplicate tabs.
+ if (!_tabs!.TabCollection.Any ())
+ {
+ New ();
+ }
+
+ LenShortcut.Title = $"Len:{GetSelectedTextLength ()}";
+ }
+ else
+ {
+ _tabs.ValueChanged -= Tabs_ValueChanged;
+ }
+ };
+
+ app.Run (_topWindow);
+ _topWindow.Dispose ();
+ }
+
+ public void Save ()
+ {
+ if (_focusedTabs?.Value is OpenedFile tab)
+ {
+ Save (_focusedTabs, tab);
+ }
+ }
+
+ private void Save (Tabs tabsToSave, OpenedFile tabToSave)
+ {
+ if (tabToSave.File is null)
+ {
+ SaveAs ();
+ }
+ else
+ {
+ tabToSave.Save ();
+ }
+
+ tabsToSave.SetNeedsDraw ();
+ }
+
+ public bool SaveAs ()
+ {
+ if (_focusedTabs?.Value is not OpenedFile tab)
+ {
+ return false;
+ }
+
+ SaveDialog fd = new ();
+
+ if (_lastDirectory is { })
+ {
+ fd.Path = _lastDirectory;
+ }
+
+ _app?.Run (fd);
+
+ if (string.IsNullOrWhiteSpace (fd.Path) || fd.Canceled)
+ {
+ fd.Dispose ();
+
+ return false;
+ }
+
+ _lastDirectory = Path.GetDirectoryName (Path.GetFullPath (fd.Path));
+ tab.File = new FileInfo (fd.Path);
+ tab.Title = fd.FileName ?? throw new InvalidOperationException ();
+ tab.Save ();
+
+ fd.Dispose ();
+
+ return true;
+ }
+
+ private void Close ()
+ {
+ if (_focusedTabs?.Value is OpenedFile tab)
+ {
+ Close (_focusedTabs, tab);
+ }
+ }
+
+ private void Close (Tabs tabs, OpenedFile tabToClose)
+ {
+ _focusedTabs = tabs;
+
+ if (tabToClose.UnsavedChanges)
+ {
+ int? result = MessageBox.Query (tabs.App!, "Save Changes", $"Save changes to {tabToClose.Title.TrimEnd ('*')}", "Yes", "No", "Cancel");
+
+ if (result is null or 2)
+ {
+ // user cancelled
+ return;
+ }
+
+ if (result == 0)
+ {
+ if (tabToClose.File is null)
+ {
+ SaveAs ();
+ }
+ else
+ {
+ tabToClose.Save ();
+ }
+ }
+ }
+
+ // close and dispose the tab
+ tabs.Remove (tabToClose);
+ tabToClose.Dispose ();
+ _focusedTabs = tabs;
+
+ // If last tab is closed, open a new one
+ if (!tabs.TabCollection.Any ())
+ {
+ New ();
+ }
+ }
+
+ private void New () => Open (null!, $"new {_numNewTabs++}");
+
+ private void Open ()
+ {
+ OpenDialog open = new ()
+ {
+ Title = "Open",
+ AllowsMultipleSelection = true,
+ AllowedTypes =
+ [
+ new AllowedType ("Markdown", ".md", ".markdown"),
+ new AllowedType ("Text", ".txt", ".csv", ".tsv"),
+ new AllowedType ("Code", ".c", ".h", ".js", ".cs", ".json", ".yml"),
+ new AllowedTypeAny ()
+ ],
+ MustExist = true,
+ OpenMode = OpenMode.File
+ };
+
+ if (_lastDirectory is { })
+ {
+ open.Path = _lastDirectory;
+ }
+
+ _app?.Run (open);
+
+ bool canceled = open.Canceled;
+
+ if (!canceled)
+ {
+ foreach (string path in open.FilePaths)
+ {
+ if (string.IsNullOrEmpty (path) || !File.Exists (path))
+ {
+ break;
+ }
+
+ _lastDirectory = Path.GetDirectoryName (Path.GetFullPath (path));
+ Open (new FileInfo (path), Path.GetFileName (path));
+ }
+ }
+
+ open.Dispose ();
+ }
+
+ /// Creates a new tab with initial text, or reuses the current tab if it is virgin.
+ /// File that was read or null if a new blank document.
+ /// Display name for the tab.
+ private void Open (FileInfo? fileInfo, string tabName)
+ {
+ if (_focusedTabs is null)
+ {
+ return;
+ }
+
+ // If the current tab is virgin (no file, no content), reuse it instead of creating a new one
+ if (fileInfo is { })
+ {
+ if (_focusedTabs.Value is OpenedFile { IsPristine: true } currentTab)
+ {
+ currentTab.File = fileInfo;
+ currentTab.Title = tabName;
+ currentTab.LoadFile (fileInfo);
+
+ return;
+ }
+ }
+
+ OpenedFile tab = new (this) { Title = tabName, File = fileInfo };
+ tab.CreateAndAddEditor (fileInfo);
+ tab.RegisterEditorEvents ();
+
+ _focusedTabs.Add (tab);
+ _focusedTabs.Value = tab;
+ }
+
+ private void Quit ()
+ {
+ if (_tabs is { })
+ {
+ foreach (OpenedFile tab in _tabs.TabCollection.OfType ())
+ {
+ if (!tab.UnsavedChanges)
+ {
+ continue;
+ }
+
+ int? result = MessageBox.Query (_app!, "Unsaved Changes", $"Save changes to {tab.Title.TrimEnd ('*')}?", "Yes", "No", "Cancel");
+
+ if (result is null or 2)
+ {
+ return;
+ }
+
+ if (result != 0)
+ {
+ continue;
+ }
+
+ _focusedTabs = _tabs;
+ _tabs.Value = tab;
+
+ if (tab.File is null)
+ {
+ if (!SaveAs ())
+ {
+ return;
+ }
+ }
+ else
+ {
+ tab.Save ();
+ }
+ }
+ }
+
+ _topWindow?.RequestStop ();
+ }
+
+ ///
+ /// Walks up the directory tree from the current directory looking for the repository root
+ /// (identified by Terminal.sln).
+ ///
+ private static string? FindRepoRoot ()
+ {
+ DirectoryInfo? dir = new (Environment.CurrentDirectory);
+
+ while (dir is { })
+ {
+ if (File.Exists (Path.Combine (dir.FullName, "Terminal.sln")))
+ {
+ return dir.FullName;
+ }
+
+ dir = dir.Parent;
+ }
+
+ return null;
+ }
+
+ private int GetSelectedTextLength ()
+ {
+ if (_focusedTabs?.Value is OpenedFile tab)
+ {
+ return tab.Editor?.Text.Length ?? 0;
+ }
+
+ return 0;
+ }
+
+ private void Tabs_ValueChanged (object? sender, ValueChangedEventArgs e)
+ {
+ if (LenShortcut is null)
+ {
+ return;
+ }
+
+ var len = 0;
+
+ if (e.NewValue is OpenedFile tab)
+ {
+ len = tab.Editor?.Text.Length ?? 0;
+ }
+
+ LenShortcut.Title = $"Len:{len}";
+ }
+
+ private class OpenedFile (NotepadEditor notepad) : View
+ {
+ public FileInfo? File { get; set; }
+
+ /// Gets whether this tab is a pristine new document — never opened to a file and has no content.
+ public bool IsPristine => File is null && string.IsNullOrEmpty (Editor?.Text);
+
+ public TextViewEditor? Editor { get; private set; }
+
+ /// The text of the tab the last time it was saved.
+ private string? _savedText;
+
+ public bool UnsavedChanges => Editor is { } && !string.Equals (_savedText, Editor.Text);
+
+ public void CreateAndAddEditor (FileInfo? file)
+ {
+ var initialText = string.Empty;
+
+ if (file is { Exists: true })
+ {
+ initialText = System.IO.File.ReadAllText (file.FullName);
+ }
+
+ Editor = new TextViewEditor
+ {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Text = initialText
+ };
+
+ _savedText = initialText;
+
+ Add (Editor);
+ }
+
+ /// Loads a file into an existing tab, replacing its content.
+ public void LoadFile (FileInfo file)
+ {
+ if (Editor is null)
+ {
+ return;
+ }
+
+ var text = string.Empty;
+
+ if (file.Exists)
+ {
+ text = System.IO.File.ReadAllText (file.FullName);
+ }
+
+ // Set _savedText first so the ContentsChanged handler sees matching text (not dirty).
+ _savedText = text;
+ Editor.Text = text;
+ }
+
+ public void RegisterEditorEvents ()
+ {
+ if (Editor is null)
+ {
+ return;
+ }
+
+ // when user makes changes rename tab to indicate unsaved
+ Editor.ContentsChanged += (_, _) =>
+ {
+ // if current text doesn't match saved text
+ bool areDiff = UnsavedChanges;
+
+ if (areDiff)
+ {
+ if (!Title.EndsWith ('*'))
+ {
+ Title = Title + "*";
+ }
+ }
+ else
+ {
+ if (Title.EndsWith ('*'))
+ {
+ Title = Title.TrimEnd ('*');
+ }
+ }
+
+ notepad.LenShortcut!.Title = $"Len:{Editor.Text.Length}";
+ };
+ }
+
+ internal void Save ()
+ {
+ if (Editor is null || File is null || string.IsNullOrWhiteSpace (File.FullName))
+ {
+ return;
+ }
+
+ string newText = Editor.Text;
+
+ System.IO.File.WriteAllText (File.FullName, newText);
+ _savedText = newText;
+
+ Title = Title.TrimEnd ('*');
+ }
+ }
+}
diff --git a/Examples/UICatalog/UICatalog.csproj b/Examples/UICatalog/UICatalog.csproj
index 1dc8d9f303..1de677e941 100644
--- a/Examples/UICatalog/UICatalog.csproj
+++ b/Examples/UICatalog/UICatalog.csproj
@@ -35,6 +35,7 @@
+
From 430b0de74f942554feb2aaca1e40505f3582eb4f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 13 May 2026 15:56:12 +0000
Subject: [PATCH 5/5] Remove NotepadEditor scenario; upgrade existing Notepad
scenario to use TextViewEditor
Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/5992c475-34d5-4941-8f1e-c499738e2721
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
Examples/UICatalog/Scenarios/Notepad.cs | 81 ++-
Examples/UICatalog/Scenarios/NotepadEditor.cs | 476 ------------------
2 files changed, 40 insertions(+), 517 deletions(-)
delete mode 100644 Examples/UICatalog/Scenarios/NotepadEditor.cs
diff --git a/Examples/UICatalog/Scenarios/Notepad.cs b/Examples/UICatalog/Scenarios/Notepad.cs
index 988e4764d5..94eaa57c64 100644
--- a/Examples/UICatalog/Scenarios/Notepad.cs
+++ b/Examples/UICatalog/Scenarios/Notepad.cs
@@ -7,7 +7,7 @@ namespace UICatalog.Scenarios;
[ScenarioMetadata ("Notepad", "Multi-tab text editor using the Tabs control.")]
[ScenarioCategory ("Controls")]
[ScenarioCategory ("Tabs")]
-[ScenarioCategory ("TextView")]
+[ScenarioCategory ("TextViewEditor")]
public class Notepad : Scenario
{
private IApplication? _app;
@@ -271,8 +271,8 @@ private void Open (FileInfo? fileInfo, string tabName)
}
OpenedFile tab = new (this) { Title = tabName, File = fileInfo };
- tab.CreateAndAddTextView (fileInfo);
- tab.RegisterTextViewEvents ();
+ tab.CreateAndAddEditor (fileInfo);
+ tab.RegisterEditorEvents ();
_focusedTabs.Add (tab);
_focusedTabs.Value = tab;
@@ -344,7 +344,7 @@ private int GetSelectedTextLength ()
{
if (_focusedTabs?.Value is OpenedFile tab)
{
- return tab.TextView?.Text.Length ?? 0;
+ return tab.Editor?.Text.Length ?? 0;
}
return 0;
@@ -360,7 +360,7 @@ private void Tabs_ValueChanged (object? sender, ValueChangedEventArgs e)
if (e.NewValue is OpenedFile tab)
{
- len = tab.TextView?.Text.Length ?? 0;
+ len = tab.Editor?.Text.Length ?? 0;
}
LenShortcut.Title = $"Len:{len}";
@@ -376,16 +376,16 @@ private class OpenedFile (Notepad notepad) : View
public FileInfo? File { get; set; }
/// Gets whether this tab is a pristine new document — never opened to a file and has no content.
- public bool IsPristine => File is null && string.IsNullOrEmpty (TextView?.Text);
+ public bool IsPristine => File is null && string.IsNullOrEmpty (Editor?.Text);
- public TextView? TextView { get; private set; }
+ public TextViewEditor? Editor { get; private set; }
/// The text of the tab the last time it was saved.
private string? _savedText;
- public bool UnsavedChanges => TextView is { } && !string.Equals (_savedText, TextView.Text);
+ public bool UnsavedChanges => Editor is { } && !string.Equals (_savedText, Editor.Text);
- public void CreateAndAddTextView (FileInfo? file)
+ public void CreateAndAddEditor (FileInfo? file)
{
var initialText = string.Empty;
@@ -394,25 +394,24 @@ public void CreateAndAddTextView (FileInfo? file)
initialText = System.IO.File.ReadAllText (file.FullName);
}
- TextView = new TextView
+ Editor = new TextViewEditor
{
X = 0,
Y = 0,
Width = Dim.Fill (),
Height = Dim.Fill (),
- Text = initialText,
- TabKeyAddsTab = false
+ Text = initialText
};
_savedText = initialText;
- Add (TextView);
+ Add (Editor);
}
/// Loads a file into an existing tab, replacing its content.
public void LoadFile (FileInfo file)
{
- if (TextView is null)
+ if (Editor is null)
{
return;
}
@@ -426,49 +425,49 @@ public void LoadFile (FileInfo file)
// Set _savedText first so the ContentsChanged handler sees matching text (not dirty).
_savedText = text;
- TextView.Text = text;
+ Editor.Text = text;
}
- public void RegisterTextViewEvents ()
+ public void RegisterEditorEvents ()
{
- if (TextView is null)
+ if (Editor is null)
{
return;
}
// when user makes changes rename tab to indicate unsaved
- TextView.ContentsChanged += (_, _) =>
- {
- // if current text doesn't match saved text
- bool areDiff = UnsavedChanges;
-
- if (areDiff)
- {
- if (!Title.EndsWith ('*'))
- {
- Title = Title + "*";
- }
- }
- else
- {
- if (Title.EndsWith ('*'))
- {
- Title = Title.TrimEnd ('*');
- }
- }
-
- notepad.LenShortcut?.Title = $"Len:{TextView.Text.Length}";
- };
+ Editor.ContentsChanged += (_, _) =>
+ {
+ // if current text doesn't match saved text
+ bool areDiff = UnsavedChanges;
+
+ if (areDiff)
+ {
+ if (!Title.EndsWith ('*'))
+ {
+ Title = Title + "*";
+ }
+ }
+ else
+ {
+ if (Title.EndsWith ('*'))
+ {
+ Title = Title.TrimEnd ('*');
+ }
+ }
+
+ notepad.LenShortcut?.Title = $"Len:{Editor.Text.Length}";
+ };
}
internal void Save ()
{
- if (TextView is null || File is null || string.IsNullOrWhiteSpace (File.FullName))
+ if (Editor is null || File is null || string.IsNullOrWhiteSpace (File.FullName))
{
return;
}
- string newText = TextView.Text;
+ string newText = Editor.Text;
System.IO.File.WriteAllText (File.FullName, newText);
_savedText = newText;
diff --git a/Examples/UICatalog/Scenarios/NotepadEditor.cs b/Examples/UICatalog/Scenarios/NotepadEditor.cs
deleted file mode 100644
index 5333d17023..0000000000
--- a/Examples/UICatalog/Scenarios/NotepadEditor.cs
+++ /dev/null
@@ -1,476 +0,0 @@
-// ReSharper disable AccessToDisposedClosure
-
-#nullable enable
-
-namespace UICatalog.Scenarios;
-
-[ScenarioMetadata ("NotepadEditor", "Multi-tab text editor using TextViewEditor (gui-cs/Editor wrapper).")]
-[ScenarioCategory ("Controls")]
-[ScenarioCategory ("Tabs")]
-[ScenarioCategory ("TextViewEditor")]
-public class NotepadEditor : Scenario
-{
- private IApplication? _app;
- private Tabs? _focusedTabs;
- private string? _lastDirectory;
- private int _numNewTabs = 1;
- private Tabs? _tabs;
- private Window? _topWindow;
- public Shortcut? LenShortcut { get; private set; }
-
- public override void Main ()
- {
- ConfigurationManager.Enable (ConfigLocations.All);
- using IApplication app = Application.Create ();
- app.Init ();
- _app = app;
-
- // Set initial directory to docfx/docs relative to the repository root
- string? repoRoot = FindRepoRoot ();
-
- if (repoRoot is { })
- {
- string docsPath = Path.Combine (repoRoot, "docfx", "docs");
-
- if (Directory.Exists (docsPath))
- {
- _lastDirectory = docsPath;
- }
- }
-
- _topWindow = new Window { BorderStyle = LineStyle.None };
-
- // MenuBar
- MenuBar menu = new ();
-
- menu.Add (new MenuBarItem (Strings.menuFile,
- [
- new MenuItem { Title = Strings.cmdNew, Key = Key.N.WithCtrl.WithAlt, Action = New },
- new MenuItem { Title = Strings.cmdOpen, Action = Open },
- new MenuItem { Title = Strings.cmdSave, Action = Save },
- new MenuItem { Title = "Save _As", Action = () => SaveAs () },
- new MenuItem { Title = Strings.cmdClose, Action = Close },
- new MenuItem { Title = Strings.cmdQuit, Action = Quit }
- ]));
-
- menu.Add (new MenuBarItem ("_About", [new MenuItem { Title = "_About", Action = () => MessageBox.Query (app, "NotepadEditor", "About NotepadEditor (using TextViewEditor)...", "Ok") }]));
-
- _tabs = new Tabs { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) };
-
- LenShortcut = new Shortcut (Key.Empty, "Len: ", null);
-
- // StatusBar
- StatusBar statusBar =
- new ([
- new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", Quit),
- new Shortcut (Key.F2, "Open", Open),
- new Shortcut (Key.F1, "New", New),
- new Shortcut (Key.F3, "Save", Save),
- new Shortcut (Key.F6, "Close", Close),
- LenShortcut
- ]) { AlignmentModes = AlignmentModes.IgnoreFirstOrLast };
-
- _topWindow.Add (menu, _tabs, statusBar);
-
- _focusedTabs = _tabs;
- _tabs.ValueChanged += Tabs_ValueChanged;
- _tabs.HasFocusChanging += (_, _) => _focusedTabs = _tabs;
-
- _topWindow.IsModalChanged += (_, e) =>
- {
- if (e.Value)
- {
- // Only create the initial tab the first time the window becomes modal.
- // IsModalChanged fires again after every nested modal dialog closes,
- // so we guard against creating duplicate tabs.
- if (!_tabs!.TabCollection.Any ())
- {
- New ();
- }
-
- LenShortcut.Title = $"Len:{GetSelectedTextLength ()}";
- }
- else
- {
- _tabs.ValueChanged -= Tabs_ValueChanged;
- }
- };
-
- app.Run (_topWindow);
- _topWindow.Dispose ();
- }
-
- public void Save ()
- {
- if (_focusedTabs?.Value is OpenedFile tab)
- {
- Save (_focusedTabs, tab);
- }
- }
-
- private void Save (Tabs tabsToSave, OpenedFile tabToSave)
- {
- if (tabToSave.File is null)
- {
- SaveAs ();
- }
- else
- {
- tabToSave.Save ();
- }
-
- tabsToSave.SetNeedsDraw ();
- }
-
- public bool SaveAs ()
- {
- if (_focusedTabs?.Value is not OpenedFile tab)
- {
- return false;
- }
-
- SaveDialog fd = new ();
-
- if (_lastDirectory is { })
- {
- fd.Path = _lastDirectory;
- }
-
- _app?.Run (fd);
-
- if (string.IsNullOrWhiteSpace (fd.Path) || fd.Canceled)
- {
- fd.Dispose ();
-
- return false;
- }
-
- _lastDirectory = Path.GetDirectoryName (Path.GetFullPath (fd.Path));
- tab.File = new FileInfo (fd.Path);
- tab.Title = fd.FileName ?? throw new InvalidOperationException ();
- tab.Save ();
-
- fd.Dispose ();
-
- return true;
- }
-
- private void Close ()
- {
- if (_focusedTabs?.Value is OpenedFile tab)
- {
- Close (_focusedTabs, tab);
- }
- }
-
- private void Close (Tabs tabs, OpenedFile tabToClose)
- {
- _focusedTabs = tabs;
-
- if (tabToClose.UnsavedChanges)
- {
- int? result = MessageBox.Query (tabs.App!, "Save Changes", $"Save changes to {tabToClose.Title.TrimEnd ('*')}", "Yes", "No", "Cancel");
-
- if (result is null or 2)
- {
- // user cancelled
- return;
- }
-
- if (result == 0)
- {
- if (tabToClose.File is null)
- {
- SaveAs ();
- }
- else
- {
- tabToClose.Save ();
- }
- }
- }
-
- // close and dispose the tab
- tabs.Remove (tabToClose);
- tabToClose.Dispose ();
- _focusedTabs = tabs;
-
- // If last tab is closed, open a new one
- if (!tabs.TabCollection.Any ())
- {
- New ();
- }
- }
-
- private void New () => Open (null!, $"new {_numNewTabs++}");
-
- private void Open ()
- {
- OpenDialog open = new ()
- {
- Title = "Open",
- AllowsMultipleSelection = true,
- AllowedTypes =
- [
- new AllowedType ("Markdown", ".md", ".markdown"),
- new AllowedType ("Text", ".txt", ".csv", ".tsv"),
- new AllowedType ("Code", ".c", ".h", ".js", ".cs", ".json", ".yml"),
- new AllowedTypeAny ()
- ],
- MustExist = true,
- OpenMode = OpenMode.File
- };
-
- if (_lastDirectory is { })
- {
- open.Path = _lastDirectory;
- }
-
- _app?.Run (open);
-
- bool canceled = open.Canceled;
-
- if (!canceled)
- {
- foreach (string path in open.FilePaths)
- {
- if (string.IsNullOrEmpty (path) || !File.Exists (path))
- {
- break;
- }
-
- _lastDirectory = Path.GetDirectoryName (Path.GetFullPath (path));
- Open (new FileInfo (path), Path.GetFileName (path));
- }
- }
-
- open.Dispose ();
- }
-
- /// Creates a new tab with initial text, or reuses the current tab if it is virgin.
- /// File that was read or null if a new blank document.
- /// Display name for the tab.
- private void Open (FileInfo? fileInfo, string tabName)
- {
- if (_focusedTabs is null)
- {
- return;
- }
-
- // If the current tab is virgin (no file, no content), reuse it instead of creating a new one
- if (fileInfo is { })
- {
- if (_focusedTabs.Value is OpenedFile { IsPristine: true } currentTab)
- {
- currentTab.File = fileInfo;
- currentTab.Title = tabName;
- currentTab.LoadFile (fileInfo);
-
- return;
- }
- }
-
- OpenedFile tab = new (this) { Title = tabName, File = fileInfo };
- tab.CreateAndAddEditor (fileInfo);
- tab.RegisterEditorEvents ();
-
- _focusedTabs.Add (tab);
- _focusedTabs.Value = tab;
- }
-
- private void Quit ()
- {
- if (_tabs is { })
- {
- foreach (OpenedFile tab in _tabs.TabCollection.OfType ())
- {
- if (!tab.UnsavedChanges)
- {
- continue;
- }
-
- int? result = MessageBox.Query (_app!, "Unsaved Changes", $"Save changes to {tab.Title.TrimEnd ('*')}?", "Yes", "No", "Cancel");
-
- if (result is null or 2)
- {
- return;
- }
-
- if (result != 0)
- {
- continue;
- }
-
- _focusedTabs = _tabs;
- _tabs.Value = tab;
-
- if (tab.File is null)
- {
- if (!SaveAs ())
- {
- return;
- }
- }
- else
- {
- tab.Save ();
- }
- }
- }
-
- _topWindow?.RequestStop ();
- }
-
- ///
- /// Walks up the directory tree from the current directory looking for the repository root
- /// (identified by Terminal.sln).
- ///
- private static string? FindRepoRoot ()
- {
- DirectoryInfo? dir = new (Environment.CurrentDirectory);
-
- while (dir is { })
- {
- if (File.Exists (Path.Combine (dir.FullName, "Terminal.sln")))
- {
- return dir.FullName;
- }
-
- dir = dir.Parent;
- }
-
- return null;
- }
-
- private int GetSelectedTextLength ()
- {
- if (_focusedTabs?.Value is OpenedFile tab)
- {
- return tab.Editor?.Text.Length ?? 0;
- }
-
- return 0;
- }
-
- private void Tabs_ValueChanged (object? sender, ValueChangedEventArgs e)
- {
- if (LenShortcut is null)
- {
- return;
- }
-
- var len = 0;
-
- if (e.NewValue is OpenedFile tab)
- {
- len = tab.Editor?.Text.Length ?? 0;
- }
-
- LenShortcut.Title = $"Len:{len}";
- }
-
- private class OpenedFile (NotepadEditor notepad) : View
- {
- public FileInfo? File { get; set; }
-
- /// Gets whether this tab is a pristine new document — never opened to a file and has no content.
- public bool IsPristine => File is null && string.IsNullOrEmpty (Editor?.Text);
-
- public TextViewEditor? Editor { get; private set; }
-
- /// The text of the tab the last time it was saved.
- private string? _savedText;
-
- public bool UnsavedChanges => Editor is { } && !string.Equals (_savedText, Editor.Text);
-
- public void CreateAndAddEditor (FileInfo? file)
- {
- var initialText = string.Empty;
-
- if (file is { Exists: true })
- {
- initialText = System.IO.File.ReadAllText (file.FullName);
- }
-
- Editor = new TextViewEditor
- {
- X = 0,
- Y = 0,
- Width = Dim.Fill (),
- Height = Dim.Fill (),
- Text = initialText
- };
-
- _savedText = initialText;
-
- Add (Editor);
- }
-
- /// Loads a file into an existing tab, replacing its content.
- public void LoadFile (FileInfo file)
- {
- if (Editor is null)
- {
- return;
- }
-
- var text = string.Empty;
-
- if (file.Exists)
- {
- text = System.IO.File.ReadAllText (file.FullName);
- }
-
- // Set _savedText first so the ContentsChanged handler sees matching text (not dirty).
- _savedText = text;
- Editor.Text = text;
- }
-
- public void RegisterEditorEvents ()
- {
- if (Editor is null)
- {
- return;
- }
-
- // when user makes changes rename tab to indicate unsaved
- Editor.ContentsChanged += (_, _) =>
- {
- // if current text doesn't match saved text
- bool areDiff = UnsavedChanges;
-
- if (areDiff)
- {
- if (!Title.EndsWith ('*'))
- {
- Title = Title + "*";
- }
- }
- else
- {
- if (Title.EndsWith ('*'))
- {
- Title = Title.TrimEnd ('*');
- }
- }
-
- notepad.LenShortcut!.Title = $"Len:{Editor.Text.Length}";
- };
- }
-
- internal void Save ()
- {
- if (Editor is null || File is null || string.IsNullOrWhiteSpace (File.FullName))
- {
- return;
- }
-
- string newText = Editor.Text;
-
- System.IO.File.WriteAllText (File.FullName, newText);
- _savedText = newText;
-
- Title = Title.TrimEnd ('*');
- }
- }
-}