1 Commits
Author SHA1 Message Date
Lixen d4cc9f2407 v0.5.2 tui: titles and labels drawn a column a rune; an unchanged frame writes nothing
* tui: titles, labels and buttons drawn a column a rune

Ten places drew each rune of a title or label at its byte offset, so a
string holding runes of more than one byte drew with holes and ran past the
width it was measured and truncated to: the Pane title, the Modal title and
hint, the Divider label, TabBar titles, Table cells, Form labels, the
TextField placeholder, the ConfirmDialog and AlertDialog buttons, and the
ProgressOverlay title. Each now draws through Region.Text, which counts
columns by rune, within the bound its loop kept; a Form label stays within
its label column, as the byte count meant it to.

* an unchanged frame writes nothing

flush closed every frame with SGR 0, so a frame that changed no cell still
sent four bytes, and an app drawing an idle screen kept writing to the
terminal. The reset now follows only a frame that wrote cells: the next
cell written restates its whole style, and Fini resets on its own.

Each widget, given a title such as "● é · ñ", draws it whole where it drew
it with holes before; a frame flushed twice writes nothing the second time,
where it wrote ESC [ 0 m.
2026-10-24 19:00:37 -04:00
12 changed files with 62 additions and 64 deletions

No files matched your search

+4
View File
@@ -100,6 +100,7 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
w := &o.buf w := &o.buf
start := w.Len()
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
rowStart := y * width rowStart := y * width
@@ -194,7 +195,10 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
} }
// A frame that changed no cell writes nothing, the reset included
if w.Len() > start {
w.Write(csiSGR0) w.Write(csiSGR0)
}
o.lastValid = false o.lastValid = false
} }
+12
View File
@@ -121,6 +121,18 @@ func TestEachCellIsOneColumn(t *testing.T) {
} }
} }
// A frame that changes no cell writes nothing, so drawing an idle screen
// again costs the terminal no bytes
func TestUnchangedFrameWritesNothing(t *testing.T) {
o := newOutputBuffer(ColorMode256)
frame := []Cell{{Rune: 'a'}, {Rune: 'é'}}
o.flush(frame, len(frame), 1)
o.buf.Reset()
if o.flush(frame, len(frame), 1); o.buf.Len() != 0 {
t.Fatalf("unchanged frame wrote %q", o.buf.String())
}
}
// A paste arrives as one event, whatever reads split it into: an escape // A paste arrives as one event, whatever reads split it into: an escape
// inside it is text even when a poll times out on it, and a paste the // inside it is text even when a poll times out on it, and a paste the
// terminal never ends ends when input pauses, whether reads time out or // terminal never ends ends when input pauses, whether reads time out or
+1 -4
View File
@@ -149,10 +149,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
text = Truncate(text, r.W-2) text = Truncate(text, r.W-2)
textLen = RuneLen(text) textLen = RuneLen(text)
} }
startX := (r.W - textLen) / 2 r.Text((r.W-textLen)/2, y, text, fg, color.RGB{}, terminal.AttrBold)
for i, ch := range text {
r.Cell(startX+i, y, ch, fg, color.RGB{}, terminal.AttrBold)
}
} }
} }
+3 -15
View File
@@ -225,11 +225,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
yesBg = style.ButtonFocusBg yesBg = style.ButtonFocusBg
} }
} }
for i, ch := range yesLabel { content.Text(buttonX, buttonY, yesLabel, yesFg, yesBg, terminal.AttrNone)
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
}
}
// No button // No button
noX := buttonX + yesW + buttonGap noX := buttonX + yesW + buttonGap
@@ -239,11 +235,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
noFg = style.ButtonFocusFg noFg = style.ButtonFocusFg
noBg = style.ButtonFocusBg noBg = style.ButtonFocusBg
} }
for i, ch := range noLabel { content.Text(noX, buttonY, noLabel, noFg, noBg, terminal.AttrNone)
if noX+i < content.W {
content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1) return content.Sub(0, 0, content.W, buttonY-1)
} }
@@ -317,11 +309,7 @@ func (r Region) AlertDialog(opts AlertOpts) Region {
buttonW := RuneLen(buttonLabel) buttonW := RuneLen(buttonLabel)
buttonX := (content.W - buttonW) / 2 buttonX := (content.W - buttonW) / 2
for i, ch := range buttonLabel { content.Text(buttonX, buttonY, buttonLabel, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1) return content.Sub(0, 0, content.W, buttonY-1)
} }
+1 -6
View File
@@ -164,12 +164,7 @@ func (r Region) Form(state *FormState, opts FormOpts) int {
// Label // Label
label := field.Label + ":" label := field.Label + ":"
for j, ch := range label { r.Sub(0, y, labelW, 1).Text(0, 0, label, style.LabelFg, style.Bg, terminal.AttrNone)
if j >= labelW || j >= r.W {
break
}
r.Cell(j, y, ch, style.LabelFg, style.Bg, terminal.AttrNone)
}
// Field area // Field area
fieldX := labelW fieldX := labelW
+2 -10
View File
@@ -36,10 +36,7 @@ func (r Region) Modal(opts ModalOpts) Region {
title = Truncate(title, r.W-4) title = Truncate(title, r.W-4)
titleLen = RuneLen(title) titleLen = RuneLen(title)
} }
x := (r.W - titleLen) / 2 r.Text((r.W-titleLen)/2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
for i, ch := range title {
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
}
} }
// Hint right-aligned on top edge // Hint right-aligned on top edge
@@ -54,12 +51,7 @@ func (r Region) Modal(opts ModalOpts) Region {
if x < r.W/2 { if x < r.W/2 {
x = r.W / 2 x = r.W / 2
} }
for i, ch := range hint { r.Sub(0, 0, r.W-1, 1).Text(x, 0, hint, opts.HintFg, opts.Bg, terminal.AttrNone)
if x+i >= r.W-1 {
break
}
r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone)
}
} }
// Return content region // Return content region
+1 -7
View File
@@ -33,13 +33,7 @@ func (r Region) Pane(opts PaneOpts) Region {
if RuneLen(title) > r.W-4 { if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4) title = Truncate(title, r.W-4)
} }
x := 2 r.Text(2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
for i, ch := range title {
if x+i >= r.W-1 {
break
}
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
}
} }
// Content region is everything inside the border. The title sits ON the border row. // Content region is everything inside the border. The title sits ON the border row.
+1 -4
View File
@@ -194,10 +194,7 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
title = Truncate(title, overlayW-4) title = Truncate(title, overlayW-4)
titleLen = RuneLen(title) titleLen = RuneLen(title)
} }
titleX := (overlayW - titleLen) / 2 overlay.Text((overlayW-titleLen)/2, 0, title, opts.AccentFg, opts.Bg, terminal.AttrBold)
for i, ch := range title {
overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold)
}
} }
// Content area // Content area
+1 -7
View File
@@ -64,13 +64,7 @@ func (r Region) TabBar(y int, titles []string, active int, opts TabBarOpts) []Ta
r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr) r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
} }
titleStart := x + opts.Padding r.TextStyled(x+opts.Padding, y, title, style)
for j, ch := range title {
if titleStart+j >= r.W {
break
}
r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr)
}
for j := 0; j < opts.Padding; j++ { for j := 0; j < opts.Padding; j++ {
pos := x + opts.Padding + RuneLen(title) + j pos := x + opts.Padding + RuneLen(title) + j
+1 -5
View File
@@ -171,11 +171,7 @@ func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Ali
startX = x startX = x
} }
for j, ch := range text { r.TextStyled(startX, y, text, style)
if startX+j < r.W {
r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr)
}
}
x += w x += w
+1 -6
View File
@@ -108,12 +108,7 @@ func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
if RuneLen(placeholder) > viewportW { if RuneLen(placeholder) > viewportW {
placeholder = Truncate(placeholder, viewportW) placeholder = Truncate(placeholder, viewportW)
} }
for i, ch := range placeholder { r.Text(x, contentY, placeholder, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
if x+i >= contentX+contentW {
break
}
r.Cell(x+i, contentY, ch, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
}
} else { } else {
// Scroll indicators // Scroll indicators
if state.Scroll > 0 && x > contentX { if state.Scroll > 0 && x > contentX {
+34
View File
@@ -467,3 +467,37 @@ func TestWindowShowsTheRowsInView(t *testing.T) {
} }
r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge
} }
// A label is drawn a column a rune, so one holding runes of more than a byte
// keeps them together: each widget draws s as many times as it is given it
func TestLabelsKeepTheirRunesTogether(t *testing.T) {
const s = "● é · ñ"
for _, c := range []struct {
name string
n int
draw func(r Region)
}{
{"pane", 1, func(r Region) { r.Pane(PaneOpts{Title: s}) }},
{"modal", 2, func(r Region) { r.Modal(ModalOpts{Title: s, Hint: s}) }},
{"divider", 1, func(r Region) { r.Divider(0, s, LineSingle, color.RGB{}) }},
{"tabs", 2, func(r Region) { r.TabBar(0, []string{s, s}, 0, DefaultTabBarOpts()) }},
{"table", 2, func(r Region) { r.Table([]string{s}, [][]string{{s}}, TableOpts{}) }},
{"form", 1, func(r Region) { r.Form(NewFormState(s), FormOpts{}) }},
{"field", 1, func(r Region) { r.TextField(NewTextFieldState(""), TextFieldOpts{Placeholder: s}) }},
{"confirm", 4, func(r Region) {
r.ConfirmDialog(NewConfirmState(true), ConfirmOpts{Title: s, Message: s, YesLabel: s, NoLabel: s})
}},
{"alert", 3, func(r Region) { r.AlertDialog(AlertOpts{Title: s, Message: s, Button: s}) }},
{"progress", 1, func(r Region) { r.ProgressOverlay(DefaultProgressOpts(s, "", ProgressSpinner)) }},
} {
r := canvas(48, 14, MonoTheme)
c.draw(r)
rows := make([]string, r.H)
for y := range rows {
rows[y] = row(r, y)
}
if got := strings.Count(strings.Join(rows, "\n"), s); got != c.n {
t.Errorf("%s: %d of %d, rows %q", c.name, got, c.n, rows)
}
}
}