From 7a778c7f59d77dcc41c23826663fd3be985b2981 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 14:26:33 +0000 Subject: [PATCH 1/3] Initial plan From da7da57c8b843d3603fff19f634e53af31123c4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 14:47:00 +0000 Subject: [PATCH 2/3] feat: add overwrite (insert-replace) mode to Editor Add OverwriteMode property with field-keyword pattern, wire Command.ToggleOverwrite/EnableOverwrite/DisableOverwrite via AddCommand and DefaultKeyBindings (Insert key = default toggle). When overwrite is active and no selection exists and caret is not at line-end, typing replaces the grapheme under the caret (wide-rune safe via StringInfo.GetNextTextElementLength). Selection + line-end still insert. Each overwrite is a single undo step. Caret renders as SteadyBlock while overwrite is active. Multi-caret insert path also respects overwrite mode. ted: INS/OVR status bar indicator. specs: overwrite-mode/spec.md created; public-api.md updated. Tests: 11 new integration tests, all green. Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/e0fe7ce8-4870-47c6-87cf-9bd7b524f8e3 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- examples/ted/TedApp.cs | 15 ++ specs/overwrite-mode/spec.md | 67 ++++++++ specs/public-api.md | 2 + src/Terminal.Gui.Editor/Editor.Commands.cs | 64 ++++++- src/Terminal.Gui.Editor/Editor.Drawing.cs | 4 +- src/Terminal.Gui.Editor/Editor.Keyboard.cs | 4 + src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 13 +- src/Terminal.Gui.Editor/Editor.cs | 24 +++ .../EditorOverwriteTests.cs | 162 ++++++++++++++++++ 9 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 specs/overwrite-mode/spec.md create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 1c6e97a..5f1d109 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -139,6 +139,8 @@ public TedApp (bool readOnly = false) new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, new Shortcut { Title = "Theme", CommandView = ThemeDropDown }, + OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) + { MouseHighlightStates = MouseState.None }, LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) { MouseHighlightStates = MouseState.None } ]) @@ -264,6 +266,7 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), // Editor.CaretChanged covers both user-driven movement and document edits that shift the // caret (insert/remove). Initial render seeds the value before any movement happens. Editor.CaretChanged += (_, _) => UpdateLocShortcut (); + Editor.OverwriteModeChanged += (_, _) => UpdateOverwriteShortcut (); Editor.FindRequested += (_, _) => ShowFindReplaceDialog (false); Editor.ReplaceRequested += (_, _) => ShowFindReplaceDialog (true); UpdateLocShortcut (); @@ -293,6 +296,12 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), /// public Shortcut LocShortcut { get; } + /// + /// The status-bar shortcut that shows whether the editor is in insert (INS) or overwrite (OVR) + /// mode. Updated whenever fires. + /// + public Shortcut OverwriteShortcut { get; } + /// /// Resolves the key shortcut for by asking the 's /// first; falls back to for @@ -382,6 +391,12 @@ private void UpdateLocShortcut () LocShortcut.SetNeedsDraw (); } + private void UpdateOverwriteShortcut () + { + OverwriteShortcut.Title = Editor.OverwriteMode ? "OVR" : "INS"; + OverwriteShortcut.SetNeedsDraw (); + } + private static string FormatLoc (int line, int column) { return $"Ln {line}, Col {column}"; diff --git a/specs/overwrite-mode/spec.md b/specs/overwrite-mode/spec.md new file mode 100644 index 0000000..8e8465b --- /dev/null +++ b/specs/overwrite-mode/spec.md @@ -0,0 +1,67 @@ +# Overwrite (Insert-Replace) Mode + +**Status**: Implemented +**Issue**: [#146](https://github.com/gui-cs/Editor/issues/146) +**Updated**: 2026-05-17 + +## Summary + +`Editor` supports an overwrite mode: when active, typed characters replace the grapheme under +the caret instead of inserting before it. At line-end or when a selection is active, typing +still inserts. The mode is toggled via the Insert key and can be controlled programmatically. + +## Public API + +```csharp +public partial class Editor : View +{ + /// Gets or sets whether the editor is in overwrite mode. + public bool OverwriteMode { get; set; } + + /// Raised whenever OverwriteMode changes. + public event EventHandler? OverwriteModeChanged; +} +``` + +## Commands & Key Bindings + +| Command | Default Key | Behaviour | +|----------------------------|-------------|------------------------------| +| `Command.ToggleOverwrite` | Insert | Toggles `OverwriteMode` | +| `Command.EnableOverwrite` | *(none)* | Sets `OverwriteMode = true` | +| `Command.DisableOverwrite` | *(none)* | Sets `OverwriteMode = false` | + +All three are wired through `AddCommand` and the `ToggleOverwrite` binding lives in +`Editor.DefaultKeyBindings` (user-overridable via `[ConfigurationProperty]`). + +## Typing Behaviour + +- **Overwrite on, no selection, caret not at line-end**: the grapheme cluster at the caret + is replaced by the typed character. Uses `RemoveAndInsert` offset mapping so the caret + anchor advances past the inserted text. Wide-rune safe (uses + `StringInfo.GetNextTextElementLength`). +- **Overwrite on, selection active**: selection is replaced (same as insert mode). +- **Overwrite on, caret at line-end**: plain insert (newline is never consumed). +- **Multi-caret**: each additional caret follows the same overwrite logic. +- **Undo**: each overwrite is a single undo step. + +## Caret Rendering + +While `OverwriteMode` is active, the cursor style is forced to `CursorStyle.SteadyBlock` +(solid block), distinct from the default bar/underline style used in insert mode. + +## ted Integration + +The `ted` demo shows an **INS** / **OVR** indicator in the status bar, updated whenever +`OverwriteModeChanged` fires. + +## Files Changed + +- `src/Terminal.Gui.Editor/Editor.cs` — `OverwriteMode` property + `OverwriteModeChanged` event +- `src/Terminal.Gui.Editor/Editor.Commands.cs` — commands, key binding, `OverwriteAtOffset` helper +- `src/Terminal.Gui.Editor/Editor.Keyboard.cs` — overwrite path in `OnKeyDownNotHandled` +- `src/Terminal.Gui.Editor/Editor.Drawing.cs` — `SteadyBlock` cursor in overwrite mode +- `src/Terminal.Gui.Editor/Editor.MultiCaret.cs` — overwrite in multi-caret insert +- `examples/ted/TedApp.cs` — INS/OVR status bar indicator +- `specs/public-api.md` — updated with new property and event +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs` — integration tests diff --git a/specs/public-api.md b/specs/public-api.md index 00c7ed3..d5b7162 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -36,6 +36,8 @@ public class Editor : View public bool ShowLineNumbers { get; set; } // exists public bool WordWrap { get; set; } // word-wrap-toggle (needs word-wrap) public bool ReadOnly { get; set; } // exists (read-only ✅) + public bool OverwriteMode { get; set; } // exists (overwrite-mode ✅) + public event EventHandler? OverwriteModeChanged; // exists (overwrite-mode ✅) // --- Indentation (tab-handling ✅ + auto-indent) --- public int IndentationSize { get; set; } = 4; // exists (codex merge) diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 732a34f..4b5e5f3 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -52,7 +52,8 @@ public partial class Editor [Command.WordLeftExtend] = Bind.All (Key.CursorLeft.WithCtrl.WithShift), [Command.WordRightExtend] = Bind.All (Key.CursorRight.WithCtrl.WithShift), [Command.KillWordLeft] = Bind.All (Key.Backspace.WithCtrl), - [Command.KillWordRight] = Bind.All (Key.Delete.WithCtrl) + [Command.KillWordRight] = Bind.All (Key.Delete.WithCtrl), + [Command.ToggleOverwrite] = Bind.All (Key.InsertChar) }; private void CreateCommandsAndBindings () @@ -244,6 +245,26 @@ private void CreateCommandsAndBindings () return true; }); + // Overwrite mode + AddCommand (Command.ToggleOverwrite, () => + { + OverwriteMode = !OverwriteMode; + + return true; + }); + AddCommand (Command.EnableOverwrite, () => + { + OverwriteMode = true; + + return true; + }); + AddCommand (Command.DisableOverwrite, () => + { + OverwriteMode = false; + + return true; + }); + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); // Reclaim Tab / Shift+Tab from the framework's default focus-cycling bindings so our @@ -350,6 +371,10 @@ private void CreateCommandsAndBindings () { ReplaceSelection (text); } + else if (OverwriteMode && _document is not null) + { + OverwriteAtCaret (text); + } else { _document!.Insert (CaretOffset, text); @@ -358,6 +383,43 @@ private void CreateCommandsAndBindings () return true; } + /// + /// Overwrites the grapheme at the caret with . If the caret is at + /// line-end, falls back to a plain insert so the newline is not consumed. + /// + private void OverwriteAtCaret (string text) + { + OverwriteAtOffset (CaretOffset, text); + } + + /// + /// Overwrites the grapheme at the given with . + /// If the offset is at line-end, falls back to a plain insert so the newline is not consumed. + /// + private void OverwriteAtOffset (int offset, string text) + { + DocumentLine line = _document!.GetLineByOffset (offset); + var lineEnd = line.Offset + line.Length; + + if (offset >= lineEnd) + { + // At or past end-of-line content — just insert. + _document.Insert (offset, text); + + return; + } + + // Determine the length of the grapheme cluster under the caret so wide runes are + // replaced atomically. StringInfo.GetNextTextElementLength gives cluster length in chars. + var remaining = _document.GetText (offset, lineEnd - offset); + var graphemeLength = System.Globalization.StringInfo.GetNextTextElementLength (remaining); + + // Use RemoveAndInsert so that the caret anchor (AfterInsertion) moves past the + // inserted text. The default same-length Replace uses CharacterReplace mode which + // does not move anchors at all. + _document.Replace (offset, graphemeLength, text, OffsetChangeMappingType.RemoveAndInsert); + } + private bool? DeleteLeft () { if (ReadOnly) diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index caa7172..23018c6 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -362,10 +362,12 @@ private void UpdateCursor () } Point screen = ViewportToScreen (new Point (col, row)); + CursorStyle style = OverwriteMode ? CursorStyle.SteadyBlock : + Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style; Cursor = Cursor with { Position = screen, - Style = Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style + Style = style }; } } diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 7f48404..7d46217 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -47,6 +47,10 @@ protected override bool OnKeyDownNotHandled (Key key) { ReplaceSelection (rune.ToString ()); } + else if (OverwriteMode && _document is not null) + { + OverwriteAtCaret (rune.ToString ()); + } else { _document!.Insert (CaretOffset, rune.ToString ()); diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index b7534a4..f1df656 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -309,6 +309,10 @@ private void MultiCaretInsert (string text) { ReplaceSelection (text); } + else if (OverwriteMode) + { + OverwriteAtCaret (text); + } else { _document.Insert (CaretOffset, text); @@ -329,7 +333,14 @@ private void MultiCaretInsert (string text) } } - _document.Insert (caret.Offset, text); + if (OverwriteMode) + { + OverwriteAtOffset (caret.Offset, text); + } + else + { + _document.Insert (caret.Offset, text); + } } } } diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index de8ad05..fd3df13 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -362,6 +362,30 @@ private void EnsureCaretNotInFold () } } + /// + /// Gets or sets whether the editor is in overwrite mode. When , typed + /// characters replace the grapheme under the caret instead of inserting before it. At line-end + /// or when a selection is active, the insertion still inserts. Defaults to . + /// + public bool OverwriteMode + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + SetNeedsDraw (); + OverwriteModeChanged?.Invoke (this, EventArgs.Empty); + } + } + + /// Raised whenever changes. + public event EventHandler? OverwriteModeChanged; + /// Raised whenever changes. public event EventHandler? CaretChanged; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs new file mode 100644 index 0000000..9cd7dc3 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs @@ -0,0 +1,162 @@ +// Copilot - gpt-4.1 + +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Integration tests for overwrite (insert-replace) mode in . +/// +public class EditorOverwriteTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + // ───────────────────── Property / Command ───────────────────── + + [Fact] + public void OverwriteMode_DefaultsToFalse () + { + Editor editor = new (); + Assert.False (editor.OverwriteMode); + } + + [Fact] + public void OverwriteMode_RaisesEvent () + { + Editor editor = new (); + var raised = false; + editor.OverwriteModeChanged += (_, _) => raised = true; + editor.OverwriteMode = true; + Assert.True (raised); + } + + [Fact] + public void DefaultKeyBindings_Contains_ToggleOverwrite () + { + Assert.True (Editor.DefaultKeyBindings!.ContainsKey (Command.ToggleOverwrite)); + } + + [Fact] + public async Task InsertKey_Toggles_OverwriteMode () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + + Assert.False (fx.Top.Editor.OverwriteMode); + + fx.Injector.InjectKey (Key.InsertChar, Direct); + Assert.True (fx.Top.Editor.OverwriteMode); + + fx.Injector.InjectKey (Key.InsertChar, Direct); + Assert.False (fx.Top.Editor.OverwriteMode); + } + + // ───────────────────── Overwrite typing behaviour ───────────────────── + + [Fact] + public async Task Overwrite_Replaces_CharacterAtCaret () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.OverwriteMode = true; + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("xbc", fx.Top.Editor.Document?.Text); + Assert.Equal (1, fx.Top.Editor.CaretOffset); + } + + [Fact] + public async Task Overwrite_AtLineEnd_Inserts () + { + await using AppFixture fx = new (() => new ("ab")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 2; // at end of "ab" + fx.Top.Editor.OverwriteMode = true; + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("abx", fx.Top.Editor.Document?.Text); + Assert.Equal (3, fx.Top.Editor.CaretOffset); + } + + [Fact] + public async Task Overwrite_WithSelection_ReplacesSelection () + { + await using AppFixture fx = new (() => new ("abcdef")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.OverwriteMode = true; + + // Select "bcd" (offsets 1..4) via Shift+Right + fx.Top.Editor.CaretOffset = 1; + fx.Injector.InjectKey (Key.CursorRight.WithShift, Direct); + fx.Injector.InjectKey (Key.CursorRight.WithShift, Direct); + fx.Injector.InjectKey (Key.CursorRight.WithShift, Direct); + Assert.True (fx.Top.Editor.HasSelection); + + fx.Injector.InjectKey (Key.X, Direct); + + // Selection should be replaced entirely, not overwrite-style. + Assert.Equal ("axef", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task Overwrite_SingleUndo_Step () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.OverwriteMode = true; + + fx.Injector.InjectKey (Key.X, Direct); + Assert.Equal ("xbc", fx.Top.Editor.Document?.Text); + + fx.Top.Editor.Document!.UndoStack.Undo (); + Assert.Equal ("abc", fx.Top.Editor.Document.Text); + } + + [Fact] + public async Task Overwrite_MultiLine_DoesNotConsumeNewline () + { + await using AppFixture fx = new (() => new ("ab\ncd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; // at 'b' + fx.Top.Editor.OverwriteMode = true; + + // Overwrite 'b', then caret at offset 2 is at line-end (\n), should insert + fx.Injector.InjectKey (Key.X, Direct); + Assert.Equal ("ax\ncd", fx.Top.Editor.Document?.Text); + Assert.Equal (2, fx.Top.Editor.CaretOffset); + + // Now at line-end — type inserts rather than consuming newline + fx.Injector.InjectKey (Key.Y, Direct); + Assert.Equal ("axy\ncd", fx.Top.Editor.Document?.Text); + } + + // ───────────────────── Enable / Disable commands ───────────────────── + + [Fact] + public async Task EnableOverwrite_Command_SetsMode () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + + fx.Top.Editor.InvokeCommand (Command.EnableOverwrite); + Assert.True (fx.Top.Editor.OverwriteMode); + } + + [Fact] + public async Task DisableOverwrite_Command_ClearsMode () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.OverwriteMode = true; + + fx.Top.Editor.InvokeCommand (Command.DisableOverwrite); + Assert.False (fx.Top.Editor.OverwriteMode); + } +} From 37c2d87b54038500425c07fb79c380b137794359 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 17 May 2026 13:09:40 -0400 Subject: [PATCH 3/3] launch settings. --- examples/ted/Properties/launchSettings.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 examples/ted/Properties/launchSettings.json diff --git a/examples/ted/Properties/launchSettings.json b/examples/ted/Properties/launchSettings.json new file mode 100644 index 0000000..5d9d44a --- /dev/null +++ b/examples/ted/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "ted": { + "commandName": "Project", + "commandLineArgs": "examples/ted/TedApp.cs", + "workingDirectory": "../.." + } + } +} \ No newline at end of file