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
|
* 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
{showScrollArrows && (
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
scrollOffset + maxItemsToShow < items.length
|
||||||
|
? Colors.Foreground
|
||||||
|
: Colors.Gray
|
||||||
}
|
}
|
||||||
|
>
|
||||||
initialIndex = initialIndex ?? 0;
|
▼
|
||||||
return (
|
</Text>
|
||||||
<SelectInput
|
)}
|
||||||
indicatorComponent={DynamicRadioIndicator}
|
</Box>
|
||||||
itemComponent={CustomThemeItemComponent}
|
|
||||||
items={items}
|
|
||||||
initialIndex={initialIndex}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onHighlight={handleHighlight}
|
|
||||||
isFocused={isFocused}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue