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';
export const Colors: ColorsTheme = {
get type() {
return themeManager.getActiveTheme().colors.type;
},
get Foreground() {
return themeManager.getActiveTheme().colors.Foreground;
},

View File

@ -5,6 +5,7 @@
*/
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export interface Suggestion {
label: string;
value: string;
@ -48,7 +49,7 @@ export function SuggestionsDisplay({
return (
<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) => {
const originalIndex = startIndex + index;
@ -56,8 +57,8 @@ export function SuggestionsDisplay({
return (
<Text
key={`${suggestion}-${originalIndex}`}
color={isActive ? 'black' : 'white'}
backgroundColor={isActive ? 'blue' : undefined}
color={isActive ? Colors.Background : Colors.Foreground}
backgroundColor={isActive ? Colors.AccentBlue : undefined}
>
{suggestion.label}
</Text>

View File

@ -32,16 +32,22 @@ export function ThemeDialog({
SettingScope.User,
);
const themeItems = themeManager.getAvailableThemes().map((theme) => ({
label: theme.active ? `${theme.name} (Active)` : theme.name,
value: theme.name,
}));
// Generate theme items
const themeItems = themeManager.getAvailableThemes().map((theme) => {
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());
// 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(
(item) =>
item.value ===
(settings.forScope(selectedScope).settings.theme || DEFAULT_THEME.name),
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
);
const scopeItems = [
@ -88,45 +94,49 @@ export function ThemeDialog({
return (
<Box
borderStyle="round"
borderColor={Colors.AccentCyan}
flexDirection="column"
borderColor={Colors.AccentPurple}
flexDirection="row"
padding={1}
width="50%"
width="100%"
>
<Text bold={focusedSection === 'theme'}>
{focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text>
</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
{/* Left Column: Selection */}
<Box flexDirection="column" width="50%" paddingRight={2}>
<Text bold={focusedSection === 'theme'}>
{focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0} // Default to User Settings
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusedSection === 'scope'}
key={selectInputKey}
items={themeItems}
initialIndex={initialThemeIndex}
onSelect={handleThemeSelect}
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 marginTop={1}>
<Text color={Colors.SubtleComment}>
(Use / arrows and Enter to select, Tab to change focus)
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
{/* Right Column: Preview */}
<Box flexDirection="column" width="50%" paddingLeft={3}>
<Text bold>Preview</Text>
<Box
borderStyle="single"

View File

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

View File

@ -27,7 +27,12 @@ export interface RadioSelectItem<T> {
*/
export interface RadioButtonSelectProps<T> {
/** 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 */
initialIndex?: number;
@ -42,33 +47,6 @@ export interface RadioButtonSelectProps<T> {
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.
* It uses '◉' for selected and '○' for unselected items.
@ -80,7 +58,7 @@ export function RadioButtonSelect<T>({
initialIndex,
onSelect,
onHighlight,
isFocused,
isFocused, // This prop indicates if the current RadioButtonSelect group is focused
}: RadioButtonSelectProps<T>): React.JSX.Element {
const handleSelect = (item: RadioSelectItem<T>) => {
onSelect(item.value);
@ -90,11 +68,72 @@ export function RadioButtonSelect<T>({
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;
return (
<SelectInput
indicatorComponent={RadioIndicator}
itemComponent={RadioItem}
indicatorComponent={DynamicRadioIndicator}
itemComponent={CustomThemeItemComponent}
items={items}
initialIndex={initialIndex}
onSelect={handleSelect}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,12 +11,12 @@ import { GoogleCode } from './googlecode.js';
import { VS } from './vs.js';
import { VS2015 } from './vs2015.js';
import { XCode } from './xcode.js';
import { Theme } from './theme.js';
import { Theme, ThemeType } from './theme.js';
import { ANSI } from './ansi.js';
export interface ThemeDisplay {
name: string;
active: boolean;
type: ThemeType;
}
export const DEFAULT_THEME: Theme = VS2015;
@ -43,9 +43,30 @@ class ThemeManager {
* Returns a list of available theme names.
*/
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,
active: theme === this.activeTheme,
type: theme.type,
}));
}

View File

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

View File

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

View File

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

View File

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