mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #423 from Ark0N/fix/xterm6-selection-background
fix(terminal): name the selection colour the way xterm 6 does
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Fix an invisible terminal text selection on the light skins (#360). Every xterm palette declared its selection colour under the key `selection`, which xterm.js renamed to `selectionBackground` in v5. An `ITheme` is a plain object, so the unknown key was dropped without an error and every skin fell back to xterm's own default of `rgba(255,255,255,0.3)`: unnoticeable on the dark skins, which wanted roughly that anyway, and effectively invisible on Paper Gray, Solarized Light, Catppuccin Latte and Rosé Pine Dawn, where white at 30% over a near-white background moves a channel by about 3/255. Selecting text on those skins now highlights it, with desktop drag-select and the mobile long-press both fixed by the same rename.
|
||||
@@ -179,14 +179,23 @@
|
||||
// theme, so default behavior is unchanged. Shared at module scope and exported on the
|
||||
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
|
||||
// a separate IIFE) can read the current skin's palette.
|
||||
//
|
||||
// ⚠️ The selection key is `selectionBackground`, xterm's name for it since v5 (#360).
|
||||
// An ITheme is a plain object handed straight to xterm, so an unknown key is not an
|
||||
// error, it is silently dropped: every palette here carried `selection`, so every skin
|
||||
// drew xterm's built-in default instead, rgba(255,255,255,0.3). On the four light skins
|
||||
// that is white at 30% over a near-white background, a delta of about 3/255 — the
|
||||
// highlight was effectively invisible, which is what a long-press selection that
|
||||
// "did nothing" actually looked like. A key only works here if xterm knows its name;
|
||||
// test/skin-themes.test.ts pins the name AND that the blend stays visible.
|
||||
const CODEMAN_XTERM_THEMES = {
|
||||
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
|
||||
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
|
||||
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
|
||||
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
|
||||
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
|
||||
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selectionBackground: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
|
||||
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selectionBackground: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selectionBackground: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selectionBackground: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
|
||||
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selectionBackground: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
|
||||
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selectionBackground: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
|
||||
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selectionBackground: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
|
||||
};
|
||||
const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
|
||||
function currentSkin() {
|
||||
|
||||
@@ -32,6 +32,35 @@ function luminance(hex: string): number {
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
/** Parse the `rgba(r,g,b,a)` form the palettes use for the selection layer. */
|
||||
function rgba(value: string): { rgb: [number, number, number]; alpha: number } {
|
||||
const match = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/);
|
||||
if (!match) throw new Error(`Expected an rgba() color, got ${value}`);
|
||||
return {
|
||||
rgb: [Number(match[1]), Number(match[2]), Number(match[3])],
|
||||
alpha: Number(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What xterm actually paints: `selectionBackgroundOpaque = blend(background, selection)`.
|
||||
* Comparing the raw rgba string against the background says nothing, since a 0.2-alpha
|
||||
* layer over a near-white surface is still near-white.
|
||||
*/
|
||||
function blendOverHex(base: string, layer: string): string {
|
||||
const [br, bg, bb] = hexRgb(base);
|
||||
const { rgb, alpha } = rgba(layer);
|
||||
const mix = (b: number, l: number) => Math.round(b * (1 - alpha) + l * alpha);
|
||||
return `#${[mix(br, rgb[0]), mix(bg, rgb[1]), mix(bb, rgb[2])].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
/** Largest per-channel distance, in 0-255 units. */
|
||||
function channelDelta(first: string, second: string): number {
|
||||
const a = hexRgb(first);
|
||||
const b = hexRgb(second);
|
||||
return Math.max(...a.map((channel, index) => Math.abs(channel - b[index])));
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
@@ -71,6 +100,42 @@ describe('Codeman light skins', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Issue #360. xterm renamed this key in v5 and an ITheme is a plain object, so the
|
||||
// old `selection` key was silently dropped and every skin fell back to xterm's own
|
||||
// default of rgba(255,255,255,0.3). Nobody noticed on the dark skins, where white at
|
||||
// 30% is roughly what the palettes asked for anyway; on the light ones it made the
|
||||
// highlight invisible, which reads as "selecting text does nothing".
|
||||
it('names the selection layer the way xterm 6 does, and keeps it visible', () => {
|
||||
const XTERM_DEFAULT_SELECTION = 'rgba(255, 255, 255, 0.3)';
|
||||
const themeBlock = terminalSource.slice(
|
||||
terminalSource.indexOf('const CODEMAN_XTERM_THEMES = {'),
|
||||
terminalSource.indexOf('const CODEMAN_LIGHT_SKINS')
|
||||
);
|
||||
// The renderer only knows this spelling (xterm.js >= 5, and package.json pins ^6);
|
||||
// a palette key it does not know is dropped without an error, so the name IS the fix.
|
||||
// Asserted against package.json rather than the bundle, which is gitignored and only
|
||||
// exists after an install step.
|
||||
expect(JSON.parse(readFileSync(resolve('package.json'), 'utf8')).dependencies['@xterm/xterm']).toMatch(
|
||||
/\^?[6-9]\./
|
||||
);
|
||||
expect(themeBlock).not.toMatch(/\bselection: '/);
|
||||
|
||||
for (const [skin, theme] of Object.entries(terminal.themes)) {
|
||||
expect(theme.selectionBackground, skin).toBeDefined();
|
||||
const painted = blendOverHex(theme.background, theme.selectionBackground);
|
||||
expect(channelDelta(theme.background, painted), skin).toBeGreaterThanOrEqual(16);
|
||||
}
|
||||
|
||||
// And the half that proves the rename was the fix rather than a tidy-up: on the
|
||||
// light skins xterm's own fallback lands under that floor (about 3/255), so those
|
||||
// palettes were declaring a selection colour nothing ever read.
|
||||
for (const skin of LIGHT_SKINS) {
|
||||
const background = terminal.themes[skin].background;
|
||||
const fallback = blendOverHex(background, XTERM_DEFAULT_SELECTION);
|
||||
expect(channelDelta(background, fallback), skin).toBeLessThan(16);
|
||||
}
|
||||
});
|
||||
|
||||
it('switches live terminals between light and dark contrast policies', () => {
|
||||
const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() };
|
||||
const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() };
|
||||
|
||||
Reference in New Issue
Block a user