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..e56a657 --- /dev/null +++ b/examples/prompt/Program.cs @@ -0,0 +1,61 @@ +// 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.AppModel = AppModel.Inline; +app.Init (); + +Window window = new () +{ + Title = "prompt", + Width = Dim.Fill (), + Height = Dim.Fill () +}; + +Editor editor = new () +{ + Multiline = false, + X = 0, + Y = 0, + Width = Dim.Fill (), + 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.Esc) + { + return; + } + + window.RequestStop (); + key.Handled = true; +}; + +window.Add (editor); + +app.Run (window); + +if (result is not null) +{ + Console.WriteLine (result); +} 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 new file mode 100644 index 0000000..ce9f324 --- /dev/null +++ b/examples/prompt/prompt.csproj @@ -0,0 +1,20 @@ + + + + Exe + Prompt + prompt + 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 + + + + + diff --git a/specs/decisions.md b/specs/decisions.md index 23c56a8..3377740 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -70,6 +70,18 @@ need to know the document construction details. --- +### DEC-008: Single-line / embeddable-input mode (resolves former OPEN-006) + +**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. + +**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-009: Completion item shape & provider interface **Decision**: Use a fresh LSP-flavored `IEditorCompletionProvider` interface and `CompletionItem` type (`Terminal.Gui.Editor.Completion` namespace), **not** Terminal.Gui's existing `IAutocomplete` / `PopupAutocomplete`. @@ -114,18 +126,6 @@ Resolved — see DEC-009. --- -### 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 7e27356..2becb5a 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -44,6 +44,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 ✅) public bool OverwriteMode { get; set; } // exists (overwrite-mode ✅) public event EventHandler? OverwriteModeChanged; // exists (overwrite-mode ✅) @@ -175,6 +176,7 @@ public readonly record struct TextDocumentProgress ( | 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 | | 2026-05-17 | Column selection during `Alt+Drag` and `Ctrl+Shift+Alt+Arrow/Page` added without new public Editor API | vertical-multi-caret | | 2026-05-17 | `IEditorCompletionProvider?` `CompletionProvider` + `bool IsCompletionActive` landed; `CompletionItem` sealed class; `Popover`-based popup; DEC-009 resolves OPEN-002 | completion | | 2026-05-17 | Streaming `TextDocument.LoadAsync` / `TextDocument.SaveAsync`, `TextDocumentProgress`, `TextDocument.Encoding`, and delegating `Editor.LoadAsync` / `Editor.SaveAsync` landed | file-io | diff --git a/specs/single-line-mode/spec.md b/specs/single-line-mode/spec.md new file mode 100644 index 0000000..92c0162 --- /dev/null +++ b/specs/single-line-mode/spec.md @@ -0,0 +1,49 @@ +# 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; 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. | +| **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). +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) + +- `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 c720fd7..af00a5d 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -82,23 +82,31 @@ 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))); + () => 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))); 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, () => @@ -156,6 +164,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) @@ -327,6 +342,11 @@ private void CreateCommandsAndBindings () private bool? MoveCaretVerticallyCollapsing (int delta) { + if (!Multiline) + { + return true; + } + MoveCaretVerticallyCollapsingSelection (delta); return true; @@ -334,6 +354,13 @@ private void CreateCommandsAndBindings () private bool? ScrollVerticalCommand (int delta) { + // 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; @@ -358,7 +385,7 @@ private void CreateCommandsAndBindings () private bool? InsertNewLineWithAutoIndent () { - if (ReadOnly) + if (ReadOnly || !Multiline) { return true; } @@ -499,6 +526,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; @@ -507,6 +541,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 c88a27a..c21feaa 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -55,6 +55,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); @@ -130,6 +137,111 @@ 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 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 idx = 0; idx < visibleLines.Count; idx++) + { + var lineNum = visibleLines[idx]; + 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 (idx < visibleLines.Count - 1) + { + // 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.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 422974b..a5d838f 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -36,7 +36,7 @@ public partial class Editor /// public void ToggleCaretAt (int offset) { - if (_document is null) + if (_document is null || !Multiline) { return; } @@ -168,7 +168,7 @@ private void NormalizeAdditionalCarets () /// private bool? AddCaretVertically (int delta) { - if (_document is null) + if (_document is null || !Multiline) { return true; } @@ -222,7 +222,7 @@ private void NormalizeAdditionalCarets () private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow, int anchorViewColumn, int activeViewColumn) { - if (_document is null) + if (_document is null || !Multiline) { return; } @@ -604,7 +604,7 @@ private void MultiCaretInsert (string text) /// private bool? MultiCaretNewLine () { - if (ReadOnly || _document is null) + if (ReadOnly || !Multiline || _document is null) { return true; } @@ -747,7 +747,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)) { foreach (DocumentLine line in LinesInRange (selStart, selEnd)) { @@ -801,9 +801,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 91b2bb2..fd1eeca 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; @@ -193,6 +194,56 @@ 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. Existing newlines in the document are preserved (no + /// data loss) and rendered as visible glyphs. 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 (); + + // 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 (); + _maxWidthDirty = true; + UpdateContentSize (); + EnsureCaretVisible (); + SetNeedsDraw (); + } + } = true; + /// /// Gets or sets the highlighting definition used for syntax coloring. When set, a /// is automatically added to @@ -282,6 +333,12 @@ public bool WordWrap get; set { + // Word wrap is meaningless in single-line mode. + if (!Multiline && value) + { + return; + } + if (field == value) { return; @@ -519,6 +576,27 @@ private void UpdateContentSize () return; } + if (!Multiline) + { + // Single-line mode: one row, width = sum of visible line visual lengths + newline glyphs. + List visibleLines = GetVisibleLineNumbers (); + var width = 0; + + for (var idx = 0; idx < visibleLines.Count; idx++) + { + width += GetOrBuildDefaultVisualLine (_document.GetLineByNumber (visibleLines[idx])).VisualLength; + + if (idx < visibleLines.Count - 1) + { + width += 1; // newline glyph + } + } + + SetContentSize (new Size (width + 1, 1)); + + return; + } + if (WordWrap) { // In word-wrap mode, the content height is the total number of visual rows @@ -535,8 +613,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))); } /// @@ -888,6 +966,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); @@ -919,6 +1002,32 @@ 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 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; + + foreach (var lineNum in visibleLines) + { + if (lineNum == targetLine.LineNumber) + { + break; + } + + flatColumn += GetOrBuildDefaultVisualLine (_document!.GetLineByNumber (lineNum)).VisualLength; + flatColumn += 1; // newline glyph + } + + flatColumn += GetOrBuildDefaultVisualLine (targetLine).GetVisualColumn (offsetInLine); + + return flatColumn; + } + /// /// 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 @@ -926,6 +1035,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 new file mode 100644 index 0000000..fd1955d --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorSingleLineTests.cs @@ -0,0 +1,341 @@ +// Copilot - claude-sonnet-4 + +using Terminal.Gui.Document.Folding; +using Terminal.Gui.Drivers; +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_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 () + { + 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_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 () + { + 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); + } + + [Fact] + 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!.TrySetClipboardData ("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_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; + + // 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" + } + + /// + /// 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 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. + 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, 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}"); + } +} 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..8205921 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/__snapshots__/SingleLine_Renders_Newlines_As_Glyphs.ans @@ -0,0 +1,3 @@ +ab⏎cd⏎ef + +