commit 28e42523e172995d50f5521527d063efc2b1bd07 Author: Lixen Wraith Date: Mon Aug 3 09:58:31 2026 -0400 v0.1.0 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce3f0b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +bin/ +dev/ +logs/ +log/ +cmake-build-*/ +build.sh +vi-fighter +catalog.txt +combined.txt +web/*.wasm diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a36bbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Lixen Wraith + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..996163e --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: all + +all: + go build -o bin/vif-log cmd/vif-log/main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9fe902 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# vif-log + +A high-performance, terminal-based JSONL diagnostic log viewer engineered for the `vi-fighter` ECS game engine. + +Designed to parse and navigate high-frequency ECS event streams and state snapshots with minimal overhead. Features zero-allocation index passes, lazy-loaded line evaluation, and asynchronous chronological merging of multiple log sources. + +## Core Architecture + +* **Zero-Alloc Indexing**: Hand-rolled RFC3339Nano parser and JSON tokenizer (`internal/logfile`). Constructs a pointer-free 48-byte `Meta` struct per record. Raw JSON strings are interned to bitset-friendly integer IDs. +* **Asynchronous Render Pipeline**: Background indexers publish lock-free slice headers to the render thread. The UI remains responsive during multi-gigabyte ingestion. +* **Multi-Source K-Way Merge**: Loads multiple `.jsonl` files (e.g., cross-network client/server logs) and performs a stable chronological merge using nanosecond timestamps without mutating the row index. +* **Smart ECS Snapshotting**: High-frequency telemetry (ECS component states pushed per-tick) are automatically collapsed into single navigable rows (`Filter.Collapse`), expanding on demand. +* **Deferred Evaluation Stack**: The filter chain evaluates index-resident predicates (Tick, Run, Subsystem, Level) first. Costly operations (Regex over raw JSON fields) only trigger for surviving records that enter the sliding read window. + +## Build & Run + +Requires Go 1.26+ (Wayland environment natively supported via underlying TUI library). + +```bash +go build -o vif-log ./cmd/vif-log +``` + +## Usage + +```bash +# Open a specific log file or directory +vif-log path/to/run.jsonl +vif-log ./logs/ + +# Multi-file merge +vif-log server.jsonl client.jsonl + +# Pre-seed filter stack +vif-log -f level:>=WARN -f sub:^(fsm|event)$ -f tick:1000-5000 ./logs/ +``` + +### Predicates (Filters) + +Filters stack. Use `\` in the UI or `-f` via CLI. +* `level`: Exact match (`IWE`) or threshold (`>=WARN`). +* `sub` / `msg`: Smart-case regex evaluated against the interned vocabulary. +* `tick` / `run`: Numeric spans (`100-200`, `150-`, `-50`). +* `fields`: Smart-case regex evaluated over the parsed JSON fields. +* `find`: Column-scoped regex search (evaluates against time, tick, sub, msg, or fields). + +## Keybindings + +### Navigation & UI +| Key | Action | +| :--- | :--- | +| `j` / `k` | Move cursor down/up | +| `gg` / `G` | Jump to first/last record | +| `Ctrl+d` / `Ctrl+u` | Half-page down/up | +| `Tab` / `Shift+Tab`| Cycle column focus | +| `s` | Sort by focused column (asc/desc/off) | +| `J` / `K` | Scroll detail pane down/up | +| `Enter` | Expand/collapse ECS stat snapshot | +| `n` / `N` | Jump to next/prev snapshot head | +| `Ctrl+l` | Force redraw | + +### Search & Filtering +| Key | Action | +| :--- | :--- | +| `/` | Regex search in the currently focused column | +| `\` | Add/replace a filter in the stack (e.g., `msg:transition`) | +| `f` / `F` | Follow: Jump to next/prev record with identical `sub`/`msg`/context | +| `t` `d` `i` `w` `e` `p` `b` | Toggle visibility of exact log levels | +| `1`-`5` | Set minimum level threshold (1=TRACE, 5=ERROR) | +| `<` / `>` | Lower/Raise level threshold | +| `Esc` | Clear search and dynamic filters | + +### Buffer & File Management +| Key | Action | +| :--- | :--- | +| `Space` | Toggle pin on current record | +| `P` | Toggle pinned-only view | +| `C` | Clear all pins | +| `o` | Open file browser (supports multi-select with `Space`) | +| `x` | Export current view (or pins) to a new `.jsonl` file | diff --git a/cmd/vif-log/main.go b/cmd/vif-log/main.go new file mode 100644 index 0000000..60bc035 --- /dev/null +++ b/cmd/vif-log/main.go @@ -0,0 +1,83 @@ +// vif-log — terminal viewer for vi-fighter JSONL diagnostic logs. +package main + +import ( + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/lixenwraith/terminal" + "github.com/lixenwraith/vif-log/internal/app" + "github.com/lixenwraith/vif-log/internal/filter" +) + +const tickInterval = 50 * time.Millisecond // 20 fps clock + +// filterSpec collects repeatable -f kind:arg flags. +type filterSpec []string + +func (f *filterSpec) String() string { return strings.Join(*f, " ") } +func (f *filterSpec) Set(v string) error { + *f = append(*f, v) + return nil +} + +func main() { + var specs filterSpec + flag.Var(&specs, "f", "filter, repeatable: kind:regexp (sub msg tick run level find fields)") + + flag.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: vif-log [-f kind:arg]... [file.jsonl... | directory]") + flag.PrintDefaults() + fmt.Fprintln(os.Stderr, "\nfilters:") + for _, d := range filter.Kinds() { + fmt.Fprintf(os.Stderr, " %-6s %s\n", d.Kind, d.Help) + } + } + flag.Parse() + + term := terminal.New() + defer func() { + if r := recover(); r != nil { + terminal.EmergencyReset(os.Stdout) + panic(r) + } + }() + if err := term.Init(); err != nil { + fmt.Fprintln(os.Stderr, "terminal init:", err) + os.Exit(1) + } + + a, err := app.New(term, flag.Args(), specs) + if err != nil { + term.Fini() + fmt.Fprintln(os.Stderr, "init:", err) + os.Exit(1) + } + defer a.Close() + + // Synthetic tick: the input reader never emits {EventKey, KeyNone}, so it is + // a collision-free wake-up for index progress and incremental filtering. + done := make(chan struct{}) + go func() { + t := time.NewTicker(tickInterval) + defer t.Stop() + for { + select { + case <-done: + return + case <-t.C: + term.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone}) + } + } + }() + + for !a.Quit() { + a.Render() + a.Handle(term.PollEvent()) + } + close(done) + term.Fini() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bab4f96 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/lixenwraith/vif-log + +go 1.26.5 + +require ( + github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897 + github.com/lixenwraith/terminal v0.0.0-20260801131017-3d9631d1dbda +) + +require ( + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4be4abd --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897 h1:YkTK1vIzG6sqKRKaeUdkPyMT2laithY1d8tCqyId77U= +github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo= +github.com/lixenwraith/terminal v0.0.0-20260801131017-3d9631d1dbda h1:xaNK5HHVBWCvqr5qsq/KT5bbj77MO5//yZi8sDm4t1M= +github.com/lixenwraith/terminal v0.0.0-20260801131017-3d9631d1dbda/go.mod h1:4rwKdMTck5HljHtRyg+ZyQGIU1Xtgdvy8IVZY9t2fL8= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..0ae416c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,432 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/lixenwraith/terminal" + "github.com/lixenwraith/terminal/tui" + "github.com/lixenwraith/vif-log/internal/filter" + "github.com/lixenwraith/vif-log/internal/keys" + "github.com/lixenwraith/vif-log/internal/logfile" + "github.com/lixenwraith/vif-log/internal/ui" +) + +const ( + firstPassBudget = 40 * time.Millisecond // filter time spent on a filter change + stepBudget = 8 * time.Millisecond // filter time spent per tick + budgetCheck = 2048 // records tested between deadline checks + followScanCap = 1 << 21 + toastFrames = 40 + + minW = 80 + minH = 24 +) + +type overlayKind uint8 + +const ( + ovNone overlayKind = iota + ovHelp + ovOpen + ovPrompt +) + +// viewBuilder tracks the incremental filter pass. A filter change resets it; +// index growth extends it. +type viewBuilder struct { + next int + busy bool +} + +// App holds all viewer state. +type App struct { + term terminal.Terminal + th ui.Theme + lay ui.Layout + res *keys.Resolver + + idx *logfile.Index + rd *logfile.Reader // render path + frd *logfile.Reader // filter pass; disjoint window, no thrash + title string + + stack filter.Stack + lvl *filter.Level + snap *filter.Collapse + find *filter.Find + pins *filter.PinSet + pinOnly *filter.Pinned + + build viewBuilder + view []int32 // record indices, always ascending + order []int32 // display order when sorted + + col logfile.Column + sortCol logfile.Column + sortDir sortDir + + cursor int + scroll int + dscroll int + listH int + + overlay overlayKind + prompt *tui.TextFieldState + promptKind promptKind + browse browser + help ui.Panel + helpLines []helpLine + toast tui.ToastState + scanShown bool + + w, h int + frame int + quit bool + cells []terminal.Cell // reused frame buffer + + rec logfile.Record + fctx filter.Ctx +} + +// New builds the viewer. start may be log files, one directory to browse, or +// empty for the working directory. specs are "kind:arg" filters applied up front. +func New(t terminal.Terminal, start []string, specs []string) (*App, error) { + w, h := t.Size() + a := &App{ + term: t, th: ui.DefaultTheme, lay: ui.DefaultLayout(), res: keys.NewResolver(), + w: w, h: h, col: logfile.ColAll, sortCol: logfile.ColTime, + helpLines: buildHelpLines(), + } + a.help = ui.Panel{W: 70} + a.browse.panel.Cursor = true + + // Snapshots collapse by default: stat members are ~half the records in a + // typical log and arrive in blocks of ~40. enter expands. + a.lvl, a.snap, a.find = filter.NewLevel(), filter.NewCollapse(true), filter.NewFind() + a.pins = filter.NewPinSet() + a.pinOnly = filter.NewPinned(a.pins) + a.stack.Add(a.lvl) + a.stack.Add(a.snap) + a.stack.Add(a.pinOnly) + a.stack.Add(a.find) + + for _, s := range specs { + if err := a.applyFilterSpec(s); err != nil { + return nil, err + } + } + + a.initStart(start) + return a, nil +} + +// applyFilterSpec parses "kind:arg" and installs it. Kinds owned by a key +// binding are routed to their owner so the app keeps a single instance. +func (a *App) applyFilterSpec(spec string) error { + kind, arg, ok := strings.Cut(spec, ":") + if !ok { + return fmt.Errorf("filter: expected kind:arg, got %q", spec) + } + kind = strings.TrimSpace(kind) + switch kind { + case "level": + f, err := filter.New(kind, arg) + if err != nil { + return err + } + a.lvl.Mask = f.(*filter.Level).Mask + return nil + case "find": + return a.find.Set(arg, a.col) + case "snap", "pin": + return fmt.Errorf("filter: %s is bound to a key, not a spec", kind) + } + if strings.TrimSpace(arg) == "" { + a.stack.Remove(kind) + return nil + } + f, err := filter.New(kind, arg) + if err != nil { + return err + } + a.stack.Set(f) + return nil +} + +// initStart loads the named files, or opens the browser at the right directory. +func (a *App) initStart(start []string) { + dir := "." + if len(start) == 1 { + if fi, err := os.Stat(start[0]); err == nil && fi.IsDir() { + a.browse.dir = start[0] + a.openBrowser() + return + } + } + if len(start) > 0 { + a.browse.dir = filepath.Dir(start[0]) + if err := a.openFiles(start); err == nil { + return + } else { + a.say(tui.ToastError, err.Error()) + } + } + if a.browse.dir == "" { + a.browse.dir = dir + } + a.openBrowser() +} + +// openFiles replaces the loaded set with one merged index. Level, search, sort +// and stack filters survive; pins, snapshot exceptions and the cursor reset. +func (a *App) openFiles(paths []string) error { + idx, err := logfile.Open(paths...) + if err != nil { + return err + } + rd, err := idx.NewReader() + if err != nil { + return err + } + frd, err := idx.NewReader() + if err != nil { + rd.Close() + return err + } + if a.rd != nil { + a.rd.Close() + } + if a.frd != nil { + a.frd.Close() + } + a.idx, a.rd, a.frd = idx, rd, frd + a.title = idx.SrcName(0) + if n := idx.SrcCount(); n > 1 { + a.title = fmt.Sprintf("%s +%d", a.title, n-1) + } + a.pins.Clear() + a.pinOnly.On = false + a.snap.ResetGroups() + a.view, a.order = a.view[:0], a.order[:0] + a.cursor, a.scroll, a.dscroll = 0, 0, 0 + a.build = viewBuilder{} + a.scanShown = false + a.rebuild() + a.say(tui.ToastSuccess, fmt.Sprintf("opened %d file(s)", idx.SrcCount())) + return nil +} + +// Close releases the line readers. +func (a *App) Close() { + if a.rd != nil { + a.rd.Close() + } + if a.frd != nil { + a.frd.Close() + } +} + +// Quit reports whether the event loop should stop. +func (a *App) Quit() bool { return a.quit } + +// --- event handling -------------------------------------------------------- + +// Handle processes one terminal event. +func (a *App) Handle(ev terminal.Event) { + switch ev.Type { + case terminal.EventResize: + a.w, a.h = ev.Width, ev.Height + return + case terminal.EventError, terminal.EventClosed: + a.quit = true + return + case terminal.EventKey: + if ev.Key == terminal.KeyNone { // synthetic tick + a.tick() + return + } + default: + return + } + + if a.overlay == ovPrompt { + a.handlePrompt(ev) + return + } + + mode := keys.ModeNormal + if a.overlay != ovNone { + mode = keys.ModeOverlay + } + act := a.res.Resolve(mode, keys.FromEvent(ev)) + if act == keys.ActNone { + return + } + table := actions + if mode == keys.ModeOverlay { + table = overlayActions + } + if fn := table[act]; fn != nil { + fn(a) + } +} + +// handlePrompt routes text entry; the binding table has no place for it. +func (a *App) handlePrompt(ev terminal.Event) { + switch ev.Key { + case terminal.KeyEscape: + a.overlay = ovNone + case terminal.KeyEnter: + a.commitPrompt() + default: + a.prompt.HandleKey(ev.Key, ev.Rune, ev.Modifiers) + } +} + +// tick advances animation and extends the filter pass into newly indexed rows. +func (a *App) tick() { + a.frame++ + if a.toast.Visible { + a.toast.Tick() + } + if a.build.busy || a.build.next < a.idx.Len() { + a.build.busy = true + a.filterStep(stepBudget) + if !a.build.busy { + a.applySort() + } + } + if !a.scanShown { + if err := a.idx.Err(); err != nil { + a.scanShown = true + a.say(tui.ToastError, "scan: "+err.Error()) + } + } + a.clamp() +} + +// panel returns the scrollable body of the open overlay, nil when none is. +func (a *App) panel() *ui.Panel { + switch a.overlay { + case ovHelp: + return &a.help + case ovOpen: + return &a.browse.panel + } + return nil +} + +func (a *App) toggleLevel(l logfile.Level) { + a.lvl.Toggle(l) + a.rebuild() +} + +func (a *App) threshold(l logfile.Level) { + a.lvl.ThresholdToggle(l) + a.rebuild() +} + +func (a *App) say(sev tui.ToastSeverity, msg string) { + o := tui.DefaultToastOpts(msg, sev) + o.Position = tui.ToastBottomRight + o.Style = tui.ToastStyleRounded + a.toast.Show(o, toastFrames) +} + +// actions maps key actions to behaviour; keys knows nothing about App. +var actions = map[keys.Action]func(*App){ + keys.ActQuit: func(a *App) { a.quit = true }, + keys.ActRedraw: func(a *App) { a.term.Sync() }, + keys.ActHelp: func(a *App) { a.overlay = ovHelp }, + keys.ActCloseOverlay: func(a *App) { a.overlay = ovNone }, + + keys.ActDown: func(a *App) { a.move(1) }, + keys.ActUp: func(a *App) { a.move(-1) }, + keys.ActPageDown: func(a *App) { a.move(max(1, a.listH)) }, + keys.ActPageUp: func(a *App) { a.move(-max(1, a.listH)) }, + keys.ActHalfDown: func(a *App) { a.move(tui.PageDelta(a.listH)) }, + keys.ActHalfUp: func(a *App) { a.move(-tui.PageDelta(a.listH)) }, + keys.ActTop: func(a *App) { a.cursor, a.dscroll = 0, 0; a.clamp() }, + keys.ActBottom: func(a *App) { a.cursor, a.dscroll = len(a.rows())-1, 0; a.clamp() }, + + keys.ActColNext: func(a *App) { a.cycleColumn(1) }, + keys.ActColPrev: func(a *App) { a.cycleColumn(-1) }, + keys.ActSort: (*App).cycleSort, + keys.ActDetailDown: func(a *App) { a.dscroll++ }, + keys.ActDetailUp: func(a *App) { a.dscroll = max(0, a.dscroll-1) }, + + keys.ActSearch: (*App).openSearch, + keys.ActFollowNext: func(a *App) { a.followJump(1) }, + keys.ActFollowPrev: func(a *App) { a.followJump(-1) }, + keys.ActFilter: (*App).openFilter, + keys.ActClear: (*App).clearState, + + keys.ActExpand: (*App).toggleSnapshot, + keys.ActSnapNext: (*App).nextSnapshot, + keys.ActSnapPrev: (*App).prevSnapshot, + + keys.ActPinToggle: (*App).togglePin, + keys.ActPinOnly: (*App).togglePinOnly, + keys.ActPinClear: (*App).clearPins, + + keys.ActOpen: (*App).openBrowser, + keys.ActExport: (*App).openExport, + + keys.ActLvlTrace: func(a *App) { a.toggleLevel(logfile.LevelTrace) }, + keys.ActLvlDebug: func(a *App) { a.toggleLevel(logfile.LevelDebug) }, + keys.ActLvlInfo: func(a *App) { a.toggleLevel(logfile.LevelInfo) }, + keys.ActLvlWarn: func(a *App) { a.toggleLevel(logfile.LevelWarn) }, + keys.ActLvlError: func(a *App) { a.toggleLevel(logfile.LevelError) }, + keys.ActLvlProc: func(a *App) { a.toggleLevel(logfile.LevelProc) }, + keys.ActLvlBad: func(a *App) { a.toggleLevel(logfile.LevelBad) }, + keys.ActLvlAll: func(a *App) { a.lvl.SetAll(true); a.rebuild() }, + keys.ActLvlRaise: func(a *App) { a.lvl.Shift(1); a.rebuild() }, + keys.ActLvlLower: func(a *App) { a.lvl.Shift(-1); a.rebuild() }, + keys.ActThresh1: func(a *App) { a.threshold(logfile.LevelTrace) }, + keys.ActThresh2: func(a *App) { a.threshold(logfile.LevelDebug) }, + keys.ActThresh3: func(a *App) { a.threshold(logfile.LevelInfo) }, + keys.ActThresh4: func(a *App) { a.threshold(logfile.LevelWarn) }, + keys.ActThresh5: func(a *App) { a.threshold(logfile.LevelError) }, +} + +// overlayActions drive the focused panel; movement keys are shared with the +// record list and resolve here only while an overlay is open. +var overlayActions = map[keys.Action]func(*App){ + keys.ActCloseOverlay: func(a *App) { a.overlay = ovNone }, + keys.ActQuit: func(a *App) { a.overlay = ovNone }, + + keys.ActDown: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Move(1) }) }, + keys.ActUp: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Move(-1) }) }, + keys.ActPageDown: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Page(1) }) }, + keys.ActPageUp: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Page(-1) }) }, + keys.ActHalfDown: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Half(1) }) }, + keys.ActHalfUp: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Half(-1) }) }, + keys.ActTop: func(a *App) { a.panelDo((*ui.Panel).First) }, + keys.ActBottom: func(a *App) { a.panelDo((*ui.Panel).Last) }, + + keys.ActMark: func(a *App) { + if a.overlay == ovOpen { + a.browse.toggleMark() + } + }, + keys.ActConfirm: func(a *App) { + if a.overlay == ovOpen { + a.browserEnter() + } + }, + keys.ActBack: func(a *App) { + if a.overlay == ovOpen { + a.browserUp() + } + }, +} + +func (a *App) panelDo(fn func(*ui.Panel)) { + if p := a.panel(); p != nil { + fn(p) + } +} diff --git a/internal/app/browser.go b/internal/app/browser.go new file mode 100644 index 0000000..b278f9a --- /dev/null +++ b/internal/app/browser.go @@ -0,0 +1,223 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/lixenwraith/terminal" + "github.com/lixenwraith/terminal/tui" + "github.com/lixenwraith/vif-log/internal/ui" +) + +// fileRow is one entry in the open panel. +type fileRow struct { + name string + dir bool + up bool + marked bool + size int64 + mtime time.Time +} + +// browser is the directory navigator behind the open overlay. +type browser struct { + dir string + rows []fileRow + panel ui.Panel + err string +} + +// load reads dir: parent first, then directories, then files, each group most +// recently modified first — the log you just produced is the one you want. +func (b *browser) load(dir string) { + if abs, err := filepath.Abs(dir); err == nil { + dir = abs + } + b.dir, b.err, b.rows = dir, "", b.rows[:0] + + des, err := os.ReadDir(dir) + if err != nil { + b.err = err.Error() + } + if parent := filepath.Dir(dir); parent != dir { + b.rows = append(b.rows, fileRow{name: "..", dir: true, up: true}) + } + + rows := make([]fileRow, 0, len(des)) + for _, de := range des { + if strings.HasPrefix(de.Name(), ".") { + continue + } + fi, err := de.Info() + if err != nil { + continue + } + rows = append(rows, fileRow{ + name: de.Name(), dir: de.IsDir(), size: fi.Size(), mtime: fi.ModTime(), + }) + } + slices.SortFunc(rows, func(x, y fileRow) int { + if x.dir != y.dir { + if x.dir { + return -1 + } + return 1 + } + return y.mtime.Compare(x.mtime) + }) + + b.rows = append(b.rows, rows...) + b.panel.Reset() +} + +func (b *browser) selected() (fileRow, bool) { + if b.panel.Sel < 0 || b.panel.Sel >= len(b.rows) { + return fileRow{}, false + } + return b.rows[b.panel.Sel], true +} + +// toggleMark selects a file for multi-open; directories are not markable. +func (b *browser) toggleMark() { + if b.panel.Sel < 0 || b.panel.Sel >= len(b.rows) || b.rows[b.panel.Sel].dir { + return + } + b.rows[b.panel.Sel].marked = !b.rows[b.panel.Sel].marked + b.panel.Move(1) +} + +// marked returns the absolute paths of every marked file. +func (b *browser) marked() []string { + var out []string + for _, r := range b.rows { + if r.marked { + out = append(out, filepath.Join(b.dir, r.name)) + } + } + return out +} + +// --- app hooks ------------------------------------------------------------- + +// openBrowser shows the file panel, refreshing the listing. +func (a *App) openBrowser() { + dir := a.browse.dir + if dir == "" { + dir = "." + } + a.browse.load(dir) + a.overlay = ovOpen +} + +// browserEnter descends into the selected directory, or loads the marked set +// (falling back to the cursor file when nothing is marked). +func (a *App) browserEnter() { + if paths := a.browse.marked(); len(paths) > 0 { + if err := a.openFiles(paths); err != nil { + a.say(tui.ToastError, err.Error()) + return + } + a.overlay = ovNone + return + } + row, ok := a.browse.selected() + if !ok { + return + } + path := filepath.Join(a.browse.dir, row.name) + if row.dir { + a.browse.load(path) + return + } + if err := a.openFiles([]string{path}); err != nil { + a.say(tui.ToastError, err.Error()) + return + } + a.overlay = ovNone +} + +func (a *App) browserUp() { a.browse.load(filepath.Dir(a.browse.dir)) } + +// --- render ---------------------------------------------------------------- + +func (a *App) renderBrowser(root tui.Region) { + b := &a.browse + b.panel.Rows = len(b.rows) + b.panel.Title = "open - " + tui.TruncateLeft(b.dir, max(12, root.W/3)) + b.panel.Hint = "spc mark enter open esc close" + b.panel.Status = b.status() + + body := b.panel.Render(root, &a.th) + if body.W < 24 || body.H < 1 { + return + } + + const sizeW, timeW = 7, 16 + nameW := max(8, body.W-sizeW-timeW-2) + + for y := 0; y < body.H; y++ { + i := b.panel.Scroll + y + if i >= len(b.rows) { + break + } + row := b.rows[i] + + bg := a.th.FocusBg + if i == b.panel.Sel { + bg = a.th.CursorBg + } + for x := 0; x < body.W; x++ { + body.Cell(x, y, ' ', a.th.Fg, bg, terminal.AttrNone) + } + + name, fg, attr := row.name, a.th.Fg, terminal.AttrNone + if row.dir { + name, fg, attr = name+"/", a.th.Accent, terminal.AttrBold + } + if row.marked { + name, fg, attr = "◆ "+name, a.th.Selected, terminal.AttrBold + } + body.Text(0, y, tui.Truncate(name, nameW), fg, bg, attr) + if !row.dir { + body.Text(nameW+1, y, tui.PadLeft(humanSize(row.size), sizeW), + a.th.NumFg, bg, terminal.AttrNone) + } + if !row.up { + body.Text(nameW+sizeW+2, y, row.mtime.Format("2006-01-02 15:04"), + a.th.HintFg, bg, terminal.AttrDim) + } + } +} + +// status is the untruncated name of the selection, or the read error. +func (b *browser) status() string { + if b.err != "" { + return b.err + } + if row, ok := b.selected(); ok { + return row.name + } + return b.dir +} + +// humanSize renders a byte count in at most six characters. +func humanSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%dB", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + v := float64(n) / float64(div) + if v < 10 { + return fmt.Sprintf("%.1f%c", v, "KMGTPE"[exp]) + } + return fmt.Sprintf("%.0f%c", v, "KMGTPE"[exp]) +} diff --git a/internal/app/render.go b/internal/app/render.go new file mode 100644 index 0000000..f54a900 --- /dev/null +++ b/internal/app/render.go @@ -0,0 +1,670 @@ +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, ' ') + } + } +} diff --git a/internal/app/view.go b/internal/app/view.go new file mode 100644 index 0000000..5d342b2 --- /dev/null +++ b/internal/app/view.go @@ -0,0 +1,550 @@ +package app + +import ( + "cmp" + "fmt" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/lixenwraith/terminal/tui" + "github.com/lixenwraith/vif-log/internal/export" + "github.com/lixenwraith/vif-log/internal/filter" + "github.com/lixenwraith/vif-log/internal/logfile" +) + +// sortDir is the display order of the sort column. +type sortDir uint8 + +const ( + sortNone sortDir = iota + sortAsc + sortDesc +) + +func (d sortDir) arrow() rune { return [...]rune{' ', '↑', '↓'}[d] } +func (d sortDir) String() string { + return [...]string{"off", "asc", "desc"}[d] +} + +// sortable reports whether a column's key lives in the index row. Sorting on +// fields would parse the whole view on every keystroke. +func sortable(c logfile.Column) bool { + return c == logfile.ColTime || c == logfile.ColTick || + c == logfile.ColSub || c == logfile.ColMsg +} + +// --- view and display order ------------------------------------------------ + +// sorted reports whether a current sorted order exists. +func (a *App) sorted() bool { + return a.sortDir != sortNone && len(a.order) == len(a.view) && len(a.view) > 0 +} + +// rows returns the display order: the index-ordered view unless a sort is +// active and its result is current. +func (a *App) rows() []int32 { + if a.sorted() { + return a.order + } + return a.view +} + +// rebuild restarts the filter pass, keeping the focused record on the same +// screen row so a filter change never scrolls the list. +func (a *App) rebuild() { + anchor := a.cursorRec() + row := min(max(a.cursor-a.scroll, 0), max(a.listH-1, 0)) + + a.stack.Compile() + a.view = a.view[:0] + a.build = viewBuilder{busy: true} + a.filterStep(firstPassBudget) + if !a.build.busy { + a.applySort() // the order is only meaningful over a complete pass + } + a.seek(anchor) + + a.scroll = a.cursor - row + a.clamp() +} + +// filterStep tests records until the deadline, appending survivors to the view. +func (a *App) filterStep(budget time.Duration) { + metas := a.idx.Metas() + n := len(metas) + if a.build.next >= n { + a.build.busy = false + return + } + a.fctx.Bind(a.idx, a.frd) + deadline := time.Now().Add(budget) + i := a.build.next + for i < n { + end := min(i+budgetCheck, n) + for ; i < end; i++ { + a.fctx.Reset(i, metas[i]) + if a.stack.Match(&a.fctx) { + a.view = append(a.view, int32(i)) + } + } + if time.Now().After(deadline) { + break + } + } + a.build.next = i + a.build.busy = i < n +} + +// applySort rebuilds the display order from index-resident keys. +func (a *App) applySort() { + if a.sortDir == sortNone || !sortable(a.sortCol) { + a.order = a.order[:0] + return + } + a.order = append(a.order[:0], a.view...) + metas := a.idx.Metas() + key := a.sortKey() + desc := a.sortDir == sortDesc + slices.SortStableFunc(a.order, func(x, y int32) int { + c := cmp.Compare(key(metas[x]), key(metas[y])) + if desc { + c = -c + } + if c != 0 { + return c + } + return cmp.Compare(x, y) // ties keep chronological order + }) +} + +func (a *App) sortKey() func(logfile.Meta) int64 { + switch a.sortCol { + case logfile.ColTick: + return func(m logfile.Meta) int64 { return int64(m.Tick) } + case logfile.ColSub: + r := ranks(a.idx.Subs()) + return func(m logfile.Meta) int64 { return rankOf(r, int(m.Sub)) } + case logfile.ColMsg: + r := ranks(a.idx.Msgs()) + return func(m logfile.Meta) int64 { return rankOf(r, int(m.Msg)) } + default: + return func(m logfile.Meta) int64 { return m.TS } + } +} + +// ranks maps interned ids to alphabetical position so the sort compares ints. +func ranks(names []string) []int32 { + ord := make([]int32, len(names)) + for i := range ord { + ord[i] = int32(i) + } + slices.SortFunc(ord, func(x, y int32) int { return strings.Compare(names[x], names[y]) }) + out := make([]int32, len(names)) + for r, id := range ord { + out[id] = int32(r) + } + return out +} + +func rankOf(r []int32, id int) int64 { + if id < 0 || id >= len(r) { + return -1 + } + return int64(r[id]) +} + +// cycleSort advances the sort on the focused column. +func (a *App) cycleSort() { + if !sortable(a.col) { + a.say(tui.ToastWarning, "sort: "+a.col.String()+" has no index key") + return + } + if a.sortCol != a.col { + a.sortCol, a.sortDir = a.col, sortNone + } + switch a.sortDir { + case sortNone: + a.sortDir = sortAsc + case sortAsc: + a.sortDir = sortDesc + default: + a.sortDir = sortNone + } + anchor := a.cursorRec() + a.applySort() + a.seek(anchor) + a.clamp() +} + +// --- cursor ---------------------------------------------------------------- + +func (a *App) cursorRec() int32 { + rows := a.rows() + if a.cursor < 0 || a.cursor >= len(rows) { + return -1 + } + return rows[a.cursor] +} + +func (a *App) meta(rec int32) (logfile.Meta, bool) { + metas := a.idx.Metas() + if rec < 0 || int(rec) >= len(metas) { + return logfile.Meta{}, false + } + return metas[rec], true +} + +// indexOf locates rec in the display order. Unsorted, a miss yields the +// insertion point — the nearest following record; sorted, it yields -1. +func (a *App) indexOf(rec int32) (int, bool) { + if a.sorted() { + i := slices.Index(a.order, rec) + return i, i >= 0 + } + return slices.BinarySearch(a.view, rec) +} + +// seek places the cursor on rec, falling back to its snapshot head when rec +// was filtered out — the head survives collapse. +func (a *App) seek(rec int32) { + rows := a.rows() + if rec < 0 || len(rows) == 0 { + a.cursor = 0 + return + } + i, found := a.indexOf(rec) + if !found { + if m, ok := a.meta(rec); ok { + if s, ok := a.idx.SnapshotOf(m); ok { + if j, ok := a.indexOf(int32(s.Head)); ok { + i, found = j, true + } + } + } + } + if !found && i < 0 { + i = a.cursor + } + a.cursor = min(max(i, 0), len(rows)-1) +} + +func (a *App) clamp() { + n := len(a.rows()) + if n == 0 { + a.cursor, a.scroll = 0, 0 + return + } + a.cursor = tui.ClampCursor(a.cursor, n) + a.scroll = tui.ClampScroll(a.scroll, a.listH, n) + a.scroll = tui.AdjustScroll(a.cursor, a.scroll, a.listH, n) +} + +func (a *App) move(d int) { + a.cursor += d + a.dscroll = 0 + a.clamp() +} + +// --- follow ---------------------------------------------------------------- + +// followKey identifies records that look the same as the focused one: the +// interned (sub, msg) pair plus the first string field, which is what varies +// within a pair — ev for event dispatch, service for service records. +type followKey struct { + sub uint16 + msg uint32 + val string +} + +func (a *App) followKeyOf(rec int32) (followKey, bool) { + m, ok := a.meta(rec) + if !ok { + return followKey{}, false + } + k := followKey{sub: m.Sub, msg: m.Msg} + if line, err := a.rd.Line(m); err == nil { + a.rec.Parse(m, line) + k.val = a.rec.FollowValue() + } + return k, true +} + +// followJump moves to the next record sharing the focused record's key. The +// index pre-check means only candidate lines are read. +func (a *App) followJump(dir int) { + rows := a.rows() + cur := a.cursorRec() + if cur < 0 { + return + } + k, ok := a.followKeyOf(cur) + if !ok { + return + } + metas := a.idx.Metas() + for i, n := a.cursor+dir, 0; i >= 0 && i < len(rows) && n < followScanCap; i, n = i+dir, n+1 { + m := metas[rows[i]] + if m.Sub != k.sub || m.Msg != k.msg { + continue + } + if k.val != "" { + line, err := a.rd.Line(m) + if err != nil { + continue + } + a.rec.Parse(m, line) + if a.rec.FollowValue() != k.val { + continue + } + } + a.cursor = i + a.dscroll = 0 + a.clamp() + return + } + a.say(tui.ToastWarning, "no more "+a.followLabel(k)) +} + +func (a *App) followLabel(k followKey) string { + s := logfile.Dash(a.idx.SubName(k.sub)) + "/" + logfile.Dash(a.idx.MsgName(k.msg)) + if k.val != "" { + s += " " + k.val + } + return s +} + +// --- snapshot, pins, column ------------------------------------------------ + +// toggleSnapshot expands or collapses the group under the cursor, anchoring on +// the head so the surrounding rows stay put. +func (a *App) toggleSnapshot() { + m, ok := a.meta(a.cursorRec()) + if !ok || m.Snap == 0 { + a.say(tui.ToastInfo, "not a stat snapshot") + return + } + if s, ok := a.idx.SnapshotOf(m); ok { + if i, found := a.indexOf(int32(s.Head)); found { + a.cursor = i + } + } + a.snap.ToggleGroup(m.Snap) + a.rebuild() +} + +func (a *App) togglePin() { + rec := a.cursorRec() + if rec < 0 { + return + } + a.pins.Toggle(rec) + if a.pinOnly.On { + a.rebuild() + return + } + a.move(1) // pinning a run should not need two keys per record +} + +func (a *App) togglePinOnly() { + if !a.pinOnly.On && a.pins.Len() == 0 { + a.say(tui.ToastWarning, "no pinned records") + return + } + a.pinOnly.On = !a.pinOnly.On + a.rebuild() +} + +func (a *App) clearPins() { + n := a.pins.Len() + a.pins.Clear() + a.pinOnly.On = false + a.rebuild() + a.say(tui.ToastInfo, fmt.Sprintf("cleared %d pin(s)", n)) +} + +// cycleColumn moves the focus, re-running an active search in the new scope. +func (a *App) cycleColumn(d int) { + a.col = a.col.Next(d) + if a.find.Active() { + _ = a.find.Set(a.find.Query, a.col) + a.rebuild() + } +} + +// nextSnapshot jumps the cursor to the nearest downward snapshot head. +func (a *App) nextSnapshot() { + rows := a.rows() + if len(rows) == 0 { + return + } + start := a.cursor + 1 + metas := a.idx.Metas() + + for i := start; i < len(rows); i++ { + rec := rows[i] + if int(rec) < len(metas) && metas[rec].Flags&logfile.FlagSnapHead != 0 { + a.cursor = i + a.dscroll = 0 + a.clamp() + return + } + } + a.say(tui.ToastWarning, "no more snapshots below") +} + +// prevSnapshot jumps the cursor to the nearest upward snapshot head. +func (a *App) prevSnapshot() { + rows := a.rows() + if len(rows) == 0 { + return + } + start := a.cursor - 1 + metas := a.idx.Metas() + + for i := start; i >= 0; i-- { + rec := rows[i] + if int(rec) < len(metas) && metas[rec].Flags&logfile.FlagSnapHead != 0 { + a.cursor = i + a.dscroll = 0 + a.clamp() + return + } + } + a.say(tui.ToastWarning, "no more snapshots above") +} + +// --- prompt: search and export --------------------------------------------- + +type promptKind uint8 + +const ( + prFind promptKind = iota + prFilter + prExport +) + +// prefix labels the prompt line and identifies the pending action. +func (k promptKind) prefix(col logfile.Column) string { + switch k { + case prExport: + return "export to: " + case prFilter: + return "filter: " + } + return "/" + col.String() + " " +} + +func (a *App) openPrompt(k promptKind, initial string) { + a.promptKind = k + a.prompt = tui.NewTextFieldState(initial) + a.overlay = ovPrompt +} + +func (a *App) openSearch() { a.openPrompt(prFind, a.find.Query) } + +func (a *App) openExport() { + if a.idx == nil { + return + } + a.openPrompt(prExport, defaultExportName(a.title)) +} + +func (a *App) openFilter() { a.openPrompt(prFilter, "") } + +func (a *App) commitPrompt() { + a.overlay = ovNone + switch a.promptKind { + case prFind: + if err := a.find.Set(a.prompt.Value(), a.col); err != nil { + a.say(tui.ToastError, err.Error()) + return + } + a.rebuild() + case prFilter: + if err := a.applyFilterSpec(strings.TrimSpace(a.prompt.Value())); err != nil { + a.say(tui.ToastError, err.Error()) + return + } + a.rebuild() + case prExport: + a.runExport(strings.TrimSpace(a.prompt.Value())) + } +} + +// clearState drops the active search and any dynamically added stack filters, +// leaving core persistent filters (level, snap, pin) intact. +func (a *App) clearState() { + changed := false + if a.find.Active() { + _ = a.find.Set("", a.col) + changed = true + } + + var keep []filter.Entry + for _, e := range a.stack.Entries { + switch e.F.Kind() { + case "level", "snap", "pin", "find": + keep = append(keep, e) + default: + changed = true + } + } + + if changed { + a.stack.Entries = keep + a.rebuild() + } +} + +// exportSet returns the records to export: the pin buffer when it holds +// anything, otherwise the current result. +func (a *App) exportSet() ([]logfile.Meta, string) { + src, what := a.rows(), "filtered" + if a.pins.Len() > 0 { + src, what = a.pins.Sorted(), "pinned" + } + metas := a.idx.Metas() + out := make([]logfile.Meta, 0, len(src)) + for _, i := range src { + if int(i) < len(metas) { + out = append(out, metas[i]) + } + } + return out, what +} + +func (a *App) runExport(path string) { + if path == "" || a.idx == nil { + return + } + if filepath.Ext(path) == "" { + path += export.JSONL{}.Ext() + } + set, what := a.exportSet() + if len(set) == 0 { + a.say(tui.ToastWarning, "nothing to export") + return + } + n, err := export.ToFile(path, export.JSONL{}, a.rd, set) + if err != nil { + a.say(tui.ToastError, err.Error()) + return + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + a.say(tui.ToastSuccess, fmt.Sprintf("%d %s → %s", n, what, abs)) +} + +// defaultExportName is timestamped: exports are exclusive-create, so a fixed +// name would collide on the second export. +func defaultExportName(src string) string { + base := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src)) + if base == "" || base == "." { + base = "vif-log" + } + return base + "-" + time.Now().Format("150405") + ".jsonl" +} diff --git a/internal/export/export.go b/internal/export/export.go new file mode 100644 index 0000000..d803ab8 --- /dev/null +++ b/internal/export/export.go @@ -0,0 +1,55 @@ +package export + +import ( + "bufio" + "io" + "os" + + "github.com/lixenwraith/vif-log/internal/logfile" +) + +// Exporter serialises a record set. JSONL is the only implementation; a plain +// text renderer is the second. +type Exporter interface { + Ext() string + Write(w io.Writer, rd *logfile.Reader, metas []logfile.Meta) (int, error) +} + +// JSONL writes the verbatim source lines, so an export reopens in the viewer. +type JSONL struct{} + +func (JSONL) Ext() string { return ".jsonl" } + +func (JSONL) Write(w io.Writer, rd *logfile.Reader, metas []logfile.Meta) (int, error) { + bw := bufio.NewWriterSize(w, 1<<16) + n := 0 + for _, m := range metas { + line, err := rd.Line(m) + if err != nil { + return n, err + } + if _, err := bw.Write(line); err != nil { + return n, err + } + if err := bw.WriteByte('\n'); err != nil { + return n, err + } + n++ + } + return n, bw.Flush() +} + +// ToFile writes metas to path. The file is created exclusively: an export can +// never overwrite a log. +func ToFile(path string, e Exporter, rd *logfile.Reader, metas []logfile.Meta) (int, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return 0, err + } + defer f.Close() + n, err := e.Write(f, rd, metas) + if err != nil { + return n, err + } + return n, f.Sync() +} diff --git a/internal/filter/fields.go b/internal/filter/fields.go new file mode 100644 index 0000000..b007c3f --- /dev/null +++ b/internal/filter/fields.go @@ -0,0 +1,47 @@ +package filter + +import "github.com/lixenwraith/vif-log/internal/logfile" + +func init() { + Register(Desc{Kind: "fields", Help: "regexp over the parsed fields, smart-case", New: newFields}) +} + +// Fields applies a regex to the fields column. This forces disk reads for +// surviving rows to evaluate the parsed JSON, so it evaluates last. +type Fields struct { + Query string + pat Pattern +} + +func newFields(arg string) (Filter, error) { + p, err := NewPattern(arg) + if err != nil { + return nil, err + } + return &Fields{Query: arg, pat: p}, nil +} + +func (f *Fields) Kind() string { return "fields" } + +func (f *Fields) Needs() Need { + if f.pat.Empty() { + return 0 + } + return NeedFields +} + +func (f *Fields) Match(c *Ctx) bool { + if f.pat.Empty() { + return true + } + // Scopes the regex exactly to the rendered "key=value" string, + // excluding the discriminator (msg). + return f.pat.MatchBytes(c.Record().ColumnBytes(logfile.ColFields)) +} + +func (f *Fields) Label() string { + if f.pat.Empty() { + return "" + } + return "fields:" + f.Query +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go new file mode 100644 index 0000000..5decd43 --- /dev/null +++ b/internal/filter/filter.go @@ -0,0 +1,198 @@ +package filter + +import ( + "fmt" + "sort" + + "github.com/lixenwraith/vif-log/internal/logfile" +) + +// Need declares what a filter must touch beyond the index row. +type Need uint8 + +const ( + NeedRaw Need = 1 << iota // raw line bytes + NeedFields // parsed fields +) + +// Ctx is the per-record evaluation context. Raw bytes and parsed fields are +// fetched lazily, so index-only predicates never touch the file. +type Ctx struct { + Idx *logfile.Index + Meta logfile.Meta + I int + + rd *logfile.Reader + raw []byte + rec logfile.Record + rawOK bool + recOK bool +} + +// Bind attaches the index and the line reader used for raw access. +func (c *Ctx) Bind(idx *logfile.Index, rd *logfile.Reader) { c.Idx, c.rd = idx, rd } + +// Reset points the context at record i. +func (c *Ctx) Reset(i int, m logfile.Meta) { + c.I, c.Meta = i, m + c.raw, c.rawOK, c.recOK = nil, false, false +} + +// Raw returns the record's line bytes, nil when unavailable. +func (c *Ctx) Raw() []byte { + if !c.rawOK { + c.rawOK = true + if c.rd != nil { + c.raw, _ = c.rd.Line(c.Meta) + } + } + return c.raw +} + +// Record returns the parsed record. +func (c *Ctx) Record() *logfile.Record { + if !c.recOK { + c.recOK = true + c.rec.Parse(c.Meta, c.Raw()) + } + return &c.rec +} + +// Filter is one predicate in the stack. +type Filter interface { + Kind() string + Label() string + Needs() Need + Match(*Ctx) bool +} + +// Entry wraps a filter with the stack-level toggles, so negation and disabling +// need no per-filter code. +type Entry struct { + F Filter + Enabled bool + Negate bool +} + +// Stack is the composed filter chain, evaluated cheapest-first. +type Stack struct { + Entries []Entry + order []int + needs Need +} + +// Compile recomputes the evaluation order; call after mutating Entries. +func (s *Stack) Compile() { + s.order = s.order[:0] + s.needs = 0 + for i, e := range s.Entries { + if !e.Enabled { + continue + } + s.order = append(s.order, i) + s.needs |= e.F.Needs() + } + // Index-only predicates first: raw readers then only see survivors. + sort.SliceStable(s.order, func(a, b int) bool { + return s.Entries[s.order[a]].F.Needs() < s.Entries[s.order[b]].F.Needs() + }) +} + +// Needs reports the union of the enabled filters' requirements. +func (s *Stack) Needs() Need { return s.needs } + +// Match evaluates the enabled filters in cost order. +func (s *Stack) Match(c *Ctx) bool { + for _, i := range s.order { + e := s.Entries[i] + if e.F.Match(c) == e.Negate { + return false + } + } + return true +} + +// Add appends an enabled filter and recompiles. +func (s *Stack) Add(f Filter) { + s.Entries = append(s.Entries, Entry{F: f, Enabled: true}) + s.Compile() +} + +// Find returns the first entry of the given kind. +func (s *Stack) Find(kind string) (*Entry, bool) { + for i := range s.Entries { + if s.Entries[i].F.Kind() == kind { + return &s.Entries[i], true + } + } + return nil, false +} + +// Set replaces the entry of f's kind, appending when absent. +func (s *Stack) Set(f Filter) { + for i := range s.Entries { + if s.Entries[i].F.Kind() == f.Kind() { + s.Entries[i].F, s.Entries[i].Enabled = f, true + s.Compile() + return + } + } + s.Add(f) +} + +// Remove drops the entry of the given kind. +func (s *Stack) Remove(kind string) bool { + for i := range s.Entries { + if s.Entries[i].F.Kind() == kind { + s.Entries = append(s.Entries[:i], s.Entries[i+1:]...) + s.Compile() + return true + } + } + return false +} + +// Summary renders the stack for the status bar: (disabled) and !negated. +func (s *Stack) Summary() []string { + out := make([]string, 0, len(s.Entries)) + for _, e := range s.Entries { + l := e.F.Label() + if l == "" { + continue // filter is inert; the header strip or nothing reports it + } + if e.Negate { + l = "!" + l + } + if !e.Enabled { + l = "(" + l + ")" + } + out = append(out, l) + } + return out +} + +// Desc describes a registered filter kind for menus and help. +type Desc struct { + Kind string + Help string + New func(arg string) (Filter, error) +} + +var registry []Desc + +// Register makes a filter kind constructible by name. A new kind is a new file +// plus one Register call; no render-path switch changes. +func Register(d Desc) { registry = append(registry, d) } + +// Kinds returns the registered filter kinds. +func Kinds() []Desc { return registry } + +// New constructs a registered filter. +func New(kind, arg string) (Filter, error) { + for _, d := range registry { + if d.Kind == kind { + return d.New(arg) + } + } + return nil, fmt.Errorf("filter: unknown kind %q", kind) +} diff --git a/internal/filter/find.go b/internal/filter/find.go new file mode 100644 index 0000000..e18f49c --- /dev/null +++ b/internal/filter/find.go @@ -0,0 +1,83 @@ +package filter + +import ( + "strconv" + + "github.com/lixenwraith/vif-log/internal/logfile" +) + +func init() { + Register(Desc{Kind: "find", Help: "regexp over the focused column, smart-case", New: newFind}) +} + +// Find keeps records whose focused column matches the pattern. Time, tick, sub +// and msg resolve from the index row, so only fields and all read the file. +type Find struct { + Query string + Col logfile.Column + pat Pattern + scratch []byte +} + +// NewFind returns an inert find filter. +func NewFind() *Find { return &Find{} } + +func newFind(arg string) (Filter, error) { + f := NewFind() + if err := f.Set(arg, logfile.ColAll); err != nil { + return nil, err + } + return f, nil +} + +// Set replaces the pattern and its column scope. +func (f *Find) Set(q string, col logfile.Column) error { + p, err := NewPattern(q) + if err != nil { + return err + } + f.Query, f.Col, f.pat = q, col, p + return nil +} + +// Active reports whether the filter constrains anything. +func (f *Find) Active() bool { return !f.pat.Empty() } + +func (f *Find) Kind() string { return "find" } + +func (f *Find) Needs() Need { + if !f.Active() { + return 0 + } + switch f.Col { + case logfile.ColFields, logfile.ColAll: + return NeedFields + } + return 0 +} + +func (f *Find) Match(c *Ctx) bool { + if !f.Active() { + return true + } + switch f.Col { + case logfile.ColTime: + f.scratch = append(f.scratch[:0], logfile.StampText(c.Meta)...) + case logfile.ColTick: + f.scratch = strconv.AppendUint(f.scratch[:0], uint64(c.Meta.Tick), 10) + case logfile.ColSub: + f.scratch = append(f.scratch[:0], c.Idx.SubName(c.Meta.Sub)...) + case logfile.ColMsg: + f.scratch = append(f.scratch[:0], c.Idx.MsgName(c.Meta.Msg)...) + default: + return f.pat.MatchBytes(c.Record().ColumnBytes(f.Col)) + } + return f.pat.MatchBytes(f.scratch) +} + +func (f *Find) Label() string { + if !f.Active() { + return "" + } + return "/" + f.Col.String() + ":" + f.Query +} diff --git a/internal/filter/level.go b/internal/filter/level.go new file mode 100644 index 0000000..08bcf36 --- /dev/null +++ b/internal/filter/level.go @@ -0,0 +1,166 @@ +package filter + +import ( + "fmt" + "strings" + + "github.com/lixenwraith/vif-log/internal/logfile" +) + +func init() { + Register(Desc{Kind: "level", Help: "level set (IWE) or threshold (>=WARN)", New: newLevel}) +} + +// Level filters by a bitmask over logfile.Level. Threshold mode is a mutation +// of the mask, not a second representation. +type Level struct{ Mask uint16 } + +// NewLevel returns a level filter with every level enabled. +func NewLevel() *Level { return &Level{Mask: (1 << uint(logfile.LevelCount)) - 1} } + +func newLevel(arg string) (Filter, error) { + f := NewLevel() + arg = strings.TrimSpace(arg) + if arg == "" { + return f, nil + } + if t, ok := strings.CutPrefix(arg, ">="); ok { + l, found := levelByName(strings.TrimSpace(t)) + if !found { + return nil, fmt.Errorf("filter/level: unknown level %q", t) + } + f.Threshold(l) + return f, nil + } + f.Mask = 0 + for i := 0; i < len(arg); i++ { + l, ok := logfile.LevelByInitial(arg[i] &^ 0x20) + if !ok { + return nil, fmt.Errorf("filter/level: unknown level initial %q", arg[i]) + } + f.Mask |= 1 << uint(l) + } + return f, nil +} + +func levelByName(s string) (logfile.Level, bool) { + s = strings.ToUpper(s) + for i := logfile.Level(0); i < logfile.LevelCount; i++ { + if i.String() == s { + return i, true + } + } + return logfile.LevelBad, false +} + +func (f *Level) Kind() string { return "level" } +func (f *Level) Needs() Need { return 0 } +func (f *Level) Match(c *Ctx) bool { return f.Mask&(1<= l { + f.Mask |= 1 << uint(i) + } else { + f.Mask &^= 1 << uint(i) + } + } +} + +// ThresholdToggle applies the threshold at l, or restores every ordered level +// when the mask already has exactly that shape. Digit keys use this: 2 hides +// TRACE, 2 again brings it back. +func (f *Level) ThresholdToggle(l logfile.Level) { + if f.isThreshold(l) { + for i := range logfile.Level(logfile.OrderedCount) { + f.Mask |= 1 << uint(i) + } + return + } + f.Threshold(l) +} + +// isThreshold reports whether the ordered levels are exactly those at or above l. +func (f *Level) isThreshold(l logfile.Level) bool { + for i := range logfile.Level(logfile.OrderedCount) { + if f.Has(i) != (i >= l) { + return false + } + } + return true +} + +// AllOn reports whether no level is filtered out. +func (f *Level) AllOn() bool { + return f.Mask&((1<= logfile.OrderedCount { + t = logfile.OrderedCount - 1 + } + f.Threshold(logfile.Level(t)) +} + +// LowestOrdered returns the lowest enabled ordered level, ERROR if none. +func (f *Level) LowestOrdered() logfile.Level { + for i := logfile.Level(0); i < logfile.Level(logfile.OrderedCount); i++ { + if f.Has(i) { + return i + } + } + return logfile.LevelError +} + +// Label reports the level filter only when it hides something; the header +// strip shows the per-level state. +func (f *Level) Label() string { + if f.AllOn() { + return "" + } + if l, ok := f.thresholdShape(); ok { + return "lvl>=" + l.String() + } + var b strings.Builder + b.WriteString("lvl:") + for i := range logfile.LevelCount { + if f.Has(i) { + b.WriteByte(i.Initial()) + } + } + return b.String() +} + +// thresholdShape reports whether the mask is "ordered >= t, plus PROC and BAD". +func (f *Level) thresholdShape() (logfile.Level, bool) { + if !f.Has(logfile.LevelProc) || !f.Has(logfile.LevelBad) { + return 0, false + } + t := f.LowestOrdered() + if t == 0 || !f.isThreshold(t) { + return 0, false + } + return t, true +} diff --git a/internal/filter/pattern.go b/internal/filter/pattern.go new file mode 100644 index 0000000..a2688bf --- /dev/null +++ b/internal/filter/pattern.go @@ -0,0 +1,110 @@ +package filter + +import ( + "bytes" + "regexp" + "strings" +) + +// Pattern is a smart-case matcher: an all-lowercase pattern matches +// case-insensitively, any upper-case character makes it case-sensitive. +// A pattern free of regexp metacharacters takes an allocation-free +// substring path; everything else compiles to stdlib RE2. +type Pattern struct { + Src string + re *regexp.Regexp + lit []byte + fold bool +} + +// NewPattern compiles s; an empty s yields an inert pattern. +func NewPattern(s string) (Pattern, error) { + p := Pattern{Src: s, fold: s == strings.ToLower(s)} + if s == "" { + return p, nil + } + if regexp.QuoteMeta(s) == s { + if p.fold { + p.lit = []byte(strings.ToLower(s)) + } else { + p.lit = []byte(s) + } + return p, nil + } + expr := s + if p.fold { + expr = "(?i)" + s + } + re, err := regexp.Compile(expr) + if err != nil { + return Pattern{}, err + } + p.re = re + return p, nil +} + +// Empty reports whether the pattern constrains nothing. +func (p Pattern) Empty() bool { return p.Src == "" } + +// MatchBytes reports whether b contains a match. +func (p Pattern) MatchBytes(b []byte) bool { + switch { + case p.Src == "": + return true + case p.lit != nil && p.fold: + return foldContains(b, p.lit) + case p.lit != nil: + return bytes.Contains(b, p.lit) + default: + return p.re.Match(b) + } +} + +// MatchString reports whether s contains a match. +func (p Pattern) MatchString(s string) bool { + switch { + case p.Src == "": + return true + case p.lit != nil && p.fold: + return foldContains([]byte(s), p.lit) + case p.lit != nil: + return strings.Contains(s, string(p.lit)) + default: + return p.re.MatchString(s) + } +} + +func lowerASCII(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + 'a' - 'A' + } + return c +} + +// foldContains reports whether hay contains needle, ASCII case-insensitively. +// needle must already be lowercased. +func foldContains(hay, needle []byte) bool { + if len(needle) == 0 { + return true + } + if len(hay) < len(needle) { + return false + } + first := needle[0] + for i := 0; i+len(needle) <= len(hay); i++ { + if lowerASCII(hay[i]) != first { + continue + } + match := true + for j := 1; j < len(needle); j++ { + if lowerASCII(hay[i+j]) != needle[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/internal/filter/pin.go b/internal/filter/pin.go new file mode 100644 index 0000000..0914424 --- /dev/null +++ b/internal/filter/pin.go @@ -0,0 +1,64 @@ +package filter + +import "slices" + +// PinSet is the pin buffer: record indices the user marked. It is owned by the +// app and outlives every filter change, which is the point — pins are +// assembled across successive filters. Not registered: it has no meaningful +// construction from a string argument. +type PinSet struct{ m map[int32]struct{} } + +// NewPinSet returns an empty pin buffer. +func NewPinSet() *PinSet { return &PinSet{m: make(map[int32]struct{})} } + +// Has reports whether record i is pinned. +func (p *PinSet) Has(i int32) bool { _, ok := p.m[i]; return ok } + +// Toggle flips record i, returning its new state. +func (p *PinSet) Toggle(i int32) bool { + if _, ok := p.m[i]; ok { + delete(p.m, i) + return false + } + p.m[i] = struct{}{} + return true +} + +// Clear empties the buffer. +func (p *PinSet) Clear() { clear(p.m) } + +// Len returns the pin count. +func (p *PinSet) Len() int { return len(p.m) } + +// Sorted returns the pinned record indices in file order. +func (p *PinSet) Sorted() []int32 { + out := make([]int32, 0, len(p.m)) + for i := range p.m { + out = append(out, i) + } + slices.Sort(out) + return out +} + +// Pinned restricts the view to the pin buffer. +type Pinned struct { + Set *PinSet + On bool +} + +// NewPinned wraps a pin buffer as a filter. +func NewPinned(s *PinSet) *Pinned { return &Pinned{Set: s} } + +func (f *Pinned) Kind() string { return "pin" } +func (f *Pinned) Needs() Need { return 0 } + +func (f *Pinned) Match(c *Ctx) bool { + return !f.On || f.Set.Has(int32(c.I)) +} + +func (f *Pinned) Label() string { + if !f.On { + return "" + } + return "pinned-only" +} diff --git a/internal/filter/snapshot.go b/internal/filter/snapshot.go new file mode 100644 index 0000000..031ad1e --- /dev/null +++ b/internal/filter/snapshot.go @@ -0,0 +1,64 @@ +package filter + +import ( + "strings" + + "github.com/lixenwraith/vif-log/internal/logfile" +) + +func init() { + Register(Desc{Kind: "snap", Help: "collapse stat snapshots to one line (arg: off)", New: newCollapse}) +} + +// Collapse hides stat-snapshot members, turning a ~40-record snapshot into one +// navigable line. On is the global state; exc holds the per-group exceptions +// toggled with Enter. Index-only, so it costs nothing to evaluate. +type Collapse struct { + On bool + exc map[uint32]bool +} + +// NewCollapse returns a collapse filter in the given global state. +func NewCollapse(on bool) *Collapse { + return &Collapse{On: on, exc: make(map[uint32]bool)} +} + +func newCollapse(arg string) (Filter, error) { + return NewCollapse(strings.TrimSpace(arg) != "off"), nil +} + +func (f *Collapse) Kind() string { return "snap" } +func (f *Collapse) Needs() Need { return 0 } + +func (f *Collapse) Match(c *Ctx) bool { + if c.Meta.Snap == 0 || c.Meta.Flags&logfile.FlagSnapHead != 0 { + return true + } + return f.Expanded(c.Meta.Snap) +} + +// Expanded reports whether group id shows its members. +func (f *Collapse) Expanded(id uint32) bool { return f.On == f.exc[id] } + +// ToggleGroup flips one group against the global state. +func (f *Collapse) ToggleGroup(id uint32) { f.exc[id] = !f.exc[id] } + +// ResetGroups drops the per-group exceptions; group ids are file-scoped. +func (f *Collapse) ResetGroups() { clear(f.exc) } + +// Toggle flips the global state and drops all per-group exceptions. +func (f *Collapse) Toggle() { + f.On = !f.On + clear(f.exc) +} + +func (f *Collapse) Label() string { + s := "snap:expanded" + if f.On { + s = "snap:collapsed" + } + if len(f.exc) > 0 { + s += "*" + } + return s +} diff --git a/internal/filter/span.go b/internal/filter/span.go new file mode 100644 index 0000000..1f98086 --- /dev/null +++ b/internal/filter/span.go @@ -0,0 +1,80 @@ +package filter + +import ( + "fmt" + "strconv" + "strings" +) + +func init() { + Register(Desc{Kind: "tick", Help: "tick range: N | N-M | N- | -M", New: newTickSpan}) + Register(Desc{Kind: "run", Help: "run range: N | N-M | N- | -M", New: newRunSpan}) +} + +// Span is an inclusive numeric range over an index-resident counter. +type Span struct { + field string + Lo, Hi uint32 + Query string +} + +func newTickSpan(arg string) (Filter, error) { return newSpan("tick", arg) } +func newRunSpan(arg string) (Filter, error) { return newSpan("run", arg) } + +func newSpan(field, arg string) (Filter, error) { + lo, hi, err := parseSpan(arg) + if err != nil { + return nil, fmt.Errorf("filter/%s: %w", field, err) + } + return &Span{field: field, Lo: lo, Hi: hi, Query: arg}, nil +} + +// parseSpan accepts N, N-M, N- and -M; an empty spec is the full range. +func parseSpan(arg string) (uint32, uint32, error) { + arg = strings.TrimSpace(arg) + if arg == "" { + return 0, ^uint32(0), nil + } + lo, hi := arg, arg + if i := strings.IndexByte(arg, '-'); i >= 0 { + lo, hi = arg[:i], arg[i+1:] + } + var l uint32 + h := ^uint32(0) + if lo != "" { + v, err := strconv.ParseUint(lo, 10, 32) + if err != nil { + return 0, 0, err + } + l = uint32(v) + } + if hi != "" { + v, err := strconv.ParseUint(hi, 10, 32) + if err != nil { + return 0, 0, err + } + h = uint32(v) + } + if l > h { + l, h = h, l + } + return l, h, nil +} + +func (f *Span) Kind() string { return f.field } +func (f *Span) Needs() Need { return 0 } + +func (f *Span) Match(c *Ctx) bool { + v := c.Meta.Tick + if f.field == "run" { + v = c.Meta.Run + } + return v >= f.Lo && v <= f.Hi +} + +func (f *Span) Label() string { + if f.Lo == 0 && f.Hi == ^uint32(0) { + return "" + } + return f.field + ":" + f.Query +} diff --git a/internal/filter/text.go b/internal/filter/text.go new file mode 100644 index 0000000..f96b32e --- /dev/null +++ b/internal/filter/text.go @@ -0,0 +1,86 @@ +package filter + +import "github.com/lixenwraith/vif-log/internal/logfile" + +func init() { + Register(Desc{Kind: "sub", Help: "subsystem regexp, e.g. sub:^(fsm|rec)$", New: newSub}) + Register(Desc{Kind: "msg", Help: "message regexp, e.g. msg:transition", New: newMsg}) +} + +// Text matches one of the interned vocabulary columns. The pattern is applied +// once per interned id into a bitset, so the per-record cost is a bit probe +// and no line is ever read. +type Text struct { + kind string + Query string + pat Pattern + mask []uint64 + n int +} + +func newSub(arg string) (Filter, error) { return newText("sub", arg) } +func newMsg(arg string) (Filter, error) { return newText("msg", arg) } + +func newText(kind, arg string) (Filter, error) { + p, err := NewPattern(arg) + if err != nil { + return nil, err + } + return &Text{kind: kind, Query: arg, pat: p}, nil +} + +func (f *Text) Kind() string { return f.kind } +func (f *Text) Needs() Need { return 0 } + +func (f *Text) Match(c *Ctx) bool { + if f.pat.Empty() { + return true + } + var id int + var tab []string + if f.kind == "sub" { + id, tab = int(c.Meta.Sub), c.Idx.Subs() + } else { + id, tab = int(c.Meta.Msg), c.Idx.Msgs() + } + f.ensure(tab) + if id < 0 || id >= f.n { + return false + } + return f.mask[id>>6]&(1<>6] |= 1 << uint(i&63) + } + } + f.n = len(tab) +} + +func (f *Text) Label() string { + if f.pat.Empty() { + return "" + } + return f.kind + ":" + f.Query +} + +// SubOf resolves the column a text filter binds to; used by the help overlay. +func (f *Text) Column() logfile.Column { + if f.kind == "sub" { + return logfile.ColSub + } + return logfile.ColMsg +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 0000000..a790de5 --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,351 @@ +package keys + +import ( + "slices" + "strings" + + "github.com/lixenwraith/terminal" +) + +// Action is a UI verb. The table maps chords to actions; the app maps actions +// to functions. Adding a key is one table row. +type Action uint16 + +const ( + ActNone Action = iota + ActQuit + ActRedraw + ActHelp + ActCloseOverlay + + ActDown + ActUp + ActPageDown + ActPageUp + ActHalfDown + ActHalfUp + ActTop + ActBottom + + ActColNext + ActColPrev + ActSort + ActDetailDown + ActDetailUp + + ActSearch + ActFollowNext + ActFollowPrev + ActFilter // add/replace a stack filter + ActClear + + ActExpand + ActSnapNext + ActSnapPrev + + ActPinToggle + ActPinOnly + ActPinClear + + ActOpen + ActExport + + ActMark // overlay: toggle multi-selection + ActConfirm // overlay: activate the selected row + ActBack // overlay: leave the current level + + ActLvlTrace + ActLvlDebug + ActLvlInfo + ActLvlWarn + ActLvlError + ActLvlProc + ActLvlBad + ActLvlAll + ActLvlRaise + ActLvlLower + ActThresh1 + ActThresh2 + ActThresh3 + ActThresh4 + ActThresh5 +) + +// Mode scopes a binding to an input context. +type Mode uint8 + +const ( + ModeNormal Mode = iota + ModeOverlay +) + +// Chord is one key press. +type Chord struct { + Key terminal.Key + Rune rune + Mod terminal.Modifier +} + +// R builds a printable-rune chord. +func R(r rune) Chord { return Chord{Key: terminal.KeyRune, Rune: r} } + +// K builds a named-key chord. +func K(k terminal.Key) Chord { return Chord{Key: k} } + +// Binding is one row of the single key table that also generates the help overlay. +type Binding struct { + Mode Mode + Seq []Chord + Act Action + Group string + Help string +} + +// Bindings is the sole source of truth for keys, help text and footer hints. +// An empty Help hides the row from the overlay without unbinding the key: the +// level letters and the threshold digits are documented as one row each. +var Bindings = []Binding{ + {ModeNormal, []Chord{R('q')}, ActQuit, "general", "quit"}, + {ModeNormal, []Chord{K(terminal.KeyCtrlC)}, ActQuit, "general", "quit"}, + {ModeNormal, []Chord{K(terminal.KeyCtrlL)}, ActRedraw, "general", "redraw"}, + {ModeNormal, []Chord{R('?')}, ActHelp, "general", "help"}, + + {ModeNormal, []Chord{R('j')}, ActDown, "move", "down"}, + {ModeNormal, []Chord{K(terminal.KeyDown)}, ActDown, "move", "down"}, + {ModeNormal, []Chord{R('k')}, ActUp, "move", "up"}, + {ModeNormal, []Chord{K(terminal.KeyUp)}, ActUp, "move", "up"}, + {ModeNormal, []Chord{K(terminal.KeyPageDown)}, ActPageDown, "move", "page down"}, + {ModeNormal, []Chord{K(terminal.KeyPageUp)}, ActPageUp, "move", "page up"}, + {ModeNormal, []Chord{K(terminal.KeyCtrlD)}, ActHalfDown, "move", "half page down"}, + {ModeNormal, []Chord{K(terminal.KeyCtrlU)}, ActHalfUp, "move", "half page up"}, + {ModeNormal, []Chord{R('g'), R('g')}, ActTop, "move", "first record"}, + {ModeNormal, []Chord{K(terminal.KeyHome)}, ActTop, "move", "first record"}, + {ModeNormal, []Chord{R('G')}, ActBottom, "move", "last record"}, + {ModeNormal, []Chord{K(terminal.KeyEnd)}, ActBottom, "move", "last record"}, + + {ModeNormal, []Chord{K(terminal.KeyTab)}, ActColNext, "column", "focus next column"}, + {ModeNormal, []Chord{K(terminal.KeyBacktab)}, ActColPrev, "column", "focus previous column"}, + {ModeNormal, []Chord{R('s')}, ActSort, "column", "sort focused column: asc / desc / off"}, + {ModeNormal, []Chord{R('J')}, ActDetailDown, "column", "scroll detail down"}, + {ModeNormal, []Chord{R('K')}, ActDetailUp, "column", "scroll detail up"}, + + {ModeNormal, []Chord{R('/')}, ActSearch, "search", "search the focused column"}, + {ModeNormal, []Chord{K(terminal.KeyEscape)}, ActClear, "search", "clear search and filters"}, + {ModeNormal, []Chord{R('f')}, ActFollowNext, "search", "next record like this one"}, + {ModeNormal, []Chord{R('F')}, ActFollowPrev, "search", "previous record like this one"}, + {ModeNormal, []Chord{R('\\')}, ActFilter, "search", "filter: kind:regexp (sub msg tick run level fields)"}, + + {ModeNormal, []Chord{K(terminal.KeyEnter)}, ActExpand, "snapshot", "expand/collapse this snapshot"}, + {ModeNormal, []Chord{R('n')}, ActSnapNext, "snapshot", "jump to next snapshot down"}, + {ModeNormal, []Chord{R('N')}, ActSnapPrev, "snapshot", "jump to previous snapshot up"}, + + {ModeNormal, []Chord{R(' ')}, ActPinToggle, "pin", "pin/unpin record"}, + {ModeNormal, []Chord{R('P')}, ActPinOnly, "pin", "show only pinned records"}, + {ModeNormal, []Chord{R('C')}, ActPinClear, "pin", "clear all pins"}, + + {ModeNormal, []Chord{R('o')}, ActOpen, "file", "open a log file"}, + {ModeNormal, []Chord{R('x')}, ActExport, "file", "export pins, or the current result"}, + + {ModeNormal, []Chord{R('t')}, ActLvlTrace, "level", "toggle one level: t d i w e p b"}, + {ModeNormal, []Chord{R('d')}, ActLvlDebug, "level", ""}, + {ModeNormal, []Chord{R('i')}, ActLvlInfo, "level", ""}, + {ModeNormal, []Chord{R('w')}, ActLvlWarn, "level", ""}, + {ModeNormal, []Chord{R('e')}, ActLvlError, "level", ""}, + {ModeNormal, []Chord{R('p')}, ActLvlProc, "level", ""}, + {ModeNormal, []Chord{R('b')}, ActLvlBad, "level", ""}, + {ModeNormal, []Chord{R('1')}, ActThresh1, "level", "hide below level: 1=TRACE … 5=ERROR"}, + {ModeNormal, []Chord{R('2')}, ActThresh2, "level", ""}, + {ModeNormal, []Chord{R('3')}, ActThresh3, "level", ""}, + {ModeNormal, []Chord{R('4')}, ActThresh4, "level", ""}, + {ModeNormal, []Chord{R('5')}, ActThresh5, "level", ""}, + {ModeNormal, []Chord{R('6')}, ActLvlProc, "level", ""}, + {ModeNormal, []Chord{R('7')}, ActLvlBad, "level", ""}, + {ModeNormal, []Chord{R('0')}, ActLvlAll, "level", "show all levels"}, + {ModeNormal, []Chord{R('>')}, ActLvlRaise, "level", "raise threshold"}, + {ModeNormal, []Chord{R('<')}, ActLvlLower, "level", "lower threshold"}, + + {ModeOverlay, []Chord{K(terminal.KeyEscape)}, ActCloseOverlay, "overlay", "close"}, + {ModeOverlay, []Chord{R('q')}, ActCloseOverlay, "overlay", "close"}, + {ModeOverlay, []Chord{R('?')}, ActCloseOverlay, "overlay", ""}, + {ModeOverlay, []Chord{R('j')}, ActDown, "overlay", "down"}, + {ModeOverlay, []Chord{K(terminal.KeyDown)}, ActDown, "overlay", "down"}, + {ModeOverlay, []Chord{R('k')}, ActUp, "overlay", "up"}, + {ModeOverlay, []Chord{K(terminal.KeyUp)}, ActUp, "overlay", "up"}, + {ModeOverlay, []Chord{K(terminal.KeyPageDown)}, ActPageDown, "overlay", "page down"}, + {ModeOverlay, []Chord{K(terminal.KeyPageUp)}, ActPageUp, "overlay", "page up"}, + {ModeOverlay, []Chord{K(terminal.KeyCtrlD)}, ActHalfDown, "overlay", ""}, + {ModeOverlay, []Chord{K(terminal.KeyCtrlU)}, ActHalfUp, "overlay", ""}, + {ModeOverlay, []Chord{R('g'), R('g')}, ActTop, "overlay", "first row"}, + {ModeOverlay, []Chord{K(terminal.KeyHome)}, ActTop, "overlay", ""}, + {ModeOverlay, []Chord{R('G')}, ActBottom, "overlay", "last row"}, + {ModeOverlay, []Chord{K(terminal.KeyEnd)}, ActBottom, "overlay", ""}, + {ModeOverlay, []Chord{K(terminal.KeyEnter)}, ActConfirm, "overlay", "open file / enter directory"}, + {ModeOverlay, []Chord{R('l')}, ActConfirm, "overlay", ""}, + {ModeOverlay, []Chord{K(terminal.KeyRight)}, ActConfirm, "overlay", ""}, + {ModeOverlay, []Chord{R('h')}, ActBack, "overlay", "parent directory"}, + {ModeOverlay, []Chord{K(terminal.KeyLeft)}, ActBack, "overlay", ""}, + {ModeOverlay, []Chord{K(terminal.KeyBackspace)}, ActBack, "overlay", ""}, + {ModeOverlay, []Chord{R(' ')}, ActMark, "overlay", "mark file for multi-open"}, +} + +type skey struct { + m Mode + c Chord +} + +type qkey struct { + m Mode + a, b Chord +} + +// Resolver turns chords into actions, holding one pending prefix for two-chord +// sequences. +type Resolver struct { + single map[skey]Action + prefix map[skey]bool + seq map[qkey]Action + pending Chord + armed bool +} + +// NewResolver compiles the binding table. +func NewResolver() *Resolver { + r := &Resolver{ + single: make(map[skey]Action, len(Bindings)), + prefix: make(map[skey]bool), + seq: make(map[qkey]Action), + } + for _, b := range Bindings { + switch len(b.Seq) { + case 1: + r.single[skey{b.Mode, b.Seq[0]}] = b.Act + case 2: + r.prefix[skey{b.Mode, b.Seq[0]}] = true + r.seq[qkey{b.Mode, b.Seq[0], b.Seq[1]}] = b.Act + } + } + return r +} + +// Resolve maps a chord to an action, returning ActNone while a prefix pends. +func (r *Resolver) Resolve(m Mode, c Chord) Action { + if r.armed { + r.armed = false + if a, ok := r.seq[qkey{m, r.pending, c}]; ok { + return a + } + } + if r.prefix[skey{m, c}] { + r.pending, r.armed = c, true + return ActNone + } + return r.single[skey{m, c}] +} + +// Reset drops any pending prefix. +func (r *Resolver) Reset() { r.armed = false } + +// FromEvent builds a chord from a key event. Shift is dropped where the key +// identity already encodes it: runes carry their shifted form, and backtab is +// shift+tab by definition. +func FromEvent(ev terminal.Event) Chord { + c := Chord{Key: ev.Key, Mod: ev.Modifiers} + switch ev.Key { + case terminal.KeyRune: + c.Rune = ev.Rune + c.Mod &^= terminal.ModShift + case terminal.KeyBacktab: + c.Mod &^= terminal.ModShift + } + return c +} + +// ChordString renders a chord for help and hints. +func ChordString(c Chord) string { + var b strings.Builder + if c.Mod&terminal.ModCtrl != 0 { + b.WriteByte('^') + } + if c.Mod&terminal.ModAlt != 0 { + b.WriteString("M-") + } + if c.Key == terminal.KeyRune { + if c.Rune == ' ' { + b.WriteString("spc") + } else { + b.WriteRune(c.Rune) + } + return b.String() + } + if n := terminal.KeyName(c.Key); n != "" { + b.WriteString(n) + return b.String() + } + b.WriteByte('?') + return b.String() +} + +// SeqString renders a chord sequence. +func SeqString(s []Chord) string { + var b strings.Builder + for _, c := range s { + b.WriteString(ChordString(c)) + } + return b.String() +} + +// KeysFor returns the chords bound to act, joined, for footer hints. +func KeysFor(m Mode, act Action) string { + var out []string + for _, b := range Bindings { + if b.Mode == m && b.Act == act { + out = append(out, SeqString(b.Seq)) + } + } + return strings.Join(out, "/") +} + +// HelpRow is one generated line of the help overlay. +type HelpRow struct { + Group string + Keys string + Help string +} + +// HelpRows generates the help overlay content from the binding table, one row +// per action with its chords merged. +func HelpRows(m Mode) []HelpRow { + type acc struct { + keys []string + help, group string + } + byAct := map[Action]*acc{} + var order []Action + var groups []string + + for _, b := range Bindings { + if b.Mode != m || b.Help == "" { + continue + } + a, ok := byAct[b.Act] + if !ok { + a = &acc{help: b.Help, group: b.Group} + byAct[b.Act] = a + order = append(order, b.Act) + if !slices.Contains(groups, b.Group) { + groups = append(groups, b.Group) + } + } + a.keys = append(a.keys, SeqString(b.Seq)) + } + + out := make([]HelpRow, 0, len(order)) + for _, g := range groups { + for _, act := range order { + if a := byAct[act]; a.group == g { + out = append(out, HelpRow{Group: g, Keys: strings.Join(a.keys, " "), Help: a.help}) + } + } + } + return out +} diff --git a/internal/logfile/index.go b/internal/logfile/index.go new file mode 100644 index 0000000..ed6956b --- /dev/null +++ b/internal/logfile/index.go @@ -0,0 +1,346 @@ +package logfile + +import ( + "io" + "os" + "sync" + "sync/atomic" +) + +// Meta is one index row: 48 bytes, pointer-free, holds no line bytes. +type Meta struct { + Off int64 + TS int64 + Len uint32 + Run uint32 + Tick uint32 + Frame uint32 + Msg uint32 // interned fields.msg + Snap uint32 // stat-snapshot group id, 0 = none + Sub uint16 // interned sub + Src uint16 // source file index + Lvl Level + Flags uint8 +} + +// Meta flag bits. +const ( + FlagMalformed uint8 = 1 << iota + FlagSnapHead + FlagTrace + FlagNoTime // TS was inherited from the previous line, for ordering only +) + +// Snapshot groups the stat records sharing one (src, run, tick). Members are +// not contiguous in the merged view; Count is authoritative, Head locates the +// group's first row in the published order. +type Snapshot struct { + Head uint32 + Count uint32 + Run uint32 + Tick uint32 + Frame uint32 + Src uint16 +} + +// Source is one indexed file. +type Source struct { + Path string + Name string + Size int64 + + scanned atomic.Int64 + bad atomic.Int64 + done atomic.Bool + failure atomic.Pointer[scanErr] +} + +// Index is the append-only line index over one or more files. The scanner +// publishes immutable slice headers; readers load them atomically, so the +// render path never locks. A single source publishes incrementally; several +// sources publish once, merged by timestamp, so row indices never shift. +type Index struct { + srcs []*Source + + subN, msgN *interner + + metas atomic.Pointer[[]Meta] + snaps atomic.Pointer[[]Snapshot] + subs atomic.Pointer[[]string] + msgs atomic.Pointer[[]string] +} + +type scanErr struct{ err error } + +// The accessors below tolerate a nil receiver: the viewer runs with no file +// open until one is chosen, and the render path reads the index every frame. + +// Sources returns the indexed files in source-id order. +func (x *Index) Sources() []*Source { + if x == nil { + return nil + } + return x.srcs +} + +// SrcCount returns the number of indexed files. +func (x *Index) SrcCount() int { + if x == nil { + return 0 + } + return len(x.srcs) +} + +// SrcName resolves a source id to its base filename. +func (x *Index) SrcName(id uint16) string { + if x == nil || int(id) >= len(x.srcs) { + return "" + } + return x.srcs[id].Name +} + +// SrcMark returns the one-character gutter label for a source id. +func (x *Index) SrcMark(id uint16) rune { + const marks = "123456789abcdefghijklmnopqrstuvwxyz" + if int(id) >= len(marks) { + return '?' + } + return rune(marks[id]) +} + +// Metas returns the currently published index rows. +func (x *Index) Metas() []Meta { + if x == nil { + return nil + } + if p := x.metas.Load(); p != nil { + return *p + } + return nil +} + +// Len returns the number of indexed records. +func (x *Index) Len() int { return len(x.Metas()) } + +// Snaps returns the published snapshot groups. +func (x *Index) Snaps() []Snapshot { + if x == nil { + return nil + } + if p := x.snaps.Load(); p != nil { + return *p + } + return nil +} + +// Subs returns the interned subsystem table. +func (x *Index) Subs() []string { + if x == nil { + return nil + } + return strTable(x.subs.Load()) +} + +// Msgs returns the interned fields.msg table. +func (x *Index) Msgs() []string { + if x == nil { + return nil + } + return strTable(x.msgs.Load()) +} + +// SubName resolves an interned sub id. +func (x *Index) SubName(id uint16) string { + if x == nil { + return "" + } + return lookup(x.subs.Load(), int(id)) +} + +// MsgName resolves an interned fields.msg id. +func (x *Index) MsgName(id uint32) string { + if x == nil { + return "" + } + return lookup(x.msgs.Load(), int(id)) +} + +func strTable(p *[]string) []string { + if p == nil { + return nil + } + return *p +} + +func lookup(p *[]string, i int) string { + t := strTable(p) + if i < 0 || i >= len(t) { + return "" + } + return t[i] +} + +// Progress reports bytes scanned, total bytes and completion across all sources. +func (x *Index) Progress() (scanned, total int64, complete bool) { + if x == nil { + return 0, 0, true + } + complete = true + for _, s := range x.srcs { + scanned += s.scanned.Load() + total += s.Size + if !s.done.Load() { + complete = false + } + } + return scanned, total, complete +} + +// Malformed returns the count of unparseable lines seen so far. +func (x *Index) Malformed() int64 { + if x == nil { + return 0 + } + var n int64 + for _, s := range x.srcs { + n += s.bad.Load() + } + return n +} + +// Err returns the first scan failure, if any. +func (x *Index) Err() error { + if x == nil { + return nil + } + for _, s := range x.srcs { + if p := s.failure.Load(); p != nil { + return p.err + } + } + return nil +} + +// SnapshotOf returns the stat group a record belongs to. The group currently +// being scanned is not published, so ok=false until it closes. +func (x *Index) SnapshotOf(m Meta) (Snapshot, bool) { + if x == nil || m.Snap == 0 { + return Snapshot{}, false + } + s := x.Snaps() + if int(m.Snap) > len(s) { + return Snapshot{}, false + } + return s[m.Snap-1], true +} + +const readWindow = 256 << 10 + +// Reader fetches raw line bytes through a per-source sliding window. +// Not safe for concurrent use; create one per goroutine. +type Reader struct { + paths []string + win []window +} + +type window struct { + f *os.File + buf []byte + base int64 + n int +} + +// NewReader opens an independent handle set on the indexed files. Handles are +// opened lazily, so a reader that only touches one source costs one fd. +func (x *Index) NewReader() (*Reader, error) { + r := &Reader{ + paths: make([]string, len(x.srcs)), + win: make([]window, len(x.srcs)), + } + for i, s := range x.srcs { + r.paths[i] = s.Path + r.win[i].base = -1 + } + return r, nil +} + +// Line returns the raw bytes of m, valid until the next call. +func (r *Reader) Line(m Meta) ([]byte, error) { + n := int(m.Len) + if n == 0 || int(m.Src) >= len(r.win) { + return nil, nil + } + w := &r.win[m.Src] + if w.f == nil { + f, err := os.Open(r.paths[m.Src]) + if err != nil { + return nil, err + } + w.f, w.buf, w.base = f, make([]byte, readWindow), -1 + } + if n+4096 > len(w.buf) { + w.buf = make([]byte, n+4096) + w.base = -1 + } + if w.base < 0 || m.Off < w.base || m.Off+int64(n) > w.base+int64(w.n) { + base := m.Off &^ 4095 + got, err := w.f.ReadAt(w.buf, base) + if got == 0 && err != nil { + return nil, err + } + w.base, w.n = base, got + } + s := int(m.Off - w.base) + if s < 0 || s+n > w.n { + return nil, io.ErrUnexpectedEOF + } + return w.buf[s : s+n], nil +} + +// Close releases every open file handle. +func (r *Reader) Close() error { + var err error + for i := range r.win { + if r.win[i].f != nil { + if e := r.win[i].f.Close(); e != nil && err == nil { + err = e + } + r.win[i].f = nil + } + } + return err +} + +// interner maps byte tokens to dense ids. Writer-side only; the id→name table +// is published as an immutable snapshot. Locked: sources scan concurrently. +type interner struct { + mu sync.Mutex + ids map[string]uint32 + tab []string +} + +func newInterner() *interner { + n := &interner{ids: make(map[string]uint32, 64)} + n.intern(nil) // id 0 == "" + return n +} + +func (n *interner) intern(b []byte) uint32 { + n.mu.Lock() + defer n.mu.Unlock() + if id, ok := n.ids[string(b)]; ok { + return id + } + s := string(b) + id := uint32(len(n.tab)) + n.tab = append(n.tab, s) + n.ids[s] = id + return id +} + +// table returns a consistent header over the interned names. +func (n *interner) table() []string { + n.mu.Lock() + defer n.mu.Unlock() + return n.tab[:len(n.tab):len(n.tab)] +} diff --git a/internal/logfile/json.go b/internal/logfile/json.go new file mode 100644 index 0000000..ff499aa --- /dev/null +++ b/internal/logfile/json.go @@ -0,0 +1,212 @@ +package logfile + +import ( + "bytes" + "strconv" +) + +// JSON token kinds. +const ( + KNone byte = 0 + KStr byte = 's' + KNum byte = 'n' + KBool byte = 'b' + KNull byte = 'z' + KObj byte = 'o' + KArr byte = 'a' +) + +func skipSpace(b []byte, i int) int { + for i < len(b) && (b[i] == ' ' || b[i] == '\t' || b[i] == '\r' || b[i] == '\n') { + i++ + } + return i +} + +func isNumByte(c byte) bool { + return c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9') +} + +// scanString returns the index past the closing quote; i indexes the opener. +func scanString(b []byte, i int) (int, bool) { + for i++; i < len(b); i++ { + switch b[i] { + case '\\': + i++ + case '"': + return i + 1, true + } + } + return i, false +} + +// scanValue returns the end index and kind of the value starting at i. +func scanValue(b []byte, i int) (int, byte, bool) { + if i >= len(b) { + return i, KNone, false + } + switch c := b[i]; c { + case '"': + e, ok := scanString(b, i) + return e, KStr, ok + + case '{', '[': + opener, closer, kind := byte('{'), byte('}'), KObj + if c == '[' { + opener, closer, kind = '[', ']', KArr + } + depth := 0 + for i < len(b) { + ch := b[i] + if ch == '"' { + e, ok := scanString(b, i) + if !ok { + return e, kind, false + } + i = e + continue + } + if ch == opener { + depth++ + } else if ch == closer { + depth-- + if depth == 0 { + return i + 1, kind, true + } + } + i++ + } + return i, kind, false + + case 't', 'f', 'n': + kind := KBool + if c == 'n' { + kind = KNull + } + for i < len(b) && b[i] >= 'a' && b[i] <= 'z' { + i++ + } + return i, kind, true + + default: + for i < len(b) && isNumByte(b[i]) { + i++ + } + return i, KNum, true + } +} + +// eachField calls fn for each member of the object at i, which must index '{'. +// fn returning false stops iteration. Reports whether the object is well formed. +func eachField(b []byte, i int, fn func(key, val []byte, kind byte) bool) bool { + if i >= len(b) || b[i] != '{' { + return false + } + i = skipSpace(b, i+1) + if i < len(b) && b[i] == '}' { + return true + } + for i < len(b) { + if b[i] != '"' { + return false + } + ke, ok := scanString(b, i) + if !ok { + return false + } + key := b[i+1 : ke-1] + + i = skipSpace(b, ke) + if i >= len(b) || b[i] != ':' { + return false + } + i = skipSpace(b, i+1) + + vs := i + ve, kind, ok := scanValue(b, i) + if !ok { + return false + } + if !fn(key, b[vs:ve], kind) { + return true + } + + i = skipSpace(b, ve) + if i >= len(b) { + return false + } + switch b[i] { + case ',': + i = skipSpace(b, i+1) + case '}': + return true + default: + return false + } + } + return false +} + +// strTok returns the undecoded content of a string token. +func strTok(tok []byte) []byte { + if len(tok) < 2 { + return nil + } + return tok[1 : len(tok)-1] +} + +// unquote returns the content of a string token, decoding escapes only when present. +func unquote(tok []byte) string { + in := strTok(tok) + if bytes.IndexByte(in, '\\') < 0 { + return string(in) + } + if s, err := strconv.Unquote(string(tok)); err == nil { + return s + } + return string(in) +} + +// parseUint32 parses a leading decimal run, saturating at the type maximum. +func parseUint32(b []byte) uint32 { + var v uint64 + for _, c := range b { + if c < '0' || c > '9' { + break + } + v = v*10 + uint64(c-'0') + if v > 0xffffffff { + return 0xffffffff + } + } + return uint32(v) +} + +// parseInt64 parses a complete decimal integer token without allocating. +func parseInt64(b []byte) (int64, bool) { + if len(b) == 0 { + return 0, false + } + i, neg := 0, false + if b[0] == '-' || b[0] == '+' { + neg = b[0] == '-' + i++ + } + if i >= len(b) { + return 0, false + } + var v int64 + for ; i < len(b); i++ { + if b[i] < '0' || b[i] > '9' { + return 0, false + } + v = v*10 + int64(b[i]-'0') + if v < 0 { + return 0, false + } + } + if neg { + v = -v + } + return v, true +} diff --git a/internal/logfile/record.go b/internal/logfile/record.go new file mode 100644 index 0000000..bebe6dc --- /dev/null +++ b/internal/logfile/record.go @@ -0,0 +1,241 @@ +package logfile + +import ( + "bytes" + "strconv" +) + +// Column identifies a list column for focus, search scope and sorting. +type Column uint8 + +const ( + ColAll Column = iota + ColTime + ColTick + ColSub + ColMsg + ColFields + ColCount +) + +var columnName = [ColCount]string{"all", "time", "tick", "sub", "msg", "fields"} + +func (c Column) String() string { + if c < ColCount { + return columnName[c] + } + return "?" +} + +// Next returns the column d steps away, wrapping. +func (c Column) Next(d int) Column { + return Column(((int(c)+d)%int(ColCount) + int(ColCount)) % int(ColCount)) +} + +// Field is one member of the fields object; Val is the raw JSON token. +type Field struct { + Key string + Val []byte + Kind byte +} + +// Record is the parsed form of a line. It is the only type that knows the log +// schema: a new sub, a renamed key or a second format touches this file alone. +type Record struct { + Meta Meta + Raw []byte + Time string + Level string + Sub string + Trace string + Fields []Field + Bad bool + + msgIdx int // index into Fields of the discriminator, -1 if none + buf []byte // column rendering scratch, reused across calls +} + +// msgKeys are the discriminator keys in precedence order. Most records use +// fields.msg; the logger self-report uses fields.type. +var msgKeys = [...]string{"msg", "type"} + +// Parse fills r from line. Slices alias line and are reused across calls, so +// copy anything retained past the next Parse. +func (r *Record) Parse(m Meta, line []byte) { + r.Meta, r.Raw = m, line + r.Time, r.Level, r.Sub, r.Trace = "", "", "", "" + r.Fields = r.Fields[:0] + r.msgIdx = -1 + r.Bad = m.Flags&FlagMalformed != 0 || len(line) == 0 + if r.Bad { + return + } + + eachField(line, skipSpace(line, 0), func(k, v []byte, kind byte) bool { + switch string(k) { + case "time": + r.Time = string(strTok(v)) + case "level": + r.Level = string(strTok(v)) + case "sub": + r.Sub = string(strTok(v)) + case "trace": + r.Trace = unquote(v) + case "fields": + if kind == KObj { + eachField(v, 0, func(fk, fv []byte, fkind byte) bool { + r.Fields = append(r.Fields, Field{Key: string(fk), Val: fv, Kind: fkind}) + return true + }) + } + } + return true + }) + + // Resolve the discriminator once: input records carry both msg and type, + // and only the one actually used may be dropped from the fields text. + for _, k := range msgKeys { + for i := range r.Fields { + if r.Fields[i].Key == k && r.Fields[i].Kind == KStr { + r.msgIdx = i + break + } + } + if r.msgIdx >= 0 { + break + } + } +} + +// Get returns the named field. +func (r *Record) Get(key string) (Field, bool) { + for _, f := range r.Fields { + if f.Key == key { + return f, true + } + } + return Field{}, false +} + +// Msg returns the record's discriminator. +func (r *Record) Msg() string { + if r.msgIdx < 0 { + return "" + } + return unquote(r.Fields[r.msgIdx].Val) +} + +// FollowValue returns the first string field other than the discriminator: the +// value distinguishing records that share a (sub, msg) pair — ev for event +// dispatch, service for service records. Empty when every field is numeric. +func (r *Record) FollowValue() string { + for i, f := range r.Fields { + if i == r.msgIdx || f.Kind != KStr { + continue + } + return unquote(f.Val) + } + return "" +} + +const fieldsTextCap = 512 + +// FieldsText is the fields column exactly as the record list renders it. +func (r *Record) FieldsText() string { + r.buf = r.appendFields(r.buf[:0]) + return string(r.buf) +} + +// ColumnBytes renders one column's searchable text into a reused buffer, so +// search matches what is on screen rather than the raw JSON. +func (r *Record) ColumnBytes(c Column) []byte { + r.buf = r.buf[:0] + switch c { + case ColTime: + r.buf = append(r.buf, StampText(r.Meta)...) + case ColTick: + r.buf = strconv.AppendUint(r.buf, uint64(r.Meta.Tick), 10) + case ColSub: + r.buf = append(r.buf, r.Sub...) + case ColMsg: + r.buf = r.appendMsg(r.buf) + case ColFields: + r.buf = r.appendFields(r.buf) + default: + r.buf = append(r.buf, StampText(r.Meta)...) + r.buf = append(r.buf, ' ') + r.buf = append(r.buf, r.Sub...) + r.buf = append(r.buf, ' ') + r.buf = r.appendMsg(r.buf) + r.buf = append(r.buf, ' ') + r.buf = r.appendFields(r.buf) + } + return r.buf +} + +func (r *Record) appendMsg(dst []byte) []byte { + if r.msgIdx < 0 { + return dst + } + return appendUnquoted(dst, r.Fields[r.msgIdx].Val) +} + +func (r *Record) appendFields(dst []byte) []byte { + start := len(dst) + for i, f := range r.Fields { + if i == r.msgIdx { + continue + } + if len(dst) > start { + dst = append(dst, ' ') + } + dst = append(dst, f.Key...) + dst = append(dst, '=') + dst = r.appendDisplay(dst, f) + if len(dst)-start > fieldsTextCap { + break + } + } + return dst +} + +// Display renders a field value: durations via the unit table, long float +// tails trimmed, everything else verbatim. +func (r *Record) Display(f Field) string { + return string(r.appendDisplay(nil, f)) +} + +func (r *Record) appendDisplay(dst []byte, f Field) []byte { + switch f.Kind { + case KStr: + return appendUnquoted(dst, f.Val) + case KNum: + if u := DurationUnit(f.Key); u != DurNone { + if v, ok := parseInt64(f.Val); ok { + return append(dst, FormatDuration(v, u)...) + } + } + return append(dst, trimFloat(f.Val)...) + } + return append(dst, f.Val...) +} + +func appendUnquoted(dst, tok []byte) []byte { + in := strTok(tok) + if bytes.IndexByte(in, '\\') < 0 { + return append(dst, in...) + } + return append(dst, unquote(tok)...) +} + +// trimFloat shortens 17-digit float tails to 6 significant digits. +func trimFloat(tok []byte) []byte { + dot := bytes.IndexByte(tok, '.') + if dot < 0 || len(tok)-dot <= 7 { + return tok + } + if v, err := strconv.ParseFloat(string(tok), 64); err == nil { + return strconv.AppendFloat(nil, v, 'g', 6, 64) + } + return tok +} diff --git a/internal/logfile/scan.go b/internal/logfile/scan.go new file mode 100644 index 0000000..37c1b47 --- /dev/null +++ b/internal/logfile/scan.go @@ -0,0 +1,314 @@ +package logfile + +import ( + "bufio" + "io" + "os" + "path/filepath" + "sync" +) + +const ( + scanBufSize = 1 << 20 + publishEach = 4096 +) + +// scanPart is one source's private scan output, merged after completion. +type scanPart struct { + metas []Meta + snaps []Snapshot +} + +// Open indexes one or more files and starts background scanning. A single +// source grows the published view incrementally; several sources publish once, +// merged by timestamp, so a row index is stable for the life of the index. +func Open(paths ...string) (*Index, error) { + if len(paths) == 0 { + return nil, os.ErrInvalid + } + x := &Index{subN: newInterner(), msgN: newInterner()} + + files := make([]*os.File, 0, len(paths)) + for _, p := range paths { + f, err := os.Open(p) + if err != nil { + closeAll(files) + return nil, err + } + fi, err := f.Stat() + if err != nil { + f.Close() + closeAll(files) + return nil, err + } + abs := p + if a, err := filepath.Abs(p); err == nil { + abs = a + } + x.srcs = append(x.srcs, &Source{Path: abs, Name: filepath.Base(p), Size: fi.Size()}) + files = append(files, f) + } + + x.publish(nil, nil, nil, nil) + go x.scanAll(files) + return x, nil +} + +func closeAll(fs []*os.File) { + for _, f := range fs { + f.Close() + } +} + +// publish stores immutable slice headers; a later append reallocates rather +// than mutating what readers already hold. +func (x *Index) publish(metas []Meta, snaps []Snapshot, subs, msgs []string) { + x.metas.Store(&metas) + x.snaps.Store(&snaps) + x.subs.Store(&subs) + x.msgs.Store(&msgs) +} + +func (x *Index) scanAll(files []*os.File) { + live := len(x.srcs) == 1 + parts := make([]scanPart, len(x.srcs)) + + var wg sync.WaitGroup + for i := range x.srcs { + wg.Add(1) + go func(i int) { + defer wg.Done() + x.scanSource(uint16(i), files[i], &parts[i], live) + }(i) + } + wg.Wait() + + if live { + x.publish(parts[0].metas, parts[0].snaps, x.subN.table(), x.msgN.table()) + return + } + metas, snaps := mergeParts(parts) + x.publish(metas, snaps, x.subN.table(), x.msgN.table()) +} + +// scanSource indexes one file. In live mode it republishes as it goes. +func (x *Index) scanSource(src uint16, f *os.File, part *scanPart, live bool) { + s := x.srcs[src] + defer f.Close() + defer s.done.Store(true) + + br := bufio.NewReaderSize(f, scanBufSize) + + var ( + line []byte + off int64 + lastPub int + lastTS int64 + curID uint32 + curR uint32 + curT uint32 + haveSnap bool + ) + + // Estimated row count avoids repeated regrowth on multi-MB files. + if s.Size > 0 { + part.metas = make([]Meta, 0, int(s.Size/160)+64) + } + + // closedSnaps excludes the still-growing last group: its Count is mutated + // in place, and readers must never observe a header containing it. + closedSnaps := func() []Snapshot { + if len(part.snaps) == 0 { + return nil + } + return part.snaps[:len(part.snaps)-1] + } + + for { + var err error + line, err = readLine(br, line) + raw := trimEOL(line) + + if len(raw) > 0 { + m := parseMeta(raw, off, src, x.subN, x.msgN) + if m.Flags&FlagMalformed != 0 { + s.bad.Add(1) + } + // Ordering key: a line without a usable stamp inherits the previous + // one and is rendered as unstamped. + if m.TS == 0 { + m.TS, m.Flags = lastTS, m.Flags|FlagNoTime + } else { + lastTS = m.TS + } + idx := uint32(len(part.metas)) + + // A stat record whose (run,tick) differs from the previous stat + // record opens a new group. Frame is excluded: it is stamped by the + // render goroutine and can change mid-snapshot. + if x.subN.table()[m.Sub] == SubStat { + if !haveSnap || curR != m.Run || curT != m.Tick { + part.snaps = append(part.snaps, Snapshot{ + Head: idx, Run: m.Run, Tick: m.Tick, Frame: m.Frame, Src: src, + }) + curID = uint32(len(part.snaps)) + curR, curT, haveSnap = m.Run, m.Tick, true + m.Flags |= FlagSnapHead + } + part.snaps[curID-1].Count++ + m.Snap = curID + } + part.metas = append(part.metas, m) + } + + off += int64(len(line)) + s.scanned.Store(off) + + if live && len(part.metas)-lastPub >= publishEach { + x.publish(part.metas, closedSnaps(), x.subN.table(), x.msgN.table()) + lastPub = len(part.metas) + } + if err != nil { + if err != io.EOF { + s.failure.Store(&scanErr{err}) + } + break + } + } + s.scanned.Store(off) +} + +// mergeParts interleaves per-source rows by timestamp and renumbers snapshot +// ids into one global space. Sources are individually monotonic, so one linear +// k-way pass suffices. +func mergeParts(parts []scanPart) ([]Meta, []Snapshot) { + total := 0 + base := make([]uint32, len(parts)) + var snaps []Snapshot + for i := range parts { + total += len(parts[i].metas) + base[i] = uint32(len(snaps)) + snaps = append(snaps, parts[i].snaps...) + } + + out := make([]Meta, 0, total) + cur := make([]int, len(parts)) + for { + best := -1 + for i := range parts { + if cur[i] >= len(parts[i].metas) { + continue + } + if best < 0 || parts[i].metas[cur[i]].TS < parts[best].metas[cur[best]].TS { + best = i + } + } + if best < 0 { + break + } + m := parts[best].metas[cur[best]] + cur[best]++ + if m.Snap != 0 { + m.Snap += base[best] + if m.Flags&FlagSnapHead != 0 { + snaps[m.Snap-1].Head = uint32(len(out)) + } + } + out = append(out, m) + } + return out, snaps +} + +// readLine appends the next line, terminator included, into buf. +func readLine(br *bufio.Reader, buf []byte) ([]byte, error) { + buf = buf[:0] + for { + chunk, err := br.ReadSlice('\n') + buf = append(buf, chunk...) + if err == bufio.ErrBufferFull { + continue + } + return buf, err + } +} + +func trimEOL(b []byte) []byte { + for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') { + b = b[:len(b)-1] + } + return b +} + +// parseMeta extracts the indexed fields from one line. Unparseable lines are +// flagged and kept, never dropped. +func parseMeta(line []byte, off int64, src uint16, subN, msgN *interner) Meta { + m := Meta{Off: off, Len: uint32(len(line)), Src: src, Lvl: LevelBad, Flags: FlagMalformed} + i := skipSpace(line, 0) + if i >= len(line) || line[i] != '{' { + return m + } + + ok := eachField(line, i, func(k, v []byte, kind byte) bool { + switch string(k) { + case "time": + if kind == KStr { + if ns, good := parseRFC3339Nano(strTok(v)); good { + m.TS = ns + } + } + case "level": + if kind == KStr { + m.Lvl = ParseLevel(strTok(v)) + } + case "sub": + if kind == KStr { + if id := subN.intern(strTok(v)); id <= 0xffff { + m.Sub = uint16(id) + } + } + case "run": + m.Run = parseUint32(v) + case "tick": + m.Tick = parseUint32(v) + case "frame": + m.Frame = parseUint32(v) + case "trace": + if kind == KStr && len(v) > 2 { + m.Flags |= FlagTrace + } + case "fields": + if kind == KObj { + m.Msg = msgN.intern(discriminator(v)) + } + } + return true + }) + + if ok { + m.Flags &^= FlagMalformed + } + return m +} + +// discriminator returns fields.msg, falling back to fields.type for records +// that omit msg. Returns nil when neither is present; that interns to id 0. +func discriminator(fields []byte) []byte { + var msg, typ []byte + eachField(fields, 0, func(k, v []byte, kind byte) bool { + if kind != KStr { + return true + } + switch string(k) { + case "msg": + msg = strTok(v) + return false // msg wins and is always first; stop scanning + case "type": + typ = strTok(v) + } + return true + }) + if msg != nil { + return msg + } + return typ +} diff --git a/internal/logfile/schema.go b/internal/logfile/schema.go new file mode 100644 index 0000000..143ea4e --- /dev/null +++ b/internal/logfile/schema.go @@ -0,0 +1,162 @@ +package logfile + +import ( + "time" +) + +// Level is record severity. LevelTrace..LevelError are ordered for threshold +// filtering; LevelProc and LevelBad sit outside that order. +type Level uint8 + +const ( + LevelTrace Level = iota + LevelDebug + LevelInfo + LevelWarn + LevelError + LevelProc + LevelBad + LevelCount +) + +// OrderedCount is the number of threshold-comparable levels. +const OrderedCount = int(LevelError) + 1 + +var levelName = [LevelCount]string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "PROC", "BAD"} +var levelInitial = [LevelCount]byte{'T', 'D', 'I', 'W', 'E', 'P', 'B'} + +func (l Level) String() string { + if l < LevelCount { + return levelName[l] + } + return "?" +} + +// Initial returns the single character used to toggle the level. +func (l Level) Initial() byte { + if l < LevelCount { + return levelInitial[l] + } + return '?' +} + +// ParseLevel maps a level token to a Level; unknown tokens are LevelBad. +func ParseLevel(b []byte) Level { + switch string(b) { + case "TRACE": + return LevelTrace + case "DEBUG": + return LevelDebug + case "INFO": + return LevelInfo + case "WARN": + return LevelWarn + case "ERROR": + return LevelError + case "PROC": + return LevelProc + } + return LevelBad +} + +// LevelByInitial resolves a toggle character to a Level. +func LevelByInitial(c byte) (Level, bool) { + for i := Level(0); i < LevelCount; i++ { + if levelInitial[i] == c { + return i, true + } + } + return LevelBad, false +} + +// SubStat marks stat snapshot records. +const SubStat = "stat" + +// StampText renders a record's wall-clock stamp, or a placeholder when the +// line carried no usable time and inherited one for ordering. +func StampText(m Meta) string { + if m.Flags&FlagNoTime != 0 { + return "--:--:--.---" + } + return FormatTS(m.TS) +} + +// KnownSubs is the closed subsystem vocabulary; ad-hoc taps are also accepted. +var KnownSubs = []string{ + "app", "service", "fsm", "event", "dispatch", "push", + "input", "stat", "rec", "lock", "race", "crash", +} + +// DurUnit is the time unit implied by a metric key's suffix. +type DurUnit uint8 + +const ( + DurNone DurUnit = iota + DurNs + DurUs + DurMs +) + +// durKeys maps a key name to the unit it implies. Order matters: the first +// match wins, so unit suffixes are tested after the bare duration names. +var durKeys = []struct { + name string + unit DurUnit +}{ + {"timer", DurNs}, {"duration", DurNs}, {"elapsed", DurNs}, {"remaining", DurNs}, + {"ns", DurNs}, {"us", DurUs}, {"ms", DurMs}, +} + +// DurationUnit reports the duration unit implied by a field key. +func DurationUnit(key string) DurUnit { + for _, d := range durKeys { + if hasKeySegment(key, d.name) { + return d.unit + } + } + return DurNone +} + +// hasKeySegment matches name as the whole key or as its trailing '.'/'_' +// segment: "elapsed", "max_duration" and "fsm.elapsed" match, "populations" +// does not match "ns". +func hasKeySegment(key, name string) bool { + if len(key) < len(name) || key[len(key)-len(name):] != name { + return false + } + if len(key) == len(name) { + return true + } + c := key[len(key)-len(name)-1] + return c == '.' || c == '_' +} + +// FormatDuration renders v, expressed in unit u, as a human duration. +func FormatDuration(v int64, u DurUnit) string { + switch u { + case DurNs: + return time.Duration(v).String() + case DurUs: + return (time.Duration(v) * time.Microsecond).String() + case DurMs: + return (time.Duration(v) * time.Millisecond).String() + } + return "" +} + +// FormatTS renders unix nanoseconds as a local wall-clock stamp. +func FormatTS(ns int64) string { + if ns == 0 { + return "--:--:--.---" + } + return time.Unix(0, ns).Format("15:04:05.000") +} + +// Dash renders empty vocabulary values: records without sub, or without a +// fields.msg/type discriminator. +func Dash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/internal/logfile/time.go b/internal/logfile/time.go new file mode 100644 index 0000000..56cdd18 --- /dev/null +++ b/internal/logfile/time.go @@ -0,0 +1,84 @@ +package logfile + +// parseRFC3339Nano parses an RFC3339 stamp with optional fractional seconds +// into unix nanoseconds. Hand-rolled to keep the index pass allocation-free. +func parseRFC3339Nano(b []byte) (int64, bool) { + if len(b) < 19 { + return 0, false + } + if b[4] != '-' || b[7] != '-' || (b[10] != 'T' && b[10] != 't') || b[13] != ':' || b[16] != ':' { + return 0, false + } + y, ok1 := numField(b[0:4]) + mo, ok2 := numField(b[5:7]) + d, ok3 := numField(b[8:10]) + h, ok4 := numField(b[11:13]) + mi, ok5 := numField(b[14:16]) + s, ok6 := numField(b[17:19]) + if !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 { + return 0, false + } + + i := 19 + var frac int64 + if i < len(b) && b[i] == '.' { + scale := int64(100000000) + for i++; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ { + if scale > 0 { + frac += int64(b[i]-'0') * scale + scale /= 10 + } + } + } + + var offSec int64 + if i < len(b) { + switch b[i] { + case 'Z', 'z': + case '+', '-': + if i+6 > len(b) || b[i+3] != ':' { + return 0, false + } + oh, okh := numField(b[i+1 : i+3]) + om, okm := numField(b[i+4 : i+6]) + if !okh || !okm { + return 0, false + } + offSec = int64(oh)*3600 + int64(om)*60 + if b[i] == '-' { + offSec = -offSec + } + } + } + + sec := daysFromCivil(y, mo, d)*86400 + int64(h)*3600 + int64(mi)*60 + int64(s) - offSec + return sec*1e9 + frac, true +} + +func numField(b []byte) (int, bool) { + v := 0 + for _, c := range b { + if c < '0' || c > '9' { + return 0, false + } + v = v*10 + int(c-'0') + } + return v, true +} + +// daysFromCivil returns days since 1970-01-01 (Hinnant's civil-date algorithm). +func daysFromCivil(y, m, d int) int64 { + if m <= 2 { + y-- + } + era := y + if era < 0 { + era -= 399 + } + era /= 400 + yoe := y - era*400 + mp := (m + 9) % 12 + doy := (153*mp+2)/5 + d - 1 + doe := yoe*365 + yoe/4 - yoe/100 + doy + return int64(era)*146097 + int64(doe) - 719468 +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..086dd12 --- /dev/null +++ b/internal/ui/layout.go @@ -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 +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go new file mode 100644 index 0000000..665ba82 --- /dev/null +++ b/internal/ui/overlay.go @@ -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 +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..927cdf4 --- /dev/null +++ b/internal/ui/theme.go @@ -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))] +}