ui(polish) blend background color with theme (#18802)

This commit is contained in:
Jacob Richman
2026-02-12 11:56:07 -08:00
committed by GitHub
parent db00c5abf3
commit 207ac6f2dc
20 changed files with 432 additions and 240 deletions
+46 -156
View File
@@ -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');
});
});
});
+127 -3
View File
@@ -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[] {