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
15 changes: 15 additions & 0 deletions scripts/e2e-settings-visual-theme.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';

import {
Expand Down Expand Up @@ -39,6 +40,20 @@ test('theme registry exposes the requested Monaco themes and safe fallback', ()
]);
});

test('terminal viewport inherits the active visual theme background', async () => {
const css = await readFile(new URL('../src/ui/styles.css', import.meta.url), 'utf8');
const app = await readFile(new URL('../src/ui/app.js', import.meta.url), 'utf8');
assert.match(css, /\.xterm\s+\.xterm-viewport\s*\{[^}]*background-color:\s*var\(--terminal-background\);/s);
assert.ok(
app.indexOf("import '@xterm/xterm/css/xterm.css';") < app.indexOf("import './styles.css';"),
'load application styles after xterm so terminal overrides win'
);
assert.deepEqual(
[...css.matchAll(/--terminal-background:\s*(#[\da-f]{3,6});/gi)].map(([, color]) => color.toLowerCase()),
['#11111b', '#fff', '#181818', '#fff', '#000']
);
});

function contrastRatio(foreground, background) {
const luminance = (color) => {
const channels = color.match(/[0-9a-f]{2}/gi).map((channel) => parseInt(channel, 16) / 255);
Expand Down
254 changes: 220 additions & 34 deletions scripts/smoke-browser.mjs

Large diffs are not rendered by default.

299 changes: 299 additions & 0 deletions specs/issue-113-20261013-sdlc_planner-visible-terminal-selection.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

'use strict';

import './styles.css';
import '@xterm/xterm/css/xterm.css';
import './styles.css';

import * as editorAPI from './editor.js';
import * as terminalAPI from './terminal.js';
Expand Down
28 changes: 28 additions & 0 deletions src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,34 @@ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) {
automaticLayout: true, // resize with container
});

if (new URLSearchParams(globalThis.location?.search || '').has('smoke-test')) {
container.__browserCppPrepareSelectionColorRead = () => {
return true;
};
container.__browserCppReadSelectionColors = () => {
const editorNode = _editor.getDomNode();
if (!editorNode) return null;
const viewLine = document.createElement('div');
viewLine.className = 'view-line focused';
const selected = document.createElement('span');
selected.className = 'selected-text inline-selected-text';
selected.textContent = 'theme color probe';
viewLine.append(selected);
editorNode.append(viewLine);
const toHex = (value) => {
const channels = value.match(/[\d.]+/g)?.map(Number);
if (!channels || channels.length < 3) return value;
return `#${channels.slice(0, 3).map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`;
};
const background = toHex(getComputedStyle(selected).backgroundColor);
const foreground = toHex(getComputedStyle(selected).color);
viewLine.classList.remove('focused');
const inactiveBackground = toHex(getComputedStyle(selected).backgroundColor);
viewLine.remove();
return { background, foreground, inactiveBackground };
};
}

return _editor;
}

Expand Down
15 changes: 11 additions & 4 deletions src/ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
--blue: #89b4fa;
--mauve: #cba6f7;
--peach: #fab387;
--terminal-background: #11111b;

--toolbar-h: 36px;
--statusbar-h: 22px;
Expand All @@ -35,10 +36,10 @@
background: var(--base);
}

:root[data-visual-theme="vs"] { --crust:#f6f8fa; --mantle:#fff; --base:#fff; --surface0:#d0d7de; --surface1:#afb8c1; --overlay0:#57606a; --text:#1f2328; --subtext0:#57606a; --green:#1a7f37; --red:#cf222e; --yellow:#9a6700; --blue:#0969da; --mauve:#8250df; --peach:#bc4c00; }
:root[data-visual-theme="vs-dark"] { --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; }
:root[data-visual-theme="hc-light"] { --crust:#fff; --mantle:#fff; --base:#fff; --surface0:#e5e5e5; --surface1:#6f6f6f; --overlay0:#333; --text:#000; --subtext0:#333; --green:#064; --red:#a80000; --yellow:#704d00; --blue:#0037da; --mauve:#5b00a5; --peach:#8a3100; }
:root[data-visual-theme="hc-black"] { --crust:#000; --mantle:#000; --base:#000; --surface0:#1f1f1f; --surface1:#fff; --overlay0:#d0d0d0; --text:#fff; --subtext0:#fff; --green:#0f0; --red:#ff6b6b; --yellow:#ff0; --blue:#3daee9; --mauve:#ff8cff; --peach:#ffb86c; }
:root[data-visual-theme="vs"] { --crust:#f6f8fa; --mantle:#fff; --base:#fff; --surface0:#d0d7de; --surface1:#afb8c1; --overlay0:#57606a; --text:#1f2328; --subtext0:#57606a; --green:#1a7f37; --red:#cf222e; --yellow:#9a6700; --blue:#0969da; --mauve:#8250df; --peach:#bc4c00; --terminal-background:#fff; }
:root[data-visual-theme="vs-dark"] { --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; --terminal-background:#181818; }
:root[data-visual-theme="hc-light"] { --crust:#fff; --mantle:#fff; --base:#fff; --surface0:#e5e5e5; --surface1:#6f6f6f; --overlay0:#333; --text:#000; --subtext0:#333; --green:#064; --red:#a80000; --yellow:#704d00; --blue:#0037da; --mauve:#5b00a5; --peach:#8a3100; --terminal-background:#fff; }
:root[data-visual-theme="hc-black"] { --crust:#000; --mantle:#000; --base:#000; --surface0:#1f1f1f; --surface1:#fff; --overlay0:#d0d0d0; --text:#fff; --subtext0:#fff; --green:#0f0; --red:#ff6b6b; --yellow:#ff0; --blue:#3daee9; --mauve:#ff8cff; --peach:#ffb86c; --terminal-background:#000; }

body {
height: 100vh;
Expand Down Expand Up @@ -672,6 +673,12 @@ select:focus {
height: 100%;
}

/* xterm defaults its scrollable viewport to black, which can be exposed below
* the canvas when terminal rows do not consume the full panel height. */
.xterm .xterm-viewport {
background-color: var(--terminal-background);
}

/* ── Status bar ───────────────────────────────────────────────────────────── */

#statusbar {
Expand Down
111 changes: 108 additions & 3 deletions src/ui/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ let fitAddon = null;
let terminalLineHasContent = false;
const trackedTerminals = new WeakSet();

/**
* Repaint xterm's current selection after it changes or the theme changes.
* The public selection event exposes the selected text, but xterm's renderer
* selection redraw can arrive before the final mouse-drag range is available.
*/
function refreshTerminalSelection() {
const position = term?.getSelectionPosition?.();
const renderer = term?._core?._renderService;
if (!renderer?.handleSelectionChanged) return;

renderer.handleSelectionChanged(
position ? [position.start.x, position.start.y] : undefined,
position ? [position.end.x, position.end.y] : undefined,
false
);
}

/** Current line being typed */
let inputBuffer = '';
/** History of commands entered */
Expand Down Expand Up @@ -323,6 +340,8 @@ export function createTerminal(container, {

term.open(container);
fitAddon.fit();
term.onSelectionChange(refreshTerminalSelection);
container.addEventListener('mouseup', refreshTerminalSelection);

// Browser smoke tests need the logical terminal buffer because xterm may
// render through canvas, where container.textContent contains only styles.
Expand All @@ -337,14 +356,44 @@ export function createTerminal(container, {
}
return lines.join('\n');
};
container.__browserCppRunCommand = (command) => {
if (!term || command !== 'help') return false;
term.clear();
terminalLineHasContent = false;
initialPromptShown = true;
busy = false;
inputBuffer = '';
writePrompt();
term.write(command + CRLF);
cmdHelp();
return true;
};
container.__browserCppScrollToText = (text) => {
const buffer = term?.buffer?.active;
if (!buffer || typeof text !== 'string') return false;
for (let lineIndex = 0; lineIndex < buffer.length; lineIndex += 1) {
if (buffer.getLine(lineIndex)?.translateToString(true).includes(text)) {
term.scrollToLine(lineIndex);
return true;
}
}
return false;
};
container.__browserCppClearTerminalSelection = () => {
term?.clearSelection?.();
return true;
};
container.__browserCppReadTerminalSelection = () => {
const buffer = term?.buffer?.active;
let firstTextRow = 0;
let foundFirstTextRow = false;
const visibleLines = [];
for (let row = 0; buffer && row < term.rows; row += 1) {
const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '';
if (line.trim()) {
visibleLines.push(line);
if (!foundFirstTextRow && line.trim()) {
firstTextRow = row;
break;
foundFirstTextRow = true;
}
}
return {
Expand All @@ -353,6 +402,8 @@ export function createTerminal(container, {
cols: term?.cols || 0,
rows: term?.rows || 0,
firstTextRow,
viewportY: buffer?.viewportY || 0,
visibleLines,
theme: {
background: term?.options.theme?.background,
selectionBackground: term?.options.theme?.selectionBackground,
Expand All @@ -361,6 +412,57 @@ export function createTerminal(container, {
},
};
};
container.__browserCppReadSelectionPaint = () => {
const overlays = [...container.querySelectorAll('.xterm-selection div')];
const selectionLayer = container.querySelector('.xterm-selection');
const xterm = container.querySelector('.xterm');
const selectionText = term?.getSelection?.() || '';
const selectedTextForegrounds = selectionText
? [...container.querySelectorAll('.xterm-rows > div')]
.flatMap((row) => [...row.querySelectorAll('span')])
.filter((element) => element.textContent?.includes(selectionText))
.map((element) => getComputedStyle(element).color)
: [];
const rects = overlays.map((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
background: style.backgroundColor,
};
});
const screen = container.querySelector('.xterm-screen')?.getBoundingClientRect();
const bounds = rects.reduce((box, rect) => ({
x: Math.min(box.x, rect.x),
y: Math.min(box.y, rect.y),
right: Math.max(box.right, rect.x + rect.width),
bottom: Math.max(box.bottom, rect.y + rect.height),
}), { x: Infinity, y: Infinity, right: -Infinity, bottom: -Infinity });
return {
rects,
selectionPosition: term?.getSelectionPosition?.() || null,
selectionText,
selectedTextForegrounds,
renderer: term?.options?.rendererType || 'default',
selectionLayer: selectionLayer && {
childCount: selectionLayer.childElementCount,
html: selectionLayer.innerHTML,
parentClass: selectionLayer.parentElement?.className || '',
},
xtermClass: xterm?.className || '',
bounds: rects.length ? {
x: bounds.x,
y: bounds.y,
width: bounds.right - bounds.x,
height: bounds.bottom - bounds.y,
} : null,
screen: screen && { x: screen.x, y: screen.y, width: screen.width, height: screen.height },
canvasCount: container.querySelectorAll('canvas').length,
};
};
}

// Welcome banner
Expand All @@ -375,7 +477,10 @@ export function createTerminal(container, {
}

export function setTheme(themeId) {
if (term) term.options.theme = { ...getVisualTheme(themeId).terminalTheme };
if (term) {
term.options.theme = { ...getVisualTheme(themeId).terminalTheme };
refreshTerminalSelection();
}
}

/** Resize the terminal to fill its container (call after layout changes). */
Expand Down
Loading