From bea0b86ee50ec331501a395c4694a0739cdf9123 Mon Sep 17 00:00:00 2001 From: zigai Date: Thu, 18 Jun 2026 00:11:10 +0200 Subject: [PATCH] add mouse support to tui --- README.md | 4 + internal/cli/search.go | 1 + internal/tui/model.go | 17 +- internal/tui/mouse.go | 447 +++++++++++++++++++++++++++++++++++++ internal/tui/mouse_test.go | 204 +++++++++++++++++ internal/tui/view.go | 28 +-- 6 files changed, 667 insertions(+), 34 deletions(-) create mode 100644 internal/tui/mouse.go create mode 100644 internal/tui/mouse_test.go diff --git a/README.md b/README.md index a717518..8994967 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,10 @@ if (Get-Command zgod -ErrorAction SilentlyContinue) { Invoke-Expression (& zgod | `alt+p` | Preview multiline command (popup mode only) | | `?` | Help overlay | +Mouse is also supported in compatible terminals: wheel scrolls the result list, +hovering a result highlights it, left-clicking a result accepts it, footer +shortcuts can be clicked, and clicking in the input moves the cursor. + ## Configuration Default paths: diff --git a/internal/cli/search.go b/internal/cli/search.go index ea857f9..9f1b12c 100644 --- a/internal/cli/search.go +++ b/internal/cli/search.go @@ -73,6 +73,7 @@ func doSearch(cmd *cobra.Command) (int, error) { ctx.model, tea.WithInput(ctx.ttyIn), tea.WithOutput(ctx.ttyOut), + tea.WithMouseAllMotion(), ) finalModel, err := p.Run() diff --git a/internal/tui/model.go b/internal/tui/model.go index 974ece6..65fac3c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -35,6 +35,7 @@ type Model struct { width int height int maxHeight int + terminalHeight int selected string mode match.Mode enabledModes []match.Mode @@ -224,7 +225,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleKey(msg) + case tea.MouseMsg: + return m.handleMouse(msg) case tea.WindowSizeMsg: + m.terminalHeight = msg.Height + innerWidth := max(msg.Width-panelBorderW-(panelPaddingX*2), 1) m.width = innerWidth @@ -746,17 +751,7 @@ func (m *Model) handlePreview(msg tea.KeyMsg) bool { return false } - if m.cfg.Display.MultilinePreview != "popup" { - return true - } - - cmd, ok := m.currentResultCommand() - if !ok || !strings.Contains(cmd, "\n") { - return true - } - - m.showPreview = true - m.previewCommand = cmd + m.showCurrentPreview() return true } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go new file mode 100644 index 0000000..433425a --- /dev/null +++ b/internal/tui/mouse.go @@ -0,0 +1,447 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type mouseInputBounds struct { + x int + y int + width int +} + +type footerShortcutAction int + +const ( + footerShortcutNone footerShortcutAction = iota + footerShortcutAccept + footerShortcutCancel + footerShortcutModeNext + footerShortcutToggleCWD + footerShortcutToggleDedupe + footerShortcutHelp + footerShortcutPreview +) + +type footerShortcut struct { + key string + desc string + action footerShortcutAction +} + +func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + ev := tea.MouseEvent(msg) + + if m.showPreview || m.showHelp { + if ev.IsWheel() || ev.Action == tea.MouseActionPress { + m.dismissTransientViews() + } + + return m, nil + } + + if ev.IsWheel() { + m.handleMouseWheel(ev) + + return m, nil + } + + if ev.Action == tea.MouseActionMotion { + m.handleMouseHover(ev) + + return m, nil + } + + if ev.Action != tea.MouseActionPress || ev.Button != tea.MouseButtonLeft { + return m, nil + } + + return m.handleMouseLeftPress(ev) +} + +func (m *Model) handleMouseWheel(ev tea.MouseEvent) { + if _, _, ok := m.mouseBodyPosition(ev); !ok { + return + } + + switch ev.Button { + case tea.MouseButtonWheelUp: + m.moveCursor(-1) + case tea.MouseButtonWheelDown: + m.moveCursor(1) + } +} + +func (m *Model) handleMouseHover(ev tea.MouseEvent) { + _, bodyY, ok := m.mouseBodyPosition(ev) + if !ok { + return + } + + if resultIdx, ok := m.resultIndexAtBodyY(bodyY); ok { + m.cursor = resultIdx + } +} + +func (m *Model) handleMouseLeftPress(ev tea.MouseEvent) (tea.Model, tea.Cmd) { + bodyX, bodyY, ok := m.mouseBodyPosition(ev) + if !ok { + return m, nil + } + + if m.handleMouseInputClick(bodyX, bodyY) { + return m, nil + } + + if resultIdx, ok := m.resultIndexAtBodyY(bodyY); ok { + m.cursor = resultIdx + if !m.acceptCurrentSelection() { + return m, nil + } + + m.quitting = true + + return m, tea.Quit + } + + if action, ok := m.footerShortcutAt(bodyX, bodyY); ok { + return m, m.triggerFooterShortcut(action) + } + + return m, nil +} + +func (m *Model) moveCursor(delta int) { + if len(m.displayEntries) == 0 { + return + } + + m.cursor = min(max(m.cursor+delta, 0), len(m.displayEntries)-1) +} + +func (m *Model) mouseBodyPosition(ev tea.MouseEvent) (int, int, bool) { + viewY := ev.Y - m.viewOriginY() + bodyY := viewY - 1 - panelPaddingY + bodyX := ev.X - 1 - panelPaddingX + + if bodyX < 0 || bodyX >= m.width || bodyY < 0 || bodyY >= m.bodyHeight() { + return 0, 0, false + } + + return bodyX, bodyY, true +} + +func (m *Model) viewOriginY() int { + if m.terminalHeight <= 0 { + return 0 + } + + return max(m.terminalHeight-m.viewHeight(), 0) +} + +func (m *Model) viewHeight() int { + return m.bodyHeight() + panelBorderH + (panelPaddingY * 2) +} + +func (m *Model) bodyHeight() int { + return m.inputRows() + m.height + m.previewPaneRows() + m.footerRows() +} + +func (m *Model) inputRows() int { + if m.isMerged() { + return 1 + } + + return 2 +} + +func (m *Model) previewPaneRows() int { + if m.cfg.Display.MultilinePreview == "preview_pane" { + return previewPaneHeight + } + + return 0 +} + +func (m *Model) footerRows() int { + if m.cfg.Display.ShowHints { + return 1 + } + + return 0 +} + +func (m *Model) resultIndexAtBodyY(bodyY int) (int, bool) { + resultY := bodyY - m.inputRows() + if resultY < 0 || resultY >= m.height { + return 0, false + } + + headerRows := resultsHeaderRows + if m.height <= resultsHeaderRows { + headerRows = 0 + } + + if resultY < headerRows { + return 0, false + } + + start, end := m.visibleResultRange() + if start == end { + return 0, false + } + + row := headerRows + expandMode := m.cfg.Display.MultilinePreview == "expand" + + for idx := start; idx < end && row < m.height; idx++ { + rowCount := 1 + if expandMode && idx == m.cursor && m.entryIsMultiline(idx) { + rowCount = m.expandedResultRowCount(idx, m.height-row) + } + + if resultY >= row && resultY < row+rowCount { + return idx, true + } + + row += rowCount + } + + return 0, false +} + +func (m *Model) expandedResultRowCount(idx int, remaining int) int { + if idx < 0 || idx >= len(m.displayEntries) || remaining <= 0 { + return 0 + } + + rows := strings.Count(m.displayEntries[idx].Entry.Command, "\n") + 1 + + return min(max(rows, 1), remaining) +} + +func (m *Model) handleMouseInputClick(bodyX int, bodyY int) bool { + bounds, ok := m.mouseInputBounds() + if !ok || bodyY != bounds.y || bodyX < bounds.x || bodyX >= bounds.x+bounds.width { + return false + } + + m.input.SetCursor(m.inputCursorPositionAtCell(bodyX-bounds.x, bounds.width)) + + return true +} + +func (m *Model) mouseInputBounds() (mouseInputBounds, bool) { + promptWidth := lipgloss.Width(m.styles.Prompt.Render(m.cfg.Theme.Prompt)) + + if m.isMerged() { + indicatorWidth := lipgloss.Width(m.renderIndicators()) + width := m.width - promptWidth - indicatorWidth - 2 + + return mouseInputBounds{x: promptWidth, y: 0, width: max(width, 0)}, width > 0 + } + + width := min(m.input.Width, max(m.width-promptWidth, 0)) + + return mouseInputBounds{x: promptWidth, y: 1, width: width}, width > 0 +} + +func (m *Model) inputCursorPositionAtCell(cell int, width int) int { + value := []rune(m.input.Value()) + if len(value) == 0 || cell <= 0 { + return m.inputVisibleStart(width) + } + + start := m.inputVisibleStart(width) + pos := start + cells := 0 + + for pos < len(value) { + runeWidth := lipgloss.Width(string(value[pos])) + next := cells + runeWidth + + if cell < next { + return pos + } + + if cell == next { + return pos + 1 + } + + cells = next + pos++ + if width > 0 && cells >= width { + break + } + } + + return pos +} + +func (m *Model) inputVisibleStart(width int) int { + value := []rune(m.input.Value()) + if width <= 0 || lipgloss.Width(string(value)) <= width { + return 0 + } + + pos := min(m.input.Position(), len(value)) + if pos <= 0 { + return 0 + } + + start := pos + cells := 0 + for start > 0 { + runeWidth := lipgloss.Width(string(value[start-1])) + if cells+runeWidth >= width { + break + } + + cells += runeWidth + start-- + } + + return start +} + +func (m *Model) footerShortcuts() []footerShortcut { + shortcuts := []footerShortcut{ + {key: m.cfg.Keys.Up + "/" + m.cfg.Keys.Down, desc: "nav", action: footerShortcutNone}, + {key: m.cfg.Keys.Accept, desc: "select", action: footerShortcutAccept}, + {key: m.cfg.Keys.Cancel, desc: "cancel", action: footerShortcutCancel}, + {key: m.cfg.Keys.ModeNext, desc: "mode", action: footerShortcutModeNext}, + {key: m.cfg.Keys.ToggleCWD, desc: "cwd", action: footerShortcutToggleCWD}, + {key: m.cfg.Keys.ToggleDedupe, desc: "dedup", action: footerShortcutToggleDedupe}, + {key: m.cfg.Keys.Help, desc: "help", action: footerShortcutHelp}, + } + + if m.cfg.Display.MultilinePreview == "popup" && m.selectedIsMultiline() { + shortcuts = append(shortcuts, footerShortcut{ + key: m.cfg.Keys.PreviewCommand, + desc: "preview", + action: footerShortcutPreview, + }) + } + + return shortcuts +} + +func (m *Model) footerShortcutAt(bodyX int, bodyY int) (footerShortcutAction, bool) { + if bodyY != m.footerBodyY() { + return footerShortcutNone, false + } + + contentWidth := max(m.width-lipgloss.Width(m.styles.Footer.Render("")), 0) + rightWidth := lipgloss.Width(m.styles.HelpDesc.Render(m.matchCountLabel())) + leftWidth := m.footerShortcutLineWidth() + if leftWidth+rightWidth > contentWidth { + return footerShortcutNone, false + } + + contentX := bodyX - 1 + if contentX < 0 || contentX >= contentWidth { + return footerShortcutNone, false + } + + x := 0 + for _, shortcut := range m.footerShortcuts() { + partWidth := m.footerShortcutWidth(shortcut) + if contentX >= x && contentX < x+partWidth { + return shortcut.action, shortcut.action != footerShortcutNone + } + + x += partWidth + 2 + } + + return footerShortcutNone, false +} + +func (m *Model) footerBodyY() int { + if !m.cfg.Display.ShowHints { + return -1 + } + + return m.inputRows() + m.height + m.previewPaneRows() +} + +func (m *Model) footerShortcutLineWidth() int { + shortcuts := m.footerShortcuts() + if len(shortcuts) == 0 { + return 0 + } + + width := 0 + for i, shortcut := range shortcuts { + if i > 0 { + width += 2 + } + + width += m.footerShortcutWidth(shortcut) + } + + return width +} + +func (m *Model) footerShortcutWidth(shortcut footerShortcut) int { + return lipgloss.Width(m.styles.HelpKey.Render(shortcut.key)) + + 1 + + lipgloss.Width(m.styles.HelpDesc.Render(shortcut.desc)) +} + +func (m *Model) triggerFooterShortcut(action footerShortcutAction) tea.Cmd { + switch action { + case footerShortcutAccept: + if !m.acceptCurrentSelection() { + return nil + } + + m.quitting = true + + return tea.Quit + case footerShortcutCancel: + m.quitting = true + m.canceled = true + + return tea.Quit + case footerShortcutModeNext: + m.mode = m.mode.Next(m.enabledModes) + m.updateMatches() + + return nil + case footerShortcutToggleCWD: + m.cwdMode = !m.cwdMode + + return m.startLoadingEntries() + case footerShortcutToggleDedupe: + m.dedupe = !m.dedupe + + return m.startLoadingEntries() + case footerShortcutHelp: + m.showHelp = true + + return nil + case footerShortcutPreview: + m.showCurrentPreview() + + return nil + default: + return nil + } +} + +func (m *Model) showCurrentPreview() { + if m.cfg.Display.MultilinePreview != "popup" { + return + } + + cmd, ok := m.currentResultCommand() + if !ok || !strings.Contains(cmd, "\n") { + return + } + + m.showPreview = true + m.previewCommand = cmd +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go new file mode 100644 index 0000000..b3cf7d5 --- /dev/null +++ b/internal/tui/mouse_test.go @@ -0,0 +1,204 @@ +package tui + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/zigai/zgod/internal/config" + "github.com/zigai/zgod/internal/db" + "github.com/zigai/zgod/internal/history" + "github.com/zigai/zgod/internal/match" +) + +func TestHandleMouseWheelMovesCursor(t *testing.T) { + t.Parallel() + + m := testMouseModel(4, 5) + m.cursor = 1 + x, y := testMouseBodyCell(m, 2, testMouseFirstResultBodyY(m)) + + _, _ = m.handleMouse(tea.MouseMsg{ + X: x, + Y: y, + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + }) + + if got, want := m.cursor, 0; got != want { + t.Fatalf("cursor after wheel up = %d, want %d", got, want) + } + + _, _ = m.handleMouse(tea.MouseMsg{ + X: x, + Y: y, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + + if got, want := m.cursor, 1; got != want { + t.Fatalf("cursor after wheel down = %d, want %d", got, want) + } +} + +func TestHandleMouseClickResultSelectsAndQuits(t *testing.T) { + t.Parallel() + + m := testMouseModel(4, 5) + x, y := testMouseBodyCell(m, 2, testMouseFirstResultBodyY(m)+1) + + _, cmd := m.handleMouse(tea.MouseMsg{ + X: x, + Y: y, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + + if got, want := m.cursor, 1; got != want { + t.Fatalf("cursor after result click = %d, want %d", got, want) + } + + if got, want := m.Selected(), "command 1"; got != want { + t.Fatalf("Selected() after result click = %q, want %q", got, want) + } + + if !m.quitting { + t.Fatal("quitting after result click = false, want true") + } + + if cmd == nil { + t.Fatal("mouse result click returned nil command, want tea.Quit") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("mouse result click command = %T, want tea.QuitMsg", msg) + } +} + +func TestHandleMouseHoverSelectsResult(t *testing.T) { + t.Parallel() + + m := testMouseModel(5, 6) + x, y := testMouseBodyCell(m, 2, testMouseFirstResultBodyY(m)+2) + + _, _ = m.handleMouse(tea.MouseMsg{ + X: x, + Y: y, + Button: tea.MouseButtonNone, + Action: tea.MouseActionMotion, + }) + + if got, want := m.cursor, 2; got != want { + t.Fatalf("cursor after result hover = %d, want %d", got, want) + } + + if m.Selected() != "" { + t.Fatalf("Selected() after result hover = %q, want empty", m.Selected()) + } +} + +func TestHandleMouseFooterShortcutCyclesMode(t *testing.T) { + t.Parallel() + + m := testMouseModel(2, 4) + m.width = 200 + m.input.Width = max(m.width-4, 1) + m.terminalHeight = m.viewHeight() + 3 + x, y := testMouseBodyCell(m, testMouseFooterShortcutBodyX(t, m, footerShortcutModeNext), m.footerBodyY()) + + _, cmd := m.handleMouse(tea.MouseMsg{ + X: x, + Y: y, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + + if cmd != nil { + t.Fatalf("mode shortcut returned command %T, want nil", cmd) + } + + if got, want := m.mode, match.ModeGlob; got != want { + t.Fatalf("mode after footer shortcut click = %v, want %v", got, want) + } +} + +func TestHandleMouseInputClickMovesCursor(t *testing.T) { + t.Parallel() + + m := testMouseModel(0, 4) + m.input.SetValue("abcdef") + m.input.SetCursor(0) + + bounds, ok := m.mouseInputBounds() + if !ok { + t.Fatal("mouseInputBounds() = false, want true") + } + + x, y := testMouseBodyCell(m, bounds.x+3, bounds.y) + _, cmd := m.handleMouse(tea.MouseMsg{ + X: x, + Y: y, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + + if cmd != nil { + t.Fatalf("input click returned command %T, want nil", cmd) + } + + if got, want := m.input.Position(), 3; got != want { + t.Fatalf("input cursor after click = %d, want %d", got, want) + } +} + +func testMouseModel(entryCount int, height int) *Model { + cfg := config.Default() + m := NewModel(cfg, nil, "", "", height, false, "") + m.loadingHistory = false + m.historyComplete = true + m.terminalHeight = m.viewHeight() + 3 + + m.displayEntries = make([]history.ScoredEntry, entryCount) + for i := range entryCount { + m.displayEntries[i] = history.ScoredEntry{ + Entry: db.HistoryEntry{ + ID: int64(i + 1), + Command: fmt.Sprintf("command %d", i), + }, + } + } + + return m +} + +func testMouseBodyCell(m *Model, bodyX int, bodyY int) (int, int) { + return 1 + panelPaddingX + bodyX, m.viewOriginY() + 1 + panelPaddingY + bodyY +} + +func testMouseFirstResultBodyY(m *Model) int { + headerRows := resultsHeaderRows + if m.height <= resultsHeaderRows { + headerRows = 0 + } + + return m.inputRows() + headerRows +} + +func testMouseFooterShortcutBodyX(t *testing.T, m *Model, action footerShortcutAction) int { + t.Helper() + + x := 1 + for _, shortcut := range m.footerShortcuts() { + if shortcut.action == action { + return x + } + + x += m.footerShortcutWidth(shortcut) + 2 + } + + t.Fatalf("footer shortcut action %v not found", action) + + return 0 +} diff --git a/internal/tui/view.go b/internal/tui/view.go index def4d94..618cc80 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -795,29 +795,11 @@ func (m *Model) renderFooterLeft() string { return m.footerCache.left } - keys := []struct { - key string - desc string - }{ - {m.cfg.Keys.Up + "/" + m.cfg.Keys.Down, "nav"}, - {m.cfg.Keys.Accept, "select"}, - {m.cfg.Keys.Cancel, "cancel"}, - {m.cfg.Keys.ModeNext, "mode"}, - {m.cfg.Keys.ToggleCWD, "cwd"}, - {m.cfg.Keys.ToggleDedupe, "dedup"}, - {m.cfg.Keys.Help, "help"}, - } - - parts := make([]string, 0, len(keys)+1) - for _, k := range keys { - key := m.styles.HelpKey.Render(k.key) - desc := m.styles.HelpDesc.Render(k.desc) - parts = append(parts, key+" "+desc) - } - - if showPreviewHint { - key := m.styles.HelpKey.Render(m.cfg.Keys.PreviewCommand) - desc := m.styles.HelpDesc.Render("preview") + shortcuts := m.footerShortcuts() + parts := make([]string, 0, len(shortcuts)) + for _, shortcut := range shortcuts { + key := m.styles.HelpKey.Render(shortcut.key) + desc := m.styles.HelpDesc.Render(shortcut.desc) parts = append(parts, key+" "+desc) }