mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 11:34:44 -07:00
ui(polish) blend background color with theme (#18802)
This commit is contained in:
@@ -6,149 +6,7 @@
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import tinygradient from 'tinygradient';
|
||||
|
||||
// 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',
|
||||
};
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
// Define the set of Ink's named colors for quick lookup
|
||||
export const INK_SUPPORTED_NAMES = new Set([
|
||||
@@ -172,6 +30,13 @@ export const INK_SUPPORTED_NAMES = new Set([
|
||||
'whitebright',
|
||||
]);
|
||||
|
||||
// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
|
||||
export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
|
||||
Object.entries(tinycolor.names)
|
||||
.filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
|
||||
.map(([name, hex]) => [name, `#${hex}`]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -217,12 +82,19 @@ export function resolveColor(colorValue: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hex codes without #
|
||||
if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
|
||||
return `#${lowerColor}`;
|
||||
}
|
||||
|
||||
// 2. Check if it's an Ink supported name (lowercase)
|
||||
else if (INK_SUPPORTED_NAMES.has(lowerColor)) {
|
||||
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]) {
|
||||
if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
|
||||
return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
|
||||
}
|
||||
|
||||
@@ -286,27 +158,45 @@ export function getThemeTypeFromBackgroundColor(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const luminance = getLuminance(backgroundColor);
|
||||
const resolvedColor = resolveColor(backgroundColor);
|
||||
if (!resolvedColor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const luminance = getLuminance(resolvedColor);
|
||||
return luminance > 128 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
|
||||
export const INK_NAME_TO_HEX_MAP: Readonly<Record<string, string>> = {
|
||||
blackbright: '#555555',
|
||||
redbright: '#ff5555',
|
||||
greenbright: '#55ff55',
|
||||
yellowbright: '#ffff55',
|
||||
bluebright: '#5555ff',
|
||||
magentabright: '#ff55ff',
|
||||
cyanbright: '#55ffff',
|
||||
whitebright: '#ffffff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the relative luminance of a color.
|
||||
* See https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
*
|
||||
* @param backgroundColor Hex color string (with or without #)
|
||||
* @param color Color string (hex or Ink-supported name)
|
||||
* @returns Luminance value (0-255)
|
||||
*/
|
||||
export function getLuminance(backgroundColor: string): number {
|
||||
let hex = backgroundColor.replace(/^#/, '');
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
export function getLuminance(color: string): number {
|
||||
const resolved = color.toLowerCase();
|
||||
const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const colorObj = tinycolor(hex);
|
||||
if (!colorObj.isValid()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// tinycolor returns 0-1, we need 0-255
|
||||
return colorObj.getLuminance() * 255;
|
||||
}
|
||||
|
||||
// Hysteresis thresholds to prevent flickering when the background color
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('ThemeManager', () => {
|
||||
// Reset themeManager state
|
||||
themeManager.loadCustomThemes({});
|
||||
themeManager.setActiveTheme(DEFAULT_THEME.name);
|
||||
themeManager.setTerminalBackground(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -238,4 +239,114 @@ describe('ThemeManager', () => {
|
||||
expect(themeManager.isCustomTheme('SettingsTheme')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminalBackground override', () => {
|
||||
it('should store and retrieve terminal background', () => {
|
||||
themeManager.setTerminalBackground('#123456');
|
||||
expect(themeManager.getTerminalBackground()).toBe('#123456');
|
||||
themeManager.setTerminalBackground(undefined);
|
||||
expect(themeManager.getTerminalBackground()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should override background.primary in semantic colors when terminal background is set', () => {
|
||||
const color = '#1a1a1a';
|
||||
themeManager.setTerminalBackground(color);
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
expect(semanticColors.background.primary).toBe(color);
|
||||
});
|
||||
|
||||
it('should override Background in colors when terminal background is set', () => {
|
||||
const color = '#1a1a1a';
|
||||
themeManager.setTerminalBackground(color);
|
||||
const colors = themeManager.getColors();
|
||||
expect(colors.Background).toBe(color);
|
||||
});
|
||||
|
||||
it('should re-calculate dependent semantic colors when terminal background is set', () => {
|
||||
themeManager.setTerminalBackground('#000000');
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
|
||||
// border.default should be interpolated from background (#000000) and Gray
|
||||
// ui.dark should be interpolated from Gray and background (#000000)
|
||||
expect(semanticColors.border.default).toBeDefined();
|
||||
expect(semanticColors.ui.dark).toBeDefined();
|
||||
expect(semanticColors.border.default).not.toBe(
|
||||
DEFAULT_THEME.semanticColors.border.default,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original semantic colors when terminal background is NOT set', () => {
|
||||
themeManager.setTerminalBackground(undefined);
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
expect(semanticColors).toEqual(DEFAULT_THEME.semanticColors);
|
||||
});
|
||||
|
||||
it('should NOT override background when theme is incompatible (Light theme on Dark terminal)', () => {
|
||||
themeManager.setActiveTheme('Default Light');
|
||||
const darkTerminalBg = '#000000';
|
||||
themeManager.setTerminalBackground(darkTerminalBg);
|
||||
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
expect(semanticColors.background.primary).toBe(
|
||||
themeManager.getTheme('Default Light')!.colors.Background,
|
||||
);
|
||||
|
||||
const colors = themeManager.getColors();
|
||||
expect(colors.Background).toBe(
|
||||
themeManager.getTheme('Default Light')!.colors.Background,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT override background when theme is incompatible (Dark theme on Light terminal)', () => {
|
||||
themeManager.setActiveTheme('Default');
|
||||
const lightTerminalBg = '#FFFFFF';
|
||||
themeManager.setTerminalBackground(lightTerminalBg);
|
||||
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
expect(semanticColors.background.primary).toBe(
|
||||
themeManager.getTheme('Default')!.colors.Background,
|
||||
);
|
||||
|
||||
const colors = themeManager.getColors();
|
||||
expect(colors.Background).toBe(
|
||||
themeManager.getTheme('Default')!.colors.Background,
|
||||
);
|
||||
});
|
||||
|
||||
it('should override background for custom theme when compatible', () => {
|
||||
themeManager.loadCustomThemes({
|
||||
MyDark: {
|
||||
name: 'MyDark',
|
||||
type: 'custom',
|
||||
Background: '#000000',
|
||||
Foreground: '#ffffff',
|
||||
},
|
||||
});
|
||||
themeManager.setActiveTheme('MyDark');
|
||||
|
||||
const darkTerminalBg = '#1a1a1a';
|
||||
themeManager.setTerminalBackground(darkTerminalBg);
|
||||
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
expect(semanticColors.background.primary).toBe(darkTerminalBg);
|
||||
});
|
||||
|
||||
it('should NOT override background for custom theme when incompatible', () => {
|
||||
themeManager.loadCustomThemes({
|
||||
MyLight: {
|
||||
name: 'MyLight',
|
||||
type: 'custom',
|
||||
Background: '#ffffff',
|
||||
Foreground: '#000000',
|
||||
},
|
||||
});
|
||||
themeManager.setActiveTheme('MyLight');
|
||||
|
||||
const darkTerminalBg = '#000000';
|
||||
themeManager.setTerminalBackground(darkTerminalBg);
|
||||
|
||||
const semanticColors = themeManager.getSemanticColors();
|
||||
expect(semanticColors.background.primary).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,16 @@ import { ShadesOfPurple } from './shades-of-purple.js';
|
||||
import { XCode } from './xcode.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { Theme, ThemeType } from './theme.js';
|
||||
import type { Theme, ThemeType, ColorsTheme } from './theme.js';
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
import { createCustomTheme, validateCustomTheme } from './theme.js';
|
||||
import type { SemanticColors } from './semantic-tokens.js';
|
||||
import {
|
||||
interpolateColor,
|
||||
getThemeTypeFromBackgroundColor,
|
||||
resolveColor,
|
||||
} from './color-utils.js';
|
||||
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
|
||||
import { ANSI } from './ansi.js';
|
||||
import { ANSILight } from './ansi-light.js';
|
||||
import { NoColorTheme } from './no-color.js';
|
||||
@@ -42,6 +48,12 @@ class ThemeManager {
|
||||
private settingsThemes: Map<string, Theme> = new Map();
|
||||
private extensionThemes: Map<string, Theme> = new Map();
|
||||
private fileThemes: Map<string, Theme> = new Map();
|
||||
private terminalBackground: string | undefined;
|
||||
|
||||
// Cache for dynamic colors
|
||||
private cachedColors: ColorsTheme | undefined;
|
||||
private cachedSemanticColors: SemanticColors | undefined;
|
||||
private lastCacheKey: string | undefined;
|
||||
|
||||
constructor() {
|
||||
this.availableThemes = [
|
||||
@@ -63,6 +75,23 @@ class ThemeManager {
|
||||
this.activeTheme = DEFAULT_THEME;
|
||||
}
|
||||
|
||||
setTerminalBackground(color: string | undefined): void {
|
||||
if (this.terminalBackground !== color) {
|
||||
this.terminalBackground = color;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
getTerminalBackground(): string | undefined {
|
||||
return this.terminalBackground;
|
||||
}
|
||||
|
||||
private clearCache(): void {
|
||||
this.cachedColors = undefined;
|
||||
this.cachedSemanticColors = undefined;
|
||||
this.lastCacheKey = undefined;
|
||||
}
|
||||
|
||||
isDefaultTheme(themeName: string | undefined): boolean {
|
||||
return (
|
||||
themeName === undefined ||
|
||||
@@ -214,7 +243,10 @@ class ThemeManager {
|
||||
if (!theme) {
|
||||
return false;
|
||||
}
|
||||
this.activeTheme = theme;
|
||||
if (this.activeTheme !== theme) {
|
||||
this.activeTheme = theme;
|
||||
this.clearCache();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -255,12 +287,104 @@ class ThemeManager {
|
||||
return this.activeTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the colors for the active theme, respecting the terminal background.
|
||||
* @returns The theme colors.
|
||||
*/
|
||||
getColors(): ColorsTheme {
|
||||
const activeTheme = this.getActiveTheme();
|
||||
const cacheKey = `${activeTheme.name}:${this.terminalBackground}`;
|
||||
if (this.cachedColors && this.lastCacheKey === cacheKey) {
|
||||
return this.cachedColors;
|
||||
}
|
||||
|
||||
const colors = activeTheme.colors;
|
||||
if (
|
||||
this.terminalBackground &&
|
||||
this.isThemeCompatible(activeTheme, this.terminalBackground)
|
||||
) {
|
||||
this.cachedColors = {
|
||||
...colors,
|
||||
Background: this.terminalBackground,
|
||||
DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
|
||||
};
|
||||
} else {
|
||||
this.cachedColors = colors;
|
||||
}
|
||||
|
||||
this.lastCacheKey = cacheKey;
|
||||
return this.cachedColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the semantic colors for the active theme.
|
||||
* @returns The semantic colors.
|
||||
*/
|
||||
getSemanticColors(): SemanticColors {
|
||||
return this.getActiveTheme().semanticColors;
|
||||
const activeTheme = this.getActiveTheme();
|
||||
const cacheKey = `${activeTheme.name}:${this.terminalBackground}`;
|
||||
if (this.cachedSemanticColors && this.lastCacheKey === cacheKey) {
|
||||
return this.cachedSemanticColors;
|
||||
}
|
||||
|
||||
const semanticColors = activeTheme.semanticColors;
|
||||
if (
|
||||
this.terminalBackground &&
|
||||
this.isThemeCompatible(activeTheme, this.terminalBackground)
|
||||
) {
|
||||
this.cachedSemanticColors = {
|
||||
...semanticColors,
|
||||
background: {
|
||||
...semanticColors.background,
|
||||
primary: this.terminalBackground,
|
||||
},
|
||||
border: {
|
||||
...semanticColors.border,
|
||||
default: interpolateColor(
|
||||
this.terminalBackground,
|
||||
activeTheme.colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
},
|
||||
ui: {
|
||||
...semanticColors.ui,
|
||||
dark: interpolateColor(
|
||||
activeTheme.colors.Gray,
|
||||
this.terminalBackground,
|
||||
0.5,
|
||||
),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.cachedSemanticColors = semanticColors;
|
||||
}
|
||||
|
||||
this.lastCacheKey = cacheKey;
|
||||
return this.cachedSemanticColors;
|
||||
}
|
||||
|
||||
isThemeCompatible(
|
||||
activeTheme: Theme,
|
||||
terminalBackground: string | undefined,
|
||||
): boolean {
|
||||
if (activeTheme.type === 'ansi') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const backgroundType = getThemeTypeFromBackgroundColor(terminalBackground);
|
||||
if (!backgroundType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const themeType =
|
||||
activeTheme.type === 'custom'
|
||||
? getThemeTypeFromBackgroundColor(
|
||||
resolveColor(activeTheme.colors.Background) ||
|
||||
activeTheme.colors.Background,
|
||||
)
|
||||
: activeTheme.type;
|
||||
|
||||
return themeType === backgroundType;
|
||||
}
|
||||
|
||||
private _getAllCustomThemes(): Theme[] {
|
||||
|
||||
Reference in New Issue
Block a user