Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
28 changes: 28 additions & 0 deletions tests/wheel-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading