From d4cc9f2407b1af5833a9dff9998be28e62f1c548 Mon Sep 17 00:00:00 2001 From: Lixen <41302669+lixenwraith@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:00:37 -0400 Subject: [PATCH] v0.5.2 tui: titles and labels drawn a column a rune; an unchanged frame writes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- output.go | 6 +++++- terminal_test.go | 12 ++++++++++++ tui/border.go | 5 +---- tui/dialog.go | 18 +++--------------- tui/form.go | 7 +------ tui/modal.go | 12 ++---------- tui/pane.go | 8 +------- tui/progress_overlay.go | 5 +---- tui/tab.go | 8 +------- tui/table.go | 6 +----- tui/textfield.go | 7 +------ tui/widget_test.go | 34 ++++++++++++++++++++++++++++++++++ 12 files changed, 63 insertions(+), 65 deletions(-) diff --git a/output.go b/output.go index 8ba68b5..1c91935 100644 --- a/output.go +++ b/output.go @@ -100,6 +100,7 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { } w := &o.buf + start := w.Len() for y := 0; y < height; y++ { rowStart := y * width @@ -194,7 +195,10 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { } } - w.Write(csiSGR0) + // A frame that changed no cell writes nothing, the reset included + if w.Len() > start { + w.Write(csiSGR0) + } o.lastValid = false } diff --git a/terminal_test.go b/terminal_test.go index 6686f3b..f85298e 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -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 // 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 diff --git a/tui/border.go b/tui/border.go index 6cf8eb4..8657912 100644 --- a/tui/border.go +++ b/tui/border.go @@ -149,10 +149,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) { text = Truncate(text, r.W-2) textLen = RuneLen(text) } - startX := (r.W - textLen) / 2 - for i, ch := range text { - r.Cell(startX+i, y, ch, fg, color.RGB{}, terminal.AttrBold) - } + r.Text((r.W-textLen)/2, y, text, fg, color.RGB{}, terminal.AttrBold) } } diff --git a/tui/dialog.go b/tui/dialog.go index fb9d633..c1cef39 100644 --- a/tui/dialog.go +++ b/tui/dialog.go @@ -225,11 +225,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region { yesBg = style.ButtonFocusBg } } - for i, ch := range yesLabel { - if buttonX+i < content.W { - content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone) - } - } + content.Text(buttonX, buttonY, yesLabel, yesFg, yesBg, terminal.AttrNone) // No button noX := buttonX + yesW + buttonGap @@ -239,11 +235,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region { noFg = style.ButtonFocusFg noBg = style.ButtonFocusBg } - for i, ch := range noLabel { - if noX+i < content.W { - content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone) - } - } + content.Text(noX, buttonY, noLabel, noFg, noBg, terminal.AttrNone) return content.Sub(0, 0, content.W, buttonY-1) } @@ -317,11 +309,7 @@ func (r Region) AlertDialog(opts AlertOpts) Region { buttonW := RuneLen(buttonLabel) buttonX := (content.W - buttonW) / 2 - for i, ch := range buttonLabel { - if buttonX+i < content.W { - content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone) - } - } + content.Text(buttonX, buttonY, buttonLabel, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone) return content.Sub(0, 0, content.W, buttonY-1) } diff --git a/tui/form.go b/tui/form.go index 4feeaff..b91bc3c 100644 --- a/tui/form.go +++ b/tui/form.go @@ -164,12 +164,7 @@ func (r Region) Form(state *FormState, opts FormOpts) int { // Label label := field.Label + ":" - for j, ch := range label { - if j >= labelW || j >= r.W { - break - } - r.Cell(j, y, ch, style.LabelFg, style.Bg, terminal.AttrNone) - } + r.Sub(0, y, labelW, 1).Text(0, 0, label, style.LabelFg, style.Bg, terminal.AttrNone) // Field area fieldX := labelW diff --git a/tui/modal.go b/tui/modal.go index b44be58..0db855b 100644 --- a/tui/modal.go +++ b/tui/modal.go @@ -36,10 +36,7 @@ func (r Region) Modal(opts ModalOpts) Region { title = Truncate(title, r.W-4) titleLen = RuneLen(title) } - x := (r.W - titleLen) / 2 - for i, ch := range title { - r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold) - } + r.Text((r.W-titleLen)/2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold) } // Hint right-aligned on top edge @@ -54,12 +51,7 @@ func (r Region) Modal(opts ModalOpts) Region { if x < r.W/2 { x = r.W / 2 } - for i, ch := range hint { - if x+i >= r.W-1 { - break - } - r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone) - } + r.Sub(0, 0, r.W-1, 1).Text(x, 0, hint, opts.HintFg, opts.Bg, terminal.AttrNone) } // Return content region diff --git a/tui/pane.go b/tui/pane.go index d211b7d..ac1e065 100644 --- a/tui/pane.go +++ b/tui/pane.go @@ -33,13 +33,7 @@ func (r Region) Pane(opts PaneOpts) Region { if RuneLen(title) > r.W-4 { title = Truncate(title, r.W-4) } - x := 2 - for i, ch := range title { - if x+i >= r.W-1 { - break - } - r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold) - } + r.Text(2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold) } // Content region is everything inside the border. The title sits ON the border row. diff --git a/tui/progress_overlay.go b/tui/progress_overlay.go index 45184e6..ed4f7d5 100644 --- a/tui/progress_overlay.go +++ b/tui/progress_overlay.go @@ -194,10 +194,7 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region { title = Truncate(title, overlayW-4) titleLen = RuneLen(title) } - titleX := (overlayW - titleLen) / 2 - for i, ch := range title { - overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold) - } + overlay.Text((overlayW-titleLen)/2, 0, title, opts.AccentFg, opts.Bg, terminal.AttrBold) } // Content area diff --git a/tui/tab.go b/tui/tab.go index dd0b996..b0be4ff 100644 --- a/tui/tab.go +++ b/tui/tab.go @@ -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) } - titleStart := x + opts.Padding - for j, ch := range title { - if titleStart+j >= r.W { - break - } - r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr) - } + r.TextStyled(x+opts.Padding, y, title, style) for j := 0; j < opts.Padding; j++ { pos := x + opts.Padding + RuneLen(title) + j diff --git a/tui/table.go b/tui/table.go index bdbec8b..6450f61 100644 --- a/tui/table.go +++ b/tui/table.go @@ -171,11 +171,7 @@ func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Ali startX = x } - for j, ch := range text { - if startX+j < r.W { - r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr) - } - } + r.TextStyled(startX, y, text, style) x += w diff --git a/tui/textfield.go b/tui/textfield.go index 0c0eb30..b86fce0 100644 --- a/tui/textfield.go +++ b/tui/textfield.go @@ -108,12 +108,7 @@ func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int { if RuneLen(placeholder) > viewportW { placeholder = Truncate(placeholder, viewportW) } - for i, ch := range placeholder { - if x+i >= contentX+contentW { - break - } - r.Cell(x+i, contentY, ch, style.PlaceholderFg, style.TextBg, terminal.AttrDim) - } + r.Text(x, contentY, placeholder, style.PlaceholderFg, style.TextBg, terminal.AttrDim) } else { // Scroll indicators if state.Scroll > 0 && x > contentX { diff --git a/tui/widget_test.go b/tui/widget_test.go index 386d9e5..d42ea42 100644 --- a/tui/widget_test.go +++ b/tui/widget_test.go @@ -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 } + +// 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) + } + } +}