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/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/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 @@ + 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..b8e3337bcd --- /dev/null +++ b/Terminal.Gui.TextViewEditor/TextViewEditor.cs @@ -0,0 +1,264 @@ +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 +{ + // 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!; + + /// + /// 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 @@ +