From d89ccf2250256bb67cdd9acfde1b679f39ca1f95 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Fri, 11 Jul 2025 18:05:21 -0700 Subject: [PATCH] Add scrolling to theme dialog (#3895) Co-authored-by: Jacob Richman --- .../cli/src/ui/components/ThemeDialog.tsx | 28 ++- .../components/shared/RadioButtonSelect.tsx | 195 ++++++++++-------- 2 files changed, 123 insertions(+), 100 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} /> )} 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 { label: string; value: T; disabled?: boolean; + themeNameDisplay?: string; + themeTypeDisplay?: string; } /** @@ -28,115 +26,132 @@ export interface RadioSelectItem { */ export interface RadioButtonSelectProps { /** An array of items to display as radio options. */ - items: Array< - RadioSelectItem & { - themeNameDisplay?: string; - themeTypeDisplay?: string; - } - >; - + items: Array>; /** 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({ items, - initialIndex, + initialIndex = 0, onSelect, onHighlight, - isFocused, // This prop indicates if the current RadioButtonSelect group is focused + isFocused, + showScrollArrows = true, + maxItemsToShow = 10, }: RadioButtonSelectProps): React.JSX.Element { - const handleSelect = (item: RadioSelectItem) => { - onSelect(item.value); - }; - const handleHighlight = (item: RadioSelectItem) => { - 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 ( - - - {isSelected ? '●' : '○'} - - + useEffect(() => { + const newScrollOffset = Math.max( + 0, + Math.min(activeIndex - maxItemsToShow + 1, items.length - 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; - }; - - let textColor = Colors.Foreground; - if (isSelected) { - textColor = Colors.AccentGreen; - } else if (itemWithThemeProps.disabled === true) { - textColor = Colors.Gray; + if (activeIndex < scrollOffset) { + setScrollOffset(activeIndex); + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newScrollOffset); } + }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); - if ( - itemWithThemeProps.themeNameDisplay && - itemWithThemeProps.themeTypeDisplay - ) { - return ( - - {itemWithThemeProps.themeNameDisplay}{' '} - {itemWithThemeProps.themeTypeDisplay} - - ); - } + 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); + } - return ( - - {label} - - ); - } + // 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 }, + ); + + const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); - initialIndex = initialIndex ?? 0; return ( - + + {showScrollArrows && ( + 0 ? Colors.Foreground : Colors.Gray}> + ▲ + + )} + {visibleItems.map((item, index) => { + const itemIndex = scrollOffset + index; + const isSelected = activeIndex === itemIndex; + + let textColor = Colors.Foreground; + if (isSelected) { + textColor = Colors.AccentGreen; + } else if (item.disabled) { + textColor = Colors.Gray; + } + + return ( + + + + {isSelected ? '●' : '○'} + + + {item.themeNameDisplay && item.themeTypeDisplay ? ( + + {item.themeNameDisplay}{' '} + {item.themeTypeDisplay} + + ) : ( + + {item.label} + + )} + + ); + })} + {showScrollArrows && ( + + ▼ + + )} + ); }