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) + } + } +}