* 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.
186 lines
4.0 KiB
Go
186 lines
4.0 KiB
Go
package tui
|
|
|
|
import "github.com/lixenwraith/terminal"
|
|
|
|
// Align specifies text alignment within a column
|
|
type Align uint8
|
|
|
|
const (
|
|
AlignLeft Align = iota
|
|
AlignRight
|
|
AlignCenter
|
|
)
|
|
|
|
// TableOpts configures table rendering
|
|
type TableOpts struct {
|
|
ColWidths []int // Fixed widths per column, 0 = auto
|
|
ColAligns []Align // Alignment per column, default AlignLeft
|
|
HeaderStyle Style
|
|
RowStyle Style
|
|
AltRowStyle Style // Alternating row style, zero = same as RowStyle
|
|
ColSeparator rune // Between columns, 0 = space
|
|
RowSeparator LineType // Between rows, LineNone = no separator
|
|
}
|
|
|
|
// DefaultTableOpts returns sensible defaults
|
|
func DefaultTableOpts() TableOpts {
|
|
return TableOpts{
|
|
HeaderStyle: Style{Attr: terminal.AttrBold},
|
|
ColSeparator: ' ',
|
|
RowSeparator: LineNone,
|
|
}
|
|
}
|
|
|
|
// CalculateColumnWidths computes optimal column widths for given data
|
|
// Returns widths that fit within availableW, respecting fixed widths in opts
|
|
func CalculateColumnWidths(availableW int, headers []string, rows [][]string, opts TableOpts) []int {
|
|
if len(headers) == 0 {
|
|
return nil
|
|
}
|
|
|
|
cols := len(headers)
|
|
widths := make([]int, cols)
|
|
separatorW := 1 // space between columns
|
|
|
|
// Start with header widths
|
|
for i, h := range headers {
|
|
widths[i] = RuneLen(h)
|
|
}
|
|
|
|
// Expand to fit data
|
|
for _, row := range rows {
|
|
for i := 0; i < cols && i < len(row); i++ {
|
|
w := RuneLen(row[i])
|
|
if w > widths[i] {
|
|
widths[i] = w
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply fixed widths from opts
|
|
for i := 0; i < cols && i < len(opts.ColWidths); i++ {
|
|
if opts.ColWidths[i] > 0 {
|
|
widths[i] = opts.ColWidths[i]
|
|
}
|
|
}
|
|
|
|
// Calculate total and scale if needed
|
|
total := 0
|
|
for _, w := range widths {
|
|
total += w
|
|
}
|
|
total += (cols - 1) * separatorW
|
|
|
|
if total > availableW && availableW > cols {
|
|
// Proportionally shrink
|
|
contentW := availableW - (cols-1)*separatorW
|
|
scale := float64(contentW) / float64(total-(cols-1)*separatorW)
|
|
for i := range widths {
|
|
widths[i] = int(float64(widths[i]) * scale)
|
|
if widths[i] < 1 {
|
|
widths[i] = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return widths
|
|
}
|
|
|
|
// Table renders a table with headers and rows
|
|
func (r Region) Table(headers []string, rows [][]string, opts TableOpts) {
|
|
if r.H < 1 || r.W < 1 || len(headers) == 0 {
|
|
return
|
|
}
|
|
|
|
widths := CalculateColumnWidths(r.W, headers, rows, opts)
|
|
sep := opts.ColSeparator
|
|
if sep == 0 {
|
|
sep = ' '
|
|
}
|
|
|
|
y := 0
|
|
|
|
// Header row
|
|
if y < r.H {
|
|
r.renderTableRow(y, headers, widths, opts.ColAligns, sep, opts.HeaderStyle)
|
|
y++
|
|
}
|
|
|
|
// Header separator
|
|
if opts.RowSeparator != LineNone && y < r.H {
|
|
r.HLine(y, opts.RowSeparator, opts.HeaderStyle.Fg)
|
|
y++
|
|
}
|
|
|
|
// Data rows
|
|
for rowIdx, row := range rows {
|
|
if y >= r.H {
|
|
break
|
|
}
|
|
|
|
style := opts.RowStyle
|
|
if !opts.AltRowStyle.IsZero() && rowIdx%2 == 1 {
|
|
style = opts.AltRowStyle
|
|
}
|
|
|
|
r.renderTableRow(y, row, widths, opts.ColAligns, sep, style)
|
|
y++
|
|
}
|
|
}
|
|
|
|
// renderTableRow renders a single table row
|
|
// The row is cleared to style.Bg across the full region width first, so
|
|
// alternating-row backgrounds render as continuous bands rather than only
|
|
// under glyphs. A zero style.Bg inherits the underlying background.
|
|
func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Align, sep rune, style Style) {
|
|
// Clear row with row background (full width, List-consistent banding)
|
|
for cx := 0; cx < r.W; cx++ {
|
|
r.Cell(cx, y, ' ', style.Fg, style.Bg, terminal.AttrNone)
|
|
}
|
|
|
|
x := 0
|
|
for i, w := range widths {
|
|
if x >= r.W {
|
|
break
|
|
}
|
|
|
|
text := ""
|
|
if i < len(cells) {
|
|
text = cells[i]
|
|
}
|
|
|
|
align := AlignLeft
|
|
if i < len(aligns) {
|
|
align = aligns[i]
|
|
}
|
|
|
|
// Truncate if needed
|
|
if RuneLen(text) > w {
|
|
text = Truncate(text, w)
|
|
}
|
|
|
|
// Render with alignment
|
|
textLen := RuneLen(text)
|
|
var startX int
|
|
switch align {
|
|
case AlignRight:
|
|
startX = x + w - textLen
|
|
case AlignCenter:
|
|
startX = x + (w-textLen)/2
|
|
default:
|
|
startX = x
|
|
}
|
|
|
|
r.TextStyled(startX, y, text, style)
|
|
|
|
x += w
|
|
|
|
// Column separator
|
|
if i < len(widths)-1 && x < r.W {
|
|
r.Cell(x, y, sep, style.Fg, style.Bg, terminal.AttrDim)
|
|
x++
|
|
}
|
|
}
|
|
}
|
|
|