v0.1.0 initial commit
This commit is contained in:
+206
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:])
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user