summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorMiguel Solorio <[email protected]>2025-07-11 18:05:21 -0700
committerGitHub <[email protected]>2025-07-12 01:05:21 +0000
commitd89ccf2250256bb67cdd9acfde1b679f39ca1f95 (patch)
treef301effcacf489b7bdb40b2d964a0eed44676b73 /packages/cli/src
parent82bde578682fcd88b1ee9df053c9dd51c7b74522 (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.tsx28
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx193
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>
);
}