You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #27 was a partial, incorrect fix. It expands \t at draw time (good), but:
It introduces custom tab math (GetVisualColumnFromLogicalColumn, GetLogicalColumnFromVisualColumn, GetVisualWidthForCharacter) on Editor that pre-empts the planned VisualLineBuilder pipeline (specs/00-plan.md §6).
The new Editor.Drawing.cs walks text char-by-char, regressing grapheme/surrogate handling that the prior segment-slicing path implicitly preserved. CLAUDE.md is explicit: "All measurement is in cells, not pixels — use grapheme clusters and string.GetColumns()."
It exposes a bespoke Editor.TabWidth property instead of mirroring AvaloniaEdit's TextEditorOptions surface (IndentationSize, ConvertTabsToSpaces, ShowTabs).
Pressing Tab still does nothing — there is no editing behavior, only a rendering knob.
Shift+Tab (unindent) is not handled.
Per CLAUDE.md, the model exposed by Terminal.Gui.Text / Terminal.Gui.Editor should be a superficial wrapper on AvaloniaEdit. This spec defines the target behavior, the public API, and what to back out of PR #27.
1. Storage model (invariant)
A tab is stored in the TextDocument as the single code point U+0009 (\t).
Loading and saving never transform tabs. No conversion to spaces on read; no conversion of leading spaces to tabs on write. What's on disk is what's in the rope.
A tab occupies exactly one logical column (one int offset in the document). Logical column ≠ visual column.
This invariant must hold regardless of ConvertTabsToSpaces. That option only governs what gets inserted when the user presses Tab; it never rewrites existing content.
2. Visual model
Tab stops are placed every IndentationSize visual columns (cells), starting at visual column 0.
A \t at visual column c advances the caret to the next multiple of IndentationSize. Width = IndentationSize - (c % IndentationSize) (a tab at a tab stop advances a full IndentationSize, never zero).
All measurement is in cells, using grapheme clusters and string.GetColumns(). Tab width math composes with grapheme width; it is not a special case sitting on top of char indices.
Caret cannot occupy a position inside a tab's visual span. Movement and hit-testing snap to the nearest logical edge (before-the-tab vs. after-the-tab); the rule below in §6 specifies which.
3. Public API — Editor (mirrors AvaloniaEdit TextEditorOptions)
The user-visible surface lives directly on Editor for now (a TextEditorOptions object can be extracted later when more options exist). Names match AvaloniaEdit verbatim so the wrapper stays superficial.
Property
Type
Default
Maps to AvaloniaEdit
Notes
IndentationSize
int (≥1)
4
TextEditorOptions.IndentationSize
Width of one indent unit, in visual columns. Replaces PR #27's TabWidth.
ConvertTabsToSpaces
bool
false
TextEditorOptions.ConvertTabsToSpaces
Governs the Tab key, not the document.
ShowTabs
bool
false
TextEditorOptions.ShowTabs
When true, render a tab glyph (e.g. →) at the tab's start cell, then fill remaining cells with spaces.
IndentationStrategy
IIndentationStrategy
DefaultIndentationStrategy
IndentationStrategy
On Enter, copies leading whitespace from the previous line. Pluggable later (e.g. C# strategy).
Editor.TabWidth (added in PR #27) is renamed to IndentationSize. There is no separate "visual tab-stop width" — AvaloniaEdit conflates the two via IndentationSize and we follow that.
Setter for IndentationSize triggers: _virtualCaretColumn = GetCaretColumn (), UpdateContentSize (), EnsureCaretVisible (), SetNeedsDraw () — same as PR #27's setter.
4. Tab key behavior
Add a key handler in Editor.Keyboard.cs for Key.Tab and Key.Tab.WithShift:
Tab (no selection)
If ConvertTabsToSpaces == false: insert a single \t at the caret.
If ConvertTabsToSpaces == true: insert IndentationSize - (visualColumn % IndentationSize) space characters, so the caret lands on the next tab stop.
Tab (with selection spanning ≥1 line)
Indent every line in the selection by one indent unit (a \t or IndentationSize spaces, per ConvertTabsToSpaces).
Selection stays anchored to the same logical text after the edit.
Whole operation runs inside one Document.OpenUpdateScope () so undo collapses to one step.
Shift+Tab
Unindent the current line (or every line in the selection) by one logical indent unit, using TextUtilities.GetSingleIndentationSegment (already lifted in src/Terminal.Gui.Text/Document/TextUtilities.cs:191). That helper consumes at most one \tor up to IndentationSize spaces — i.e. mixed-indent lines unindent one logical level at a time.
No-op on lines with no leading whitespace.
The issue's proposed InsertTabBehavior : TabBehavior enum is dropped in favor of ConvertTabsToSpaces to keep the wrapper superficial. The two-state space is identical; the AvaloniaEdit name wins.
5. Backspace at indentation
When the caret sits at the end of a run of leading whitespace and the user presses Backspace, delete one logical indent unit (same GetSingleIndentationSegment call) rather than one space. Outside of leading whitespace, Backspace stays one-character. This matches AvaloniaEdit. (Stretch — can ship after the Tab handler if needed.)
6. Rendering
Expansion happens at render time, never in the document.
Interim (now) — in Editor.Drawing.cs, replace the char-by-char walk introduced in PR #27 with a grapheme-aware walk:
Iterate the visible portion of the line by grapheme cluster, not by char.
For each cluster, compute its cell width: \t → IndentationSize - (visualColumn % IndentationSize); everything else → Rune.GetColumns(grapheme) (or string.GetColumns for clusters).
For \t: emit IndentationSize - (visualColumn % IndentationSize) spaces, OR if ShowTabs, emit the tab glyph in the first cell and pad the rest with spaces.
Final (per specs/00-plan.md §6) — tabs become a TabTextElement-equivalent emitted by VisualLineBuilder. Visual lines are cached and selectively invalidated. The interim helpers (GetVisualColumnFromLogicalColumn etc.) are removed in favor of VisualLine.GetVisualColumn / GetRelativeOffset. Mark the interim helpers private and add a // TODO(VisualLineBuilder) so they get deleted, not promoted.
Horizontal arrow keys move by one logical column (so Right over a tab moves the caret across the entire visual span in one keystroke). This is the AvaloniaEdit behavior.
8. ted demo
Rename the status-bar NumericUpDown from "Tab" / TabWidthUpDown to "Indent Size" / IndentationSizeUpDown, bound to Editor.IndentationSize.
There shoudl be a single shorttcut for "Indent Size". The current code uses 2; use shortcut.CommandView for the numericupdown and shortcut.Text for "Indent Size"
Add a checkbox/toggle menu item to the Options menu for ConvertTabsToSpaces (Title: "_Convert Tabs To Spaces").
Add a statusbar shortcut to the right of "Indent Size" to toggle for ShowTabs (Use a checkbox who's title is "↹").
Editor.cs — rename TabWidth → IndentationSize everywhere (property, field _tabWidth → _indentationSize, all callers). Keep the setter side-effects. Mark GetVisualColumnFromLogicalColumn, GetLogicalColumnFromVisualColumn, GetVisualWidthForCharacter as interim with a TODO referencing specs/00-plan.md §6.
Editor.Drawing.cs — back out the char-by-char DrawLineContent rewrite. Restore segment-aware drawing, but route the per-line walk through grapheme clusters with cell-width math (see §6 interim). The c == '\t' ? new (' ', drawEnd - drawStart) : c.ToString () shortcut goes away in favor of explicit "emit N spaces for tab" + grapheme emit for everything else.
Editor.Mouse.cs — keep GetLogicalColumnFromVisualColumn, but change the tab-span snap rule to "nearest edge" (§7).
PR #27 was a partial, incorrect fix. It expands
\tat draw time (good), but:GetVisualColumnFromLogicalColumn,GetLogicalColumnFromVisualColumn,GetVisualWidthForCharacter) onEditorthat pre-empts the plannedVisualLineBuilderpipeline (specs/00-plan.md§6).Editor.Drawing.cswalkstextchar-by-char, regressing grapheme/surrogate handling that the prior segment-slicing path implicitly preserved. CLAUDE.md is explicit: "All measurement is in cells, not pixels — use grapheme clusters andstring.GetColumns()."Editor.TabWidthproperty instead of mirroring AvaloniaEdit'sTextEditorOptionssurface (IndentationSize,ConvertTabsToSpaces,ShowTabs).Per CLAUDE.md, the model exposed by
Terminal.Gui.Text/Terminal.Gui.Editorshould be a superficial wrapper on AvaloniaEdit. This spec defines the target behavior, the public API, and what to back out of PR #27.1. Storage model (invariant)
TextDocumentas the single code pointU+0009(\t).intoffset in the document). Logical column ≠ visual column.This invariant must hold regardless of
ConvertTabsToSpaces. That option only governs what gets inserted when the user presses Tab; it never rewrites existing content.2. Visual model
IndentationSizevisual columns (cells), starting at visual column 0.\tat visual columncadvances the caret to the next multiple ofIndentationSize. Width =IndentationSize - (c % IndentationSize)(a tab at a tab stop advances a fullIndentationSize, never zero).string.GetColumns(). Tab width math composes with grapheme width; it is not a special case sitting on top ofcharindices.3. Public API —
Editor(mirrors AvaloniaEditTextEditorOptions)The user-visible surface lives directly on
Editorfor now (aTextEditorOptionsobject can be extracted later when more options exist). Names match AvaloniaEdit verbatim so the wrapper stays superficial.IndentationSizeint(≥1)4TextEditorOptions.IndentationSizeTabWidth.ConvertTabsToSpacesboolfalseTextEditorOptions.ConvertTabsToSpacesShowTabsboolfalseTextEditorOptions.ShowTabstrue, render a tab glyph (e.g.→) at the tab's start cell, then fill remaining cells with spaces.IndentationStrategyIIndentationStrategyDefaultIndentationStrategyIndentationStrategyEditor.TabWidth(added in PR #27) is renamed toIndentationSize. There is no separate "visual tab-stop width" — AvaloniaEdit conflates the two viaIndentationSizeand we follow that.Setter for
IndentationSizetriggers:_virtualCaretColumn = GetCaretColumn (),UpdateContentSize (),EnsureCaretVisible (),SetNeedsDraw ()— same as PR #27's setter.4. Tab key behavior
Add a key handler in
Editor.Keyboard.csforKey.TabandKey.Tab.WithShift:Tab (no selection)
ConvertTabsToSpaces == false: insert a single\tat the caret.ConvertTabsToSpaces == true: insertIndentationSize - (visualColumn % IndentationSize)space characters, so the caret lands on the next tab stop.Tab (with selection spanning ≥1 line)
\torIndentationSizespaces, perConvertTabsToSpaces).Document.OpenUpdateScope ()so undo collapses to one step.Shift+Tab
TextUtilities.GetSingleIndentationSegment(already lifted insrc/Terminal.Gui.Text/Document/TextUtilities.cs:191). That helper consumes at most one\tor up toIndentationSizespaces — i.e. mixed-indent lines unindent one logical level at a time.The issue's proposed
InsertTabBehavior : TabBehaviorenum is dropped in favor ofConvertTabsToSpacesto keep the wrapper superficial. The two-state space is identical; the AvaloniaEdit name wins.5. Backspace at indentation
When the caret sits at the end of a run of leading whitespace and the user presses Backspace, delete one logical indent unit (same
GetSingleIndentationSegmentcall) rather than one space. Outside of leading whitespace, Backspace stays one-character. This matches AvaloniaEdit. (Stretch — can ship after the Tab handler if needed.)6. Rendering
Expansion happens at render time, never in the document.
Interim (now) — in
Editor.Drawing.cs, replace the char-by-char walk introduced in PR #27 with a grapheme-aware walk:char.\t→IndentationSize - (visualColumn % IndentationSize); everything else →Rune.GetColumns(grapheme)(orstring.GetColumnsfor clusters).\t: emitIndentationSize - (visualColumn % IndentationSize)spaces, OR ifShowTabs, emit the tab glyph in the first cell and pad the rest with spaces.\tin Editor rendering with configurable tab stops (default 4) and add ted status-bar tab width control #27 conflation of "iterate chars + advance segment index inline" is what regressed graphemes.Final (per
specs/00-plan.md§6) — tabs become aTabTextElement-equivalent emitted byVisualLineBuilder. Visual lines are cached and selectively invalidated. The interim helpers (GetVisualColumnFromLogicalColumnetc.) are removed in favor ofVisualLine.GetVisualColumn/GetRelativeOffset. Mark the interim helpersprivateand add a// TODO(VisualLineBuilder)so they get deleted, not promoted.7. Caret and mouse mapping
\tin Editor rendering with configurable tab stops (default 4) and add ted status-bar tab width control #27 — keep that test).\tin Editor rendering with configurable tab stops (default 4) and add ted status-bar tab width control #27 always snaps to after the tab; change to nearest. UpdateLeftClick_Inside_TabExpansion_Snaps_After_Tab_Characteraccordingly (rename + adjust expectations).8. ted demo
NumericUpDownfrom "Tab" /TabWidthUpDownto "Indent Size" /IndentationSizeUpDown, bound toEditor.IndentationSize.ConvertTabsToSpaces(Title: "_Convert Tabs To Spaces").ShowTabs(Use a checkbox who's title is "↹").9. What to back out from PR #27
Concrete diff against the PR #27 merge:
Editor.cs— renameTabWidth→IndentationSizeeverywhere (property, field_tabWidth→_indentationSize, all callers). Keep the setter side-effects. MarkGetVisualColumnFromLogicalColumn,GetLogicalColumnFromVisualColumn,GetVisualWidthForCharacteras interim with a TODO referencingspecs/00-plan.md§6.Editor.Drawing.cs— back out the char-by-charDrawLineContentrewrite. Restore segment-aware drawing, but route the per-line walk through grapheme clusters with cell-width math (see §6 interim). Thec == '\t' ? new (' ', drawEnd - drawStart) : c.ToString ()shortcut goes away in favor of explicit "emit N spaces for tab" + grapheme emit for everything else.Editor.Mouse.cs— keepGetLogicalColumnFromVisualColumn, but change the tab-span snap rule to "nearest edge" (§7).Editor.Keyboard.cs— add Tab / Shift+Tab handlers (§4). This is net-new; PR Expand\tin Editor rendering with configurable tab stops (default 4) and add ted status-bar tab width control #27 added nothing here.examples/ted/TedApp.cs— rename the control and labels (§8).\tin Editor rendering with configurable tab stops (default 4) and add ted status-bar tab width control #27 tests fromTabWidth*→IndentationSize*. Update the mouse test per §7. Add tests for: Tab inserts\t(default), Tab inserts spaces (ConvertTabsToSpaces = true), Shift+Tab unindents, Tab on selection indents block,\tsurvives load → save round trip, grapheme cluster (e.g. emoji ZWJ) renders correctly on a line with tabs.10. Out of scope (track separately if wanted)
DefaultIndentationStrategy(e.g. C# brace indent).