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) } }