v0.1.0 initial commit

This commit is contained in:
2026-08-06 10:18:22 -04:00
commit 28e42523e1
31 changed files with 5055 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
package ui
import "github.com/lixenwraith/terminal/tui"
// Pane identifies a region of the frame.
type Pane uint8
const (
PaneNone Pane = iota
PaneHeader
PaneList
PaneDetail
PaneStatus
PaneFooter
)
// Dir is the split axis of a container node.
type Dir uint8
const (
Vertical Dir = iota // children stacked top to bottom
Horizontal // children placed left to right
)
// Node is either a leaf bound to a Pane or a container. Layout is data:
// changing pane count or arrangement edits this tree only.
type Node struct {
Pane Pane
Dir Dir
Fixed int // size along the parent axis, 0 = share by Weight
Weight float64
Children []Node
}
// Layout is the root of the pane tree.
type Layout struct{ Root Node }
// DefaultLayout is the phase-1 layout: header, list|detail body, status, footer.
func DefaultLayout() Layout {
return Layout{Root: Node{Dir: Vertical, Children: []Node{
{Pane: PaneHeader, Fixed: 1},
{Dir: Horizontal, Weight: 1, Children: []Node{
{Pane: PaneList, Weight: 0.62},
{Pane: PaneDetail, Weight: 0.38},
}},
{Pane: PaneStatus, Fixed: 1},
{Pane: PaneFooter, Fixed: 1},
}}}
}
// Resolve assigns a region to every leaf pane.
func (l Layout) Resolve(r tui.Region) map[Pane]tui.Region {
out := make(map[Pane]tui.Region, 8)
place(l.Root, r, out)
return out
}
func place(n Node, r tui.Region, out map[Pane]tui.Region) {
if len(n.Children) == 0 {
if n.Pane != PaneNone {
out[n.Pane] = r
}
return
}
total := r.H
if n.Dir == Horizontal {
total = r.W
}
sizes := share(n.Children, total)
pos := 0
for i, c := range n.Children {
var sub tui.Region
if n.Dir == Horizontal {
sub = r.Sub(pos, 0, sizes[i], r.H)
} else {
sub = r.Sub(0, pos, r.W, sizes[i])
}
place(c, sub, out)
pos += sizes[i]
}
}
// share splits total between fixed and weighted children; the rounding
// remainder goes to the last weighted child.
func share(cs []Node, total int) []int {
sizes := make([]int, len(cs))
rest, sum := total, 0.0
for i, c := range cs {
if c.Fixed > 0 {
s := min(c.Fixed, max(rest, 0))
sizes[i], rest = s, rest-s
} else {
sum += c.Weight
}
}
if sum <= 0 {
sum = 1
}
lastW, acc := -1, 0
for i, c := range cs {
if c.Fixed > 0 {
continue
}
s := int(float64(rest) * c.Weight / sum)
sizes[i], acc, lastW = s, acc+s, i
}
if lastW >= 0 {
sizes[lastW] += rest - acc
}
return sizes
}
+107
View File
@@ -0,0 +1,107 @@
package ui
import (
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
)
// Panel is a modal overlay with a scrollable body. It owns the frame, the
// scroll state and the scrollbar; the caller renders rows into the region
// Render returns, starting at Scroll.
type Panel struct {
Title string
Hint string
Status string // line inside the bottom of the frame, e.g. a full filename
W, H int // 0 = 80% of the screen
Cursor bool // track a selected row rather than scrolling freely
Sel int
Scroll int
Rows int // content rows, set by the caller before Render
View int // visible rows, set by Render
}
// Move advances the selection, or the scroll offset when Cursor is unset.
func (p *Panel) Move(d int) {
if p.Cursor {
p.Sel += d
} else {
p.Scroll += d
}
p.clamp()
}
// Page moves by n screens.
func (p *Panel) Page(n int) { p.Move(n * max(1, p.View)) }
// Half moves by n half screens.
func (p *Panel) Half(n int) { p.Move(n * max(1, p.View/2)) }
// First jumps to the top.
func (p *Panel) First() { p.Sel, p.Scroll = 0, 0 }
// Last jumps to the bottom.
func (p *Panel) Last() {
p.Sel, p.Scroll = p.Rows-1, p.Rows
p.clamp()
}
// Reset returns the panel to the top, for new content.
func (p *Panel) Reset() { p.Sel, p.Scroll = 0, 0 }
func (p *Panel) clamp() {
if p.Rows <= 0 {
p.Sel, p.Scroll = 0, 0
return
}
p.Sel = tui.ClampCursor(p.Sel, p.Rows)
if p.Cursor {
p.Scroll = tui.AdjustScroll(p.Sel, p.Scroll, p.View, p.Rows)
}
p.Scroll = tui.ClampScroll(p.Scroll, p.View, p.Rows)
}
// Render draws the frame and returns the body region, scrollbar excluded.
// A zero-width result means the screen is too small to show the panel.
func (p *Panel) Render(root tui.Region, th *Theme) tui.Region {
w, h := p.W, p.H
if w <= 0 {
w = root.W * 80 / 100
}
if h <= 0 {
h = root.H * 80 / 100
}
w, h = min(w, root.W-4), min(h, root.H-2)
if w < 24 || h < 6 {
return tui.Region{}
}
t, hint := p.Title, p.Hint
if t != "" {
t = " " + t + " "
}
if hint != "" {
hint = " " + hint + " "
}
content := tui.Center(root, w, h).Modal(tui.ModalOpts{
Title: t, Hint: hint, Border: tui.LineDouble,
BorderFg: th.Accent, TitleFg: th.HeaderFg, HintFg: th.HintFg, Bg: th.FocusBg,
})
body := content
if p.Status != "" && content.H > 3 {
body = content.Sub(0, 0, content.W, content.H-2)
content.HLine(content.H-2, tui.LineSingle, th.Border)
content.Text(0, content.H-1, tui.Truncate(p.Status, content.W),
th.HintFg, th.FocusBg, terminal.AttrDim)
}
p.View = body.H
p.clamp()
if p.Rows > body.H && body.W > 2 {
body.Sub(body.W-1, 0, 1, body.H).ScrollBar(0, p.Scroll, body.H, p.Rows, th.Border)
body = body.Sub(0, 0, body.W-1, body.H)
}
return body
}
+73
View File
@@ -0,0 +1,73 @@
package ui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/logfile"
)
// Theme extends the tui theme with log-specific colors.
type Theme struct {
tui.Theme
Accent color.RGB
Accent2 color.RGB
Level [logfile.LevelCount]color.RGB
KeyFg color.RGB
NumFg color.RGB
StrFg color.RGB
SnapFg color.RGB
}
// DefaultTheme is the built-in theme.
var DefaultTheme = Theme{
Theme: tui.Theme{
Bg: color.Gunmetal, Fg: color.RGB{R: 192, G: 202, B: 245},
FocusBg: color.DarkSlate, CursorBg: color.RGB{R: 45, G: 50, B: 80},
Selected: color.RGB{R: 158, G: 206, B: 106}, Unselected: color.RGB{R: 86, G: 95, B: 137},
Partial: color.RGB{R: 125, G: 207, B: 255}, Error: color.RGB{R: 247, G: 118, B: 142},
Warning: color.RGB{R: 224, G: 175, B: 104}, Border: color.RGB{R: 59, G: 66, B: 97},
HeaderBg: color.RGB{R: 22, G: 22, B: 30}, HeaderFg: color.RGB{R: 192, G: 202, B: 245},
StatusFg: color.RGB{R: 140, G: 152, B: 200}, HintFg: color.RGB{R: 86, G: 95, B: 137},
InputBg: color.RGB{R: 31, G: 32, B: 46},
},
Accent: color.RGB{R: 122, G: 162, B: 247},
Accent2: color.RGB{R: 187, G: 154, B: 247},
Level: [logfile.LevelCount]color.RGB{
logfile.LevelTrace: {R: 100, G: 108, B: 140},
logfile.LevelDebug: {R: 125, G: 207, B: 255},
logfile.LevelInfo: {R: 158, G: 206, B: 106},
logfile.LevelWarn: {R: 224, G: 175, B: 104},
logfile.LevelError: {R: 247, G: 118, B: 142},
logfile.LevelProc: {R: 187, G: 154, B: 247},
logfile.LevelBad: {R: 255, G: 100, B: 100},
},
KeyFg: color.RGB{R: 140, G: 152, B: 200},
NumFg: color.RGB{R: 224, G: 175, B: 104},
StrFg: color.RGB{R: 158, G: 206, B: 106},
SnapFg: color.RGB{R: 125, G: 207, B: 255},
}
// subPalette gives ad-hoc subsystem tags stable colors without a lookup table.
var subPalette = []color.RGB{
{R: 122, G: 162, B: 247}, {R: 158, G: 206, B: 106}, {R: 224, G: 175, B: 104},
{R: 187, G: 154, B: 247}, {R: 125, G: 207, B: 255}, {R: 247, G: 118, B: 142},
{R: 180, G: 220, B: 200}, {R: 220, G: 200, B: 140},
}
// SubColor returns a stable color for a subsystem tag.
func (t *Theme) SubColor(s string) color.RGB {
if s == "" {
return t.StatusFg
}
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h = (h ^ uint32(s[i])) * 16777619
}
return subPalette[h%uint32(len(subPalette))]
}
// SrcColor returns a stable color for a source id, drawn from the same palette
// so gutter marks and subsystem tags share a visual language.
func (t *Theme) SrcColor(id int) color.RGB {
return subPalette[uint32(id)%uint32(len(subPalette))]
}