Skip to content

fix(tui): decouple mouse scroll from input history via timing-based debounce#85

Merged
yishuiliunian merged 2 commits intomainfrom
fix/scroll-debounce
Apr 5, 2026
Merged

fix(tui): decouple mouse scroll from input history via timing-based debounce#85
yishuiliunian merged 2 commits intomainfrom
fix/scroll-debounce

Conversation

@yishuiliunian
Copy link
Copy Markdown
Contributor

Summary

  • xterm alternate scroll converts mouse wheel → Up/Down arrow keys, causing an unresolvable conflict between content scrolling and history navigation (6 prior fix attempts)
  • Introduces a 30 ms debounce state machine that distinguishes mouse wheel bursts (< 5 ms gap) from keyboard presses (> 20 ms gap)
  • Mouse wheel → content scroll; keyboard Up/Down → history; multiline cursor → immediate; Ctrl+P/N → always history

Changes

  • New: crates/loopal-tui/src/input/scroll_debounce.rs — debounce state machine (Idle → Pending → Scrolling)
  • New: crates/loopal-tui/tests/suite/scroll_burst_test.rs — 12 burst/stale/mixed-direction tests
  • Modified: input/mod.rs — integrate debounce into handle_input_mode_key, add discard_pending on early-return paths
  • Modified: event.rs / actions.rsArrowDebounceTimeout event + StartArrowDebounce action
  • Modified: key_dispatch.rs — spawn 30 ms timer on StartArrowDebounce
  • Modified: tui_loop.rs — handle timeout event + tick-based Scrolling expiry
  • Modified: app/mod.rsarrow_debounce field on App
  • Modified: 3 test files adapted for debounce behavior

Test plan

  • CI passes (clippy zero warnings, rustfmt, all tests)

…ebounce

xterm alternate scroll (\x1b[?1007h) converts mouse wheel events into
Up/Down arrow keys, creating an unresolvable conflict between content
scrolling and history navigation. Previous attempts (#6, #13, #30, #42,
#79) toggled priority chains without a lasting fix because the two
event sources share the same key codes.

Resolve by introducing a 30 ms debounce state machine that exploits
the timing difference between mouse wheel bursts (<5 ms between events)
and keyboard presses (>20 ms):

  Idle → Pending (defer 30 ms) → Scrolling (burst confirmed)
                                → History   (timer expired / other key)

- Mouse wheel: rapid-fire Up/Down detected as burst → scroll content
- Keyboard Up/Down: single isolated event → history navigation
- Multiline cursor: bypasses debounce entirely (immediate response)
- Ctrl+P/N: dedicated history bindings, always immediate
- Global/modal/autocomplete keys: discard pending debounce (prevents
  stale timer from polluting state after Ctrl+C, paste, etc.)

Stale timer and dropped-tick resilience:
- Pending state checks elapsed time on second arrow to handle delayed
  timers; stale pending is flushed as history before starting new
- Scrolling state has lazy 150 ms expiry check in addition to tick-
  based cleanup, so dropped ticks don't leave stale scroll mode
@yishuiliunian yishuiliunian merged commit fa85fa9 into main Apr 5, 2026
3 checks passed
@yishuiliunian yishuiliunian deleted the fix/scroll-debounce branch April 5, 2026 09:32
yishuiliunian added a commit that referenced this pull request Apr 6, 2026
…nd absolute position indexing (#85)

The 30ms async timer debounce had race conditions between ArrowDebounceTimeout
and event batch processing, causing scroll/history misfires. The tail-window
rendering model drifted during output because window_size depended on
scroll_offset, creating a feedback loop.

Changes:
- Delete scroll_debounce.rs (197 lines) and all timer machinery
- Batch detection in tui_loop: ≥2 arrows in one batch = mouse wheel → scroll;
  1 arrow = keyboard → history. No timers, no races.
- ContentScroll struct encapsulates offset + prev_total + LineCache with
  reset()/to_bottom()/scroll_up()/scroll_down() methods
- Absolute position indexing via slice() replaces tail() feedback loop;
  offset auto-compensated on content growth while pinned
- E2E regression tests for both bugs + unit tests for compensation logic
yishuiliunian added a commit that referenced this pull request Apr 6, 2026
…nd absolute position indexing (#85) (#88)

The 30ms async timer debounce had race conditions between ArrowDebounceTimeout
and event batch processing, causing scroll/history misfires. The tail-window
rendering model drifted during output because window_size depended on
scroll_offset, creating a feedback loop.

Changes:
- Delete scroll_debounce.rs (197 lines) and all timer machinery
- Batch detection in tui_loop: ≥2 arrows in one batch = mouse wheel → scroll;
  1 arrow = keyboard → history. No timers, no races.
- ContentScroll struct encapsulates offset + prev_total + LineCache with
  reset()/to_bottom()/scroll_up()/scroll_down() methods
- Absolute position indexing via slice() replaces tail() feedback loop;
  offset auto-compensated on content growth while pinned
- E2E regression tests for both bugs + unit tests for compensation logic
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant