From f5b71d6b617eac0dcf69b4dcd312e47a6efdac28 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Tue, 13 Oct 2026 10:06:49 -0700 Subject: [PATCH] fix(terminal): align selection colors across visual themes Define contrast-checked selection colors once per theme and apply them to Monaco and xterm. Keep stored visual theme IDs stable by mapping built-in palettes to app-owned Monaco themes. Add registry and browser mouse-selection regression coverage. Closes #113 --- scripts/e2e-settings-visual-theme.test.mjs | 23 ++++++++ scripts/smoke-browser.mjs | 63 ++++++++++++++++++++++ src/ui/editor.js | 21 +++++++- src/ui/terminal.js | 24 +++++++++ src/ui/themes.mjs | 63 +++++++++++++++------- 5 files changed, 172 insertions(+), 22 deletions(-) diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs index ee827a9..2a9d983 100644 --- a/scripts/e2e-settings-visual-theme.test.mjs +++ b/scripts/e2e-settings-visual-theme.test.mjs @@ -25,9 +25,32 @@ test('theme registry exposes the requested Monaco themes and safe fallback', () assert.equal(getVisualTheme('not-a-theme').id, DEFAULT_VISUAL_THEME_ID); for (const theme of VISUAL_THEMES) { assert.ok(theme.label && theme.monacoTheme && theme.terminalTheme && theme.preview); + assert.deepEqual(theme.terminalTheme.selectionForeground, theme.selection.foreground); + assert.deepEqual(theme.terminalTheme.selectionBackground, theme.selection.background); + assert.deepEqual(theme.terminalTheme.selectionInactiveBackground, theme.selection.inactiveBackground); + assert.ok(contrastRatio(theme.selection.foreground, theme.selection.background) >= 4.5); + assert.notEqual(theme.selection.background, theme.terminalTheme.background); } + assert.deepEqual(VISUAL_THEMES.map(({ id }) => id), [ + 'browser-cpp-dark', 'vs', 'vs-dark', 'hc-light', 'hc-black', + ]); + assert.deepEqual(VISUAL_THEMES.slice(1).map(({ monacoTheme }) => monacoTheme), [ + 'browser-cpp-vs', 'browser-cpp-vs-dark', 'browser-cpp-hc-light', 'browser-cpp-hc-black', + ]); }); +function contrastRatio(foreground, background) { + const luminance = (color) => { + const channels = color.match(/[0-9a-f]{2}/gi).map((channel) => parseInt(channel, 16) / 255); + const linear = channels.map((channel) => ( + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + )); + return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722; + }; + const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a); + return (values[0] + 0.05) / (values[1] + 0.05); +} + test('visual theme defaults, sanitizes legacy storage, and persists immediately', async () => { const storage = createStorage({ memoryDebugMode: true, visualTheme: 'unknown' }); const settings = createExtensionSettings({ storage }); diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index ddb977a..a8d65c6 100644 --- a/scripts/smoke-browser.mjs +++ b/scripts/smoke-browser.mjs @@ -314,6 +314,40 @@ async function replaceEditorText(cdp, sessionId, source) { await cdp.send('Input.insertText', { text: source }, sessionId); } +async function selectTerminalText(cdp, sessionId) { + const bounds = await evaluate(cdp, sessionId, `(() => { + const screen = document.querySelector('#terminal-container .xterm-screen'); + const state = document.getElementById('terminal-container')?.__browserCppReadTerminalSelection?.(); + if (!screen || !state) return null; + const rect = screen.getBoundingClientRect(); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + cols: state.cols, + rows: state.rows, + firstTextRow: state.firstTextRow, + }; + })()`); + assert(bounds?.width > 0 && bounds?.height > 0 && bounds.cols > 0 && bounds.rows > 0, + 'Terminal screen is not available for mouse selection'); + + const cellWidth = bounds.width / bounds.cols; + const cellHeight = bounds.height / bounds.rows; + const startX = bounds.x + cellWidth; + const endX = startX + cellWidth * 9; + const y = bounds.y + (bounds.firstTextRow + 0.5) * cellHeight; + await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: startX, y, button: 'left', buttons: 1 }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: endX, y, button: 'left', buttons: 1 }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: endX, y, button: 'left', buttons: 0 }, sessionId); + return waitFor(async () => { + const selection = await evaluate(cdp, sessionId, + `document.getElementById('terminal-container')?.__browserCppReadTerminalSelection?.() || null`); + return selection?.hasSelection && selection.text.length >= 5 ? selection : null; + }, 'terminal text selection', 5_000); +} + async function openExtensionPage(cdp, extensionId) { const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', @@ -947,6 +981,35 @@ int main() { assert(createdFileText === 'hello from fstream\n', `Expected output.txt to be created, got: ${JSON.stringify(createdFileText)}`); assert(terminalText.includes('5 stream wrote output.txt'), `Expected stream-insertion output, got: ${JSON.stringify(terminalText)}`); + for (const themeId of ['vs', 'hc-light']) { + await evaluate(cdp, sessionId, `(() => { + const input = document.querySelector('input[name="visual-theme"][value="${themeId}"]'); + input?.click(); + return !!input; + })()`); + await waitFor(async () => evaluate( + cdp, + sessionId, + `document.documentElement.dataset.visualTheme === '${themeId}'` + ), `${themeId} theme selection`, 10_000); + + const selection = await selectTerminalText(cdp, sessionId); + const expectedSelectionBackground = themeId === 'vs' ? '#0969da' : '#0037da'; + assert(selection.theme.selectionBackground, `${themeId} terminal selection background is missing`); + assert(selection.theme.selectionForeground, `${themeId} terminal selection foreground is missing`); + assert.equal(selection.theme.selectionBackground, expectedSelectionBackground, + `${themeId} did not apply its configured terminal selection background`); + assert.notEqual(selection.theme.selectionBackground, selection.theme.background, + `${themeId} terminal selection is not distinct from its background`); + const currentTerminal = await evaluate( + cdp, + sessionId, + `document.getElementById('terminal-container')?.__browserCppReadTerminal?.() || ''` + ); + assert(currentTerminal.includes('5 stream wrote output.txt'), + `Terminal content was lost while applying ${themeId}`); + } + const explorerPath = await waitFor(async () => { return evaluate( cdp, diff --git a/src/ui/editor.js b/src/ui/editor.js index 01f3563..ef3b618 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -15,6 +15,7 @@ import * as monaco from 'monaco-editor'; import { parseDiagnostics as parseDiagnosticsShared } from './diagnostics.mjs'; +import { VISUAL_THEMES } from './themes.mjs'; // ── Monaco worker bootstrap ─────────────────────────────────────────────────── // Must run before any monaco API call. Tells Monaco where its worker scripts @@ -39,6 +40,7 @@ let _editor = null; * @returns {monaco.editor.IStandaloneCodeEditor} */ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) { + const darkTheme = VISUAL_THEMES.find(({ id }) => id === 'browser-cpp-dark'); // Define a custom VS Code–like dark theme that matches our CSS palette monaco.editor.defineTheme('browser-cpp-dark', { base: 'vs-dark', @@ -59,8 +61,9 @@ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) { 'editorLineNumber.activeForeground': '#a6adc8', 'editor.lineHighlightBackground': '#313244', 'editorCursor.foreground': '#f5c2e7', - 'editor.selectionBackground': '#45475a', - 'editor.inactiveSelectionBackground': '#313244', + 'editor.selectionForeground': darkTheme.selection.foreground, + 'editor.selectionBackground': darkTheme.selection.background, + 'editor.inactiveSelectionBackground': darkTheme.selection.inactiveBackground, 'editorIndentGuide.background': '#313244', 'editorWidget.background': '#181825', 'editorSuggestWidget.background': '#181825', @@ -68,6 +71,20 @@ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) { }, }); + for (const visualTheme of VISUAL_THEMES) { + if (visualTheme.id === 'browser-cpp-dark') continue; + monaco.editor.defineTheme(visualTheme.monacoTheme, { + base: visualTheme.id, + inherit: true, + rules: [], + colors: { + 'editor.selectionForeground': visualTheme.selection.foreground, + 'editor.selectionBackground': visualTheme.selection.background, + 'editor.inactiveSelectionBackground': visualTheme.selection.inactiveBackground, + }, + }); + } + _editor = monaco.editor.create(container, { value: '', language: 'cpp', diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 4674138..a3a9e26 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -337,6 +337,30 @@ export function createTerminal(container, { } return lines.join('\n'); }; + container.__browserCppReadTerminalSelection = () => { + const buffer = term?.buffer?.active; + let firstTextRow = 0; + for (let row = 0; buffer && row < term.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) || ''; + if (line.trim()) { + firstTextRow = row; + break; + } + } + return { + text: term?.getSelection() || '', + hasSelection: term?.hasSelection() || false, + cols: term?.cols || 0, + rows: term?.rows || 0, + firstTextRow, + theme: { + background: term?.options.theme?.background, + selectionBackground: term?.options.theme?.selectionBackground, + selectionForeground: term?.options.theme?.selectionForeground, + selectionInactiveBackground: term?.options.theme?.selectionInactiveBackground, + }, + }; + }; } // Welcome banner diff --git a/src/ui/themes.mjs b/src/ui/themes.mjs index 368d52f..2f792bb 100644 --- a/src/ui/themes.mjs +++ b/src/ui/themes.mjs @@ -2,8 +2,11 @@ export const DEFAULT_VISUAL_THEME_ID = 'browser-cpp-dark'; -const terminal = (background, foreground, accent) => ({ +const terminal = (background, foreground, accent, selection) => ({ background, foreground, cursor: accent, cursorAccent: background, + selectionForeground: selection.foreground, + selectionBackground: selection.background, + selectionInactiveBackground: selection.inactiveBackground, black: foreground, red: '#c01c28', green: '#2d7d46', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#0a7b83', white: foreground, brightBlack: '#768390', brightRed: '#d1242f', brightGreen: '#1a7f37', @@ -11,32 +14,52 @@ const terminal = (background, foreground, accent) => ({ brightCyan: '#1b9aaa', brightWhite: foreground, }); +const visualTheme = ({ id, label, monacoTheme, selection, cssTokens, terminalColors, preview }) => ({ + id, + label, + monacoTheme, + selection, + cssTokens, + terminalTheme: terminal( + terminalColors.background, + terminalColors.foreground, + terminalColors.accent, + selection + ), + preview, +}); + export const VISUAL_THEMES = Object.freeze([ - { + visualTheme({ id: 'browser-cpp-dark', label: 'browser.cpp Dark', monacoTheme: 'browser-cpp-dark', + selection: { foreground: '#cdd6f4', background: '#45475a', inactiveBackground: '#313244' }, cssTokens: { crust: '#11111b', mantle: '#181825', base: '#1e1e2e', surface0: '#313244', surface1: '#45475a', overlay0: '#6c7086', text: '#cdd6f4', subtext0: '#a6adc8', green: '#a6e3a1', red: '#f38ba8', yellow: '#f9e2af', blue: '#89b4fa', mauve: '#cba6f7', peach: '#fab387' }, - terminalTheme: terminal('#11111b', '#cdd6f4', '#f5c2e7'), preview: { background: '#1e1e2e', foreground: '#cdd6f4', accent: '#89b4fa' }, - }, - { - id: 'vs', label: 'Light', monacoTheme: 'vs', + terminalColors: { background: '#11111b', foreground: '#cdd6f4', accent: '#f5c2e7' }, preview: { background: '#1e1e2e', foreground: '#cdd6f4', accent: '#89b4fa' }, + }), + visualTheme({ + id: 'vs', label: 'Light', monacoTheme: 'browser-cpp-vs', + selection: { foreground: '#ffffff', background: '#0969da', inactiveBackground: '#0550ae' }, cssTokens: { crust: '#f6f8fa', mantle: '#ffffff', base: '#ffffff', surface0: '#d0d7de', surface1: '#afb8c1', overlay0: '#57606a', text: '#1f2328', subtext0: '#57606a', green: '#1a7f37', red: '#cf222e', yellow: '#9a6700', blue: '#0969da', mauve: '#8250df', peach: '#bc4c00' }, - terminalTheme: terminal('#ffffff', '#1f2328', '#0969da'), preview: { background: '#ffffff', foreground: '#1f2328', accent: '#0969da' }, - }, - { - id: 'vs-dark', label: 'Dark', monacoTheme: 'vs-dark', + terminalColors: { background: '#ffffff', foreground: '#1f2328', accent: '#0969da' }, preview: { background: '#ffffff', foreground: '#1f2328', accent: '#0969da' }, + }), + visualTheme({ + id: 'vs-dark', label: 'Dark', monacoTheme: 'browser-cpp-vs-dark', + selection: { foreground: '#ffffff', background: '#264f78', inactiveBackground: '#1f3a5f' }, cssTokens: { crust: '#181818', mantle: '#202020', base: '#1e1e1e', surface0: '#2d2d30', surface1: '#3e3e42', overlay0: '#858585', text: '#d4d4d4', subtext0: '#a0a0a0', green: '#89d185', red: '#f48771', yellow: '#dcdcaa', blue: '#569cd6', mauve: '#c586c0', peach: '#ce9178' }, - terminalTheme: terminal('#181818', '#d4d4d4', '#aeafad'), preview: { background: '#1e1e1e', foreground: '#d4d4d4', accent: '#569cd6' }, - }, - { - id: 'hc-light', label: 'High Contrast Light', monacoTheme: 'hc-light', + terminalColors: { background: '#181818', foreground: '#d4d4d4', accent: '#aeafad' }, preview: { background: '#1e1e1e', foreground: '#d4d4d4', accent: '#569cd6' }, + }), + visualTheme({ + id: 'hc-light', label: 'High Contrast Light', monacoTheme: 'browser-cpp-hc-light', + selection: { foreground: '#ffffff', background: '#0037da', inactiveBackground: '#00208a' }, cssTokens: { crust: '#ffffff', mantle: '#ffffff', base: '#ffffff', surface0: '#e5e5e5', surface1: '#6f6f6f', overlay0: '#333333', text: '#000000', subtext0: '#333333', green: '#006400', red: '#a80000', yellow: '#704d00', blue: '#0037da', mauve: '#5b00a5', peach: '#8a3100' }, - terminalTheme: terminal('#ffffff', '#000000', '#0037da'), preview: { background: '#ffffff', foreground: '#000000', accent: '#0037da' }, - }, - { - id: 'hc-black', label: 'High Contrast Dark', monacoTheme: 'hc-black', + terminalColors: { background: '#ffffff', foreground: '#000000', accent: '#0037da' }, preview: { background: '#ffffff', foreground: '#000000', accent: '#0037da' }, + }), + visualTheme({ + id: 'hc-black', label: 'High Contrast Dark', monacoTheme: 'browser-cpp-hc-black', + selection: { foreground: '#000000', background: '#ffff00', inactiveBackground: '#ffff00' }, cssTokens: { crust: '#000000', mantle: '#000000', base: '#000000', surface0: '#1f1f1f', surface1: '#ffffff', overlay0: '#d0d0d0', text: '#ffffff', subtext0: '#ffffff', green: '#00ff00', red: '#ff6b6b', yellow: '#ffff00', blue: '#3daee9', mauve: '#ff8cff', peach: '#ffb86c' }, - terminalTheme: terminal('#000000', '#ffffff', '#ffffff'), preview: { background: '#000000', foreground: '#ffffff', accent: '#ffff00' }, - }, + terminalColors: { background: '#000000', foreground: '#ffffff', accent: '#ffffff' }, preview: { background: '#000000', foreground: '#ffffff', accent: '#ffff00' }, + }), ]); export function getVisualTheme(id) {