From 5020f996b120a715fee9da43966ec3aad1ceecdc Mon Sep 17 00:00:00 2001 From: pancsta <155631569+pancsta@users.noreply.github.com> Date: Wed, 10 Jul 2024 22:51:49 +0200 Subject: [PATCH] feat(am-dbg): add tx and log filtering (#79) --- pkg/machine/machine.go | 2 +- pkg/machine/misc.go | 5 + pkg/machine/transition.go | 2 +- pkg/telemetry/telemetry.go | 33 +- tools/cmd/am-dbg/main.go | 10 +- tools/debugger/debugger.go | 1295 +++++-------------------------- tools/debugger/handlers.go | 170 ++-- tools/debugger/keyboard.go | 667 ++++++++++++++++ tools/debugger/log.go | 246 ++++++ tools/debugger/server/rpc.go | 125 +++ tools/debugger/states/ss_dbg.go | 54 +- tools/debugger/tree.go | 40 +- tools/debugger/ui.go | 319 ++++++++ tools/debugger/utils.go | 81 ++ 14 files changed, 1866 insertions(+), 1183 deletions(-) create mode 100644 tools/debugger/keyboard.go create mode 100644 tools/debugger/log.go create mode 100644 tools/debugger/server/rpc.go create mode 100644 tools/debugger/ui.go create mode 100644 tools/debugger/utils.go diff --git a/pkg/machine/machine.go b/pkg/machine/machine.go index d7dbd4e..5504b85 100644 --- a/pkg/machine/machine.go +++ b/pkg/machine/machine.go @@ -1734,7 +1734,7 @@ func (m *Machine) log(level LogLevel, msg string, args ...any) { if t != nil { // append the log msg to the current transition // TODO not thread safe - t.LogEntries = append(t.LogEntries, out) + t.LogEntries = append(t.LogEntries, &LogEntry{level, out}) } else { // append the log msg the machine and collect at the end of the next diff --git a/pkg/machine/misc.go b/pkg/machine/misc.go index b6bf715..0b0fc47 100644 --- a/pkg/machine/misc.go +++ b/pkg/machine/misc.go @@ -311,6 +311,11 @@ type Logger func(level LogLevel, msg string, args ...any) // LogLevel enum type LogLevel int +type LogEntry struct { + Level LogLevel + Text string +} + const ( // LogNothing means no logging, including external msgs. LogNothing LogLevel = iota diff --git a/pkg/machine/transition.go b/pkg/machine/transition.go index 80f02fb..161d757 100644 --- a/pkg/machine/transition.go +++ b/pkg/machine/transition.go @@ -55,7 +55,7 @@ type Transition struct { // Parent machine Machine *Machine // Log entries produced during the transition - LogEntries []string + LogEntries []*LogEntry // start time of the transition // Latest / current step of the transition diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index c394ae4..eef8ac9 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -20,9 +20,9 @@ import ( am "github.com/pancsta/asyncmachine-go/pkg/machine" ) -////////////////// -///// AM-DBG -////////////////// +// //////////////// +// /// AM-DBG +// //////////////// const DbgHost = "localhost:6831" @@ -42,8 +42,6 @@ type DbgMsgStruct struct { StatesIndex am.S // all the states with relations States am.Struct - // log level of the machine - LogLevel am.LogLevel } func (d *DbgMsgStruct) Clock(_ am.S, _ string) uint64 { @@ -70,8 +68,7 @@ type DbgMsgTx struct { // all the transition steps Steps []*am.Step // log entries created during the transition - // TODO include log levels - LogEntries []string + LogEntries []*am.LogEntry // log entries before the transition, which happened after the prev one PreLogEntries []string // transition was triggered by an auto state @@ -247,7 +244,6 @@ func sendStructMsg(mach *am.Machine, client *dbgClient) error { ID: mach.ID, StatesIndex: mach.StateNames, States: mach.GetStruct(), - LogLevel: mach.GetLogLevel(), } // TODO retries @@ -264,11 +260,14 @@ func removeLogPrefix(msg *DbgMsgTx) { addChars := 3 // "[] " prefixLen := min(len(msg.MachineID)+addChars, maxIDlen+addChars) - for i := range msg.LogEntries { - if len(msg.LogEntries[i]) < prefixLen { + for i, le := range msg.LogEntries { + if len(msg.LogEntries[i].Text) < prefixLen { continue } - msg.LogEntries[i] = msg.LogEntries[i][prefixLen:] + msg.LogEntries[i] = &am.LogEntry{ + Level: le.Level, + Text: le.Text[prefixLen:], + } } for i := range msg.PreLogEntries { @@ -279,9 +278,9 @@ func removeLogPrefix(msg *DbgMsgTx) { } } -////////////////// -///// OPEN TELEMETRY -////////////////// +// //////////////// +// /// OPEN TELEMETRY +// //////////////// // OtelMachTracer implements machine.Tracer for OpenTelemetry. // Support tracing multiple machines @@ -683,9 +682,9 @@ func (ot *OtelMachTracer) Inheritable() bool { return true } -////////////////// -///// UTILS -////////////////// +// //////////////// +// /// UTILS +// //////////////// // j joins state names func j(states []string) string { diff --git a/tools/cmd/am-dbg/main.go b/tools/cmd/am-dbg/main.go index 029cb3d..bcaf749 100644 --- a/tools/cmd/am-dbg/main.go +++ b/tools/cmd/am-dbg/main.go @@ -13,6 +13,7 @@ import ( "github.com/pancsta/asyncmachine-go/pkg/telemetry" "github.com/pancsta/asyncmachine-go/tools/debugger" ss "github.com/pancsta/asyncmachine-go/tools/debugger/states" + "github.com/pancsta/asyncmachine-go/tools/debugger/server" "github.com/spf13/cobra" ) @@ -155,6 +156,7 @@ func cliRun(cmd *cobra.Command, _ []string) { CleanOnConnect: cleanOnConnect, SelectConnected: selectConnected, Clients: make(map[string]*debugger.Client), + LogLevel: am.LogChanges, } gob.Register(debugger.Exportable{}) gob.Register(am.Relation(0)) @@ -180,7 +182,7 @@ func cliRun(cmd *cobra.Command, _ []string) { } // rpc server - go debugger.StartRCP(dbg.Mach, serverURL) + go server.StartRCP(dbg.Mach, serverURL) // start and wait till the end dbg.Mach.Add1(ss.Start, am.A{ @@ -189,14 +191,14 @@ func cliRun(cmd *cobra.Command, _ []string) { "dbgView": startupView, }) <-dbg.Mach.WhenNot1(ss.Start, nil) - txes := 0 + txs := 0 for _, c := range dbg.Clients { - txes += len(c.MsgTxs) + txs += len(c.MsgTxs) } // TODO format numbers _, _ = dbg.P.Printf("Clients: %d\n", len(dbg.Clients)) - _, _ = dbg.P.Printf("Transitions: %d\n", txes) + _, _ = dbg.P.Printf("Transitions: %d\n", txs) } // TODO extract to tools/debugger diff --git a/tools/debugger/debugger.go b/tools/debugger/debugger.go index cb5412f..f13c802 100644 --- a/tools/debugger/debugger.go +++ b/tools/debugger/debugger.go @@ -8,20 +8,15 @@ import ( "log" "os" "path" - "regexp" "slices" - "sort" "strconv" "strings" "time" - "code.rocketnine.space/tslocum/cbind" "github.com/dsnet/compress/bzip2" "github.com/gdamore/tcell/v2" - "github.com/lithammer/dedent" "github.com/pancsta/cview" "github.com/samber/lo" - "golang.org/x/exp/maps" "golang.org/x/text/message" am "github.com/pancsta/asyncmachine-go/pkg/machine" @@ -47,6 +42,8 @@ const ( searchAsTypeWindow = 1500 * time.Millisecond ) +// TODO NewDebugger + type Debugger struct { am.ExceptionHandler Mach *am.Machine @@ -71,7 +68,7 @@ type Debugger struct { nextTxBarLeft *cview.TextView nextTxBarRight *cview.TextView helpDialog *cview.Flex - keystrokesBar *cview.TextView + keyBar *cview.TextView layoutRoot *cview.Panels sidebar *cview.List mainGrid *cview.Grid @@ -88,6 +85,9 @@ type Debugger struct { focusManager *cview.FocusManager exportDialog *cview.Modal contentPanels *cview.Panels + filtersBar *cview.TextView + focusedFilter string + LogLevel am.LogLevel } type Exportable struct { @@ -111,8 +111,10 @@ type Client struct { selectedState string // processed msgTxsParsed []MsgTxParsed + // processed list of filtered tx indexes + msgTxsFiltered []int // processed - logMsgs []string + logMsgs [][]*am.LogEntry } type MsgTxParsed struct { @@ -121,30 +123,6 @@ type MsgTxParsed struct { StatesTouched am.S } -type nodeRef struct { - // TODO type - // type nodeType - // node is a state (reference or top level) - stateName string - // node is a state reference, not a top level state - // eg Bar in case of: Foo -> Remove -> Bar - // TODO name collision with nodeRef - isRef bool - // node is a relation (Remove, Add, Require, After) - isRel bool - // relation type (if isRel) - rel am.Relation - // top level state name (for both rels and refs) - parentState string - // node touched by a transition step - touched bool - // expanded by the user - expanded bool - // node is a state property (Auto, Multi) - isProp bool - propLabel string -} - // ScrollToStateTx scrolls to the next transition involving the state being // activated or deactivated. If fwd is true, it scrolls forward, otherwise // backwards. @@ -173,16 +151,6 @@ func (d *Debugger) ScrollToStateTx(state string, fwd bool) { } } -// RedrawFull updates all components of the debugger UI, except the sidebar. -func (d *Debugger) RedrawFull(immediate bool) { - d.updateViews(immediate) - d.updateTimelines() - d.updateTxBars() - d.updateKeyBars() - d.updateBorderColor() - d.draw() -} - func (d *Debugger) NextTx() *telemetry.DbgMsgTx { c := d.C if c == nil { @@ -217,350 +185,83 @@ func (d *Debugger) PrevTx() *telemetry.DbgMsgTx { return c.MsgTxs[c.CursorTx-2] } -func (d *Debugger) initUIComponents() { - d.helpDialog = d.initHelpDialog() - d.exportDialog = d.initExportDialog() - - // tree view - d.tree = d.initMachineTree() - d.tree.SetTitle(" Structure ") - d.tree.SetBorder(true) - - // sidebar - d.sidebar = cview.NewList() - d.sidebar.SetTitle(" Machines ") - d.sidebar.SetBorder(true) - d.sidebar.ShowSecondaryText(false) - d.sidebar.SetSelectedFocusOnly(true) - d.sidebar.SetMainTextColor(colorActive) - d.sidebar.SetSelectedTextColor(tcell.ColorWhite) - d.sidebar.SetSelectedBackgroundColor(colorHighlight2) - d.sidebar.SetHighlightFullLine(true) - d.sidebar.SetSelectedFunc(func(i int, listItem *cview.ListItem) { - cid := listItem.GetReference().(string) - d.Mach.Add1(ss.SelectingClient, am.A{"Client.id": cid}) - }) - d.sidebar.SetSelectedAlwaysVisible(true) - - // log view - d.log = cview.NewTextView() - d.log.SetBorder(true) - d.log.SetRegions(true) - d.log.SetTextAlign(cview.AlignLeft) - d.log.SetWrap(true) - d.log.SetDynamicColors(true) - d.log.SetTitle(" Log ") - d.log.SetHighlightForegroundColor(tcell.ColorWhite) - d.log.SetHighlightBackgroundColor(colorHighlight2) - - // matrix - d.matrix = cview.NewTable() - d.matrix.SetBorder(true) - d.matrix.SetTitle(" Matrix ") - // TODO draw scrollbar at the right edge - d.matrix.SetScrollBarVisibility(cview.ScrollBarNever) - d.matrix.SetPadding(0, 0, 1, 0) - - // current tx bar - d.currTxBarLeft = cview.NewTextView() - d.currTxBarLeft.SetDynamicColors(true) - d.currTxBarRight = cview.NewTextView() - d.currTxBarRight.SetTextAlign(cview.AlignRight) - - // next tx bar - d.nextTxBarLeft = cview.NewTextView() - d.nextTxBarLeft.SetDynamicColors(true) - d.nextTxBarRight = cview.NewTextView() - d.nextTxBarRight.SetTextAlign(cview.AlignRight) - - // TODO step info bar: type, from, to, data - // timeline tx - d.timelineTxs = cview.NewProgressBar() - d.timelineTxs.SetBorder(true) - - // timeline steps - d.timelineSteps = cview.NewProgressBar() - d.timelineSteps.SetBorder(true) - - // keystrokes bar - d.keystrokesBar = cview.NewTextView() - d.keystrokesBar.SetTextAlign(cview.AlignCenter) - d.keystrokesBar.SetDynamicColors(true) - - // update models - d.updateTimelines() - d.updateTxBars() - d.updateKeyBars() - d.updateFocusable() -} +func (d *Debugger) updateFiltersBar() { + focused := d.Mach.Is1(ss.FiltersFocused) + f := fmt.Sprintf + text := "" -func (d *Debugger) initLayout() { - // TODO flexbox - currTxBar := cview.NewGrid() - currTxBar.AddItem(d.currTxBarLeft, 0, 0, 1, 1, 0, 0, false) - currTxBar.AddItem(d.currTxBarRight, 0, 1, 1, 1, 0, 0, false) - - // TODO flexbox - nextTxBar := cview.NewGrid() - nextTxBar.AddItem(d.nextTxBarLeft, 0, 0, 1, 1, 0, 0, false) - nextTxBar.AddItem(d.nextTxBarRight, 0, 1, 1, 1, 0, 0, false) - - // content grid - treeLogGrid := cview.NewGrid() - treeLogGrid.SetRows(-1) - treeLogGrid.SetColumns( /*tree*/ -1 /*log*/, -1, -1) - treeLogGrid.AddItem(d.tree, 0, 0, 1, 1, 0, 0, false) - treeLogGrid.AddItem(d.log, 0, 1, 1, 2, 0, 0, false) - - treeMatrixGrid := cview.NewGrid() - treeMatrixGrid.SetRows(-1) - treeMatrixGrid.SetColumns( /*tree*/ -1 /*log*/, -1, -1) - treeMatrixGrid.AddItem(d.tree, 0, 0, 1, 1, 0, 0, false) - treeMatrixGrid.AddItem(d.matrix, 0, 1, 1, 2, 0, 0, false) - - // content panels - d.contentPanels = cview.NewPanels() - d.contentPanels.AddPanel("tree-log", treeLogGrid, true, true) - d.contentPanels.AddPanel("tree-matrix", treeMatrixGrid, true, false) - d.contentPanels.AddPanel("matrix", d.matrix, true, false) - d.contentPanels.SetBackgroundColor(colorHighlight) - - // main grid - mainGrid := cview.NewGrid() - mainGrid.SetRows(-1, 2, 3, 2, 3, 2) - cols := []int{ /*sidebar*/ -1 /*content*/, -1, -1, -1, -1, -1, -1} - mainGrid.SetColumns(cols...) - // row 1 left - mainGrid.AddItem(d.sidebar, 0, 0, 1, 1, 0, 0, false) - // row 1 mid, right - mainGrid.AddItem(d.contentPanels, 0, 1, 1, 6, 0, 0, false) - // row 2...5 - mainGrid.AddItem(currTxBar, 1, 0, 1, len(cols), 0, 0, false) - mainGrid.AddItem(d.timelineTxs, 2, 0, 1, len(cols), 0, 0, false) - mainGrid.AddItem(nextTxBar, 3, 0, 1, len(cols), 0, 0, false) - mainGrid.AddItem(d.timelineSteps, 4, 0, 1, len(cols), 0, 0, false) - mainGrid.AddItem(d.keystrokesBar, 5, 0, 1, len(cols), 0, 0, false) - - panels := cview.NewPanels() - panels.AddPanel("export", d.exportDialog, false, true) - panels.AddPanel("help", d.helpDialog, true, true) - panels.AddPanel("main", mainGrid, true, true) - - d.mainGrid = mainGrid - d.layoutRoot = panels -} - -type Focusable struct { - cview.Primitive - *cview.Box -} - -// TODO feat: support dialogs -// TODO refac: generate d.focusable list via GetFocusable -// TODO fix: preserve currently focused element -func (d *Debugger) updateFocusable() { - if d.focusManager == nil { - d.Mach.Log("Error: focus manager not initialized") - return + // title + if focused { + text += "[::bu]F[::-][::b]ilters:[::-]" + } else { + text += "[::u]F[::-]ilters:" } - var prims []cview.Primitive - switch d.Mach.Switch(ss.GroupViews...) { - - case ss.MatrixView: - d.focusable = []*cview.Box{ - d.sidebar.Box, d.matrix.Box, d.timelineTxs.Box, d.timelineSteps.Box, - } - prims = []cview.Primitive{ - d.sidebar, d.matrix, d.timelineTxs, - d.timelineSteps, - } - - case ss.TreeMatrixView: - d.focusable = []*cview.Box{ - d.sidebar.Box, d.tree.Box, d.matrix.Box, d.timelineTxs.Box, - d.timelineSteps.Box, - } - prims = []cview.Primitive{ - d.sidebar, d.tree, d.matrix, d.timelineTxs, - d.timelineSteps, - } - - case ss.TreeLogView: - fallthrough - default: - d.focusable = []*cview.Box{ - d.sidebar.Box, d.tree.Box, d.log.Box, d.timelineTxs.Box, - d.timelineSteps.Box, - } - prims = []cview.Primitive{ - d.sidebar, d.tree, d.log, d.timelineTxs, - d.timelineSteps, - } - + // TODO extract + filters := []filter{ + { + id: "skip-canceled", + label: "Skip Canceled", + active: d.Mach.Is1(ss.FilterCanceledTx), + }, + { + id: "skip-auto", + label: "Skip Auto", + active: d.Mach.Is1(ss.FilterAutoTx), + }, + { + id: "skip-empty", + label: "Skip Empty", + active: d.Mach.Is1(ss.FilterEmptyTx), + }, + { + id: "log-0", + label: "L0", + active: d.LogLevel == am.LogNothing, + }, + { + id: "log-1", + label: "L1", + active: d.LogLevel == am.LogChanges, + }, + { + id: "log-2", + label: "L2", + active: d.LogLevel == am.LogOps, + }, + { + id: "log-3", + label: "L3", + active: d.LogLevel == am.LogDecisions, + }, + { + id: "log-4", + label: "L4", + active: d.LogLevel == am.LogEverything, + }, } - d.focusManager.Reset() - d.focusManager.Add(prims...) - - switch d.Mach.Switch(ss.GroupFocused...) { - case ss.SidebarFocused: - d.focusManager.Focus(d.sidebar) - case ss.TreeFocused: - if d.Mach.Any1(ss.TreeMatrixView, ss.TreeLogView) { - d.focusManager.Focus(d.tree) - } else { - d.focusManager.Focus(d.sidebar) - } - case ss.LogFocused: - if d.Mach.Is1(ss.TreeLogView) { - d.focusManager.Focus(d.tree) + // tx filters + for _, item := range filters { + if item.active { + text += f(" [::b]%s[::-]", cview.Escape("[X]")) } else { - d.focusManager.Focus(d.sidebar) + text += f(" [ ]") } - d.focusManager.Focus(d.log) - case ss.MatrixFocused: - if d.Mach.Any1(ss.TreeMatrixView, ss.MatrixView) { - d.focusManager.Focus(d.matrix) - } else { - d.focusManager.Focus(d.sidebar) - } - case ss.TimelineTxsFocused: - d.focusManager.Focus(d.timelineTxs) - case ss.TimelineStepsFocused: - d.focusManager.Focus(d.timelineSteps) - default: - d.focusManager.Focus(d.sidebar) - } -} -// TODO tab navigation -// TODO delegated flow machine? -func (d *Debugger) initExportDialog() *cview.Modal { - exportDialog := cview.NewModal() - form := exportDialog.GetForm() - form.AddInputField("Filename", "am-dbg-dump", 20, nil, nil) - - exportDialog.SetText("Export all clients data (esc quits)") - exportDialog.AddButtons([]string{"Save"}) - // TODO support cancel - // exportDialog.AddButtons([]string{"Save", "Cancel"}) - exportDialog.SetDoneFunc(func(buttonIndex int, buttonLabel string) { - field, ok := form.GetFormItemByLabel("Filename").(*cview.InputField) - if !ok { - d.Mach.Log("Error: export dialog field not found") - return - } - filename := field.GetText() - - if buttonLabel == "Save" && filename != "" { - form.GetButton(0).SetLabel("Saving...") - form.Draw(d.app.GetScreen()) - d.exportData(filename) - d.Mach.Remove1(ss.ExportDialog, nil) - form.GetButton(0).SetLabel("Save") - } else if buttonLabel == "Cancel" { - d.Mach.Remove1(ss.ExportDialog, nil) + if d.focusedFilter == item.id && focused { + text += f("[%s][::b]%s[::-]", colorActive, item.label) + } else if !focused { + text += f("[%s]%s", colorHighlight2, item.label) + } else { + text += f("%s", item.label) } - }) - return exportDialog -} + text += "[-]" + } -// TODO better approach to modals -// TODO modal titles -// TODO state color meanings -// TODO page up/down on tx timeline -func (d *Debugger) initHelpDialog() *cview.Flex { - left := cview.NewTextView() - left.SetBackgroundColor(colorHighlight) - left.SetTitle(" Legend ") - left.SetDynamicColors(true) - left.SetPadding(1, 1, 1, 1) - left.SetText(dedent.Dedent(strings.Trim(` - [::b]### [::u]tree legend[::-] - - [::b]*[::-] handler ran - [::b]+[::-] to be added - [::b]-[::-] to be removed - [::b]bold[::-] touched state - [::b]underline[::-] called state - [::b]![::-] state canceled - - [::b]### [::u]matrix legend[::-] - - [::b]underline[::-] called state - - [::b]1st row[::-] called states - col == state index - - [::b]2nd row[::-] state tick changes - col == state index - - [::b]>=3 row[::-] state relations - cartesian product - col == source state index - row == target state index - - `, "\n "))) - - right := cview.NewTextView() - right.SetBackgroundColor(colorHighlight) - right.SetTitle(" Keystrokes ") - right.SetDynamicColors(true) - right.SetPadding(1, 1, 1, 1) - right.SetText(dedent.Dedent(strings.Trim(` - [::b]### [::u]keystrokes[::-] - - [::b]tab[::-] change focus - [::b]shift+tab[::-] change focus - [::b]space[::-] play/pause - [::b]left/right[::-] back/fwd - [::b]alt+left/right[::-] fast jump - [::b]alt+h/l[::-] fast jump - [::b]alt+h/l[::-] state jump (if selected) - [::b]up/down[::-] scroll / navigate - [::b]j/k[::-] scroll / navigate - [::b]alt+j/k[::-] page up/down - [::b]alt+e[::-] expand/collapse tree - [::b]enter[::-] expand/collapse node - [::b]alt+v[::-] tail mode - [::b]alt+m[::-] matrix view - [::b]home/end[::-] struct / last tx - [::b]alt+s[::-] export data - [::b]backspace[::-] remove machine - [::b]ctrl+q[::-] quit - [::b]?[::-] show help - `, "\n "))) - - grid := cview.NewGrid() - grid.SetTitle(" AsyncMachine Debugger ") - grid.SetColumns(0, 0) - grid.SetRows(0) - grid.AddItem(left, 0, 0, 1, 1, 0, 0, false) - grid.AddItem(right, 0, 1, 1, 1, 0, 0, false) - - box1 := cview.NewBox() - box1.SetBackgroundTransparent(true) - box2 := cview.NewBox() - box2.SetBackgroundTransparent(true) - box3 := cview.NewBox() - box3.SetBackgroundTransparent(true) - box4 := cview.NewBox() - box4.SetBackgroundTransparent(true) - - flexHor := cview.NewFlex() - flexHor.AddItem(box1, 0, 1, false) - flexHor.AddItem(grid, 0, 2, false) - flexHor.AddItem(box2, 0, 1, false) - - flexVer := cview.NewFlex() - flexVer.SetDirection(cview.FlexRow) - flexVer.AddItem(box3, 0, 1, false) - flexVer.AddItem(flexHor, 0, 2, false) - flexVer.AddItem(box4, 0, 1, false) - - return flexVer + // TODO save filters per machine checkbox + d.filtersBar.SetText(text) } func (d *Debugger) updateViews(immediate bool) { @@ -593,449 +294,6 @@ func (d *Debugger) updateViews(immediate bool) { } } -func (d *Debugger) bindKeyboard() { - // focus manager - d.focusManager = cview.NewFocusManager(d.app.SetFocus) - d.focusManager.SetWrapAround(true) - inputHandler := cbind.NewConfiguration() - d.app.SetAfterFocusFunc(d.afterFocus()) - - focusChange := func(f func()) func(ev *tcell.EventKey) *tcell.EventKey { - return func(ev *tcell.EventKey) *tcell.EventKey { - // keep Tab inside dialogs - if d.Mach.Any1(ss.GroupDialog...) { - return ev - } - - // fwd to FocusManager - f() - return nil - } - } - - // tab - for _, key := range cview.Keys.MovePreviousField { - err := inputHandler.Set(key, focusChange(d.focusManager.FocusPrevious)) - if err != nil { - log.Printf("Error: binding keys %s", err) - } - } - - // shift+tab - for _, key := range cview.Keys.MoveNextField { - err := inputHandler.Set(key, focusChange(d.focusManager.FocusNext)) - if err != nil { - log.Printf("Error: binding keys %s", err) - } - } - - // custom keys - for key, fn := range d.getKeystrokes() { - err := inputHandler.Set(key, fn) - if err != nil { - log.Printf("Error: binding keys %s", err) - } - } - - d.searchTreeSidebar(inputHandler) - d.app.SetInputCapture(inputHandler.Capture) -} - -// afterFocus forwards focus events to machine states -func (d *Debugger) afterFocus() func(p cview.Primitive) { - return func(p cview.Primitive) { - switch p { - - case d.tree: - fallthrough - case d.tree.Box: - d.Mach.Add1(ss.TreeFocused, nil) - - case d.log: - fallthrough - case d.log.Box: - d.Mach.Add1(ss.LogFocused, nil) - - case d.timelineTxs: - fallthrough - case d.timelineTxs.Box: - d.Mach.Add1(ss.TimelineTxsFocused, nil) - - case d.timelineSteps: - fallthrough - case d.timelineSteps.Box: - d.Mach.Add1(ss.TimelineStepsFocused, nil) - - case d.sidebar: - fallthrough - case d.sidebar.Box: - d.Mach.Add1(ss.SidebarFocused, nil) - - case d.matrix: - fallthrough - case d.matrix.Box: - d.Mach.Add1(ss.MatrixFocused, nil) - - case d.helpDialog: - fallthrough - case d.helpDialog.Box: - fallthrough - case d.exportDialog: - fallthrough - case d.exportDialog.Box: - d.Mach.Add1(ss.DialogFocused, nil) - } - - // update the log highlight on focus change - if d.Mach.Is1(ss.TreeLogView) { - d.updateLog(true) - } - - d.updateSidebar(true) - } -} - -// searchTreeSidebar searches for a-z, -, _ in the tree and sidebar, with a -// searchAsTypeWindow buffer. -func (d *Debugger) searchTreeSidebar(inputHandler *cbind.Configuration) { - var ( - bufferStart time.Time - buffer string - keys = []string{"-", "_"} - ) - - for i := 0; i < 26; i++ { - keys = append(keys, - fmt.Sprintf("%c", 'a'+i)) - } - - for _, key := range keys { - key := key - err := inputHandler.Set(key, func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not(am.S{ss.SidebarFocused, ss.TreeFocused}) { - return ev - } - - // buffer - if bufferStart.Add(searchAsTypeWindow).After(time.Now()) { - buffer += key - } else { - bufferStart = time.Now() - buffer = key - } - - // find the first client starting with the key - - // sidebar - if d.Mach.Is1(ss.SidebarFocused) { - for i, item := range d.sidebar.GetItems() { - text := normalizeText(item.GetMainText()) - if strings.HasPrefix(text, buffer) { - d.sidebar.SetCurrentItem(i) - d.updateSidebar(true) - - d.draw() - break - } - } - } else if d.Mach.Is1(ss.TreeFocused) { - - // tree - found := false - d.treeRoot.WalkUnsafe( - func(node, parent *cview.TreeNode, depth int) bool { - if found { - return false - } - - text := normalizeText(node.GetText()) - - if parent != nil && parent.IsExpanded() && - strings.HasPrefix(text, buffer) { - d.Mach.Remove1(ss.StateNameSelected, nil) - d.tree.SetCurrentNode(node) - d.updateTree() - d.draw() - found = true - - return false - } - - return true - }) - } - - return nil - }) - if err != nil { - log.Printf("Error: binding keys %s", err) - } - } -} - -// regexp removing [foo] -var re = regexp.MustCompile(`\[(.*?)\]`) - -func normalizeText(text string) string { - return strings.ToLower(re.ReplaceAllString(text, "")) -} - -func (d *Debugger) getKeystrokes() map[string]func( - ev *tcell.EventKey) *tcell.EventKey { - // TODO add state deps to the keystrokes structure - // TODO use tcell.KeyNames instead of strings as keys - // TODO rate limit - return map[string]func(ev *tcell.EventKey) *tcell.EventKey{ - // play/pause - "space": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.ClientSelected) { - return nil - } - - if d.Mach.Is1(ss.Paused) { - d.Mach.Add1(ss.Playing, nil) - } else { - d.Mach.Add1(ss.Paused, nil) - } - - return nil - }, - - // prev tx - "left": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.ClientSelected) { - return nil - } - if d.throttleKey(ev, arrowThrottleMs) { - // TODO fast jump scroll while holding the key - return nil - } - - // scroll matrix - if d.Mach.Is1(ss.MatrixFocused) { - return ev - } - - // scroll timelines - if d.Mach.Is1(ss.TimelineStepsFocused) { - d.Mach.Add1(ss.UserBackStep, nil) - } else { - d.Mach.Add1(ss.UserBack, nil) - } - - return nil - }, - - // next tx - "right": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.ClientSelected) { - return nil - } - if d.throttleKey(ev, arrowThrottleMs) { - // TODO fast jump scroll while holding the key - return nil - } - - // scroll matrix - if d.Mach.Is1(ss.MatrixFocused) { - return ev - } - - // scroll timelines - if d.Mach.Is1(ss.TimelineStepsFocused) { - // TODO try mach.IsScheduled(ss.UserFwdStep, am.MutationTypeAdd) - d.Mach.Add1(ss.UserFwdStep, nil) - } else { - d.Mach.Add1(ss.UserFwd, nil) - } - - return nil - }, - - // state jumps - "alt+h": d.jumpBack, - "alt+l": d.jumpFwd, - "alt+Left": d.jumpBack, - "alt+Right": d.jumpFwd, - - // page up / down - "alt+j": func(ev *tcell.EventKey) *tcell.EventKey { - return tcell.NewEventKey(tcell.KeyPgDn, ' ', tcell.ModNone) - }, - "alt+k": func(ev *tcell.EventKey) *tcell.EventKey { - return tcell.NewEventKey(tcell.KeyPgUp, ' ', tcell.ModNone) - }, - - // expand / collapse the tree root - "alt+e": func(ev *tcell.EventKey) *tcell.EventKey { - expanded := false - children := d.tree.GetRoot().GetChildren() - - for _, child := range children { - if child.IsExpanded() { - expanded = true - break - } - child.Collapse() - } - - for _, child := range children { - if expanded { - child.Collapse() - child.GetReference().(*nodeRef).expanded = false - } else { - child.Expand() - child.GetReference().(*nodeRef).expanded = true - } - } - - return nil - }, - - // tail mode - "alt+v": func(ev *tcell.EventKey) *tcell.EventKey { - d.Mach.Add1(ss.TailMode, nil) - - return nil - }, - - // matrix view - "alt+m": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Is1(ss.TreeLogView) { - d.Mach.Add1(ss.MatrixView, nil) - } else if d.Mach.Is1(ss.MatrixView) { - d.Mach.Add1(ss.TreeMatrixView, nil) - } else { - d.Mach.Add1(ss.TreeLogView, nil) - } - - return nil - }, - - // scroll to the first tx - "home": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.ClientSelected) { - return nil - } - d.C.CursorTx = 0 - d.RedrawFull(true) - - return nil - }, - - // scroll to the last tx - "end": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.ClientSelected) { - return nil - } - d.C.CursorTx = len(d.C.MsgTxs) - d.RedrawFull(true) - - return nil - }, - - // quit the app - "ctrl+q": func(ev *tcell.EventKey) *tcell.EventKey { - d.Mach.Remove1(ss.Start, nil) - - return nil - }, - - // help modal - "?": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.HelpDialog) { - d.Mach.Add1(ss.HelpDialog, nil) - } else { - d.Mach.Remove(ss.GroupDialog, nil) - } - - return ev - }, - - // export modal - "alt+s": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.ExportDialog) { - d.Mach.Add1(ss.ExportDialog, nil) - } else { - d.Mach.Remove(ss.GroupDialog, nil) - } - - return ev - }, - - // exit modals - "esc": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Any1(ss.GroupDialog...) { - d.Mach.Remove(ss.GroupDialog, nil) - return nil - } - - return ev - }, - - // remove client (sidebar) - "backspace": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Not1(ss.SidebarFocused) { - return ev - } - - sel := d.sidebar.GetCurrentItem() - if sel == nil || d.Mach.Not1(ss.SidebarFocused) { - return nil - } - - cid := sel.GetReference().(string) - d.Mach.Add1(ss.RemoveClient, am.A{"Client.id": cid}) - - return nil - }, - - // scroll to LogScrolled - // scroll sidebar - "down": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Is1(ss.SidebarFocused) { - // TODO state? - go func() { - d.updateSidebar(true) - d.draw() - }() - } else if d.Mach.Is1(ss.LogFocused) { - d.Mach.Add1(ss.LogUserScrolled, nil) - } - - return ev - }, - "up": func(ev *tcell.EventKey) *tcell.EventKey { - if d.Mach.Is1(ss.SidebarFocused) { - // TODO state? - go func() { - d.updateSidebar(true) - d.draw() - }() - } else if d.Mach.Is1(ss.LogFocused) { - d.Mach.Add1(ss.LogUserScrolled, nil) - } - - return ev - }, - } -} - -// TODO optimize usage places -func (d *Debugger) throttleKey(ev *tcell.EventKey, ms int) bool { - // throttle - sameKey := d.lastKey == ev.Key() - elapsed := time.Since(d.lastKeyTime) - if sameKey && elapsed < time.Duration(ms)*time.Millisecond { - return true - } - - d.lastKey = ev.Key() - d.lastKeyTime = time.Now() - - return false -} - func (d *Debugger) jumpBack(ev *tcell.EventKey) *tcell.EventKey { if d.throttleKey(ev, arrowThrottleMs) { return nil @@ -1071,9 +329,10 @@ func (d *Debugger) jumpFwd(ev *tcell.EventKey) *tcell.EventKey { } // TODO verify host token, to distinguish 2 hosts with the same ID -func (d *Debugger) parseClientMsg(c *Client, idx int) { +func (d *Debugger) parseMsg(c *Client, idx int) { msgTxParsed := MsgTxParsed{} msgTx := c.MsgTxs[idx] + var entries []*am.LogEntry // added / removed if len(c.MsgTxs) > 1 && idx > 0 { @@ -1109,71 +368,47 @@ func (d *Debugger) parseClientMsg(c *Client, idx int) { // TODO take from msgs c.lastActive = time.Now() - // pre-log entries - logStr := "" - for _, entry := range msgTx.PreLogEntries { - logStr += fmtLogEntry(entry, c.MsgStruct.States) - } - - // tx log entries - if len(msgTx.LogEntries) > 0 { - f := "" - for _, entry := range msgTx.LogEntries { - f += fmtLogEntry(entry, c.MsgStruct.States) - } - // create a highlight region - logStr += `["` + msgTx.ID + `"]` + f + `[""]` - } - - // TODO highlight the selected state + d.parseMsgLog(c, msgTx, entries) +} - // store the parsed log - c.logMsgs = append(c.logMsgs, logStr) +// isTxSkipped checks if the tx at the given index is skipped by filters +// idx is 0-based +func (d *Debugger) isTxSkipped(c *Client, idx int) bool { + return slices.Index(c.msgTxsFiltered, idx) == -1 } -func (d *Debugger) appendLog(index int) error { - c := d.C - logStr := c.logMsgs[index] - if logStr == "" { - return nil - } - if index > 0 { - msgTime := c.MsgTxs[index].Time - prevMsgTime := c.MsgTxs[index-1].Time - if prevMsgTime.Second() != msgTime.Second() { - // grouping labels (per second) - // TODO [::-] - logStr = `[grey]` + msgTime.Format(timeFormat) + "[white]\n" + logStr +// filterTxCursor fixes the current cursor according to filters +// by skipping filtered out txs. If none found, returns the current cursor. +func (d *Debugger) filterTxCursor(c *Client, newCursor int, fwd bool) int { + if !d.isFiltered() { + return newCursor + } + + // skip filtered out txs + for { + if newCursor < 1 { + return 0 + } else if newCursor > len(c.MsgTxs) { + // not found + if !d.isTxSkipped(c, c.CursorTx-1) { + return c.CursorTx + } else { + return 0 + } } - } - _, err := d.log.Write([]byte(logStr)) - if err != nil { - return err - } - - // prevent scrolling when not in tail mode - if d.Mach.Not(am.S{ss.TailMode, ss.LogUserScrolled}) { - d.log.ScrollToHighlight() - } else if d.Mach.Not1(ss.TailMode) { - d.log.ScrollToEnd() - } - return nil -} - -func (d *Debugger) draw() { - if d.repaintScheduled { - return + if d.isTxSkipped(c, newCursor-1) { + if fwd { + newCursor++ + } else { + newCursor-- + } + } else { + break + } } - d.repaintScheduled = true - go func() { - // debounce every 16msec - time.Sleep(16 * time.Millisecond) - // TODO re-draw only changed components - d.app.QueueUpdateDraw(func() {}) - d.repaintScheduled = false - }() + return newCursor } // TODO highlight selected state names, extract common logic @@ -1216,7 +451,7 @@ func (d *Debugger) updateTxBars() { } nextTx := d.NextTx() - if nextTx != nil { + if nextTx != nil && c != nil { title := "Next " left, right := d.getTxInfo(c.CursorTx+1, nextTx, &c.msgTxsParsed[c.CursorTx], title) @@ -1225,76 +460,6 @@ func (d *Debugger) updateTxBars() { } } -func (d *Debugger) updateLog(immediate bool) { - if immediate { - d.doUpdateLog() - return - } - - if d.updateLogScheduled { - return - } - d.updateLogScheduled = true - - go func() { - time.Sleep(logUpdateDebounce) - d.doUpdateLog() - d.draw() - d.updateLogScheduled = false - }() -} - -func (d *Debugger) doUpdateLog() { - // check for a ready client - c := d.C - if c == nil { - return - } - - if c.MsgStruct != nil { - lvl := c.MsgStruct.LogLevel - d.log.SetTitle(" Log:" + lvl.String() + " ") - } - - // highlight the next tx if scrolling by steps - bySteps := d.Mach.Is1(ss.TimelineStepsFocused) - tx := d.CurrentTx() - if bySteps { - tx = d.NextTx() - } - if tx == nil { - d.log.Highlight("") - if bySteps { - d.log.ScrollToEnd() - } else { - d.log.ScrollToBeginning() - } - - return - } - - // highlight this tx or the prev if empty - if len(tx.LogEntries) == 0 && d.PrevTx() != nil { - last := d.PrevTx() - for i := d.C.CursorTx - 1; i > 0; i-- { - if len(last.LogEntries) > 0 { - tx = last - break - } - last = d.C.MsgTxs[i-1] - } - - d.log.Highlight(last.ID) - } else { - d.log.Highlight(tx.ID) - } - - // scroll, but only if not manually scrolled - if d.Mach.Not1(ss.LogUserScrolled) { - d.log.ScrollToHighlight() - } -} - func (d *Debugger) updateTimelines() { // check for a ready client c := d.C @@ -1333,8 +498,20 @@ func (d *Debugger) updateTimelines() { d.timelineTxs.SetMax(max(txCount, 1)) // progress <= max d.timelineTxs.SetProgress(c.CursorTx) - d.timelineTxs.SetTitle(d.P.Sprintf( - " Transition %d / %d ", c.CursorTx, txCount)) + + // title + var title string + if d.isFiltered() { + pos := slices.Index(c.msgTxsFiltered, c.CursorTx-1) + 1 + if c.CursorTx == 0 { + pos = 0 + } + title = d.P.Sprintf(" Transition %d / %d [%s]| %d / %d[-] ", + pos, len(c.msgTxsFiltered), colorHighlight2, c.CursorTx, txCount) + } else { + title = d.P.Sprintf(" Transition %d / %d ", c.CursorTx, txCount) + } + d.timelineTxs.SetTitle(title) // progressbar cant be max==0 d.timelineSteps.SetMax(max(stepsCount, 1)) @@ -1344,15 +521,6 @@ func (d *Debugger) updateTimelines() { " Next mutation step %d / %d ", c.CursorStep, stepsCount)) } -func (d *Debugger) updateKeyBars() { - // TODO light mode - keys := "[yellow]space: play/pause | left/right: back/fwd | " + - "alt+left/right/h/l: fast/state jump | home/end: start/end | " + - "alt+e/enter: expand/collapse | tab: focus | alt+v: tail mode | " + - "alt+m: matrix view | alt+s: export | ?: help" - d.keystrokesBar.SetText(keys) -} - func (d *Debugger) updateSidebar(immediate bool) { if immediate { d.doUpdateSidebar() @@ -1549,94 +717,54 @@ func (d *Debugger) ImportData(filename string) { Exportable: *data, } for i := range data.MsgTxs { - d.parseClientMsg(d.Clients[id], i) + d.parseMsg(d.Clients[id], i) } } } -///// ///// ///// ///// ///// -///// UTILS ///// -///// ///// ///// ///// ///// - -func fmtLogEntry(entry string, machStruct am.Struct) string { - if entry == "" { - return entry - } - - prefixEnd := "[][white]" - - // color the first brackets per each line - ret := "" - for _, s := range strings.Split(entry, "\n") { - s = strings.Replace(strings.Replace(s, - "]", prefixEnd, 1), - "[", "[yellow][", 1) - ret += s + "\n" - } - - // highlight state names (in the msg body) - // TODO removes the last letter - idx := strings.Index(ret, prefixEnd) - prefix := ret[0 : idx+len(prefixEnd)] - - // style state names, start from the longest ones - // TODO compile as regexp and limit to words only - toReplace := maps.Keys(machStruct) - slices.Sort(toReplace) - slices.Reverse(toReplace) - for _, name := range toReplace { - body := ret[idx+len(prefixEnd):] - body = strings.ReplaceAll(body, " "+name, " [::b]"+name+"[::-]") - body = strings.ReplaceAll(body, "+"+name, "+[::b]"+name+"[::-]") - body = strings.ReplaceAll(body, "-"+name, "-[::b]"+name+"[::-]") - body = strings.ReplaceAll(body, ","+name, ",[::b]"+name+"[::-]") - ret = prefix + strings.ReplaceAll(body, "("+name, "([::b]"+name+"[::-]") - } - - return strings.Trim(ret, " \n ") + "\n" -} - func (d *Debugger) getTxInfo(txIndex int, tx *telemetry.DbgMsgTx, parsed *MsgTxParsed, title string, ) (string, string) { - // left side left := title - if tx != nil { + right := " " - prevT := uint64(0) - if txIndex > 1 { - prevT = d.C.MsgTxs[txIndex-2].TimeSum() - } + if tx == nil { + return left, right + } - left += d.P.Sprintf(" | tx: %v", txIndex) - left += d.P.Sprintf(" | T: +%v", tx.TimeSum()-prevT) - left += " |" + // left side + prevT := uint64(0) + if txIndex > 1 { + prevT = d.C.MsgTxs[txIndex-2].TimeSum() + } - multi := "" - if len(tx.CalledStates) == 1 && - d.C.MsgStruct.States[tx.CalledStates[0]].Multi { - multi += " multi" - } + left += d.P.Sprintf(" | tx: %v", txIndex) + left += d.P.Sprintf(" | T: +%v", tx.TimeSum()-prevT) + left += " |" - if !tx.Accepted { - left += "[grey]" - } - left += fmt.Sprintf(" %s%s: [::b]%s[::-]", tx.Type, multi, - strings.Join(tx.CalledStates, ", ")) + multi := "" + if len(tx.CalledStates) == 1 && + d.C.MsgStruct.States[tx.CalledStates[0]].Multi { + multi += " multi" + } - if !tx.Accepted { - left += "[-]" - } + if !tx.Accepted { + left += "[grey]" + } + left += fmt.Sprintf(" %s%s: [::b]%s[::-]", tx.Type, multi, + strings.Join(tx.CalledStates, ", ")) + + if !tx.Accepted { + left += "[-]" } // right side // TODO time to execute - right := " " if tx.IsAuto { right += "auto | " } if !tx.Accepted { - right += "rejected | " + right += "canceled | " } right += fmt.Sprintf("add: %d | rm: %d | touch: %d | %s", len(parsed.StatesAdded), len(parsed.StatesRemoved), @@ -1672,7 +800,6 @@ func (d *Debugger) doCleanOnConnect() bool { return false } -// TODO func (d *Debugger) updateMatrix() { d.matrix.Clear() d.matrix.SetTitle(" Matrix ") @@ -1800,32 +927,6 @@ func (d *Debugger) updateMatrix() { d.matrix.SetTitle(" Matrix:" + strconv.Itoa(sum) + " ") } -func matrixCellVal(strVal string) string { - switch len(strVal) { - case 1: - strVal = " " + strVal + " " - case 2: - strVal = " " + strVal - } - return strVal -} - -func matrixEmptyRow(d *Debugger, row, colsCount, highlightIndex int) { - // empty row - for ii := 0; ii < colsCount; ii++ { - d.matrix.SetCellSimple(row, ii, " ") - if ii == highlightIndex { - d.matrix.GetCell(row, ii).SetBackgroundColor(colorHighlight) - } - } -} - -func (d *Debugger) drawViews() { - d.updateViews(true) - d.updateFocusable() - d.draw() -} - func (d *Debugger) ConnectedClients() int { // if only 1 client connected, select it (if SelectConnected == true) var conns int @@ -1854,43 +955,77 @@ func (d *Debugger) getSidebarCurrClientIdx() int { return -1 } -func formatTxBarTitle(title string) string { - return "[::u]" + title + "[::-]" +// filterClientTxs filters client's txs according the selected filters. +// Called by filter states, not directly. +func (d *Debugger) filterClientTxs() { + if d.C == nil || !d.isFiltered() { + return + } + + auto := d.Mach.Is1(ss.FilterAutoTx) + empty := d.Mach.Is1(ss.FilterEmptyTx) + canceled := d.Mach.Is1(ss.FilterCanceledTx) + + d.C.msgTxsFiltered = nil + for i := range d.C.MsgTxs { + if d.filterTx(i, auto, empty, canceled) { + d.C.msgTxsFiltered = append(d.C.msgTxsFiltered, i) + } + } +} + +// isFiltered checks if any filter is active. +func (d *Debugger) isFiltered() bool { + return d.Mach.Any1(ss.FilterCanceledTx, ss.FilterAutoTx, ss.FilterEmptyTx) } -var humanSortRE = regexp.MustCompile(`[0-9]+`) +// filterTx returns true when a TX passed the passed filters. +func (d *Debugger) filterTx(idx int, auto, empty, canceled bool) bool { + tx := d.C.MsgTxs[idx] + + if auto && tx.IsAuto { + return false + } + if canceled && !tx.Accepted { + return false + } -func humanSort(strs []string) { - sort.Slice(strs, func(i, j int) bool { - // skip overlapping parts - maxChars := min(len(strs[i]), len(strs[j])) - firstDiff := 0 - for k := 0; k < maxChars; k++ { - if strs[i][k] != strs[j][k] { - break + // empty + parsed := d.C.msgTxsParsed[idx] + txEmpty := true + if empty && len(parsed.StatesAdded) == 0 && + len(parsed.StatesRemoved) == 0 { + for _, step := range tx.Steps { + // running a handler is not empty + if step.Type == am.StepHandler { + txEmpty = false } - firstDiff++ } - // if no numbers - compare as strings - posI := humanSortRE.FindStringIndex(strs[i][firstDiff:]) - posJ := humanSortRE.FindStringIndex(strs[j][firstDiff:]) - if len(posI) <= 0 || len(posJ) <= 0 || posI[0] != posJ[0] { - return strs[i] < strs[j] + if txEmpty { + return false } + } + + return true +} - // if contains numbers - sort by numbers - numsI := humanSortRE.FindAllString(strs[i][firstDiff:], -1) - numsJ := humanSortRE.FindAllString(strs[j][firstDiff:], -1) - numI, _ := strconv.Atoi(numsI[0]) - numJ, _ := strconv.Atoi(numsJ[0]) +func (d *Debugger) scrollToTime(t time.Time) bool { + for i, tx := range d.C.MsgTxs { + if !tx.Time.After(t) { + continue + } - if numI != numJ { - // If the numbers are different, order by the numbers - return numI < numJ + // pick the closer one + if i > 0 && tx.Time.Sub(t) > + t.Sub(*d.C.MsgTxs[i-1].Time) { + d.C.CursorTx = d.filterTxCursor(d.C, i, true) + } else { + d.C.CursorTx = d.filterTxCursor(d.C, i+1, true) } - // If the numbers are the same, order lexicographically - return strs[i] < strs[j] - }) + return true + } + + return false } diff --git a/tools/debugger/handlers.go b/tools/debugger/handlers.go index d2ad443..74c83cb 100644 --- a/tools/debugger/handlers.go +++ b/tools/debugger/handlers.go @@ -143,18 +143,6 @@ func (d *Debugger) StateNameSelectedEnd(_ *am.Event) { d.updateKeyBars() } -func (d *Debugger) LiveViewEnter(_ *am.Event) bool { - return d.C != nil -} - -func (d *Debugger) LiveViewState(_ *am.Event) { - d.C.CursorTx = len(d.C.MsgTxs) - d.C.CursorStep = 0 - d.updateTxBars() - d.updateTimelines() - d.draw() -} - func (d *Debugger) PlayingState(_ *am.Event) { if d.playTimer == nil { d.playTimer = time.NewTicker(playInterval) @@ -199,13 +187,13 @@ func (d *Debugger) PausedState(_ *am.Event) { } func (d *Debugger) TailModeState(_ *am.Event) { - d.C.CursorTx = len(d.C.MsgTxs) + d.C.CursorTx = d.filterTxCursor(d.C, len(d.C.MsgTxs), true) // needed bc tail mode if carried over via SelectingClient d.updateTxBars() d.draw() } -///// FWD / BACK +// ///// FWD / BACK func (d *Debugger) UserFwdState(e *am.Event) { d.Mach.Remove1(ss.UserFwd, nil) @@ -223,9 +211,10 @@ func (d *Debugger) FwdState(e *am.Event) { amount, _ := e.Args["amount"].(int) amount = max(amount, 1) - d.C.CursorTx += amount - d.C.CursorStep = 0 - if d.Mach.Is1(ss.Playing) && d.C.CursorTx == len(d.C.MsgTxs) { + c := d.C + c.CursorTx = d.filterTxCursor(c, c.CursorTx+amount, true) + c.CursorStep = 0 + if d.Mach.Is1(ss.Playing) && c.CursorTx == len(c.MsgTxs) { d.Mach.Remove1(ss.Playing, nil) } @@ -248,13 +237,14 @@ func (d *Debugger) BackState(e *am.Event) { amount, _ := e.Args["amount"].(int) amount = max(amount, 1) - d.C.CursorTx -= amount - d.C.CursorStep = 0 + c := d.C + c.CursorTx = d.filterTxCursor(c, c.CursorTx-amount, false) + c.CursorStep = 0 d.RedrawFull(false) } -///// STEP BACK / FWD +// ///// STEP BACK / FWD func (d *Debugger) UserFwdStepState(e *am.Event) { d.Mach.Remove1(ss.UserFwdStep, nil) @@ -295,10 +285,11 @@ func (d *Debugger) BackStepState(_ *am.Event) { // wrap if there's a prev tx if d.C.CursorStep <= 0 { - d.C.CursorTx-- + d.C.CursorTx = d.filterTxCursor(d.C, d.C.CursorTx-1, false) d.updateLog(false) nextTx := d.NextTx() d.C.CursorStep = len(nextTx.Steps) + } else { d.C.CursorStep-- } @@ -314,7 +305,17 @@ func (d *Debugger) TimelineStepsFocusedEnd(_ *am.Event) { d.RedrawFull(false) } -///// CONNECTION +func (d *Debugger) FiltersFocusedState(_ *am.Event) { + d.filtersBar.SetBackgroundColor(cview.Styles.MoreContrastBackgroundColor) + d.updateFiltersBar() +} + +func (d *Debugger) FiltersFocusedEnd(_ *am.Event) { + d.filtersBar.SetBackgroundColor(cview.Styles.PrimitiveBackgroundColor) + d.updateFiltersBar() +} + +// ///// CONNECTION func (d *Debugger) ConnectEventEnter(e *am.Event) bool { _, ok1 := e.Args["msg_struct"].(*telemetry.DbgMsgStruct) @@ -458,7 +459,7 @@ func (d *Debugger) DisconnectEventState(e *am.Event) { d.draw() } -///// CLIENTS +// ///// CLIENTS func (d *Debugger) ClientMsgEnter(e *am.Event) bool { _, ok := e.Args["msgs_tx"].([]*telemetry.DbgMsgTx) @@ -473,8 +474,7 @@ func (d *Debugger) ClientMsgEnter(e *am.Event) bool { func (d *Debugger) ClientMsgState(e *am.Event) { msgs := e.Args["msgs_tx"].([]*telemetry.DbgMsgTx) - // add a timestamp - updateLive := false + updateTailMode := false updateFirstTx := false for _, msg := range msgs { @@ -493,23 +493,24 @@ func (d *Debugger) ClientMsgState(e *am.Event) { index := len(c.MsgTxs) c.MsgTxs = append(c.MsgTxs, msg) // parse the msg - d.parseClientMsg(c, len(c.MsgTxs)-1) + d.parseMsg(c, len(c.MsgTxs)-1) // update the UI // TODO debounce UI updates if c == d.C { - err := d.appendLog(index) + err := d.appendLogEntry(index) if err != nil { d.Mach.Log("Error: log append %s\n", err) // d.Mach.AddErr(err) return } if d.Mach.Is1(ss.TailMode) { - updateLive = true - c.CursorTx = len(c.MsgTxs) + updateTailMode = true + c.CursorTx = d.filterTxCursor(c, len(c.MsgTxs), true) c.CursorStep = 0 } + // update Tx info on the first Tx if len(c.MsgTxs) == 1 { updateFirstTx = true @@ -519,13 +520,13 @@ func (d *Debugger) ClientMsgState(e *am.Event) { d.updateSidebar(false) // UI updates for the selected client - if updateLive { + if updateTailMode { // force the latest tx d.updateViews(false) } // update Tx info on the first Tx - if updateLive || updateFirstTx { + if updateTailMode || updateFirstTx { d.updateTxBars() } @@ -616,29 +617,19 @@ func (d *Debugger) SelectingClientState(e *am.Event) { return // expired } + // start with prepping the data + d.filterClientTxs() + // scroll to the same place as the prev client + // TODO extract match := false if !wasTailMode { - for i, tx := range d.C.MsgTxs { - if tx.Time.After(d.prevClientTxTime) { - - // pick the closer one - if i > 0 && tx.Time.Sub(d.prevClientTxTime) > - d.prevClientTxTime.Sub(*d.C.MsgTxs[i-1].Time) { - d.C.CursorTx = i - } else { - d.C.CursorTx = i + 1 - } - match = true - - break - } - } + match = d.scrollToTime(d.prevClientTxTime) } // or scroll to the last one if !match { - d.C.CursorTx = len(d.C.MsgTxs) + d.C.CursorTx = d.filterTxCursor(d.C, len(d.C.MsgTxs), false) } d.C.CursorStep = 0 d.updateTimelines() @@ -661,12 +652,9 @@ func (d *Debugger) SelectingClientState(e *am.Event) { } // rebuild the whole log, keep an eye on the ctx - // TODO cache in single []byte - for i := 0; i < logRebuildEnd && ctx.Err() == nil; i++ { - err := d.appendLog(i) - if err != nil { - d.Mach.Log("Error: log rebuild %s\n", err) - } + err := d.rebuildLog(ctx, logRebuildEnd) + if err != nil { + d.Mach.AddErr(err) } if ctx.Err() != nil { return // expired @@ -697,7 +685,7 @@ func (d *Debugger) ClientSelectedState(e *am.Event) { // catch up with new log msgs for i := d.logRebuildEnd; i < len(d.C.logMsgs); i++ { - err := d.appendLog(i) + err := d.appendLogEntry(i) if err != nil { d.Mach.Log("Error: log rebuild %s\n", err) } @@ -795,6 +783,78 @@ func (d *Debugger) ScrollToTxState(e *am.Event) { d.Mach.Remove1(ss.ScrollToTx, nil) cursor := e.Args["Client.cursorTx"].(int) - d.C.CursorTx = cursor + d.C.CursorTx = d.filterTxCursor(d.C, cursor, true) d.RedrawFull(false) } + +func (d *Debugger) ToggleFilterState(_ *am.Event) { + filterTxs := false + + switch d.focusedFilter { + // TODO filter enum + case "skip-canceled": + if d.Mach.Is1(ss.FilterCanceledTx) { + d.Mach.Remove1(ss.FilterCanceledTx, nil) + } else { + d.Mach.Add1(ss.FilterCanceledTx, nil) + } + filterTxs = true + case "skip-auto": + if d.Mach.Is1(ss.FilterAutoTx) { + d.Mach.Remove1(ss.FilterAutoTx, nil) + } else { + d.Mach.Add1(ss.FilterAutoTx, nil) + } + filterTxs = true + case "skip-empty": + if d.Mach.Is1(ss.FilterEmptyTx) { + d.Mach.Remove1(ss.FilterEmptyTx, nil) + } else { + d.Mach.Add1(ss.FilterEmptyTx, nil) + } + filterTxs = true + case "log-0": + d.LogLevel = am.LogNothing + case "log-1": + d.LogLevel = am.LogChanges + case "log-2": + d.LogLevel = am.LogOps + case "log-3": + d.LogLevel = am.LogDecisions + case "log-4": + d.LogLevel = am.LogEverything + } + + stateCtx := d.Mach.NewStateCtx(ss.ToggleFilter) + + go func() { + // TODO split the state + <-d.Mach.WhenQueueEnds(stateCtx) + if stateCtx.Err() != nil { + return // expired + } + + if filterTxs { + d.filterClientTxs() + } + + // rebuild the whole log to reflect the UI changes + err := d.rebuildLog(stateCtx, len(d.C.MsgTxs)-1) + if err != nil { + d.Mach.AddErr(err) + } + d.updateLog(false) + + if stateCtx.Err() != nil { + return // expired + } + + d.C.CursorTx = d.filterTxCursor(d.C, d.C.CursorTx, false) + // queue this removal after filter states, so we can depend on WhenNot + d.Mach.Remove1(ss.ToggleFilter, nil) + + d.updateFiltersBar() + d.updateTimelines() + d.draw() + }() +} diff --git a/tools/debugger/keyboard.go b/tools/debugger/keyboard.go new file mode 100644 index 0000000..1b2a10a --- /dev/null +++ b/tools/debugger/keyboard.go @@ -0,0 +1,667 @@ +package debugger + +import ( + "fmt" + "log" + "regexp" + "strings" + "time" + + "code.rocketnine.space/tslocum/cbind" + + "github.com/gdamore/tcell/v2" + "github.com/pancsta/cview" + + am "github.com/pancsta/asyncmachine-go/pkg/machine" + ss "github.com/pancsta/asyncmachine-go/tools/debugger/states" +) + +// regexp removing [foo] +var re = regexp.MustCompile(`\[(.*?)\]`) + +func normalizeText(text string) string { + return strings.ToLower(re.ReplaceAllString(text, "")) +} + +func (d *Debugger) bindKeyboard() { + // focus manager + d.focusManager = cview.NewFocusManager(d.app.SetFocus) + d.focusManager.SetWrapAround(true) + inputHandler := cbind.NewConfiguration() + d.app.SetAfterFocusFunc(d.afterFocus()) + + focusChange := func(f func()) func(ev *tcell.EventKey) *tcell.EventKey { + return func(ev *tcell.EventKey) *tcell.EventKey { + // keep Tab inside dialogs + if d.Mach.Any1(ss.GroupDialog...) { + return ev + } + + // fwd to FocusManager + f() + return nil + } + } + + // tab + for _, key := range cview.Keys.MovePreviousField { + err := inputHandler.Set(key, focusChange(d.focusManager.FocusPrevious)) + if err != nil { + log.Printf("Error: binding keys %s", err) + } + } + + // shift+tab + for _, key := range cview.Keys.MoveNextField { + err := inputHandler.Set(key, focusChange(d.focusManager.FocusNext)) + if err != nil { + log.Printf("Error: binding keys %s", err) + } + } + + // custom keys + for key, fn := range d.getKeystrokes() { + err := inputHandler.Set(key, fn) + if err != nil { + log.Printf("Error: binding keys %s", err) + } + } + + d.searchTreeSidebar(inputHandler) + d.app.SetInputCapture(inputHandler.Capture) +} + +// afterFocus forwards focus events to machine states +func (d *Debugger) afterFocus() func(p cview.Primitive) { + return func(p cview.Primitive) { + switch p { + + case d.tree: + fallthrough + case d.tree.Box: + d.Mach.Add1(ss.TreeFocused, nil) + + case d.log: + fallthrough + case d.log.Box: + d.Mach.Add1(ss.LogFocused, nil) + + case d.timelineTxs: + fallthrough + case d.timelineTxs.Box: + d.Mach.Add1(ss.TimelineTxsFocused, nil) + + case d.timelineSteps: + fallthrough + case d.timelineSteps.Box: + d.Mach.Add1(ss.TimelineStepsFocused, nil) + + case d.filtersBar: + fallthrough + case d.filtersBar.Box: + d.Mach.Add1(ss.FiltersFocused, nil) + + case d.sidebar: + fallthrough + case d.sidebar.Box: + d.Mach.Add1(ss.SidebarFocused, nil) + + case d.matrix: + fallthrough + case d.matrix.Box: + d.Mach.Add1(ss.MatrixFocused, nil) + + // DIALOGS + + case d.helpDialog: + fallthrough + case d.helpDialog.Box: + fallthrough + case d.exportDialog: + fallthrough + case d.exportDialog.Box: + d.Mach.Add1(ss.DialogFocused, nil) + } + + // update the log highlight on focus change + if d.Mach.Is1(ss.TreeLogView) { + d.updateLog(true) + } + + d.updateSidebar(true) + } +} + +// searchTreeSidebar searches for a-z, -, _ in the tree and sidebar, with a +// searchAsTypeWindow buffer. +func (d *Debugger) searchTreeSidebar(inputHandler *cbind.Configuration) { + var ( + bufferStart time.Time + buffer string + keys = []string{"-", "_"} + ) + + for i := 0; i < 26; i++ { + keys = append(keys, + fmt.Sprintf("%c", 'a'+i)) + } + + for _, key := range keys { + key := key + err := inputHandler.Set(key, func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not(am.S{ss.SidebarFocused, ss.TreeFocused}) { + return ev + } + + // buffer + if bufferStart.Add(searchAsTypeWindow).After(time.Now()) { + buffer += key + } else { + bufferStart = time.Now() + buffer = key + } + + // find the first client starting with the key + + // sidebar + if d.Mach.Is1(ss.SidebarFocused) { + for i, item := range d.sidebar.GetItems() { + text := normalizeText(item.GetMainText()) + if strings.HasPrefix(text, buffer) { + d.sidebar.SetCurrentItem(i) + d.updateSidebar(true) + + d.draw() + break + } + } + } else if d.Mach.Is1(ss.TreeFocused) { + + // tree + found := false + d.treeRoot.WalkUnsafe( + func(node, parent *cview.TreeNode, depth int) bool { + if found { + return false + } + + text := normalizeText(node.GetText()) + + if parent != nil && parent.IsExpanded() && + strings.HasPrefix(text, buffer) { + d.Mach.Remove1(ss.StateNameSelected, nil) + d.tree.SetCurrentNode(node) + d.updateTree() + d.draw() + found = true + + return false + } + + return true + }) + } + + return nil + }) + if err != nil { + log.Printf("Error: binding keys %s", err) + } + } +} + +func (d *Debugger) getKeystrokes() map[string]func( + ev *tcell.EventKey) *tcell.EventKey { + // TODO add state deps to the keystrokes structure + // TODO use tcell.KeyNames instead of strings as keys + // TODO rate limit + return map[string]func(ev *tcell.EventKey) *tcell.EventKey{ + // filters - toggle select + "enter": func(ev *tcell.EventKey) *tcell.EventKey { + // filters - toggle select + if d.Mach.Is1(ss.FiltersFocused) { + d.Mach.Add1(ss.ToggleFilter, nil) + + return nil + } + + return ev + }, + + // play/pause + "space": func(ev *tcell.EventKey) *tcell.EventKey { + // filters - toggle select + if d.Mach.Is1(ss.FiltersFocused) { + d.Mach.Add1(ss.ToggleFilter, nil) + + return nil + } + + if d.Mach.Not1(ss.ClientSelected) { + return nil + } + + if d.Mach.Is1(ss.Paused) { + d.Mach.Add1(ss.Playing, nil) + } else { + d.Mach.Add1(ss.Paused, nil) + } + + return nil + }, + + // prev tx + "left": func(ev *tcell.EventKey) *tcell.EventKey { + // filters - switch inner focus + // TODO extract + if d.Mach.Is1(ss.FiltersFocused) { + switch d.focusedFilter { + // TODO enum + case "skip-canceled": + d.focusedFilter = "log-4" + case "skip-auto": + d.focusedFilter = "skip-canceled" + case "skip-empty": + d.focusedFilter = "skip-auto" + case "log-0": + d.focusedFilter = "skip-empty" + case "log-1": + d.focusedFilter = "log-0" + case "log-2": + d.focusedFilter = "log-1" + case "log-3": + d.focusedFilter = "log-2" + case "log-4": + d.focusedFilter = "log-3" + } + d.updateFiltersBar() + + return nil + } + + if d.Mach.Not1(ss.ClientSelected) { + return nil + } + if d.throttleKey(ev, arrowThrottleMs) { + // TODO fast jump scroll while holding the key + return nil + } + + // scroll matrix + if d.Mach.Is1(ss.MatrixFocused) { + return ev + } + + // scroll timelines + if d.Mach.Is1(ss.TimelineStepsFocused) { + d.Mach.Add1(ss.UserBackStep, nil) + } else { + d.Mach.Add1(ss.UserBack, nil) + } + + return nil + }, + + // next tx + "right": func(ev *tcell.EventKey) *tcell.EventKey { + // filters - switch inner focus + // TODO extract + if d.Mach.Is1(ss.FiltersFocused) { + switch d.focusedFilter { + // TODO enum + case "skip-canceled": + d.focusedFilter = "skip-auto" + case "skip-auto": + d.focusedFilter = "skip-empty" + case "skip-empty": + d.focusedFilter = "log-0" + case "log-0": + d.focusedFilter = "log-1" + case "log-1": + d.focusedFilter = "log-2" + case "log-2": + d.focusedFilter = "log-3" + case "log-3": + d.focusedFilter = "log-4" + case "log-4": + d.focusedFilter = "skip-canceled" + } + d.updateFiltersBar() + + return nil + } + + if d.Mach.Not1(ss.ClientSelected) { + return nil + } + if d.throttleKey(ev, arrowThrottleMs) { + // TODO fast jump scroll while holding the key + return nil + } + + // scroll matrix + if d.Mach.Is1(ss.MatrixFocused) { + return ev + } + + // scroll timelines + if d.Mach.Is1(ss.TimelineStepsFocused) { + // TODO try mach.IsScheduled(ss.UserFwdStep, am.MutationTypeAdd) + d.Mach.Add1(ss.UserFwdStep, nil) + } else { + d.Mach.Add1(ss.UserFwd, nil) + } + + return nil + }, + + // state jumps + "alt+h": d.jumpBack, + "alt+l": d.jumpFwd, + "alt+Left": d.jumpBack, + "alt+Right": d.jumpFwd, + + // page up / down + "alt+j": func(ev *tcell.EventKey) *tcell.EventKey { + return tcell.NewEventKey(tcell.KeyPgDn, ' ', tcell.ModNone) + }, + "alt+k": func(ev *tcell.EventKey) *tcell.EventKey { + return tcell.NewEventKey(tcell.KeyPgUp, ' ', tcell.ModNone) + }, + + // expand / collapse the tree root + "alt+e": func(ev *tcell.EventKey) *tcell.EventKey { + expanded := false + children := d.tree.GetRoot().GetChildren() + + for _, child := range children { + if child.IsExpanded() { + expanded = true + break + } + child.Collapse() + } + + for _, child := range children { + if expanded { + child.Collapse() + child.GetReference().(*nodeRef).expanded = false + } else { + child.Expand() + child.GetReference().(*nodeRef).expanded = true + } + } + + return nil + }, + + // tail mode + "alt+v": func(ev *tcell.EventKey) *tcell.EventKey { + d.Mach.Add1(ss.TailMode, nil) + + return nil + }, + + // matrix view + "alt+m": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Is1(ss.TreeLogView) { + d.Mach.Add1(ss.MatrixView, nil) + } else if d.Mach.Is1(ss.MatrixView) { + d.Mach.Add1(ss.TreeMatrixView, nil) + } else { + d.Mach.Add1(ss.TreeLogView, nil) + } + + return nil + }, + + // scroll to the first tx + "home": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not1(ss.ClientSelected) { + return nil + } + d.C.CursorTx = d.filterTxCursor(d.C, 0, true) + d.Mach.Remove1(ss.TailMode, nil) + d.RedrawFull(true) + + return nil + }, + + // scroll to the last tx + "end": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not1(ss.ClientSelected) { + return nil + } + d.C.CursorTx = d.filterTxCursor(d.C, len(d.C.MsgTxs), false) + d.Mach.Remove1(ss.TailMode, nil) + d.RedrawFull(true) + + return nil + }, + + // quit the app + "ctrl+q": func(ev *tcell.EventKey) *tcell.EventKey { + d.Mach.Remove1(ss.Start, nil) + + return nil + }, + + // help modal + "?": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not1(ss.HelpDialog) { + d.Mach.Add1(ss.HelpDialog, nil) + } else { + d.Mach.Remove(ss.GroupDialog, nil) + } + + return ev + }, + + // focus filter bar + "alt+f": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not1(ss.FiltersFocused) { + d.focusManager.Focus(d.filtersBar) + } else { + d.focusManager.Focus(d.sidebar) + } + d.draw() + + return ev + }, + + // export modal + "alt+s": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not1(ss.ExportDialog) { + d.Mach.Add1(ss.ExportDialog, nil) + } else { + d.Mach.Remove(ss.GroupDialog, nil) + } + + return ev + }, + + // exit modals + "esc": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Any1(ss.GroupDialog...) { + d.Mach.Remove(ss.GroupDialog, nil) + return nil + } + + if d.Mach.Is1(ss.FiltersFocused) { + d.focusManager.Focus(d.sidebar) + } + + return ev + }, + + // remove client (sidebar) + "backspace": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Not1(ss.SidebarFocused) { + return ev + } + + sel := d.sidebar.GetCurrentItem() + if sel == nil || d.Mach.Not1(ss.SidebarFocused) { + return nil + } + + cid := sel.GetReference().(string) + d.Mach.Add1(ss.RemoveClient, am.A{"Client.id": cid}) + + return nil + }, + + // scroll to LogScrolled + // scroll sidebar + "down": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Is1(ss.SidebarFocused) { + // TODO state? + go func() { + d.updateSidebar(true) + d.draw() + }() + } else if d.Mach.Is1(ss.LogFocused) { + d.Mach.Add1(ss.LogUserScrolled, nil) + } + + return ev + }, + "up": func(ev *tcell.EventKey) *tcell.EventKey { + if d.Mach.Is1(ss.SidebarFocused) { + // TODO state? + go func() { + d.updateSidebar(true) + d.draw() + }() + } else if d.Mach.Is1(ss.LogFocused) { + d.Mach.Add1(ss.LogUserScrolled, nil) + } + + return ev + }, + } +} + +// TODO optimize usage places +func (d *Debugger) throttleKey(ev *tcell.EventKey, ms int) bool { + // throttle + sameKey := d.lastKey == ev.Key() + elapsed := time.Since(d.lastKeyTime) + if sameKey && elapsed < time.Duration(ms)*time.Millisecond { + return true + } + + d.lastKey = ev.Key() + d.lastKeyTime = time.Now() + + return false +} + +func (d *Debugger) updateFocusable() { + if d.focusManager == nil { + d.Mach.Log("Error: focus manager not initialized") + return + } + + var prims []cview.Primitive + switch d.Mach.Switch(ss.GroupViews...) { + + case ss.MatrixView: + d.focusable = []*cview.Box{ + d.sidebar.Box, d.matrix.Box, d.timelineTxs.Box, d.timelineSteps.Box, + d.filtersBar.Box, + } + prims = []cview.Primitive{ + d.sidebar, d.matrix, d.timelineTxs, + d.timelineSteps, d.filtersBar, + } + + case ss.TreeMatrixView: + d.focusable = []*cview.Box{ + d.sidebar.Box, d.tree.Box, d.matrix.Box, d.timelineTxs.Box, + d.timelineSteps.Box, d.filtersBar.Box, + } + prims = []cview.Primitive{ + d.sidebar, d.tree, d.matrix, d.timelineTxs, + d.timelineSteps, d.filtersBar, + } + + case ss.TreeLogView: + fallthrough + default: + d.focusable = []*cview.Box{ + d.sidebar.Box, d.tree.Box, d.log.Box, d.timelineTxs.Box, + d.timelineSteps.Box, d.filtersBar.Box, + } + prims = []cview.Primitive{ + d.sidebar, d.tree, d.log, d.timelineTxs, + d.timelineSteps, d.filtersBar, + } + } + + d.focusManager.Reset() + d.focusManager.Add(prims...) + + switch d.Mach.Switch(ss.GroupFocused...) { + case ss.SidebarFocused: + d.focusManager.Focus(d.sidebar) + case ss.TreeFocused: + if d.Mach.Any1(ss.TreeMatrixView, ss.TreeLogView) { + d.focusManager.Focus(d.tree) + } else { + d.focusManager.Focus(d.sidebar) + } + case ss.LogFocused: + if d.Mach.Is1(ss.TreeLogView) { + d.focusManager.Focus(d.tree) + } else { + d.focusManager.Focus(d.sidebar) + } + d.focusManager.Focus(d.log) + case ss.MatrixFocused: + if d.Mach.Any1(ss.TreeMatrixView, ss.MatrixView) { + d.focusManager.Focus(d.matrix) + } else { + d.focusManager.Focus(d.sidebar) + } + case ss.TimelineTxsFocused: + d.focusManager.Focus(d.timelineTxs) + case ss.TimelineStepsFocused: + d.focusManager.Focus(d.timelineSteps) + case ss.FiltersFocused: + d.focusManager.Focus(d.filtersBar) + default: + d.focusManager.Focus(d.sidebar) + } +} + +// updateKeyBars TODO light mode +func (d *Debugger) updateKeyBars() { + l := string(cview.Styles.DropDownSymbol) + r := string(cview.Styles.DropDownSelectedSymbol) + + keys := []struct{ key, desc string }{ + {"space", "play/pause"}, + {l + " /" + r + " ", "back/fwd"}, + {"alt+" + l + " /" + r + " /h/l", "fast/state jump"}, + {"alt+h/l", "fast jump"}, + {"home/end", "start/end"}, + {"alt+e/enter", "expand/collapse"}, + {"tab", "focus"}, + {"alt+v", "tail mode"}, + {"alt+m", "matrix view"}, + {"alt+s", "export"}, + {"?", "help"}, + } + + txt := "[" + colorActive.String() + "]" + for i, key := range keys { + txt += fmt.Sprintf("%s[%s]: %s", key.key, colorHighlight2, key.desc) + // suffix + if i != len(keys)-1 { + txt += fmt.Sprintf(" |[%s] ", colorActive) + } + } + + d.keyBar.SetText(txt) +} diff --git a/tools/debugger/log.go b/tools/debugger/log.go new file mode 100644 index 0000000..79826f4 --- /dev/null +++ b/tools/debugger/log.go @@ -0,0 +1,246 @@ +package debugger + +import ( + "context" + "slices" + "strings" + "time" + + "golang.org/x/exp/maps" + + am "github.com/pancsta/asyncmachine-go/pkg/machine" + "github.com/pancsta/asyncmachine-go/pkg/telemetry" + ss "github.com/pancsta/asyncmachine-go/tools/debugger/states" +) + +func (d *Debugger) updateLog(immediate bool) { + if immediate { + d.doUpdateLog() + return + } + + if d.updateLogScheduled { + return + } + d.updateLogScheduled = true + + go func() { + time.Sleep(logUpdateDebounce) + d.doUpdateLog() + d.draw() + d.updateLogScheduled = false + }() +} + +func (d *Debugger) doUpdateLog() { + // check for a ready client + c := d.C + if c == nil { + return + } + + if c.MsgStruct != nil { + d.log.SetTitle(" Log:" + d.LogLevel.String() + " ") + } + + // highlight the next tx if scrolling by steps + bySteps := d.Mach.Is1(ss.TimelineStepsFocused) + tx := d.CurrentTx() + if bySteps { + tx = d.NextTx() + } + if tx == nil { + d.log.Highlight("") + if bySteps { + d.log.ScrollToEnd() + } else { + d.log.ScrollToBeginning() + } + + return + } + + // highlight this tx or the prev if empty + if len(tx.LogEntries) == 0 && d.PrevTx() != nil { + last := d.PrevTx() + for i := d.C.CursorTx - 1; i > 0; i-- { + if len(last.LogEntries) > 0 { + tx = last + break + } + last = d.C.MsgTxs[i-1] + } + + d.log.Highlight(last.ID) + } else { + d.log.Highlight(tx.ID) + } + + // scroll, but only if not manually scrolled + if d.Mach.Not1(ss.LogUserScrolled) { + d.log.ScrollToHighlight() + } +} + +func (d *Debugger) parseMsgLog( + c *Client, msgTx *telemetry.DbgMsgTx, entries []*am.LogEntry, +) { + // pre-log entries + if len(msgTx.PreLogEntries) > 0 { + logStr := "" + for _, entry := range msgTx.PreLogEntries { + logStr += fmtLogEntry(entry, c.MsgStruct.States) + } + entries = append(entries, &am.LogEntry{ + Level: am.LogNothing, Text: logStr, + }) + } + + // tx log entries + for _, entry := range msgTx.LogEntries { + // TODO make [extern] as LogNothing + lvl := entry.Level + if strings.HasPrefix(entry.Text, "[extern]") { + lvl = am.LogNothing + } + t := fmtLogEntry(entry.Text, c.MsgStruct.States) + // create a highlight region + t = `["` + msgTx.ID + `"]` + t + `[""]` + entries = append(entries, &am.LogEntry{ + Level: lvl, Text: t, + }) + } + + // store the parsed log + c.logMsgs = append(c.logMsgs, entries) +} + +func (d *Debugger) rebuildLog(ctx context.Context, endIndex int) error { + d.log.Clear() + var buf []byte + + for i := 0; i < endIndex && ctx.Err() == nil; i++ { + // flush every N txs + if i%500 == 0 { + _, err := d.log.Write(buf) + if err != nil { + return err + } + buf = nil + } + + buf = append(buf, d.getLogEntryTxt(i)...) + } + + // TODO rebuild from endIndex to len(msgs) + + _, err := d.log.Write(buf) + if err != nil { + return err + } + + // prevent scrolling when not in tail mode + if d.Mach.Not(am.S{ss.TailMode, ss.LogUserScrolled}) { + d.log.ScrollToHighlight() + } else if d.Mach.Not1(ss.TailMode) { + d.log.ScrollToEnd() + } + + return nil +} + +func (d *Debugger) appendLogEntry(index int) error { + entry := d.getLogEntryTxt(index) + if entry == nil { + return nil + } + + _, err := d.log.Write(entry) + if err != nil { + return err + } + + // prevent scrolling when not in tail mode + if d.Mach.Not(am.S{ss.TailMode, ss.LogUserScrolled}) { + d.log.ScrollToHighlight() + } else if d.Mach.Not1(ss.TailMode) { + d.log.ScrollToEnd() + } + + return nil +} + +// getLogEntryTxt prepares a log entry for UI rendering +func (d *Debugger) getLogEntryTxt(index int) []byte { + c := d.C + ret := "" + + if index > 0 { + msgTime := c.MsgTxs[index].Time + prevMsgTime := c.MsgTxs[index-1].Time + if prevMsgTime.Second() != msgTime.Second() { + // grouping labels (per second) + // TODO duplicates for empty log entries + ret += `[grey]` + msgTime.Format(timeFormat) + "[-]\n" + } + } + + for _, le := range c.logMsgs[index] { + logStr := le.Text + logLvl := le.Level + if logStr == "" { + continue + } + + if d.isFiltered() && d.isTxSkipped(c, index) { + // skip filtered txs + continue + } else if logLvl > d.LogLevel { + // filter higher log level + continue + } + + ret += logStr + } + + return []byte(ret) +} + +func fmtLogEntry(entry string, machStruct am.Struct) string { + if entry == "" { + return entry + } + + prefixEnd := "[][white]" + + // color the first brackets per each line + ret := "" + for _, s := range strings.Split(entry, "\n") { + s = strings.Replace(strings.Replace(s, + "]", prefixEnd, 1), + "[", "[yellow][", 1) + ret += s + "\n" + // TODO replace further brackets with escape sequences + // after indexof yellow + } + + // highlight state names (in the msg body) + idx := strings.Index(ret, prefixEnd) + prefix := ret[0 : idx+len(prefixEnd)] + + // style state names, start from the longest ones + // TODO compile as regexp and limit to words only + toReplace := maps.Keys(machStruct) + slices.Sort(toReplace) + slices.Reverse(toReplace) + for _, name := range toReplace { + body := ret[idx+len(prefixEnd):] + body = strings.ReplaceAll(body, " "+name, " [::b]"+name+"[::-]") + body = strings.ReplaceAll(body, "+"+name, "+[::b]"+name+"[::-]") + body = strings.ReplaceAll(body, "-"+name, "-[::b]"+name+"[::-]") + body = strings.ReplaceAll(body, ","+name, ",[::b]"+name+"[::-]") + ret = prefix + strings.ReplaceAll(body, "("+name, "([::b]"+name+"[::-]") + } + + return strings.Trim(ret, " \n ") + "\n" +} diff --git a/tools/debugger/server/rpc.go b/tools/debugger/server/rpc.go new file mode 100644 index 0000000..6c92ac6 --- /dev/null +++ b/tools/debugger/server/rpc.go @@ -0,0 +1,125 @@ +// TODO move to debugger/server +package server + +import ( + "encoding/gob" + "log" + "net" + "net/rpc" + "sync" + "time" + + "github.com/soheilhy/cmux" + + am "github.com/pancsta/asyncmachine-go/pkg/machine" + "github.com/pancsta/asyncmachine-go/pkg/telemetry" + ss "github.com/pancsta/asyncmachine-go/tools/debugger/states" +) + +type RPCServer struct { + Mach *am.Machine + ConnID string +} + +func (r *RPCServer) DbgMsgStruct( + msgStruct *telemetry.DbgMsgStruct, _ *string, +) error { + r.Mach.Add1(ss.ConnectEvent, am.A{ + "msg_struct": msgStruct, + "conn_id": r.ConnID, + }) + + return nil +} + +var ( + queue []*telemetry.DbgMsgTx + queueMx sync.Mutex + scheduled bool +) + +func (r *RPCServer) DbgMsgTx(msgTx *telemetry.DbgMsgTx, _ *string) error { + queueMx.Lock() + defer queueMx.Unlock() + + if !scheduled { + scheduled = true + go func() { + time.Sleep(time.Second) + + queueMx.Lock() + defer queueMx.Unlock() + + r.Mach.Add1(ss.ClientMsg, am.A{"msgs_tx": queue}) + queue = nil + scheduled = false + }() + } + + now := time.Now() + msgTx.Time = &now + queue = append(queue, msgTx) + return nil +} + +func StartRCP(mach *am.Machine, url string) { + var err error + gob.Register(am.Relation(0)) + if url == "" { + url = telemetry.DbgHost + } + lis, err := net.Listen("tcp", url) + if err != nil { + log.Println(err) + mach.AddErr(err) + // TODO nice err msg + panic(err) + } + log.Println("RPC server started at", url) + // Create a new cmux instance. + m := cmux.New(lis) + + // Match connections in order: first rpc, then everything else. + rpcL := m.Match(cmux.Any()) + go rpcAccept(rpcL, mach) + + // Start cmux serving. + if err := m.Serve(); err != nil { + panic(err) + } +} + +func rpcAccept(l net.Listener, mach *am.Machine) { + // TODO test if this fixes cmux + defer func() { + if r := recover(); r != nil { + log.Println("recovered:", r) + } + }() + for { + conn, err := l.Accept() + if err != nil { + log.Println(err) + mach.AddErr(err) + continue + } + // handle the client + go func() { + server := rpc.NewServer() + connID := conn.RemoteAddr().String() + rcvr := &RPCServer{ + Mach: mach, + ConnID: connID, + } + err = server.Register(rcvr) + if err != nil { + log.Println(err) + rcvr.Mach.AddErr(err) + // TODO err msg + panic(err) + } + server.ServeConn(conn) + rcvr.Mach.Add1(ss.DisconnectEvent, am.A{"conn_id": connID}) + }() + } +} diff --git a/tools/debugger/states/ss_dbg.go b/tools/debugger/states/ss_dbg.go index 426cf1e..e2a0c49 100644 --- a/tools/debugger/states/ss_dbg.go +++ b/tools/debugger/states/ss_dbg.go @@ -8,7 +8,7 @@ type S = am.S // States map defines relations and properties of states. var States = am.Struct{ - // /// Input events + // ///// Input events ClientMsg: {Multi: true}, ConnectEvent: {Multi: true}, @@ -34,7 +34,7 @@ var States = am.Struct{ Remove: am.SMerge(GroupPlaying, S{LogUserScrolled}), }, - // /// External state (eg UI) + // ///// Read-only states (eg UI) // focus group @@ -45,6 +45,7 @@ var States = am.Struct{ TimelineStepsFocused: {Remove: GroupFocused}, MatrixFocused: {Remove: GroupFocused}, DialogFocused: {Remove: GroupFocused}, + FiltersFocused: {Remove: GroupFocused}, StateNameSelected: {Require: S{ClientSelected}}, HelpDialog: {Remove: GroupDialog}, @@ -52,10 +53,13 @@ var States = am.Struct{ Require: S{ClientSelected}, Remove: GroupDialog, }, - LogUserScrolled: {}, - Ready: {Require: S{Start}}, + LogUserScrolled: {}, + Ready: {Require: S{Start}}, + FilterAutoTx: {}, + FilterCanceledTx: {}, + FilterEmptyTx: {}, - // /// Actions + // ///// Actions Start: {}, TreeLogView: { @@ -77,33 +81,34 @@ var States = am.Struct{ Require: S{ClientSelected}, Remove: GroupPlaying, }, + ToggleFilter: {}, // tx / steps back / fwd Fwd: { Require: S{ClientSelected}, - Remove: S{Playing}, }, Back: { Require: S{ClientSelected}, - Remove: S{Playing}, }, FwdStep: { Require: S{ClientSelected}, - Remove: S{Playing}, }, BackStep: { Require: S{ClientSelected}, - Remove: S{Playing}, }, ScrollToTx: {Require: S{ClientSelected}}, // client selection - SelectingClient: {Remove: S{ClientSelected}}, + SelectingClient: { + Require: S{Start}, + Remove: S{ClientSelected}, + }, ClientSelected: { - Remove: S{SelectingClient, LogUserScrolled}, + Require: S{Start}, + Remove: S{SelectingClient, LogUserScrolled}, }, RemoveClient: {Require: S{ClientSelected}}, } @@ -114,6 +119,7 @@ var ( GroupFocused = S{ TreeFocused, LogFocused, TimelineTxsFocused, TimelineStepsFocused, SidebarFocused, MatrixFocused, DialogFocused, + FiltersFocused, } GroupPlaying = S{ Playing, Paused, TailMode, @@ -137,11 +143,13 @@ const ( TimelineStepsFocused = "TimelineStepsFocused" MatrixFocused = "MatrixFocused" DialogFocused = "DialogFocused" - ClientMsg = "ClientMsg" - StateNameSelected = "StateNameSelected" - Start = "Start" - Playing = "Playing" - Paused = "Paused" + FiltersFocused = "FiltersFocused" + + ClientMsg = "ClientMsg" + StateNameSelected = "StateNameSelected" + Start = "Start" + Playing = "Playing" + Paused = "Paused" // TailMode always shows the latest transition TailMode = "TailMode" // UserFwd is a user generated event @@ -173,7 +181,14 @@ const ( TreeMatrixView = "TreeMatrixView" LogUserScrolled = "LogUserScrolled" ScrollToTx = "ScrollToTx" - Ready = "Ready" + // Ready is an async result of start + Ready = "Ready" + FilterCanceledTx = "FilterCanceledTx" + FilterAutoTx = "FilterAutoTx" + // FilterEmptyTx is a filter for txes which didn't change state and didn't + // run any self handler either + FilterEmptyTx = "FilterEmptyTx" + ToggleFilter = "ToggleFilter" ) // Names of all the states (pkg enum). @@ -200,6 +215,7 @@ var Names = S{ SidebarFocused, TimelineTxsFocused, TimelineStepsFocused, + FiltersFocused, MatrixFocused, DialogFocused, StateNameSelected, @@ -217,6 +233,10 @@ var Names = S{ TailMode, Playing, Paused, + FilterAutoTx, + FilterCanceledTx, + FilterEmptyTx, + ToggleFilter, // tx / steps back / fwd Fwd, diff --git a/tools/debugger/tree.go b/tools/debugger/tree.go index 7a7cf79..3f2ed2a 100644 --- a/tools/debugger/tree.go +++ b/tools/debugger/tree.go @@ -16,6 +16,30 @@ import ( "github.com/pancsta/cview" ) +type nodeRef struct { + // TODO type + // type nodeType + // node is a state (reference or top level) + stateName string + // node is a state reference, not a top level state + // eg Bar in case of: Foo -> Remove -> Bar + // TODO name collision with nodeRef + isRef bool + // node is a relation (Remove, Add, Require, After) + isRel bool + // relation type (if isRel) + rel am.Relation + // top level state name (for both rels and refs) + parentState string + // node touched by a transition step + touched bool + // expanded by the user + expanded bool + // node is a state property (Auto, Multi) + isProp bool + propLabel string +} + func (d *Debugger) initMachineTree() *cview.TreeView { d.treeRoot = cview.NewTreeNode("") d.treeRoot.SetColor(tcell.ColorRed) @@ -83,11 +107,11 @@ func (d *Debugger) updateTree() { } else { tx := c.MsgTxs[c.CursorTx-1] msg = tx - queue = fmt.Sprintf("(Q:%d S:%d) ", - tx.Queue, len(c.MsgStruct.StatesIndex)) + queue = fmt.Sprintf(":%d (Q:%d) ", + len(c.MsgStruct.StatesIndex), tx.Queue) } - d.tree.SetTitle(" Structure " + queue) + d.tree.SetTitle(" Structure" + queue) var steps []*am.Step if c.CursorTx < len(c.MsgTxs) && c.CursorStep > 0 { @@ -173,8 +197,8 @@ func (d *Debugger) updateTreeDefaultsHighlights(msg telemetry.DbgMsg) int { } // TODO K delimiters - tick := strconv.FormatUint(msg.Clock(c.MsgStruct.StatesIndex, - stateName), 10) + tick := d.P.Sprintf("%v", msg.Clock(c.MsgStruct.StatesIndex, + stateName)) node.SetColor(color) node.SetText(stateName + " (" + tick + ")") } @@ -448,10 +472,10 @@ func (d *Debugger) updateTreeRelCols(colStartIdx int, steps []*am.Step) { // d.Mach.Log("close %s", colName) forcedCols = append(forcedCols, colName) } - //} else { + // } else { // debug - //d.Mach.Log("open %s", colName) - //} + // d.Mach.Log("open %s", colName) + // } } } } diff --git a/tools/debugger/ui.go b/tools/debugger/ui.go new file mode 100644 index 0000000..366c784 --- /dev/null +++ b/tools/debugger/ui.go @@ -0,0 +1,319 @@ +package debugger + +import ( + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/lithammer/dedent" + "github.com/pancsta/cview" + + am "github.com/pancsta/asyncmachine-go/pkg/machine" + ss "github.com/pancsta/asyncmachine-go/tools/debugger/states" +) + +func (d *Debugger) initUIComponents() { + d.helpDialog = d.initHelpDialog() + d.exportDialog = d.initExportDialog() + + // tree view + d.tree = d.initMachineTree() + d.tree.SetTitle(" Structure ") + d.tree.SetBorder(true) + + // sidebar + d.sidebar = cview.NewList() + d.sidebar.SetTitle(" Machines ") + d.sidebar.SetBorder(true) + d.sidebar.ShowSecondaryText(false) + d.sidebar.SetSelectedFocusOnly(true) + d.sidebar.SetMainTextColor(colorActive) + d.sidebar.SetSelectedTextColor(tcell.ColorWhite) + d.sidebar.SetSelectedBackgroundColor(colorHighlight2) + d.sidebar.SetHighlightFullLine(true) + d.sidebar.SetSelectedFunc(func(i int, listItem *cview.ListItem) { + cid := listItem.GetReference().(string) + d.Mach.Add1(ss.SelectingClient, am.A{"Client.id": cid}) + }) + d.sidebar.SetSelectedAlwaysVisible(true) + + // log view + d.log = cview.NewTextView() + d.log.SetBorder(true) + d.log.SetRegions(true) + d.log.SetTextAlign(cview.AlignLeft) + d.log.SetWrap(true) + d.log.SetDynamicColors(true) + d.log.SetTitle(" Log ") + d.log.SetHighlightForegroundColor(tcell.ColorWhite) + d.log.SetHighlightBackgroundColor(colorHighlight2) + + // matrix + d.matrix = cview.NewTable() + d.matrix.SetBorder(true) + d.matrix.SetTitle(" Matrix ") + // TODO draw scrollbar at the right edge + d.matrix.SetScrollBarVisibility(cview.ScrollBarNever) + d.matrix.SetPadding(0, 0, 1, 0) + + // current tx bar + d.currTxBarLeft = cview.NewTextView() + d.currTxBarLeft.SetDynamicColors(true) + d.currTxBarRight = cview.NewTextView() + d.currTxBarRight.SetTextAlign(cview.AlignRight) + + // next tx bar + d.nextTxBarLeft = cview.NewTextView() + d.nextTxBarLeft.SetDynamicColors(true) + d.nextTxBarRight = cview.NewTextView() + d.nextTxBarRight.SetTextAlign(cview.AlignRight) + + // TODO step info bar: type, from, to, data + // timeline tx + d.timelineTxs = cview.NewProgressBar() + d.timelineTxs.SetBorder(true) + + // timeline steps + d.timelineSteps = cview.NewProgressBar() + d.timelineSteps.SetBorder(true) + + // keystrokes bar + d.keyBar = cview.NewTextView() + d.keyBar.SetTextAlign(cview.AlignCenter) + d.keyBar.SetDynamicColors(true) + + // filters bar + + d.initFiltersBar() + + // update models + d.updateTimelines() + d.updateTxBars() + d.updateKeyBars() + d.updateFocusable() +} + +func (d *Debugger) initFiltersBar() { + d.filtersBar = cview.NewTextView() + d.filtersBar.SetTextAlign(cview.AlignLeft) + d.filtersBar.SetDynamicColors(true) + // TODO enum + d.focusedFilter = "skip-canceled" + d.updateFiltersBar() +} + +// TODO tab navigation +// TODO anon machine with handlers +func (d *Debugger) initExportDialog() *cview.Modal { + exportDialog := cview.NewModal() + form := exportDialog.GetForm() + form.AddInputField("Filename", "am-dbg-dump", 20, nil, nil) + + exportDialog.SetText("Export all clients data (esc quits)") + // exportDialog.AddButtons([]string{"Save"}) + exportDialog.AddButtons([]string{"Save", "Cancel"}) + exportDialog.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + field, ok := form.GetFormItemByLabel("Filename").(*cview.InputField) + if !ok { + d.Mach.Log("Error: export dialog field not found") + return + } + filename := field.GetText() + + if buttonLabel == "Save" && filename != "" { + form.GetButton(0).SetLabel("Saving...") + form.Draw(d.app.GetScreen()) + d.exportData(filename) + d.Mach.Remove1(ss.ExportDialog, nil) + form.GetButton(0).SetLabel("Save") + } else if buttonLabel == "Cancel" { + d.Mach.Remove1(ss.ExportDialog, nil) + } + }) + + return exportDialog +} + +// TODO better approach to modals +// TODO modal titles +// TODO state color meanings +// TODO page up/down on tx timeline +func (d *Debugger) initHelpDialog() *cview.Flex { + left := cview.NewTextView() + left.SetBackgroundColor(colorHighlight) + left.SetTitle(" Legend ") + left.SetDynamicColors(true) + left.SetPadding(1, 1, 1, 1) + left.SetText(dedent.Dedent(strings.Trim(` + [::b]### [::u]tree legend[::-] + + [::b]*[::-] handler ran + [::b]+[::-] to be added + [::b]-[::-] to be removed + [::b]bold[::-] touched state + [::b]underline[::-] called state + [::b]![::-] state canceled + + [::b]### [::u]matrix legend[::-] + + [::b]underline[::-] called state + + [::b]1st row[::-] called states + col == state index + + [::b]2nd row[::-] state tick changes + col == state index + + [::b]>=3 row[::-] state relations + cartesian product + col == source state index + row == target state index + + `, "\n "))) + + right := cview.NewTextView() + right.SetBackgroundColor(colorHighlight) + right.SetTitle(" Keystrokes ") + right.SetDynamicColors(true) + right.SetPadding(1, 1, 1, 1) + right.SetText(dedent.Dedent(strings.Trim(` + [::b]### [::u]keystrokes[::-] + + [::b]tab[::-] change focus + [::b]shift+tab[::-] change focus + [::b]space[::-] play/pause + [::b]left/right[::-] back/fwd + [::b]alt+left/right[::-] fast jump + [::b]alt+h/l[::-] fast jump + [::b]alt+h/l[::-] state jump (if selected) + [::b]up/down[::-] scroll / navigate + [::b]j/k[::-] scroll / navigate + [::b]alt+j/k[::-] page up/down + [::b]alt+e[::-] expand/collapse tree + [::b]enter[::-] expand/collapse node + [::b]alt+v[::-] tail mode + [::b]alt+m[::-] matrix view + [::b]home/end[::-] struct / last tx + [::b]alt+s[::-] export data + [::b]backspace[::-] remove machine + [::b]ctrl+q[::-] quit + [::b]?[::-] show help + `, "\n "))) + + grid := cview.NewGrid() + grid.SetTitle(" AsyncMachine Debugger ") + grid.SetColumns(0, 0) + grid.SetRows(0) + grid.AddItem(left, 0, 0, 1, 1, 0, 0, false) + grid.AddItem(right, 0, 1, 1, 1, 0, 0, false) + + box1 := cview.NewBox() + box1.SetBackgroundTransparent(true) + box2 := cview.NewBox() + box2.SetBackgroundTransparent(true) + box3 := cview.NewBox() + box3.SetBackgroundTransparent(true) + box4 := cview.NewBox() + box4.SetBackgroundTransparent(true) + + flexHor := cview.NewFlex() + flexHor.AddItem(box1, 0, 1, false) + flexHor.AddItem(grid, 0, 2, false) + flexHor.AddItem(box2, 0, 1, false) + + flexVer := cview.NewFlex() + flexVer.SetDirection(cview.FlexRow) + flexVer.AddItem(box3, 0, 1, false) + flexVer.AddItem(flexHor, 0, 2, false) + flexVer.AddItem(box4, 0, 1, false) + + return flexVer +} + +func (d *Debugger) initLayout() { + // TODO flexbox + currTxBar := cview.NewGrid() + currTxBar.AddItem(d.currTxBarLeft, 0, 0, 1, 1, 0, 0, false) + currTxBar.AddItem(d.currTxBarRight, 0, 1, 1, 1, 0, 0, false) + + // TODO flexbox + nextTxBar := cview.NewGrid() + nextTxBar.AddItem(d.nextTxBarLeft, 0, 0, 1, 1, 0, 0, false) + nextTxBar.AddItem(d.nextTxBarRight, 0, 1, 1, 1, 0, 0, false) + + // content grid + treeLogGrid := cview.NewGrid() + treeLogGrid.SetRows(-1) + treeLogGrid.SetColumns( /*tree*/ -1 /*log*/, -1, -1) + treeLogGrid.AddItem(d.tree, 0, 0, 1, 1, 0, 0, false) + treeLogGrid.AddItem(d.log, 0, 1, 1, 2, 0, 0, false) + + treeMatrixGrid := cview.NewGrid() + treeMatrixGrid.SetRows(-1) + treeMatrixGrid.SetColumns( /*tree*/ -1 /*log*/, -1, -1) + treeMatrixGrid.AddItem(d.tree, 0, 0, 1, 1, 0, 0, false) + treeMatrixGrid.AddItem(d.matrix, 0, 1, 1, 2, 0, 0, false) + + // content panels + d.contentPanels = cview.NewPanels() + d.contentPanels.AddPanel("tree-log", treeLogGrid, true, true) + d.contentPanels.AddPanel("tree-matrix", treeMatrixGrid, true, false) + d.contentPanels.AddPanel("matrix", d.matrix, true, false) + d.contentPanels.SetBackgroundColor(colorHighlight) + + // main grid + mainGrid := cview.NewGrid() + mainGrid.SetRows(-1, 2, 3, 2, 3, 1, 2) + cols := []int{ /*sidebar*/ -1 /*content*/, -1, -1, -1, -1, -1, -1} + mainGrid.SetColumns(cols...) + // row 1 left + mainGrid.AddItem(d.sidebar, 0, 0, 1, 1, 0, 0, false) + // row 1 mid, right + mainGrid.AddItem(d.contentPanels, 0, 1, 1, 6, 0, 0, false) + // row 2...5 + mainGrid.AddItem(currTxBar, 1, 0, 1, len(cols), 0, 0, false) + mainGrid.AddItem(d.timelineTxs, 2, 0, 1, len(cols), 0, 0, false) + mainGrid.AddItem(nextTxBar, 3, 0, 1, len(cols), 0, 0, false) + mainGrid.AddItem(d.timelineSteps, 4, 0, 1, len(cols), 0, 0, false) + mainGrid.AddItem(d.filtersBar, 5, 0, 1, len(cols), 0, 0, false) + mainGrid.AddItem(d.keyBar, 6, 0, 1, len(cols), 0, 0, false) + + panels := cview.NewPanels() + panels.AddPanel("export", d.exportDialog, false, true) + panels.AddPanel("help", d.helpDialog, true, true) + panels.AddPanel("main", mainGrid, true, true) + + d.mainGrid = mainGrid + d.layoutRoot = panels +} + +func (d *Debugger) drawViews() { + d.updateViews(true) + d.updateFocusable() + d.draw() +} + +// RedrawFull updates all components of the debugger UI, except the sidebar. +func (d *Debugger) RedrawFull(immediate bool) { + d.updateViews(immediate) + d.updateTimelines() + d.updateTxBars() + d.updateKeyBars() + d.updateBorderColor() + d.draw() +} + +func (d *Debugger) draw() { + if d.repaintScheduled { + return + } + d.repaintScheduled = true + + go func() { + // debounce every 16msec + time.Sleep(16 * time.Millisecond) + // TODO re-draw only changed components + d.app.QueueUpdateDraw(func() {}) + d.repaintScheduled = false + }() +} diff --git a/tools/debugger/utils.go b/tools/debugger/utils.go new file mode 100644 index 0000000..5e4a618 --- /dev/null +++ b/tools/debugger/utils.go @@ -0,0 +1,81 @@ +package debugger + +import ( + "regexp" + "sort" + "strconv" + + "github.com/pancsta/cview" +) + +type Focusable struct { + cview.Primitive + *cview.Box +} + +type filter struct { + id string + label string + active bool +} + +var humanSortRE = regexp.MustCompile(`[0-9]+`) + +func formatTxBarTitle(title string) string { + return "[::u]" + title + "[::-]" +} + +func humanSort(strs []string) { + sort.Slice(strs, func(i, j int) bool { + // skip overlapping parts + maxChars := min(len(strs[i]), len(strs[j])) + firstDiff := 0 + for k := 0; k < maxChars; k++ { + if strs[i][k] != strs[j][k] { + break + } + firstDiff++ + } + + // if no numbers - compare as strings + posI := humanSortRE.FindStringIndex(strs[i][firstDiff:]) + posJ := humanSortRE.FindStringIndex(strs[j][firstDiff:]) + if len(posI) <= 0 || len(posJ) <= 0 || posI[0] != posJ[0] { + return strs[i] < strs[j] + } + + // if contains numbers - sort by numbers + numsI := humanSortRE.FindAllString(strs[i][firstDiff:], -1) + numsJ := humanSortRE.FindAllString(strs[j][firstDiff:], -1) + numI, _ := strconv.Atoi(numsI[0]) + numJ, _ := strconv.Atoi(numsJ[0]) + + if numI != numJ { + // If the numbers are different, order by the numbers + return numI < numJ + } + + // If the numbers are the same, order lexicographically + return strs[i] < strs[j] + }) +} + +func matrixCellVal(strVal string) string { + switch len(strVal) { + case 1: + strVal = " " + strVal + " " + case 2: + strVal = " " + strVal + } + return strVal +} + +func matrixEmptyRow(d *Debugger, row, colsCount, highlightIndex int) { + // empty row + for ii := 0; ii < colsCount; ii++ { + d.matrix.SetCellSimple(row, ii, " ") + if ii == highlightIndex { + d.matrix.GetCell(row, ii).SetBackgroundColor(colorHighlight) + } + } +}