Add scrolling to theme dialog (#3895)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
82bde57868
commit
d89ccf2250
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{itemWithThemeProps.themeNameDisplay}{' '}
|
||||
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// 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 (
|
||||
<SelectInput
|
||||
indicatorComponent={DynamicRadioIndicator}
|
||||
itemComponent={CustomThemeItemComponent}
|
||||
items={items}
|
||||
initialIndex={initialIndex}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
<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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue