diff --git a/src/dashboard.tsx b/src/dashboard.tsx index efb5bb16..aa8cecef 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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(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)) diff --git a/tests/wheel-scroll.test.ts b/tests/wheel-scroll.test.ts new file mode 100644 index 00000000..8482b54f --- /dev/null +++ b/tests/wheel-scroll.test.ts @@ -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) + }) +})