v0.1.0 initial commit

This commit is contained in:
2026-07-12 17:01:37 -04:00
commit c3ca2ebb63
64 changed files with 10453 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
# tui
Immediate-mode widget toolkit on top of the `terminal` cell buffer. No retained
widget tree, no framework loop: the application owns a `[]terminal.Cell` buffer
and all state; `tui` provides regions, layout math, render functions, and
plain-struct state helpers. Every frame is a full logical redraw — the
`terminal` diff layer keeps actual output minimal.
## Core concept: Region
A `Region` is a bounds-checked rectangular view over a cell slice. All drawing
goes through regions; coordinates are region-relative. Sub-regions nest and
clip to parent bounds, so widgets cannot draw outside their allotted area.
```go
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
root := tui.NewRegion(cells, w, 0, 0, w, h)
panel := root.Sub(2, 1, 40, 10) // clipped view
inner := panel.Inset(1) // shrink 1 cell on all sides
```
Out-of-bounds writes are silently dropped — no bounds management needed in
widget code.
## Quick start
```go
term := terminal.New()
term.Init()
defer term.Fini()
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
list := tui.NewScrollState(len(items), h-2)
list.Selection = 0
for {
root := tui.NewRegion(cells, w, 0, 0, w, h)
root.Fill(terminal.Gunmetal)
root.Box(tui.LineRounded, terminal.SteelBlue)
content := root.Inset(1)
content.List(buildItems(items), list.Selection, list.Offset, tui.ListOpts{
CursorBg: terminal.DarkSlate,
})
tui.ScrollBar(content, content.W-1, list.Offset, list.Visible, list.Total,
terminal.IronGray)
term.Flush(cells, w, h)
ev := term.PollEvent()
switch ev.Type {
case terminal.EventKey:
switch ev.Key {
case terminal.KeyUp:
list.SelectPrev()
case terminal.KeyDown:
list.SelectNext()
case terminal.KeyEscape:
return
}
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
list.SetVisible(h - 2)
}
}
```
## Layout
```go
cols := tui.SplitH(root, 0.3, 0.7) // ratio split, normalized
rows := tui.SplitV(cols[1], 0.5, 0.5)
side, main := tui.SplitHFixed(root, 24) // fixed left width
top, rest := tui.SplitVFixed(root, 3) // fixed top height
dlg := tui.Center(root, 50, 12) // centered sub-region
```
The last ratio segment absorbs rounding remainder — no gaps.
## Text and style
```go
r.Text(x, y, "label", fg, bg, terminal.AttrNone)
r.TextCenter(y, "title", fg, bg, terminal.AttrBold)
r.TextRight(y, "hint", fg, bg, terminal.AttrDim)
lines := r.TextBlock(x, y, longText, fg, bg, attr) // word-wrapped, returns line count
r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr})
```
String utilities operate on rune counts: `RuneLen`, `Truncate` /
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` /
`PadCenter`, `WrapText`.
`Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
accept it.
## Widgets
Widgets are stateless render functions (mostly `Region` methods). Application
state lives in plain structs passed by pointer. Available renderers:
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double,
rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
`KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` /
`ScrollIndicator`, masonry layout.
Representative patterns below; remaining widgets follow the same
opts-struct + state-struct shape — read the source for full options.
### Scrollable list with scrollbar
```go
items := make([]tui.ListItem, 0, len(files))
for _, f := range files {
items = append(items, tui.ListItem{
Icon: '▸', IconFg: terminal.Amber,
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
})
}
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: terminal.DarkSlate})
tui.ScrollBar(r, r.W-1, state.Offset, state.Visible, state.Total, terminal.IronGray)
```
### Modal dialog
```go
dlg := tui.Center(root, 50, 12)
content := dlg.Modal(tui.ModalOpts{
Title: "Settings",
Border: tui.LineDouble,
BorderFg: terminal.SteelBlue,
TitleFg: terminal.White,
Bg: terminal.DarkSlate,
})
content.TextBlock(0, 0, body, fg, terminal.DarkSlate, terminal.AttrNone)
```
`Modal` fills, borders, titles, and returns the content region. `Overlay`
adds fullscreen/floating/shadow variants; `ConfirmDialog` adds yes/no buttons
with focus state.
### Progress overlay
```go
prog := tui.NewProgressState(tui.DefaultProgressOpts("Indexing", "Scanning...",
tui.ProgressDeterminate))
// per frame:
prog.Tick()
prog.SetProgress(done / total)
if prog.Visible {
root.ProgressOverlay(prog.Opts)
}
```
Five progress types (spinner, determinate, indeterminate, pulse, dots), eight
spinner styles, eight bar styles, seven frame styles — combinable via opts.
### Multi-line editor
```go
ed := tui.NewEditorState(initialText)
// input:
if ev.Type == terminal.EventKey {
ed.HandleKey(ev.Key, ev.Rune, ev.Modifiers) // full emacs-style bindings built in
}
// render:
r.Editor(ed, tui.EditorOpts{LineNumbers: true, Border: tui.LineSingle, Focused: true})
text := ed.Value()
```
`TextFieldState` + `TextField` provide the single-line equivalent
(placeholder, prefix, password mask, max length).
## State helpers
Pure logic, no rendering — usable independently:
- `ScrollState` — item-index scrolling with selection
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
- `ViewportScroll` — row-based content scrolling with viewport clipping
(`ClipToViewport` maps content rows to visible rows)
- `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
keyed state, hierarchical → flat visible-node list
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
- `MasonryState` — multi-column layout calculation over a viewport
- Free functions: `AdjustScroll`, `ClampScroll`, `ClampCursor`, `ScrollPercent`,
`PageDelta`
## Notes
- Width calculations count runes, not terminal columns; East Asian wide
characters and combining marks are not width-aware.
- Zero-value `terminal.RGB` in style fields generally means "inherit"
(widget default or row background) — check specific widget docs.
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
application-side geometry from the regions used.
+159
View File
@@ -0,0 +1,159 @@
package tui
import (
"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)
)
// boxChars contains box drawing character sets indexed by LineType
var boxChars = [...][6]rune{
LineSingle: {'┌', '─', '┐', '│', '└', '┘'},
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'},
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'},
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'},
LineNone: {' ', ' ', ' ', ' ', ' ', ' '},
}
const (
boxTL = 0 // top-left
boxH = 1 // horizontal
boxTR = 2 // top-right
boxV = 3 // vertical
boxBL = 4 // bottom-left
boxBR = 5 // bottom-right
)
// --- Box Rendering ---
// Box draws border around region edge
func (r Region) Box(line LineType, fg terminal.RGB) {
if r.W < 2 || r.H < 2 {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
chars := boxChars[line]
bg := terminal.RGB{} // Transparent (use existing bg)
// Corners
r.Cell(0, 0, chars[boxTL], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, 0, chars[boxTR], fg, bg, terminal.AttrNone)
r.Cell(0, r.H-1, chars[boxBL], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, r.H-1, chars[boxBR], fg, bg, terminal.AttrNone)
// Horizontal edges
for x := 1; x < r.W-1; x++ {
r.Cell(x, 0, chars[boxH], fg, bg, terminal.AttrNone)
r.Cell(x, r.H-1, chars[boxH], fg, bg, terminal.AttrNone)
}
// Vertical edges
for y := 1; y < r.H-1; y++ {
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
}
}
// BoxFilled draws border and fills interior with background
func (r Region) BoxFilled(line LineType, fg, bg terminal.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 terminal.RGB) {
if y < 0 || y >= r.H {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxH]
for x := 0; x < r.W; x++ {
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
}
}
// VLine draws vertical line across region height at column x
func (r Region) VLine(x int, line LineType, fg terminal.RGB) {
if x < 0 || x >= r.W {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxV]
for y := 0; y < r.H; y++ {
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
}
}
// Divider draws horizontal line with optional centered label
func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
if y < 0 || y >= r.H {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
hChar := boxChars[line][boxH]
// Fill with horizontal line
for x := 0; x < r.W; x++ {
r.Cell(x, y, hChar, fg, terminal.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)
}
startX := (r.W - textLen) / 2
for i, ch := range text {
r.Cell(startX+i, y, ch, fg, terminal.RGB{}, terminal.AttrBold)
}
}
}
// --- Card rendering ---
// Card draws titled border and returns inner content region
func (r Region) Card(title string, line LineType, fg terminal.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, terminal.RGB{}, terminal.AttrBold)
}
return r.Inset(1)
}
+127
View File
@@ -0,0 +1,127 @@
package tui
import "github.com/lixenwraith/terminal"
// Button defines a single button in a button bar
type Button struct {
Label string
Key string // Keyboard hint (e.g., "Ctrl+S")
Focused bool
}
// ButtonBarOpts configures button bar rendering
type ButtonBarOpts struct {
Align BarAlign
Gap int
Style ButtonStyle
}
// ButtonStyle defines button bar colors
type ButtonStyle struct {
LabelFg terminal.RGB
LabelBg terminal.RGB
KeyFg terminal.RGB
FocusFg terminal.RGB
FocusBg terminal.RGB
Bg terminal.RGB
}
// DefaultButtonStyle returns default button colors with dark background
func DefaultButtonStyle() ButtonStyle {
return DefaultButtonStyleFrom(terminal.RGB{R: 25, G: 25, B: 35})
}
// DefaultButtonStyleFrom returns default button colors using the given background
func DefaultButtonStyleFrom(bg terminal.RGB) ButtonStyle {
return ButtonStyle{
LabelFg: terminal.RGB{R: 200, G: 200, B: 200},
LabelBg: terminal.RGB{R: 50, G: 50, B: 60},
KeyFg: terminal.RGB{R: 130, G: 130, B: 150},
FocusFg: terminal.RGB{R: 255, G: 255, B: 255},
FocusBg: terminal.RGB{R: 60, G: 80, B: 120},
Bg: bg,
}
}
// ButtonBar renders a row of buttons with labels and keyboard hints at row y
func (r Region) ButtonBar(y int, buttons []Button, opts ButtonBarOpts) {
if len(buttons) == 0 || y < 0 || y >= r.H {
return
}
style := opts.Style
if style == (ButtonStyle{}) {
style = DefaultButtonStyle()
}
gap := opts.Gap
if gap < 1 {
gap = 2
}
// Calculate total width
totalW := 0
for i, btn := range buttons {
btnW := RuneLen(btn.Label) + 2
if btn.Key != "" {
btnW += RuneLen(btn.Key) + 1
}
totalW += btnW
if i < len(buttons)-1 {
totalW += gap
}
}
// Starting X
x := 0
switch opts.Align {
case BarAlignRight:
x = r.W - totalW
case BarAlignCenter:
x = (r.W - totalW) / 2
case BarAlignLeft:
x = 0
}
if x < 0 {
x = 0
}
// Clear row
for i := 0; i < r.W; i++ {
r.Cell(i, y, ' ', style.LabelFg, style.Bg, terminal.AttrNone)
}
// Render buttons
for i, btn := range buttons {
fg := style.LabelFg
bg := style.LabelBg
if btn.Focused {
fg = style.FocusFg
bg = style.FocusBg
}
label := " " + btn.Label + " "
for _, ch := range label {
if x >= r.W {
break
}
r.Cell(x, y, ch, fg, bg, terminal.AttrNone)
x++
}
if btn.Key != "" {
keyStr := " " + btn.Key
for _, ch := range keyStr {
if x >= r.W {
break
}
r.Cell(x, y, ch, style.KeyFg, style.Bg, terminal.AttrNone)
x++
}
}
if i < len(buttons)-1 {
x += gap
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// CheckState represents checkbox visual state
type CheckState uint8
const (
CheckNone CheckState = iota // [ ]
CheckPartial // [o]
CheckFull // [x]
CheckPlus // [+]
)
// Checkbox draws a checkbox indicator
func (r Region) Checkbox(x, y int, state CheckState, fg terminal.RGB) {
if x < 0 || x+2 >= r.W || y < 0 || y >= r.H {
return
}
var ch rune
switch state {
case CheckNone:
ch = ' '
case CheckPartial:
ch = 'o'
case CheckFull:
ch = 'x'
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x+1, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x+2, y, ']', fg, terminal.RGB{}, terminal.AttrNone)
}
+67
View File
@@ -0,0 +1,67 @@
package tui
// Coverage represents count/total for display in tree nodes
type Coverage struct {
Count int
Total int
}
// IsAll returns true if count equals total and total is positive
func (c Coverage) IsAll() bool {
return c.Count == c.Total && c.Total > 0
}
// IsPartial returns true if count is between zero and total (exclusive)
func (c Coverage) IsPartial() bool {
return c.Count > 0 && c.Count < c.Total
}
// IsNone returns true if count is zero
func (c Coverage) IsNone() bool {
return c.Count == 0
}
// String returns coverage as "[count/total]", "[ALL]", or empty string if total is zero
func (c Coverage) String() string {
if c.Total == 0 {
return ""
}
if c.IsAll() {
return "[ALL]"
}
return "[" + intStr(c.Count) + "/" + intStr(c.Total) + "]"
}
// FormatCoverageSuffix returns a suffix string for TreeNode.Suffix
func FormatCoverageSuffix(count, total int) string {
if total == 0 {
return ""
}
if count == total {
return " [ALL]"
}
return " [" + intStr(count) + "/" + intStr(total) + "]"
}
// intStr converts int to string without fmt dependency
func intStr(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+324
View File
@@ -0,0 +1,324 @@
package tui
import "github.com/lixenwraith/terminal"
// ConfirmResult represents dialog outcome
type ConfirmResult uint8
const (
ConfirmPending ConfirmResult = iota
ConfirmYes
ConfirmNo
ConfirmCancel
)
// ConfirmState holds confirmation dialog state
type ConfirmState struct {
FocusYes bool // true = Yes focused, false = No focused
Result ConfirmResult
}
// NewConfirmState creates dialog state with default selection
func NewConfirmState(defaultYes bool) *ConfirmState {
return &ConfirmState{
FocusYes: defaultYes,
Result: ConfirmPending,
}
}
// Toggle switches focus between Yes and No
func (c *ConfirmState) Toggle() {
c.FocusYes = !c.FocusYes
}
// Confirm selects currently focused button
func (c *ConfirmState) Confirm() {
if c.FocusYes {
c.Result = ConfirmYes
} else {
c.Result = ConfirmNo
}
}
// SelectYes directly selects Yes
func (c *ConfirmState) SelectYes() {
c.Result = ConfirmYes
}
// SelectNo directly selects No
func (c *ConfirmState) SelectNo() {
c.Result = ConfirmNo
}
// Cancel cancels the dialog
func (c *ConfirmState) Cancel() {
c.Result = ConfirmCancel
}
// HandleKey processes input, returns true if dialog should close
func (c *ConfirmState) HandleKey(key terminal.Key, r rune) bool {
switch key {
case terminal.KeyLeft, terminal.KeyRight, terminal.KeyTab:
c.Toggle()
return false
case terminal.KeyEnter:
c.Confirm()
return true
case terminal.KeyEscape:
c.Cancel()
return true
case terminal.KeyRune:
switch r {
case 'y', 'Y':
c.SelectYes()
return true
case 'n', 'N':
c.SelectNo()
return true
case 'h':
c.FocusYes = true
return false
case 'l':
c.FocusYes = false
return false
}
}
return false
}
// ConfirmOpts configures confirmation dialog
type ConfirmOpts struct {
Title string
Message string
YesLabel string // Default "Yes"
NoLabel string // Default "No"
Destructive bool // Style Yes as warning
Style ConfirmStyle
}
// ConfirmStyle defines dialog colors
type ConfirmStyle struct {
BorderFg terminal.RGB
TitleFg terminal.RGB
MessageFg terminal.RGB
Bg terminal.RGB
ButtonFg terminal.RGB
ButtonBg terminal.RGB
ButtonFocusFg terminal.RGB
ButtonFocusBg terminal.RGB
DestructiveFg terminal.RGB
DestructiveBg terminal.RGB
}
// DefaultConfirmStyle returns default dialog colors
func DefaultConfirmStyle() ConfirmStyle {
return ConfirmStyle{
BorderFg: terminal.RGB{R: 100, G: 100, B: 120},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
MessageFg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 30, G: 30, B: 40},
ButtonFg: terminal.RGB{R: 180, G: 180, B: 180},
ButtonBg: terminal.RGB{R: 50, G: 50, B: 60},
ButtonFocusFg: terminal.RGB{R: 255, G: 255, B: 255},
ButtonFocusBg: terminal.RGB{R: 60, G: 80, B: 120},
DestructiveFg: terminal.RGB{R: 255, G: 255, B: 255},
DestructiveBg: terminal.RGB{R: 180, G: 60, B: 60},
}
}
// ConfirmDialog renders confirmation dialog centered in region
// Returns content region for additional content if needed
func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
style := opts.Style
if style == (ConfirmStyle{}) {
style = DefaultConfirmStyle()
}
if opts.YesLabel == "" {
opts.YesLabel = "Yes"
}
if opts.NoLabel == "" {
opts.NoLabel = "No"
}
// Calculate dialog size
msgLines := WrapText(opts.Message, r.W-8)
if len(msgLines) == 0 {
msgLines = []string{""}
}
dialogW := 40
msgMaxW := 0
for _, line := range msgLines {
if w := RuneLen(line); w > msgMaxW {
msgMaxW = w
}
}
if msgMaxW+6 > dialogW {
dialogW = msgMaxW + 6
}
if dialogW > r.W-4 {
dialogW = r.W - 4
}
if dialogW < 20 {
dialogW = 20
}
dialogH := 3 + len(msgLines) + 3 // border + message + spacing + buttons + border
if dialogH > r.H-2 {
dialogH = r.H - 2
}
// Center dialog
dialog := Center(r, dialogW, dialogH)
// Draw modal frame
content := dialog.Modal(ModalOpts{
Title: opts.Title,
Border: LineDouble,
BorderFg: style.BorderFg,
TitleFg: style.TitleFg,
Bg: style.Bg,
})
// Message
y := 0
for _, line := range msgLines {
if y >= content.H-2 {
break
}
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
y++
}
// Buttons row
buttonY := content.H - 1
if buttonY < y+1 {
buttonY = y + 1
}
yesLabel := " " + opts.YesLabel + " "
noLabel := " " + opts.NoLabel + " "
yesW := RuneLen(yesLabel)
noW := RuneLen(noLabel)
buttonGap := 4
totalButtonW := yesW + buttonGap + noW
buttonX := (content.W - totalButtonW) / 2
if buttonX < 0 {
buttonX = 0
}
// Yes button
yesFg := style.ButtonFg
yesBg := style.ButtonBg
if state.FocusYes {
if opts.Destructive {
yesFg = style.DestructiveFg
yesBg = style.DestructiveBg
} else {
yesFg = style.ButtonFocusFg
yesBg = style.ButtonFocusBg
}
}
for i, ch := range yesLabel {
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
}
}
// No button
noX := buttonX + yesW + buttonGap
noFg := style.ButtonFg
noBg := style.ButtonBg
if !state.FocusYes {
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)
}
}
return content.Sub(0, 0, content.W, buttonY-1)
}
// AlertOpts configures single-button alert dialog
type AlertOpts struct {
Title string
Message string
Button string // Default "OK"
Style ConfirmStyle
}
// AlertDialog renders single-button alert, returns true when dismissed
func (r Region) AlertDialog(opts AlertOpts) Region {
style := opts.Style
if style == (ConfirmStyle{}) {
style = DefaultConfirmStyle()
}
if opts.Button == "" {
opts.Button = "OK"
}
msgLines := WrapText(opts.Message, r.W-8)
if len(msgLines) == 0 {
msgLines = []string{""}
}
dialogW := 36
msgMaxW := 0
for _, line := range msgLines {
if w := RuneLen(line); w > msgMaxW {
msgMaxW = w
}
}
if msgMaxW+6 > dialogW {
dialogW = msgMaxW + 6
}
if dialogW > r.W-4 {
dialogW = r.W - 4
}
dialogH := 3 + len(msgLines) + 3
if dialogH > r.H-2 {
dialogH = r.H - 2
}
dialog := Center(r, dialogW, dialogH)
content := dialog.Modal(ModalOpts{
Title: opts.Title,
Border: LineDouble,
BorderFg: style.BorderFg,
TitleFg: style.TitleFg,
Bg: style.Bg,
})
// Message
y := 0
for _, line := range msgLines {
if y >= content.H-2 {
break
}
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
y++
}
// Button
buttonY := content.H - 1
buttonLabel := " " + opts.Button + " "
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)
}
}
return content.Sub(0, 0, content.W, buttonY-1)
}
+197
View File
@@ -0,0 +1,197 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// EditorOpts configures editor rendering
type EditorOpts struct {
LineNumbers bool
LineNumWidth int // 0 = auto-size
WrapLines bool
Border LineType
Focused bool
Style EditorStyle
}
// EditorStyle defines editor colors
type EditorStyle struct {
TextFg terminal.RGB
TextBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
LineNumFg terminal.RGB
LineNumBg terminal.RGB
CurrentLineBg terminal.RGB
BorderFg terminal.RGB
}
// DefaultEditorStyle returns default colors
func DefaultEditorStyle() EditorStyle {
return EditorStyle{
TextFg: terminal.RGB{R: 220, G: 220, B: 220},
TextBg: terminal.RGB{R: 25, G: 25, B: 35},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
LineNumFg: terminal.RGB{R: 100, G: 100, B: 120},
LineNumBg: terminal.RGB{R: 30, G: 30, B: 40},
CurrentLineBg: terminal.RGB{R: 35, G: 35, B: 50},
BorderFg: terminal.RGB{R: 80, G: 80, B: 100},
}
}
// Editor renders multi-line editor and returns content height used
func (r Region) Editor(state *EditorState, opts EditorOpts) int {
if r.W < 3 || r.H < 1 {
return 0
}
style := opts.Style
if style == (EditorStyle{}) {
style = DefaultEditorStyle()
}
// Calculate content area accounting for border
contentX := 0
contentY := 0
contentW := r.W
contentH := r.H
if opts.Border != LineNone {
if r.H < 3 {
return 0
}
r.Box(opts.Border, style.BorderFg)
contentX = 1
contentY = 1
contentW = r.W - 2
contentH = r.H - 2
}
// Line number gutter
gutterW := 0
if opts.LineNumbers {
gutterW = opts.LineNumWidth
if gutterW == 0 {
digits := 1
n := len(state.Lines)
for n >= 10 {
digits++
n /= 10
}
gutterW = digits + 1
}
contentW -= gutterW
}
if contentW < 1 || contentH < 1 {
return 0
}
state.AdjustScroll(contentW, contentH)
// Render each visible line
for y := 0; y < contentH; y++ {
lineIdx := state.ScrollY + y
isCurrentLine := lineIdx == state.CursorLine
bg := style.TextBg
if isCurrentLine && opts.Focused {
bg = style.CurrentLineBg
}
// Line number gutter
if opts.LineNumbers {
for gx := 0; gx < gutterW-1; gx++ {
r.Cell(contentX+gx, contentY+y, ' ', style.LineNumFg, style.LineNumBg, terminal.AttrNone)
}
r.Cell(contentX+gutterW-1, contentY+y, '│', style.LineNumFg, style.LineNumBg, terminal.AttrDim)
if lineIdx < len(state.Lines) {
numStr := formatLineNum(lineIdx+1, gutterW-1)
for i, ch := range numStr {
r.Cell(contentX+i, contentY+y, ch, style.LineNumFg, style.LineNumBg, terminal.AttrNone)
}
}
}
textX := contentX + gutterW
// Fill text area with background
for x := 0; x < contentW; x++ {
r.Cell(textX+x, contentY+y, ' ', style.TextFg, bg, terminal.AttrNone)
}
if lineIdx >= len(state.Lines) {
continue
}
line := []rune(state.Lines[lineIdx])
// Scroll indicator left
if state.ScrollX > 0 && len(line) > 0 {
r.Cell(textX, contentY+y, '◀', style.LineNumFg, bg, terminal.AttrDim)
}
// Render visible text
for x := 0; x < contentW; x++ {
charIdx := state.ScrollX + x
if charIdx >= len(line) {
break
}
ch := line[charIdx]
fg := style.TextFg
cellBg := bg
if opts.Focused && lineIdx == state.CursorLine && charIdx == state.CursorCol {
fg = style.CursorFg
cellBg = style.CursorBg
}
r.Cell(textX+x, contentY+y, ch, fg, cellBg, terminal.AttrNone)
}
// Scroll indicator right
if state.ScrollX+contentW < len(line) {
r.Cell(textX+contentW-1, contentY+y, '▶', style.LineNumFg, bg, terminal.AttrDim)
}
// Cursor at end of line
if opts.Focused && lineIdx == state.CursorLine && state.CursorCol >= len(line) {
cursorX := state.CursorCol - state.ScrollX
if cursorX >= 0 && cursorX < contentW {
r.Cell(textX+cursorX, contentY+y, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
}
// Vertical scroll indicators
if state.ScrollY > 0 {
r.Cell(contentX+gutterW+contentW-1, contentY, '▲', style.LineNumFg, style.TextBg, terminal.AttrDim)
}
if state.ScrollY+contentH < len(state.Lines) {
r.Cell(contentX+gutterW+contentW-1, contentY+contentH-1, '▼', style.LineNumFg, style.TextBg, terminal.AttrDim)
}
if opts.Border != LineNone {
return r.H
}
return contentH
}
// formatLineNum formats line number right-aligned to width
func formatLineNum(num, width int) string {
s := ""
for num > 0 {
s = string(rune('0'+num%10)) + s
num /= 10
}
if s == "" {
s = "0"
}
for len(s) < width {
s = " " + s
}
return s
}
+535
View File
@@ -0,0 +1,535 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// splitLines splits string into lines by newline character
func splitLines(s string) []string {
if s == "" {
return []string{""}
}
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
// EditorState holds multi-line text editor state
type EditorState struct {
Lines []string
CursorLine int
CursorCol int
ScrollX int
ScrollY int
ViewportW int // Updated during render
ViewportH int // Updated during render
}
// NewEditorState creates initialized editor state
func NewEditorState(initial string) *EditorState {
lines := splitLines(initial)
if len(lines) == 0 {
lines = []string{""}
}
return &EditorState{
Lines: lines,
}
}
// --- Value access ---
// Value returns all lines joined with newlines
func (e *EditorState) Value() string {
if len(e.Lines) == 0 {
return ""
}
result := e.Lines[0]
for i := 1; i < len(e.Lines); i++ {
result += "\n" + e.Lines[i]
}
return result
}
// SetValue replaces all content and resets cursor
func (e *EditorState) SetValue(s string) {
e.Lines = splitLines(s)
if len(e.Lines) == 0 {
e.Lines = []string{""}
}
e.CursorLine = 0
e.CursorCol = 0
e.ScrollX = 0
e.ScrollY = 0
}
// Clear empties the editor
func (e *EditorState) Clear() {
e.Lines = []string{""}
e.CursorLine = 0
e.CursorCol = 0
e.ScrollX = 0
e.ScrollY = 0
}
// --- Line queries ---
// LineCount returns number of lines
func (e *EditorState) LineCount() int {
return len(e.Lines)
}
// CurrentLine returns current line text
func (e *EditorState) CurrentLine() string {
if e.CursorLine < 0 || e.CursorLine >= len(e.Lines) {
return ""
}
return e.Lines[e.CursorLine]
}
// --- Cursor clamping ---
// clampCursor ensures cursor is within valid bounds
func (e *EditorState) clampCursor() {
if len(e.Lines) == 0 {
e.Lines = []string{""}
}
if e.CursorLine < 0 {
e.CursorLine = 0
}
if e.CursorLine >= len(e.Lines) {
e.CursorLine = len(e.Lines) - 1
}
lineLen := len([]rune(e.Lines[e.CursorLine]))
if e.CursorCol < 0 {
e.CursorCol = 0
}
if e.CursorCol > lineLen {
e.CursorCol = lineLen
}
}
// --- Character insertion ---
// Insert adds a rune at cursor position
func (e *EditorState) Insert(r rune) {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
line = append(line[:e.CursorCol], append([]rune{r}, line[e.CursorCol:]...)...)
e.Lines[e.CursorLine] = string(line)
e.CursorCol++
}
// InsertString adds string at cursor, handling newlines
func (e *EditorState) InsertString(s string) {
for _, r := range s {
if r == '\n' {
e.InsertNewline()
} else {
e.Insert(r)
}
}
}
// InsertNewline splits current line at cursor
func (e *EditorState) InsertNewline() {
e.clampCursor()
runes := []rune(e.Lines[e.CursorLine])
before := string(runes[:e.CursorCol])
after := string(runes[e.CursorCol:])
e.Lines[e.CursorLine] = before
e.Lines = append(e.Lines[:e.CursorLine+1], append([]string{after}, e.Lines[e.CursorLine+1:]...)...)
e.CursorLine++
e.CursorCol = 0
}
// --- Character deletion ---
// DeleteBackward deletes character before cursor or merges lines
func (e *EditorState) DeleteBackward() bool {
e.clampCursor()
if e.CursorCol > 0 {
line := []rune(e.Lines[e.CursorLine])
line = append(line[:e.CursorCol-1], line[e.CursorCol:]...)
e.Lines[e.CursorLine] = string(line)
e.CursorCol--
return true
}
if e.CursorLine > 0 {
prevLine := e.Lines[e.CursorLine-1]
curLine := e.Lines[e.CursorLine]
newCol := len([]rune(prevLine))
e.Lines[e.CursorLine-1] = prevLine + curLine
e.Lines = append(e.Lines[:e.CursorLine], e.Lines[e.CursorLine+1:]...)
e.CursorLine--
e.CursorCol = newCol
return true
}
return false
}
// DeleteForward deletes character at cursor or merges with next line
func (e *EditorState) DeleteForward() bool {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
if e.CursorCol < len(line) {
line = append(line[:e.CursorCol], line[e.CursorCol+1:]...)
e.Lines[e.CursorLine] = string(line)
return true
}
if e.CursorLine < len(e.Lines)-1 {
e.Lines[e.CursorLine] = e.Lines[e.CursorLine] + e.Lines[e.CursorLine+1]
e.Lines = append(e.Lines[:e.CursorLine+1], e.Lines[e.CursorLine+2:]...)
return true
}
return false
}
// --- Word deletion ---
// DeleteWordBackward deletes word before cursor
func (e *EditorState) DeleteWordBackward() bool {
e.clampCursor()
if e.CursorCol == 0 {
return e.DeleteBackward()
}
line := []rune(e.Lines[e.CursorLine])
end := e.CursorCol
// Skip trailing non-word chars
for end > 0 && !isWordChar(line[end-1]) {
end--
}
// Skip word chars
start := end
for start > 0 && isWordChar(line[start-1]) {
start--
}
if start == e.CursorCol {
start = e.CursorCol - 1
}
line = append(line[:start], line[e.CursorCol:]...)
e.Lines[e.CursorLine] = string(line)
e.CursorCol = start
return true
}
// DeleteWordForward deletes word after cursor
func (e *EditorState) DeleteWordForward() bool {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
if e.CursorCol >= len(line) {
return e.DeleteForward()
}
start := e.CursorCol
end := start
// Skip word chars
for end < len(line) && isWordChar(line[end]) {
end++
}
// Skip trailing non-word chars
for end < len(line) && !isWordChar(line[end]) {
end++
}
if end == start {
end = start + 1
}
line = append(line[:start], line[end:]...)
e.Lines[e.CursorLine] = string(line)
return true
}
// Line deletion
// DeleteToEndOfLine deletes from cursor to end of line
func (e *EditorState) DeleteToEndOfLine() bool {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
if e.CursorCol < len(line) {
e.Lines[e.CursorLine] = string(line[:e.CursorCol])
return true
}
// At end of line - merge with next
return e.DeleteForward()
}
// DeleteToStartOfLine deletes from start to cursor
func (e *EditorState) DeleteToStartOfLine() bool {
e.clampCursor()
if e.CursorCol > 0 {
line := []rune(e.Lines[e.CursorLine])
e.Lines[e.CursorLine] = string(line[e.CursorCol:])
e.CursorCol = 0
return true
}
return false
}
// DeleteLine removes current line
func (e *EditorState) DeleteLine() bool {
if len(e.Lines) == 1 {
e.Lines[0] = ""
e.CursorCol = 0
return true
}
e.Lines = append(e.Lines[:e.CursorLine], e.Lines[e.CursorLine+1:]...)
e.clampCursor()
return true
}
// --- Line navigation ---
// MoveUp moves cursor to previous line
func (e *EditorState) MoveUp() {
if e.CursorLine > 0 {
e.CursorLine--
e.clampCursor()
}
}
// MoveDown moves cursor to next line
func (e *EditorState) MoveDown() {
if e.CursorLine < len(e.Lines)-1 {
e.CursorLine++
e.clampCursor()
}
}
// --- Character navigation ---
// MoveLeft moves cursor left, wrapping to previous line
func (e *EditorState) MoveLeft() {
if e.CursorCol > 0 {
e.CursorCol--
} else if e.CursorLine > 0 {
e.CursorLine--
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
}
// MoveRight moves cursor right, wrapping to next line
func (e *EditorState) MoveRight() {
lineLen := len([]rune(e.Lines[e.CursorLine]))
if e.CursorCol < lineLen {
e.CursorCol++
} else if e.CursorLine < len(e.Lines)-1 {
e.CursorLine++
e.CursorCol = 0
}
}
// --- Word navigation ---
// MoveWordLeft moves cursor to previous word boundary
func (e *EditorState) MoveWordLeft() {
if e.CursorCol == 0 {
if e.CursorLine > 0 {
e.CursorLine--
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
return
}
line := []rune(e.Lines[e.CursorLine])
for e.CursorCol > 0 && !isWordChar(line[e.CursorCol-1]) {
e.CursorCol--
}
for e.CursorCol > 0 && isWordChar(line[e.CursorCol-1]) {
e.CursorCol--
}
}
// MoveWordRight moves cursor to next word boundary
func (e *EditorState) MoveWordRight() {
line := []rune(e.Lines[e.CursorLine])
lineLen := len(line)
if e.CursorCol >= lineLen {
if e.CursorLine < len(e.Lines)-1 {
e.CursorLine++
e.CursorCol = 0
}
return
}
for e.CursorCol < lineLen && isWordChar(line[e.CursorCol]) {
e.CursorCol++
}
for e.CursorCol < lineLen && !isWordChar(line[e.CursorCol]) {
e.CursorCol++
}
}
// --- Positions navigation ---
// MoveToLineStart moves cursor to start of line
func (e *EditorState) MoveToLineStart() {
e.CursorCol = 0
}
// MoveToLineEnd moves cursor to end of line
func (e *EditorState) MoveToLineEnd() {
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
// MoveToStart moves cursor to start of document
func (e *EditorState) MoveToStart() {
e.CursorLine = 0
e.CursorCol = 0
}
// MoveToEnd moves cursor to end of document
func (e *EditorState) MoveToEnd() {
e.CursorLine = len(e.Lines) - 1
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
// --- Page navigation ---
// PageUp moves cursor up by half viewport
func (e *EditorState) PageUp() {
delta := e.ViewportH / 2
if delta < 1 {
delta = 1
}
e.CursorLine -= delta
if e.CursorLine < 0 {
e.CursorLine = 0
}
e.clampCursor()
}
// PageDown moves cursor down by half viewport
func (e *EditorState) PageDown() {
delta := e.ViewportH / 2
if delta < 1 {
delta = 1
}
e.CursorLine += delta
if e.CursorLine >= len(e.Lines) {
e.CursorLine = len(e.Lines) - 1
}
e.clampCursor()
}
// --- Scroll management ---
// AdjustScroll updates scroll to keep cursor visible
func (e *EditorState) AdjustScroll(viewportW, viewportH int) {
e.ViewportW = viewportW
e.ViewportH = viewportH
// Vertical
if e.CursorLine < e.ScrollY {
e.ScrollY = e.CursorLine
}
if e.CursorLine >= e.ScrollY+viewportH {
e.ScrollY = e.CursorLine - viewportH + 1
}
if e.ScrollY < 0 {
e.ScrollY = 0
}
// Horizontal
if e.CursorCol < e.ScrollX {
e.ScrollX = e.CursorCol
}
if e.CursorCol >= e.ScrollX+viewportW {
e.ScrollX = e.CursorCol - viewportW + 1
}
if e.ScrollX < 0 {
e.ScrollX = 0
}
}
// --- Input handling ---
// HandleKey processes keyboard input, returns true if state changed
func (e *EditorState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyUp:
e.MoveUp()
return true
case terminal.KeyDown:
e.MoveDown()
return true
case terminal.KeyLeft:
if mod&terminal.ModCtrl != 0 {
e.MoveWordLeft()
} else {
e.MoveLeft()
}
return true
case terminal.KeyRight:
if mod&terminal.ModCtrl != 0 {
e.MoveWordRight()
} else {
e.MoveRight()
}
return true
case terminal.KeyHome:
if mod&terminal.ModCtrl != 0 {
e.MoveToStart()
} else {
e.MoveToLineStart()
}
return true
case terminal.KeyEnd:
if mod&terminal.ModCtrl != 0 {
e.MoveToEnd()
} else {
e.MoveToLineEnd()
}
return true
case terminal.KeyPageUp:
e.PageUp()
return true
case terminal.KeyPageDown:
e.PageDown()
return true
case terminal.KeyEnter:
e.InsertNewline()
return true
case terminal.KeyBackspace:
if mod&terminal.ModCtrl != 0 {
return e.DeleteWordBackward()
}
return e.DeleteBackward()
case terminal.KeyDelete:
if mod&terminal.ModCtrl != 0 {
return e.DeleteWordForward()
}
return e.DeleteForward()
case terminal.KeyCtrlA:
e.MoveToLineStart()
return true
case terminal.KeyCtrlE:
e.MoveToLineEnd()
return true
case terminal.KeyCtrlK:
return e.DeleteToEndOfLine()
case terminal.KeyCtrlU:
return e.DeleteToStartOfLine()
case terminal.KeyCtrlW:
return e.DeleteWordBackward()
case terminal.KeyRune:
if r >= 32 {
e.Insert(r)
return true
}
}
return false
}
+228
View File
@@ -0,0 +1,228 @@
package tui
import "github.com/lixenwraith/terminal"
// FormField pairs a label with an editable text field
type FormField struct {
Label string
State *TextFieldState
}
// FormState holds state for a multi-field form with focus tracking
type FormState struct {
Fields []FormField
Focus int
}
// NewFormState creates a form with labeled fields initialized to empty values
func NewFormState(labels ...string) *FormState {
fields := make([]FormField, len(labels))
for i, label := range labels {
fields[i] = FormField{
Label: label,
State: NewTextFieldState(""),
}
}
return &FormState{Fields: fields}
}
// Value returns the text value of the field at idx
func (f *FormState) Value(idx int) string {
if idx >= 0 && idx < len(f.Fields) {
return f.Fields[idx].State.Value()
}
return ""
}
// SetValue replaces the text of the field at idx
func (f *FormState) SetValue(idx int, val string) {
if idx >= 0 && idx < len(f.Fields) {
f.Fields[idx].State.SetValue(val)
}
}
// Clear empties all fields in the form
func (f *FormState) Clear() {
for i := range f.Fields {
f.Fields[i].State.Clear()
}
}
// FocusNext moves focus to the next field, wrapping around
func (f *FormState) FocusNext() {
if len(f.Fields) > 0 {
f.Focus = (f.Focus + 1) % len(f.Fields)
}
}
// FocusPrev moves focus to the previous field, wrapping around
func (f *FormState) FocusPrev() {
if len(f.Fields) > 0 {
f.Focus = (f.Focus - 1 + len(f.Fields)) % len(f.Fields)
}
}
// CurrentField returns the TextFieldState of the focused field, or nil
func (f *FormState) CurrentField() *TextFieldState {
if f.Focus >= 0 && f.Focus < len(f.Fields) {
return f.Fields[f.Focus].State
}
return nil
}
// HandleKey processes keyboard input for form navigation and field editing, returns true if state changed
func (f *FormState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyTab:
if mod&terminal.ModShift != 0 {
f.FocusPrev()
} else {
f.FocusNext()
}
return true
case terminal.KeyUp:
f.FocusPrev()
return true
case terminal.KeyDown:
f.FocusNext()
return true
default:
if field := f.CurrentField(); field != nil {
return field.HandleKey(key, r, mod)
}
}
return false
}
// FormOpts configures form rendering
type FormOpts struct {
LabelWidth int
Spacing int
Style FormStyle
}
// FormStyle defines form colors
type FormStyle struct {
LabelFg terminal.RGB
FieldFg terminal.RGB
FieldBg terminal.RGB
FocusBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
Bg terminal.RGB
}
// DefaultFormStyle returns default form colors
func DefaultFormStyle() FormStyle {
return FormStyle{
LabelFg: terminal.RGB{R: 150, G: 150, B: 180},
FieldFg: terminal.RGB{R: 220, G: 220, B: 220},
FieldBg: terminal.RGB{R: 35, G: 35, B: 45},
FocusBg: terminal.RGB{R: 45, G: 45, B: 60},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 25, G: 25, B: 35},
}
}
// Form renders a multi-field form with labels and editable text fields, returns height used
func (r Region) Form(state *FormState, opts FormOpts) int {
if len(state.Fields) == 0 || r.H < 1 {
return 0
}
style := opts.Style
if style == (FormStyle{}) {
style = DefaultFormStyle()
}
labelW := opts.LabelWidth
if labelW <= 0 {
for _, f := range state.Fields {
if w := RuneLen(f.Label); w > labelW {
labelW = w
}
}
labelW += 2
}
spacing := opts.Spacing
if spacing < 1 {
spacing = 1
}
y := 0
for i, field := range state.Fields {
if y >= r.H {
break
}
isFocused := i == state.Focus
// 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)
}
// Field area
fieldX := labelW
fieldW := r.W - labelW
if fieldW < 1 {
y += spacing
continue
}
fieldBg := style.FieldBg
if isFocused {
fieldBg = style.FocusBg
}
for x := fieldX; x < fieldX+fieldW && x < r.W; x++ {
r.Cell(x, y, ' ', style.FieldFg, fieldBg, terminal.AttrNone)
}
// Render text using existing TextField logic
text := field.State.Text
scroll := field.State.Scroll
cursor := field.State.Cursor
field.State.AdjustScroll(fieldW)
scroll = field.State.Scroll
for x := 0; x < fieldW; x++ {
runeIdx := scroll + x
ch := ' '
if runeIdx < len(text) {
ch = text[runeIdx]
}
fg := style.FieldFg
bg := fieldBg
if isFocused && runeIdx == cursor {
fg = style.CursorFg
bg = style.CursorBg
}
if fieldX+x < r.W {
r.Cell(fieldX+x, y, ch, fg, bg, terminal.AttrNone)
}
}
// Cursor at end of text
if isFocused && cursor == len(text) {
cursorX := fieldX + cursor - scroll
if cursorX >= fieldX && cursorX < fieldX+fieldW && cursorX < r.W {
r.Cell(cursorX, y, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
y += spacing
}
return y
}
+78
View File
@@ -0,0 +1,78 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// InputOpts configures single-line input field
type InputOpts struct {
Label string
LabelFg terminal.RGB
Text string
Cursor int // Cursor position in text (rune index)
CursorBg terminal.RGB
TextFg terminal.RGB
Bg terminal.RGB
}
// Input renders labeled text input field on row y, handling cursor display and horizontal scrolling
func (r Region) Input(y int, opts InputOpts) {
if y < 0 || y >= r.H || r.W < 5 {
return
}
x := 0
// Label
if opts.Label != "" {
for _, ch := range opts.Label {
if x >= r.W {
break
}
r.Cell(x, y, ch, opts.LabelFg, opts.Bg, terminal.AttrNone)
x++
}
}
// Available width for input text
inputW := r.W - x
if inputW < 3 {
return
}
runes := []rune(opts.Text)
cursor := opts.Cursor
if cursor > len(runes) {
cursor = len(runes)
}
if cursor < 0 {
cursor = 0
}
// Horizontal scroll to keep cursor visible
scroll := 0
if cursor >= inputW-1 {
scroll = cursor - inputW + 2
}
// Render visible portion
for i := 0; i < inputW; i++ {
runeIdx := scroll + i
ch := ' '
if runeIdx < len(runes) {
ch = runes[runeIdx]
}
bg := opts.Bg
if runeIdx == cursor {
bg = opts.CursorBg
}
r.Cell(x+i, y, ch, opts.TextFg, bg, terminal.AttrNone)
}
// Cursor at end if past text
if cursor == len(runes) && cursor-scroll < inputW {
r.Cell(x+cursor-scroll, y, ' ', opts.TextFg, opts.CursorBg, terminal.AttrNone)
}
}
+199
View File
@@ -0,0 +1,199 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// KeyValue renders right-aligned key, separator, left-aligned value on row
// Key width auto-sizes based on content, capped at 40% of region width
// Value gets remainder, minimum 30% of region width
func (r Region) KeyValue(y int, key, value string, keyStyle, valStyle Style, sep rune) {
if y < 0 || y >= r.H || r.W < 3 {
return
}
keyLen := RuneLen(key)
// Dynamic allocation: key gets what it needs up to 40%
maxKeyW := (r.W * 2) / 5 // 40%
minValW := (r.W * 3) / 10 // 30%
keyW := keyLen
if keyW > maxKeyW {
keyW = maxKeyW
}
if keyW < 1 {
keyW = 1
}
valW := r.W - keyW - 1 // -1 for separator
if valW < minValW && r.W > minValW+2 {
// Reclaim from key to meet minimum value width
valW = minValW
keyW = r.W - valW - 1
if keyW < 1 {
keyW = 1
valW = r.W - 2
}
}
if valW < 1 {
valW = 1
}
// Truncate key if needed
keyRunes := []rune(key)
if len(keyRunes) > keyW {
if keyW > 1 {
keyRunes = keyRunes[:keyW-1]
keyRunes = append(keyRunes, '…')
} else {
keyRunes = keyRunes[:1]
}
}
// Truncate value if needed
valRunes := []rune(value)
if len(valRunes) > valW {
if valW > 1 {
valRunes = valRunes[:valW-1]
valRunes = append(valRunes, '…')
} else {
valRunes = valRunes[:1]
}
}
// Right-align key within allocated width
keyX := keyW - len(keyRunes)
for i, ch := range keyRunes {
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
}
// Separator
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
// Left-align value
for i, ch := range valRunes {
r.Cell(keyW+1+i, y, ch, valStyle.Fg, valStyle.Bg, valStyle.Attr)
}
}
// KeyValueWrap renders key-value with value wrapping to subsequent lines
// Returns number of lines used
// Layout:
//
// key: value text that is
// long and wraps to
// next line
func (r Region) KeyValueWrap(y int, key, value string, keyStyle, valStyle Style, sep rune) int {
if y < 0 || y >= r.H || r.W < 3 {
return 0
}
keyLen := RuneLen(key)
// Dynamic allocation same as KeyValue
maxKeyW := (r.W * 2) / 5 // 40%
minValW := (r.W * 3) / 10 // 30%
keyW := keyLen
if keyW > maxKeyW {
keyW = maxKeyW
}
if keyW < 1 {
keyW = 1
}
valW := r.W - keyW - 1 // -1 for separator
if valW < minValW && r.W > minValW+2 {
valW = minValW
keyW = r.W - valW - 1
if keyW < 1 {
keyW = 1
valW = r.W - 2
}
}
if valW < 1 {
valW = 1
}
// Truncate key if needed
keyRunes := []rune(key)
if len(keyRunes) > keyW {
if keyW > 1 {
keyRunes = keyRunes[:keyW-1]
keyRunes = append(keyRunes, '…')
} else {
keyRunes = keyRunes[:1]
}
}
// Right-align key within allocated width
keyX := keyW - len(keyRunes)
for i, ch := range keyRunes {
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
}
// Separator
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
// Wrap value text
valueX := keyW + 1
lines := WrapText(value, valW)
if len(lines) == 0 {
return 1
}
rendered := 0
for i, line := range lines {
lineY := y + i
if lineY >= r.H {
break
}
r.Text(valueX, lineY, line, valStyle.Fg, valStyle.Bg, valStyle.Attr)
rendered++
}
if rendered < 1 {
rendered = 1
}
return rendered
}
// MeasureKeyValueWrap calculates lines needed for KeyValueWrap without rendering
// Useful for layout pre-calculation
func (r Region) MeasureKeyValueWrap(key, value string) int {
if r.W < 3 {
return 1
}
keyLen := RuneLen(key)
maxKeyW := (r.W * 2) / 5
minValW := (r.W * 3) / 10
keyW := keyLen
if keyW > maxKeyW {
keyW = maxKeyW
}
if keyW < 1 {
keyW = 1
}
valW := r.W - keyW - 1
if valW < minValW && r.W > minValW+2 {
valW = minValW
keyW = r.W - valW - 1
if keyW < 1 {
keyW = 1
valW = r.W - 2
}
}
if valW < 1 {
valW = 1
}
lines := WrapText(value, valW)
if len(lines) == 0 {
return 1
}
return len(lines)
}
+309
View File
@@ -0,0 +1,309 @@
package tui
// --- Centering ---
// Center returns a centered region of given size within outer
func Center(outer Region, w, h int) Region {
x := (outer.W - w) / 2
y := (outer.H - h) / 2
return outer.Sub(x, y, w, h)
}
// --- Ratio-based splitting ---
// Ratios are normalized if they don't sum to 1.0
// SplitH splits region horizontally by ratios (0.0-1.0)
func SplitH(r Region, ratios ...float64) []Region {
if len(ratios) == 0 {
return nil
}
// Normalize ratios
var sum float64
for _, ratio := range ratios {
sum += ratio
}
if sum <= 0 {
sum = 1
}
regions := make([]Region, len(ratios))
x := 0
remaining := r.W
for i, ratio := range ratios {
var w int
if i == len(ratios)-1 {
w = remaining // Last one gets remainder to avoid rounding gaps
} else {
w = int((float64(r.W) * ratio / sum) + 0.5) // Round to nearest cell
if w > remaining {
w = remaining
}
}
regions[i] = r.Sub(x, 0, w, r.H)
x += w
remaining -= w
}
return regions
}
// SplitV splits region vertically by ratios (0.0-1.0)
func SplitV(r Region, ratios ...float64) []Region {
if len(ratios) == 0 {
return nil
}
var sum float64
for _, ratio := range ratios {
sum += ratio
}
if sum <= 0 {
sum = 1
}
regions := make([]Region, len(ratios))
y := 0
remaining := r.H
for i, ratio := range ratios {
var h int
if i == len(ratios)-1 {
h = remaining
} else {
h = int(float64(r.H) * ratio / sum)
}
regions[i] = r.Sub(0, y, r.W, h)
y += h
remaining -= h
}
return regions
}
// --- Fixed-size splitting ---
// SplitHFixed splits with fixed left width, rest to right
func SplitHFixed(r Region, leftW int) (left, right Region) {
if leftW > r.W {
leftW = r.W
}
if leftW < 0 {
leftW = 0
}
left = r.Sub(0, 0, leftW, r.H)
right = r.Sub(leftW, 0, r.W-leftW, r.H)
return
}
// SplitVFixed splits with fixed top height, rest to bottom
func SplitVFixed(r Region, topH int) (top, bottom Region) {
if topH > r.H {
topH = r.H
}
if topH < 0 {
topH = 0
}
top = r.Sub(0, 0, r.W, topH)
bottom = r.Sub(0, topH, r.W, r.H-topH)
return
}
// --- Equal splitting ---
// SplitHEqual splits region into n equal-width columns
// gap specifies spacing between columns (e.g., 1 for divider lines)
// Returns n regions positioned with gaps between them
func SplitHEqual(r Region, n, gap int) []Region {
if n <= 0 {
return nil
}
if n == 1 {
return []Region{r}
}
totalGaps := gap * (n - 1)
availW := r.W - totalGaps
if availW < n {
availW = n
}
baseW := availW / n
extra := availW % n
regions := make([]Region, n)
x := 0
for i := 0; i < n; i++ {
w := baseW
if i < extra {
w++
}
regions[i] = r.Sub(x, 0, w, r.H)
x += w + gap
}
return regions
}
// SplitVEqual splits region into n equal-height rows
// gap specifies spacing between rows
func SplitVEqual(r Region, n, gap int) []Region {
if n <= 0 {
return nil
}
if n == 1 {
return []Region{r}
}
totalGaps := gap * (n - 1)
availH := r.H - totalGaps
if availH < n {
availH = n
}
baseH := availH / n
extra := availH % n
regions := make([]Region, n)
y := 0
for i := 0; i < n; i++ {
h := baseH
if i < extra {
h++
}
regions[i] = r.Sub(0, y, r.W, h)
y += h + gap
}
return regions
}
// --- Grid layout ---
// Columns calculates how many columns fit in width
func Columns(availableW, itemW, gap int) int {
if itemW <= 0 {
return 0
}
if availableW < itemW {
return 0
}
// First item has no gap, subsequent items need gap + itemW
cols := 1 + (availableW-itemW)/(itemW+gap)
if cols < 0 {
cols = 0
}
return cols
}
// GridLayout returns a grid of equally sized regions
func GridLayout(r Region, cols, rows, gapX, gapY int) []Region {
if cols <= 0 || rows <= 0 {
return nil
}
cellW := (r.W - gapX*(cols-1)) / cols
cellH := (r.H - gapY*(rows-1)) / rows
if cellW < 1 {
cellW = 1
}
if cellH < 1 {
cellH = 1
}
regions := make([]Region, cols*rows)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
x := col * (cellW + gapX)
y := row * (cellH + gapY)
regions[row*cols+col] = r.Sub(x, y, cellW, cellH)
}
}
return regions
}
// --- Divider positioning ---
// DividerPositions returns X coordinates for vertical dividers between equal columns
// Use with VLine to draw dividers in gaps created by SplitHEqual
func DividerPositions(regionW, n, gap int) []int {
if n <= 1 || gap <= 0 {
return nil
}
totalGaps := gap * (n - 1)
availW := regionW - totalGaps
if availW < n {
availW = n
}
baseW := availW / n
extra := availW % n
positions := make([]int, n-1)
x := 0
for i := 0; i < n-1; i++ {
w := baseW
if i < extra {
w++
}
x += w
positions[i] = x
x += gap
}
return positions
}
// HDividerPositions returns Y coordinates for horizontal dividers between equal rows
func HDividerPositions(regionH, n, gap int) []int {
if n <= 1 || gap <= 0 {
return nil
}
totalGaps := gap * (n - 1)
availH := regionH - totalGaps
if availH < n {
availH = n
}
baseH := availH / n
extra := availH % n
positions := make([]int, n-1)
y := 0
for i := 0; i < n-1; i++ {
h := baseH
if i < extra {
h++
}
y += h
positions[i] = y
y += gap
}
return positions
}
// --- Responsive utilities ---
// FitOrScroll returns true if content exceeds available height
func FitOrScroll(contentH, availableH int) bool {
return contentH > availableH
}
// Breakpoints should be in descending order
// BreakpointH returns index of first breakpoint <= w, returns len(breakpoints) if w is less than all breakpoints
func BreakpointH(w int, breakpoints ...int) int {
for i, bp := range breakpoints {
if w >= bp {
return i
}
}
return len(breakpoints)
}
// BreakpointV returns index of first breakpoint <= h
func BreakpointV(h int, breakpoints ...int) int {
return BreakpointH(h, breakpoints...)
}
+99
View File
@@ -0,0 +1,99 @@
package tui
import "github.com/lixenwraith/terminal"
// ListItem represents a single row in a scrollable list
type ListItem struct {
Indent int // Left padding in cells
Icon rune // Expand indicator or bullet, 0 = none
IconFg terminal.RGB
Check CheckState // CheckNone to skip checkbox
CheckFg terminal.RGB
Text string
TextStyle Style
}
// ListOpts configures list rendering
type ListOpts struct {
CursorBg terminal.RGB
DefaultBg terminal.RGB
IconWidth int // Width reserved for icon, default 2
}
// List renders scrollable list items within region, returns number of rows rendered
func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
if r.H < 1 || len(items) == 0 {
return 0
}
iconW := opts.IconWidth
if iconW == 0 {
iconW = 2
}
rendered := 0
for y := 0; y < r.H; y++ {
idx := scroll + y
if idx >= len(items) {
break
}
item := items[idx]
isCursor := idx == cursor
// Row background
bg := opts.DefaultBg
if isCursor {
bg = opts.CursorBg
}
// Clear row
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
}
x := item.Indent
// Icon
if item.Icon != 0 && x < r.W {
r.Cell(x, y, item.Icon, item.IconFg, bg, terminal.AttrNone)
}
x += iconW
// Checkbox
if item.Check != CheckNone || item.CheckFg != (terminal.RGB{}) {
if x+3 <= r.W {
var ch rune
switch item.Check {
case CheckNone:
ch = ' '
case CheckPartial:
ch = 'o'
case CheckFull:
ch = 'x'
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', item.CheckFg, bg, terminal.AttrNone)
r.Cell(x+1, y, ch, item.CheckFg, bg, terminal.AttrNone)
r.Cell(x+2, y, ']', item.CheckFg, bg, terminal.AttrNone)
}
x += 4
}
// Text
textStyle := item.TextStyle
if textStyle.Bg == (terminal.RGB{}) {
textStyle.Bg = bg
}
text := item.Text
if x+RuneLen(text) > r.W {
text = Truncate(text, r.W-x)
}
r.TextStyled(x, y, text, textStyle)
rendered++
}
return rendered
}
+170
View File
@@ -0,0 +1,170 @@
package tui
// MasonryItem represents a single item in masonry layout
type MasonryItem struct {
Key string
Height int
Data any
}
// MasonryLayout holds calculated position for an item
type MasonryLayout struct {
X, Y, W, H int
Item MasonryItem
}
// MasonryOpts configures masonry layout
type MasonryOpts struct {
Columns int
Gap int
MinColW int
Breakpoints map[int]int
}
// DefaultMasonryOpts returns sensible defaults
func DefaultMasonryOpts() MasonryOpts {
return MasonryOpts{
Gap: 1,
MinColW: 30,
Breakpoints: map[int]int{
140: 4,
100: 3,
60: 2,
},
}
}
// MasonryState manages masonry layout with viewport scroll
type MasonryState struct {
Viewport *ViewportScroll
Layouts []MasonryLayout
}
// NewMasonryState creates masonry state
func NewMasonryState() *MasonryState {
return &MasonryState{
Viewport: NewViewportScroll(),
}
}
// CalculateLayout computes item positions
func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts MasonryOpts) {
cols := opts.Columns
if cols <= 0 {
cols = m.autoColumns(width, opts)
}
gap := opts.Gap
if gap < 0 {
gap = 1
}
colW := (width - (cols-1)*gap) / cols
if colW < 1 {
colW = 1
}
m.Layouts = make([]MasonryLayout, 0, len(items))
colHeights := make([]int, cols)
for _, item := range items {
minCol := 0
minH := colHeights[0]
for i := 1; i < cols; i++ {
if colHeights[i] < minH {
minH = colHeights[i]
minCol = i
}
}
x := minCol * (colW + gap)
y := colHeights[minCol]
m.Layouts = append(m.Layouts, MasonryLayout{
X: x, Y: y, W: colW, H: item.Height,
Item: item,
})
colHeights[minCol] += item.Height + gap
}
totalH := 0
for _, h := range colHeights {
if h > totalH {
totalH = h
}
}
if totalH > 0 {
totalH -= gap
}
m.Viewport.ContentH = totalH
}
func (m *MasonryState) autoColumns(width int, opts MasonryOpts) int {
if opts.Breakpoints != nil {
best := 1
bestThresh := 0
for thresh, cols := range opts.Breakpoints {
if width >= thresh && thresh > bestThresh {
best = cols
bestThresh = thresh
}
}
return best
}
cols := width / opts.MinColW
if cols < 1 {
cols = 1
}
return cols
}
// SetViewport updates viewport height
func (m *MasonryState) SetViewport(h int) {
m.Viewport.ViewportH = h
m.Viewport.clamp()
}
// MasonryRenderFunc renders a single item
type MasonryRenderFunc func(region Region, layout MasonryLayout, contentOffset int)
// Masonry renders visible items via callback
func (r Region) Masonry(state *MasonryState, render MasonryRenderFunc) {
state.SetViewport(r.H)
for _, l := range state.Layouts {
viewY, viewH, offset, visible := state.Viewport.ClipToViewport(l.Y, l.H)
if !visible {
continue
}
itemRegion := r.Sub(l.X, viewY, l.W, viewH)
render(itemRegion, l, offset)
}
}
// ScrollIndicator renders scroll position indicator for masonry
func (m *MasonryState) ScrollIndicator() string {
if !m.Viewport.CanScroll() {
return ""
}
pos := m.Viewport.Offset + 1
maxPos := m.Viewport.MaxOffset() + 1
return "[" + itoa(pos) + "/" + itoa(maxPos) + "]"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
+66
View File
@@ -0,0 +1,66 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// ModalOpts configures modal overlay rendering
type ModalOpts struct {
Title string
Hint string // Right-aligned hint text
Border LineType
BorderFg terminal.RGB
TitleFg terminal.RGB
HintFg terminal.RGB
Bg terminal.RGB
}
// Modal fills region with background, draws border with title/hint, returns content region
func (r Region) Modal(opts ModalOpts) Region {
if r.W < 5 || r.H < 3 {
return r.Sub(1, 1, 0, 0)
}
// Fill entire region
r.Fill(opts.Bg)
// Draw border
r.Box(opts.Border, opts.BorderFg)
// Title centered on top edge
if opts.Title != "" {
title := " " + opts.Title + " "
titleLen := RuneLen(title)
if titleLen > r.W-4 {
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)
}
}
// Hint right-aligned on top edge
if opts.Hint != "" {
hint := opts.Hint
hintLen := RuneLen(hint)
if hintLen > r.W/3 {
hint = Truncate(hint, r.W/3)
hintLen = RuneLen(hint)
}
x := r.W - hintLen - 2
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)
}
}
// Return content region
return r.Sub(1, 1, r.W-2, r.H-2)
}
+373
View File
@@ -0,0 +1,373 @@
package tui
import "github.com/lixenwraith/terminal"
// OverlayStyle specifies overlay appearance
type OverlayStyle uint8
const (
OverlayFullscreen OverlayStyle = iota // No border, fills region
OverlayModal // Centered box with border
OverlayFloating // Positioned box with shadow
OverlayBorderTitle // Title embedded in top border line
)
// OverlayOpts configures overlay rendering
type OverlayOpts struct {
Style OverlayStyle
Title string
Border LineType
Bg terminal.RGB
Fg terminal.RGB // Border and title color
TitleBg terminal.RGB // Title bar background, zero = same as Fg
TitleFg terminal.RGB // Title text color, zero = same as Bg
// Modal/Floating positioning (ignored for Fullscreen)
Width int // 0 = 80% of region
Height int // 0 = 80% of region
X, Y int // Offset from center, 0 = centered
// Shadow for Floating style
ShadowColor terminal.RGB
}
// DefaultOverlayOpts returns sensible defaults for modal overlay
func DefaultOverlayOpts(title string) OverlayOpts {
return OverlayOpts{
Style: OverlayModal,
Title: title,
Border: LineDouble,
Bg: terminal.RGB{R: 25, G: 25, B: 35},
Fg: terminal.RGB{R: 100, G: 140, B: 180},
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
}
}
// FullscreenOverlayOpts returns opts for fullscreen overlay with title bar
func FullscreenOverlayOpts(title string) OverlayOpts {
return OverlayOpts{
Style: OverlayFullscreen,
Title: title,
Bg: terminal.RGB{R: 20, G: 20, B: 30},
Fg: terminal.RGB{R: 100, G: 140, B: 180},
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
}
}
// OverlayResult contains regions returned by Overlay rendering
type OverlayResult struct {
Outer Region // Full overlay bounds (including border/title)
Content Region // Inner content area
TitleY int // Y position of title bar in Outer, -1 if no title
}
// Overlay renders an overlay and returns content region for caller to populate
// Caller should render content into result.Content after this call
func (r Region) Overlay(opts OverlayOpts) OverlayResult {
if r.W < 3 || r.H < 3 {
return OverlayResult{}
}
switch opts.Style {
case OverlayFullscreen:
return r.renderFullscreenOverlay(opts)
case OverlayModal:
return r.renderModalOverlay(opts)
case OverlayFloating:
return r.renderFloatingOverlay(opts)
case OverlayBorderTitle:
return r.renderBorderTitleOverlay(opts)
}
return OverlayResult{}
}
func (r Region) renderFullscreenOverlay(opts OverlayOpts) OverlayResult {
r.Fill(opts.Bg)
result := OverlayResult{
Outer: r,
TitleY: -1,
}
contentY := 0
contentH := r.H
// Title bar
if opts.Title != "" {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Bg
}
// Fill title row
for x := 0; x < r.W; x++ {
r.Cell(x, 0, ' ', titleFg, titleBg, terminal.AttrNone)
}
// Center title text
title := opts.Title
if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4)
}
titleX := (r.W - RuneLen(title)) / 2
r.Text(titleX, 0, title, titleFg, titleBg, terminal.AttrBold)
result.TitleY = 0
contentY = 1
contentH = r.H - 1
}
result.Content = r.Sub(0, contentY, r.W, contentH)
return result
}
func (r Region) renderModalOverlay(opts OverlayOpts) OverlayResult {
// Calculate dimensions
w := opts.Width
if w <= 0 {
w = r.W * 80 / 100
}
h := opts.Height
if h <= 0 {
h = r.H * 80 / 100
}
// Clamp to region
if w > r.W-2 {
w = r.W - 2
}
if h > r.H-2 {
h = r.H - 2
}
if w < 5 {
w = 5
}
if h < 3 {
h = 3
}
// Center with offset
x := (r.W-w)/2 + opts.X
y := (r.H-h)/2 + opts.Y
// Clamp position
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
if x+w > r.W {
x = r.W - w
}
if y+h > r.H {
y = r.H - h
}
outer := r.Sub(x, y, w, h)
outer.BoxFilled(opts.Border, opts.Fg, opts.Bg)
result := OverlayResult{
Outer: outer,
TitleY: -1,
}
// Content inset by border
contentX := 1
contentY := 1
contentW := w - 2
contentH := h - 2
// Title in top border
if opts.Title != "" && contentW > 2 {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Bg
}
// Fill title row inside border
for i := 0; i < contentW; i++ {
outer.Cell(contentX+i, contentY, ' ', titleFg, titleBg, terminal.AttrNone)
}
title := opts.Title
if RuneLen(title) > contentW-2 {
title = Truncate(title, contentW-2)
}
titleX := contentX + (contentW-RuneLen(title))/2
outer.Text(titleX, contentY, title, titleFg, titleBg, terminal.AttrBold)
result.TitleY = contentY
contentY++
contentH--
}
if contentH < 1 {
contentH = 1
}
result.Content = outer.Sub(contentX, contentY, contentW, contentH)
return result
}
func (r Region) renderFloatingOverlay(opts OverlayOpts) OverlayResult {
// Same as modal but with shadow
shadowColor := opts.ShadowColor
if shadowColor == (terminal.RGB{}) {
shadowColor = terminal.RGB{R: 10, G: 10, B: 15}
}
// Calculate dimensions (same as modal)
w := opts.Width
if w <= 0 {
w = r.W * 80 / 100
}
h := opts.Height
if h <= 0 {
h = r.H * 80 / 100
}
if w > r.W-3 {
w = r.W - 3
}
if h > r.H-2 {
h = r.H - 2
}
if w < 5 {
w = 5
}
if h < 3 {
h = 3
}
x := (r.W-w)/2 + opts.X - 1 // Offset for shadow
y := (r.H-h)/2 + opts.Y
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
if x+w+1 > r.W {
x = r.W - w - 1
}
if y+h+1 > r.H {
y = r.H - h - 1
}
// Draw shadow
shadowR := r.Sub(x+1, y+1, w, h)
shadowR.Fill(shadowColor)
// Draw box over shadow
outer := r.Sub(x, y, w, h)
outer.BoxFilled(opts.Border, opts.Fg, opts.Bg)
result := OverlayResult{
Outer: outer,
TitleY: -1,
}
contentX := 1
contentY := 1
contentW := w - 2
contentH := h - 2
if opts.Title != "" && contentW > 2 {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Bg
}
for i := 0; i < contentW; i++ {
outer.Cell(contentX+i, contentY, ' ', titleFg, titleBg, terminal.AttrNone)
}
title := opts.Title
if RuneLen(title) > contentW-2 {
title = Truncate(title, contentW-2)
}
titleX := contentX + (contentW-RuneLen(title))/2
outer.Text(titleX, contentY, title, titleFg, titleBg, terminal.AttrBold)
result.TitleY = contentY
contentY++
contentH--
}
if contentH < 1 {
contentH = 1
}
result.Content = outer.Sub(contentX, contentY, contentW, contentH)
return result
}
func (r Region) renderBorderTitleOverlay(opts OverlayOpts) OverlayResult {
r.BoxFilled(opts.Border, opts.Fg, opts.Bg)
result := OverlayResult{
Outer: r,
TitleY: 0,
}
if opts.Title != "" && r.W > 6 {
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Fg
}
title := " " + opts.Title + " "
if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4)
}
titleX := (r.W - RuneLen(title)) / 2
r.Text(titleX, 0, title, titleFg, opts.Bg, terminal.AttrBold)
}
result.Content = r.Sub(1, 1, r.W-2, r.H-2)
return result
}
// OverlayState manages overlay visibility and content state
type OverlayState struct {
Visible bool
Opts OverlayOpts
Data any // Application-specific state
}
// NewOverlayState creates hidden overlay state
func NewOverlayState(opts OverlayOpts) *OverlayState {
return &OverlayState{
Visible: false,
Opts: opts,
}
}
// Show makes overlay visible
func (o *OverlayState) Show() {
o.Visible = true
}
// Hide makes overlay invisible
func (o *OverlayState) Hide() {
o.Visible = false
}
// Toggle switches visibility
func (o *OverlayState) Toggle() {
o.Visible = !o.Visible
}
+69
View File
@@ -0,0 +1,69 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// PaneOpts configures pane rendering
type PaneOpts struct {
Title string
Border LineType
BorderFg terminal.RGB
Bg terminal.RGB
TitleFg terminal.RGB
}
// Pane draws bordered pane with optional title, returns content region
// Content region is inside border, below title row if present
func (r Region) Pane(opts PaneOpts) Region {
if r.W < 3 || r.H < 3 {
return r.Sub(1, 1, 0, 0)
}
// Fill background
r.Fill(opts.Bg)
// Draw border
r.Box(opts.Border, opts.BorderFg)
// Title on top edge
headerH := 0
if opts.Title != "" {
headerH = 1
title := " " + opts.Title + " "
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)
}
}
// Return content region (inside border, below title)
return r.Sub(1, 1+headerH, r.W-2, r.H-2-headerH)
}
// TitledPane fills region with background, draws centered title at top, returns content region
// Content region starts at row 1 with full width
func (r Region) TitledPane(title string, titleFg, bg terminal.RGB) Region {
r.Fill(bg)
if title != "" && r.H > 0 {
r.TextCenter(0, title, titleFg, bg, terminal.AttrBold)
}
if r.H <= 1 {
return r.Sub(0, 0, r.W, 0)
}
return r.Sub(0, 1, r.W, r.H-1)
}
// TitledPaneFocused is TitledPane with focus-dependent background
func (r Region) TitledPaneFocused(title string, titleFg, bg, focusBg terminal.RGB, focused bool) Region {
if focused {
bg = focusBg
}
return r.TitledPane(title, titleFg, bg)
}
+124
View File
@@ -0,0 +1,124 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// Progress bar characters
const (
progressFull = '█'
progressEmpty = '░'
progressHalf = '▌'
)
// Progress draws horizontal progress bar (0.0-1.0)
func (r Region) Progress(x, y, w int, pct float64, fg, bg terminal.RGB) {
if y < 0 || y >= r.H || w <= 0 {
return
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(w) * pct)
remainder := float64(w)*pct - float64(filled)
for i := 0; i < w; i++ {
if x+i >= r.W {
break
}
var ch rune
if i < filled {
ch = progressFull
} else if i == filled && remainder >= 0.5 {
ch = progressHalf
} else {
ch = progressEmpty
}
r.Cell(x+i, y, ch, fg, bg, terminal.AttrNone)
}
}
// ProgressV draws vertical progress bar (fills bottom-up)
func (r Region) ProgressV(x, y, h int, pct float64, fg, bg terminal.RGB) {
if x < 0 || x >= r.W || h <= 0 {
return
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(h) * pct)
for i := 0; i < h; i++ {
if y+i >= r.H {
break
}
var ch rune
// Fill from bottom up
if h-1-i < filled {
ch = progressFull
} else {
ch = progressEmpty
}
r.Cell(x, y+i, ch, fg, bg, terminal.AttrNone)
}
}
// Spinner draws spinner character based on frame counter
func (r Region) Spinner(x, y int, frame int, fg terminal.RGB) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
idx := frame % len(spinnerFrames)
if idx < 0 {
idx = -idx
}
r.Cell(x, y, spinnerFrames[idx], fg, terminal.RGB{}, terminal.AttrNone)
}
// Gauge draws labeled gauge with percentage
func (r Region) Gauge(x, y, w int, value, max int, fg, bg terminal.RGB) {
if w < 5 || y < 0 || y >= r.H {
return
}
var pct float64
if max > 0 {
pct = float64(value) / float64(max)
}
if pct > 1 {
pct = 1
}
if pct < 0 {
pct = 0
}
// Format: [████░░░░] 75%
labelW := 5 // " XXX%" or " 100%"
barW := w - labelW - 2
if barW < 1 {
barW = 1
}
r.Cell(x, y, '[', fg, bg, terminal.AttrNone)
r.Progress(x+1, y, barW, pct, fg, bg)
r.Cell(x+1+barW, y, ']', fg, bg, terminal.AttrNone)
pctInt := int(pct * 100)
var label string
if pctInt >= 100 {
label = " 100%"
} else if pctInt >= 10 {
label = " " + string(rune('0'+pctInt/10)) + string(rune('0'+pctInt%10)) + "%"
} else {
label = " " + string(rune('0'+pctInt)) + "%"
}
r.Text(x+2+barW, y, label, fg, bg, terminal.AttrNone)
}
+466
View File
@@ -0,0 +1,466 @@
package tui
import "github.com/lixenwraith/terminal"
// ProgressType specifies progress indicator variant
type ProgressType uint8
const (
ProgressSpinner ProgressType = iota // Animated spinner
ProgressDeterminate // Bar with percentage
ProgressIndeterminate // Marquee animation
ProgressPulse // Pulsing bar
ProgressDots // Animated dots
)
// ProgressStyle defines visual appearance
type ProgressStyle uint8
const (
ProgressStyleMinimal ProgressStyle = iota // Text only
ProgressStyleBox // Single border
ProgressStyleDouble // Double border
ProgressStyleRounded // Rounded border
ProgressStyleShadow // Box with shadow
ProgressStyleNeon // Bright colors
ProgressStyleRetro // Block characters
)
// SpinnerStyle defines spinner animation type
type SpinnerStyle uint8
const (
SpinnerBraille SpinnerStyle = iota // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
SpinnerDots // ⣾⣽⣻⢿⡿⣟⣯⣷
SpinnerLine // |/-\
SpinnerBlock // ▖▘▝▗
SpinnerCircle // ◐◓◑◒
SpinnerArc // ◜◠◝◞◡◟
SpinnerBounce // ⠁⠂⠄⠂
SpinnerGrow // ▁▃▄▅▆▇█▇▆▅▄▃
)
// Spinner frame sets
var spinnerSets = map[SpinnerStyle][]rune{
SpinnerBraille: {'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'},
SpinnerDots: {'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'},
SpinnerLine: {'|', '/', '-', '\\'},
SpinnerBlock: {'▖', '▘', '▝', '▗'},
SpinnerCircle: {'◐', '◓', '◑', '◒'},
SpinnerArc: {'◜', '◠', '◝', '◞', '◡', '◟'},
SpinnerBounce: {'⠁', '⠂', '⠄', '⠂'},
SpinnerGrow: {'▁', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'},
}
// BarStyle defines progress bar appearance
type BarStyle uint8
const (
BarStyleBlock BarStyle = iota // █░
BarStyleShade // ▓▒░
BarStyleArrow // =>-
BarStyleDot // ●○
BarStyleBracket // [### ]
BarStylePipe // |=== |
BarStyleThin // ━╺
BarStyleThick // ▰▱
)
// Bar character sets: [filled, partial, empty]
var barCharSets = map[BarStyle][3]rune{
BarStyleBlock: {'█', '▌', '░'},
BarStyleShade: {'▓', '▒', '░'},
BarStyleArrow: {'=', '>', '-'},
BarStyleDot: {'●', '◐', '○'},
BarStyleBracket: {'#', '#', ' '},
BarStylePipe: {'=', '=', ' '},
BarStyleThin: {'━', '╸', '╺'},
BarStyleThick: {'▰', '▰', '▱'},
}
// ProgressOverlayOpts configures progress overlay
type ProgressOverlayOpts struct {
Title string
Message string
Type ProgressType
Style ProgressStyle
SpinnerStyle SpinnerStyle
BarStyle BarStyle
Progress float64 // 0.0-1.0 for determinate
Frame int // Animation frame counter
ShowPercent bool // Show percentage text
ShowETA string // Optional ETA string
Width int // Overlay width, 0 = auto
Cancelable bool // Show cancel hint
CancelKey string // e.g., "Esc"
Fg terminal.RGB
Bg terminal.RGB
BarFg terminal.RGB
BarBg terminal.RGB
AccentFg terminal.RGB // Spinner/highlight color
}
// DefaultProgressOpts returns sensible defaults
func DefaultProgressOpts(title, message string, ptype ProgressType) ProgressOverlayOpts {
return ProgressOverlayOpts{
Title: title,
Message: message,
Type: ptype,
Style: ProgressStyleBox,
SpinnerStyle: SpinnerBraille,
BarStyle: BarStyleBlock,
ShowPercent: true,
Width: 40,
Fg: terminal.RGB{R: 220, G: 220, B: 220},
Bg: terminal.RGB{R: 30, G: 30, B: 40},
BarFg: terminal.RGB{R: 80, G: 160, B: 255},
BarBg: terminal.RGB{R: 50, G: 50, B: 60},
AccentFg: terminal.RGB{R: 100, G: 200, B: 255},
}
}
// ProgressOverlay renders centered progress overlay
func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
if r.W < 10 || r.H < 5 {
return Region{}
}
// Calculate overlay dimensions
overlayW := opts.Width
if overlayW == 0 {
overlayW = 40
}
if overlayW > r.W-4 {
overlayW = r.W - 4
}
// Height: title + message + progress + cancel hint
overlayH := 3 // border top/bottom + 1 content
if opts.Title != "" {
overlayH++ // Title takes space on border
}
if opts.Message != "" {
overlayH++
}
if opts.Type != ProgressSpinner && opts.Type != ProgressDots {
overlayH++ // Progress bar row
}
if opts.Cancelable {
overlayH++
}
if overlayH > r.H-2 {
overlayH = r.H - 2
}
// Center overlay
overlay := Center(r, overlayW, overlayH)
// Determine border type
var borderLine LineType
switch opts.Style {
case ProgressStyleMinimal:
borderLine = LineNone
case ProgressStyleBox:
borderLine = LineSingle
case ProgressStyleDouble:
borderLine = LineDouble
case ProgressStyleRounded:
borderLine = LineRounded
case ProgressStyleShadow:
// Draw shadow first
shadow := r.Sub(overlay.X-r.X+1, overlay.Y-r.Y+1, overlayW, overlayH)
shadow.Fill(terminal.RGB{R: 10, G: 10, B: 15})
borderLine = LineSingle
case ProgressStyleNeon:
borderLine = LineDouble
opts.AccentFg = terminal.RGB{R: 0, G: 255, B: 200}
opts.BarFg = terminal.RGB{R: 255, G: 0, B: 255}
case ProgressStyleRetro:
borderLine = LineHeavy
opts.BarStyle = BarStyleBlock
}
// Draw frame
overlay.BoxFilled(borderLine, opts.Fg, opts.Bg)
// Title on border
if opts.Title != "" {
title := " " + opts.Title + " "
titleLen := RuneLen(title)
if titleLen > overlayW-4 {
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)
}
}
// Content area
content := overlay.Inset(1)
y := 0
// Spinner/dots for spinner types - inline with message
if opts.Type == ProgressSpinner || opts.Type == ProgressDots {
spinnerChar := r.getSpinnerChar(opts)
if content.W > 2 {
content.Cell(0, y, spinnerChar, opts.AccentFg, opts.Bg, terminal.AttrBold)
}
// Message after spinner
if opts.Message != "" {
msg := opts.Message
availW := content.W - 2
if RuneLen(msg) > availW {
msg = Truncate(msg, availW)
}
content.Text(2, y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
}
y++
} else {
// Message on its own line
if opts.Message != "" {
msg := opts.Message
if RuneLen(msg) > content.W {
msg = Truncate(msg, content.W)
}
content.TextCenter(y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
y++
}
// Progress bar
if y < content.H {
r.renderProgressBar(content.Sub(0, y, content.W, 1), opts)
y++
}
}
// Cancel hint
if opts.Cancelable && y < content.H {
hint := opts.CancelKey + " to cancel"
if opts.CancelKey == "" {
hint = "Esc to cancel"
}
content.TextCenter(y, hint, terminal.RGB{R: 120, G: 120, B: 130}, opts.Bg, terminal.AttrDim)
}
return overlay
}
func (r Region) getSpinnerChar(opts ProgressOverlayOpts) rune {
frames := spinnerSets[opts.SpinnerStyle]
if len(frames) == 0 {
frames = spinnerSets[SpinnerBraille]
}
idx := opts.Frame % len(frames)
if idx < 0 {
idx = -idx
}
return frames[idx]
}
func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
if bar.W < 3 || bar.H < 1 {
return
}
barW := bar.W
labelW := 0
// Reserve space for percentage
if opts.ShowPercent {
labelW = 5 // " 100%"
barW -= labelW
}
// Reserve space for ETA
if opts.ShowETA != "" {
etaW := RuneLen(opts.ShowETA) + 1
barW -= etaW
}
if barW < 3 {
barW = 3
}
chars := barCharSets[opts.BarStyle]
switch opts.Type {
case ProgressDeterminate:
pct := opts.Progress
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(barW) * pct)
remainder := float64(barW)*pct - float64(filled)
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
if x < filled {
ch = chars[0]
fg = opts.BarFg
} else if x == filled && remainder >= 0.5 {
ch = chars[1]
fg = opts.BarFg
} else {
ch = chars[2]
fg = opts.BarBg
}
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
}
// Percentage
if opts.ShowPercent {
pctStr := formatPercent(int(pct * 100))
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
}
case ProgressIndeterminate:
// Marquee effect
pos := opts.Frame % (barW * 2)
if pos >= barW {
pos = barW*2 - pos - 1
}
markerW := barW / 4
if markerW < 2 {
markerW = 2
}
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
if x >= pos && x < pos+markerW {
ch = chars[0]
fg = opts.BarFg
} else {
ch = chars[2]
fg = opts.BarBg
}
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
}
case ProgressPulse:
// Pulsing intensity based on frame
pulsePhase := opts.Frame % 20
intensity := float64(pulsePhase) / 20.0
if pulsePhase > 10 {
intensity = 1.0 - float64(pulsePhase-10)/10.0
}
pct := opts.Progress
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(barW) * pct)
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
if x < filled {
ch = chars[0]
// Pulse the color
fg = terminal.RGB{
R: uint8(float64(opts.BarFg.R) * (0.5 + intensity*0.5)),
G: uint8(float64(opts.BarFg.G) * (0.5 + intensity*0.5)),
B: uint8(float64(opts.BarFg.B) * (0.5 + intensity*0.5)),
}
} else {
ch = chars[2]
fg = opts.BarBg
}
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
}
if opts.ShowPercent {
pctStr := formatPercent(int(pct * 100))
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
}
}
// ETA
if opts.ShowETA != "" {
etaX := bar.W - RuneLen(opts.ShowETA)
bar.Text(etaX, 0, opts.ShowETA, terminal.RGB{R: 150, G: 150, B: 160}, opts.Bg, terminal.AttrDim)
}
}
func formatPercent(pct int) string {
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
if pct == 100 {
return "100%"
}
if pct >= 10 {
return " " + string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
}
return " " + string(rune('0'+pct)) + "%"
}
// ProgressState manages progress overlay state
type ProgressState struct {
Visible bool
Opts ProgressOverlayOpts
Frame int
Progress float64
}
// NewProgressState creates progress overlay state
func NewProgressState(opts ProgressOverlayOpts) *ProgressState {
return &ProgressState{
Visible: true,
Opts: opts,
Progress: opts.Progress,
}
}
// Tick advances animation frame
func (p *ProgressState) Tick() {
p.Frame++
p.Opts.Frame = p.Frame
}
// SetProgress updates progress value (0.0-1.0)
func (p *ProgressState) SetProgress(pct float64) {
p.Progress = pct
p.Opts.Progress = pct
}
// SetMessage updates message text
func (p *ProgressState) SetMessage(msg string) {
p.Opts.Message = msg
}
// SetETA updates ETA string
func (p *ProgressState) SetETA(eta string) {
p.Opts.ShowETA = eta
}
// Complete marks progress as done
func (p *ProgressState) Complete() {
p.Progress = 1.0
p.Opts.Progress = 1.0
}
// Dismiss hides the progress overlay
func (p *ProgressState) Dismiss() {
p.Visible = false
}
// Show displays the progress overlay
func (p *ProgressState) Show() {
p.Visible = true
}
+112
View File
@@ -0,0 +1,112 @@
package tui
import "github.com/lixenwraith/terminal"
// Region represents a rectangular area within a cell buffer
// All coordinates are relative to the region's origin
type Region struct {
Cells []terminal.Cell
TotalW int // Total width of the underlying cell buffer
X, Y int // Absolute position in cell buffer
W, H int // Region dimensions
}
// NewRegion creates a region referencing a cell slice with bounds
func NewRegion(cells []terminal.Cell, totalW, x, y, w, h int) Region {
return Region{
Cells: cells,
TotalW: totalW,
X: x,
Y: y,
W: w,
H: h,
}
}
// Sub returns a nested region with coordinates relative to parent, result is clipped to parent bounds
func (r Region) Sub(x, y, w, h int) Region {
// Clip to parent bounds
if x < 0 {
w += x
x = 0
}
if y < 0 {
h += y
y = 0
}
if x+w > r.W {
w = r.W - x
}
if y+h > r.H {
h = r.H - y
}
if w < 0 {
w = 0
}
if h < 0 {
h = 0
}
return Region{
Cells: r.Cells,
TotalW: r.TotalW,
X: r.X + x,
Y: r.Y + y,
W: w,
H: h,
}
}
// Inset returns a region shrunk by n cells on all sides
func (r Region) Inset(n int) Region {
return r.Sub(n, n, r.W-2*n, r.H-2*n)
}
// Cell sets a single cell with bounds checking
func (r Region) Cell(x, y int, ch rune, fg, bg terminal.RGB, attr terminal.Attr) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
absX := r.X + x
absY := r.Y + y
// Bounds check against the physical buffer dimensions
if uint(absX) >= uint(r.TotalW) {
return
}
idx := absY*r.TotalW + absX
// Single bounds check for the backing slice
if uint(idx) < uint(len(r.Cells)) {
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
}
}
// Fill fills entire region with background color
func (r Region) Fill(bg terminal.RGB) {
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
}
}
}
// Clear fills region with spaces and zero colors
func (r Region) Clear() {
r.Fill(terminal.RGB{})
}
// Width returns region width
func (r Region) Width() int {
return r.W
}
// Height returns region height
func (r Region) Height() int {
return r.H
}
// Bounds returns absolute position and dimensions
func (r Region) Bounds() (x, y, w, h int) {
return r.X, r.Y, r.W, r.H
}
+83
View File
@@ -0,0 +1,83 @@
package tui
import "github.com/lixenwraith/terminal"
// Spinner frames
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
// Text renders text at position, truncates at region edge
func (r Region) Text(x, y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
if y < 0 || y >= r.H {
return
}
col := 0
for _, ch := range s {
if x+col >= r.W {
break
}
if x+col >= 0 {
r.Cell(x+col, y, ch, fg, bg, attr)
}
col++
}
}
// TextStyled renders text using Style struct
func (r Region) TextStyled(x, y int, s string, style Style) {
if y < 0 || y >= r.H {
return
}
col := 0
for _, ch := range s {
if x+col >= r.W {
break
}
if x+col >= 0 {
r.Cell(x+col, y, ch, style.Fg, style.Bg, style.Attr)
}
col++
}
}
// TextRight renders text right-aligned on row
func (r Region) TextRight(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
x := r.W - RuneLen(s)
r.Text(x, y, s, fg, bg, attr)
}
// TextCenter renders text centered on row
func (r Region) TextCenter(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
x := (r.W - RuneLen(s)) / 2
r.Text(x, y, s, fg, bg, attr)
}
// TextBlock renders wrapped text within region bounds, returns number of lines rendered
func (r Region) TextBlock(x, y int, text string, fg, bg terminal.RGB, attr terminal.Attr) int {
if x >= r.W || y >= r.H || text == "" {
return 0
}
availW := r.W - x
if availW < 1 {
return 0
}
lines := WrapText(text, availW)
rendered := 0
for i, line := range lines {
lineY := y + i
if lineY >= r.H {
break
}
r.Text(x, lineY, line, fg, bg, attr)
rendered++
}
return rendered
}
// TextBlockStyled renders wrapped text using Style struct, returns number of lines rendered
func (r Region) TextBlockStyled(x, y int, text string, style Style) int {
return r.TextBlock(x, y, text, style.Fg, style.Bg, style.Attr)
}
+76
View File
@@ -0,0 +1,76 @@
package tui
// --- Scroll position calculation ---
// AdjustScroll returns new scroll offset keeping cursor visible
func AdjustScroll(cursor, scroll, visible, total int) int {
if total <= visible {
return 0
}
if cursor < scroll {
return cursor
}
if cursor >= scroll+visible {
return cursor - visible + 1
}
return scroll
}
// ScrollPercent returns scroll position as 0-100 percentage
func ScrollPercent(scroll, visible, total int) int {
if total <= visible {
return 0
}
maxScroll := total - visible
if maxScroll <= 0 {
return 0
}
pct := (scroll * 100) / maxScroll
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
return pct
}
// --- Clamping utilities ---
// ClampScroll ensures scroll offset is within valid range
func ClampScroll(scroll, visible, total int) int {
if total <= visible {
return 0
}
maxScroll := total - visible
if scroll < 0 {
return 0
}
if scroll > maxScroll {
return maxScroll
}
return scroll
}
// ClampCursor ensures cursor is within valid range
func ClampCursor(cursor, total int) int {
if total <= 0 {
return 0
}
if cursor < 0 {
return 0
}
if cursor >= total {
return total - 1
}
return cursor
}
// PageDelta returns recommended page scroll amount
func PageDelta(visible int) int {
delta := visible / 2
if delta < 1 {
delta = 1
}
return delta
}
+115
View File
@@ -0,0 +1,115 @@
package tui
// ScrollState tracks scroll position for a scrollable container
type ScrollState struct {
Offset int // First visible item index
Total int // Total item count
Visible int // Visible item count (viewport height)
Selection int // Currently selected item, -1 if none
}
// NewScrollState creates initialized scroll state
func NewScrollState(total, visible int) *ScrollState {
return &ScrollState{
Total: total,
Visible: visible,
Selection: -1,
}
}
// --- Scroll manipulation ---
// ScrollBy adjusts offset by delta, clamping to valid range
func (s *ScrollState) ScrollBy(delta int) {
s.Offset += delta
s.Clamp()
}
// ScrollTo sets offset to specific position
func (s *ScrollState) ScrollTo(pos int) {
s.Offset = pos
s.Clamp()
}
// EnsureVisible adjusts offset to make item at pos visible
func (s *ScrollState) EnsureVisible(pos int) {
if pos < s.Offset {
s.Offset = pos
} else if pos >= s.Offset+s.Visible {
s.Offset = pos - s.Visible + 1
}
s.Clamp()
}
// Clamp ensures offset is within valid range
func (s *ScrollState) Clamp() {
s.Offset = ClampScroll(s.Offset, s.Visible, s.Total)
}
// --- Page navigation ---
// PageUp scrolls up by half visible height
func (s *ScrollState) PageUp() {
s.ScrollBy(-PageDelta(s.Visible))
}
// PageDown scrolls down by half visible height
func (s *ScrollState) PageDown() {
s.ScrollBy(PageDelta(s.Visible))
}
// --- State updates ---
// SetTotal updates total count and reclamps
func (s *ScrollState) SetTotal(total int) {
s.Total = total
s.Clamp()
if s.Selection >= total {
s.Selection = total - 1
}
}
// SetVisible updates visible count and reclamps
func (s *ScrollState) SetVisible(visible int) {
s.Visible = visible
s.Clamp()
}
// --- Positions queries ---
// AtTop returns true if scrolled to top
func (s *ScrollState) AtTop() bool {
return s.Offset == 0
}
// AtBottom returns true if scrolled to bottom
func (s *ScrollState) AtBottom() bool {
if s.Total <= s.Visible {
return true
}
return s.Offset >= s.Total-s.Visible
}
// --- Selection management ---
// Select sets selection and ensures it's visible
func (s *ScrollState) Select(idx int) {
s.Selection = ClampCursor(idx, s.Total)
s.EnsureVisible(s.Selection)
}
// SelectNext moves selection down
func (s *ScrollState) SelectNext() {
if s.Selection < s.Total-1 {
s.Selection++
s.EnsureVisible(s.Selection)
}
}
// SelectPrev moves selection up
func (s *ScrollState) SelectPrev() {
if s.Selection > 0 {
s.Selection--
s.EnsureVisible(s.Selection)
}
}
+78
View File
@@ -0,0 +1,78 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// ScrollBar draws vertical scrollbar track with thumb
func ScrollBar(r Region, x int, offset, visible, total int, fg terminal.RGB) {
if x < 0 || x >= r.W || r.H < 1 {
return
}
trackH := r.H
if total <= visible || trackH < 3 {
// No scrolling needed or track too small
for y := 0; y < trackH; y++ {
r.Cell(x, y, '│', fg, terminal.RGB{}, terminal.AttrDim)
}
return
}
// Calculate thumb size and position
thumbH := (visible * trackH) / total
if thumbH < 1 {
thumbH = 1
}
if thumbH > trackH {
thumbH = trackH
}
maxScroll := total - visible
thumbY := 0
if maxScroll > 0 {
thumbY = (offset * (trackH - thumbH)) / maxScroll
}
if thumbY < 0 {
thumbY = 0
}
if thumbY+thumbH > trackH {
thumbY = trackH - thumbH
}
// Draw track and thumb
for y := 0; y < trackH; y++ {
var ch rune
if y >= thumbY && y < thumbY+thumbH {
ch = '█'
} else {
ch = '░'
}
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
}
}
// ScrollIndicator draws compact indicator text (Top/Bot/XX%)
func ScrollIndicator(r Region, y int, offset, visible, total int, fg terminal.RGB) {
if y < 0 || y >= r.H {
return
}
var text string
if total <= visible || offset <= 0 {
text = "Top"
} else if offset+visible >= total {
text = "Bot"
} else {
pct := ScrollPercent(offset, visible, total)
if pct >= 100 {
text = "99%"
} else if pct >= 10 {
text = string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
} else {
text = " " + string(rune('0'+pct)) + "%"
}
}
r.TextRight(y, text, fg, terminal.RGB{}, terminal.AttrDim)
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import "github.com/lixenwraith/terminal"
// SparklineChars provides 8-level vertical resolution
var SparklineChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
// SparklineOpts configures sparkline rendering
type SparklineOpts struct {
Min, Max float64 // Range bounds, auto-scale if both 0
Style Style
}
// Sparkline renders an inline graph of values, values are mapped to 8-level block characters
func (r Region) Sparkline(x, y, width int, values []float64, opts SparklineOpts) {
if y < 0 || y >= r.H || width <= 0 || len(values) == 0 {
return
}
// Determine range
min, max := opts.Min, opts.Max
if min == 0 && max == 0 {
min, max = values[0], values[0]
for _, v := range values {
if v < min {
min = v
}
if v > max {
max = v
}
}
}
// Handle flat line
rangeV := max - min
if rangeV == 0 {
rangeV = 1
}
// Sample or use last N values if more than width
var sampled []float64
if len(values) <= width {
sampled = values
} else {
sampled = values[len(values)-width:]
}
// Render each value
for i, v := range sampled {
if x+i >= r.W {
break
}
// Normalize to 0-1
norm := (v - min) / rangeV
if norm < 0 {
norm = 0
}
if norm > 1 {
norm = 1
}
// Map to character index (0-7)
idx := int(norm * 7.99)
if idx > 7 {
idx = 7
}
r.Cell(x+i, y, SparklineChars[idx], opts.Style.Fg, opts.Style.Bg, opts.Style.Attr)
}
// Pad remaining width with lowest char if values shorter than width
for i := len(sampled); i < width && x+i < r.W; i++ {
r.Cell(x+i, y, SparklineChars[0], opts.Style.Fg, opts.Style.Bg, terminal.AttrDim)
}
}
// SparklineV renders vertical sparkline (bottom to top)
func (r Region) SparklineV(x, y, height int, values []float64, opts SparklineOpts) {
if x < 0 || x >= r.W || height <= 0 || len(values) == 0 {
return
}
min, max := opts.Min, opts.Max
if min == 0 && max == 0 {
min, max = values[0], values[0]
for _, v := range values {
if v < min {
min = v
}
if v > max {
max = v
}
}
}
rangeV := max - min
if rangeV == 0 {
rangeV = 1
}
var sampled []float64
if len(values) <= height {
sampled = values
} else {
sampled = values[len(values)-height:]
}
// Render bottom-up
for i, v := range sampled {
yPos := y + height - 1 - i
if yPos < y || yPos >= r.H {
continue
}
norm := (v - min) / rangeV
if norm < 0 {
norm = 0
}
if norm > 1 {
norm = 1
}
idx := int(norm * 7.99)
if idx > 7 {
idx = 7
}
r.Cell(x, yPos, SparklineChars[idx], opts.Style.Fg, opts.Style.Bg, opts.Style.Attr)
}
}
+210
View File
@@ -0,0 +1,210 @@
package tui
import "github.com/lixenwraith/terminal"
// BarSection represents one segment of a status bar
type BarSection struct {
Label string
Value string
LabelStyle Style
ValueStyle Style
Priority int // Higher = survives truncation
}
// BarAlign specifies status bar alignment mode
type BarAlign uint8
const (
BarAlignRight BarAlign = iota // Pack sections from right
BarAlignLeft // Pack sections from left
BarAlignDistribute // Evenly space sections
BarAlignCenter // Center all sections as a group
)
// BarOpts configures status bar rendering
type BarOpts struct {
Separator string // Between sections, default " │ "
SepStyle Style // Separator styling
Bg terminal.RGB
Align BarAlign
Padding int // Left/right padding, default 1
}
// DefaultBarOpts returns sensible defaults
func DefaultBarOpts() BarOpts {
return BarOpts{
Separator: " │ ",
SepStyle: Style{Fg: terminal.RGB{R: 80, G: 80, B: 100}},
Padding: 1,
Align: BarAlignRight,
}
}
// StatusBar renders horizontal status bar on row y
func (r Region) StatusBar(y int, sections []BarSection, opts BarOpts) {
if y < 0 || y >= r.H || len(sections) == 0 {
return
}
if opts.Separator == "" {
opts.Separator = " │ "
}
if opts.Padding == 0 {
opts.Padding = 1
}
// Fill background
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, opts.Bg, terminal.AttrNone)
}
sepLen := RuneLen(opts.Separator)
// Calculate total width needed
totalW := 0
sectionWidths := make([]int, len(sections))
for i, sec := range sections {
w := RuneLen(sec.Label) + RuneLen(sec.Value)
sectionWidths[i] = w
totalW += w
if i < len(sections)-1 {
totalW += sepLen
}
}
availW := r.W - opts.Padding*2
// Truncate low-priority sections if needed
if totalW > availW {
sections, sectionWidths = truncateSections(sections, sectionWidths, sepLen, availW)
totalW = 0
for i, w := range sectionWidths {
totalW += w
if i < len(sectionWidths)-1 {
totalW += sepLen
}
}
}
// Calculate starting position based on alignment
var x int
switch opts.Align {
case BarAlignLeft:
x = opts.Padding
case BarAlignRight:
x = r.W - opts.Padding - totalW
if x < opts.Padding {
x = opts.Padding
}
case BarAlignDistribute:
x = opts.Padding
// Handled specially below
case BarAlignCenter:
x = (r.W - totalW) / 2
if x < opts.Padding {
x = opts.Padding
}
}
// Render sections
if opts.Align == BarAlignDistribute && len(sections) > 1 {
gap := (availW - totalW) / (len(sections) - 1)
if gap < 0 {
gap = 0
}
for i, sec := range sections {
x = r.renderBarSection(x, y, sec, opts)
if i < len(sections)-1 {
x += gap
}
}
} else {
for i, sec := range sections {
x = r.renderBarSection(x, y, sec, opts)
if i < len(sections)-1 {
// Separator
for j, ch := range opts.Separator {
if x+j < r.W-opts.Padding {
r.Cell(x+j, y, ch, opts.SepStyle.Fg, opts.Bg, opts.SepStyle.Attr)
}
}
x += sepLen
}
}
}
}
func (r Region) renderBarSection(x, y int, sec BarSection, opts BarOpts) int {
// Label
for _, ch := range sec.Label {
if x >= r.W-opts.Padding {
break
}
r.Cell(x, y, ch, sec.LabelStyle.Fg, opts.Bg, sec.LabelStyle.Attr)
x++
}
// Value
for _, ch := range sec.Value {
if x >= r.W-opts.Padding {
break
}
r.Cell(x, y, ch, sec.ValueStyle.Fg, opts.Bg, sec.ValueStyle.Attr)
x++
}
return x
}
// truncateSections removes lowest priority sections until fit
func truncateSections(sections []BarSection, widths []int, sepLen, availW int) ([]BarSection, []int) {
// Copy to avoid modifying original
secs := make([]BarSection, len(sections))
copy(secs, sections)
ws := make([]int, len(widths))
copy(ws, widths)
for {
total := 0
for i, w := range ws {
total += w
if i < len(ws)-1 {
total += sepLen
}
}
if total <= availW || len(secs) <= 1 {
break
}
// Find lowest priority
minIdx := 0
minPrio := secs[0].Priority
for i, sec := range secs {
if sec.Priority < minPrio {
minPrio = sec.Priority
minIdx = i
}
}
// RemoveEntityAt it
secs = append(secs[:minIdx], secs[minIdx+1:]...)
ws = append(ws[:minIdx], ws[minIdx+1:]...)
}
return secs, ws
}
// QuickStatusBar renders simple label:value pairs right-aligned
func (r Region) QuickStatusBar(y int, pairs [][2]string, labelFg, valueFg, bg terminal.RGB) {
sections := make([]BarSection, len(pairs))
for i, p := range pairs {
sections[i] = BarSection{
Label: p[0],
Value: p[1],
LabelStyle: Style{Fg: labelFg},
ValueStyle: Style{Fg: valueFg},
}
}
r.StatusBar(y, sections, BarOpts{
Bg: bg,
Align: BarAlignRight,
})
}
+22
View File
@@ -0,0 +1,22 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// Style bundles foreground, background, and attributes for text rendering
type Style struct {
Fg terminal.RGB
Bg terminal.RGB
Attr terminal.Attr
}
// DefaultStyle returns style with zero values (transparent bg)
func DefaultStyle(fg terminal.RGB) Style {
return Style{Fg: fg}
}
// IsZero returns true if style has no colors or attributes set
func (s Style) IsZero() bool {
return s.Fg == (terminal.RGB{}) && s.Bg == (terminal.RGB{}) && s.Attr == terminal.AttrNone
}
+131
View File
@@ -0,0 +1,131 @@
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)
}
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)
}
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
}
+180
View File
@@ -0,0 +1,180 @@
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
func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Align, sep rune, style Style) {
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
}
for j, ch := range text {
if startX+j < r.W {
r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr)
}
}
x += w
// Column separator
if i < len(widths)-1 && x < r.W {
r.Cell(x, y, sep, style.Fg, style.Bg, terminal.AttrDim)
x++
}
}
}
+190
View File
@@ -0,0 +1,190 @@
package tui
import (
"unicode/utf8"
)
// --- Length calculation ---
// RuneLen returns display width (rune count, not byte count)
func RuneLen(s string) int {
return utf8.RuneCountInString(s)
}
// --- Truncation ---
// Truncate truncates string with … suffix if exceeds maxLen
func Truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= maxLen {
return s
}
// Boundary-safe truncation for UTF-8
count := 0
for i := range s {
if count == maxLen-1 {
return s[:i] + "…"
}
count++
}
return s
}
// TruncateLeft truncates with … prefix, keeps end of string
func TruncateLeft(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 1 {
return "…"
}
return "…" + string(runes[len(runes)-maxLen+1:])
}
// TruncateMiddle keeps start and end, … in middle
func TruncateMiddle(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return Truncate(s, maxLen)
}
// Split remaining space between start and end
// Favor start slightly: (maxLen-1)/2 for start, rest for end
startLen := (maxLen - 1) / 2
endLen := maxLen - 1 - startLen
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
}
// --- Padding ---
// PadRight pads string with spaces to width
func PadRight(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return s
}
result := make([]rune, width)
copy(result, runes)
for i := len(runes); i < width; i++ {
result[i] = ' '
}
return string(result)
}
// PadLeft left-pads string with spaces to width
func PadLeft(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return s
}
result := make([]rune, width)
padding := width - len(runes)
for i := 0; i < padding; i++ {
result[i] = ' '
}
copy(result[padding:], runes)
return string(result)
}
// PadCenter centers string within width
func PadCenter(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return s
}
result := make([]rune, width)
leftPad := (width - len(runes)) / 2
for i := range result {
result[i] = ' '
}
copy(result[leftPad:], runes)
return string(result)
}
// --- Text wrapping ---
// WrapText wraps text at word boundaries to fit width
// Returns slice of lines, each no longer than width
func WrapText(s string, width int) []string {
if width <= 0 {
return nil
}
runes := []rune(s)
if len(runes) == 0 {
return []string{""}
}
var lines []string
lineStart := 0
lastSpace := -1
for i := 0; i <= len(runes); i++ {
// Check if we need to wrap
if i-lineStart >= width || i == len(runes) {
if i == len(runes) {
// End of string
if lineStart < len(runes) {
lines = append(lines, string(runes[lineStart:]))
}
break
}
// Need to wrap
wrapAt := i
if lastSpace > lineStart {
// Wrap at last space
wrapAt = lastSpace
}
lines = append(lines, string(runes[lineStart:wrapAt]))
// Skip space at wrap point
if wrapAt < len(runes) && runes[wrapAt] == ' ' {
lineStart = wrapAt + 1
} else {
lineStart = wrapAt
}
lastSpace = -1
}
// Track spaces for word wrapping
if i < len(runes) && runes[i] == ' ' {
lastSpace = i
}
}
if len(lines) == 0 {
lines = []string{""}
}
return lines
}
// --- Repetition ---
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
func RepeatRune(r rune, n int) string {
if n <= 0 {
return ""
}
runes := make([]rune, n)
for i := range runes {
runes[i] = r
}
return string(runes)
}
+171
View File
@@ -0,0 +1,171 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// TextFieldOpts configures text field rendering
type TextFieldOpts struct {
Placeholder string // Shown when empty
Prefix string // Left prompt (e.g., "> ")
Mask rune // Password mask, 0 = none
MaxLen int // Max runes, 0 = unlimited
Border LineType // Border style, LineNone = no border
Focused bool // Show cursor and accept input
Style TextFieldStyle
}
// DefaultTextFieldStyle returns default colors
func DefaultTextFieldStyle() TextFieldStyle {
return TextFieldStyle{
TextFg: terminal.RGB{R: 220, G: 220, B: 220},
TextBg: terminal.RGB{R: 30, G: 30, B: 40},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
PlaceholderFg: terminal.RGB{R: 100, G: 100, B: 110},
PrefixFg: terminal.RGB{R: 150, G: 150, B: 180},
BorderFg: terminal.RGB{R: 80, G: 80, B: 100},
}
}
// TextFieldStyle defines text field colors
type TextFieldStyle struct {
TextFg terminal.RGB
TextBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
PlaceholderFg terminal.RGB
PrefixFg terminal.RGB
BorderFg terminal.RGB
}
// TextField renders text field and returns content height used
func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
if r.W < 3 || r.H < 1 {
return 0
}
style := opts.Style
if style == (TextFieldStyle{}) {
style = DefaultTextFieldStyle()
}
// Calculate content area
contentY := 0
contentX := 0
contentW := r.W
contentH := 1
if opts.Border != LineNone {
if r.H < 3 {
return 0
}
r.Box(opts.Border, style.BorderFg)
contentY = 1
contentX = 1
contentW = r.W - 2
contentH = r.H - 2
if contentH > 1 {
contentH = 1
}
}
// Fill background
for x := contentX; x < contentX+contentW; x++ {
r.Cell(x, contentY, ' ', style.TextFg, style.TextBg, terminal.AttrNone)
}
x := contentX
// Prefix
if opts.Prefix != "" {
for _, ch := range opts.Prefix {
if x >= contentX+contentW {
break
}
r.Cell(x, contentY, ch, style.PrefixFg, style.TextBg, terminal.AttrNone)
x++
}
}
// Calculate viewport
viewportW := contentX + contentW - x
if viewportW < 1 {
return contentH + 2*boolToInt(opts.Border != LineNone)
}
// Adjust scroll
state.AdjustScroll(viewportW)
// Render text or placeholder
text := state.Text
isEmpty := len(text) == 0
if isEmpty && opts.Placeholder != "" && !opts.Focused {
// Placeholder
placeholder := opts.Placeholder
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)
}
} else {
// Scroll indicators
if state.Scroll > 0 && x > contentX {
r.Cell(x-1, contentY, '◀', style.PlaceholderFg, style.TextBg, terminal.AttrNone)
}
// Text content
for i := 0; i < viewportW; i++ {
runeIdx := state.Scroll + i
ch := ' '
if runeIdx < len(text) {
ch = text[runeIdx]
if opts.Mask != 0 {
ch = opts.Mask
}
}
fg := style.TextFg
bg := style.TextBg
// Cursor highlighting
if opts.Focused && runeIdx == state.Cursor {
fg = style.CursorFg
bg = style.CursorBg
}
r.Cell(x+i, contentY, ch, fg, bg, terminal.AttrNone)
}
// Cursor at end
if opts.Focused && state.Cursor == len(text) {
cursorX := x + state.Cursor - state.Scroll
if cursorX >= x && cursorX < contentX+contentW {
r.Cell(cursorX, contentY, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
// Right scroll indicator
if state.Scroll+viewportW < len(text) {
r.Cell(contentX+contentW-1, contentY, '▶', style.PlaceholderFg, style.TextBg, terminal.AttrNone)
}
}
if opts.Border != LineNone {
return 3
}
return 1
}
// boolToInt converts boolean to integer (0 or 1)
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+280
View File
@@ -0,0 +1,280 @@
package tui
import (
"unicode"
"github.com/lixenwraith/terminal"
)
// isWordChar returns true for word-constituent characters
func isWordChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
// TextFieldState holds editable text field state
type TextFieldState struct {
Text []rune
Cursor int // Positions before which cursor sits (0 = before first char)
Scroll int // First visible rune index
}
// NewTextFieldState creates initialized text field state
func NewTextFieldState(initial string) *TextFieldState {
runes := []rune(initial)
return &TextFieldState{
Text: runes,
Cursor: len(runes),
Scroll: 0,
}
}
// --- Value access ---
// Value returns current text as string
func (t *TextFieldState) Value() string {
return string(t.Text)
}
// SetValue replaces text and moves cursor to end
func (t *TextFieldState) SetValue(s string) {
t.Text = []rune(s)
t.Cursor = len(t.Text)
t.Scroll = 0
}
// Clear empties the field
func (t *TextFieldState) Clear() {
t.Text = nil
t.Cursor = 0
t.Scroll = 0
}
// --- Character insertion ---
// Insert adds rune at cursor position
func (t *TextFieldState) Insert(r rune) {
t.Text = append(t.Text[:t.Cursor], append([]rune{r}, t.Text[t.Cursor:]...)...)
t.Cursor++
}
// InsertString adds string at cursor position
func (t *TextFieldState) InsertString(s string) {
runes := []rune(s)
t.Text = append(t.Text[:t.Cursor], append(runes, t.Text[t.Cursor:]...)...)
t.Cursor += len(runes)
}
// --- Character deletion ---
// DeleteBackward removes rune before cursor
func (t *TextFieldState) DeleteBackward() bool {
if t.Cursor > 0 {
t.Text = append(t.Text[:t.Cursor-1], t.Text[t.Cursor:]...)
t.Cursor--
return true
}
return false
}
// DeleteForward removes rune at cursor
func (t *TextFieldState) DeleteForward() bool {
if t.Cursor < len(t.Text) {
t.Text = append(t.Text[:t.Cursor], t.Text[t.Cursor+1:]...)
return true
}
return false
}
// --- Word deletion ---
// DeleteWordBackward removes word before cursor
func (t *TextFieldState) DeleteWordBackward() bool {
if t.Cursor == 0 {
return false
}
// Skip trailing non-word chars
end := t.Cursor
for end > 0 && !isWordChar(t.Text[end-1]) {
end--
}
// Skip word chars
start := end
for start > 0 && isWordChar(t.Text[start-1]) {
start--
}
if start == t.Cursor {
start = t.Cursor - 1
}
t.Text = append(t.Text[:start], t.Text[t.Cursor:]...)
t.Cursor = start
return true
}
// DeleteWordForward removes word after cursor
func (t *TextFieldState) DeleteWordForward() bool {
if t.Cursor >= len(t.Text) {
return false
}
// Skip word chars
end := t.Cursor
for end < len(t.Text) && isWordChar(t.Text[end]) {
end++
}
// Skip trailing non-word chars
for end < len(t.Text) && !isWordChar(t.Text[end]) {
end++
}
if end == t.Cursor {
end = t.Cursor + 1
}
t.Text = append(t.Text[:t.Cursor], t.Text[end:]...)
return true
}
// DeleteToEnd removes from cursor to end
func (t *TextFieldState) DeleteToEnd() bool {
if t.Cursor < len(t.Text) {
t.Text = t.Text[:t.Cursor]
return true
}
return false
}
// DeleteToStart removes from start to cursor
func (t *TextFieldState) DeleteToStart() bool {
if t.Cursor > 0 {
t.Text = t.Text[t.Cursor:]
t.Cursor = 0
t.Scroll = 0
return true
}
return false
}
// --- Character movement ---
// MoveLeft moves cursor left
func (t *TextFieldState) MoveLeft() {
if t.Cursor > 0 {
t.Cursor--
}
}
// MoveRight moves cursor right
func (t *TextFieldState) MoveRight() {
if t.Cursor < len(t.Text) {
t.Cursor++
}
}
// --- Word movement ---
// MoveWordLeft moves cursor to previous word boundary
func (t *TextFieldState) MoveWordLeft() {
if t.Cursor == 0 {
return
}
// Skip non-word chars
for t.Cursor > 0 && !isWordChar(t.Text[t.Cursor-1]) {
t.Cursor--
}
// Skip word chars
for t.Cursor > 0 && isWordChar(t.Text[t.Cursor-1]) {
t.Cursor--
}
}
// MoveWordRight moves cursor to next word boundary
func (t *TextFieldState) MoveWordRight() {
if t.Cursor >= len(t.Text) {
return
}
// Skip word chars
for t.Cursor < len(t.Text) && isWordChar(t.Text[t.Cursor]) {
t.Cursor++
}
// Skip non-word chars
for t.Cursor < len(t.Text) && !isWordChar(t.Text[t.Cursor]) {
t.Cursor++
}
}
// --- Line movement ---
// MoveToStart moves cursor to beginning
func (t *TextFieldState) MoveToStart() {
t.Cursor = 0
}
// MoveToEnd moves cursor to end
func (t *TextFieldState) MoveToEnd() {
t.Cursor = len(t.Text)
}
// --- Scroll management ---
// AdjustScroll updates scroll to keep cursor visible within viewport width
func (t *TextFieldState) AdjustScroll(viewportW int) {
if viewportW <= 0 {
return
}
if t.Cursor < t.Scroll {
t.Scroll = t.Cursor
}
if t.Cursor >= t.Scroll+viewportW {
t.Scroll = t.Cursor - viewportW + 1
}
if t.Scroll < 0 {
t.Scroll = 0
}
}
// --- Input handling ---
// HandleKey processes keyboard input, returns true if state changed
func (t *TextFieldState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyLeft:
if mod&terminal.ModCtrl != 0 {
t.MoveWordLeft()
} else {
t.MoveLeft()
}
return true
case terminal.KeyRight:
if mod&terminal.ModCtrl != 0 {
t.MoveWordRight()
} else {
t.MoveRight()
}
return true
case terminal.KeyHome, terminal.KeyCtrlA:
t.MoveToStart()
return true
case terminal.KeyEnd, terminal.KeyCtrlE:
t.MoveToEnd()
return true
case terminal.KeyBackspace:
if mod&terminal.ModCtrl != 0 {
return t.DeleteWordBackward()
}
return t.DeleteBackward()
case terminal.KeyDelete:
if mod&terminal.ModCtrl != 0 {
return t.DeleteWordForward()
}
return t.DeleteForward()
case terminal.KeyCtrlK:
return t.DeleteToEnd()
case terminal.KeyCtrlU:
return t.DeleteToStart()
case terminal.KeyCtrlW:
return t.DeleteWordBackward()
case terminal.KeyRune:
if r >= 32 { // Printable
t.Insert(r)
return true
}
}
return false
}
+63
View File
@@ -0,0 +1,63 @@
package tui
import "github.com/lixenwraith/terminal"
// Theme defines semantic colors for TUI components
type Theme struct {
Bg terminal.RGB
Fg terminal.RGB
FocusBg terminal.RGB
CursorBg terminal.RGB
Selected terminal.RGB
Unselected terminal.RGB
Partial terminal.RGB
Error terminal.RGB
Warning terminal.RGB
Border terminal.RGB
HeaderBg terminal.RGB
HeaderFg terminal.RGB
StatusFg terminal.RGB
HintFg terminal.RGB
InputBg terminal.RGB
DirFg terminal.RGB
FileFg terminal.RGB
SymbolFg terminal.RGB
SyntaxComment terminal.RGB
SyntaxString terminal.RGB
SyntaxKeyword terminal.RGB
SyntaxType terminal.RGB
SyntaxNumber terminal.RGB
SyntaxSymbol terminal.RGB
}
// DefaultTheme provides reasonable defaults
var DefaultTheme = Theme{
Bg: terminal.RGB{R: 20, G: 20, B: 30},
Fg: terminal.RGB{R: 200, G: 200, B: 200},
FocusBg: terminal.RGB{R: 30, G: 35, B: 45},
CursorBg: terminal.RGB{R: 50, G: 50, B: 70},
Selected: terminal.RGB{R: 80, G: 200, B: 80},
Unselected: terminal.RGB{R: 100, G: 100, B: 100},
Partial: terminal.RGB{R: 80, G: 160, B: 220},
Error: terminal.RGB{R: 255, G: 80, B: 80},
Warning: terminal.RGB{R: 255, G: 80, B: 80},
Border: terminal.RGB{R: 60, G: 80, B: 100},
HeaderBg: terminal.RGB{R: 40, G: 60, B: 90},
HeaderFg: terminal.RGB{R: 255, G: 255, B: 255},
StatusFg: terminal.RGB{R: 140, G: 140, B: 140},
HintFg: terminal.RGB{R: 100, G: 180, B: 200},
InputBg: terminal.RGB{R: 30, G: 30, B: 50},
DirFg: terminal.RGB{R: 130, G: 170, B: 220},
FileFg: terminal.RGB{R: 200, G: 200, B: 200},
SymbolFg: terminal.RGB{R: 180, G: 220, B: 220},
SyntaxComment: terminal.RGB{R: 100, G: 110, B: 120},
SyntaxString: terminal.RGB{R: 180, G: 220, B: 140},
SyntaxKeyword: terminal.RGB{R: 180, G: 140, B: 220},
SyntaxType: terminal.RGB{R: 80, G: 200, B: 200},
SyntaxNumber: terminal.RGB{R: 220, G: 180, B: 120},
SyntaxSymbol: terminal.RGB{R: 220, G: 180, B: 80},
}
+301
View File
@@ -0,0 +1,301 @@
package tui
import "github.com/lixenwraith/terminal"
// ToastPosition specifies where toast renders
type ToastPosition uint8
const (
ToastBottom ToastPosition = iota // Full-width bar at bottom
ToastTop // Full-width bar at top
ToastBottomRight // Floating box bottom-right
ToastBottomLeft // Floating box bottom-left
ToastTopRight // Floating box top-right
ToastTopLeft // Floating box top-left
ToastCenter // Centered floating box
)
// ToastSeverity defines message type for styling
type ToastSeverity uint8
const (
ToastInfo ToastSeverity = iota // Default, neutral
ToastSuccess // Green, positive
ToastWarning // Yellow, caution
ToastError // Red, failure
)
// ToastStyle defines visual appearance
type ToastStyle uint8
const (
ToastStyleMinimal ToastStyle = iota // No border, just text
ToastStyleBar // Full-width background bar
ToastStyleBox // Bordered box
ToastStyleRounded // Rounded border box
ToastStyleDouble // Double-line border
ToastStyleShadow // Box with shadow effect
)
// ToastIcons for severity levels
var ToastIcons = map[ToastSeverity]rune{
ToastInfo: '',
ToastSuccess: '✓',
ToastWarning: '⚠',
ToastError: '✗',
}
// ToastColors default colors per severity
var ToastColors = map[ToastSeverity]struct{ Fg, Bg, Icon terminal.RGB }{
ToastInfo: {
Fg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 40, G: 40, B: 50},
Icon: terminal.RGB{R: 100, G: 150, B: 255},
},
ToastSuccess: {
Fg: terminal.RGB{R: 220, G: 255, B: 220},
Bg: terminal.RGB{R: 30, G: 60, B: 30},
Icon: terminal.RGB{R: 80, G: 220, B: 80},
},
ToastWarning: {
Fg: terminal.RGB{R: 255, G: 240, B: 200},
Bg: terminal.RGB{R: 60, G: 50, B: 20},
Icon: terminal.RGB{R: 255, G: 200, B: 60},
},
ToastError: {
Fg: terminal.RGB{R: 255, G: 220, B: 220},
Bg: terminal.RGB{R: 60, G: 25, B: 25},
Icon: terminal.RGB{R: 255, G: 80, B: 80},
},
}
// ToastOpts configures toast rendering
type ToastOpts struct {
Message string
Severity ToastSeverity
Position ToastPosition
Style ToastStyle
ShowIcon bool
MinWidth int // Minimum width for floating toasts, 0 = auto
MaxWidth int // Maximum width, 0 = region width
Padding int // Horizontal padding, default 1
MarginX int // Margin from edge for floating positions
MarginY int // Margin from edge for floating positions
CustomFg terminal.RGB
CustomBg terminal.RGB
CustomIcon terminal.RGB
}
// DefaultToastOpts returns sensible defaults
func DefaultToastOpts(message string, severity ToastSeverity) ToastOpts {
return ToastOpts{
Message: message,
Severity: severity,
Position: ToastBottom,
Style: ToastStyleBar,
ShowIcon: true,
Padding: 1,
MarginX: 2,
MarginY: 1,
}
}
// Toast renders a toast message overlay
// Returns the region occupied by the toast for hit testing
func (r Region) Toast(opts ToastOpts) Region {
if r.W < 5 || r.H < 1 || opts.Message == "" {
return Region{}
}
// Resolve colors
fg, bg, iconFg := opts.CustomFg, opts.CustomBg, opts.CustomIcon
if fg == (terminal.RGB{}) {
fg = ToastColors[opts.Severity].Fg
}
if bg == (terminal.RGB{}) {
bg = ToastColors[opts.Severity].Bg
}
if iconFg == (terminal.RGB{}) {
iconFg = ToastColors[opts.Severity].Icon
}
padding := opts.Padding
if padding == 0 {
padding = 1
}
// Calculate content width
iconW := 0
if opts.ShowIcon {
iconW = 2 // icon + space
}
msgLen := RuneLen(opts.Message)
contentW := iconW + msgLen + padding*2
// Determine toast dimensions based on style
borderW := 0
if opts.Style >= ToastStyleBox {
borderW = 2
}
toastW := contentW + borderW
toastH := 1 + borderW
// Apply width constraints
maxW := opts.MaxWidth
if maxW == 0 || maxW > r.W {
maxW = r.W
}
if toastW > maxW {
toastW = maxW
}
if opts.MinWidth > 0 && toastW < opts.MinWidth {
toastW = opts.MinWidth
}
// Calculate position
var toastX, toastY int
marginX := opts.MarginX
marginY := opts.MarginY
switch opts.Position {
case ToastBottom:
toastX = 0
toastY = r.H - toastH
toastW = r.W // Full width for bar positions
case ToastTop:
toastX = 0
toastY = 0
toastW = r.W
case ToastBottomRight:
toastX = r.W - toastW - marginX
toastY = r.H - toastH - marginY
case ToastBottomLeft:
toastX = marginX
toastY = r.H - toastH - marginY
case ToastTopRight:
toastX = r.W - toastW - marginX
toastY = marginY
case ToastTopLeft:
toastX = marginX
toastY = marginY
case ToastCenter:
toastX = (r.W - toastW) / 2
toastY = (r.H - toastH) / 2
}
// Clamp position
if toastX < 0 {
toastX = 0
}
if toastY < 0 {
toastY = 0
}
toastRegion := r.Sub(toastX, toastY, toastW, toastH)
// Render based on style
switch opts.Style {
case ToastStyleMinimal:
r.renderToastContent(toastRegion, opts, fg, bg, iconFg, 0)
case ToastStyleBar:
toastRegion.Fill(bg)
r.renderToastContent(toastRegion, opts, fg, bg, iconFg, 0)
case ToastStyleBox:
toastRegion.BoxFilled(LineSingle, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
case ToastStyleRounded:
toastRegion.BoxFilled(LineRounded, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
case ToastStyleDouble:
toastRegion.BoxFilled(LineDouble, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
case ToastStyleShadow:
// Shadow offset
shadowRegion := r.Sub(toastX+1, toastY+1, toastW, toastH)
shadowRegion.Fill(terminal.RGB{R: 10, G: 10, B: 15})
toastRegion.BoxFilled(LineSingle, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
}
return toastRegion
}
func (r Region) renderToastContent(content Region, opts ToastOpts, fg, bg, iconFg terminal.RGB, _ int) {
if content.W < 1 || content.H < 1 {
return
}
x := opts.Padding
y := 0
// Icon
if opts.ShowIcon {
icon := ToastIcons[opts.Severity]
if x < content.W {
content.Cell(x, y, icon, iconFg, bg, terminal.AttrBold)
}
x += 2
}
// Message
msg := opts.Message
availW := content.W - x - opts.Padding
if availW < 1 {
return
}
if RuneLen(msg) > availW {
msg = Truncate(msg, availW)
}
content.Text(x, y, msg, fg, bg, terminal.AttrNone)
}
// ToastState manages toast lifecycle
type ToastState struct {
Visible bool
Opts ToastOpts
FramesLeft int // Countdown to auto-dismiss, -1 = persistent
}
// NewToastState creates a toast that auto-dismisses after frames
// Use frames=-1 for persistent toast
func NewToastState(opts ToastOpts, frames int) *ToastState {
return &ToastState{
Visible: true,
Opts: opts,
FramesLeft: frames,
}
}
// Tick decrements frame counter, returns true if toast should dismiss
func (t *ToastState) Tick() bool {
if !t.Visible {
return false
}
if t.FramesLeft < 0 {
return false // Persistent
}
t.FramesLeft--
if t.FramesLeft <= 0 {
t.Visible = false
return true
}
return false
}
// Dismiss hides the toast
func (t *ToastState) Dismiss() {
t.Visible = false
}
// Show displays a new toast
func (t *ToastState) Show(opts ToastOpts, frames int) {
t.Opts = opts
t.FramesLeft = frames
t.Visible = true
}
+64
View File
@@ -0,0 +1,64 @@
package tui
// TreeExpansion manages expand/collapse state
type TreeExpansion struct {
State map[string]bool
}
// NewTreeExpansion creates initialized expansion state
func NewTreeExpansion() *TreeExpansion {
return &TreeExpansion{
State: make(map[string]bool),
}
}
// --- State queries ---
// IsExpanded returns expansion state for key
func (e *TreeExpansion) IsExpanded(key string) bool {
return e.State[key]
}
// --- State modification ---
// SetExpanded sets expansion state for key
func (e *TreeExpansion) SetExpanded(key string, expanded bool) {
e.State[key] = expanded
}
// Toggle toggles expansion state for key
func (e *TreeExpansion) Toggle(key string) bool {
e.State[key] = !e.State[key]
return e.State[key]
}
// Expand sets key to expanded
func (e *TreeExpansion) Expand(key string) {
e.State[key] = true
}
// Collapse sets key to collapsed
func (e *TreeExpansion) Collapse(key string) {
e.State[key] = false
}
// --- Bulk operations ---
// ExpandAll expands all provided keys
func (e *TreeExpansion) ExpandAll(keys []string) {
for _, k := range keys {
e.State[k] = true
}
}
// CollapseAll collapses all keys
func (e *TreeExpansion) CollapseAll() {
for k := range e.State {
e.State[k] = false
}
}
// Clear removes all expansion state
func (e *TreeExpansion) Clear() {
e.State = make(map[string]bool)
}
+337
View File
@@ -0,0 +1,337 @@
package tui
import "github.com/lixenwraith/terminal"
// ExpandIcon chars
const (
IconExpanded = '▼'
IconCollapsed = '▶'
IconBullet = '•'
)
// ExpandIconRune returns appropriate expand/collapse indicator
func ExpandIconRune(expanded bool) rune {
if expanded {
return IconExpanded
}
return IconCollapsed
}
// TreeLineMode specifies connector line rendering
type TreeLineMode uint8
const (
TreeLinesNone TreeLineMode = iota // Indent only, no lines
TreeLinesSimple // │ continuation, └ for last
)
// TreeNode represents a node in a hierarchical tree
type TreeNode struct {
Key string // Unique identifier for expansion state
Label string // Display text
Icon rune // Custom icon, 0 = auto (▶/▼ for expandable, • for leaf)
IconFg terminal.RGB
Expandable bool // Has children
Expanded bool // Currently expanded
Depth int // Nesting level (0 = root)
Check CheckState // Optional checkbox, CheckNone to skip
CheckFg terminal.RGB
Style Style // Text styling
IsLast bool // Last sibling at this depth (for tree lines)
Data any // Application payload
Suffix string // Secondary text after label (e.g., "(5 files)")
SuffixStyle Style // Styling for suffix, zero = dimmed version of Style
Badge rune // Icon between checkbox and label (e.g., '★'), 0 = none
BadgeFg terminal.RGB // Badge color
}
// ancestorHasMoreSiblings checks if there are more nodes at given depth after idx
func (r Region) ancestorHasMoreSiblings(nodes []TreeNode, idx, depth int) bool {
targetDepth := depth
for i := idx + 1; i < len(nodes); i++ {
if nodes[i].Depth <= targetDepth {
return nodes[i].Depth == targetDepth
}
}
return false
}
// TreeOpts configures tree rendering
type TreeOpts struct {
CursorBg terminal.RGB
DefaultBg terminal.RGB
IndentWidth int // Cells per depth, default 2
IconWidth int // Width for icon column, default 2
LineMode TreeLineMode
LineFg terminal.RGB
}
// DefaultTreeOpts returns sensible defaults
func DefaultTreeOpts() TreeOpts {
return TreeOpts{
IndentWidth: 2,
IconWidth: 2,
LineMode: TreeLinesNone,
LineFg: terminal.RGB{R: 80, G: 80, B: 100},
}
}
// Tree renders hierarchical tree nodes within region, returns number of rows rendered
// Nodes must be pre-flattened (only visible/expanded nodes included)
func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
if r.H < 1 || len(nodes) == 0 {
return 0
}
indentW := opts.IndentWidth
if indentW < 1 {
indentW = 2
}
iconW := opts.IconWidth
if iconW < 1 {
iconW = 2
}
lineFg := opts.LineFg
if lineFg == (terminal.RGB{}) {
lineFg = DefaultTheme.Border
}
rendered := 0
for y := 0; y < r.H; y++ {
idx := scroll + y
if idx >= len(nodes) {
break
}
node := nodes[idx]
isCursor := idx == cursor
bg := opts.DefaultBg
if isCursor {
bg = opts.CursorBg
}
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
}
x := 0
if opts.LineMode == TreeLinesSimple && node.Depth > 0 {
x = r.renderTreeLines(y, x, node, nodes, idx, scroll, indentW, lineFg, bg)
} else {
x = node.Depth * indentW
}
// Icon (expand indicator or bullet)
icon := node.Icon
iconFg := node.IconFg
if icon == 0 {
if node.Expandable {
if node.Expanded {
icon = IconExpanded
} else {
icon = IconCollapsed
}
} else {
icon = IconBullet
}
}
if iconFg == (terminal.RGB{}) {
iconFg = lineFg
}
if x < r.W {
r.Cell(x, y, icon, iconFg, bg, terminal.AttrNone)
}
x += iconW
// Checkbox
if node.Check != CheckNone || node.CheckFg != (terminal.RGB{}) {
if x+3 <= r.W {
checkFg := node.CheckFg
if checkFg == (terminal.RGB{}) {
checkFg = node.Style.Fg
}
var ch rune
switch node.Check {
case CheckNone:
ch = ' '
case CheckPartial:
ch = 'o'
case CheckFull:
ch = 'x'
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', checkFg, bg, terminal.AttrNone)
r.Cell(x+1, y, ch, checkFg, bg, terminal.AttrNone)
r.Cell(x+2, y, ']', checkFg, bg, terminal.AttrNone)
}
x += 4
}
// Badge (optional icon between checkbox and label)
if node.Badge != 0 {
if x < r.W {
badgeFg := node.BadgeFg
if badgeFg == (terminal.RGB{}) {
badgeFg = node.Style.Fg
}
r.Cell(x, y, node.Badge, badgeFg, bg, terminal.AttrNone)
}
x += 2
}
// Label
style := node.Style
if style.Bg == (terminal.RGB{}) {
style.Bg = bg
}
// Calculate available width for label + suffix
availW := r.W - x - 1
labelLen := RuneLen(node.Label)
suffixLen := RuneLen(node.Suffix)
label := node.Label
suffix := node.Suffix
// Truncate if needed, prioritizing label over suffix
if labelLen+suffixLen > availW {
if labelLen > availW {
label = Truncate(label, availW)
suffix = ""
} else {
suffix = Truncate(suffix, availW-labelLen)
}
}
r.TextStyled(x, y, label, style)
x += RuneLen(label)
// Suffix
if suffix != "" {
suffixStyle := node.SuffixStyle
if suffixStyle.Fg == (terminal.RGB{}) {
// Default: dimmed version of label style
suffixStyle.Fg = terminal.RGB{
R: node.Style.Fg.R / 2,
G: node.Style.Fg.G / 2,
B: node.Style.Fg.B / 2,
}
}
if suffixStyle.Bg == (terminal.RGB{}) {
suffixStyle.Bg = bg
}
r.TextStyled(x, y, suffix, suffixStyle)
}
rendered++
}
return rendered
}
// renderTreeLines draws connector lines for tree structure, returns x position after lines
func (r Region) renderTreeLines(y, startX int, node TreeNode, nodes []TreeNode, idx, scroll, indentW int, fg terminal.RGB, bg terminal.RGB) int {
x := startX
for d := 0; d < node.Depth; d++ {
// Determine if ancestor at this depth has more siblings below
hasMore := r.ancestorHasMoreSiblings(nodes, idx, d)
if d == node.Depth-1 {
// Direct parent level - show branch
if node.IsLast {
r.Cell(x, y, '└', fg, bg, terminal.AttrNone)
} else {
r.Cell(x, y, '├', fg, bg, terminal.AttrNone)
}
// Horizontal connector
for i := 1; i < indentW; i++ {
if x+i < r.W {
r.Cell(x+i, y, '─', fg, bg, terminal.AttrNone)
}
}
} else {
// Ancestor level - show continuation or space
if hasMore {
r.Cell(x, y, '│', fg, bg, terminal.AttrNone)
}
// Fill rest with spaces (already cleared)
}
x += indentW
}
return x
}
// --- Navigation helpers ---
// FindParentIndex returns index of parent node for node at idx, or -1 if root
func FindParentIndex(nodes []TreeNode, idx int) int {
if idx <= 0 || idx >= len(nodes) {
return -1
}
targetDepth := nodes[idx].Depth - 1
if targetDepth < 0 {
return -1
}
for i := idx - 1; i >= 0; i-- {
if nodes[i].Depth == targetDepth {
return i
}
}
return -1
}
// FindFirstChildIndex returns index of first child for node at idx, or -1 if none
func FindFirstChildIndex(nodes []TreeNode, idx int) int {
if idx < 0 || idx >= len(nodes)-1 {
return -1
}
if !nodes[idx].Expandable || !nodes[idx].Expanded {
return -1
}
childDepth := nodes[idx].Depth + 1
if idx+1 < len(nodes) && nodes[idx+1].Depth == childDepth {
return idx + 1
}
return -1
}
// FindNextSiblingIndex returns index of next sibling at same depth, or -1
func FindNextSiblingIndex(nodes []TreeNode, idx int) int {
if idx < 0 || idx >= len(nodes) {
return -1
}
depth := nodes[idx].Depth
for i := idx + 1; i < len(nodes); i++ {
if nodes[i].Depth < depth {
return -1 // Went up, no more siblings
}
if nodes[i].Depth == depth {
return i
}
}
return -1
}
// FindPrevSiblingIndex returns index of previous sibling at same depth, or -1
func FindPrevSiblingIndex(nodes []TreeNode, idx int) int {
if idx <= 0 || idx >= len(nodes) {
return -1
}
depth := nodes[idx].Depth
for i := idx - 1; i >= 0; i-- {
if nodes[i].Depth < depth {
return -1 // Went up, no more siblings
}
if nodes[i].Depth == depth {
return i
}
}
return -1
}
+67
View File
@@ -0,0 +1,67 @@
package tui
// TreeBuilder helps construct flattened visible node list from hierarchical data
type TreeBuilder struct {
nodes []TreeNode
expansion *TreeExpansion
}
// NewTreeBuilder creates a builder with expansion state
func NewTreeBuilder(expansion *TreeExpansion) *TreeBuilder {
return &TreeBuilder{
expansion: expansion,
}
}
// Reset clears accumulated nodes
func (b *TreeBuilder) Reset() {
b.nodes = b.nodes[:0]
}
// Add adds a node if visible (parent expanded)
// parentExpanded should be true for root-level nodes
func (b *TreeBuilder) Add(node TreeNode, parentExpanded bool) {
if !parentExpanded {
return
}
node.Expanded = b.expansion.IsExpanded(node.Key)
b.nodes = append(b.nodes, node)
}
// Nodes returns accumulated visible nodes
func (b *TreeBuilder) Nodes() []TreeNode {
return b.nodes
}
// MarkLastSiblings sets IsLast flag on nodes that are last at their depth
// Call after all nodes added, before rendering
func (b *TreeBuilder) MarkLastSiblings() {
n := len(b.nodes)
if n == 0 {
return
}
// Track last seen index at each depth
depthLast := make(map[int]int)
for i := 0; i < n; i++ {
depth := b.nodes[i].Depth
depthLast[depth] = i
// When we see a node, all deeper nodes before it are "last" at their levels
// Actually we need to mark when depth decreases or changes
}
// Simpler approach: scan backwards, mark last at each depth transition
for i := n - 1; i >= 0; i-- {
depth := b.nodes[i].Depth
// Check if next node (i+1) is at same or lesser depth
if i == n-1 {
b.nodes[i].IsLast = true
} else {
nextDepth := b.nodes[i+1].Depth
b.nodes[i].IsLast = nextDepth <= depth
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package tui
// TreeState manages navigation state for a tree
type TreeState struct {
Cursor int
Scroll int
Visible int // Viewport height
}
// NewTreeState creates initialized tree state
func NewTreeState(visible int) *TreeState {
return &TreeState{
Visible: visible,
}
}
// --- Cursor movement ---
// MoveCursor adjusts cursor position by delta
func (t *TreeState) MoveCursor(delta, total int) {
t.Cursor += delta
if t.Cursor < 0 {
t.Cursor = 0
}
if t.Cursor >= total {
t.Cursor = total - 1
}
if t.Cursor < 0 {
t.Cursor = 0
}
t.AdjustScroll(total)
}
// AdjustScroll ensures cursor is visible
func (t *TreeState) AdjustScroll(total int) {
if t.Visible <= 0 {
return
}
if t.Cursor < t.Scroll {
t.Scroll = t.Cursor
}
if t.Cursor >= t.Scroll+t.Visible {
t.Scroll = t.Cursor - t.Visible + 1
}
// Clamp scroll
maxScroll := total - t.Visible
if maxScroll < 0 {
maxScroll = 0
}
if t.Scroll > maxScroll {
t.Scroll = maxScroll
}
if t.Scroll < 0 {
t.Scroll = 0
}
}
// --- Jump navigation ---
// JumpStart moves cursor to first item
func (t *TreeState) JumpStart() {
t.Cursor = 0
t.Scroll = 0
}
// JumpEnd moves cursor to last item
func (t *TreeState) JumpEnd(total int) {
if total > 0 {
t.Cursor = total - 1
}
t.AdjustScroll(total)
}
// --- Page navigation ---
// PageUp scrolls up by half viewport
func (t *TreeState) PageUp(total int) {
delta := t.Visible / 2
if delta < 1 {
delta = 1
}
t.MoveCursor(-delta, total)
}
// PageDown scrolls down by half viewport
func (t *TreeState) PageDown(total int) {
delta := t.Visible / 2
if delta < 1 {
delta = 1
}
t.MoveCursor(delta, total)
}
// SetVisible updates viewport height
func (t *TreeState) SetVisible(visible int) {
t.Visible = visible
}
+107
View File
@@ -0,0 +1,107 @@
package tui
// ViewportScroll manages row-based scroll for content regions
// Distinct from ScrollState which is item-index based
type ViewportScroll struct {
Offset int // Row offset from top of content
ContentH int // Total content height in rows
ViewportH int // Visible viewport height
}
// NewViewportScroll creates viewport scroll state
func NewViewportScroll() *ViewportScroll {
return &ViewportScroll{}
}
// SetDimensions updates content and viewport heights, clamps offset
func (v *ViewportScroll) SetDimensions(contentH, viewportH int) {
v.ContentH = contentH
v.ViewportH = viewportH
v.clamp()
}
// MaxOffset returns maximum valid scroll offset
func (v *ViewportScroll) MaxOffset() int {
maxOffset := v.ContentH - v.ViewportH
if maxOffset < 0 {
return 0
}
return maxOffset
}
// CanScroll returns true if content exceeds viewport
func (v *ViewportScroll) CanScroll() bool {
return v.ContentH > v.ViewportH
}
// ScrollBy adjusts offset by delta
func (v *ViewportScroll) ScrollBy(delta int) {
v.Offset += delta
v.clamp()
}
// ScrollTo sets absolute offset
func (v *ViewportScroll) ScrollTo(pos int) {
v.Offset = pos
v.clamp()
}
// PageUp scrolls up by viewport height
func (v *ViewportScroll) PageUp() {
v.ScrollBy(-v.ViewportH)
}
// PageDown scrolls down by viewport height
func (v *ViewportScroll) PageDown() {
v.ScrollBy(v.ViewportH)
}
// Home scrolls to top
func (v *ViewportScroll) Home() {
v.Offset = 0
}
// End scrolls to bottom
func (v *ViewportScroll) End() {
v.Offset = v.MaxOffset()
}
func (v *ViewportScroll) clamp() {
max := v.MaxOffset()
if v.Offset > max {
v.Offset = max
}
if v.Offset < 0 {
v.Offset = 0
}
}
// IsVisible returns true if content row range intersects viewport
func (v *ViewportScroll) IsVisible(y, h int) bool {
return y+h > v.Offset && y < v.Offset+v.ViewportH
}
// ClipToViewport maps content coordinates to viewport coordinates
// Returns viewY (in viewport), viewH (visible height), contentOffset (rows clipped from top)
// visible=false if entirely outside viewport
func (v *ViewportScroll) ClipToViewport(y, h int) (viewY, viewH, contentOffset int, visible bool) {
if !v.IsVisible(y, h) {
return 0, 0, 0, false
}
viewY = y - v.Offset
viewH = h
contentOffset = 0
if viewY < 0 {
contentOffset = -viewY
viewH += viewY
viewY = 0
}
if viewY+viewH > v.ViewportH {
viewH = v.ViewportH - viewY
}
return viewY, viewH, contentOffset, viewH > 0
}