From 60fe5acd606165d75a294ead6cee7a7f81bc340b Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 13 Nov 2025 11:16:23 -0800 Subject: [PATCH] feat(ui) support animated page up/down, fn-up/down and end+home (#13012) --- docs/cli/keyboard-shortcuts.md | 11 ++ packages/cli/src/config/keyBindings.ts | 33 +++++ .../components/shared/ScrollableList.test.tsx | 93 +++++++++++++ .../ui/components/shared/ScrollableList.tsx | 123 ++++++++++++++++-- packages/cli/src/ui/keyMatchers.test.ts | 38 ++++++ 5 files changed, 289 insertions(+), 9 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index cb312d5f0a..f44a1c7d14 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -35,6 +35,17 @@ available combinations. | -------------------------------------------- | ---------- | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | +#### Scrolling + +| Action | Keys | +| ------------------------ | -------------------- | +| Scroll content up. | `Shift + Up Arrow` | +| Scroll content down. | `Shift + Down Arrow` | +| Scroll to the top. | `Home` | +| Scroll to the bottom. | `End` | +| Scroll up by one page. | `Page Up` | +| Scroll down by one page. | `Page Down` | + #### History & Search | Action | Keys | diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index f166d0427d..42b1ea334e 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -25,6 +25,14 @@ export enum Command { // Screen control CLEAR_SCREEN = 'clearScreen', + // Scrolling + SCROLL_UP = 'scrollUp', + SCROLL_DOWN = 'scrollDown', + SCROLL_HOME = 'scrollHome', + SCROLL_END = 'scrollEnd', + PAGE_UP = 'pageUp', + PAGE_DOWN = 'pageDown', + // History navigation HISTORY_UP = 'historyUp', HISTORY_DOWN = 'historyDown', @@ -120,6 +128,14 @@ export const defaultKeyBindings: KeyBindingConfig = { // Screen control [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], + // Scrolling + [Command.SCROLL_UP]: [{ key: 'up', shift: true }], + [Command.SCROLL_DOWN]: [{ key: 'down', shift: true }], + [Command.SCROLL_HOME]: [{ key: 'home' }], + [Command.SCROLL_END]: [{ key: 'end' }], + [Command.PAGE_UP]: [{ key: 'pageup' }], + [Command.PAGE_DOWN]: [{ key: 'pagedown' }], + // History navigation [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }], @@ -230,6 +246,17 @@ export const commandCategories: readonly CommandCategory[] = [ title: 'Screen Control', commands: [Command.CLEAR_SCREEN], }, + { + title: 'Scrolling', + commands: [ + Command.SCROLL_UP, + Command.SCROLL_DOWN, + Command.SCROLL_HOME, + Command.SCROLL_END, + Command.PAGE_UP, + Command.PAGE_DOWN, + ], + }, { title: 'History & Search', commands: [ @@ -298,6 +325,12 @@ export const commandDescriptions: Readonly> = { [Command.CLEAR_INPUT]: 'Clear all text in the input field.', [Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', + [Command.SCROLL_UP]: 'Scroll content up.', + [Command.SCROLL_DOWN]: 'Scroll content down.', + [Command.SCROLL_HOME]: 'Scroll to the top.', + [Command.SCROLL_END]: 'Scroll to the bottom.', + [Command.PAGE_UP]: 'Scroll up by one page.', + [Command.PAGE_DOWN]: 'Scroll down by one page.', [Command.HISTORY_UP]: 'Show the previous entry in history.', [Command.HISTORY_DOWN]: 'Show the next entry in history.', [Command.NAVIGATION_UP]: 'Move selection up in lists.', diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index dec86fa56f..3b0f9fab51 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -281,4 +281,97 @@ describe('ScrollableList Demo Behavior', () => { }); expect(lastFrame!()).not.toContain('[STICKY] Item 1'); }); + + describe('Keyboard Navigation', () => { + it('should handle scroll keys correctly', async () => { + let listRef: ScrollableListRef | null = null; + let lastFrame: () => string | undefined; + let stdin: { write: (data: string) => void }; + + const items = Array.from({ length: 50 }, (_, i) => ({ + id: String(i), + title: `Item ${i}`, + })); + + await act(async () => { + const result = render( + + + + + { + listRef = ref; + }} + data={items} + renderItem={({ item }) => {item.title}} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + /> + + + + , + ); + lastFrame = result.lastFrame; + stdin = result.stdin; + }); + + // Initial state + expect(lastFrame!()).toContain('Item 0'); + expect(listRef).toBeDefined(); + expect(listRef!.getScrollState()?.scrollTop).toBe(0); + + // Scroll Down (Shift+Down) -> \x1b[b + await act(async () => { + stdin.write('\x1b[b'); + }); + await waitFor(() => { + expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(0); + }); + + // Scroll Up (Shift+Up) -> \x1b[a + await act(async () => { + stdin.write('\x1b[a'); + }); + await waitFor(() => { + expect(listRef?.getScrollState()?.scrollTop).toBe(0); + }); + + // Page Down -> \x1b[6~ + await act(async () => { + stdin.write('\x1b[6~'); + }); + await waitFor(() => { + // Height is 10, so should scroll ~10 units + expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThanOrEqual(9); + }); + + // Page Up -> \x1b[5~ + await act(async () => { + stdin.write('\x1b[5~'); + }); + await waitFor(() => { + expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2); + }); + + // End -> \x1b[F + await act(async () => { + stdin.write('\x1b[F'); + }); + await waitFor(() => { + // Total 50 items, height 10. Max scroll ~40. + expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30); + }); + + // Home -> \x1b[H + await act(async () => { + stdin.write('\x1b[H'); + }); + await waitFor(() => { + expect(listRef?.getScrollState()?.scrollTop).toBe(0); + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index d0f2cb27b1..a274e1001c 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -10,13 +10,21 @@ import { useImperativeHandle, useCallback, useMemo, + useEffect, } from 'react'; import type React from 'react'; -import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js'; +import { + VirtualizedList, + type VirtualizedListRef, + SCROLL_TO_ITEM_END, +} from './VirtualizedList.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; import { Box, type DOMElement } from 'ink'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; + +const ANIMATION_FRAME_DURATION_MS = 33; type VirtualizedListProps = { data: T[]; @@ -79,15 +87,112 @@ function ScrollableList( const { scrollbarColor, flashScrollbar, scrollByWithAnimation } = useAnimatedScrollbar(hasFocus, scrollBy); + const smoothScrollState = useRef<{ + active: boolean; + start: number; + from: number; + to: number; + duration: number; + timer: NodeJS.Timeout | null; + }>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null }); + + const stopSmoothScroll = useCallback(() => { + if (smoothScrollState.current.timer) { + clearInterval(smoothScrollState.current.timer); + smoothScrollState.current.timer = null; + } + smoothScrollState.current.active = false; + }, []); + + useEffect(() => stopSmoothScroll, [stopSmoothScroll]); + + const smoothScrollTo = useCallback( + (targetScrollTop: number, duration: number = 200) => { + stopSmoothScroll(); + + const scrollState = virtualizedListRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + }; + const { + scrollTop: startScrollTop, + scrollHeight, + innerHeight, + } = scrollState; + + const maxScrollTop = Math.max(0, scrollHeight - innerHeight); + + let effectiveTarget = targetScrollTop; + if (targetScrollTop === SCROLL_TO_ITEM_END) { + effectiveTarget = maxScrollTop; + } + + const clampedTarget = Math.max( + 0, + Math.min(maxScrollTop, effectiveTarget), + ); + + smoothScrollState.current = { + active: true, + start: Date.now(), + from: startScrollTop, + to: clampedTarget, + duration, + timer: setInterval(() => { + const now = Date.now(); + const elapsed = now - smoothScrollState.current.start; + const progress = Math.min(elapsed / duration, 1); + + // Ease-in-out + const t = progress; + const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + + const current = + smoothScrollState.current.from + + (smoothScrollState.current.to - smoothScrollState.current.from) * + ease; + + if (progress >= 1) { + if (targetScrollTop === SCROLL_TO_ITEM_END) { + virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END); + } else { + virtualizedListRef.current?.scrollTo(Math.round(current)); + } + stopSmoothScroll(); + flashScrollbar(); + } else { + virtualizedListRef.current?.scrollTo(Math.round(current)); + } + }, ANIMATION_FRAME_DURATION_MS), + }; + }, + [stopSmoothScroll, flashScrollbar], + ); + useKeypress( (key: Key) => { - if (key.shift) { - if (key.name === 'up') { - scrollByWithAnimation(-1); - } - if (key.name === 'down') { - scrollByWithAnimation(1); - } + if (keyMatchers[Command.SCROLL_UP](key)) { + stopSmoothScroll(); + scrollByWithAnimation(-1); + } else if (keyMatchers[Command.SCROLL_DOWN](key)) { + stopSmoothScroll(); + scrollByWithAnimation(1); + } else if ( + keyMatchers[Command.PAGE_UP](key) || + keyMatchers[Command.PAGE_DOWN](key) + ) { + const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1; + const scrollState = getScrollState(); + const current = smoothScrollState.current.active + ? smoothScrollState.current.to + : scrollState.scrollTop; + const innerHeight = scrollState.innerHeight; + smoothScrollTo(current + direction * innerHeight); + } else if (keyMatchers[Command.SCROLL_HOME](key)) { + smoothScrollTo(0); + } else if (keyMatchers[Command.SCROLL_END](key)) { + smoothScrollTo(SCROLL_TO_ITEM_END); } }, { isActive: hasFocus }, @@ -103,7 +208,7 @@ function ScrollableList( hasFocus: hasFocusCallback, flashScrollbar, }), - [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], + [getScrollState, hasFocusCallback, flashScrollbar, scrollByWithAnimation], ); useScrollable(scrollableEntry, hasFocus); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index be028f5c02..caf1216579 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -33,6 +33,12 @@ describe('keyMatchers', () => { [Command.DELETE_WORD_BACKWARD]: (key: Key) => (key.ctrl || key.meta) && key.name === 'backspace', [Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l', + [Command.SCROLL_UP]: (key: Key) => key.name === 'up' && !!key.shift, + [Command.SCROLL_DOWN]: (key: Key) => key.name === 'down' && !!key.shift, + [Command.SCROLL_HOME]: (key: Key) => key.name === 'home', + [Command.SCROLL_END]: (key: Key) => key.name === 'end', + [Command.PAGE_UP]: (key: Key) => key.name === 'pageup', + [Command.PAGE_DOWN]: (key: Key) => key.name === 'pagedown', [Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p', [Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n', [Command.NAVIGATION_UP]: (key: Key) => key.name === 'up', @@ -141,6 +147,38 @@ describe('keyMatchers', () => { negative: [createKey('l'), createKey('k', { ctrl: true })], }, + // Scrolling + { + command: Command.SCROLL_UP, + positive: [createKey('up', { shift: true })], + negative: [createKey('up'), createKey('up', { ctrl: true })], + }, + { + command: Command.SCROLL_DOWN, + positive: [createKey('down', { shift: true })], + negative: [createKey('down'), createKey('down', { ctrl: true })], + }, + { + command: Command.SCROLL_HOME, + positive: [createKey('home')], + negative: [createKey('end')], + }, + { + command: Command.SCROLL_END, + positive: [createKey('end')], + negative: [createKey('home')], + }, + { + command: Command.PAGE_UP, + positive: [createKey('pageup'), createKey('pageup', { shift: true })], + negative: [createKey('pagedown'), createKey('up')], + }, + { + command: Command.PAGE_DOWN, + positive: [createKey('pagedown'), createKey('pagedown', { ctrl: true })], + negative: [createKey('pageup'), createKey('down')], + }, + // History navigation { command: Command.HISTORY_UP,