diff --git a/README.md b/README.md index 74299d6..f743b59 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,11 @@ Compact footers show only the first configured key for each action. The contextual help popup shows all alternatives and is the authoritative in-app reference. +Set `mouse = true` to enable mouse-wheel scrolling. Each wheel event moves the +focused pane by three items or rendered lines. Mouse reporting remains disabled +by default so normal terminal text selection is unchanged; with mouse reporting +enabled, terminals commonly require holding Shift while selecting text. + The PR description editor defaults to Vim-style modal editing, including Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a @@ -249,6 +254,7 @@ repository = "" # optional "owner/repository" show_all = false # requires repository limit = 50 # 1-1000 endpoint = "https://api.github.com/graphql" +mouse = false # opt in to accelerated mouse-wheel scrolling mascot = false # show the optional Difflet terminal mascot mascot_expressive = false # allow emotional Difflet expressions mascot_animated = false # allow brief state-driven motion diff --git a/TODO.md b/TODO.md index e6a4893..ace1b5b 100644 --- a/TODO.md +++ b/TODO.md @@ -120,8 +120,7 @@ editing are already implemented. even when their key is forgotten or unbound. - Audit screen-reader behavior beyond no-color/high-contrast themes, including focus announcements, status symbols, popup ordering, and live refreshes. -- Add optional mouse selection/scrolling without changing keyboard-first - defaults. +- Add optional mouse selection. - Make relative/absolute timestamp display and timezone configurable. ## Testing and maintainability diff --git a/config.go b/config.go index 54c13ea..65366f1 100644 --- a/config.go +++ b/config.go @@ -31,6 +31,7 @@ type Config struct { ShowAll bool `toml:"show_all"` Limit int `toml:"limit"` Endpoint string `toml:"endpoint"` + Mouse bool `toml:"mouse"` Mascot bool `toml:"mascot"` MascotExpressive bool `toml:"mascot_expressive"` MascotAnimated bool `toml:"mascot_animated"` @@ -104,6 +105,7 @@ func defaultConfig() Config { RefreshInterval: configDuration{10 * time.Second}, Limit: 50, Endpoint: "https://api.github.com/graphql", + Mouse: false, Mascot: false, MascotExpressive: false, MascotAnimated: false, diff --git a/config_test.go b/config_test.go index 5f5b185..a65f628 100644 --- a/config_test.go +++ b/config_test.go @@ -17,6 +17,7 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) { want := defaultConfig() if got.Theme != want.Theme || got.RefreshInterval.Duration != want.RefreshInterval.Duration || + got.Mouse != want.Mouse || got.Paths.Scroll != want.Paths.Scroll || got.Display.FoldResolved != want.Display.FoldResolved || got.Display.CompactReviews != want.Display.CompactReviews || @@ -34,6 +35,7 @@ repository = "owner/repo" show_all = true limit = 75 endpoint = "https://github.example.com/api/graphql" +mouse = true mascot = true mascot_expressive = true mascot_animated = true @@ -77,6 +79,7 @@ up = ["ctrl+k"] } if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || + !got.Mouse || !got.Mascot || !got.MascotExpressive || !got.MascotAnimated || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.DashboardMode != "hotkey" || diff --git a/main.go b/main.go index fea7e86..330e427 100644 --- a/main.go +++ b/main.go @@ -204,10 +204,16 @@ func main() { ) cursorOutput := newTerminalCursorOutput(os.Stdout) app.cursorOutput = cursorOutput - if _, err := tea.NewProgram( - app, + programOptions := []tea.ProgramOption{ tea.WithAltScreen(), tea.WithOutput(cursorOutput), + } + if config.Mouse { + programOptions = append(programOptions, tea.WithMouseCellMotion()) + } + if _, err := tea.NewProgram( + app, + programOptions..., ).Run(); err != nil { exitf("run TUI: %v", err) } diff --git a/markdown.go b/markdown.go index 55ed312..47cc929 100644 --- a/markdown.go +++ b/markdown.go @@ -13,6 +13,7 @@ import ( ) var commentMarkdownRenderers sync.Map +var commentMarkdownLines = newMarkdownLineCache(512) var quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) var markdownStyleName = "dark" var renderedMentionPattern = regexp.MustCompile( @@ -20,12 +21,63 @@ var renderedMentionPattern = regexp.MustCompile( ) var sgrPattern = regexp.MustCompile(`\x1b\[[0-9:;]*m`) +type markdownLineCacheKey struct { + markdown string + width int +} + +type markdownLineCache struct { + mu sync.Mutex + limit int + entries map[markdownLineCacheKey][]string + order []markdownLineCacheKey +} + +func newMarkdownLineCache(limit int) *markdownLineCache { + return &markdownLineCache{ + limit: limit, entries: make(map[markdownLineCacheKey][]string), + } +} + +func (c *markdownLineCache) get(key markdownLineCacheKey) ([]string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + lines, ok := c.entries[key] + return append([]string(nil), lines...), ok +} + +func (c *markdownLineCache) put(key markdownLineCacheKey, lines []string) []string { + c.mu.Lock() + defer c.mu.Unlock() + if cached, ok := c.entries[key]; ok { + return append([]string(nil), cached...) + } + if len(c.entries) >= c.limit { + delete(c.entries, c.order[0]) + c.order = c.order[1:] + } + c.entries[key] = append([]string(nil), lines...) + c.order = append(c.order, key) + return append([]string(nil), lines...) +} + +func (c *markdownLineCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.entries = make(map[markdownLineCacheKey][]string) + c.order = nil +} + func renderCommentMarkdown(markdown string, width int) []string { if strings.TrimSpace(markdown) == "" { return nil } width = max(10, width) markdown = normalizeGitHubAlerts(markdown) + cacheKey := markdownLineCacheKey{markdown: markdown, width: width} + if lines, ok := commentMarkdownLines.get(cacheKey); ok { + return lines + } var ( result []string block []string @@ -64,7 +116,7 @@ func renderCommentMarkdown(markdown string, width int) []string { block = append(block, content) } flush() - return trimMarkdownLines(result) + return commentMarkdownLines.put(cacheKey, trimMarkdownLines(result)) } func renderMarkdownFragment(markdown string, width int) []string { diff --git a/markdown_test.go b/markdown_test.go index 70f0ee9..7923d19 100644 --- a/markdown_test.go +++ b/markdown_test.go @@ -23,6 +23,28 @@ func TestCommentMarkdownDistinguishesQuoteAndReply(t *testing.T) { } } +func TestCommentMarkdownCacheReturnsIndependentLines(t *testing.T) { + commentMarkdownLines.clear() + t.Cleanup(commentMarkdownLines.clear) + const body = "> Cached quote\n\n```go\nprintln(\"cached\")\n```" + + first := renderCommentMarkdown(body, 60) + if len(first) == 0 { + t.Fatal("cached Markdown rendered no lines") + } + first[0] = "mutated by caller" + second := renderCommentMarkdown(body, 60) + if second[0] == first[0] { + t.Fatal("caller mutation changed cached Markdown lines") + } + + key := markdownLineCacheKey{markdown: normalizeGitHubAlerts(body), width: 60} + cached, ok := commentMarkdownLines.get(key) + if !ok || len(cached) == 0 { + t.Fatal("rendered Markdown was not cached") + } +} + func TestCommentMarkdownStylesInlineCode(t *testing.T) { defer applyTheme("dark") if err := applyTheme("dark"); err != nil { diff --git a/theme.go b/theme.go index 400b2dc..22ce37d 100644 --- a/theme.go +++ b/theme.go @@ -101,6 +101,7 @@ func applyTheme(name string, custom ...CustomThemeConfig) error { } currentThemeName = name commentMarkdownRenderers.Clear() + commentMarkdownLines.clear() return nil } diff --git a/tui.go b/tui.go index 52a60e6..c33cde7 100644 --- a/tui.go +++ b/tui.go @@ -34,6 +34,8 @@ const ( type tickMsg time.Time type pathTickMsg time.Time +const mouseWheelScrollStep = 3 + type writeMode int const ( @@ -703,6 +705,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if tick, ok := msg.(diffletTickMsg); ok { return m, m.difflet.update(tick) } + if mouse, ok := msg.(tea.MouseMsg); ok && m.handleMouseWheel(mouse) { + return m, nil + } if m.aiMode != aiNone { if updated, command, handled := m.updateAI(msg); handled { return updated, command @@ -1887,6 +1892,49 @@ func (m *App) page(direction int) { m.move(direction * max(3, m.height/2)) } +func (m *App) handleMouseWheel(message tea.MouseMsg) bool { + if message.Action != tea.MouseActionPress { + return false + } + direction := 0 + switch message.Button { + case tea.MouseButtonWheelUp: + direction = -1 + case tea.MouseButtonWheelDown: + direction = 1 + default: + return false + } + delta := direction * mouseWheelScrollStep + if m.helpVisible { + m.helpScroll = clamp(m.helpScroll+delta, 0, m.helpMaxScroll()) + return true + } + if m.aiMode == aiConfirm { + m.aiPreviewScroll = max(0, m.aiPreviewScroll+delta) + return true + } + if m.aiMode != aiNone && m.aiMode != aiDiscussion { + return true + } + switch m.screen { + case dashboardScreen: + m.scrollDashboard(delta) + case healthScreen: + m.healthScroll = clamp(m.healthScroll+delta, 0, m.healthMaxScroll()) + case threadScreen: + if m.focus == threadDetailPane || m.writeMode == writeReply || + m.aiMode == aiDiscussion { + m.scrollDetail(delta) + } else { + m.move(delta) + } + case prScreen: + m.move(delta) + } + return true +} + func (m *App) scrollDetail(delta int) { m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll()) m.acknowledgeVisibleUnread() diff --git a/tui_test.go b/tui_test.go index 5ea1ac7..bf3e6d7 100644 --- a/tui_test.go +++ b/tui_test.go @@ -1267,6 +1267,79 @@ func TestLongThreadReplyComposerIsVisibleWithDifflet(t *testing.T) { } } +func TestMouseWheelScrollsFocusedPaneByThree(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, time.Second) + m.screen, m.loading, m.width, m.height = threadScreen, false, 60, 12 + m.listHidden, m.focus = true, threadDetailPane + m.details = PRDetails{ + PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"}, + Threads: []ReviewThread{{ + ID: "thread", Path: "main.go", + Comments: []ReviewComment{{ + ID: "comment", Author: "reviewer", + Body: strings.Repeat("A long discussion line. ", 80), + }}, + }}, + } + + updated, _ := m.Update(tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + m = updated.(App) + if m.scroll != mouseWheelScrollStep { + t.Fatalf("wheel down scrolled %d lines, want %d", m.scroll, mouseWheelScrollStep) + } + + updated, _ = m.Update(tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + }) + m = updated.(App) + if m.scroll != 0 { + t.Fatalf("wheel up did not return to the top: %d", m.scroll) + } + + m.writeMode, m.writeThreadID = writeReply, "thread" + updated, _ = m.Update(tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + m = updated.(App) + if m.scroll != mouseWheelScrollStep { + t.Fatalf("reply-mode wheel down scrolled %d lines, want %d", + m.scroll, mouseWheelScrollStep) + } + + m.scroll = max(0, m.detailMaxScroll()-1) + updated, _ = m.Update(tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + m = updated.(App) + if m.scroll != m.detailMaxScroll() { + t.Fatalf("wheel scrolling exceeded or missed the lower bound: %d/%d", + m.scroll, m.detailMaxScroll()) + } + + m.writeMode, m.writeThreadID = writeNone, "" + m.focus, m.threadIndex = threadListPane, 0 + for index := 1; index < 8; index++ { + m.details.Threads = append(m.details.Threads, ReviewThread{ + ID: fmt.Sprintf("thread-%d", index), + }) + } + updated, _ = m.Update(tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + m = updated.(App) + if m.threadIndex != mouseWheelScrollStep { + t.Fatalf("thread-list wheel moved %d items, want %d", + m.threadIndex, mouseWheelScrollStep) + } +} + func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) { service := &recordingService{} m := NewApp(service, "o", "r", false, 50, time.Second) diff --git a/version.go b/version.go index 70fb90e..72ff270 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const dipleVersion = "0.1.3" +const dipleVersion = "0.2.0"