Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions scripts/e2e-settings-visual-theme.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
63 changes: 63 additions & 0 deletions scripts/smoke-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 19 additions & 2 deletions src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -59,15 +61,30 @@ 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',
'editorSuggestWidget.border': '#45475a',
},
});

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',
Expand Down
24 changes: 24 additions & 0 deletions src/ui/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 43 additions & 20 deletions src/ui/themes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,64 @@

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',
brightYellow: '#bf8700', brightBlue: '#218bff', brightMagenta: '#a475f9',
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) {
Expand Down
Loading