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