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
|
contextual help popup shows all alternatives and is the authoritative in-app
|
||||||
reference.
|
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
|
The PR description editor defaults to Vim-style modal editing, including
|
||||||
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
|
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
|
||||||
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
|
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
|
show_all = false # requires repository
|
||||||
limit = 50 # 1-1000
|
limit = 50 # 1-1000
|
||||||
endpoint = "https://api.github.com/graphql"
|
endpoint = "https://api.github.com/graphql"
|
||||||
|
mouse = false # opt in to accelerated mouse-wheel scrolling
|
||||||
mascot = false # show the optional Difflet terminal mascot
|
mascot = false # show the optional Difflet terminal mascot
|
||||||
mascot_expressive = false # allow emotional Difflet expressions
|
mascot_expressive = false # allow emotional Difflet expressions
|
||||||
mascot_animated = false # allow brief state-driven motion
|
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.
|
even when their key is forgotten or unbound.
|
||||||
- Audit screen-reader behavior beyond no-color/high-contrast themes, including
|
- Audit screen-reader behavior beyond no-color/high-contrast themes, including
|
||||||
focus announcements, status symbols, popup ordering, and live refreshes.
|
focus announcements, status symbols, popup ordering, and live refreshes.
|
||||||
- Add optional mouse selection/scrolling without changing keyboard-first
|
- Add optional mouse selection.
|
||||||
defaults.
|
|
||||||
- Make relative/absolute timestamp display and timezone configurable.
|
- Make relative/absolute timestamp display and timezone configurable.
|
||||||
|
|
||||||
## Testing and maintainability
|
## Testing and maintainability
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Config struct {
|
|||||||
ShowAll bool `toml:"show_all"`
|
ShowAll bool `toml:"show_all"`
|
||||||
Limit int `toml:"limit"`
|
Limit int `toml:"limit"`
|
||||||
Endpoint string `toml:"endpoint"`
|
Endpoint string `toml:"endpoint"`
|
||||||
|
Mouse bool `toml:"mouse"`
|
||||||
Mascot bool `toml:"mascot"`
|
Mascot bool `toml:"mascot"`
|
||||||
MascotExpressive bool `toml:"mascot_expressive"`
|
MascotExpressive bool `toml:"mascot_expressive"`
|
||||||
MascotAnimated bool `toml:"mascot_animated"`
|
MascotAnimated bool `toml:"mascot_animated"`
|
||||||
@@ -104,6 +105,7 @@ func defaultConfig() Config {
|
|||||||
RefreshInterval: configDuration{10 * time.Second},
|
RefreshInterval: configDuration{10 * time.Second},
|
||||||
Limit: 50,
|
Limit: 50,
|
||||||
Endpoint: "https://api.github.com/graphql",
|
Endpoint: "https://api.github.com/graphql",
|
||||||
|
Mouse: false,
|
||||||
Mascot: false,
|
Mascot: false,
|
||||||
MascotExpressive: false,
|
MascotExpressive: false,
|
||||||
MascotAnimated: false,
|
MascotAnimated: false,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
|
|||||||
want := defaultConfig()
|
want := defaultConfig()
|
||||||
if got.Theme != want.Theme ||
|
if got.Theme != want.Theme ||
|
||||||
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
||||||
|
got.Mouse != want.Mouse ||
|
||||||
got.Paths.Scroll != want.Paths.Scroll ||
|
got.Paths.Scroll != want.Paths.Scroll ||
|
||||||
got.Display.FoldResolved != want.Display.FoldResolved ||
|
got.Display.FoldResolved != want.Display.FoldResolved ||
|
||||||
got.Display.CompactReviews != want.Display.CompactReviews ||
|
got.Display.CompactReviews != want.Display.CompactReviews ||
|
||||||
@@ -34,6 +35,7 @@ repository = "owner/repo"
|
|||||||
show_all = true
|
show_all = true
|
||||||
limit = 75
|
limit = 75
|
||||||
endpoint = "https://github.example.com/api/graphql"
|
endpoint = "https://github.example.com/api/graphql"
|
||||||
|
mouse = true
|
||||||
mascot = true
|
mascot = true
|
||||||
mascot_expressive = true
|
mascot_expressive = true
|
||||||
mascot_animated = true
|
mascot_animated = true
|
||||||
@@ -77,6 +79,7 @@ up = ["ctrl+k"]
|
|||||||
}
|
}
|
||||||
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
|
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
|
||||||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
||||||
|
!got.Mouse ||
|
||||||
!got.Mascot || !got.MascotExpressive || !got.MascotAnimated ||
|
!got.Mascot || !got.MascotExpressive || !got.MascotAnimated ||
|
||||||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
||||||
got.Display.DashboardMode != "hotkey" ||
|
got.Display.DashboardMode != "hotkey" ||
|
||||||
|
|||||||
10
main.go
10
main.go
@@ -204,10 +204,16 @@ func main() {
|
|||||||
)
|
)
|
||||||
cursorOutput := newTerminalCursorOutput(os.Stdout)
|
cursorOutput := newTerminalCursorOutput(os.Stdout)
|
||||||
app.cursorOutput = cursorOutput
|
app.cursorOutput = cursorOutput
|
||||||
if _, err := tea.NewProgram(
|
programOptions := []tea.ProgramOption{
|
||||||
app,
|
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
tea.WithOutput(cursorOutput),
|
tea.WithOutput(cursorOutput),
|
||||||
|
}
|
||||||
|
if config.Mouse {
|
||||||
|
programOptions = append(programOptions, tea.WithMouseCellMotion())
|
||||||
|
}
|
||||||
|
if _, err := tea.NewProgram(
|
||||||
|
app,
|
||||||
|
programOptions...,
|
||||||
).Run(); err != nil {
|
).Run(); err != nil {
|
||||||
exitf("run TUI: %v", err)
|
exitf("run TUI: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
54
markdown.go
54
markdown.go
@@ -13,6 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var commentMarkdownRenderers sync.Map
|
var commentMarkdownRenderers sync.Map
|
||||||
|
var commentMarkdownLines = newMarkdownLineCache(512)
|
||||||
var quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
|
var quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
|
||||||
var markdownStyleName = "dark"
|
var markdownStyleName = "dark"
|
||||||
var renderedMentionPattern = regexp.MustCompile(
|
var renderedMentionPattern = regexp.MustCompile(
|
||||||
@@ -20,12 +21,63 @@ var renderedMentionPattern = regexp.MustCompile(
|
|||||||
)
|
)
|
||||||
var sgrPattern = regexp.MustCompile(`\x1b\[[0-9:;]*m`)
|
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 {
|
func renderCommentMarkdown(markdown string, width int) []string {
|
||||||
if strings.TrimSpace(markdown) == "" {
|
if strings.TrimSpace(markdown) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
width = max(10, width)
|
width = max(10, width)
|
||||||
markdown = normalizeGitHubAlerts(markdown)
|
markdown = normalizeGitHubAlerts(markdown)
|
||||||
|
cacheKey := markdownLineCacheKey{markdown: markdown, width: width}
|
||||||
|
if lines, ok := commentMarkdownLines.get(cacheKey); ok {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
var (
|
var (
|
||||||
result []string
|
result []string
|
||||||
block []string
|
block []string
|
||||||
@@ -64,7 +116,7 @@ func renderCommentMarkdown(markdown string, width int) []string {
|
|||||||
block = append(block, content)
|
block = append(block, content)
|
||||||
}
|
}
|
||||||
flush()
|
flush()
|
||||||
return trimMarkdownLines(result)
|
return commentMarkdownLines.put(cacheKey, trimMarkdownLines(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMarkdownFragment(markdown string, width int) []string {
|
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) {
|
func TestCommentMarkdownStylesInlineCode(t *testing.T) {
|
||||||
defer applyTheme("dark")
|
defer applyTheme("dark")
|
||||||
if err := applyTheme("dark"); err != nil {
|
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
|
currentThemeName = name
|
||||||
commentMarkdownRenderers.Clear()
|
commentMarkdownRenderers.Clear()
|
||||||
|
commentMarkdownLines.clear()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
tui.go
48
tui.go
@@ -34,6 +34,8 @@ const (
|
|||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
type pathTickMsg time.Time
|
type pathTickMsg time.Time
|
||||||
|
|
||||||
|
const mouseWheelScrollStep = 3
|
||||||
|
|
||||||
type writeMode int
|
type writeMode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -703,6 +705,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if tick, ok := msg.(diffletTickMsg); ok {
|
if tick, ok := msg.(diffletTickMsg); ok {
|
||||||
return m, m.difflet.update(tick)
|
return m, m.difflet.update(tick)
|
||||||
}
|
}
|
||||||
|
if mouse, ok := msg.(tea.MouseMsg); ok && m.handleMouseWheel(mouse) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.aiMode != aiNone {
|
if m.aiMode != aiNone {
|
||||||
if updated, command, handled := m.updateAI(msg); handled {
|
if updated, command, handled := m.updateAI(msg); handled {
|
||||||
return updated, command
|
return updated, command
|
||||||
@@ -1887,6 +1892,49 @@ func (m *App) page(direction int) {
|
|||||||
m.move(direction * max(3, m.height/2))
|
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) {
|
func (m *App) scrollDetail(delta int) {
|
||||||
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
|
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
|
||||||
m.acknowledgeVisibleUnread()
|
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) {
|
func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
|
||||||
service := &recordingService{}
|
service := &recordingService{}
|
||||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const dipleVersion = "0.1.3"
|
const dipleVersion = "0.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user