Files
terminal/tui/tab.go
T
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

125 lines
2.7 KiB
Go

package tui
import "github.com/lixenwraith/terminal"
// TabBounds stores position and size of a rendered tab
type TabBounds struct {
X, W int
}
// TabBarOpts configures tab bar rendering
type TabBarOpts struct {
ActiveStyle Style
InactiveStyle Style
Separator string // Between tabs, default " │ "
Padding int // Horizontal padding inside each tab, default 1
}
// DefaultTabBarOpts returns sensible defaults
func DefaultTabBarOpts() TabBarOpts {
return TabBarOpts{
ActiveStyle: Style{Attr: terminal.AttrBold | terminal.AttrReverse},
InactiveStyle: Style{Attr: terminal.AttrNone},
Separator: " │ ",
Padding: 1,
}
}
// TabBar renders horizontal tab strip at row y
// Returns bounds of each tab for hit testing / navigation
func (r Region) TabBar(y int, titles []string, active int, opts TabBarOpts) []TabBounds {
if y < 0 || y >= r.H || len(titles) == 0 {
return nil
}
if opts.Separator == "" {
opts.Separator = " │ "
}
bounds := make([]TabBounds, len(titles))
x := 0
sepLen := RuneLen(opts.Separator)
for i, title := range titles {
if x >= r.W {
break
}
// Calculate tab width
tabW := RuneLen(title) + opts.Padding*2
if x+tabW > r.W {
tabW = r.W - x
}
bounds[i] = TabBounds{X: x, W: tabW}
// Select style
style := opts.InactiveStyle
if i == active {
style = opts.ActiveStyle
}
// Render padding + title + padding
for j := 0; j < opts.Padding && x+j < r.W; j++ {
r.Cell(x+j, y, ' ', 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
if pos < r.W {
r.Cell(pos, y, ' ', style.Fg, style.Bg, style.Attr)
}
}
x += tabW
// Separator between tabs
if i < len(titles)-1 && x+sepLen <= r.W {
for j, ch := range opts.Separator {
r.Cell(x+j, y, ch, opts.InactiveStyle.Fg, opts.InactiveStyle.Bg, terminal.AttrDim)
}
x += sepLen
}
}
return bounds
}
// TabBarCentered renders tab bar centered horizontally
func (r Region) TabBarCentered(y int, titles []string, active int, opts TabBarOpts) []TabBounds {
if len(titles) == 0 {
return nil
}
if opts.Separator == "" {
opts.Separator = " │ "
}
// Calculate total width
totalW := 0
sepLen := RuneLen(opts.Separator)
for i, title := range titles {
totalW += RuneLen(title) + opts.Padding*2
if i < len(titles)-1 {
totalW += sepLen
}
}
// Create sub-region for centering
startX := (r.W - totalW) / 2
if startX < 0 {
startX = 0
}
subR := r.Sub(startX, y, totalW, 1)
bounds := subR.TabBar(0, titles, active, opts)
// Adjust bounds to parent coordinates
for i := range bounds {
bounds[i].X += startX
}
return bounds
}