Skip to content

Tab handling: storage, rendering, key behavior, and options surface #37

@tig

Description

@tig

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 \t or 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: \tIndentationSize - (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.
  • Syntax-highlighting segment boundaries must still be honored — the grapheme walk replaces the outer per-line iteration, not the segment-attribute lookup. The PR Expand \t in 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 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.

7. Caret and mouse mapping

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 "↹").

9. What to back out from PR #27

Concrete diff against the PR #27 merge:

  • Editor.cs — rename TabWidthIndentationSize 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).
  • Editor.Keyboard.cs — add Tab / Shift+Tab handlers (§4). This is net-new; PR Expand \t in 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).
  • Tests — rename PR Expand \t in Editor rendering with configurable tab stops (default 4) and add ted status-bar tab width control #27 tests from TabWidth*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, \t survives 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)

  • Smart indentation strategies beyond DefaultIndentationStrategy (e.g. C# brace indent).
  • Elastic tabstops.
  • Detecting and warning on mixed indentation.
  • Per-language indent settings.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions