From 9028f8175bda63d6a85e5a2dee2b4039020317a4 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:35:00 +0200 Subject: Mouse support: wheel scroll everywhere, right-click context menus Explicitly requested - this is a real terminal app meant to work in any terminal (the name is a naming convention, not a tmux runtime dependency), and should be genuinely mouse-driven, not keyboard-only. Enabled via tea.WithMouseCellMotion() (SGR mouse mode, the same protocol nvim and most modern TUI apps use); coexists with tmux's own mouse mode the same way it would for any other terminal app. Scope was decided by a real, verified library constraint, not convenience: bubbles/table exposes no way to learn its own scroll offset (confirmed by reading its source - no YOffset accessor, the rendered window comes from unexported fields via a second internal layer of viewport scrolling on top of that). Mapping a click's screen coordinates to a specific table row can't be done without reaching into that library's private internals, which this deliberately doesn't do - silently selecting the wrong row on a misjudged click is worse than not supporting precise click-to-row at all. What's shipped, the reliable subset: - Wheel scroll everywhere there's something to scroll: tables via the already-exported MoveUp/MoveDown (no scroll-state assumptions needed), viewports via their own native wheel handling (bubbles/ viewport already has this - nothing in the codebase was routing tea.MouseMsg to it yet), and vi-modal text editors via new viTextarea.ScrollUp/ScrollDown (bubbles/textarea has zero native mouse handling at all, confirmed the same way - feeds wheel events as repeated up/down keypresses through the same tested movement path h/j/k/l already use). - Right-click opens a context menu (cmd/mitmux/contextmenu.go) - a horizontal strip taking over the status/help line, the same "replace the bottom of the screen" pattern confirmPrompt and the export/import prompts already use, rather than a floating popup positioned at the click (lipgloss/bubbletea have no compositor for splicing an overlay into an arbitrary screen position - not worth building just for this). Wired into every list-based view: history (view/repeater/intruder/flag-unflag/delete), rules (edit/enable-disable/delete), scope (enable-disable/delete). Menu items ARE reliably clickable, unlike table rows - the menu renders its own strip, so every item's width is fully known rather than hidden behind a library's unexported scroll state. Navigable by mouse click or j/k/arrows+enter; esc or right-clicking again dismisses. Deferred, not silently dropped: click-to-select-a-different-row (same scroll-offset limitation), and click-to-switch-pane-focus in Repeater/ Intruder (tractable via the same Y-coordinate math WindowSizeMsg already computes, just not done yet - keyboard tab already covers it, so lower priority than what shipped). Verified live in tmux by injecting real SGR mouse escape sequences directly into the pane (tmux send-keys -l with hand-built ESC [ < Cb;Cx;Cy M/m sequences, since tmux has no built-in "synthesize a click" primitive) against a running daemon with real captured entries: wheel-down/up on the history table correctly moved the selected-row highlight (confirmed via ANSI-aware capture, not just "no crash"); right-click opened the menu with the right actions; clicking directly on a computed menu-item position correctly triggered that exact action (clicked "delete", saw the correct entry's ID in the resulting confirm prompt); keyboard navigation inside the menu moved the highlight correctly; esc and a second right-click both dismissed cleanly with no side effects; wheel events in Repeater's text pane and Detail's viewport caused no crash and left vi-mode state intact; an empty rules table's right-click correctly no-opped; adding a real rule then right-clicking and clicking "disable" correctly toggled it off (confirmed via the rendered checkmark disappearing). go build/vet/gofmt/test/mod tidy all clean. --- cmd/mitmux/contextmenu.go | 68 ++++++++++++++ cmd/mitmux/main.go | 57 +++++++++++- cmd/mitmux/mouse.go | 233 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/mitmux/vimode.go | 19 ++++ 4 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 cmd/mitmux/contextmenu.go create mode 100644 cmd/mitmux/mouse.go (limited to 'cmd') diff --git a/cmd/mitmux/contextmenu.go b/cmd/mitmux/contextmenu.go new file mode 100644 index 0000000..48df0f3 --- /dev/null +++ b/cmd/mitmux/contextmenu.go @@ -0,0 +1,68 @@ +package main + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// contextMenuItem is one entry in a right-click context menu. +type contextMenuItem struct { + label string + action func() tea.Cmd +} + +// A right-click menu is rendered as a horizontal strip taking over the +// status/help line, the same "replace the bottom of the screen" pattern +// confirmPrompt and the export/import prompts already use, rather than +// a floating popup positioned at the click. That's a deliberate +// simplification: lipgloss/bubbletea have no compositor for splicing an +// ANSI-styled overlay into an arbitrary screen position, and building +// one just for this would be a lot of new, fragile machinery for a +// nice-to-have. Navigable by j/k/arrows + enter, or by clicking an item +// directly (each item's on-screen width is fully known here, unlike a +// table row's - see the table-click limitation noted where mouse +// handling is wired into Update()), esc or right-click again to +// dismiss. +func (m *model) openContextMenu(items []contextMenuItem) { + m.contextMenu = items + m.contextMenuIndex = 0 +} + +func (m *model) closeContextMenu() { + m.contextMenu = nil + m.contextMenuIndex = 0 +} + +// contextMenuAt returns the item index at screen column x within the +// menu's rendered strip, or -1 if x falls outside every item - used to +// resolve a mouse click to a specific item. +func (m *model) contextMenuAt(x int) int { + col := 0 + for i, item := range m.contextMenu { + w := lipgloss.Width(contextMenuLabel(item.label)) + if x >= col && x < col+w { + return i + } + col += w + } + return -1 +} + +func contextMenuLabel(label string) string { + return " " + label + " " +} + +func (m *model) contextMenuView() string { + var b strings.Builder + for i, item := range m.contextMenu { + label := contextMenuLabel(item.label) + if i == m.contextMenuIndex { + b.WriteString(tabActive.Render(label)) + } else { + b.WriteString(tabInactive.Render(label)) + } + } + return b.String() +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index c9563fe..f248ef0 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -67,7 +67,18 @@ func main() { defer subClose() m := newModel(client, subCh, path) - p := tea.NewProgram(m, tea.WithAltScreen()) + // WithMouseCellMotion enables click and wheel events (SGR mouse + // mode, the same protocol nvim and most modern TUI apps use) - + // see contextmenu.go and the mouse handling in Update() for what's + // actually wired up. Coexists with tmux's own mouse mode the same + // way it does for any other terminal app: tmux forwards these + // events to the focused pane's application when tmux's own `set -g + // mouse on` is active, nothing mitmux-specific required. The + // standard terminal-wide workaround for selecting on-screen text + // with the mouse while an app has mouse mode on (holding Shift + // while click-dragging, in most terminal emulators) still works - + // mitmux doesn't intercept or need to know about that at all. + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "mitmux: %v\n", err) os.Exit(1) @@ -230,6 +241,13 @@ type model struct { confirmPrompt string confirmYes func() tea.Cmd + // contextMenu, when non-empty, is a right-click menu taking over + // the status line - see contextmenu.go. Checked globally, ahead of + // every mode's own key handling, the same priority confirmPrompt + // gets. + contextMenu []contextMenuItem + contextMenuIndex int + // exportEditing mirrors the grep-match/extract edit-buffer pattern: // a modal textinput overlay for the destination path, enter writes // and confirms, esc cancels. Used from two places sharing the same @@ -897,6 +915,9 @@ func (m *model) Init() tea.Cmd { func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) + case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.ready = true @@ -1143,6 +1164,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.waitForIntrudeResult case tea.KeyMsg: + if len(m.contextMenu) > 0 { + switch msg.String() { + case "esc": + m.closeContextMenu() + return m, nil + case "enter", " ": + item := m.contextMenu[m.contextMenuIndex] + m.closeContextMenu() + if item.action != nil { + return m, item.action() + } + return m, nil + case "up", "k", "left", "h": + m.contextMenuIndex = (m.contextMenuIndex - 1 + len(m.contextMenu)) % len(m.contextMenu) + return m, nil + case "down", "j", "right", "l", "tab": + m.contextMenuIndex = (m.contextMenuIndex + 1) % len(m.contextMenu) + return m, nil + case "ctrl+c": + return m, tea.Quit + } + return m, nil + } switch m.mode { case viewList: if m.confirmPrompt != "" { @@ -1865,6 +1909,9 @@ func (m *model) View() string { default: body = m.listView() } + if len(m.contextMenu) > 0 { + return body + "\n" + m.contextMenuView() + } return body + "\n" + m.statusBar() } @@ -1905,6 +1952,14 @@ func (m *model) helpView() string { "? this screen (any key returns)", "ctrl+c quit", ) + section("Mouse", + "wheel scroll whatever's focused (table, text, or response pane)", + "right-click context menu for the current selection (history/rules/scope lists)", + " - always the *selected* row's actions, not whatever's", + " literally under the cursor; click a menu item, or", + " j/k/arrows + enter, esc or right-click again to dismiss", + " (needs tmux's own 'set -g mouse on' if running inside tmux)", + ) section("History list", "↑/↓ or j/k navigate (also g/G top/bottom, ctrl+u/d half-page)", "enter view request/response detail", diff --git a/cmd/mitmux/mouse.go b/cmd/mitmux/mouse.go new file mode 100644 index 0000000..102dc4f --- /dev/null +++ b/cmd/mitmux/mouse.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// isWheel reports whether msg is a wheel event. tea.MouseMsg is a +// distinct named type from tea.MouseEvent (type MouseMsg MouseEvent), +// so MouseEvent's own IsWheel method isn't in MouseMsg's method set +// despite the identical underlying struct - converting once here is +// simpler than repeating the conversion at every call site. +func isWheel(msg tea.MouseMsg) bool { + return tea.MouseEvent(msg).IsWheel() +} + +// handleMouse dispatches a tea.MouseMsg by mode, the mouse counterpart +// to Update()'s tea.KeyMsg dispatch. Deliberately scoped to what's +// reliably achievable rather than everything a GUI might do: wheel +// scroll works everywhere there's something to scroll, and right-click +// opens a context menu for the *currently selected* row in every +// list-based view - not the row directly under the cursor, because +// bubbles/table exposes no way to learn its own scroll offset (no +// YOffset accessor, and the visible window is computed from unexported +// fields - confirmed by reading its source rather than guessing), so +// mapping a click's Y coordinate to a specific row can't be done +// without reflection into private library state. Left-click-to-select- +// a-different-row and click-to-switch-pane-focus (Repeater/Intruder) +// are deferred for the same reason: getting either subtly wrong (silently +// selecting the wrong row) is worse than not having them yet. +func (m *model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if len(m.contextMenu) > 0 { + switch { + case msg.Action != tea.MouseActionPress: + return m, nil + case msg.Button == tea.MouseButtonRight: + m.closeContextMenu() + return m, nil + case msg.Button == tea.MouseButtonLeft: + if i := m.contextMenuAt(msg.X); i >= 0 { + item := m.contextMenu[i] + m.closeContextMenu() + if item.action != nil { + return m, item.action() + } + } + return m, nil + } + return m, nil + } + + switch m.mode { + case viewList: + return m.handleListMouse(msg) + case viewDetail: + if isWheel(msg) { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + case viewRepeater: + return m.handleRepeaterMouse(msg) + case viewIntruder: + return m.handleIntruderMouse(msg) + case viewCompare: + if isWheel(msg) { + var cmd tea.Cmd + m.compareViewport, cmd = m.compareViewport.Update(msg) + return m, cmd + } + case viewDecoder: + if isWheel(msg) { + var cmd tea.Cmd + m.decoderOutput, cmd = m.decoderOutput.Update(msg) + return m, cmd + } + case viewRules: + return m.handleRulesMouse(msg) + case viewScope: + return m.handleScopeMouse(msg) + } + return m, nil +} + +func (m *model) handleListMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.searching || m.exportEditing || m.importEditing || m.confirmPrompt != "" { + return m, nil // an active modal text input owns input right now + } + switch { + case msg.Button == tea.MouseButtonWheelUp: + m.table.MoveUp(3) + case msg.Button == tea.MouseButtonWheelDown: + m.table.MoveDown(3) + case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress: + row := m.table.Cursor() + if row < 0 || row >= len(m.entries) { + return m, nil + } + id := m.entries[row].ID + flagLabel := "flag" + if m.entries[row].Flagged { + flagLabel = "unflag" + } + m.openContextMenu([]contextMenuItem{ + {label: "view", action: func() tea.Cmd { + m.statusMsg = "" + return m.loadDetail(id, "") + }}, + {label: "repeater", action: func() tea.Cmd { + m.statusMsg = "" + return m.loadDetail(id, "repeater") + }}, + {label: "intruder", action: func() tea.Cmd { + m.statusMsg = "" + return m.loadDetail(id, "intruder") + }}, + {label: flagLabel, action: func() tea.Cmd { + m.entries[row].Flagged = !m.entries[row].Flagged + setTableRows(&m.table, rowsFor(m.entries)) + return m.setFlagged(id, m.entries[row].Flagged) + }}, + {label: "delete", action: func() tea.Cmd { + m.confirmPrompt = fmt.Sprintf("delete #%d? y/n", id) + m.confirmYes = func() tea.Cmd { return m.deleteEntry(id) } + return nil + }}, + }) + } + return m, nil +} + +func (m *model) handleRepeaterMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + t := m.activeRepeaterTab() + if t == nil || !isWheel(msg) { + return m, nil + } + up := msg.Button == tea.MouseButtonWheelUp + if t.focus == focusRequest { + if up { + t.reqArea.ScrollUp(3) + } else { + t.reqArea.ScrollDown(3) + } + return m, nil + } + var cmd tea.Cmd + t.respView, cmd = t.respView.Update(msg) + return m, cmd +} + +func (m *model) handleIntruderMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if !isWheel(msg) { + return m, nil + } + up := msg.Button == tea.MouseButtonWheelUp + switch m.intruderFocus { + case focusTemplate: + if up { + m.intruderTemplate.ScrollUp(3) + } else { + m.intruderTemplate.ScrollDown(3) + } + case focusPayloads: + if up { + m.intruderPayloads.ScrollUp(3) + } else { + m.intruderPayloads.ScrollDown(3) + } + case focusResults: + if up { + m.intruderResults.MoveUp(3) + } else { + m.intruderResults.MoveDown(3) + } + } + return m, nil +} + +func (m *model) handleRulesMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.ruleForm { + return m, nil + } + switch { + case msg.Button == tea.MouseButtonWheelUp: + m.rulesTable.MoveUp(3) + case msg.Button == tea.MouseButtonWheelDown: + m.rulesTable.MoveDown(3) + case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress: + row := m.rulesTable.Cursor() + if row < 0 || row >= len(m.ruleRows) { + return m, nil + } + sel := m.ruleRows[row] + enableLabel := "enable" + if sel.Enabled { + enableLabel = "disable" + } + m.openContextMenu([]contextMenuItem{ + {label: "edit", action: func() tea.Cmd { m.enterRuleForm(&sel); return nil }}, + {label: enableLabel, action: func() tea.Cmd { return m.toggleSelectedRule() }}, + {label: "delete", action: func() tea.Cmd { return m.deleteSelectedRule() }}, + }) + } + return m, nil +} + +func (m *model) handleScopeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.scopeForm { + return m, nil + } + switch { + case msg.Button == tea.MouseButtonWheelUp: + m.scopeTable.MoveUp(3) + case msg.Button == tea.MouseButtonWheelDown: + m.scopeTable.MoveDown(3) + case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress: + row := m.scopeTable.Cursor() + if row < 0 || row >= len(m.scopeRows) { + return m, nil + } + sel := m.scopeRows[row] + enableLabel := "enable" + if sel.Enabled { + enableLabel = "disable" + } + m.openContextMenu([]contextMenuItem{ + {label: enableLabel, action: func() tea.Cmd { return m.toggleSelectedScopeRule() }}, + {label: "delete", action: func() tea.Cmd { return m.deleteSelectedScopeRule() }}, + }) + } + return m, nil +} diff --git a/cmd/mitmux/vimode.go b/cmd/mitmux/vimode.go index 5da82a3..1af1fb9 100644 --- a/cmd/mitmux/vimode.go +++ b/cmd/mitmux/vimode.go @@ -65,6 +65,25 @@ func (v *viTextarea) Focus() tea.Cmd { // itself. func (v *viTextarea) InsertRune(r rune) { v.ta.InsertRune(r) } +// ScrollUp/ScrollDown move the cursor n lines up/down - used for mouse +// wheel scrolling. bubbles/textarea has no native mouse handling at all +// (confirmed in its source: no case for tea.MouseMsg anywhere), so +// there's nothing to forward a wheel event to; feeding it as n +// up/down keypresses reuses the same tested movement path vi's own +// h/j/k/l already go through rather than reaching into textarea's +// internal line offset directly. +func (v *viTextarea) ScrollUp(n int) { + for i := 0; i < n; i++ { + v.feed("up") + } +} + +func (v *viTextarea) ScrollDown(n int) { + for i := 0; i < n; i++ { + v.feed("down") + } +} + // Update processes one message. Callers should check Mode()=="normal" // before treating "esc" as "leave this view" - in insert mode, esc // belongs to viTextarea (it drops back to normal mode without -- cgit v1.2.3