Files
2026-08-06 10:18:22 -04:00

671 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"strconv"
"strings"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/keys"
"github.com/lixenwraith/vif-log/internal/logfile"
"github.com/lixenwraith/vif-log/internal/ui"
)
// paneOrder fixes render order. The list sizes itself first so the status bar
// reads that height in the same frame.
var paneOrder = []ui.Pane{
ui.PaneList, ui.PaneDetail, ui.PaneHeader, ui.PaneStatus, ui.PaneFooter,
}
// renderers binds panes to draw functions; layout decides where they land.
var renderers = map[ui.Pane]func(*App, tui.Region){
ui.PaneHeader: (*App).renderHeader,
ui.PaneList: (*App).renderList,
ui.PaneDetail: (*App).renderDetail,
ui.PaneStatus: (*App).renderStatus,
ui.PaneFooter: (*App).renderFooter,
}
// Render draws one frame.
func (a *App) Render() {
w, h := a.w, a.h
if w < 4 || h < 2 {
return
}
if len(a.cells) != w*h {
a.cells = make([]terminal.Cell, w*h)
}
blank := terminal.Cell{Rune: ' ', Fg: a.th.Fg, Bg: a.th.Bg}
for i := range a.cells {
a.cells[i] = blank
}
cells := a.cells
root := tui.NewRegion(cells, w, 0, 0, w, h)
if w < minW || h < minH {
a.renderTooSmall(root)
a.term.Flush(cells, w, h)
return
}
regions := a.lay.Resolve(root)
for _, pane := range paneOrder {
if fn, region := renderers[pane], regions[pane]; fn != nil && region.W > 0 {
fn(a, region)
}
}
switch a.overlay {
case ovPrompt:
a.renderPrompt(regions[ui.PaneFooter])
case ovHelp:
a.renderHelp(root)
case ovOpen:
a.renderBrowser(root)
}
if a.toast.Visible {
root.Toast(a.toast.Opts)
}
a.term.Flush(cells, w, h)
}
// renderTooSmall reports the shortfall rather than degrading the layout.
func (a *App) renderTooSmall(r tui.Region) {
r.Fill(a.th.Bg)
y := r.H / 2
r.TextCenter(y-1, "terminal too small", a.th.Warning, a.th.Bg, terminal.AttrBold)
r.TextCenter(y+1, fmt.Sprintf("%dx%d - need %dx%d", a.w, a.h, minW, minH),
a.th.HintFg, a.th.Bg, terminal.AttrNone)
r.TextCenter(y+3, "q quits", a.th.HintFg, a.th.Bg, terminal.AttrDim)
}
// --- bars ------------------------------------------------------------------
// pair is a label/value cell in the header and status bars.
type pair struct {
label, value string
fg color.RGB
}
const pairSep = " · "
const pairSepW = 3 // runes; len(pairSep) is 4 bytes
// drawPairsRight renders pairs right-aligned, dropping leading pairs that do
// not fit. Returns the x where drawing started, so the caller can clip its own
// left-aligned content. Unlike tui.StatusBar this does not clear the row.
func (a *App) drawPairsRight(r tui.Region, y int, ps []pair, bg color.RGB) int {
width := func(p pair) int { return tui.RuneLen(p.label) + 1 + tui.RuneLen(p.value) }
total := 0
for i, p := range ps {
total += width(p)
if i > 0 {
total += pairSepW
}
}
for len(ps) > 1 && total > r.W-2 {
total -= width(ps[0]) + pairSepW
ps = ps[1:]
}
x := max(0, r.W-total-1)
start := x
for i, p := range ps {
if i > 0 {
r.Text(x, y, pairSep, a.th.HintFg, bg, terminal.AttrDim)
x += pairSepW
}
r.Text(x, y, p.label, a.th.HintFg, bg, terminal.AttrNone)
x += tui.RuneLen(p.label) + 1
r.Text(x, y, p.value, p.fg, bg, terminal.AttrBold)
x += tui.RuneLen(p.value)
}
return start
}
func (a *App) renderHeader(r tui.Region) {
r.Fill(a.th.HeaderBg)
rx := a.drawPairsRight(r, 0, a.headerPairs(), a.th.HeaderBg)
x := 1
r.Text(x, 0, " vif-log ", a.th.Bg, a.th.Accent, terminal.AttrBold)
x += 10
if rx > x+2*int(logfile.LevelCount) {
x = a.drawLevelStrip(r, x)
}
if w := rx - x - 2; w > 8 {
// title carries "name +N" once several files are merged
name := a.title
if name == "" {
name = "no file - press o to open"
}
r.Text(x+1, 0, tui.TruncateLeft(name, w), a.th.HeaderFg, a.th.HeaderBg, terminal.AttrBold)
}
}
// drawLevelStrip shows every level's initial, filled for the levels currently
// shown and outlined for the hidden ones. Returns the x past the strip.
func (a *App) drawLevelStrip(r tui.Region, x int) int {
for l := logfile.Level(0); l < logfile.LevelCount; l++ {
fg, bg, attr := a.th.Level[l], a.th.HeaderBg, terminal.AttrNone
if a.lvl.Has(l) {
fg, bg, attr = a.th.HeaderBg, a.th.Level[l], terminal.AttrBold
}
r.Cell(x, 0, rune(l.Initial()), fg, bg, attr)
x += 2
}
return x
}
func (a *App) headerPairs() []pair {
ps := make([]pair, 0, 4)
if scanned, total, done := a.idx.Progress(); !done {
p := 0
if total > 0 {
p = int(scanned * 100 / total)
}
ps = append(ps, pair{"indexing", fmt.Sprintf("%d%%", p), a.th.Accent2})
}
n := len(a.rows())
row := "0"
if n > 0 {
row = fmt.Sprint(a.cursor + 1)
}
return append(ps,
pair{"row", row, a.th.HeaderFg},
pair{"shown", fmt.Sprint(n), a.th.Selected},
pair{"total", fmt.Sprint(a.idx.Len()), a.th.StatusFg},
)
}
func (a *App) renderStatus(r tui.Region) {
r.Fill(a.th.HeaderBg)
rx := a.drawPairsRight(r, 0, a.statusPairs(), a.th.HeaderBg)
if s := strings.Join(a.stack.Summary(), " "); s != "" && rx > 3 {
r.Text(1, 0, tui.Truncate(s, rx-2), a.th.Accent, a.th.HeaderBg, terminal.AttrBold)
}
}
// statusPairs is ordered least to most important: overflow drops from the left.
func (a *App) statusPairs() []pair {
ps := make([]pair, 0, 5)
if a.build.busy {
ps = append(ps, pair{"filtering",
fmt.Sprintf("%d%%", pct(a.build.next, a.idx.Len())), a.th.Accent2})
}
if n := a.idx.Malformed(); n > 0 {
ps = append(ps, pair{"bad", fmt.Sprint(n), a.th.Error})
}
if n := a.pins.Len(); n > 0 {
ps = append(ps, pair{"pins", fmt.Sprint(n), a.th.Warning})
}
if a.sortDir != sortNone {
ps = append(ps, pair{"sort", a.sortCol.String() + " " + a.sortDir.String(), a.th.Partial})
}
return append(ps, pair{"col", a.col.String(), a.th.Accent})
}
func pct(n, total int) int {
if total <= 0 {
return 100
}
return n * 100 / total
}
// --- record list -----------------------------------------------------------
// colLayout is the physical geometry of the list pane: pin gutter, optional
// source gutter, then the focusable columns with level wedged after time.
type colLayout struct {
w int
pinX int
srcX, srcW int
tsX, tsW int
lvlX int
tickX, tickW int
subX, subW int
markX int
msgX, msgW int
fldX int
}
func listCols(w, nsrc int) colLayout {
c := colLayout{w: w, tsW: 12, subW: 8, msgW: 16}
if nsrc > 1 {
c.srcW = 1
}
if w >= 96 {
c.tickW = 6
}
if w < 76 {
c.msgW = 12
}
if w < 60 {
c.subW, c.msgW = 6, 10
}
x := 0
c.pinX, x = x, x+2
c.srcX = x
if c.srcW > 0 {
x += c.srcW + 1
}
c.tsX, x = x, x+c.tsW+1
c.lvlX, x = x, x+2
c.tickX = x
if c.tickW > 0 {
x += c.tickW + 1
}
c.subX, x = x, x+c.subW+1
c.markX, x = x, x+2
c.msgX, x = x, x+c.msgW+1
c.fldX = x
return c
}
// span returns the header extent of a focusable column.
func (c colLayout) span(col logfile.Column) (int, int) {
switch col {
case logfile.ColTime:
return c.tsX, c.tsW
case logfile.ColTick:
return c.tickX, c.tickW
case logfile.ColSub:
return c.subX, c.subW
case logfile.ColMsg:
return c.msgX, c.msgW
case logfile.ColFields:
return c.fldX, max(0, c.w-c.fldX)
}
return 0, c.w
}
func (a *App) renderList(r tui.Region) {
r.Fill(a.th.Bg)
list := r.Sub(0, 0, r.W-1, r.H)
c := listCols(list.W, a.idx.SrcCount())
a.renderColHeader(list, c)
body := list.Sub(0, 1, list.W, list.H-1)
a.listH = body.H
a.clamp()
rows := a.rows()
if len(rows) == 0 {
msg := "no records match"
if a.idx == nil {
msg = "no file open — press o"
}
body.TextCenter(body.H/2, msg, a.th.HintFg, a.th.Bg, terminal.AttrDim)
} else {
metas := a.idx.Metas()
for y := 0; y < body.H; y++ {
i := a.scroll + y
if i < 0 || i >= len(rows) {
break
}
rec := rows[i]
if int(rec) >= len(metas) {
break
}
a.renderRow(body, y, rec, metas[rec], c, i == a.cursor)
}
}
sb := r.Sub(r.W-1, 1, 1, r.H-1)
sb.ScrollBar(0, a.scroll, sb.H, len(rows), a.th.Border)
}
// renderColHeader names the columns and marks the focused one, which is both
// the search scope and the sort target.
func (a *App) renderColHeader(r tui.Region, c colLayout) {
for x := 0; x < r.W; x++ {
r.Cell(x, 0, ' ', a.th.HintFg, a.th.HeaderBg, terminal.AttrNone)
}
fx, fw := c.span(a.col)
for x := fx; x < fx+fw && x < r.W; x++ {
r.Cell(x, 0, ' ', a.th.Bg, a.th.Accent, terminal.AttrNone)
}
head := func(x, w int, s string, focused bool) {
fg, bg := a.th.HintFg, a.th.HeaderBg
if focused {
fg, bg = a.th.Bg, a.th.Accent
}
r.Text(x, 0, tui.Truncate(s, max(1, w)), fg, bg, terminal.AttrBold)
}
all := a.col == logfile.ColAll
head(c.tsX, c.tsW, "time", all || a.col == logfile.ColTime)
head(c.lvlX, 1, "T", all)
if c.tickW > 0 {
head(c.tickX, c.tickW, "tick", all || a.col == logfile.ColTick)
}
head(c.subX, c.subW, "sub", all || a.col == logfile.ColSub)
head(c.msgX, c.msgW, "msg", all || a.col == logfile.ColMsg)
head(c.fldX, max(1, r.W-c.fldX), "fields", all || a.col == logfile.ColFields)
if a.sortDir != sortNone && sortable(a.sortCol) {
sx, sw := c.span(a.sortCol)
if x := sx + sw - 1; sw > 0 && x < r.W {
fg, bg := a.th.Accent, a.th.HeaderBg
if all || a.sortCol == a.col {
fg, bg = a.th.Bg, a.th.Accent
}
r.Cell(x, 0, a.sortDir.arrow(), fg, bg, terminal.AttrBold)
}
}
}
func (a *App) renderRow(r tui.Region, y int, rec int32, m logfile.Meta, c colLayout, cursor bool) {
pinned := a.pins.Has(rec)
bg := a.th.Bg
switch {
case cursor:
bg = a.th.CursorBg
case pinned:
bg = a.th.FocusBg
}
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', a.th.Fg, bg, terminal.AttrNone)
}
if pinned {
r.Cell(c.pinX, y, '◆', a.th.Warning, bg, terminal.AttrBold)
}
collapsed := m.Flags&logfile.FlagSnapHead != 0 && !a.snap.Expanded(m.Snap)
// Source gutter: present only when more than one file is indexed
if c.srcW > 0 {
r.Cell(c.srcX, y, a.idx.SrcMark(m.Src), a.th.SrcColor(int(m.Src)), bg, terminal.AttrBold)
}
r.Text(c.tsX, y, tui.Truncate(logfile.StampText(m), c.tsW), a.th.HintFg, bg, terminal.AttrDim)
lvlFg := a.th.Level[logfile.LevelBad]
if m.Lvl < logfile.LevelCount {
lvlFg = a.th.Level[m.Lvl]
}
r.Cell(c.lvlX, y, rune(m.Lvl.Initial()), lvlFg, bg, terminal.AttrBold)
// Tick column appears only on wide terminals; the index carries it always
if c.tickW > 0 {
r.Text(c.tickX, y, tui.PadLeft(strconv.FormatUint(uint64(m.Tick), 10), c.tickW),
a.th.HintFg, bg, terminal.AttrDim)
}
sub := logfile.Dash(a.idx.SubName(m.Sub))
r.Text(c.subX, y, tui.PadRight(tui.Truncate(sub, c.subW), c.subW), a.th.SubColor(sub), bg, terminal.AttrNone)
if mark := snapMark(m, collapsed); mark != 0 {
r.Cell(c.markX, y, mark, a.th.SnapFg, bg, terminal.AttrNone)
}
msg, msgAttr := logfile.Dash(a.idx.MsgName(m.Msg)), terminal.AttrNone
if collapsed {
msg, msgAttr = "snapshot", terminal.AttrBold
}
r.Text(c.msgX, y, tui.PadRight(tui.Truncate(msg, c.msgW), c.msgW), a.th.Fg, bg, msgAttr)
if c.fldX >= r.W {
return
}
switch {
case m.Flags&logfile.FlagMalformed != 0:
r.Text(c.fldX, y, tui.Truncate("<malformed line>", r.W-c.fldX),
a.th.Level[logfile.LevelBad], bg, terminal.AttrItalic)
case collapsed:
r.Text(c.fldX, y, tui.Truncate(a.snapSummary(m), r.W-c.fldX), a.th.SnapFg, bg, terminal.AttrNone)
default:
r.Text(c.fldX, y, tui.Truncate(a.summary(m), r.W-c.fldX), a.th.StatusFg, bg, terminal.AttrNone)
}
}
// snapMark returns the group indicator: collapsed head, expanded head, member.
func snapMark(m logfile.Meta, collapsed bool) rune {
switch {
case m.Flags&logfile.FlagSnapHead != 0 && collapsed:
return '▶'
case m.Flags&logfile.FlagSnapHead != 0:
return '▼'
case m.Snap != 0:
return '·'
}
return 0
}
// snapSummary describes a collapsed group from the index alone, no line read.
func (a *App) snapSummary(m logfile.Meta) string {
if s, ok := a.idx.SnapshotOf(m); ok {
return fmt.Sprintf("stat snapshot ×%d run=%d tick=%d", s.Count, s.Run, s.Tick)
}
return fmt.Sprintf("stat snapshot run=%d tick=%d", m.Run, m.Tick)
}
// summary renders the fields column; the search filter matches this same text.
func (a *App) summary(m logfile.Meta) string {
line, err := a.rd.Line(m)
if err != nil {
return ""
}
a.rec.Parse(m, line)
return a.rec.FieldsText()
}
// --- detail ----------------------------------------------------------------
type detailRow struct {
k, v string
head bool
}
func (a *App) renderDetail(r tui.Region) {
r.Fill(a.th.Bg)
r.VLine(0, tui.LineSingle, a.th.Border)
c := r.Sub(2, 0, r.W-2, r.H)
if c.W < 8 || c.H < 2 {
return
}
rec := a.cursorRec()
m, ok := a.meta(rec)
if !ok {
c.TextCenter(0, "no record", a.th.HintFg, a.th.Bg, terminal.AttrDim)
return
}
line, err := a.rd.Line(m)
if err != nil {
c.Text(0, 0, "read: "+err.Error(), a.th.Error, a.th.Bg, terminal.AttrNone)
return
}
a.rec.Parse(m, line)
rows := make([]detailRow, 0, 10+len(a.rec.Fields))
appendWrapped := func(key, val string, head bool) string {
if val == "" {
rows = append(rows, detailRow{key, "", head})
return " "
}
for _, vline := range strings.Split(val, "\n") {
runes := []rune(vline)
if len(runes) == 0 {
rows = append(rows, detailRow{key, "", head})
key = " "
continue
}
for len(runes) > 0 {
width := max(10, c.W-tui.RuneLen(key)-2)
chunk := runes
if len(chunk) > width {
chunk = chunk[:width]
}
rows = append(rows, detailRow{key, string(chunk), head})
runes = runes[len(chunk):]
key = " "
}
}
return key
}
appendWrapped("time", a.rec.Time, true)
appendWrapped("level", m.Lvl.String(), true)
// Source line only earns its row in a merged view
if a.idx.SrcCount() > 1 {
appendWrapped("file", a.idx.SrcName(m.Src), true)
}
appendWrapped("sub", logfile.Dash(a.idx.SubName(m.Sub)), true)
appendWrapped("run", fmt.Sprint(m.Run), true)
appendWrapped("tick", fmt.Sprint(m.Tick), true)
appendWrapped("frame", fmt.Sprint(m.Frame), true)
if a.rec.Trace != "" {
key := "trace"
steps := strings.Split(a.rec.Trace, " -> ")
for i, step := range steps {
if i > 0 {
step = "-> " + step
}
key = appendWrapped(key, step, true)
}
}
if s, ok := a.idx.SnapshotOf(m); ok {
appendWrapped("snapshot", fmt.Sprintf("%d records, head %d", s.Count, s.Head), true)
}
if a.pins.Has(rec) {
appendWrapped("pinned", "yes", true)
}
rows = append(rows, detailRow{})
if a.rec.Bad {
raw := strings.TrimRight(string(line), "\r\n")
appendWrapped("raw", raw, false)
} else {
for _, f := range a.rec.Fields {
appendWrapped(f.Key, a.rec.Display(f), false)
}
}
ks := tui.Style{Fg: a.th.KeyFg}
a.dscroll = tui.ClampScroll(a.dscroll, c.H, len(rows))
for y := 0; y < c.H; y++ {
i := a.dscroll + y
if i >= len(rows) {
break
}
if rows[i].k == "" {
c.HLine(y, tui.LineSingle, a.th.Border)
continue
}
vs := tui.Style{Fg: a.th.StrFg}
if rows[i].head {
vs = tui.Style{Fg: a.th.Fg}
}
c.KeyValue(y, rows[i].k, rows[i].v, ks, vs, ':')
}
}
// --- footer / prompt -------------------------------------------------------
// footerActions lists the bindings specific to this viewer; generic movement
// and quit keys live in the help overlay. Chords come from the key table.
var footerActions = []struct {
act keys.Action
label string
}{
{keys.ActHelp, "help"},
{keys.ActOpen, "open"},
{keys.ActSearch, "find"},
{keys.ActFilter, "filter"},
{keys.ActFollowNext, "same"},
{keys.ActExpand, "snap"},
{keys.ActPinToggle, "pin"},
{keys.ActPinOnly, "only"},
{keys.ActExport, "export"},
{keys.ActColNext, "col"},
{keys.ActSort, "sort"},
}
func (a *App) renderFooter(r tui.Region) {
r.Fill(a.th.HeaderBg)
x := 1
for _, h := range footerActions {
k := " " + keys.KeysFor(keys.ModeNormal, h.act) + " "
l := h.label + " "
if x+tui.RuneLen(k)+tui.RuneLen(l) > r.W {
break
}
r.Text(x, 0, k, a.th.Bg, a.th.Accent, terminal.AttrBold)
x += tui.RuneLen(k)
r.Text(x, 0, l, a.th.HintFg, a.th.HeaderBg, terminal.AttrNone)
x += tui.RuneLen(l)
}
}
// renderPrompt replaces the footer row while text is being entered.
func (a *App) renderPrompt(r tui.Region) {
if r.W < 5 || r.H < 1 || a.prompt == nil {
return
}
st := tui.DefaultTextFieldStyle()
st.TextBg, st.PrefixFg, st.TextFg = a.th.InputBg, a.th.Accent, a.th.Fg
st.CursorBg, st.CursorFg = a.th.Fg, a.th.Bg
r.TextField(a.prompt, tui.TextFieldOpts{
Prefix: a.promptKind.prefix(a.col), Border: tui.LineNone,
Focused: true, Style: st,
})
}
// --- help ------------------------------------------------------------------
// helpLine is one rendered row of the help panel: a group heading or a binding.
type helpLine struct {
keys, text string
head bool
}
// buildHelpLines flattens the key table once at startup.
func buildHelpLines() []helpLine {
var out []helpLine
group := ""
for _, m := range []keys.Mode{keys.ModeNormal, keys.ModeOverlay} {
for _, row := range keys.HelpRows(m) {
if row.Group != group {
group = row.Group
if len(out) > 0 {
out = append(out, helpLine{})
}
out = append(out, helpLine{text: strings.ToUpper(group), head: true})
}
out = append(out, helpLine{keys: row.Keys, text: row.Help})
}
}
return out
}
func (a *App) renderHelp(root tui.Region) {
a.help.Rows = len(a.helpLines)
a.help.Title = "vif-log keys"
a.help.Hint = "↑↓ scroll esc close"
body := a.help.Render(root, &a.th)
if body.W < 10 || body.H < 1 {
return
}
ks := tui.Style{Fg: a.th.Accent, Bg: a.th.FocusBg, Attr: terminal.AttrBold}
vs := tui.Style{Fg: a.th.Fg, Bg: a.th.FocusBg}
for y := 0; y < body.H; y++ {
i := a.help.Scroll + y
if i >= len(a.helpLines) {
break
}
l := a.helpLines[i]
switch {
case l.head:
body.Text(1, y, l.text, a.th.Accent2, a.th.FocusBg, terminal.AttrBold)
case l.text != "":
body.KeyValue(y, l.keys, l.text, ks, vs, ' ')
}
}
}