feat(tui): mouse-wheel scrolling for the dashboard viewport via SGR mouse reporting

This commit is contained in:
iamtoruk 2026-08-10 04:41:34 -07:00
parent 7b6757504f
commit cf281a2fb1
2 changed files with 66 additions and 0 deletions

View file

@ -1067,12 +1067,33 @@ function DashboardContent({ projects, period, columns, maxContentWidth, activePr
)
}
/// Sum of wheel scroll deltas in a raw stdin chunk under SGR mouse reporting
/// (DECSET 1006): button 64 is wheel-up, 65 wheel-down, three lines per tick.
/// Every other mouse event (clicks, drags, wheel with modifiers) contributes
/// nothing. Exported for tests.
export function wheelDelta(chunk: string): number {
let delta = 0
for (const m of chunk.matchAll(/\x1b\[<(64|65);\d+;\d+[Mm]/g)) {
delta += m[1] === '64' ? -WHEEL_LINES_PER_TICK : WHEEL_LINES_PER_TICK
}
return delta
}
const WHEEL_LINES_PER_TICK = 3
const MOUSE_TRACKING_ON = '\x1b[?1000h\x1b[?1006h'
const MOUSE_TRACKING_OFF = '\x1b[?1006l\x1b[?1000l'
function ScrollableViewport({ children, width, lineScroll = true }: { children: React.ReactNode; width: number; lineScroll?: boolean }) {
const { rows } = useWindowSize()
const height = Math.max(1, rows - 1)
const contentRef = useRef<DOMElement>(null)
const [maxOffset, setMaxOffset] = useState(0)
const [offset, setOffset] = useState(0)
// The stdin listener below outlives renders; it reads the current bound
// through a ref so scrolling never re-subscribes (and never re-emits the
// tracking enable sequence).
const maxOffsetRef = useRef(0)
maxOffsetRef.current = maxOffset
useLayoutEffect(() => {
if (!contentRef.current) return
@ -1081,6 +1102,23 @@ function ScrollableViewport({ children, width, lineScroll = true }: { children:
setOffset(current => Math.min(current, nextMaxOffset))
})
// Mouse-wheel scrolling via SGR mouse reporting. Known tradeoff: while
// tracking is on, click-drag text selection needs Shift held in most
// terminals. Tracking is disabled again on unmount (view switches, q).
useEffect(() => {
if (!process.stdout.isTTY || !process.stdin.isTTY) return
process.stdout.write(MOUSE_TRACKING_ON)
const onData = (data: Buffer) => {
const delta = wheelDelta(data.toString('utf8'))
if (delta !== 0) setOffset(current => Math.max(0, Math.min(current + delta, maxOffsetRef.current)))
}
process.stdin.on('data', onData)
return () => {
process.stdin.off('data', onData)
process.stdout.write(MOUSE_TRACKING_OFF)
}
}, [])
useInput((_input, key) => {
if (lineScroll && key.downArrow) setOffset(current => Math.min(current + 1, maxOffset))
else if (lineScroll && key.upArrow) setOffset(current => Math.max(current - 1, 0))

View file

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { wheelDelta } from '../src/dashboard.js'
describe('wheelDelta', () => {
it('scrolls down three lines per wheel-down tick (button 65)', () => {
expect(wheelDelta('\x1b[<65;10;5M')).toBe(3)
})
it('scrolls up three lines per wheel-up tick (button 64)', () => {
expect(wheelDelta('\x1b[<64;10;5M')).toBe(-3)
})
it('sums multiple ticks in one chunk, mixed directions', () => {
expect(wheelDelta('\x1b[<65;1;1M\x1b[<65;1;1M\x1b[<64;1;1M')).toBe(3)
})
it('ignores clicks, releases, drags, and modified wheel events', () => {
// press, release, drag, shift+wheel (button 69), ctrl+wheel (80)
expect(wheelDelta('\x1b[<0;12;7M\x1b[<0;12;7m\x1b[<32;13;7M\x1b[<69;1;1M\x1b[<80;1;1M')).toBe(0)
})
it('ignores plain keyboard input and partial sequences', () => {
expect(wheelDelta('q')).toBe(0)
expect(wheelDelta('\x1b[B')).toBe(0)
expect(wheelDelta('\x1b[<65;10')).toBe(0)
})
})