Add scrolling to theme dialog (#3895)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Miguel Solorio 2025-07-11 18:05:21 -07:00 committed by GitHub
parent 82bde57868
commit d89ccf2250
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 100 deletions

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
@ -60,19 +60,25 @@ export function ThemeDialog({
{ label: 'System Settings', value: SettingScope.System }, { label: 'System Settings', value: SettingScope.System },
]; ];
const handleThemeSelect = (themeName: string) => { const handleThemeSelect = useCallback(
(themeName: string) => {
onSelect(themeName, selectedScope); onSelect(themeName, selectedScope);
}; },
[onSelect, selectedScope],
);
const handleScopeHighlight = (scope: SettingScope) => { const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope); setSelectedScope(scope);
setSelectInputKey(Date.now()); setSelectInputKey(Date.now());
}; }, []);
const handleScopeSelect = (scope: SettingScope) => { const handleScopeSelect = useCallback(
(scope: SettingScope) => {
handleScopeHighlight(scope); handleScopeHighlight(scope);
setFocusedSection('theme'); // Reset focus to theme section setFocusedSection('theme'); // Reset focus to theme section
}; },
[handleScopeHighlight],
);
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>( const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
'theme', 'theme',
@ -196,6 +202,7 @@ export function ThemeDialog({
onSelect={handleThemeSelect} onSelect={handleThemeSelect}
onHighlight={onHighlight} onHighlight={onHighlight}
isFocused={currenFocusedSection === 'theme'} isFocused={currenFocusedSection === 'theme'}
maxItemsToShow={8}
/> />
{/* Scope Selection */} {/* Scope Selection */}
@ -210,6 +217,7 @@ export function ThemeDialog({
onSelect={handleScopeSelect} onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight} onHighlight={handleScopeHighlight}
isFocused={currenFocusedSection === 'scope'} isFocused={currenFocusedSection === 'scope'}
showScrollArrows={false}
/> />
</Box> </Box>
)} )}

View File

@ -4,12 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Text, Box } from 'ink'; import { Text, Box, useInput } from 'ink';
import SelectInput, {
type ItemProps as InkSelectItemProps,
type IndicatorProps as InkSelectIndicatorProps,
} from 'ink-select-input';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
/** /**
@ -20,6 +16,8 @@ export interface RadioSelectItem<T> {
label: string; label: string;
value: T; value: T;
disabled?: boolean; disabled?: boolean;
themeNameDisplay?: string;
themeTypeDisplay?: string;
} }
/** /**
@ -28,115 +26,132 @@ export interface RadioSelectItem<T> {
*/ */
export interface RadioButtonSelectProps<T> { export interface RadioButtonSelectProps<T> {
/** An array of items to display as radio options. */ /** An array of items to display as radio options. */
items: Array< items: Array<RadioSelectItem<T>>;
RadioSelectItem<T> & {
themeNameDisplay?: string;
themeTypeDisplay?: string;
}
>;
/** The initial index selected */ /** The initial index selected */
initialIndex?: number; initialIndex?: number;
/** Function called when an item is selected. Receives the `value` of the selected item. */ /** Function called when an item is selected. Receives the `value` of the selected item. */
onSelect: (value: T) => void; onSelect: (value: T) => void;
/** Function called when an item is highlighted. Receives the `value` of the selected item. */ /** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void; onHighlight?: (value: T) => void;
/** Whether this select input is currently focused and should respond to input. */ /** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean; 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. * A custom component that displays a list of items with radio buttons,
* It uses '◉' for selected and '○' for unselected items. * supporting scrolling and keyboard navigation.
* *
* @template T The type of the value associated with each radio item. * @template T The type of the value associated with each radio item.
*/ */
export function RadioButtonSelect<T>({ export function RadioButtonSelect<T>({
items, items,
initialIndex, initialIndex = 0,
onSelect, onSelect,
onHighlight, onHighlight,
isFocused, // This prop indicates if the current RadioButtonSelect group is focused isFocused,
showScrollArrows = true,
maxItemsToShow = 10,
}: RadioButtonSelectProps<T>): React.JSX.Element { }: RadioButtonSelectProps<T>): React.JSX.Element {
const handleSelect = (item: RadioSelectItem<T>) => { const [activeIndex, setActiveIndex] = useState(initialIndex);
onSelect(item.value); const [scrollOffset, setScrollOffset] = useState(0);
};
const handleHighlight = (item: RadioSelectItem<T>) => { useEffect(() => {
if (onHighlight) { const newScrollOffset = Math.max(
onHighlight(item.value); 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]);
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);
}
// 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);
/**
* 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 ( 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;
let textColor = Colors.Foreground;
if (isSelected) {
textColor = Colors.AccentGreen;
} else if (item.disabled) {
textColor = Colors.Gray;
}
return (
<Box key={item.label}>
<Box minWidth={2} flexShrink={0}> <Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}> <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
{isSelected ? '●' : '○'} {isSelected ? '●' : '○'}
</Text> </Text>
</Box> </Box>
); {item.themeNameDisplay && item.themeTypeDisplay ? (
}
/**
* 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 (
itemWithThemeProps.themeNameDisplay &&
itemWithThemeProps.themeTypeDisplay
) {
return (
<Text color={textColor} wrap="truncate"> <Text color={textColor} wrap="truncate">
{itemWithThemeProps.themeNameDisplay}{' '} {item.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text> <Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
</Text> </Text>
); ) : (
}
return (
<Text color={textColor} wrap="truncate"> <Text color={textColor} wrap="truncate">
{label} {item.label}
</Text> </Text>
); )}
} </Box>
);
initialIndex = initialIndex ?? 0; })}
return ( {showScrollArrows && (
<SelectInput <Text
indicatorComponent={DynamicRadioIndicator} color={
itemComponent={CustomThemeItemComponent} scrollOffset + maxItemsToShow < items.length
items={items} ? Colors.Foreground
initialIndex={initialIndex} : Colors.Gray
onSelect={handleSelect} }
onHighlight={handleHighlight} >
isFocused={isFocused}
/> </Text>
)}
</Box>
); );
} }