From e20b7ac95c0a570238f4daea34594996a8036c49 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 14:25:49 +0000
Subject: [PATCH 01/14] Initial plan
From b270f6c88f04820b9ed902c919526d0d99348991 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 14:34:44 +0000
Subject: [PATCH 02/14] feat: add Multiline property for single-line /
embeddable-input mode
When Multiline is false:
- Newline insertion (Enter) is suppressed
- WordWrap is forced off
- Vertical navigation/scroll constrained to one row
- Multi-caret operations are no-ops
- Pasted newlines are stripped
- Selection and horizontal editing still work
Adds 13 integration tests for single-line mode.
Updates specs/decisions.md (DEC-008), specs/public-api.md (R8),
and creates specs/single-line-mode/spec.md.
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/c67979cf-b66e-4d68-a914-6a08623411ac
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
specs/decisions.md | 24 +--
specs/public-api.md | 2 +
specs/single-line-mode/spec.md | 46 +++++
src/Terminal.Gui.Editor/Editor.Commands.cs | 24 ++-
src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 18 +-
src/Terminal.Gui.Editor/Editor.cs | 57 +++++-
.../EditorSingleLineTests.cs | 189 ++++++++++++++++++
7 files changed, 332 insertions(+), 28 deletions(-)
create mode 100644 specs/single-line-mode/spec.md
create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
diff --git a/specs/decisions.md b/specs/decisions.md
index 79b601f..e3ce391 100644
--- a/specs/decisions.md
+++ b/specs/decisions.md
@@ -56,6 +56,18 @@ Decisions are recorded here when an open question from the plan is resolved. Eac
---
+### DEC-008: Single-line / embeddable-input mode (resolves former OPEN-006)
+
+**Decision**: **Yes** — `Editor` adds a single-line / fixed-height input mode: `Multiline` (default `true`), `EnterKeyAddsLine` (default `true`; when `false`, Enter raises `Accepting` instead of inserting a newline), `TabKeyAddsTab` (default `true`; when `false`, Tab traverses focus). Defaults preserve today's multi-line behavior exactly. Tracked in [#147](https://github.com/gui-cs/Editor/issues/147).
+
+**Rationale**: The earlier "tension" rested on the CLAUDE.md non-goal *"`Editor` ships beside `TextView`, not as a replacement."* Maintainer direction (2026-05-17): `Editor` **will** functionally replace `TextView` — just **not** in a source/API- or UI-compatible way. For *feature* purposes that dissolves the tension: a code-aware single-/few-line input (highlighted expression field, REPL line) is a capability `TextView` serves and `Editor` must therefore serve. The behavior is mostly binding-shaped (Enter/Tab semantics + an `Accepting` event + a height/scroll constraint), so the cost is low and the defaults are non-breaking.
+
+**Affected features**: see [`textview-parity-gap/spec.md`](textview-parity-gap/spec.md) Gap 3 (#147). Note: this "functionally replaces `TextView`" framing also reclassifies `IDesignable` (#151) from non-goal to a tracked gap and keeps single-line Enter/Tab as a real feature (not mere rebinding).
+
+**Date**: 2026-05-17
+
+---
+
## Open
### OPEN-001: Independent `Terminal.Gui.Editor` NuGet from day one
@@ -100,18 +112,6 @@ Decisions are recorded here when an open question from the plan is resolved. Eac
---
-### DEC-008: Single-line / embeddable-input mode (resolves former OPEN-006)
-
-**Decision**: **Yes** — `Editor` adds a single-line / fixed-height input mode: `Multiline` (default `true`), `EnterKeyAddsLine` (default `true`; when `false`, Enter raises `Accepting` instead of inserting a newline), `TabKeyAddsTab` (default `true`; when `false`, Tab traverses focus). Defaults preserve today's multi-line behavior exactly. Tracked in [#147](https://github.com/gui-cs/Editor/issues/147).
-
-**Rationale**: The earlier "tension" rested on the CLAUDE.md non-goal *"`Editor` ships beside `TextView`, not as a replacement."* Maintainer direction (2026-05-17): `Editor` **will** functionally replace `TextView` — just **not** in a source/API- or UI-compatible way. For *feature* purposes that dissolves the tension: a code-aware single-/few-line input (highlighted expression field, REPL line) is a capability `TextView` serves and `Editor` must therefore serve. The behavior is mostly binding-shaped (Enter/Tab semantics + an `Accepting` event + a height/scroll constraint), so the cost is low and the defaults are non-breaking.
-
-**Affected features**: see [`textview-parity-gap/spec.md`](textview-parity-gap/spec.md) Gap 3 (#147). Note: this "functionally replaces `TextView`" framing also reclassifies `IDesignable` (#151) from non-goal to a tracked gap and keeps single-line Enter/Tab as a real feature (not mere rebinding).
-
-**Date**: 2026-05-17
-
----
-
### DEC-005: Word-wrap continuation-line indent policy
**Decision**: Continuation lines render flush at column 0 for v1 (no leading indent).
diff --git a/specs/public-api.md b/specs/public-api.md
index 00c7ed3..c322d11 100644
--- a/specs/public-api.md
+++ b/specs/public-api.md
@@ -36,6 +36,7 @@ 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 Multiline { get; set; } = true; // single-line-mode (single-line ✅)
// --- Indentation (tab-handling ✅ + auto-indent) ---
public int IndentationSize { get; set; } = 4; // exists (codex merge)
@@ -114,3 +115,4 @@ public interface IOverlayRenderer
| 2026-05-11 | ReadOnly property landed on Editor | read-only |
| 2026-05-12 | `ISearchStrategy?` `SearchStrategy { get; set; }` landed on Editor; string-based FindNext/FindPrevious/ReplaceNext/ReplaceAll overloads retained as convenience wrappers | find-and-replace |
| 2026-05-16 | Vertical multi-caret keybindings (`Ctrl+Alt+CursorUp/Down`, `Alt+Drag`) added via `Editor.DefaultKeyBindings`; no new public Editor API (R8) | vertical-multi-caret |
+| 2026-05-17 | `Multiline` property added (default `true`); single-line mode suppresses newlines, constrains vertical nav/scroll, forces WordWrap off, disables multi-caret | single-line-mode |
diff --git a/specs/single-line-mode/spec.md b/specs/single-line-mode/spec.md
new file mode 100644
index 0000000..a01f3a8
--- /dev/null
+++ b/specs/single-line-mode/spec.md
@@ -0,0 +1,46 @@
+# Single-Line / Embeddable-Input Mode
+
+**Status**: Implemented
+**Issue**: [#147](https://github.com/gui-cs/Editor/issues/147)
+**Decision**: [DEC-008](../decisions.md#dec-008-single-line--embeddable-input-mode-resolves-former-open-006)
+
+## Summary
+
+`Editor` supports a single-line input mode via the `Multiline` property (default `true`).
+When `Multiline` is `false`, `Editor` behaves as a one-line text input suitable for embedding
+in dialogs, forms, and tool bars — with syntax highlighting, selection, and all horizontal
+editing intact.
+
+## Behavior when `Multiline == false`
+
+| Aspect | Behavior |
+|--------|----------|
+| **Newline insertion** | `Command.NewLine` (Enter) is a no-op; the document stays single-line. |
+| **Word wrap** | Forced off; setting `WordWrap = true` is silently ignored. |
+| **Vertical navigation** | `Up`, `Down`, `PageUp`, `PageDown` and their `*Extend` (Shift) variants are no-ops. |
+| **Vertical scroll** | `ScrollUp` / `ScrollDown` are no-ops. Content height is always 1. |
+| **Multi-caret** | `ToggleCaretAt`, `AddCaretVertically`, `SetVerticalCaretsFromViewRows` are no-ops. Existing additional carets are cleared on transition to `Multiline = false`. |
+| **Paste** | Newlines (`\r\n`, `\r`, `\n`) are stripped from pasted content before insertion. |
+| **Selection** | Works normally (horizontal). `SelectAll`, `Shift+Left/Right`, `Shift+Home/End` all function. |
+| **Editing** | Insert, delete, backspace, undo/redo, cut/copy/paste all work. |
+| **Horizontal navigation** | `Left`, `Right`, `Home`, `End`, word-left/right all work. |
+
+## Property
+
+```csharp
+///
+/// Gets or sets whether the editor supports multiple lines. Default is .
+///
+public bool Multiline { get; set; } = true;
+```
+
+Setting `Multiline` to `false`:
+1. Forces `WordWrap = false`.
+2. Clears any additional carets (`ClearAdditionalCarets`).
+3. Clears visual-line caches and recomputes content size (height = 1).
+
+## Not in scope (this phase)
+
+- `EnterKeyAddsLine` — when `false`, Enter raises `Accepting` instead of `Command.NewLine`.
+- `TabKeyAddsTab` — when `false`, Tab falls through to focus traversal.
+- These are tracked in [#147](https://github.com/gui-cs/Editor/issues/147) as follow-up work.
diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs
index 732a34f..7cd1733 100644
--- a/src/Terminal.Gui.Editor/Editor.Commands.cs
+++ b/src/Terminal.Gui.Editor/Editor.Commands.cs
@@ -81,8 +81,8 @@ private void CreateCommandsAndBindings ()
// Selection-extending movement
AddCommand (Command.LeftExtend, () => ExtendCommand (() => ExtendCaretBy (-1)));
AddCommand (Command.RightExtend, () => ExtendCommand (() => ExtendCaretBy (1)));
- AddCommand (Command.UpExtend, () => ExtendCommand (() => ExtendCaretVertically (-1)));
- AddCommand (Command.DownExtend, () => ExtendCommand (() => ExtendCaretVertically (1)));
+ AddCommand (Command.UpExtend, () => Multiline ? ExtendCommand (() => ExtendCaretVertically (-1)) : true);
+ AddCommand (Command.DownExtend, () => Multiline ? ExtendCommand (() => ExtendCaretVertically (1)) : true);
AddCommand (Command.LeftStartExtend,
() => ExtendCommand (() => ExtendCaretTo (_document!.GetLineByOffset (CaretOffset).Offset)));
@@ -95,9 +95,9 @@ private void CreateCommandsAndBindings ()
AddCommand (Command.StartExtend, () => ExtendCommand (() => ExtendCaretTo (0)));
AddCommand (Command.EndExtend, () => ExtendCommand (() => ExtendCaretTo (_document!.TextLength)));
AddCommand (Command.PageUpExtend,
- () => ExtendCommand (() => ExtendCaretVertically (-Math.Max (1, Viewport.Height))));
+ () => Multiline ? ExtendCommand (() => ExtendCaretVertically (-Math.Max (1, Viewport.Height))) : true);
AddCommand (Command.PageDownExtend,
- () => ExtendCommand (() => ExtendCaretVertically (Math.Max (1, Viewport.Height))));
+ () => Multiline ? ExtendCommand (() => ExtendCaretVertically (Math.Max (1, Viewport.Height))) : true);
// Selection ops
AddCommand (Command.SelectAll, () =>
@@ -155,6 +155,13 @@ private void CreateCommandsAndBindings ()
return true;
}
+ // In single-line mode, strip newlines from pasted content so the document
+ // stays on one line.
+ if (!Multiline)
+ {
+ contents = contents.ReplaceLineEndings (string.Empty);
+ }
+
using (_document!.RunUpdate ())
{
if (HasSelection)
@@ -278,6 +285,11 @@ private void CreateCommandsAndBindings ()
private bool? MoveCaretVerticallyCollapsing (int delta)
{
+ if (!Multiline)
+ {
+ return true;
+ }
+
MoveCaretVerticallyCollapsingSelection (delta);
return true;
@@ -285,7 +297,7 @@ private void CreateCommandsAndBindings ()
private bool? ScrollVerticalCommand (int delta)
{
- if (_document is null || ScrollVertical (delta) != true)
+ if (!Multiline || _document is null || ScrollVertical (delta) != true)
{
return false;
}
@@ -309,7 +321,7 @@ private void CreateCommandsAndBindings ()
private bool? InsertNewLineWithAutoIndent ()
{
- if (ReadOnly)
+ if (ReadOnly || !Multiline)
{
return true;
}
diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
index b7534a4..181c64e 100644
--- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
+++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
@@ -32,7 +32,7 @@ public partial class Editor
///
public void ToggleCaretAt (int offset)
{
- if (_document is null)
+ if (_document is null || !Multiline)
{
return;
}
@@ -144,7 +144,7 @@ private void NormalizeAdditionalCarets ()
///
private bool? AddCaretVertically (int delta)
{
- if (_document is null)
+ if (_document is null || !Multiline)
{
return true;
}
@@ -197,7 +197,7 @@ private void NormalizeAdditionalCarets ()
///
private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow, int viewColumn)
{
- if (_document is null)
+ if (_document is null || !Multiline)
{
return;
}
@@ -468,7 +468,7 @@ private void MultiCaretInsert (string text)
///
private bool? MultiCaretNewLine ()
{
- if (ReadOnly || _document is null)
+ if (ReadOnly || !Multiline || _document is null)
{
return true;
}
@@ -611,7 +611,7 @@ private bool MultiCaretInsertTab ()
foreach (CaretEditInfo caret in GetAllCaretsDescending ())
{
- if (TryGetCaretSelectionRange (caret, out int selStart, out int selEnd))
+ if (TryGetCaretSelectionRange (caret, out var selStart, out var selEnd))
{
if (RangeSpansMultipleLines (selStart, selEnd))
{
@@ -674,9 +674,11 @@ private bool MultiCaretUnindent ()
foreach (CaretEditInfo caret in GetAllCaretsDescending ())
{
- List lines = TryGetCaretSelectionRange (caret, out int selStart, out int selEnd) && RangeSpansMultipleLines (selStart, selEnd)
- ? LinesInRange (selStart, selEnd)
- : [_document!.GetLineByOffset (caret.Offset)];
+ List lines =
+ TryGetCaretSelectionRange (caret, out var selStart, out var selEnd) &&
+ RangeSpansMultipleLines (selStart, selEnd)
+ ? LinesInRange (selStart, selEnd)
+ : [_document!.GetLineByOffset (caret.Offset)];
foreach (DocumentLine line in lines)
{
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index de8ad05..6a86ed0 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -162,6 +162,42 @@ public GutterOptions GutterOptions
///
public bool ReadOnly { get; set; }
+ ///
+ /// Gets or sets whether the editor supports multiple lines. When (default),
+ /// Enter inserts a newline, vertical navigation moves across lines, and content height reflects the
+ /// full document. When , newline insertion is suppressed, vertical
+ /// navigation and scroll are constrained to a single row, is forced off,
+ /// and multi-caret operations are disabled. Selection and horizontal editing still work.
+ ///
+ public bool Multiline
+ {
+ get;
+ set
+ {
+ if (field == value)
+ {
+ return;
+ }
+
+ field = value;
+
+ if (!value)
+ {
+ // Force off WordWrap — meaningless for a single-line input.
+ WordWrap = false;
+
+ // Collapse additional carets — vertical multi-caret is a multi-line concept.
+ ClearAdditionalCarets ();
+ }
+
+ ClearVisualLineCaches ();
+ _maxWidthDirty = true;
+ UpdateContentSize ();
+ EnsureCaretVisible ();
+ SetNeedsDraw ();
+ }
+ } = true;
+
///
/// Gets or sets the highlighting definition used for syntax coloring. When set, a
/// is automatically added to
@@ -251,6 +287,12 @@ public bool WordWrap
get;
set
{
+ // Word wrap is meaningless in single-line mode.
+ if (!Multiline && value)
+ {
+ return;
+ }
+
if (field == value)
{
return;
@@ -463,6 +505,17 @@ private void UpdateContentSize ()
return;
}
+ if (!Multiline)
+ {
+ // Single-line mode: one row, width from line 1 only.
+ var width = _document.LineCount > 0
+ ? GetOrBuildDefaultVisualLine (_document.GetLineByNumber (1)).VisualLength
+ : 0;
+ SetContentSize (new Size (width + 1, 1));
+
+ return;
+ }
+
if (WordWrap)
{
// In word-wrap mode, the content height is the total number of visual rows
@@ -656,7 +709,7 @@ private void InvalidateVisualLineCaches (DocumentChangeEventArgs e)
// Net character shift. Cached visual lines store *absolute* element offsets, so a
// same-line-count edit upstream (no newline added/removed) still leaves every
// downstream cached line stale even though its line *number* is unchanged.
- var offsetDelta = (insertedText.Length - removedText.Length);
+ var offsetDelta = insertedText.Length - removedText.Length;
RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta);
RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta);
@@ -948,7 +1001,7 @@ private bool TryGetVerticalOffset (int startOffset, int delta, int targetVisualC
return true;
}
- var targetLineIndex = (_document.GetLineByOffset (startOffset).LineNumber - 1) + delta;
+ var targetLineIndex = _document.GetLineByOffset (startOffset).LineNumber - 1 + delta;
if (targetLineIndex < 0 || targetLineIndex > _document.LineCount - 1)
{
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
new file mode 100644
index 0000000..d4063db
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -0,0 +1,189 @@
+// Copilot - claude-sonnet-4
+
+using Terminal.Gui.Editor.IntegrationTests.Testing;
+using Terminal.Gui.Input;
+using Terminal.Gui.Testing;
+using Xunit;
+
+namespace Terminal.Gui.Editor.IntegrationTests;
+
+///
+/// Integration tests for = (single-line mode).
+/// Verifies that newline insertion is suppressed, vertical navigation is constrained,
+/// word-wrap is forced off, multi-caret is disabled, and selection + editing still work.
+///
+public class EditorSingleLineTests
+{
+ private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct };
+
+ [Fact]
+ public async Task SingleLine_Enter_Does_Not_Insert_Newline ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("ab"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 1;
+
+ fx.Injector.InjectKey (Key.Enter, Direct);
+
+ Assert.Equal ("ab", fx.Top.Editor.Document!.Text);
+ Assert.Equal (1, fx.Top.Editor.Document.LineCount);
+ }
+
+ [Fact]
+ public async Task SingleLine_Up_Down_Are_NoOps ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 2;
+
+ fx.Injector.InjectKey (Key.CursorUp, Direct);
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+
+ fx.Injector.InjectKey (Key.CursorDown, Direct);
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+ }
+
+ [Fact]
+ public async Task SingleLine_WordWrap_Cannot_Be_Enabled ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello world"));
+ fx.Top.Editor.Multiline = false;
+
+ fx.Top.Editor.WordWrap = true;
+
+ Assert.False (fx.Top.Editor.WordWrap);
+ }
+
+ [Fact]
+ public async Task SingleLine_Setting_Multiline_False_Disables_WordWrap ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello world"));
+ fx.Top.Editor.WordWrap = true;
+ Assert.True (fx.Top.Editor.WordWrap);
+
+ fx.Top.Editor.Multiline = false;
+
+ Assert.False (fx.Top.Editor.WordWrap);
+ }
+
+ [Fact]
+ public async Task SingleLine_MultiCaret_Toggle_Is_NoOp ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("abcdef"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 0;
+
+ fx.Top.Editor.ToggleCaretAt (3);
+
+ Assert.False (fx.Top.Editor.HasMultipleCarets);
+ }
+
+ [Fact]
+ public async Task SingleLine_Selection_Still_Works ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 0;
+
+ fx.Injector.InjectKey (Key.A.WithCtrl, Direct);
+
+ Assert.True (fx.Top.Editor.HasSelection);
+ Assert.Equal ("hello", fx.Top.Editor.SelectedText);
+ }
+
+ [Fact]
+ public async Task SingleLine_Editing_Insert_Delete_Still_Works ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("abc"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 3;
+
+ fx.Injector.InjectKey (Key.Backspace, Direct);
+
+ Assert.Equal ("ab", fx.Top.Editor.Document!.Text);
+ }
+
+ [Fact]
+ public async Task SingleLine_ContentSize_Height_Is_One ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Render ();
+
+ Assert.Equal (1, fx.Top.Editor.GetContentSize ().Height);
+ }
+
+ [Fact]
+ public async Task SingleLine_PageUp_PageDown_Are_NoOps ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 2;
+
+ fx.Injector.InjectKey (Key.PageUp, Direct);
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+
+ fx.Injector.InjectKey (Key.PageDown, Direct);
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+ }
+
+ [Fact]
+ public async Task SingleLine_Multiline_Default_Is_True ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+
+ Assert.True (fx.Top.Editor.Multiline);
+ }
+
+ [Fact]
+ public async Task SingleLine_Existing_Carets_Cleared_When_Setting_Multiline_False ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("abcdef"));
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 0;
+ fx.Top.Editor.ToggleCaretAt (3);
+ Assert.True (fx.Top.Editor.HasMultipleCarets);
+
+ fx.Top.Editor.Multiline = false;
+
+ Assert.False (fx.Top.Editor.HasMultipleCarets);
+ }
+
+ [Fact]
+ public async Task SingleLine_Home_End_Still_Navigate ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello world"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 5;
+
+ fx.Injector.InjectKey (Key.Home, Direct);
+ Assert.Equal (0, fx.Top.Editor.CaretOffset);
+
+ fx.Injector.InjectKey (Key.End, Direct);
+ Assert.Equal (11, fx.Top.Editor.CaretOffset);
+ }
+
+ [Fact]
+ public async Task SingleLine_VerticalExtend_NoOp ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 2;
+
+ fx.Injector.InjectKey (Key.CursorUp.WithShift, Direct);
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+ Assert.False (fx.Top.Editor.HasSelection);
+
+ fx.Injector.InjectKey (Key.CursorDown.WithShift, Direct);
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+ Assert.False (fx.Top.Editor.HasSelection);
+ }
+}
From a5d83be48f9270c311e449e40c2336ad57c18ef3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 21:00:40 +0000
Subject: [PATCH 03/14] =?UTF-8?q?fix:=20address=20CR=20feedback=20?=
=?UTF-8?q?=E2=80=94=20strip=20newlines=20on=20transition,=20add=20paste?=
=?UTF-8?q?=20test,=20reword=20DEC-008?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/5f5ba029-c2d1-42dd-979c-50224c9053e6
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
specs/decisions.md | 2 +-
src/Terminal.Gui.Editor/Editor.cs | 8 ++++++
.../EditorSingleLineTests.cs | 27 +++++++++++++++++++
3 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/specs/decisions.md b/specs/decisions.md
index e3ce391..8805e19 100644
--- a/specs/decisions.md
+++ b/specs/decisions.md
@@ -58,7 +58,7 @@ Decisions are recorded here when an open question from the plan is resolved. Eac
### DEC-008: Single-line / embeddable-input mode (resolves former OPEN-006)
-**Decision**: **Yes** — `Editor` adds a single-line / fixed-height input mode: `Multiline` (default `true`), `EnterKeyAddsLine` (default `true`; when `false`, Enter raises `Accepting` instead of inserting a newline), `TabKeyAddsTab` (default `true`; when `false`, Tab traverses focus). Defaults preserve today's multi-line behavior exactly. Tracked in [#147](https://github.com/gui-cs/Editor/issues/147).
+**Decision**: **Yes** — `Editor` adds a `Multiline` property (default `true`) that enables a single-line / fixed-height input mode. When `false`, newlines are suppressed (Enter is a no-op), vertical navigation is constrained, WordWrap is forced off, and multi-caret is disabled. Follow-up properties `EnterKeyAddsLine` and `TabKeyAddsTab` (Enter raises `Accepting`, Tab traverses focus) are tracked separately in [#147](https://github.com/gui-cs/Editor/issues/147) and not yet implemented. Defaults preserve today's multi-line behavior exactly.
**Rationale**: The earlier "tension" rested on the CLAUDE.md non-goal *"`Editor` ships beside `TextView`, not as a replacement."* Maintainer direction (2026-05-17): `Editor` **will** functionally replace `TextView` — just **not** in a source/API- or UI-compatible way. For *feature* purposes that dissolves the tension: a code-aware single-/few-line input (highlighted expression field, REPL line) is a capability `TextView` serves and `Editor` must therefore serve. The behavior is mostly binding-shaped (Enter/Tab semantics + an `Accepting` event + a height/scroll constraint), so the cost is low and the defaults are non-breaking.
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index 5f46a6a..6f5b7a4 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -188,6 +188,14 @@ public bool Multiline
// Collapse additional carets — vertical multi-caret is a multi-line concept.
ClearAdditionalCarets ();
+
+ // Strip existing newlines so the document is guaranteed single-line.
+ if (_document is { LineCount: > 1 })
+ {
+ string flat = _document.Text.ReplaceLineEndings (string.Empty);
+ _document.Text = flat;
+ CaretOffset = Math.Min (CaretOffset, _document.TextLength);
+ }
}
ClearVisualLineCaches ();
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
index d4063db..adcfb16 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -186,4 +186,31 @@ public async Task SingleLine_VerticalExtend_NoOp ()
Assert.Equal (2, fx.Top.Editor.CaretOffset);
Assert.False (fx.Top.Editor.HasSelection);
}
+
+ [Fact]
+ public async Task SingleLine_Paste_Strips_Newlines ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("ab"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Top.Editor.CaretOffset = 2;
+
+ fx.App.Clipboard!.SetClipboardData ("cd\nef\r\ngh");
+ fx.Injector.InjectKey (Key.V.WithCtrl, Direct);
+
+ Assert.Equal ("abcdefgh", fx.Top.Editor.Document!.Text);
+ Assert.Equal (1, fx.Top.Editor.Document.LineCount);
+ }
+
+ [Fact]
+ public async Task SingleLine_Transition_Strips_Existing_Newlines ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("line1\nline2\nline3"));
+ Assert.Equal (3, fx.Top.Editor.Document!.LineCount);
+
+ fx.Top.Editor.Multiline = false;
+
+ Assert.Equal ("line1line2line3", fx.Top.Editor.Document.Text);
+ Assert.Equal (1, fx.Top.Editor.Document.LineCount);
+ }
}
From da32ef9f2fab00bd50aca61b8b52731d2e0471dc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 21:11:43 +0000
Subject: [PATCH 04/14] fix: show newline glyphs instead of stripping newlines
in single-line mode (no data loss)
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/a31fcadb-b57a-435a-824a-91a121a30ed3
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
specs/single-line-mode/spec.md | 5 +-
src/Terminal.Gui.Editor/Editor.Commands.cs | 28 ++++-
src/Terminal.Gui.Editor/Editor.Drawing.cs | 104 ++++++++++++++++++
src/Terminal.Gui.Editor/Editor.cs | 86 ++++++++++++---
.../Rendering/NewlineGlyphElement.cs | 27 +++++
.../EditorSingleLineTests.cs | 30 ++++-
6 files changed, 260 insertions(+), 20 deletions(-)
create mode 100644 src/Terminal.Gui.Editor/Rendering/NewlineGlyphElement.cs
diff --git a/specs/single-line-mode/spec.md b/specs/single-line-mode/spec.md
index a01f3a8..92c0162 100644
--- a/specs/single-line-mode/spec.md
+++ b/specs/single-line-mode/spec.md
@@ -15,7 +15,8 @@ editing intact.
| Aspect | Behavior |
|--------|----------|
-| **Newline insertion** | `Command.NewLine` (Enter) is a no-op; the document stays single-line. |
+| **Newline insertion** | `Command.NewLine` (Enter) is a no-op; prevents adding new newlines. |
+| **Newline display** | Existing newlines in the document are preserved (no data loss) and rendered as visible glyphs (`⏎`). |
| **Word wrap** | Forced off; setting `WordWrap = true` is silently ignored. |
| **Vertical navigation** | `Up`, `Down`, `PageUp`, `PageDown` and their `*Extend` (Shift) variants are no-ops. |
| **Vertical scroll** | `ScrollUp` / `ScrollDown` are no-ops. Content height is always 1. |
@@ -38,6 +39,8 @@ Setting `Multiline` to `false`:
1. Forces `WordWrap = false`.
2. Clears any additional carets (`ClearAdditionalCarets`).
3. Clears visual-line caches and recomputes content size (height = 1).
+4. Preserves existing document content (no data loss) — newlines are rendered as `⏎` glyphs.
+5. Home/End navigate to document start/end (since the visual representation is one row).
## Not in scope (this phase)
diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs
index a9bc8aa..8fb121d 100644
--- a/src/Terminal.Gui.Editor/Editor.Commands.cs
+++ b/src/Terminal.Gui.Editor/Editor.Commands.cs
@@ -85,12 +85,20 @@ private void CreateCommandsAndBindings ()
AddCommand (Command.UpExtend, () => Multiline ? ExtendCommand (() => ExtendCaretVertically (-1)) : true);
AddCommand (Command.DownExtend, () => Multiline ? ExtendCommand (() => ExtendCaretVertically (1)) : true);
AddCommand (Command.LeftStartExtend,
- () => ExtendCommand (() => ExtendCaretTo (_document!.GetLineByOffset (CaretOffset).Offset)));
+ () => ExtendCommand (() =>
+ ExtendCaretTo (!Multiline ? 0 : _document!.GetLineByOffset (CaretOffset).Offset)));
AddCommand (Command.RightEndExtend, () => ExtendCommand (() =>
{
- DocumentLine line = _document!.GetLineByOffset (CaretOffset);
- ExtendCaretTo (line.Offset + line.Length);
+ if (!Multiline)
+ {
+ ExtendCaretTo (_document!.TextLength);
+ }
+ else
+ {
+ DocumentLine line = _document!.GetLineByOffset (CaretOffset);
+ ExtendCaretTo (line.Offset + line.Length);
+ }
}));
AddCommand (Command.StartExtend, () => ExtendCommand (() => ExtendCaretTo (0)));
@@ -483,6 +491,13 @@ private void OverwriteAtOffset (int offset, string text)
private bool? MoveCaretToLineStart ()
{
+ if (!Multiline)
+ {
+ CaretOffset = 0;
+
+ return true;
+ }
+
DocumentLine line = _document!.GetLineByOffset (CaretOffset);
CaretOffset = line.Offset;
@@ -491,6 +506,13 @@ private void OverwriteAtOffset (int offset, string text)
private bool? MoveCaretToLineEnd ()
{
+ if (!Multiline)
+ {
+ CaretOffset = _document!.TextLength;
+
+ return true;
+ }
+
DocumentLine line = _document!.GetLineByOffset (CaretOffset);
CaretOffset = line.Offset + line.Length;
diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs
index 23018c6..2800ba3 100644
--- a/src/Terminal.Gui.Editor/Editor.Drawing.cs
+++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs
@@ -45,6 +45,13 @@ private void DrawVisibleLines (Rectangle viewport, Attribute normal, Attribute s
var visibleStart = viewport.X;
var visibleEnd = viewport.X + viewport.Width;
+ if (!Multiline)
+ {
+ DrawSingleLineFlat (viewport, normal, selected, selStart, selEnd, visibleStart, visibleEnd);
+
+ return;
+ }
+
if (WordWrap)
{
DrawWrappedLines (viewport, normal, selected, selStart, selEnd);
@@ -120,6 +127,103 @@ private void DrawWrappedLines (Rectangle viewport, Attribute normal, Attribute s
}
}
+ ///
+ /// Draws all document content on a single visual row with newline characters rendered as
+ /// visible glyphs. Used when is .
+ ///
+ private void DrawSingleLineFlat (
+ Rectangle viewport,
+ Attribute normal,
+ Attribute selected,
+ int selStart,
+ int selEnd,
+ int visibleStart,
+ int visibleEnd)
+ {
+ // Build a composite visual line from all document lines, inserting newline glyph
+ // elements between them. Uses line 1 as the owning DocumentLine.
+ DocumentLine firstLine = _document!.GetLineByNumber (1);
+ CellVisualLine composite = new (firstLine);
+ var flatColumn = 0;
+
+ for (var lineNum = 1; lineNum <= _document.LineCount; lineNum++)
+ {
+ DocumentLine line = _document.GetLineByNumber (lineNum);
+ CellVisualLine lineVisual = GetOrBuildDrawVisualLine (line, null, normal, selected, selStart, selEnd);
+
+ foreach (CellVisualLineElement element in lineVisual.Elements)
+ {
+ CellVisualLineElement shifted = ShiftElement (element, flatColumn);
+ composite.AddElement (shifted);
+ }
+
+ flatColumn += lineVisual.VisualLength;
+
+ if (lineNum < _document.LineCount)
+ {
+ // The newline delimiter occupies document offsets at the end of the line.
+ var nlOffset = line.Offset + line.Length;
+ var nlLength = line.DelimiterLength;
+
+ // Determine the newline glyph attribute: use selection attribute if within selection.
+ Attribute nlAttr = selStart < selEnd && nlOffset < selEnd && nlOffset + nlLength > selStart
+ ? selected
+ : normal;
+
+ composite.AddElement (new NewlineGlyphElement (nlOffset, nlLength, flatColumn, nlAttr));
+ flatColumn += 1;
+ }
+ }
+
+ foreach (IBackgroundRenderer renderer in BackgroundRenderers)
+ {
+ renderer.Draw (this, composite, 0, viewport);
+ }
+
+ foreach (CellVisualLineElement element in composite.Elements)
+ {
+ if (element.VisualColumn >= visibleEnd)
+ {
+ break;
+ }
+
+ if (element.VisualEndColumn <= visibleStart)
+ {
+ continue;
+ }
+
+ element.Draw (this, 0, 0, visibleStart, visibleEnd);
+ }
+
+ foreach (IOverlayRenderer renderer in OverlayRenderers)
+ {
+ renderer.Draw (this, composite, 0, viewport);
+ }
+ }
+
+ ///
+ /// Creates a copy of an element shifted by a flat column offset. Used to compose
+ /// elements from multiple document lines onto a single visual row.
+ ///
+ private static CellVisualLineElement ShiftElement (CellVisualLineElement element, int columnOffset)
+ {
+ if (columnOffset == 0)
+ {
+ return element;
+ }
+
+ return element switch
+ {
+ TabElement tab => new TabElement (
+ tab.DocumentOffset, tab.VisualColumn + columnOffset, tab.VisualLength, tab.ShowTabs, tab.Attribute),
+ FoldingMarkerElement fold => new FoldingMarkerElement (
+ fold.DocumentOffset, fold.DocumentLength, fold.VisualColumn + columnOffset, fold.Attribute, fold.Title),
+ TextRunElement text => new TextRunElement (
+ text.DocumentOffset, text.DocumentLength, text.VisualColumn + columnOffset, text.Text, text.Attribute),
+ _ => element // Unknown element type — leave as-is (shouldn't happen).
+ };
+ }
+
private CellVisualLine BuildWrappedSegmentVisualLine (
DocumentLine documentLine,
int segmentStartOffset,
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index 6f5b7a4..7eb5b86 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -167,7 +167,8 @@ public GutterOptions GutterOptions
/// Enter inserts a newline, vertical navigation moves across lines, and content height reflects the
/// full document. When , newline insertion is suppressed, vertical
/// navigation and scroll are constrained to a single row, is forced off,
- /// and multi-caret operations are disabled. Selection and horizontal editing still work.
+ /// and multi-caret operations are disabled. Existing newlines in the document are preserved (no
+ /// data loss) and rendered as visible ⏎ glyphs. Selection and horizontal editing still work.
///
public bool Multiline
{
@@ -188,14 +189,6 @@ public bool Multiline
// Collapse additional carets — vertical multi-caret is a multi-line concept.
ClearAdditionalCarets ();
-
- // Strip existing newlines so the document is guaranteed single-line.
- if (_document is { LineCount: > 1 })
- {
- string flat = _document.Text.ReplaceLineEndings (string.Empty);
- _document.Text = flat;
- CaretOffset = Math.Min (CaretOffset, _document.TextLength);
- }
}
ClearVisualLineCaches ();
@@ -539,10 +532,19 @@ private void UpdateContentSize ()
if (!Multiline)
{
- // Single-line mode: one row, width from line 1 only.
- var width = _document.LineCount > 0
- ? GetOrBuildDefaultVisualLine (_document.GetLineByNumber (1)).VisualLength
- : 0;
+ // Single-line mode: one row, width = sum of all line visual lengths + newline glyphs.
+ var width = 0;
+
+ for (var i = 1; i <= _document.LineCount; i++)
+ {
+ width += GetOrBuildDefaultVisualLine (_document.GetLineByNumber (i)).VisualLength;
+
+ if (i < _document.LineCount)
+ {
+ width += 1; // newline glyph
+ }
+ }
+
SetContentSize (new Size (width + 1, 1));
return;
@@ -898,6 +900,11 @@ private int GetVisualColumnForOffset (int caretOffset)
return 0;
}
+ if (!Multiline)
+ {
+ return GetFlatVisualColumn (line, caretOffset - line.Offset);
+ }
+
if (!WordWrap)
{
return GetOrBuildDefaultVisualLine (line).GetVisualColumn (caretOffset - line.Offset);
@@ -929,6 +936,54 @@ private int GetCaretLineIndex ()
return _document?.GetLineByOffset (CaretOffset).LineNumber - 1 ?? 0;
}
+ ///
+ /// Returns the visual column of an offset in a flattened single-line view. Sums the visual
+ /// lengths of all preceding lines (plus 1-cell newline glyphs) and adds the column within
+ /// the target line.
+ ///
+ private int GetFlatVisualColumn (DocumentLine targetLine, int offsetInLine)
+ {
+ var flatColumn = 0;
+
+ for (var i = 1; i < targetLine.LineNumber; i++)
+ {
+ flatColumn += GetOrBuildDefaultVisualLine (_document!.GetLineByNumber (i)).VisualLength;
+ flatColumn += 1; // newline glyph
+ }
+
+ flatColumn += GetOrBuildDefaultVisualLine (targetLine).GetVisualColumn (offsetInLine);
+
+ return flatColumn;
+ }
+
+ ///
+ /// Returns the document offset for a given flat visual column in single-line mode.
+ /// Inverse of .
+ ///
+ private int GetOffsetFromFlatVisualColumn (int flatColumn)
+ {
+ var remaining = flatColumn;
+
+ for (var i = 1; i <= _document!.LineCount; i++)
+ {
+ CellVisualLine visualLine = GetOrBuildDefaultVisualLine (_document.GetLineByNumber (i));
+ var lineWidth = visualLine.VisualLength;
+
+ if (i < _document.LineCount && remaining > lineWidth)
+ {
+ remaining -= lineWidth + 1; // +1 for newline glyph
+ }
+ else
+ {
+ DocumentLine line = _document.GetLineByNumber (i);
+
+ return line.Offset + visualLine.GetRelativeOffset (Math.Min (remaining, lineWidth));
+ }
+ }
+
+ return _document.TextLength;
+ }
+
///
/// Returns the caret's position as an index into the visible-line list (i.e. the coordinate
/// system used by Viewport.Y). Falls back to when
@@ -936,6 +991,11 @@ private int GetCaretLineIndex ()
///
private int GetCaretVisibleLineIndex ()
{
+ if (!Multiline)
+ {
+ return 0;
+ }
+
if (WordWrap)
{
return GetCaretWrapRow ();
diff --git a/src/Terminal.Gui.Editor/Rendering/NewlineGlyphElement.cs b/src/Terminal.Gui.Editor/Rendering/NewlineGlyphElement.cs
new file mode 100644
index 0000000..ce60bcb
--- /dev/null
+++ b/src/Terminal.Gui.Editor/Rendering/NewlineGlyphElement.cs
@@ -0,0 +1,27 @@
+using Terminal.Gui.ViewBase;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace Terminal.Gui.Editor.Rendering;
+
+/// A newline character rendered as a visible glyph in single-line mode.
+public sealed class NewlineGlyphElement (
+ int documentOffset,
+ int documentLength,
+ int visualColumn,
+ Attribute attribute)
+ : CellVisualLineElement (documentOffset, documentLength, visualColumn, 1, attribute)
+{
+ /// The glyph used to represent a newline character.
+ internal const string NewlineGlyph = "⏎";
+
+ public override void Draw (View host, int x, int y, int visibleStart, int visibleEnd)
+ {
+ if (VisualEndColumn <= visibleStart || VisualColumn >= visibleEnd)
+ {
+ return;
+ }
+
+ host.SetAttribute (Attribute);
+ host.AddStr (x + VisualColumn - visibleStart, y, NewlineGlyph);
+ }
+}
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
index adcfb16..a168944 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -198,19 +198,43 @@ public async Task SingleLine_Paste_Strips_Newlines ()
fx.App.Clipboard!.SetClipboardData ("cd\nef\r\ngh");
fx.Injector.InjectKey (Key.V.WithCtrl, Direct);
+ // Paste still strips newlines so new content stays on one logical line.
Assert.Equal ("abcdefgh", fx.Top.Editor.Document!.Text);
Assert.Equal (1, fx.Top.Editor.Document.LineCount);
}
[Fact]
- public async Task SingleLine_Transition_Strips_Existing_Newlines ()
+ public async Task SingleLine_Transition_Preserves_Newlines ()
{
await using AppFixture fx = new (() => new EditorTestHost ("line1\nline2\nline3"));
Assert.Equal (3, fx.Top.Editor.Document!.LineCount);
fx.Top.Editor.Multiline = false;
- Assert.Equal ("line1line2line3", fx.Top.Editor.Document.Text);
- Assert.Equal (1, fx.Top.Editor.Document.LineCount);
+ // Newlines are preserved — no data loss.
+ Assert.Equal ("line1\nline2\nline3", fx.Top.Editor.Document.Text);
+ Assert.Equal (3, fx.Top.Editor.Document.LineCount);
+
+ // Content size height is still 1 (single visual row).
+ fx.Render ();
+ Assert.Equal (1, fx.Top.Editor.GetContentSize ().Height);
+ }
+
+ [Fact]
+ public async Task SingleLine_Home_End_Navigate_To_Document_Bounds ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("ab\ncd\nef"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+
+ // Place caret in the middle of line 2.
+ fx.Top.Editor.CaretOffset = 4; // 'c' on line 2
+
+ fx.Injector.InjectKey (Key.Home, Direct);
+ Assert.Equal (0, fx.Top.Editor.CaretOffset);
+
+ fx.Top.Editor.CaretOffset = 4;
+ fx.Injector.InjectKey (Key.End, Direct);
+ Assert.Equal (8, fx.Top.Editor.CaretOffset); // end of "ef"
}
}
From a2a465befb2c172adf3da46cbea99587b80bc5f6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 21:13:57 +0000
Subject: [PATCH 05/14] =?UTF-8?q?fix:=20address=20code=20review=20?=
=?UTF-8?q?=E2=80=94=20eliminate=20duplicated=20line=20lookup=20in=20GetOf?=
=?UTF-8?q?fsetFromFlatVisualColumn?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/a31fcadb-b57a-435a-824a-91a121a30ed3
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
src/Terminal.Gui.Editor/Editor.cs | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index 4729a33..6acd59c 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -967,7 +967,8 @@ private int GetOffsetFromFlatVisualColumn (int flatColumn)
for (var i = 1; i <= _document!.LineCount; i++)
{
- CellVisualLine visualLine = GetOrBuildDefaultVisualLine (_document.GetLineByNumber (i));
+ DocumentLine line = _document.GetLineByNumber (i);
+ CellVisualLine visualLine = GetOrBuildDefaultVisualLine (line);
var lineWidth = visualLine.VisualLength;
if (i < _document.LineCount && remaining > lineWidth)
@@ -976,8 +977,6 @@ private int GetOffsetFromFlatVisualColumn (int flatColumn)
}
else
{
- DocumentLine line = _document.GetLineByNumber (i);
-
return line.Offset + visualLine.GetRelativeOffset (Math.Min (remaining, lineWidth));
}
}
From b646a20608e31b9e98ca6d6de176e59449e6f9e4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 21:56:56 +0000
Subject: [PATCH 06/14] =?UTF-8?q?fix:=20address=205=20CR=20items=20?=
=?UTF-8?q?=E2=80=94=20scroll=20returns=20true,=20use=20visible=20lines=20?=
=?UTF-8?q?for=20fold-aware=20flat=20view,=20remove=20dead=20code,=20fix?=
=?UTF-8?q?=20CI=20paste=20test?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/64aad595-2e97-470a-9034-7aaa766da26b
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
src/Terminal.Gui.Editor/Editor.Commands.cs | 9 ++-
src/Terminal.Gui.Editor/Editor.Drawing.cs | 18 ++++--
src/Terminal.Gui.Editor/Editor.cs | 54 ++++++------------
.../EditorSingleLineTests.cs | 57 ++++++++++++++++++-
4 files changed, 94 insertions(+), 44 deletions(-)
diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs
index 7374606..20fea39 100644
--- a/src/Terminal.Gui.Editor/Editor.Commands.cs
+++ b/src/Terminal.Gui.Editor/Editor.Commands.cs
@@ -339,7 +339,14 @@ private void CreateCommandsAndBindings ()
private bool? ScrollVerticalCommand (int delta)
{
- if (!Multiline || _document is null || ScrollVertical (delta) != true)
+ // In single-line mode, vertical scroll is a no-op but must return true (handled)
+ // to prevent the event from bubbling to parent containers.
+ if (!Multiline)
+ {
+ return true;
+ }
+
+ if (_document is null || ScrollVertical (delta) != true)
{
return false;
}
diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs
index 2800ba3..aa8dc53 100644
--- a/src/Terminal.Gui.Editor/Editor.Drawing.cs
+++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs
@@ -140,14 +140,22 @@ private void DrawSingleLineFlat (
int visibleStart,
int visibleEnd)
{
- // Build a composite visual line from all document lines, inserting newline glyph
- // elements between them. Uses line 1 as the owning DocumentLine.
- DocumentLine firstLine = _document!.GetLineByNumber (1);
+ // Build a composite visual line from visible document lines (respecting folds),
+ // inserting newline glyph elements between them. Uses the first visible line as the owner.
+ List visibleLines = GetVisibleLineNumbers ();
+
+ if (visibleLines.Count == 0)
+ {
+ return;
+ }
+
+ DocumentLine firstLine = _document!.GetLineByNumber (visibleLines[0]);
CellVisualLine composite = new (firstLine);
var flatColumn = 0;
- for (var lineNum = 1; lineNum <= _document.LineCount; lineNum++)
+ for (var idx = 0; idx < visibleLines.Count; idx++)
{
+ var lineNum = visibleLines[idx];
DocumentLine line = _document.GetLineByNumber (lineNum);
CellVisualLine lineVisual = GetOrBuildDrawVisualLine (line, null, normal, selected, selStart, selEnd);
@@ -159,7 +167,7 @@ private void DrawSingleLineFlat (
flatColumn += lineVisual.VisualLength;
- if (lineNum < _document.LineCount)
+ if (idx < visibleLines.Count - 1)
{
// The newline delimiter occupies document offsets at the end of the line.
var nlOffset = line.Offset + line.Length;
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index 6acd59c..8e1c330 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -533,14 +533,15 @@ private void UpdateContentSize ()
if (!Multiline)
{
- // Single-line mode: one row, width = sum of all line visual lengths + newline glyphs.
+ // Single-line mode: one row, width = sum of visible line visual lengths + newline glyphs.
+ List visibleLines = GetVisibleLineNumbers ();
var width = 0;
- for (var i = 1; i <= _document.LineCount; i++)
+ for (var idx = 0; idx < visibleLines.Count; idx++)
{
- width += GetOrBuildDefaultVisualLine (_document.GetLineByNumber (i)).VisualLength;
+ width += GetOrBuildDefaultVisualLine (_document.GetLineByNumber (visibleLines[idx])).VisualLength;
- if (i < _document.LineCount)
+ if (idx < visibleLines.Count - 1)
{
width += 1; // newline glyph
}
@@ -567,8 +568,8 @@ private void UpdateContentSize ()
}
// +1 column lets the caret sit just past the end-of-line.
- var visibleLines = _document.LineCount - (FoldingManager?.GetHiddenLineCount () ?? 0);
- SetContentSize (new Size (_maxVisualWidth + 1, Math.Max (1, visibleLines)));
+ var visibleLineCount = _document.LineCount - (FoldingManager?.GetHiddenLineCount () ?? 0);
+ SetContentSize (new Size (_maxVisualWidth + 1, Math.Max (1, visibleLineCount)));
}
/// Full O(N) recompute — only called on Document swap, IndentationSize change, etc.
@@ -939,16 +940,22 @@ private int GetCaretLineIndex ()
///
/// Returns the visual column of an offset in a flattened single-line view. Sums the visual
- /// lengths of all preceding lines (plus 1-cell newline glyphs) and adds the column within
- /// the target line.
+ /// lengths of all visible preceding lines (plus 1-cell newline glyphs) and adds the column
+ /// within the target line. Respects folding by using .
///
private int GetFlatVisualColumn (DocumentLine targetLine, int offsetInLine)
{
+ List visibleLines = GetVisibleLineNumbers ();
var flatColumn = 0;
- for (var i = 1; i < targetLine.LineNumber; i++)
+ foreach (var lineNum in visibleLines)
{
- flatColumn += GetOrBuildDefaultVisualLine (_document!.GetLineByNumber (i)).VisualLength;
+ if (lineNum == targetLine.LineNumber)
+ {
+ break;
+ }
+
+ flatColumn += GetOrBuildDefaultVisualLine (_document!.GetLineByNumber (lineNum)).VisualLength;
flatColumn += 1; // newline glyph
}
@@ -957,33 +964,6 @@ private int GetFlatVisualColumn (DocumentLine targetLine, int offsetInLine)
return flatColumn;
}
- ///
- /// Returns the document offset for a given flat visual column in single-line mode.
- /// Inverse of .
- ///
- private int GetOffsetFromFlatVisualColumn (int flatColumn)
- {
- var remaining = flatColumn;
-
- for (var i = 1; i <= _document!.LineCount; i++)
- {
- DocumentLine line = _document.GetLineByNumber (i);
- CellVisualLine visualLine = GetOrBuildDefaultVisualLine (line);
- var lineWidth = visualLine.VisualLength;
-
- if (i < _document.LineCount && remaining > lineWidth)
- {
- remaining -= lineWidth + 1; // +1 for newline glyph
- }
- else
- {
- return line.Offset + visualLine.GetRelativeOffset (Math.Min (remaining, lineWidth));
- }
- }
-
- return _document.TextLength;
- }
-
///
/// Returns the caret's position as an index into the visible-line list (i.e. the coordinate
/// system used by Viewport.Y). Falls back to when
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
index a168944..28bb553 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -1,5 +1,6 @@
// Copilot - claude-sonnet-4
+using Terminal.Gui.Drivers;
using Terminal.Gui.Editor.IntegrationTests.Testing;
using Terminal.Gui.Input;
using Terminal.Gui.Testing;
@@ -191,11 +192,12 @@ public async Task SingleLine_VerticalExtend_NoOp ()
public async Task SingleLine_Paste_Strips_Newlines ()
{
await using AppFixture fx = new (() => new EditorTestHost ("ab"));
+ fx.Driver.Clipboard = new FakeClipboard (false, false);
fx.Top.Editor.Multiline = false;
fx.Top.Editor.SetFocus ();
fx.Top.Editor.CaretOffset = 2;
- fx.App.Clipboard!.SetClipboardData ("cd\nef\r\ngh");
+ fx.App.Clipboard!.TrySetClipboardData ("cd\nef\r\ngh");
fx.Injector.InjectKey (Key.V.WithCtrl, Direct);
// Paste still strips newlines so new content stays on one logical line.
@@ -237,4 +239,57 @@ public async Task SingleLine_Home_End_Navigate_To_Document_Bounds ()
fx.Injector.InjectKey (Key.End, Direct);
Assert.Equal (8, fx.Top.Editor.CaretOffset); // end of "ef"
}
+
+ ///
+ /// CR1: Vertical scroll command should return true (handled) in single-line mode so the
+ /// event doesn't bubble to parent containers.
+ ///
+ [Fact]
+ public async Task SingleLine_ScrollVertical_Is_Handled_NoOp ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+
+ // Scroll commands should not change caret position and should be handled (not bubble).
+ // Viewport.Y should remain 0 since there's only one row.
+ fx.Top.Editor.CaretOffset = 2;
+ fx.Injector.InjectKey (Key.CursorUp.WithCtrl, Direct); // typically triggers scroll
+ Assert.Equal (2, fx.Top.Editor.CaretOffset);
+ Assert.Equal (0, fx.Top.Editor.Viewport.Y);
+ }
+
+ ///
+ /// CR2/CR3/CR5: Folded lines should be excluded from the single-line flat view. When lines
+ /// are hidden by folding, the flat visual width and rendering should skip them.
+ ///
+ [Fact]
+ public async Task SingleLine_Folding_Hides_Lines_From_Flat_View ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("ab\ncd\nef"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+
+ // Install a FoldingManager so we can fold lines.
+ fx.Top.Editor.FoldingManager = new Terminal.Gui.Document.Folding.FoldingManager (fx.Top.Editor.Document!);
+ fx.Render ();
+
+ // Full flat width: "ab" (2) + ⏎ (1) + "cd" (2) + ⏎ (1) + "ef" (2) = 8 plus +1 for caret-past-end = 9
+ Assert.Equal (9, fx.Top.Editor.GetContentSize ().Width);
+
+ // CreateFolding spanning from start of "ab" to end of "cd\n" hides line 2.
+ Terminal.Gui.Document.Folding.FoldingSection fold = fx.Top.Editor.FoldingManager!.CreateFolding (
+ 0, fx.Top.Editor.Document!.GetLineByNumber (2).EndOffset);
+ fold.IsFolded = true;
+
+ // Toggling a property that clears caches to force re-computation
+ fx.Top.Editor.SetNeedsDraw ();
+ fx.Render ();
+
+ // After folding, line 2 is hidden. Content width should be smaller than unfolded.
+ // BUG (before fix): UpdateContentSize iterates all physical lines, so width stays at 9.
+ int widthAfterFold = fx.Top.Editor.GetContentSize ().Width;
+ Assert.True (widthAfterFold < 9,
+ $"Content width after fold should decrease from 9, was {widthAfterFold}");
+ }
}
From debf6eb7071ee10eda7da7ca985e8662a704ac3f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 17 May 2026 21:59:16 +0000
Subject: [PATCH 07/14] style: clean up inline namespace qualifications and
comment in fold test
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/64aad595-2e97-470a-9034-7aaa766da26b
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
.../EditorSingleLineTests.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
index 28bb553..e4870fc 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -1,5 +1,6 @@
// Copilot - claude-sonnet-4
+using Terminal.Gui.Document.Folding;
using Terminal.Gui.Drivers;
using Terminal.Gui.Editor.IntegrationTests.Testing;
using Terminal.Gui.Input;
@@ -271,14 +272,14 @@ public async Task SingleLine_Folding_Hides_Lines_From_Flat_View ()
fx.Top.Editor.SetFocus ();
// Install a FoldingManager so we can fold lines.
- fx.Top.Editor.FoldingManager = new Terminal.Gui.Document.Folding.FoldingManager (fx.Top.Editor.Document!);
+ fx.Top.Editor.FoldingManager = new FoldingManager (fx.Top.Editor.Document!);
fx.Render ();
// Full flat width: "ab" (2) + ⏎ (1) + "cd" (2) + ⏎ (1) + "ef" (2) = 8 plus +1 for caret-past-end = 9
Assert.Equal (9, fx.Top.Editor.GetContentSize ().Width);
// CreateFolding spanning from start of "ab" to end of "cd\n" hides line 2.
- Terminal.Gui.Document.Folding.FoldingSection fold = fx.Top.Editor.FoldingManager!.CreateFolding (
+ FoldingSection fold = fx.Top.Editor.FoldingManager!.CreateFolding (
0, fx.Top.Editor.Document!.GetLineByNumber (2).EndOffset);
fold.IsFolded = true;
@@ -286,8 +287,7 @@ public async Task SingleLine_Folding_Hides_Lines_From_Flat_View ()
fx.Top.Editor.SetNeedsDraw ();
fx.Render ();
- // After folding, line 2 is hidden. Content width should be smaller than unfolded.
- // BUG (before fix): UpdateContentSize iterates all physical lines, so width stays at 9.
+ // After folding, UpdateContentSize skips hidden lines, reducing the width.
int widthAfterFold = fx.Top.Editor.GetContentSize ().Width;
Assert.True (widthAfterFold < 9,
$"Content width after fold should decrease from 9, was {widthAfterFold}");
From 56db2ef7b053219055d52af40c3d93e7523a214b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 00:34:24 +0000
Subject: [PATCH 08/14] =?UTF-8?q?feat:=20add=20prompt=20example=20?=
=?UTF-8?q?=E2=80=94=20single-line=20Editor=20CLI=20tool?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/0eacbfd7-0359-41cb-b64d-ba884572b9e2
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
Terminal.Gui.Editor.slnx | 1 +
examples/prompt/Program.cs | 58 +++++++++++++++++++++++++++++++++++
examples/prompt/prompt.csproj | 15 +++++++++
3 files changed, 74 insertions(+)
create mode 100644 examples/prompt/Program.cs
create mode 100644 examples/prompt/prompt.csproj
diff --git a/Terminal.Gui.Editor.slnx b/Terminal.Gui.Editor.slnx
index 5f3c90a..9442a9d 100644
--- a/Terminal.Gui.Editor.slnx
+++ b/Terminal.Gui.Editor.slnx
@@ -29,6 +29,7 @@
+
diff --git a/examples/prompt/Program.cs b/examples/prompt/Program.cs
new file mode 100644
index 0000000..de035f5
--- /dev/null
+++ b/examples/prompt/Program.cs
@@ -0,0 +1,58 @@
+// Claude - claude-sonnet-4-5
+// prompt — single-line Editor example. Captures command-line text, lets the user edit it,
+// outputs to stdout on Enter, exits silently on Esc.
+
+using Terminal.Gui.App;
+using Terminal.Gui.Document;
+using Terminal.Gui.Editor;
+using Terminal.Gui.Input;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+var initialText = string.Join (' ', args);
+string? result = null;
+
+using IApplication app = Application.Create ();
+app.Init ();
+
+var window = new Window
+{
+ Title = "prompt",
+ Width = Dim.Fill (),
+ Height = Dim.Fill ()
+};
+
+var editor = new Editor
+{
+ Multiline = false,
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = 1,
+ Document = new TextDocument (initialText),
+ CaretOffset = initialText.Length
+};
+
+editor.KeyDown += (_, key) =>
+{
+ if (key == Key.Enter)
+ {
+ result = editor.Document.Text;
+ window.RequestStop ();
+ key.Handled = true;
+ }
+ else if (key == Key.Esc)
+ {
+ window.RequestStop ();
+ key.Handled = true;
+ }
+};
+
+window.Add (editor);
+
+app.Run (window);
+
+if (result is not null)
+{
+ Console.WriteLine (result);
+}
diff --git a/examples/prompt/prompt.csproj b/examples/prompt/prompt.csproj
new file mode 100644
index 0000000..efe1025
--- /dev/null
+++ b/examples/prompt/prompt.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ Prompt
+ prompt
+ false
+
+
+
+
+
+
+
+
From 9bf0b73596148e6ccda8a00ff1195597b25e63f2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 00:41:16 +0000
Subject: [PATCH 09/14] fix(prompt): use explicit types per CLAUDE.md
conventions
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/7c5801fd-eda3-4bc0-88f9-8d93cc6d38cd
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
examples/prompt/Program.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/examples/prompt/Program.cs b/examples/prompt/Program.cs
index de035f5..9bc722b 100644
--- a/examples/prompt/Program.cs
+++ b/examples/prompt/Program.cs
@@ -9,20 +9,20 @@
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
-var initialText = string.Join (' ', args);
+string initialText = string.Join (' ', args);
string? result = null;
using IApplication app = Application.Create ();
app.Init ();
-var window = new Window
+Window window = new ()
{
Title = "prompt",
Width = Dim.Fill (),
Height = Dim.Fill ()
};
-var editor = new Editor
+Editor editor = new ()
{
Multiline = false,
X = 0,
From 8d01b7f8ca6210af2337252ea51e7ed9edcb7091 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 00:53:41 +0000
Subject: [PATCH 10/14] feat: single-line Enter raises Accept; Height defaults
to Dim.Auto(Content)
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/18b39565-5677-4f8d-97b5-57e82de49317
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
examples/prompt/Program.cs | 16 +++++------
src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 9 +++++-
src/Terminal.Gui.Editor/Editor.cs | 3 ++
.../EditorSingleLineTests.cs | 28 +++++++++++++++++++
4 files changed, 47 insertions(+), 9 deletions(-)
diff --git a/examples/prompt/Program.cs b/examples/prompt/Program.cs
index 9bc722b..d7ad948 100644
--- a/examples/prompt/Program.cs
+++ b/examples/prompt/Program.cs
@@ -28,20 +28,20 @@
X = 0,
Y = 0,
Width = Dim.Fill (),
- Height = 1,
Document = new TextDocument (initialText),
CaretOffset = initialText.Length
};
+editor.Accepting += (_, e) =>
+{
+ result = editor.Document.Text;
+ window.RequestStop ();
+ e.Handled = true;
+};
+
editor.KeyDown += (_, key) =>
{
- if (key == Key.Enter)
- {
- result = editor.Document.Text;
- window.RequestStop ();
- key.Handled = true;
- }
- else if (key == Key.Esc)
+ if (key == Key.Esc)
{
window.RequestStop ();
key.Handled = true;
diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
index 9033abb..fbc37f3 100644
--- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
+++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
@@ -1,5 +1,6 @@
using System.Drawing;
using Terminal.Gui.Document;
+using Terminal.Gui.Input;
namespace Terminal.Gui.Editor;
@@ -479,11 +480,17 @@ private void MultiCaretInsert (string text)
///
private bool? MultiCaretNewLine ()
{
- if (ReadOnly || !Multiline || _document is null)
+ if (ReadOnly || _document is null)
{
return true;
}
+ if (!Multiline)
+ {
+ // Single-line mode: Enter raises Accept (like TextField) instead of inserting a newline.
+ return InvokeCommand (Command.Accept);
+ }
+
if (!HasMultipleCarets)
{
return InsertNewLineWithAutoIndent ();
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index 4522ca0..c7de340 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -220,6 +220,9 @@ public bool Multiline
// Collapse additional carets — vertical multi-caret is a multi-line concept.
ClearAdditionalCarets ();
+
+ // Auto-size to ContentSize (height = 1) so callers don't need explicit Height = 1.
+ Height = Dim.Auto (DimAutoStyle.Content);
}
ClearVisualLineCaches ();
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
index e4870fc..cd2a301 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -120,6 +120,34 @@ public async Task SingleLine_ContentSize_Height_Is_One ()
Assert.Equal (1, fx.Top.Editor.GetContentSize ().Height);
}
+ [Fact]
+ public async Task SingleLine_Height_Defaults_To_DimAuto ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Render ();
+
+ // Multiline=false sets Height to Dim.Auto(Content), so no explicit Height=1 needed.
+ Assert.Equal (1, fx.Top.Editor.Frame.Height);
+ }
+
+ [Fact]
+ public async Task SingleLine_Enter_Raises_Accept ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("hello"));
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+
+ var accepted = false;
+ fx.Top.Editor.Accepting += (_, _) => accepted = true;
+
+ fx.Injector.InjectKey (Key.Enter, Direct);
+
+ Assert.True (accepted);
+ // Document is still unchanged (no newline inserted).
+ Assert.Equal ("hello", fx.Top.Editor.Document!.Text);
+ }
+
[Fact]
public async Task SingleLine_PageUp_PageDown_Are_NoOps ()
{
From 0af663b285cdefd338578d50c545af2b85f13f20 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 01:10:04 +0000
Subject: [PATCH 11/14] =?UTF-8?q?refactor:=20use=20keybinding=20Enter?=
=?UTF-8?q?=E2=86=92Command.Accept=20in=20single-line=20mode?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Instead of having MultiCaretNewLine internally call InvokeCommand(Accept),
the Multiline setter now rebinds Key.Enter to Command.Accept directly.
This follows TG's keybinding architecture properly.
Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/01cadd3f-680a-4caa-a09c-24a549765214
Co-authored-by: tig <585482+tig@users.noreply.github.com>
---
src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 9 +--------
src/Terminal.Gui.Editor/Editor.cs | 11 +++++++++++
2 files changed, 12 insertions(+), 8 deletions(-)
diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
index fbc37f3..9033abb 100644
--- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
+++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs
@@ -1,6 +1,5 @@
using System.Drawing;
using Terminal.Gui.Document;
-using Terminal.Gui.Input;
namespace Terminal.Gui.Editor;
@@ -480,17 +479,11 @@ private void MultiCaretInsert (string text)
///
private bool? MultiCaretNewLine ()
{
- if (ReadOnly || _document is null)
+ if (ReadOnly || !Multiline || _document is null)
{
return true;
}
- if (!Multiline)
- {
- // Single-line mode: Enter raises Accept (like TextField) instead of inserting a newline.
- return InvokeCommand (Command.Accept);
- }
-
if (!HasMultipleCarets)
{
return InsertNewLineWithAutoIndent ();
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index c7de340..2e17284 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -6,6 +6,7 @@
using Terminal.Gui.Drawing;
using Terminal.Gui.Editor.Rendering;
using Terminal.Gui.Highlighting;
+using Terminal.Gui.Input;
using Terminal.Gui.Text;
using Terminal.Gui.Text.Indentation;
using Terminal.Gui.ViewBase;
@@ -223,6 +224,16 @@ public bool Multiline
// Auto-size to ContentSize (height = 1) so callers don't need explicit Height = 1.
Height = Dim.Auto (DimAutoStyle.Content);
+
+ // Rebind Enter to Accept (like TextField) — raises Accepting event.
+ KeyBindings.Remove (Key.Enter);
+ KeyBindings.Add (Key.Enter, Command.Accept);
+ }
+ else
+ {
+ // Restore Enter → NewLine binding for multiline mode.
+ KeyBindings.Remove (Key.Enter);
+ KeyBindings.Add (Key.Enter, Command.NewLine);
}
ClearVisualLineCaches ();
From 847ba1e648f668e147a33841f0113a57c9b50906 Mon Sep 17 00:00:00 2001
From: Tig
Date: Mon, 18 May 2026 21:01:25 -0500
Subject: [PATCH 12/14] Lock single-line newline-glyph rendering with an ANSI
snapshot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the AnsiSnapshot harness (IDriver.ToAnsi() golden-file capture; see
gui-cs/Editor PR #142 and gui-cs/Terminal.Gui PR #5343) and a test that
pins DrawSingleLineFlat: with Multiline = false a multi-line document
flattens onto one row with each newline rendered as a visible ⏎ glyph.
The golden reproduces the exact look (`cat` the .ans). *.ans is marked
binary in .gitattributes so core.autocrlf cannot corrupt the compare.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.gitattributes | 4 +
.../EditorSingleLineTests.cs | 18 +++
.../Testing/AnsiSnapshot.cs | 117 ++++++++++++++++++
.../SingleLine_Renders_Newlines_As_Glyphs.ans | 3 +
4 files changed, 142 insertions(+)
create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs
create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans
diff --git a/.gitattributes b/.gitattributes
index adbc93a..2fd3cd6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -19,6 +19,10 @@
*.bat text eol=crlf
*.ps1 text eol=crlf
+# ANSI snapshot goldens: raw escape-sequence streams with CRLF row breaks.
+# Must NOT be EOL-normalized or the byte-exact compare + `cat` fidelity breaks.
+*.ans binary
+
# Binary
*.png binary
*.jpg binary
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
index cd2a301..fd1955d 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs
@@ -32,6 +32,24 @@ public async Task SingleLine_Enter_Does_Not_Insert_Newline ()
Assert.Equal (1, fx.Top.Editor.Document.LineCount);
}
+ [Fact]
+ public async Task SingleLine_Renders_Newlines_As_Glyphs ()
+ {
+ await using AppFixture fx = new (() => new EditorTestHost ("ab\ncd\nef"), 12, 3);
+ fx.Top.Editor.Multiline = false;
+ fx.Top.Editor.SetFocus ();
+ fx.Render ();
+
+ // The document keeps its newlines; single-line mode flattens every visible line onto one
+ // row, rendering each newline as a visible ⏎ glyph (DrawSingleLineFlat). The ANSI golden
+ // locks that exact look — cat __snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans.
+ Assert.Equal ("ab\ncd\nef", fx.Top.Editor.Document!.Text);
+ Assert.Equal (3, fx.Top.Editor.Document.LineCount);
+ Assert.Equal (1, fx.Top.Editor.Viewport.Height);
+
+ AnsiSnapshot.Verify (fx.Driver, nameof (SingleLine_Renders_Newlines_As_Glyphs));
+ }
+
[Fact]
public async Task SingleLine_Up_Down_Are_NoOps ()
{
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs
new file mode 100644
index 0000000..ba13085
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs
@@ -0,0 +1,117 @@
+// Claude - claude-opus-4-7
+
+using System.Runtime.CompilerServices;
+using System.Text;
+using Terminal.Gui.Drivers;
+using Xunit;
+using Xunit.Sdk;
+
+namespace Terminal.Gui.Editor.IntegrationTests.Testing;
+
+///
+/// Golden-file snapshot of the rendered screen as pure ANSI.
+/// emits exactly the escape-sequence stream the driver would
+/// write to recreate the current screen contents — colors, text styles, layout, everything
+/// except the terminal cursor (which is a separate, non-deterministic SetCursor). So a
+/// recorded .ans file is the look: cat <file>.ans in any terminal
+/// reproduces it faithfully.
+///
+///
+///
+/// The point is letting an agent iterate on rendering without a human eyeballing the
+/// result. On mismatch the failure prints the plain-text render inline (visible directly
+/// in the test log, no terminal needed) and writes a sibling .ans.actual the agent
+/// can cat for an exact, full-fidelity view before accepting.
+///
+///
+/// Workflow: first run records the golden and passes. Later runs compare byte-for-byte.
+/// To accept an intended change, re-run with UPDATE_SNAPSHOTS=1 (or delete the
+/// .ans file). Goldens live next to the test source in __snapshots__/;
+/// override the root with SNAPSHOT_DIR.
+///
+///
+public static class AnsiSnapshot
+{
+ private static bool UpdateRequested =>
+ Environment.GetEnvironmentVariable ("UPDATE_SNAPSHOTS") is "1" or "true";
+
+ ///
+ /// Compares the driver's current ANSI render against the golden named .
+ /// Records the golden (and passes) when it does not exist or UPDATE_SNAPSHOTS is set.
+ ///
+ /// The driver to snapshot — typically fx.Driver after fx.Render ().
+ /// Stable snapshot name, unique within the test class (becomes <name>.ans).
+ /// Compiler-supplied; locates __snapshots__/ beside the test source.
+ public static void Verify (IDriver driver, string name, [CallerFilePath] string callerFile = "")
+ {
+ ArgumentNullException.ThrowIfNull (driver);
+ ArgumentException.ThrowIfNullOrWhiteSpace (name);
+
+ var actual = driver.ToAnsi ();
+ string dir = SnapshotDir (callerFile);
+ Directory.CreateDirectory (dir);
+ string path = Path.Combine (dir, name + ".ans");
+
+ if (UpdateRequested || !File.Exists (path))
+ {
+ WriteRaw (path, actual);
+ var verb = UpdateRequested ? "updated" : "recorded";
+ TestContext.Current.SendDiagnosticMessage ($"[snapshot {verb}] {path} — `cat` it to verify the look.");
+
+ return;
+ }
+
+ var expected = File.ReadAllText (path);
+
+ if (string.Equals (expected, actual, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ string actualPath = path + ".actual";
+ WriteRaw (actualPath, actual);
+
+ throw new XunitException (
+ $"""
+ ANSI snapshot '{name}' does not match {path}.
+
+ Plain-text render of the actual screen (cell glyphs only — attributes/colors omitted):
+ ----------------------------------------------------------------------
+ {driver}
+ ----------------------------------------------------------------------
+
+ Exact look (with colors/styles): cat '{actualPath}'
+ Expected look: cat '{path}'
+
+ If this change is intended, accept it by re-running with UPDATE_SNAPSHOTS=1
+ (or copy the .actual over the .ans).
+ """);
+ }
+
+ private static string SnapshotDir (string callerFile)
+ {
+ string? overrideDir = Environment.GetEnvironmentVariable ("SNAPSHOT_DIR");
+
+ if (!string.IsNullOrWhiteSpace (overrideDir))
+ {
+ return overrideDir;
+ }
+
+ string? sourceDir = Path.GetDirectoryName (callerFile);
+
+ if (string.IsNullOrEmpty (sourceDir))
+ {
+ throw new InvalidOperationException (
+ "Could not resolve the snapshot directory from the caller path. Set SNAPSHOT_DIR.");
+ }
+
+ return Path.Combine (sourceDir, "__snapshots__");
+ }
+
+ // Byte-exact, UTF-8 without BOM, no added/translated newlines: the file must remain a
+ // faithful `cat`-able reproduction of the terminal stream.
+ private static void WriteRaw (string path, string ansi)
+ {
+ File.WriteAllText (path, ansi, new UTF8Encoding (false));
+ }
+}
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans
new file mode 100644
index 0000000..b68f1cf
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans
@@ -0,0 +1,3 @@
+[39m[49mab⏎cd⏎ef
+
+
From 8ba1124a1235efc78e630aa621b06dd5853ddfb0 Mon Sep 17 00:00:00 2001
From: Tig
Date: Mon, 18 May 2026 21:06:26 -0500
Subject: [PATCH 13/14] Canonicalize snapshot newlines to LF (cross-platform CI
fix)
Same fix as gui-cs/Editor PR #142: TG's ToAnsi uses Environment.NewLine
for row breaks, so a Windows-recorded golden fails on Linux/macOS CI.
AnsiSnapshot now normalizes capture + golden to \n; golden re-recorded LF.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../Testing/AnsiSnapshot.cs | 18 ++++++++++++++----
.../SingleLine_Renders_Newlines_As_Glyphs.ans | 6 +++---
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs
index ba13085..c0937ff 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/AnsiSnapshot.cs
@@ -47,7 +47,7 @@ public static void Verify (IDriver driver, string name, [CallerFilePath] string
ArgumentNullException.ThrowIfNull (driver);
ArgumentException.ThrowIfNullOrWhiteSpace (name);
- var actual = driver.ToAnsi ();
+ var actual = Canonicalize (driver.ToAnsi ());
string dir = SnapshotDir (callerFile);
Directory.CreateDirectory (dir);
string path = Path.Combine (dir, name + ".ans");
@@ -61,7 +61,7 @@ public static void Verify (IDriver driver, string name, [CallerFilePath] string
return;
}
- var expected = File.ReadAllText (path);
+ var expected = Canonicalize (File.ReadAllText (path));
if (string.Equals (expected, actual, StringComparison.Ordinal))
{
@@ -108,8 +108,18 @@ private static string SnapshotDir (string callerFile)
return Path.Combine (sourceDir, "__snapshots__");
}
- // Byte-exact, UTF-8 without BOM, no added/translated newlines: the file must remain a
- // faithful `cat`-able reproduction of the terminal stream.
+ // TG's OutputBase.ToAnsi separates rows with StringBuilder.AppendLine () == Environment.NewLine
+ // — CRLF on Windows, LF elsewhere. Without this, a golden recorded on one OS never matches
+ // another OS's render (the CI failure that motivated this). Canonicalize both sides to LF.
+ // `cat` fidelity is unaffected: terminals map LF -> CRLF via the ONLCR tty discipline, so a
+ // LF-only .ans still reproduces the screen exactly.
+ private static string Canonicalize (string? ansi)
+ {
+ return (ansi ?? string.Empty).Replace ("\r\n", "\n").Replace ("\r", "\n");
+ }
+
+ // UTF-8 without BOM. Content is already LF-canonical; write it verbatim (no extra
+ // translation) so the on-disk golden stays a faithful `cat`-able reproduction.
private static void WriteRaw (string path, string ansi)
{
File.WriteAllText (path, ansi, new UTF8Encoding (false));
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans
index b68f1cf..8205921 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans
@@ -1,3 +1,3 @@
-[39m[49mab⏎cd⏎ef
-
-
+[39m[49mab⏎cd⏎ef
+
+
From 1fd43b8ff810f88e94055b3702835f8485af06e3 Mon Sep 17 00:00:00 2001
From: Tig
Date: Mon, 18 May 2026 21:31:45 -0500
Subject: [PATCH 14/14] Update editor key handling and project config files
Refactored Program.cs to use var for initialText and set AppModel to Inline. Modified editor KeyDown to only handle Esc key. Excluded LICENSE and README.md from prompt and ted projects. Added launchSettings.json with test profile for prompt.
---
examples/prompt/Program.cs | 11 +++++++----
examples/prompt/Properties/launchSettings.json | 8 ++++++++
examples/prompt/prompt.csproj | 5 +++++
examples/ted/ted.csproj | 5 +++++
4 files changed, 25 insertions(+), 4 deletions(-)
create mode 100644 examples/prompt/Properties/launchSettings.json
diff --git a/examples/prompt/Program.cs b/examples/prompt/Program.cs
index d7ad948..e56a657 100644
--- a/examples/prompt/Program.cs
+++ b/examples/prompt/Program.cs
@@ -9,10 +9,11 @@
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
-string initialText = string.Join (' ', args);
+var initialText = string.Join (' ', args);
string? result = null;
using IApplication app = Application.Create ();
+app.AppModel = AppModel.Inline;
app.Init ();
Window window = new ()
@@ -41,11 +42,13 @@
editor.KeyDown += (_, key) =>
{
- if (key == Key.Esc)
+ if (key != Key.Esc)
{
- window.RequestStop ();
- key.Handled = true;
+ return;
}
+
+ window.RequestStop ();
+ key.Handled = true;
};
window.Add (editor);
diff --git a/examples/prompt/Properties/launchSettings.json b/examples/prompt/Properties/launchSettings.json
new file mode 100644
index 0000000..c9b6688
--- /dev/null
+++ b/examples/prompt/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "prompt": {
+ "commandName": "Project",
+ "commandLineArgs": "this is line 1\r\n\r\nthis is line 2"
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/prompt/prompt.csproj b/examples/prompt/prompt.csproj
index efe1025..ce9f324 100644
--- a/examples/prompt/prompt.csproj
+++ b/examples/prompt/prompt.csproj
@@ -7,6 +7,11 @@
false
+
+
+
+
+
diff --git a/examples/ted/ted.csproj b/examples/ted/ted.csproj
index 9086d64..d4728cc 100644
--- a/examples/ted/ted.csproj
+++ b/examples/ted/ted.csproj
@@ -7,6 +7,11 @@
false
+
+
+
+
+