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 }