diff options
| author | Miguel Solorio <[email protected]> | 2025-07-11 18:05:21 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-12 01:05:21 +0000 |
| commit | d89ccf2250256bb67cdd9acfde1b679f39ca1f95 (patch) | |
| tree | f301effcacf489b7bdb40b2d964a0eed44676b73 /packages/cli/src | |
| parent | 82bde578682fcd88b1ee9df053c9dd51c7b74522 (diff) | |
Add scrolling to theme dialog (#3895)
Co-authored-by: Jacob Richman <[email protected]>
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/ui/components/ThemeDialog.tsx | 28 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/shared/RadioButtonSelect.tsx | 193 |
2 files changed, 122 insertions, 99 deletions
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index ba49f8e3..e6c09225 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { Colors } from '../colors.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; @@ -60,19 +60,25 @@ export function ThemeDialog({ { label: 'System Settings', value: SettingScope.System }, ]; - const handleThemeSelect = (themeName: string) => { - onSelect(themeName, selectedScope); - }; + const handleThemeSelect = useCallback( + (themeName: string) => { + onSelect(themeName, selectedScope); + }, + [onSelect, selectedScope], + ); - const handleScopeHighlight = (scope: SettingScope) => { + const handleScopeHighlight = useCallback((scope: SettingScope) => { setSelectedScope(scope); setSelectInputKey(Date.now()); - }; + }, []); - const handleScopeSelect = (scope: SettingScope) => { - handleScopeHighlight(scope); - setFocusedSection('theme'); // Reset focus to theme section - }; + const handleScopeSelect = useCallback( + (scope: SettingScope) => { + handleScopeHighlight(scope); + setFocusedSection('theme'); // Reset focus to theme section + }, + [handleScopeHighlight], + ); const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>( 'theme', @@ -196,6 +202,7 @@ export function ThemeDialog({ onSelect={handleThemeSelect} onHighlight={onHighlight} isFocused={currenFocusedSection === 'theme'} + maxItemsToShow={8} /> {/* Scope Selection */} @@ -210,6 +217,7 @@ export function ThemeDialog({ onSelect={handleScopeSelect} onHighlight={handleScopeHighlight} isFocused={currenFocusedSection === 'scope'} + showScrollArrows={false} /> </Box> )} diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index fab0615c..c3829bb4 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -4,12 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { Text, Box } from 'ink'; -import SelectInput, { - type ItemProps as InkSelectItemProps, - type IndicatorProps as InkSelectIndicatorProps, -} from 'ink-select-input'; +import React, { useEffect, useState } from 'react'; +import { Text, Box, useInput } from 'ink'; import { Colors } from '../../colors.js'; /** @@ -20,6 +16,8 @@ export interface RadioSelectItem<T> { label: string; value: T; disabled?: boolean; + themeNameDisplay?: string; + themeTypeDisplay?: string; } /** @@ -28,115 +26,132 @@ export interface RadioSelectItem<T> { */ export interface RadioButtonSelectProps<T> { /** An array of items to display as radio options. */ - items: Array< - RadioSelectItem<T> & { - themeNameDisplay?: string; - themeTypeDisplay?: string; - } - >; - + items: Array<RadioSelectItem<T>>; /** The initial index selected */ initialIndex?: number; - /** Function called when an item is selected. Receives the `value` of the selected item. */ onSelect: (value: T) => void; - /** Function called when an item is highlighted. Receives the `value` of the selected item. */ onHighlight?: (value: T) => void; - /** Whether this select input is currently focused and should respond to input. */ isFocused?: boolean; + /** Whether to show the scroll arrows. */ + showScrollArrows?: boolean; + /** The maximum number of items to show at once. */ + maxItemsToShow?: number; } /** - * A specialized SelectInput component styled to look like radio buttons. - * It uses '◉' for selected and '○' for unselected items. + * A custom component that displays a list of items with radio buttons, + * supporting scrolling and keyboard navigation. * * @template T The type of the value associated with each radio item. */ export function RadioButtonSelect<T>({ items, - initialIndex, + initialIndex = 0, onSelect, onHighlight, - isFocused, // This prop indicates if the current RadioButtonSelect group is focused + isFocused, + showScrollArrows = true, + maxItemsToShow = 10, }: RadioButtonSelectProps<T>): React.JSX.Element { - const handleSelect = (item: RadioSelectItem<T>) => { - onSelect(item.value); - }; - const handleHighlight = (item: RadioSelectItem<T>) => { - if (onHighlight) { - onHighlight(item.value); - } - }; + const [activeIndex, setActiveIndex] = useState(initialIndex); + const [scrollOffset, setScrollOffset] = useState(0); - /** - * Custom indicator component displaying radio button style (◉/○). - * Color changes based on whether the item is selected and if its group is focused. - */ - function DynamicRadioIndicator({ - isSelected = false, - }: InkSelectIndicatorProps): React.JSX.Element { - return ( - <Box minWidth={2} flexShrink={0}> - <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}> - {isSelected ? '●' : '○'} - </Text> - </Box> + useEffect(() => { + const newScrollOffset = Math.max( + 0, + Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow), ); - } + if (activeIndex < scrollOffset) { + setScrollOffset(activeIndex); + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newScrollOffset); + } + }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); - /** - * Custom item component for displaying the label. - * Color changes based on whether the item is selected and if its group is focused. - * Now also handles displaying theme type with custom color. - */ - function CustomThemeItemComponent( - props: InkSelectItemProps, - ): React.JSX.Element { - const { isSelected = false, label } = props; - const itemWithThemeProps = props as typeof props & { - themeNameDisplay?: string; - themeTypeDisplay?: string; - disabled?: boolean; - }; + useInput( + (input, key) => { + if (input === 'k' || key.upArrow) { + const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; + setActiveIndex(newIndex); + onHighlight?.(items[newIndex]!.value); + } + if (input === 'j' || key.downArrow) { + const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; + setActiveIndex(newIndex); + onHighlight?.(items[newIndex]!.value); + } + if (key.return) { + onSelect(items[activeIndex]!.value); + } - let textColor = Colors.Foreground; - if (isSelected) { - textColor = Colors.AccentGreen; - } else if (itemWithThemeProps.disabled === true) { - textColor = Colors.Gray; - } + // Enable selection directly from number keys. + if (/^[1-9]$/.test(input)) { + const targetIndex = Number.parseInt(input, 10) - 1; + if (targetIndex >= 0 && targetIndex < visibleItems.length) { + const selectedItem = visibleItems[targetIndex]; + if (selectedItem) { + onSelect?.(selectedItem.value); + } + } + } + }, + { isActive: isFocused && items.length > 0 }, + ); - if ( - itemWithThemeProps.themeNameDisplay && - itemWithThemeProps.themeTypeDisplay - ) { - return ( - <Text color={textColor} wrap="truncate"> - {itemWithThemeProps.themeNameDisplay}{' '} - <Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text> + const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); + + return ( + <Box flexDirection="column"> + {showScrollArrows && ( + <Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}> + ▲ </Text> - ); - } + )} + {visibleItems.map((item, index) => { + const itemIndex = scrollOffset + index; + const isSelected = activeIndex === itemIndex; - return ( - <Text color={textColor} wrap="truncate"> - {label} - </Text> - ); - } + let textColor = Colors.Foreground; + if (isSelected) { + textColor = Colors.AccentGreen; + } else if (item.disabled) { + textColor = Colors.Gray; + } - initialIndex = initialIndex ?? 0; - return ( - <SelectInput - indicatorComponent={DynamicRadioIndicator} - itemComponent={CustomThemeItemComponent} - items={items} - initialIndex={initialIndex} - onSelect={handleSelect} - onHighlight={handleHighlight} - isFocused={isFocused} - /> + return ( + <Box key={item.label}> + <Box minWidth={2} flexShrink={0}> + <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}> + {isSelected ? '●' : '○'} + </Text> + </Box> + {item.themeNameDisplay && item.themeTypeDisplay ? ( + <Text color={textColor} wrap="truncate"> + {item.themeNameDisplay}{' '} + <Text color={Colors.Gray}>{item.themeTypeDisplay}</Text> + </Text> + ) : ( + <Text color={textColor} wrap="truncate"> + {item.label} + </Text> + )} + </Box> + ); + })} + {showScrollArrows && ( + <Text + color={ + scrollOffset + maxItemsToShow < items.length + ? Colors.Foreground + : Colors.Gray + } + > + ▼ + </Text> + )} + </Box> ); } |
