Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions specs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

## Open

### OPEN-001: Independent `Terminal.Gui.Editor` NuGet from day one
Expand Down Expand Up @@ -106,18 +118,6 @@ need to know the document construction details.

---

### 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).
Expand Down
2 changes: 2 additions & 0 deletions specs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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 ✅)

Expand Down Expand Up @@ -157,5 +158,6 @@ 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 | Streaming `TextDocument.LoadAsync` / `TextDocument.SaveAsync`, `TextDocumentProgress`, `TextDocument.Encoding`, and delegating `Editor.LoadAsync` / `Editor.SaveAsync` landed | file-io |
| 2026-05-17 | `Editor` implements `IDesignable`; `EnableForDesign()` seeds C# sample code with syntax highlighting and line numbers | design-time |
49 changes: 49 additions & 0 deletions specs/single-line-mode/spec.md
Original file line number Diff line number Diff line change
@@ -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
/// <summary>
/// Gets or sets whether the editor supports multiple lines. Default is <see langword="true" />.
/// </summary>
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.
57 changes: 49 additions & 8 deletions src/Terminal.Gui.Editor/Editor.Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, () =>
Expand Down Expand Up @@ -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);
Comment thread
tig marked this conversation as resolved.
}

using (_document!.RunUpdate ())
{
if (HasSelection)
Expand Down Expand Up @@ -317,13 +332,25 @@ private void CreateCommandsAndBindings ()

private bool? MoveCaretVerticallyCollapsing (int delta)
{
if (!Multiline)
{
return true;
}

MoveCaretVerticallyCollapsingSelection (delta);

return true;
}

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;
Expand All @@ -348,7 +375,7 @@ private void CreateCommandsAndBindings ()

private bool? InsertNewLineWithAutoIndent ()
{
if (ReadOnly)
if (ReadOnly || !Multiline)
{
return true;
}
Expand Down Expand Up @@ -489,6 +516,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;

Expand All @@ -497,6 +531,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;

Expand Down
112 changes: 112 additions & 0 deletions src/Terminal.Gui.Editor/Editor.Drawing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
tig marked this conversation as resolved.

return;
}

if (WordWrap)
{
DrawWrappedLines (viewport, normal, selected, selStart, selEnd);
Expand Down Expand Up @@ -130,6 +137,111 @@ private void DrawWrappedLines (Rectangle viewport, Attribute normal, Attribute s
}
}

/// <summary>
/// Draws all document content on a single visual row with newline characters rendered as
/// visible glyphs. Used when <see cref="Multiline" /> is <see langword="false" />.
/// </summary>
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<int> visibleLines = GetVisibleLineNumbers ();

if (visibleLines.Count == 0)
{
return;
}

DocumentLine firstLine = _document!.GetLineByNumber (visibleLines[0]);
CellVisualLine composite = new (firstLine);
Comment thread
tig marked this conversation as resolved.
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);
}
}

/// <summary>
/// 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.
/// </summary>
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,
Expand Down
Loading
Loading