fix: sluggish mouse scroll
This commit is contained in:
@@ -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
|
||||
|
||||
3
TODO.md
3
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
10
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)
|
||||
}
|
||||
|
||||
54
markdown.go
54
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1
theme.go
1
theme.go
@@ -101,6 +101,7 @@ func applyTheme(name string, custom ...CustomThemeConfig) error {
|
||||
}
|
||||
currentThemeName = name
|
||||
commentMarkdownRenderers.Clear()
|
||||
commentMarkdownLines.clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
48
tui.go
48
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()
|
||||
|
||||
73
tui_test.go
73
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)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
const dipleVersion = "0.1.3"
|
||||
const dipleVersion = "0.2.0"
|
||||
|
||||
Reference in New Issue
Block a user