UI Polish for theme selector (#294)

This commit is contained in:
Miguel Solorio 2025-05-08 16:00:55 -07:00 committed by GitHub
parent 6b0ac084b8
commit a685597b70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 171 additions and 81 deletions

View File

@ -8,6 +8,9 @@ import { themeManager } from './themes/theme-manager.js';
import { ColorsTheme } from './themes/theme.js'; import { ColorsTheme } from './themes/theme.js';
export const Colors: ColorsTheme = { export const Colors: ColorsTheme = {
get type() {
return themeManager.getActiveTheme().colors.type;
},
get Foreground() { get Foreground() {
return themeManager.getActiveTheme().colors.Foreground; return themeManager.getActiveTheme().colors.Foreground;
}, },

View File

@ -5,6 +5,7 @@
*/ */
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export interface Suggestion { export interface Suggestion {
label: string; label: string;
value: string; value: string;
@ -48,7 +49,7 @@ export function SuggestionsDisplay({
return ( return (
<Box borderStyle="round" flexDirection="column" paddingX={1} width={width}> <Box borderStyle="round" flexDirection="column" paddingX={1} width={width}>
{scrollOffset > 0 && <Text color="gray"></Text>} {scrollOffset > 0 && <Text color={Colors.Foreground}></Text>}
{visibleSuggestions.map((suggestion, index) => { {visibleSuggestions.map((suggestion, index) => {
const originalIndex = startIndex + index; const originalIndex = startIndex + index;
@ -56,8 +57,8 @@ export function SuggestionsDisplay({
return ( return (
<Text <Text
key={`${suggestion}-${originalIndex}`} key={`${suggestion}-${originalIndex}`}
color={isActive ? 'black' : 'white'} color={isActive ? Colors.Background : Colors.Foreground}
backgroundColor={isActive ? 'blue' : undefined} backgroundColor={isActive ? Colors.AccentBlue : undefined}
> >
{suggestion.label} {suggestion.label}
</Text> </Text>

View File

@ -32,16 +32,22 @@ export function ThemeDialog({
SettingScope.User, SettingScope.User,
); );
const themeItems = themeManager.getAvailableThemes().map((theme) => ({ // Generate theme items
label: theme.active ? `${theme.name} (Active)` : theme.name, const themeItems = themeManager.getAvailableThemes().map((theme) => {
value: theme.name, const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
})); return {
label: theme.name,
value: theme.name,
themeNameDisplay: theme.name,
themeTypeDisplay: typeString,
};
});
const [selectInputKey, setSelectInputKey] = useState(Date.now()); const [selectInputKey, setSelectInputKey] = useState(Date.now());
// Determine which radio button should be initially selected in the theme list
// This should reflect the theme *saved* for the selected scope, or the default
const initialThemeIndex = themeItems.findIndex( const initialThemeIndex = themeItems.findIndex(
(item) => (item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
item.value ===
(settings.forScope(selectedScope).settings.theme || DEFAULT_THEME.name),
); );
const scopeItems = [ const scopeItems = [
@ -88,45 +94,49 @@ export function ThemeDialog({
return ( return (
<Box <Box
borderStyle="round" borderStyle="round"
borderColor={Colors.AccentCyan} borderColor={Colors.AccentPurple}
flexDirection="column" flexDirection="row"
padding={1} padding={1}
width="50%" width="100%"
> >
<Text bold={focusedSection === 'theme'}> {/* Left Column: Selection */}
{focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '} <Box flexDirection="column" width="50%" paddingRight={2}>
<Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text> <Text bold={focusedSection === 'theme'}>
</Text> {focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text>
<RadioButtonSelect
key={selectInputKey}
items={themeItems}
initialIndex={initialThemeIndex}
onSelect={handleThemeSelect} // Use the wrapper handler
onHighlight={onHighlight}
isFocused={focusedSection === 'theme'}
/>
{/* Scope Selection */}
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
</Text> </Text>
<RadioButtonSelect <RadioButtonSelect
items={scopeItems} key={selectInputKey}
initialIndex={0} // Default to User Settings items={themeItems}
onSelect={handleScopeSelect} initialIndex={initialThemeIndex}
onHighlight={handleScopeHighlight} onSelect={handleThemeSelect}
isFocused={focusedSection === 'scope'} onHighlight={onHighlight}
isFocused={focusedSection === 'theme'}
/> />
{/* Scope Selection */}
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0} // Default to User Settings
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusedSection === 'scope'}
/>
</Box>
<Box marginTop={1}>
<Text color={Colors.SubtleComment}>
(Use / arrows and Enter to select, Tab to change focus)
</Text>
</Box>
</Box> </Box>
<Box marginTop={1}> {/* Right Column: Preview */}
<Text color={Colors.SubtleComment}> <Box flexDirection="column" width="50%" paddingLeft={3}>
(Use / arrows and Enter to select, Tab to change focus)
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Text bold>Preview</Text> <Text bold>Preview</Text>
<Box <Box
borderStyle="single" borderStyle="single"

View File

@ -27,7 +27,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const hasPending = !toolCalls.every( const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success, (t) => t.status === ToolCallStatus.Success,
); );
const borderColor = hasPending ? Colors.AccentYellow : Colors.AccentCyan; const borderColor = hasPending ? Colors.AccentYellow : Colors.AccentPurple;
return ( return (
<Box <Box

View File

@ -27,7 +27,12 @@ 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<RadioSelectItem<T>>; items: Array<
RadioSelectItem<T> & {
themeNameDisplay?: string;
themeTypeDisplay?: string;
}
>;
/** The initial index selected */ /** The initial index selected */
initialIndex?: number; initialIndex?: number;
@ -42,33 +47,6 @@ export interface RadioButtonSelectProps<T> {
isFocused?: boolean; isFocused?: boolean;
} }
/**
* Custom indicator component displaying radio button style (/).
*/
function RadioIndicator({
isSelected = false,
}: InkSelectIndicatorProps): React.JSX.Element {
return (
<Box marginRight={1}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Gray}>
{isSelected ? '◉' : '○'}
</Text>
</Box>
);
}
/**
* Custom item component for displaying the label with appropriate color.
*/
function RadioItem({
isSelected = false,
label,
}: InkSelectItemProps): React.JSX.Element {
return (
<Text color={isSelected ? Colors.AccentGreen : Colors.Gray}>{label}</Text>
);
}
/** /**
* A specialized SelectInput component styled to look like radio buttons. * A specialized SelectInput component styled to look like radio buttons.
* It uses '◉' for selected and '○' for unselected items. * It uses '◉' for selected and '○' for unselected items.
@ -80,7 +58,7 @@ export function RadioButtonSelect<T>({
initialIndex, initialIndex,
onSelect, onSelect,
onHighlight, onHighlight,
isFocused, isFocused, // This prop indicates if the current RadioButtonSelect group is focused
}: RadioButtonSelectProps<T>): React.JSX.Element { }: RadioButtonSelectProps<T>): React.JSX.Element {
const handleSelect = (item: RadioSelectItem<T>) => { const handleSelect = (item: RadioSelectItem<T>) => {
onSelect(item.value); onSelect(item.value);
@ -90,11 +68,72 @@ export function RadioButtonSelect<T>({
onHighlight(item.value); onHighlight(item.value);
} }
}; };
/**
* 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 {
let indicatorColor = Colors.Foreground; // Default for not selected
if (isSelected) {
if (isFocused) {
// Group is focused, selected item is AccentGreen
indicatorColor = Colors.AccentGreen;
} else {
// Group is NOT focused, selected item is Foreground
indicatorColor = Colors.Foreground;
}
}
return (
<Box marginRight={1}>
<Text color={indicatorColor}>{isSelected ? '●' : '○'}</Text>
</Box>
);
}
/**
* 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;
};
let textColor = Colors.Foreground;
if (isSelected) {
textColor = isFocused ? Colors.AccentGreen : Colors.Foreground;
}
if (
itemWithThemeProps.themeNameDisplay &&
itemWithThemeProps.themeTypeDisplay
) {
return (
<Text color={textColor}>
{itemWithThemeProps.themeNameDisplay}{' '}
<Text color={Colors.SubtleComment}>
{itemWithThemeProps.themeTypeDisplay}
</Text>
</Text>
);
}
return <Text color={textColor}>{label}</Text>;
}
initialIndex = initialIndex ?? 0; initialIndex = initialIndex ?? 0;
return ( return (
<SelectInput <SelectInput
indicatorComponent={RadioIndicator} indicatorComponent={DynamicRadioIndicator}
itemComponent={RadioItem} itemComponent={CustomThemeItemComponent}
items={items} items={items}
initialIndex={initialIndex} initialIndex={initialIndex}
onSelect={handleSelect} onSelect={handleSelect}

View File

@ -19,7 +19,7 @@ interface UseThemeCommandReturn {
} }
export const useThemeCommand = ( export const useThemeCommand = (
loadedSettings: LoadedSettings, // Changed parameter loadedSettings: LoadedSettings,
): UseThemeCommandReturn => { ): UseThemeCommandReturn => {
// Determine the effective theme // Determine the effective theme
const effectiveTheme = loadedSettings.merged.theme; const effectiveTheme = loadedSettings.merged.theme;

View File

@ -7,7 +7,8 @@
import { ansiTheme, Theme } from './theme.js'; import { ansiTheme, Theme } from './theme.js';
export const ANSI: Theme = new Theme( export const ANSI: Theme = new Theme(
'ANSI colors only', 'ANSI',
'ansi',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -7,7 +7,8 @@
import { darkTheme, Theme } from './theme.js'; import { darkTheme, Theme } from './theme.js';
export const AtomOneDark: Theme = new Theme( export const AtomOneDark: Theme = new Theme(
'Atom One Dark', 'Atom One',
'dark',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -8,6 +8,7 @@ import { darkTheme, Theme } from './theme.js';
export const Dracula: Theme = new Theme( export const Dracula: Theme = new Theme(
'Dracula', 'Dracula',
'dark',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -8,6 +8,7 @@ import { lightTheme, Theme } from './theme.js';
export const GitHub: Theme = new Theme( export const GitHub: Theme = new Theme(
'GitHub', 'GitHub',
'light',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -8,6 +8,7 @@ import { lightTheme, Theme } from './theme.js';
export const GoogleCode: Theme = new Theme( export const GoogleCode: Theme = new Theme(
'Google Code', 'Google Code',
'light',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -11,12 +11,12 @@ import { GoogleCode } from './googlecode.js';
import { VS } from './vs.js'; import { VS } from './vs.js';
import { VS2015 } from './vs2015.js'; import { VS2015 } from './vs2015.js';
import { XCode } from './xcode.js'; import { XCode } from './xcode.js';
import { Theme } from './theme.js'; import { Theme, ThemeType } from './theme.js';
import { ANSI } from './ansi.js'; import { ANSI } from './ansi.js';
export interface ThemeDisplay { export interface ThemeDisplay {
name: string; name: string;
active: boolean; type: ThemeType;
} }
export const DEFAULT_THEME: Theme = VS2015; export const DEFAULT_THEME: Theme = VS2015;
@ -43,9 +43,30 @@ class ThemeManager {
* Returns a list of available theme names. * Returns a list of available theme names.
*/ */
getAvailableThemes(): ThemeDisplay[] { getAvailableThemes(): ThemeDisplay[] {
return this.availableThemes.map((theme) => ({ const sortedThemes = [...this.availableThemes].sort((a, b) => {
const typeOrder = (type: ThemeType): number => {
switch (type) {
case 'dark':
return 1;
case 'light':
return 2;
case 'ansi':
return 3;
default:
return 4;
}
};
const typeComparison = typeOrder(a.type) - typeOrder(b.type);
if (typeComparison !== 0) {
return typeComparison;
}
return a.name.localeCompare(b.name);
});
return sortedThemes.map((theme) => ({
name: theme.name, name: theme.name,
active: theme === this.activeTheme, type: theme.type,
})); }));
} }

View File

@ -5,7 +5,11 @@
*/ */
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
export type ThemeType = 'light' | 'dark' | 'ansi';
export interface ColorsTheme { export interface ColorsTheme {
type: ThemeType;
Background: string; Background: string;
Foreground: string; Foreground: string;
LightBlue: string; LightBlue: string;
@ -21,6 +25,7 @@ export interface ColorsTheme {
} }
export const lightTheme: ColorsTheme = { export const lightTheme: ColorsTheme = {
type: 'light',
Background: '#FAFAFA', Background: '#FAFAFA',
Foreground: '#3C3C43', Foreground: '#3C3C43',
LightBlue: '#ADD8E6', LightBlue: '#ADD8E6',
@ -36,6 +41,7 @@ export const lightTheme: ColorsTheme = {
}; };
export const darkTheme: ColorsTheme = { export const darkTheme: ColorsTheme = {
type: 'dark',
Background: '#1E1E2E', Background: '#1E1E2E',
Foreground: '#CDD6F4', Foreground: '#CDD6F4',
LightBlue: '#ADD8E6', LightBlue: '#ADD8E6',
@ -51,6 +57,7 @@ export const darkTheme: ColorsTheme = {
}; };
export const ansiTheme: ColorsTheme = { export const ansiTheme: ColorsTheme = {
type: 'ansi',
Background: 'black', Background: 'black',
Foreground: 'white', Foreground: 'white',
LightBlue: 'blue', LightBlue: 'blue',
@ -250,6 +257,7 @@ export class Theme {
*/ */
constructor( constructor(
readonly name: string, readonly name: string,
readonly type: ThemeType,
rawMappings: Record<string, CSSProperties>, rawMappings: Record<string, CSSProperties>,
readonly colors: ColorsTheme, readonly colors: ColorsTheme,
) { ) {

View File

@ -8,6 +8,7 @@ import { lightTheme, Theme } from './theme.js';
export const VS: Theme = new Theme( export const VS: Theme = new Theme(
'VS', 'VS',
'light',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -8,6 +8,7 @@ import { darkTheme, Theme } from './theme.js';
export const VS2015: Theme = new Theme( export const VS2015: Theme = new Theme(
'VS2015', 'VS2015',
'dark',
{ {
hljs: { hljs: {
display: 'block', display: 'block',

View File

@ -8,6 +8,7 @@ import { lightTheme, Theme } from './theme.js';
export const XCode: Theme = new Theme( export const XCode: Theme = new Theme(
'XCode', 'XCode',
'light',
{ {
hljs: { hljs: {
display: 'block', display: 'block',