fix: sluggish mouse scroll

This commit is contained in:
2026-07-30 14:57:16 +02:00
parent 21d44ea3a1
commit b24669e604
11 changed files with 218 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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" ||

10
main.go
View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -101,6 +101,7 @@ func applyTheme(name string, custom ...CustomThemeConfig) error {
}
currentThemeName = name
commentMarkdownRenderers.Clear()
commentMarkdownLines.clear()
return nil
}

48
tui.go
View File

@@ -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()

View File

@@ -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)

View File

@@ -1,3 +1,3 @@
package main
const dipleVersion = "0.1.3"
const dipleVersion = "0.2.0"