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("", 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, ' ') } } }