Feature custom themes logic (#2639)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Ali Al Jufairi
2025-07-20 16:51:18 +09:00
committed by GitHub
parent c0bfa388c5
commit 76b935d598
19 changed files with 1313 additions and 341 deletions

View File

@@ -0,0 +1,221 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
isValidColor,
resolveColor,
CSS_NAME_TO_HEX_MAP,
INK_SUPPORTED_NAMES,
} from './color-utils.js';
describe('Color Utils', () => {
describe('isValidColor', () => {
it('should validate hex colors', () => {
expect(isValidColor('#ff0000')).toBe(true);
expect(isValidColor('#00ff00')).toBe(true);
expect(isValidColor('#0000ff')).toBe(true);
expect(isValidColor('#fff')).toBe(true);
expect(isValidColor('#000')).toBe(true);
expect(isValidColor('#FF0000')).toBe(true); // Case insensitive
});
it('should validate Ink-supported color names', () => {
expect(isValidColor('black')).toBe(true);
expect(isValidColor('red')).toBe(true);
expect(isValidColor('green')).toBe(true);
expect(isValidColor('yellow')).toBe(true);
expect(isValidColor('blue')).toBe(true);
expect(isValidColor('cyan')).toBe(true);
expect(isValidColor('magenta')).toBe(true);
expect(isValidColor('white')).toBe(true);
expect(isValidColor('gray')).toBe(true);
expect(isValidColor('grey')).toBe(true);
expect(isValidColor('blackbright')).toBe(true);
expect(isValidColor('redbright')).toBe(true);
expect(isValidColor('greenbright')).toBe(true);
expect(isValidColor('yellowbright')).toBe(true);
expect(isValidColor('bluebright')).toBe(true);
expect(isValidColor('cyanbright')).toBe(true);
expect(isValidColor('magentabright')).toBe(true);
expect(isValidColor('whitebright')).toBe(true);
});
it('should validate Ink-supported color names case insensitive', () => {
expect(isValidColor('BLACK')).toBe(true);
expect(isValidColor('Red')).toBe(true);
expect(isValidColor('GREEN')).toBe(true);
});
it('should validate CSS color names', () => {
expect(isValidColor('darkkhaki')).toBe(true);
expect(isValidColor('coral')).toBe(true);
expect(isValidColor('teal')).toBe(true);
expect(isValidColor('tomato')).toBe(true);
expect(isValidColor('turquoise')).toBe(true);
expect(isValidColor('violet')).toBe(true);
expect(isValidColor('wheat')).toBe(true);
expect(isValidColor('whitesmoke')).toBe(true);
expect(isValidColor('yellowgreen')).toBe(true);
});
it('should validate CSS color names case insensitive', () => {
expect(isValidColor('DARKKHAKI')).toBe(true);
expect(isValidColor('Coral')).toBe(true);
expect(isValidColor('TEAL')).toBe(true);
});
it('should reject invalid color names', () => {
expect(isValidColor('invalidcolor')).toBe(false);
expect(isValidColor('notacolor')).toBe(false);
expect(isValidColor('')).toBe(false);
});
});
describe('resolveColor', () => {
it('should resolve hex colors', () => {
expect(resolveColor('#ff0000')).toBe('#ff0000');
expect(resolveColor('#00ff00')).toBe('#00ff00');
expect(resolveColor('#0000ff')).toBe('#0000ff');
expect(resolveColor('#fff')).toBe('#fff');
expect(resolveColor('#000')).toBe('#000');
});
it('should resolve Ink-supported color names', () => {
expect(resolveColor('black')).toBe('black');
expect(resolveColor('red')).toBe('red');
expect(resolveColor('green')).toBe('green');
expect(resolveColor('yellow')).toBe('yellow');
expect(resolveColor('blue')).toBe('blue');
expect(resolveColor('cyan')).toBe('cyan');
expect(resolveColor('magenta')).toBe('magenta');
expect(resolveColor('white')).toBe('white');
expect(resolveColor('gray')).toBe('gray');
expect(resolveColor('grey')).toBe('grey');
});
it('should resolve CSS color names to hex', () => {
expect(resolveColor('darkkhaki')).toBe('#bdb76b');
expect(resolveColor('coral')).toBe('#ff7f50');
expect(resolveColor('teal')).toBe('#008080');
expect(resolveColor('tomato')).toBe('#ff6347');
expect(resolveColor('turquoise')).toBe('#40e0d0');
expect(resolveColor('violet')).toBe('#ee82ee');
expect(resolveColor('wheat')).toBe('#f5deb3');
expect(resolveColor('whitesmoke')).toBe('#f5f5f5');
expect(resolveColor('yellowgreen')).toBe('#9acd32');
});
it('should handle case insensitive color names', () => {
expect(resolveColor('DARKKHAKI')).toBe('#bdb76b');
expect(resolveColor('Coral')).toBe('#ff7f50');
expect(resolveColor('TEAL')).toBe('#008080');
});
it('should return undefined for invalid colors', () => {
expect(resolveColor('invalidcolor')).toBeUndefined();
expect(resolveColor('notacolor')).toBeUndefined();
expect(resolveColor('')).toBeUndefined();
});
});
describe('CSS_NAME_TO_HEX_MAP', () => {
it('should contain expected CSS color mappings', () => {
expect(CSS_NAME_TO_HEX_MAP.darkkhaki).toBe('#bdb76b');
expect(CSS_NAME_TO_HEX_MAP.coral).toBe('#ff7f50');
expect(CSS_NAME_TO_HEX_MAP.teal).toBe('#008080');
expect(CSS_NAME_TO_HEX_MAP.tomato).toBe('#ff6347');
expect(CSS_NAME_TO_HEX_MAP.turquoise).toBe('#40e0d0');
});
it('should not contain Ink-supported color names', () => {
expect(CSS_NAME_TO_HEX_MAP.black).toBeUndefined();
expect(CSS_NAME_TO_HEX_MAP.red).toBeUndefined();
expect(CSS_NAME_TO_HEX_MAP.green).toBeUndefined();
expect(CSS_NAME_TO_HEX_MAP.blue).toBeUndefined();
});
});
describe('INK_SUPPORTED_NAMES', () => {
it('should contain all Ink-supported color names', () => {
expect(INK_SUPPORTED_NAMES.has('black')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('red')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('green')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('yellow')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('blue')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('cyan')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('magenta')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('white')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('gray')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('grey')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('blackbright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('redbright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('greenbright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('yellowbright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('bluebright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('cyanbright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('magentabright')).toBe(true);
expect(INK_SUPPORTED_NAMES.has('whitebright')).toBe(true);
});
it('should not contain CSS color names', () => {
expect(INK_SUPPORTED_NAMES.has('darkkhaki')).toBe(false);
expect(INK_SUPPORTED_NAMES.has('coral')).toBe(false);
expect(INK_SUPPORTED_NAMES.has('teal')).toBe(false);
});
});
describe('Consistency between validation and resolution', () => {
it('should have consistent behavior between isValidColor and resolveColor', () => {
// Test that any color that isValidColor returns true for can be resolved
const testColors = [
'#ff0000',
'#00ff00',
'#0000ff',
'#fff',
'#000',
'black',
'red',
'green',
'yellow',
'blue',
'cyan',
'magenta',
'white',
'gray',
'grey',
'darkkhaki',
'coral',
'teal',
'tomato',
'turquoise',
'violet',
'wheat',
'whitesmoke',
'yellowgreen',
];
for (const color of testColors) {
expect(isValidColor(color)).toBe(true);
expect(resolveColor(color)).toBeDefined();
}
// Test that invalid colors are consistently rejected
const invalidColors = [
'invalidcolor',
'notacolor',
'',
'#gg0000',
'#ff00',
];
for (const color of invalidColors) {
expect(isValidColor(color)).toBe(false);
expect(resolveColor(color)).toBeUndefined();
}
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
// Excludes names directly supported by Ink
export const CSS_NAME_TO_HEX_MAP: Readonly<Record<string, string>> = {
aliceblue: '#f0f8ff',
antiquewhite: '#faebd7',
aqua: '#00ffff',
aquamarine: '#7fffd4',
azure: '#f0ffff',
beige: '#f5f5dc',
bisque: '#ffe4c4',
blanchedalmond: '#ffebcd',
blueviolet: '#8a2be2',
brown: '#a52a2a',
burlywood: '#deb887',
cadetblue: '#5f9ea0',
chartreuse: '#7fff00',
chocolate: '#d2691e',
coral: '#ff7f50',
cornflowerblue: '#6495ed',
cornsilk: '#fff8dc',
crimson: '#dc143c',
darkblue: '#00008b',
darkcyan: '#008b8b',
darkgoldenrod: '#b8860b',
darkgray: '#a9a9a9',
darkgrey: '#a9a9a9',
darkgreen: '#006400',
darkkhaki: '#bdb76b',
darkmagenta: '#8b008b',
darkolivegreen: '#556b2f',
darkorange: '#ff8c00',
darkorchid: '#9932cc',
darkred: '#8b0000',
darksalmon: '#e9967a',
darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b',
darkslategray: '#2f4f4f',
darkslategrey: '#2f4f4f',
darkturquoise: '#00ced1',
darkviolet: '#9400d3',
deeppink: '#ff1493',
deepskyblue: '#00bfff',
dimgray: '#696969',
dimgrey: '#696969',
dodgerblue: '#1e90ff',
firebrick: '#b22222',
floralwhite: '#fffaf0',
forestgreen: '#228b22',
fuchsia: '#ff00ff',
gainsboro: '#dcdcdc',
ghostwhite: '#f8f8ff',
gold: '#ffd700',
goldenrod: '#daa520',
greenyellow: '#adff2f',
honeydew: '#f0fff0',
hotpink: '#ff69b4',
indianred: '#cd5c5c',
indigo: '#4b0082',
ivory: '#fffff0',
khaki: '#f0e68c',
lavender: '#e6e6fa',
lavenderblush: '#fff0f5',
lawngreen: '#7cfc00',
lemonchiffon: '#fffacd',
lightblue: '#add8e6',
lightcoral: '#f08080',
lightcyan: '#e0ffff',
lightgoldenrodyellow: '#fafad2',
lightgray: '#d3d3d3',
lightgrey: '#d3d3d3',
lightgreen: '#90ee90',
lightpink: '#ffb6c1',
lightsalmon: '#ffa07a',
lightseagreen: '#20b2aa',
lightskyblue: '#87cefa',
lightslategray: '#778899',
lightslategrey: '#778899',
lightsteelblue: '#b0c4de',
lightyellow: '#ffffe0',
lime: '#00ff00',
limegreen: '#32cd32',
linen: '#faf0e6',
maroon: '#800000',
mediumaquamarine: '#66cdaa',
mediumblue: '#0000cd',
mediumorchid: '#ba55d3',
mediumpurple: '#9370db',
mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee',
mediumspringgreen: '#00fa9a',
mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585',
midnightblue: '#191970',
mintcream: '#f5fffa',
mistyrose: '#ffe4e1',
moccasin: '#ffe4b5',
navajowhite: '#ffdead',
navy: '#000080',
oldlace: '#fdf5e6',
olive: '#808000',
olivedrab: '#6b8e23',
orange: '#ffa500',
orangered: '#ff4500',
orchid: '#da70d6',
palegoldenrod: '#eee8aa',
palegreen: '#98fb98',
paleturquoise: '#afeeee',
palevioletred: '#db7093',
papayawhip: '#ffefd5',
peachpuff: '#ffdab9',
peru: '#cd853f',
pink: '#ffc0cb',
plum: '#dda0dd',
powderblue: '#b0e0e6',
purple: '#800080',
rebeccapurple: '#663399',
rosybrown: '#bc8f8f',
royalblue: '#4169e1',
saddlebrown: '#8b4513',
salmon: '#fa8072',
sandybrown: '#f4a460',
seagreen: '#2e8b57',
seashell: '#fff5ee',
sienna: '#a0522d',
silver: '#c0c0c0',
skyblue: '#87ceeb',
slateblue: '#6a5acd',
slategray: '#708090',
slategrey: '#708090',
snow: '#fffafa',
springgreen: '#00ff7f',
steelblue: '#4682b4',
tan: '#d2b48c',
teal: '#008080',
thistle: '#d8bfd8',
tomato: '#ff6347',
turquoise: '#40e0d0',
violet: '#ee82ee',
wheat: '#f5deb3',
whitesmoke: '#f5f5f5',
yellowgreen: '#9acd32',
};
// Define the set of Ink's named colors for quick lookup
export const INK_SUPPORTED_NAMES = new Set([
'black',
'red',
'green',
'yellow',
'blue',
'cyan',
'magenta',
'white',
'gray',
'grey',
'blackbright',
'redbright',
'greenbright',
'yellowbright',
'bluebright',
'cyanbright',
'magentabright',
'whitebright',
]);
/**
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
* This function uses the same validation logic as the Theme class's _resolveColor method
* to ensure consistency between validation and resolution.
* @param color The color string to validate.
* @returns True if the color is valid.
*/
export function isValidColor(color: string): boolean {
const lowerColor = color.toLowerCase();
// 1. Check if it's a hex code
if (lowerColor.startsWith('#')) {
return /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(color);
}
// 2. Check if it's an Ink supported name
if (INK_SUPPORTED_NAMES.has(lowerColor)) {
return true;
}
// 3. Check if it's a known CSS name we can map to hex
if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
return true;
}
// 4. Not a valid color
return false;
}
/**
* Resolves a CSS color value (name or hex) into an Ink-compatible color string.
* @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
*/
export function resolveColor(colorValue: string): string | undefined {
const lowerColor = colorValue.toLowerCase();
// 1. Check if it's already a hex code and valid
if (lowerColor.startsWith('#')) {
if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
return lowerColor;
} else {
return undefined;
}
}
// 2. Check if it's an Ink supported name (lowercase)
else if (INK_SUPPORTED_NAMES.has(lowerColor)) {
return lowerColor; // Use Ink name directly
}
// 3. Check if it's a known CSS name we can map to hex
else if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
}
// 4. Could not resolve
console.warn(
`[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
);
return undefined;
}

View File

@@ -22,7 +22,7 @@ const noColorColorsTheme: ColorsTheme = {
};
export const NoColorTheme: Theme = new Theme(
'No Color',
'NoColor',
'dark',
{
hljs: {

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Patch: Unset NO_COLOR at the very top before any imports
if (process.env.NO_COLOR !== undefined) {
delete process.env.NO_COLOR;
}
import { describe, it, expect, beforeEach } from 'vitest';
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
import { CustomTheme } from './theme.js';
const validCustomTheme: CustomTheme = {
type: 'custom',
name: 'MyCustomTheme',
Background: '#000000',
Foreground: '#ffffff',
LightBlue: '#89BDCD',
AccentBlue: '#3B82F6',
AccentPurple: '#8B5CF6',
AccentCyan: '#06B6D4',
AccentGreen: '#3CA84B',
AccentYellow: '#D5A40A',
AccentRed: '#DD4C4C',
Comment: '#008000',
Gray: '#B7BECC',
};
describe('ThemeManager', () => {
beforeEach(() => {
// Reset themeManager state
themeManager.loadCustomThemes({});
themeManager.setActiveTheme(DEFAULT_THEME.name);
});
it('should load valid custom themes', () => {
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
expect(themeManager.isCustomTheme('MyCustomTheme')).toBe(true);
});
it('should not load invalid custom themes', () => {
const invalidTheme = { ...validCustomTheme, Background: 'not-a-color' };
themeManager.loadCustomThemes({
InvalidTheme: invalidTheme as unknown as CustomTheme,
});
expect(themeManager.getCustomThemeNames()).not.toContain('InvalidTheme');
expect(themeManager.isCustomTheme('InvalidTheme')).toBe(false);
});
it('should set and get the active theme', () => {
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
themeManager.setActiveTheme('Ayu');
expect(themeManager.getActiveTheme().name).toBe('Ayu');
});
it('should set and get a custom active theme', () => {
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
themeManager.setActiveTheme('MyCustomTheme');
expect(themeManager.getActiveTheme().name).toBe('MyCustomTheme');
});
it('should return false when setting a non-existent theme', () => {
expect(themeManager.setActiveTheme('NonExistentTheme')).toBe(false);
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
});
it('should list available themes including custom themes', () => {
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
const available = themeManager.getAvailableThemes();
expect(
available.some(
(t: { name: string; isCustom?: boolean }) =>
t.name === 'MyCustomTheme' && t.isCustom,
),
).toBe(true);
});
it('should get a theme by name', () => {
expect(themeManager.getTheme('Ayu')).toBeDefined();
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
expect(themeManager.getTheme('MyCustomTheme')).toBeDefined();
});
it('should fallback to default theme if active theme is invalid', () => {
(themeManager as unknown as { activeTheme: unknown }).activeTheme = {
name: 'NonExistent',
type: 'custom',
};
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
});
it('should return NoColorTheme if NO_COLOR is set', () => {
const original = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
expect(themeManager.getActiveTheme().name).toBe('NoColor');
if (original === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = original;
}
});
});

View File

@@ -15,7 +15,13 @@ import { DefaultLight } from './default-light.js';
import { DefaultDark } from './default.js';
import { ShadesOfPurple } from './shades-of-purple.js';
import { XCode } from './xcode.js';
import { Theme, ThemeType } from './theme.js';
import {
Theme,
ThemeType,
CustomTheme,
createCustomTheme,
validateCustomTheme,
} from './theme.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -24,6 +30,7 @@ import process from 'node:process';
export interface ThemeDisplay {
name: string;
type: ThemeType;
isCustom?: boolean;
}
export const DEFAULT_THEME: Theme = DefaultDark;
@@ -31,6 +38,7 @@ export const DEFAULT_THEME: Theme = DefaultDark;
class ThemeManager {
private readonly availableThemes: Theme[];
private activeTheme: Theme;
private customThemes: Map<string, Theme> = new Map();
constructor() {
this.availableThemes = [
@@ -51,19 +59,121 @@ class ThemeManager {
this.activeTheme = DEFAULT_THEME;
}
/**
* Loads custom themes from settings.
* @param customThemesSettings Custom themes from settings.
*/
loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
this.customThemes.clear();
if (!customThemesSettings) {
return;
}
for (const [name, customThemeConfig] of Object.entries(
customThemesSettings,
)) {
const validation = validateCustomTheme(customThemeConfig);
if (validation.isValid) {
try {
const theme = createCustomTheme(customThemeConfig);
this.customThemes.set(name, theme);
} catch (error) {
console.warn(`Failed to load custom theme "${name}":`, error);
}
} else {
console.warn(`Invalid custom theme "${name}": ${validation.error}`);
}
}
// If the current active theme is a custom theme, keep it if still valid
if (
this.activeTheme &&
this.activeTheme.type === 'custom' &&
this.customThemes.has(this.activeTheme.name)
) {
this.activeTheme = this.customThemes.get(this.activeTheme.name)!;
}
}
/**
* Sets the active theme.
* @param themeName The name of the theme to set as active.
* @returns True if the theme was successfully set, false otherwise.
*/
setActiveTheme(themeName: string | undefined): boolean {
const theme = this.findThemeByName(themeName);
if (!theme) {
return false;
}
this.activeTheme = theme;
return true;
}
/**
* Gets the currently active theme.
* @returns The active theme.
*/
getActiveTheme(): Theme {
if (process.env.NO_COLOR) {
return NoColorTheme;
}
// Ensure the active theme is always valid (fallback to default if not)
if (!this.activeTheme || !this.findThemeByName(this.activeTheme.name)) {
this.activeTheme = DEFAULT_THEME;
}
return this.activeTheme;
}
/**
* Gets a list of custom theme names.
* @returns Array of custom theme names.
*/
getCustomThemeNames(): string[] {
return Array.from(this.customThemes.keys());
}
/**
* Checks if a theme name is a custom theme.
* @param themeName The theme name to check.
* @returns True if the theme is custom.
*/
isCustomTheme(themeName: string): boolean {
return this.customThemes.has(themeName);
}
/**
* Returns a list of available theme names.
*/
getAvailableThemes(): ThemeDisplay[] {
const sortedThemes = [...this.availableThemes].sort((a, b) => {
const builtInThemes = this.availableThemes.map((theme) => ({
name: theme.name,
type: theme.type,
isCustom: false,
}));
const customThemes = Array.from(this.customThemes.values()).map(
(theme) => ({
name: theme.name,
type: theme.type,
isCustom: true,
}),
);
const allThemes = [...builtInThemes, ...customThemes];
const sortedThemes = allThemes.sort((a, b) => {
const typeOrder = (type: ThemeType): number => {
switch (type) {
case 'dark':
return 1;
case 'light':
return 2;
default:
case 'ansi':
return 3;
case 'custom':
return 4; // Custom themes at the end
default:
return 5;
}
};
@@ -74,50 +184,33 @@ class ThemeManager {
return a.name.localeCompare(b.name);
});
return sortedThemes.map((theme) => ({
name: theme.name,
type: theme.type,
}));
return sortedThemes;
}
/**
* Sets the active theme.
* @param themeName The name of the theme to activate.
* @returns True if the theme was successfully set, false otherwise.
* Gets a theme by name.
* @param themeName The name of the theme to get.
* @returns The theme if found, undefined otherwise.
*/
setActiveTheme(themeName: string | undefined): boolean {
const foundTheme = this.findThemeByName(themeName);
if (foundTheme) {
this.activeTheme = foundTheme;
return true;
} else {
// If themeName is undefined, it means we want to set the default theme.
// If findThemeByName returns undefined (e.g. default theme is also not found for some reason)
// then this will return false.
if (themeName === undefined) {
this.activeTheme = DEFAULT_THEME;
return true;
}
return false;
}
getTheme(themeName: string): Theme | undefined {
return this.findThemeByName(themeName);
}
findThemeByName(themeName: string | undefined): Theme | undefined {
if (!themeName) {
return DEFAULT_THEME;
}
return this.availableThemes.find((theme) => theme.name === themeName);
}
/**
* Returns the currently active theme object.
*/
getActiveTheme(): Theme {
if (process.env.NO_COLOR) {
return NoColorTheme;
// First check built-in themes
const builtInTheme = this.availableThemes.find(
(theme) => theme.name === themeName,
);
if (builtInTheme) {
return builtInTheme;
}
return this.activeTheme;
// Then check custom themes
return this.customThemes.get(themeName);
}
}

View File

@@ -5,8 +5,9 @@
*/
import type { CSSProperties } from 'react';
import { isValidColor, resolveColor } from './color-utils.js';
export type ThemeType = 'light' | 'dark' | 'ansi';
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
export interface ColorsTheme {
type: ThemeType;
@@ -24,6 +25,11 @@ export interface ColorsTheme {
GradientColors?: string[];
}
export interface CustomTheme extends ColorsTheme {
type: 'custom';
name: string;
}
export const lightTheme: ColorsTheme = {
type: 'light',
Background: '#FAFAFA',
@@ -83,173 +89,6 @@ export class Theme {
*/
protected readonly _colorMap: Readonly<Record<string, string>>;
// --- Static Helper Data ---
// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
// Excludes names directly supported by Ink
private static readonly cssNameToHexMap: Readonly<Record<string, string>> = {
aliceblue: '#f0f8ff',
antiquewhite: '#faebd7',
aqua: '#00ffff',
aquamarine: '#7fffd4',
azure: '#f0ffff',
beige: '#f5f5dc',
bisque: '#ffe4c4',
blanchedalmond: '#ffebcd',
blueviolet: '#8a2be2',
brown: '#a52a2a',
burlywood: '#deb887',
cadetblue: '#5f9ea0',
chartreuse: '#7fff00',
chocolate: '#d2691e',
coral: '#ff7f50',
cornflowerblue: '#6495ed',
cornsilk: '#fff8dc',
crimson: '#dc143c',
darkblue: '#00008b',
darkcyan: '#008b8b',
darkgoldenrod: '#b8860b',
darkgray: '#a9a9a9',
darkgrey: '#a9a9a9',
darkgreen: '#006400',
darkkhaki: '#bdb76b',
darkmagenta: '#8b008b',
darkolivegreen: '#556b2f',
darkorange: '#ff8c00',
darkorchid: '#9932cc',
darkred: '#8b0000',
darksalmon: '#e9967a',
darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b',
darkslategray: '#2f4f4f',
darkslategrey: '#2f4f4f',
darkturquoise: '#00ced1',
darkviolet: '#9400d3',
deeppink: '#ff1493',
deepskyblue: '#00bfff',
dimgray: '#696969',
dimgrey: '#696969',
dodgerblue: '#1e90ff',
firebrick: '#b22222',
floralwhite: '#fffaf0',
forestgreen: '#228b22',
fuchsia: '#ff00ff',
gainsboro: '#dcdcdc',
ghostwhite: '#f8f8ff',
gold: '#ffd700',
goldenrod: '#daa520',
greenyellow: '#adff2f',
honeydew: '#f0fff0',
hotpink: '#ff69b4',
indianred: '#cd5c5c',
indigo: '#4b0082',
ivory: '#fffff0',
khaki: '#f0e68c',
lavender: '#e6e6fa',
lavenderblush: '#fff0f5',
lawngreen: '#7cfc00',
lemonchiffon: '#fffacd',
lightblue: '#add8e6',
lightcoral: '#f08080',
lightcyan: '#e0ffff',
lightgoldenrodyellow: '#fafad2',
lightgray: '#d3d3d3',
lightgrey: '#d3d3d3',
lightgreen: '#90ee90',
lightpink: '#ffb6c1',
lightsalmon: '#ffa07a',
lightseagreen: '#20b2aa',
lightskyblue: '#87cefa',
lightslategray: '#778899',
lightslategrey: '#778899',
lightsteelblue: '#b0c4de',
lightyellow: '#ffffe0',
lime: '#00ff00',
limegreen: '#32cd32',
linen: '#faf0e6',
maroon: '#800000',
mediumaquamarine: '#66cdaa',
mediumblue: '#0000cd',
mediumorchid: '#ba55d3',
mediumpurple: '#9370db',
mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee',
mediumspringgreen: '#00fa9a',
mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585',
midnightblue: '#191970',
mintcream: '#f5fffa',
mistyrose: '#ffe4e1',
moccasin: '#ffe4b5',
navajowhite: '#ffdead',
navy: '#000080',
oldlace: '#fdf5e6',
olive: '#808000',
olivedrab: '#6b8e23',
orange: '#ffa500',
orangered: '#ff4500',
orchid: '#da70d6',
palegoldenrod: '#eee8aa',
palegreen: '#98fb98',
paleturquoise: '#afeeee',
palevioletred: '#db7093',
papayawhip: '#ffefd5',
peachpuff: '#ffdab9',
peru: '#cd853f',
pink: '#ffc0cb',
plum: '#dda0dd',
powderblue: '#b0e0e6',
purple: '#800080',
rebeccapurple: '#663399',
rosybrown: '#bc8f8f',
royalblue: '#4169e1',
saddlebrown: '#8b4513',
salmon: '#fa8072',
sandybrown: '#f4a460',
seagreen: '#2e8b57',
seashell: '#fff5ee',
sienna: '#a0522d',
silver: '#c0c0c0',
skyblue: '#87ceeb',
slateblue: '#6a5acd',
slategray: '#708090',
slategrey: '#708090',
snow: '#fffafa',
springgreen: '#00ff7f',
steelblue: '#4682b4',
tan: '#d2b48c',
teal: '#008080',
thistle: '#d8bfd8',
tomato: '#ff6347',
turquoise: '#40e0d0',
violet: '#ee82ee',
wheat: '#f5deb3',
whitesmoke: '#f5f5f5',
yellowgreen: '#9acd32',
};
// Define the set of Ink's named colors for quick lookup
private static readonly inkSupportedNames = new Set([
'black',
'red',
'green',
'yellow',
'blue',
'cyan',
'magenta',
'white',
'gray',
'grey',
'blackbright',
'redbright',
'greenbright',
'yellowbright',
'bluebright',
'cyanbright',
'magentabright',
'whitebright',
]);
/**
* Creates a new Theme instance.
* @param name The name of the theme.
@@ -285,26 +124,7 @@ export class Theme {
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
*/
private static _resolveColor(colorValue: string): string | undefined {
const lowerColor = colorValue.toLowerCase();
// 1. Check if it's already a hex code
if (lowerColor.startsWith('#')) {
return lowerColor; // Use hex directly
}
// 2. Check if it's an Ink supported name (lowercase)
else if (Theme.inkSupportedNames.has(lowerColor)) {
return lowerColor; // Use Ink name directly
}
// 3. Check if it's a known CSS name we can map to hex
else if (Theme.cssNameToHexMap[lowerColor]) {
return Theme.cssNameToHexMap[lowerColor]; // Use mapped hex
}
// 4. Could not resolve
console.warn(
`[Theme] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
);
return undefined;
return resolveColor(colorValue);
}
/**
@@ -339,3 +159,230 @@ export class Theme {
return inkTheme;
}
}
/**
* Creates a Theme instance from a custom theme configuration.
* @param customTheme The custom theme configuration.
* @returns A new Theme instance.
*/
export function createCustomTheme(customTheme: CustomTheme): Theme {
// Generate CSS properties mappings based on the custom theme colors
const rawMappings: Record<string, CSSProperties> = {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: customTheme.Background,
color: customTheme.Foreground,
},
'hljs-keyword': {
color: customTheme.AccentBlue,
},
'hljs-literal': {
color: customTheme.AccentBlue,
},
'hljs-symbol': {
color: customTheme.AccentBlue,
},
'hljs-name': {
color: customTheme.AccentBlue,
},
'hljs-link': {
color: customTheme.AccentBlue,
textDecoration: 'underline',
},
'hljs-built_in': {
color: customTheme.AccentCyan,
},
'hljs-type': {
color: customTheme.AccentCyan,
},
'hljs-number': {
color: customTheme.AccentGreen,
},
'hljs-class': {
color: customTheme.AccentGreen,
},
'hljs-string': {
color: customTheme.AccentYellow,
},
'hljs-meta-string': {
color: customTheme.AccentYellow,
},
'hljs-regexp': {
color: customTheme.AccentRed,
},
'hljs-template-tag': {
color: customTheme.AccentRed,
},
'hljs-subst': {
color: customTheme.Foreground,
},
'hljs-function': {
color: customTheme.Foreground,
},
'hljs-title': {
color: customTheme.Foreground,
},
'hljs-params': {
color: customTheme.Foreground,
},
'hljs-formula': {
color: customTheme.Foreground,
},
'hljs-comment': {
color: customTheme.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: customTheme.Comment,
fontStyle: 'italic',
},
'hljs-doctag': {
color: customTheme.Comment,
},
'hljs-meta': {
color: customTheme.Gray,
},
'hljs-meta-keyword': {
color: customTheme.Gray,
},
'hljs-tag': {
color: customTheme.Gray,
},
'hljs-variable': {
color: customTheme.AccentPurple,
},
'hljs-template-variable': {
color: customTheme.AccentPurple,
},
'hljs-attr': {
color: customTheme.LightBlue,
},
'hljs-attribute': {
color: customTheme.LightBlue,
},
'hljs-builtin-name': {
color: customTheme.LightBlue,
},
'hljs-section': {
color: customTheme.AccentYellow,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-bullet': {
color: customTheme.AccentYellow,
},
'hljs-selector-tag': {
color: customTheme.AccentYellow,
},
'hljs-selector-id': {
color: customTheme.AccentYellow,
},
'hljs-selector-class': {
color: customTheme.AccentYellow,
},
'hljs-selector-attr': {
color: customTheme.AccentYellow,
},
'hljs-selector-pseudo': {
color: customTheme.AccentYellow,
},
'hljs-addition': {
backgroundColor: customTheme.AccentGreen,
display: 'inline-block',
width: '100%',
},
'hljs-deletion': {
backgroundColor: customTheme.AccentRed,
display: 'inline-block',
width: '100%',
},
};
return new Theme(customTheme.name, 'custom', rawMappings, customTheme);
}
/**
* Validates a custom theme configuration.
* @param customTheme The custom theme to validate.
* @returns An object with isValid boolean and error message if invalid.
*/
export function validateCustomTheme(customTheme: Partial<CustomTheme>): {
isValid: boolean;
error?: string;
} {
// Check required fields
const requiredFields: Array<keyof CustomTheme> = [
'name',
'Background',
'Foreground',
'LightBlue',
'AccentBlue',
'AccentPurple',
'AccentCyan',
'AccentGreen',
'AccentYellow',
'AccentRed',
'Comment',
'Gray',
];
for (const field of requiredFields) {
if (!customTheme[field]) {
return {
isValid: false,
error: `Missing required field: ${field}`,
};
}
}
// Validate color format (basic hex validation)
const colorFields: Array<keyof CustomTheme> = [
'Background',
'Foreground',
'LightBlue',
'AccentBlue',
'AccentPurple',
'AccentCyan',
'AccentGreen',
'AccentYellow',
'AccentRed',
'Comment',
'Gray',
];
for (const field of colorFields) {
const color = customTheme[field] as string;
if (!isValidColor(color)) {
return {
isValid: false,
error: `Invalid color format for ${field}: ${color}`,
};
}
}
// Validate theme name
if (customTheme.name && !isValidThemeName(customTheme.name)) {
return {
isValid: false,
error: `Invalid theme name: ${customTheme.name}`,
};
}
return { isValid: true };
}
/**
* Checks if a theme name is valid.
* @param name The theme name to validate.
* @returns True if the theme name is valid.
*/
function isValidThemeName(name: string): boolean {
// Theme name should be non-empty and not contain invalid characters
return name.trim().length > 0 && name.trim().length <= 50;
}