* 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.
248 lines
6.9 KiB
Go
248 lines
6.9 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/lixenwraith/color"
|
|
"github.com/lixenwraith/terminal"
|
|
)
|
|
|
|
// LineType specifies box drawing character style
|
|
type LineType uint8
|
|
|
|
const (
|
|
LineSingle LineType = iota // ┌─┐│└┘
|
|
LineDouble // ╔═╗║╚╝
|
|
LineRounded // ╭─╮│╰╯
|
|
LineHeavy // ┏━┓┃┗┛
|
|
LineNone // spaces (invisible border with padding)
|
|
LineASCII // +-+|++, for a terminal outside UTF-8
|
|
LineDashed // ╭╌╮╎╰╯
|
|
)
|
|
|
|
// lineChars are each LineType's runes by the arms that meet in a cell: up 1,
|
|
// right 2, down 4, left 8, so corners, tees and crossings all join
|
|
var lineChars = [...]string{
|
|
LineSingle: " │─└││┌├─┘─┴┐┤┬┼",
|
|
LineDouble: " ║═╚║║╔╠═╝═╩╗╣╦╬",
|
|
LineRounded: " │─╰││╭├─╯─┴╮┤┬┼",
|
|
LineHeavy: " ┃━┗┃┃┏┣━┛━┻┓┫┳╋",
|
|
LineNone: " ",
|
|
LineASCII: " |-+||++-+-+++++",
|
|
LineDashed: " ╎╌╰╎╎╭├╌╯╌┴╮┤┬┼",
|
|
}
|
|
|
|
const (
|
|
boxTL = 6 // top-left
|
|
boxH = 10 // horizontal
|
|
boxTR = 12 // top-right
|
|
boxV = 5 // vertical
|
|
boxBL = 3 // bottom-left
|
|
boxBR = 9 // bottom-right
|
|
)
|
|
|
|
// runes returns the line's runes by arms; an unknown type draws single
|
|
func (l LineType) runes() []rune {
|
|
if l >= LineType(len(lineChars)) {
|
|
l = LineSingle
|
|
}
|
|
return []rune(lineChars[l])
|
|
}
|
|
|
|
// --- Box Rendering ---
|
|
|
|
// Box draws border around region edge
|
|
func (r Region) Box(line LineType, fg color.RGB) {
|
|
r.BoxClipped(line, fg, r.H, 0)
|
|
}
|
|
|
|
// BoxClipped draws the visible slice of a box whose logical height is totalH,
|
|
// with off rows scrolled past its top edge. Rows outside the box are skipped,
|
|
// so a partially visible box keeps its sides and grows no false edges.
|
|
func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) {
|
|
r.boxStyled(line, Style{Fg: fg}, totalH, off)
|
|
}
|
|
|
|
// BoxStyle draws a border around the region's edge in a theme's style
|
|
func (r Region) BoxStyle(line LineType, s Style) {
|
|
r.boxStyled(line, s, r.H, 0)
|
|
}
|
|
|
|
func (r Region) boxStyled(line LineType, s Style, totalH, off int) {
|
|
if r.W < 2 || r.H < 1 || totalH < 2 {
|
|
return
|
|
}
|
|
chars := line.runes()
|
|
edge := func(y int, left, right rune) {
|
|
r.TextStyled(0, y, string(left), s)
|
|
r.TextStyled(r.W-1, y, string(right), s)
|
|
}
|
|
for y := range r.H {
|
|
switch c := off + y; {
|
|
case c < 0 || c >= totalH:
|
|
case c == 0 || c == totalH-1:
|
|
left, right := chars[boxTL], chars[boxTR]
|
|
if c > 0 {
|
|
left, right = chars[boxBL], chars[boxBR]
|
|
}
|
|
r.TextStyled(1, y, strings.Repeat(string(chars[boxH]), max(0, r.W-2)), s)
|
|
edge(y, left, right)
|
|
default:
|
|
edge(y, chars[boxV], chars[boxV])
|
|
}
|
|
}
|
|
}
|
|
|
|
// BoxFilled draws border and fills interior with background
|
|
func (r Region) BoxFilled(line LineType, fg, bg color.RGB) {
|
|
// Fill interior first
|
|
for y := 1; y < r.H-1; y++ {
|
|
for x := 1; x < r.W-1; x++ {
|
|
r.Cell(x, y, ' ', fg, bg, terminal.AttrNone)
|
|
}
|
|
}
|
|
// Draw border on top
|
|
r.Box(line, fg)
|
|
}
|
|
|
|
// --- Line rendering ---
|
|
|
|
// HLine draws horizontal line across region width at row y
|
|
func (r Region) HLine(y int, line LineType, fg color.RGB) {
|
|
if y < 0 || y >= r.H {
|
|
return
|
|
}
|
|
ch := line.runes()[boxH]
|
|
for x := 0; x < r.W; x++ {
|
|
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
|
|
}
|
|
}
|
|
|
|
// VLine draws vertical line across region height at column x
|
|
func (r Region) VLine(x int, line LineType, fg color.RGB) {
|
|
if x < 0 || x >= r.W {
|
|
return
|
|
}
|
|
ch := line.runes()[boxV]
|
|
for y := 0; y < r.H; y++ {
|
|
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
|
|
}
|
|
}
|
|
|
|
// Divider draws horizontal line with optional centered label
|
|
func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
|
|
if y < 0 || y >= r.H {
|
|
return
|
|
}
|
|
hChar := line.runes()[boxH]
|
|
|
|
// Fill with horizontal line
|
|
for x := 0; x < r.W; x++ {
|
|
r.Cell(x, y, hChar, fg, color.RGB{}, terminal.AttrNone)
|
|
}
|
|
|
|
// Center label if provided
|
|
if label != "" && r.W > 4 {
|
|
text := " " + label + " "
|
|
textLen := RuneLen(text)
|
|
if textLen > r.W-2 {
|
|
text = Truncate(text, r.W-2)
|
|
textLen = RuneLen(text)
|
|
}
|
|
r.Text((r.W-textLen)/2, y, text, fg, color.RGB{}, terminal.AttrBold)
|
|
}
|
|
}
|
|
|
|
// --- Card rendering ---
|
|
|
|
// Card draws titled border and returns inner content region
|
|
func (r Region) Card(title string, line LineType, fg color.RGB) Region {
|
|
r.Box(line, fg)
|
|
|
|
if title != "" && r.W > 4 {
|
|
maxTitleLen := r.W - 4
|
|
displayTitle := title
|
|
if RuneLen(displayTitle) > maxTitleLen {
|
|
displayTitle = Truncate(displayTitle, maxTitleLen)
|
|
}
|
|
titleX := (r.W - RuneLen(displayTitle) - 2) / 2
|
|
r.Text(titleX, 0, " "+displayTitle+" ", fg, color.RGB{}, terminal.AttrBold)
|
|
}
|
|
|
|
return r.Inset(1)
|
|
}
|
|
|
|
// --- Themed frames, rules and wires ---
|
|
|
|
// Frame fills the region with the theme's text style and draws its border
|
|
// in Glyphs.Line, the title on the top edge, and returns the inside with a
|
|
// column of padding each side. Callers size it to its content, with Center.
|
|
func (r Region) Frame(title string, th Theme) Region {
|
|
r.FillStyle(th.Text)
|
|
r.BoxStyle(th.Glyphs.Line, th.Border)
|
|
if title != "" && r.W > 6 {
|
|
r.TextStyled(2, 0, " "+Truncate(title, r.W-6)+" ", th.Accent)
|
|
}
|
|
return r.Sub(2, 1, r.W-4, r.H-2)
|
|
}
|
|
|
|
// Rule draws a line across row y, the title at its third column and the
|
|
// hint muted at its end. The title keeps its room: the hint is dropped where
|
|
// both do not fit.
|
|
func (r Region) Rule(y int, title, hint string, th Theme) {
|
|
r.TextStyled(0, y, strings.Repeat(string(th.Glyphs.Line.runes()[boxH]), max(0, r.W)), th.Border)
|
|
if title != "" && r.W > 6 {
|
|
title = " " + Truncate(title, r.W-6) + " "
|
|
r.TextStyled(2, y, title, th.Text)
|
|
}
|
|
if hint != "" && RuneLen(title)+RuneLen(hint)+6 <= r.W {
|
|
r.TextStyled(r.W-RuneLen(hint)-3, y, " "+hint+" ", th.Muted)
|
|
}
|
|
}
|
|
|
|
// Wires are lines on a grid, each cell holding the arms that meet there: up
|
|
// 1, right 2, down 4, left 8. Wherever lines branch or cross, the cell draws
|
|
// the junction, whatever order they were laid in.
|
|
type Wires struct {
|
|
W int
|
|
Arms []uint8
|
|
}
|
|
|
|
// NewWires creates an empty grid w by h
|
|
func NewWires(w, h int) Wires {
|
|
return Wires{W: w, Arms: make([]uint8, max(0, w*h))}
|
|
}
|
|
|
|
// H lays a wire along row y between columns x0 and x1
|
|
func (g Wires) H(y, x0, x1 int) {
|
|
for x := min(x0, x1); x < max(x0, x1); x++ {
|
|
g.arm(x, y, 2)
|
|
g.arm(x+1, y, 8)
|
|
}
|
|
}
|
|
|
|
// V lays a wire down column x between rows y0 and y1
|
|
func (g Wires) V(x, y0, y1 int) {
|
|
for y := min(y0, y1); y < max(y0, y1); y++ {
|
|
g.arm(x, y, 4)
|
|
g.arm(x, y+1, 1)
|
|
}
|
|
}
|
|
|
|
// arm adds an arm to a cell; one off the grid is dropped
|
|
func (g Wires) arm(x, y int, a uint8) {
|
|
if i := y*g.W + x; x >= 0 && x < g.W && y >= 0 && i < len(g.Arms) {
|
|
g.Arms[i] |= a
|
|
}
|
|
}
|
|
|
|
// DrawWires draws every cell of the grid that has arms, in the line type
|
|
func (r Region) DrawWires(g Wires, line LineType, s Style) {
|
|
chars := line.runes()
|
|
for i, arms := range g.Arms {
|
|
if arms != 0 {
|
|
r.TextStyled(i%g.W, i/g.W, string(chars[arms&15]), s)
|
|
}
|
|
}
|
|
}
|