Compare commits

...
19 changed files with 401 additions and 10 deletions

No files matched your search

+1
View File
@@ -76,6 +76,7 @@ on:
- "scripts/mosh-extra-resources.cjs"
- "scripts/nodePtyConptyPatch.cjs"
- "scripts/patch-xterm-macos-column-selection.cjs"
- "scripts/patch-xterm-foreground-intense.cjs"
- "scripts/patch-xterm-webgl-atlas.cjs"
- "scripts/rebuildPatchedNodePty.cjs"
- "scripts/resolve-et-bin-release.cjs"
+1
View File
@@ -251,6 +251,7 @@ export const enTerminalMessages: Messages = {
'terminal.customTheme.group.bright': 'Bright Colors',
'terminal.customTheme.color.background': 'Background',
'terminal.customTheme.color.foreground': 'Foreground',
'terminal.customTheme.color.foregroundIntense': 'Intense foreground (bold)',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selection',
'terminal.customTheme.color.black': 'Black',
+1
View File
@@ -251,6 +251,7 @@ export const esTerminalMessages: Messages = {
'terminal.customTheme.group.bright': 'Colores brillantes',
'terminal.customTheme.color.background': 'Fondo',
'terminal.customTheme.color.foreground': 'Primer plano',
'terminal.customTheme.color.foregroundIntense': 'Primer plano intenso (negrita)',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selección',
'terminal.customTheme.color.black': 'Negro',
+1
View File
@@ -269,6 +269,7 @@ export const ruTerminalMessages: Messages = {
'terminal.customTheme.group.bright': 'Яркие цвета',
'terminal.customTheme.color.background': 'Фон',
'terminal.customTheme.color.foreground': 'Текст',
'terminal.customTheme.color.foregroundIntense': 'Яркий текст (жирный)',
'terminal.customTheme.color.cursor': 'Курсор',
'terminal.customTheme.color.selection': 'Выделение',
'terminal.customTheme.color.black': 'Чёрный',
+1
View File
@@ -644,6 +644,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.customTheme.group.bright': '高亮色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗体)',
'terminal.customTheme.color.cursor': '光标',
'terminal.customTheme.color.selection': '选区',
'terminal.customTheme.color.black': '黑色',
+1
View File
@@ -644,6 +644,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.customTheme.group.bright': '標示色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗體)',
'terminal.customTheme.color.cursor': '游標',
'terminal.customTheme.color.selection': '選區',
'terminal.customTheme.color.black': '黑色',
@@ -8,6 +8,7 @@ import {
isPluginTerminalProviderRefreshCurrent,
mergePluginTerminalThemeColors,
resolvePluginTerminalTheme,
toPluginTerminalTheme,
waitForProviderResponse,
} from './usePluginTerminalProviders.ts';
@@ -24,6 +25,15 @@ const baseTheme = {
},
} as const satisfies TerminalTheme;
test('theme provider requests keep the existing palette contract with intense color enabled or disabled', () => {
for (const foregroundIntense of ['#ff8800', undefined]) {
const theme = { ...baseTheme, colors: { ...baseTheme.colors, foregroundIntense } };
assert.deepEqual(toPluginTerminalTheme(theme), { type: baseTheme.type, colors: baseTheme.colors });
assert.equal(theme.colors.foregroundIntense, foregroundIntense);
assert.equal(resolvePluginTerminalTheme(theme, { background: '#112233' }).colors.foregroundIntense, foregroundIntense);
}
});
test('terminal theme Providers merge deterministically over the host theme', () => {
const merged = mergePluginTerminalThemeColors([
{ background: '#102030', cursor: '#abcdef' },
@@ -51,6 +51,13 @@ interface UsePluginTerminalProvidersOptions extends PluginTerminalProviderRefres
getSnapshotState(): Partial<NetcattyTerminalSessionSnapshot>;
}
export function toPluginTerminalTheme(theme: TerminalTheme) {
// The preview plugin contract exposes the existing palette only. Keep the
// host-only optional color out of its strictly validated request payload.
const { foregroundIntense: _foregroundIntense, ...colors } = theme.colors;
return { type: theme.type, colors };
}
export function mergePluginTerminalThemeColors(
results: readonly PluginTerminalThemeColors[],
): PluginTerminalThemeColors {
@@ -211,10 +218,7 @@ export function usePluginTerminalProviders(options: UsePluginTerminalProvidersOp
if (hasProvider('terminal.theme')) {
tasks.push(applyCurrentProviderResponse(waitForProviderResponse(request('terminal.theme', 'provideTheme', {
reason,
currentTheme: {
type: metadata.baseTheme.type,
colors: metadata.baseTheme.colors,
},
currentTheme: toPluginTerminalTheme(metadata.baseTheme),
}, PROVIDER_DEADLINE_MS, undefined, controller.signal), controller), isCurrent,
(response) => {
setThemeColors(mergePluginTerminalThemeColors(response.results.map((result) => result.status === 'ok'
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { CustomThemeEditor } from './CustomThemeEditor';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import type { TerminalTheme } from '../../domain/models';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
test('intense foreground can be enabled, edited, disabled and saved without changing the palette', async () => {
let theme: TerminalTheme = structuredClone(TERMINAL_THEMES[0]);
delete theme.colors.foregroundIntense;
const palette = { ...theme.colors };
let renderer: ReturnType<typeof create>;
function Editor() {
const [current, setCurrent] = React.useState(theme);
return <CustomThemeEditor theme={current} onChange={(next) => { theme = next; setCurrent(next); }} />;
}
await act(async () => { renderer = create(<Editor />); });
const toggle = () => renderer.root.findByProps({ role: 'switch' });
const color = () => renderer.root.findAllByType('input').find((input) =>
input.props.type === 'color' && input.props['aria-label'] === 'terminal.customTheme.color.foregroundIntense')!;
assert.equal(toggle().props['aria-checked'], false);
assert.equal(color().props.disabled, true);
await act(async () => toggle().props.onClick());
assert.equal(theme.colors.foregroundIntense, theme.colors.foreground);
await act(async () => color().props.onChange({ target: { value: '#ff8800' } }));
assert.equal(theme.colors.foregroundIntense, '#ff8800');
assert.equal(JSON.parse(JSON.stringify(theme)).colors.foregroundIntense, '#ff8800');
await act(async () => toggle().props.onClick());
assert.equal(theme.colors.foregroundIntense, undefined);
assert.deepEqual(JSON.parse(JSON.stringify(theme.colors)), palette);
await act(async () => renderer.unmount());
});
test('iTerm import preserves Bold Color and leaves older themes disabled', async () => {
const { JSDOM } = await import('jsdom');
const { parseItermcolors } = await import('../../infrastructure/parsers/itermcolorsParser');
const dom = new JSDOM();
const original = globalThis.DOMParser;
globalThis.DOMParser = dom.window.DOMParser;
const color = (key: string, r: number, g: number, b: number) => `<key>${key}</key><dict><key>Red Component</key><real>${r}</real><key>Green Component</key><real>${g}</real><key>Blue Component</key><real>${b}</real></dict>`;
const essential = color('Background Color', 0, 0, 0) + color('Foreground Color', 1, 1, 1);
try {
assert.equal(parseItermcolors(`<plist><dict>${essential}${color('Bold Color', 1, 0.5, 0)}</dict></plist>`, 'imported')?.colors.foregroundIntense, '#ff8000');
assert.equal(parseItermcolors(`<plist><dict>${essential}</dict></plist>`, 'older')?.colors.foregroundIntense, undefined);
} finally {
if (original === undefined) delete (globalThis as { DOMParser?: typeof DOMParser }).DOMParser;
else globalThis.DOMParser = original;
dom.window.close();
}
});
+26 -3
View File
@@ -7,16 +7,24 @@
import React, { useCallback, memo } from 'react';
import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Switch } from '../ui/switch';
interface ColorFieldDef {
key: keyof TerminalTheme['colors'];
labelKey: string;
/** Fallback shown when the (optional) field has no value yet. */
fallbackKey?: keyof TerminalTheme['colors'];
}
const GENERAL_COLORS: ColorFieldDef[] = [
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
{
key: 'foregroundIntense',
labelKey: 'terminal.customTheme.color.foregroundIntense',
fallbackKey: 'foreground',
},
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
];
@@ -47,10 +55,14 @@ const ColorInput = memo(({
label,
value,
onChange,
enabled,
onEnabledChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
}) => {
// Local state for text input — allows partial hex while typing
const [textValue, setTextValue] = React.useState(value);
@@ -76,9 +88,14 @@ const ColorInput = memo(({
return (
<div className="flex items-center gap-2">
{onEnabledChange && (
<Switch checked={enabled} onCheckedChange={onEnabledChange} aria-label={label} />
)}
<div className="relative">
<input
type="color"
aria-label={label}
disabled={enabled === false}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
@@ -88,6 +105,8 @@ const ColorInput = memo(({
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
<input
type="text"
aria-label={label}
disabled={enabled === false}
value={textValue}
onChange={handleTextChange}
onBlur={handleBlur}
@@ -114,7 +133,7 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
}) => {
const { t } = useI18n();
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string | undefined) => {
onChange({
...theme,
colors: { ...theme.colors, [key]: value },
@@ -135,12 +154,16 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
{title}
</div>
<div className="space-y-1">
{fields.map(({ key, labelKey }) => (
{fields.map(({ key, labelKey, fallbackKey }) => (
<ColorInput
key={key}
label={t(labelKey)}
value={theme.colors[key]}
value={theme.colors[key] ?? (fallbackKey ? theme.colors[fallbackKey] : '') ?? ''}
onChange={(v) => updateColor(key, v)}
enabled={key === 'foregroundIntense' ? theme.colors.foregroundIntense !== undefined : undefined}
onEnabledChange={key === 'foregroundIntense'
? (enabled) => updateColor(key, enabled ? theme.colors.foreground : undefined)
: undefined}
/>
))}
</div>
+14
View File
@@ -56,3 +56,17 @@ test("terminal memo refreshes when terminal context reader callback changes", ()
false,
);
});
test('terminal memo refreshes for intense foreground edits and toggles', async () => {
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes');
const theme = TERMINAL_THEMES[0];
for (const field of ['terminalTheme', 'appearanceTheme'] as const) {
const props = { ...baseProps, [field]: theme };
const enabled = { ...props, [field]: { ...theme, colors: { ...theme.colors, foregroundIntense: '#ff8800' } } };
const recolored = { ...props, [field]: { ...theme, colors: { ...theme.colors, foregroundIntense: '#00ff00' } } };
assert.equal(terminalPropsAreEqual(props, enabled), false);
assert.equal(terminalPropsAreEqual(enabled, recolored), false);
assert.equal(terminalPropsAreEqual(enabled, props), false);
assert.equal(terminalPropsAreEqual(enabled, { ...enabled }), true);
}
});
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TerminalProps } from './terminalHelpers';
const themeFingerprint = (theme: TerminalProps['terminalTheme'] | undefined): string => (
theme?.colors ? `${theme.id}:${theme.colors.background}:${theme.colors.foreground}:${theme.colors.cursor}` : ''
theme?.colors ? `${theme.id}:${theme.colors.background}:${theme.colors.foreground}:${theme.colors.foregroundIntense ?? ''}:${theme.colors.cursor}` : ''
);
export const terminalPropsAreEqual = (
+1 -1
View File
@@ -1047,7 +1047,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}, [status]);
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.cursor}`;
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.foregroundIntense ?? ""}:${effectiveTheme.colors.cursor}`;
// Sync xterm theme before browser paint; apply synchronously on visible panes.
useLayoutEffect(() => {
+7
View File
@@ -569,6 +569,13 @@ export interface TerminalTheme {
colors: {
background: string;
foreground: string;
/**
* Optional intense variant for bold text drawn with the default foreground
* (SGR 1 / SGR 39;1). When set and different from `foreground`, bold text
* without an explicit ANSI color renders with this color instead (#3352).
* Explicit ANSI colors keep the existing normal/bright palette behavior.
*/
foregroundIntense?: string;
cursor: string;
selection: string;
black: string;
@@ -28,6 +28,7 @@ const COLOR_KEY_MAP: Record<string, keyof TerminalTheme['colors']> = {
'Ansi 15 Color': 'brightWhite',
'Background Color': 'background',
'Foreground Color': 'foreground',
'Bold Color': 'foregroundIntense',
'Cursor Color': 'cursor',
'Selection Color': 'selection',
};
@@ -138,6 +139,7 @@ export function parseItermcolors(xml: string, name: string): TerminalTheme | nul
const defaults: TerminalTheme['colors'] = {
background: colors.background,
foreground: colors.foreground,
foregroundIntense: colors.foregroundIntense,
cursor: colors.cursor || colors.foreground,
selection: colors.selection || (isDarkBackground(colors.background) ? '#264f78' : '#add6ff'),
black: colors.black || '#000000',
+2 -1
View File
@@ -42,7 +42,7 @@
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
@@ -65,6 +65,7 @@
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
"test:xterm-macos-selection": "electron scripts/xterm-macos-column-selection.live.test.cjs",
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_VERSIONS = {
xterm: "6.1.0-beta.292",
"addon-webgl": "0.20.0-beta.291",
};
// Resolve the optional color only when rendering bold default-foreground cells.
// Buffer attributes and serialized terminal output stay unchanged. Converting
// renderer-local colors before inverse/decoration/selection processing lets the
// existing renderer handle their precedence, contrast and dimming normally.
const PATCHES = [
{
file: "xterm/lib/xterm.js",
original: "t.foreground=m(e.foreground,d),t.background=",
replacement: "t.foreground=m(e.foreground,d),t.foregroundIntense=m(e.foregroundIntense,void 0),t.background=",
},
{
file: "xterm/lib/xterm.mjs",
original: "t.foreground=M(e.foreground,qe),t.background=",
replacement: "t.foreground=M(e.foreground,qe),t.foregroundIntense=M(e.foregroundIntense,void 0),t.background=",
},
{
file: "xterm/lib/xterm.js",
original: "let V=W.getFgColor(),q=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();",
replacement: "let V=W.getFgColor(),q=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();if(q===0&&W.isBold()&&b.foregroundIntense){V=b.foregroundIntense.rgba>>>8;q=50331648;}",
},
{
file: "xterm/lib/xterm.mjs",
original: "let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();",
replacement: "let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();if(Ft===0&&x.isBold()&&f.foregroundIntense){de=f.foregroundIntense.rgba>>>8;Ft=50331648;}",
},
{
file: "addon-webgl/lib/addon-webgl.js",
original: "this.result.fg=t.fg,this.result.ext=",
replacement: "this.result.fg=t.isBold()&&t.getFgColorMode()===0&&this._themeService.colors.foregroundIntense?(t.fg&~67108863)|50331648|(this._themeService.colors.foregroundIntense.rgba>>>8):t.fg,this.result.ext=",
},
{
file: "addon-webgl/lib/addon-webgl.mjs",
original: "this.result.fg=e.fg,this.result.ext=",
replacement: "this.result.fg=e.isBold()&&e.getFgColorMode()===0&&this._themeService.colors.foregroundIntense?(e.fg&~67108863)|50331648|(this._themeService.colors.foregroundIntense.rgba>>>8):e.fg,this.result.ext=",
},
];
function patchXtermSource(source, patch) {
// Some replacements contain the original. Check the fully patched shape
// first, then reject any additional original occurrences in the remainder.
const patchedCount = source.split(patch.replacement).length - 1;
if (patchedCount === 1 && !source.replace(patch.replacement, "").includes(patch.original)) {
return { source, changed: false };
}
const originalCount = source.split(patch.original).length - 1;
if (patchedCount === 0 && originalCount === 1) {
return { source: source.replace(patch.original, patch.replacement), changed: true };
}
throw new Error(`${patch.file}: unexpected foreground-intense patch shape (original=${originalCount}, patched=${patchedCount})`);
}
function patchInstalledXterm(root = process.cwd()) {
const base = path.resolve(root, "node_modules/@xterm");
for (const [name, expected] of Object.entries(EXPECTED_VERSIONS)) {
const actual = JSON.parse(fs.readFileSync(path.join(base, name, "package.json"), "utf8")).version;
if (actual !== expected) throw new Error(`unsupported @xterm/${name} version ${actual}; expected ${expected}`);
}
// Validate every bundle before writing any, so a changed dependency cannot
// leave an installation with only one renderer patched.
const files = new Map();
let changed = 0;
for (const patch of PATCHES) {
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
const result = patchXtermSource(source, patch);
files.set(patch.file, result.source);
if (result.changed) changed++;
}
if (changed) {
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
}
return { changed, checked: PATCHES.length };
}
if (require.main === module) {
try {
const result = patchInstalledXterm();
console.log(`[patch-xterm-foreground-intense] changed=${result.changed} checked=${result.checked}`);
} catch (error) {
console.error(`[patch-xterm-foreground-intense] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = { EXPECTED_VERSIONS, PATCHES, patchXtermSource, patchInstalledXterm };
@@ -0,0 +1,38 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const vm = require("node:vm");
const { PATCHES, patchXtermSource } = require("./patch-xterm-foreground-intense.cjs");
for (const patch of PATCHES) {
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
const first = patchXtermSource(`before;${patch.original}after`, patch);
assert.equal(first.changed, true);
assert.equal(first.source, `before;${patch.replacement}after`);
assert.deepEqual(patchXtermSource(first.source, patch), { source: first.source, changed: false });
assert.throws(() => patchXtermSource("upstream changed", patch));
assert.throws(() => patchXtermSource(patch.original.repeat(2), patch));
assert.throws(() => patchXtermSource(patch.replacement.repeat(2), patch));
assert.throws(() => patchXtermSource(patch.replacement + patch.original, patch));
});
}
for (const patch of PATCHES.filter(p => p.file.startsWith("addon-webgl"))) {
test(`${patch.file}: renderer color resolution preserves cell flags and buffer`, () => {
const expression = patch.replacement.slice("this.result.fg=".length, -",this.result.ext=".length);
const resolve = (fg, intense) => {
const cell = { fg, isBold: () => fg & 0x08000000, getFgColorMode: () => fg & 0x03000000 };
const context = { t: cell, e: cell, _themeService: { colors: { foregroundIntense: intense } } };
const result = vm.runInNewContext(expression, context);
assert.equal(cell.fg, fg, "must not alter the stored cell");
return result;
};
const color = { rgba: 0xff6000ff };
assert.equal(resolve(0, color), 0);
assert.equal(resolve(0x08000000, undefined), 0x08000000);
assert.equal(resolve(0x08000000, color), 0x0bff6000);
assert.equal(resolve(0x0c000000, color), 0x0fff6000, "inverse flag remains set");
assert.equal(resolve(0x09000002, color), 0x09000002, "ANSI color preserved");
assert.equal(resolve(0x0b123456, color), 0x0b123456, "truecolor preserved");
});
}
@@ -0,0 +1,136 @@
"use strict";
/* global process, __dirname, console, document, getComputedStyle */
if (!process.versions.electron) {
require("node:test")("foreground intense renders without changing terminal data", {
skip: "run with Electron to exercise DOM and WebGL rendering",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("foreground-intense")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
const bundle = esbuild.buildSync({
stdin: {
contents: 'export { Terminal } from "@xterm/xterm"; export { WebglAddon } from "@xterm/addon-webgl"; export { SerializeAddon } from "@xterm/addon-serialize";',
resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", write: false,
}).outputFiles[0].text;
async function exercise({ Terminal, WebglAddon, SerializeAddon }) {
const assert = require("node:assert/strict");
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const results = [];
for (const renderer of ["dom", "webgl"]) {
const host = document.createElement("div");
host.style.cssText = "width:800px;height:250px";
document.body.appendChild(host);
const theme = { foreground: "#aaaaaa", background: "#000000", foregroundIntense: "#ff0000", cyan: "#00aaaa", brightCyan: "#00ffff" };
const term = new Terminal({ cols: 40, rows: 8, fontFamily: "monospace", fontSize: 20, cursorBlink: false, allowProposedApi: true, theme, drawBoldTextInBrightColors: true, minimumContrastRatio: 1 });
term.open(host);
let addon;
if (renderer === "webgl") {
addon = new WebglAddon({ preserveDrawingBuffer: true });
term.loadAddon(addon);
assert.ok(host.querySelector("canvas"), "WebGL must actually initialize");
}
const serial = new SerializeAddon();
term.loadAddon(serial);
const write = data => new Promise(resolve => term.write(data, resolve));
const render = async () => { term.refresh(0, term.rows - 1); await wait(100); };
const color = (x, background = false) => {
if (renderer === "dom") {
const spans = host.querySelector(".xterm-rows").firstElementChild.children;
let offset = 0;
for (const span of spans) {
if (x < offset + span.textContent.length) return background ? getComputedStyle(span).backgroundColor : getComputedStyle(span).color;
offset += span.textContent.length;
}
throw new Error("DOM cell not found: " + x);
}
const canvas = [...host.querySelectorAll(".xterm-screen canvas")].find(c => c.getContext("webgl2"));
const gl = canvas?.getContext("webgl2");
assert.ok(gl, "read actual WebGL output");
const cell = term._core._renderService.dimensions.device.cell;
const pixels = new Uint8Array(4 * Math.floor(cell.width) * Math.floor(cell.height));
gl.readPixels(Math.floor(x * cell.width), canvas.height - Math.floor(cell.height), Math.floor(cell.width), Math.floor(cell.height), gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const counts = new Map();
for (let i = 0; i < pixels.length; i += 4) {
if (!(pixels[i] || pixels[i + 1] || pixels[i + 2])) continue;
const key = `rgb(${pixels[i]}, ${pixels[i + 1]}, ${pixels[i + 2]})`;
counts.set(key, (counts.get(key) || 0) + 1);
}
return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] || "rgb(0, 0, 0)";
};
// Blocks have a solid interior, allowing exact pixel checks despite font antialiasing.
await write("\x1b[?25l\x1b[0m█\x1b[1m█\x1b[0;36m█\x1b[1m█\x1b[39;1m█\x1b[0m");
await render();
assert.deepEqual([0, 1, 2, 3, 4].map(x => color(x)), ["rgb(170, 170, 170)", "rgb(255, 0, 0)", "rgb(0, 170, 170)", "rgb(0, 255, 255)", "rgb(255, 0, 0)"], renderer + " issue examples");
assert.equal(term.buffer.active.getLine(0).getCell(1).isFgDefault(), true, "rendering must preserve default color provenance");
const snapshot = serial.serialize();
assert.ok(!snapshot.includes("38;2;255;0;0"), "snapshot must not bake theme colors into data");
term.options.theme = { ...theme, foregroundIntense: "#00ff00" };
await render();
assert.equal(color(1), "rgb(0, 255, 0)", renderer + " recolors existing text");
term.options.theme = { ...theme, foregroundIntense: undefined };
await render();
assert.equal(color(1), "rgb(170, 170, 170)", renderer + " disabling restores existing text");
term.options.theme = { ...theme, foregroundIntense: "#00ff00" };
term.reset();
await write(snapshot);
await render();
assert.equal(color(1), "rgb(0, 255, 0)", renderer + " snapshot uses current theme");
await write("\x1b[?1049h\x1b[2J\x1b[H\x1b[1m█\x1b[0m");
await render();
assert.equal(color(0), "rgb(0, 255, 0)", renderer + " alternate screen");
await write("\x1b[?1049l");
await render();
assert.equal(color(1), "rgb(0, 255, 0)", renderer + " restores normal screen");
term.reset();
await write("\x1b[?25l█\x1b[1m█\x1b[0m");
await render();
assert.deepEqual([0, 1].map(x => color(x)), ["rgb(170, 170, 170)", "rgb(0, 255, 0)"], renderer + " full reset");
term.options.theme = { ...theme, foregroundIntense: "#00ff00", selectionForeground: "#ff00ff", selectionBackground: "#000000", selectionInactiveBackground: "#000000" };
term.reset();
await write("\x1b[?25lM\x1b[1mM\x1b[0m");
term.select(1, 0, 1);
await render();
assert.equal(color(1), "rgb(255, 0, 255)", renderer + " selection overrides intense");
const marker = term.registerMarker(0);
const decoration = term.registerDecoration({ marker, x: 1, width: 1, foregroundColor: "#ffff00", layer: "top" });
await render();
assert.equal(color(1), "rgb(255, 255, 0)", renderer + " top keyword decoration overrides selection/intense");
decoration.dispose();
marker.dispose();
term.clearSelection();
term.reset();
await write("\x1b[?25l\x1b[1;7m█ \x1b[0m");
await render();
assert.equal(color(0), "rgb(0, 0, 0)", renderer + " inverse foreground is default background");
assert.equal(color(1, true), "rgb(0, 255, 0)", renderer + " inverse background uses intense");
results.push({ renderer, issueExamples: true, themeSwitch: true, disable: true, snapshot: true, alternateScreen: true, reset: true, selection: true, decoration: true, inverse: true });
addon?.dispose();
term.dispose();
host.remove();
}
return results;
}
void app.whenReady().then(async () => {
win = new BrowserWindow({ show: true, width: 900, height: 400, webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false } });
await win.loadURL("data:text/html,<body style='margin:0;background:black'>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => { const loaded = {exports:{}}; ((module,exports)=>{${bundle}})(loaded,loaded.exports); return (${exercise.toString()})(loaded.exports); })()`);
console.log("FOREGROUND_INTENSE_RENDERING_OK", JSON.stringify(result));
cleanup(0);
}).catch(error => { console.error(error); cleanup(1); });
}