Skip to content

fix(tui): stop rows wrapping and shearing the layout - #69

Merged
zaidejjo merged 1 commit into
devfrom
fix/tui-row-wrap
Oct 18, 2026
Merged

zaidejjo merged 1 commit into
devfrom
fix/tui-row-wrap

Conversation

@zaidejjo

Copy link
Copy Markdown
Owner

Why

Reported: the TUI "breaks" on a big repository, and again when the theme is
changed — rows wrap, the layout shears sideways. Both were the same class of
bug: rendered lines were allowed to be wider than the cells reserved for them,
so a real terminal wrapped the row and every row below it shifted.

What was actually wrong

# Cause Fix
1 Panel.View used Width(p.Width); lipgloss draws the border outside Width, so each panel rendered p.Width + 2 Width(innerW), title/body capped to innerW
2 Header never adapted: ~170 cells at a 60-cell terminal density ladder (counts → short labels → drop meta → padding-free), first that fits wins
3 AI sidebar drew panelW + 4; input row sliced by byte offset sizes from inner width, rune-aware tail, every row clamped
4 Dialogs pinned at a stale huh form width → form burst out of its border views/dialog.go owns the budget; every dialog clamps its body
5 Per-view selection loops sliced styled strings by byte/rune index components.RowLine is the single row path (charges style frame, truncates, draws cursor)
6 ansi.TruncateLeft cannot cut into a wide grapheme — TruncateLeft(s, 1, "") in front of a 2-cell character returns s unchanged new components.CutLeft/TruncateLeft walk the input with ansi.DecodeSequence
7 Modal compositor used rune indices → CJK/emoji backgrounds shifted the modal places overlays by cell column (ansi.Strip + Truncate/CutLeft)
8 Light theme reused dark diff backgrounds; styles carried Width(80) DiffAddBg/DiffDelBg tokens; fixed widths removed

Bug 6 is worth calling out: the first attempt at a left-truncation loop
("shave one cell until the tail fits") hung the test suite because the
library hands the string straight back in that case. The loop is gone.

Guarantees now tested

  • TestLayoutFitsWithoutTheSafetyNet — renders header + content raw, with
    clampFrame out of the path: 8 widths × 9 screens × 2 themes. This is the
    test with teeth; temporarily restoring Width(p.Width) in Panel.View
    fails it immediately (line 1 is 64 cells at a 60-cell terminal).
  • TestViewNeverWraps, TestViewNeverWrapsWithSidebar — every line of the
    real View() stays inside the terminal.
  • TestPanelOccupiesExactWidth, TestRowLineChargesForTheFrame — containers
    and rows occupy exactly their budget.
  • TestDialogsFitTerminal — each modal's raw output fits at 7 widths.
  • TestOverlayCenterIsColumnAccurate — the modal lands on the same column
    over ASCII, CJK and emoji backgrounds.
  • TestTruncateLeftKeepsTail sweeps every width (a hang would fail the run),
    TestCutLeftDropsWholeGraphemes, TestCursorLineKeepsLeadingEscapes.
  • TestRenderHeaderAdapts / TestRenderHeaderNarrowShrinks.
  • TestThemeSwitchRepaintsStyles / TestThemeTokensStayLegible.

View() keeps a per-line clamp as defence in depth — but it is no longer what
makes the layout correct.

Checks

  • go test ./... -count=1 green
  • go vet ./... clean, gofmt -l . empty
  • rg 'line\[1:\]|runes\[1:\]' pkg/tui/ -g '!zlist.go' empty (CI width-law step)

The TUI wrapped rows whenever a repository supplied long paths, long
branches or long subjects, and again whenever the theme changed. Every
cause was structural, so each one is fixed where it starts instead of
being papered over in View():

- Panel.View rendered Width(p.Width) while lipgloss draws the border
  outside Width, so every panel was two cells wider than the layout had
  reserved for it. Width is now the inner width, and the title and every
  body line are capped to it.
- The header never adapted to the terminal: it measured ~170 cells on a
  60-column terminal. It is now a density ladder (counts, then short
  labels, then drop the meta block, then padding-free tabs) that renders
  the first strip which fits.
- The AI sidebar drew panelW+4 cells and sliced its input row by byte
  offset. It sizes itself from the inner width, keeps the tail of its
  input rune-wise, and clamps every row.
- Dialogs were pinned at a stale huh form width, so the form burst out
  of its border. views/dialog.go now owns the budget (border, padding,
  form field frame) and every dialog clamps its body to it.
- Rows went through per-view selection loops that sliced styled strings
  by byte or rune index. components.RowLine is now the single path: it
  charges the style frame, truncates with the shared width law, then
  draws the cursor.
- components.CutLeft/TruncateLeft replace ansi.TruncateLeft, which
  cannot cut into a wide grapheme: asking it to drop one cell in front
  of a 2-cell character returns the string untouched, so a caller
  looping until the tail fits never terminates.
- The modal compositor places overlays by cell column, not rune index,
  so a CJK or emoji background no longer shifts them.
- Light theme: diff backgrounds are tokens instead of dark literals, and
  no style carries a hardcoded Width(80) any more.

View() still clamps each line as a last line of defence, but the new
tests assert that the raw header + content layout fits at eight widths,
nine screens and both themes without it.
@zaidejjo
zaidejjo merged commit 1825fc8 into dev Oct 18, 2026
7 checks passed
@zaidejjo
zaidejjo deleted the fix/tui-row-wrap branch October 18, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant