Archived
feat(terminal): add touch-friendly copy mode
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a terminal copy mode with a touch-selectable, color-preserving output snapshot and a Copy all action for mobile browsers.
|
||||
@@ -1,10 +1,13 @@
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { styleMap, type StyleInfo } from "lit/directives/style-map.js";
|
||||
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
|
||||
import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
|
||||
import { createTerminalCopySnapshot, DEFAULT_TERMINAL_ANSI_THEME, type TerminalCopyRunStyle, type TerminalCopySnapshot } from "../terminalCopySnapshot";
|
||||
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
|
||||
import "./TerminalSoftKeys";
|
||||
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
|
||||
@@ -27,6 +30,8 @@ export class TerminalPanel extends LitElement {
|
||||
@property({ type: Boolean }) autoStart = false;
|
||||
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
||||
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
|
||||
@query(".terminal-copy-content") private terminalCopyContent?: HTMLPreElement | null;
|
||||
@query(".terminal-copy-selector") private terminalCopySelector?: HTMLTextAreaElement | null;
|
||||
@state() private terminals: TerminalInfo[] = [];
|
||||
@state() private commandRuns: TerminalCommandRun[] = [];
|
||||
@state() private selectedId: string | undefined;
|
||||
@@ -37,6 +42,8 @@ export class TerminalPanel extends LitElement {
|
||||
@state() private continuingTerminalIds: string[] = [];
|
||||
@state() private defaultSoftKeysEnvironment = false;
|
||||
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
|
||||
@state() private copySnapshot: TerminalCopySnapshot | undefined;
|
||||
@state() private copyStatus: string | undefined;
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
@@ -311,7 +318,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
|
||||
this.resizeObserver.observe(terminalHost);
|
||||
terminal.onData((data) => {
|
||||
if (this.suppressTerminalInput) return;
|
||||
if (this.suppressTerminalInput || this.copySnapshot !== undefined) return;
|
||||
this.sendTerminalInput(data);
|
||||
});
|
||||
const initialSize = this.fitTerminal();
|
||||
@@ -406,6 +413,7 @@ export class TerminalPanel extends LitElement {
|
||||
}
|
||||
|
||||
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.sendTerminalInput(data);
|
||||
if (options.refocus) this.focusTerminal();
|
||||
}
|
||||
@@ -429,6 +437,8 @@ export class TerminalPanel extends LitElement {
|
||||
this.terminal?.dispose();
|
||||
this.terminal = undefined;
|
||||
this.fitAddon = undefined;
|
||||
this.copySnapshot = undefined;
|
||||
this.copyStatus = undefined;
|
||||
}
|
||||
|
||||
private renderCommandRunNotice() {
|
||||
@@ -464,20 +474,147 @@ export class TerminalPanel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
private enterCopyMode(): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.captureCopySnapshot();
|
||||
}
|
||||
|
||||
private refreshCopyMode(): void {
|
||||
if (this.copySnapshot === undefined) return;
|
||||
this.captureCopySnapshot();
|
||||
}
|
||||
|
||||
private captureCopySnapshot(): void {
|
||||
const terminal = this.terminal;
|
||||
if (terminal === undefined) return;
|
||||
const snapshot = createTerminalCopySnapshot(terminal.buffer.active, terminal.cols, {
|
||||
theme: terminal.options.theme,
|
||||
drawBoldTextInBrightColors: terminal.options.drawBoldTextInBrightColors,
|
||||
});
|
||||
this.copySnapshot = snapshot;
|
||||
this.copyStatus = undefined;
|
||||
terminal.blur();
|
||||
void this.updateComplete.then(() => {
|
||||
const selector = this.terminalCopySelector;
|
||||
if (selector === null || selector === undefined) return;
|
||||
const sourceScrollRange = Math.max(0, snapshot.physicalLineCount - terminal.rows);
|
||||
const sourceScrollTop = Math.min(sourceScrollRange, snapshot.viewportLine);
|
||||
const scrollRatio = sourceScrollRange === 0 ? 0 : sourceScrollTop / sourceScrollRange;
|
||||
selector.scrollTop = scrollRatio * Math.max(0, selector.scrollHeight - selector.clientHeight);
|
||||
this.syncCopySnapshotScroll();
|
||||
});
|
||||
}
|
||||
|
||||
private exitCopyMode(): void {
|
||||
if (this.copySnapshot === undefined) return;
|
||||
this.copySnapshot = undefined;
|
||||
this.copyStatus = undefined;
|
||||
}
|
||||
|
||||
// iOS WebKit offsets native selection hit-testing in a scrolled generic
|
||||
// overflow container. A textarea owns selection and scrolling while the
|
||||
// synchronized, noninteractive pre preserves the terminal's ANSI styling.
|
||||
// Keep its caret visible: iOS hides native selection handles with the caret.
|
||||
private syncCopySnapshotScroll(): void {
|
||||
const selector = this.terminalCopySelector;
|
||||
const content = this.terminalCopyContent;
|
||||
if (selector === null || selector === undefined || content === null || content === undefined) return;
|
||||
const selectorVerticalRange = Math.max(0, selector.scrollHeight - selector.clientHeight);
|
||||
const contentVerticalRange = Math.max(0, content.scrollHeight - content.clientHeight);
|
||||
const selectorHorizontalRange = Math.max(0, selector.scrollWidth - selector.clientWidth);
|
||||
const contentHorizontalRange = Math.max(0, content.scrollWidth - content.clientWidth);
|
||||
content.scrollTop = normalizedScrollOffset(selector.scrollTop, selectorVerticalRange, contentVerticalRange);
|
||||
content.scrollLeft = normalizedScrollOffset(selector.scrollLeft, selectorHorizontalRange, contentHorizontalRange);
|
||||
}
|
||||
|
||||
private async copyAllSnapshotText(): Promise<void> {
|
||||
const text = this.copySnapshot?.text ?? "";
|
||||
if (text === "") {
|
||||
this.copyStatus = "No terminal output to copy.";
|
||||
return;
|
||||
}
|
||||
this.copyStatus = await writeClipboardText(text) ? "Copied all terminal output." : "Unable to copy terminal output.";
|
||||
}
|
||||
|
||||
private renderCopyModeToggle() {
|
||||
if (this.selectedId === undefined) return null;
|
||||
const active = this.copySnapshot !== undefined;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class=${active ? "copy-mode-toggle selected" : "copy-mode-toggle"}
|
||||
title=${active ? "Return to the interactive terminal" : "Select and copy terminal output"}
|
||||
aria-label=${active ? "Close terminal copy mode" : "Open terminal copy mode"}
|
||||
aria-pressed=${String(active)}
|
||||
@click=${() => { if (active) this.exitCopyMode(); else this.enterCopyMode(); }}
|
||||
>
|
||||
<span>${active ? "Done" : "Select"}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCopyModeToolbar() {
|
||||
const snapshot = this.copySnapshot;
|
||||
if (snapshot === undefined) return null;
|
||||
return html`
|
||||
<div class="terminal-copy-toolbar" role="toolbar" aria-label="Terminal copy controls">
|
||||
<span aria-live="polite">${this.copyStatus ?? "Snapshot · long-press and select text"}</span>
|
||||
<small>${snapshot.physicalLineCount} ${snapshot.physicalLineCount === 1 ? "row" : "rows"}</small>
|
||||
<button type="button" @click=${() => { this.refreshCopyMode(); }}>Refresh</button>
|
||||
<button type="button" @click=${() => { void this.copyAllSnapshotText(); }}>Copy all</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCopyMode() {
|
||||
const snapshot = this.copySnapshot;
|
||||
if (snapshot === undefined) return null;
|
||||
return html`
|
||||
<section class="terminal-copy-view" aria-label="Terminal copy mode">
|
||||
${this.copyToolbarReplacesSoftKeys() ? null : this.renderCopyModeToolbar()}
|
||||
<div class="terminal-copy-layers">
|
||||
<pre class="terminal-copy-content" aria-hidden="true">${snapshot.lines.map((line, index) => html`${index === 0 ? null : "\n"}${line.runs.map((run) => html`<span style=${styleMap(terminalCopyRunStyle(run.style))}>${run.text}</span>`)}`)}</pre>
|
||||
<textarea
|
||||
class="terminal-copy-selector"
|
||||
readonly
|
||||
inputmode="none"
|
||||
wrap="soft"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
aria-label="Selectable terminal output"
|
||||
.value=${snapshot.text}
|
||||
@scroll=${() => { this.syncCopySnapshotScroll(); }}
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectedTerminalAcceptsInput(): boolean {
|
||||
const terminal = this.selectedTerminalInfo();
|
||||
return terminal !== undefined && !terminal.exited;
|
||||
}
|
||||
|
||||
private copyToolbarReplacesSoftKeys(): boolean {
|
||||
return this.copySnapshot !== undefined && this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private renderTerminalAccessoryBar() {
|
||||
if (this.copySnapshot !== undefined) return this.copyToolbarReplacesSoftKeys() ? this.renderCopyModeToolbar() : null;
|
||||
return this.shouldShowSoftKeys() ? this.renderSoftKeys() : null;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeys(): boolean {
|
||||
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeysToggle(): boolean {
|
||||
return this.selectedTerminalAcceptsInput();
|
||||
return this.copySnapshot === undefined && this.selectedTerminalAcceptsInput();
|
||||
}
|
||||
|
||||
private toggleSoftKeys(): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.softKeysEnabled = !this.softKeysEnabled;
|
||||
this.softKeysPreferenceStored = true;
|
||||
writeTerminalSoftKeysPreference(this.softKeysEnabled);
|
||||
@@ -518,6 +655,7 @@ export class TerminalPanel extends LitElement {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
<div class="terminal-tabs">
|
||||
${this.renderCopyModeToggle()}
|
||||
${this.renderSoftKeysToggle()}
|
||||
${this.terminals.map((terminal) => html`
|
||||
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
|
||||
@@ -529,9 +667,12 @@ export class TerminalPanel extends LitElement {
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.renderCommandRunNotice()}
|
||||
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
|
||||
${this.renderTerminalAccessoryBar()}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
<div class="terminal-stage">
|
||||
<div class=${this.copySnapshot === undefined ? "terminal-host" : "terminal-host copying"} ?inert=${this.copySnapshot !== undefined}></div>
|
||||
${this.renderCopyMode()}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -540,11 +681,19 @@ export class TerminalPanel extends LitElement {
|
||||
:host { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
.terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
.terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow: auto; }
|
||||
.terminal-tabs > button { box-sizing: border-box; height: 30px; line-height: 16px; }
|
||||
/* Desktop xterm already has mouse selection and hardware keys; keep touch controls to touch/narrow layouts. */
|
||||
.copy-mode-toggle, .soft-keys-toggle, terminal-soft-keys { display: none; }
|
||||
.copy-mode-toggle.selected { display: inline-flex; }
|
||||
@media (pointer: coarse), (max-width: 760px) {
|
||||
.copy-mode-toggle, .soft-keys-toggle { display: inline-flex; }
|
||||
terminal-soft-keys { display: block; }
|
||||
}
|
||||
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
button.new { flex: 0 0 auto; color: var(--pi-muted); }
|
||||
.soft-keys-toggle { flex: 0 0 auto; }
|
||||
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
.soft-keys-toggle .keyboard-icon { display: block; flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: var(--pi-danger); }
|
||||
@@ -558,7 +707,20 @@ export class TerminalPanel extends LitElement {
|
||||
.command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice button { justify-self: end; max-width: none; }
|
||||
.terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
.terminal-host { position: absolute; inset: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-host.copying { visibility: hidden; pointer-events: none; }
|
||||
.terminal-copy-view { position: absolute; inset: 0; display: flex; flex-direction: column; min-height: 0; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); }
|
||||
.terminal-copy-toolbar { box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 47px; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
||||
.terminal-copy-toolbar > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.terminal-copy-toolbar small { margin-left: auto; white-space: nowrap; color: var(--pi-dim); }
|
||||
.terminal-copy-toolbar button { flex: 0 0 auto; width: auto; min-height: 34px; padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.terminal-copy-layers { flex: 1 1 auto; min-height: 0; display: grid; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
/* xterm renders the configured 13px terminal font in 17px-high cells. */
|
||||
.terminal-copy-content, .terminal-copy-selector { grid-area: 1 / 1; box-sizing: border-box; min-width: 0; min-height: 0; width: 100%; height: 100%; margin: 0; padding: 6px; border: 0; border-radius: 0; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 17px; letter-spacing: normal; font-variant-ligatures: none; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.terminal-copy-content { overflow: auto; pointer-events: none; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); -webkit-user-select: none; user-select: none; }
|
||||
.terminal-copy-selector { z-index: 1; overflow: auto; resize: none; outline: none; appearance: none; background: transparent; color: transparent; caret-color: var(--pi-accent); -webkit-text-fill-color: transparent; cursor: text; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; touch-action: auto; }
|
||||
.terminal-copy-selector::selection { background: var(--pi-terminal-selection); color: transparent; -webkit-text-fill-color: transparent; }
|
||||
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
|
||||
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
|
||||
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
|
||||
@@ -582,6 +744,30 @@ export class TerminalPanel extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizedScrollOffset(sourceOffset: number, sourceRange: number, targetRange: number): number {
|
||||
if (sourceRange <= 0 || targetRange <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, sourceOffset / sourceRange)) * targetRange;
|
||||
}
|
||||
|
||||
function dimTerminalCopyColor(color: string): string {
|
||||
return /^#[\da-f]{6}$/i.test(color) ? `${color}80` : `color-mix(in srgb, ${color} 50%, transparent)`;
|
||||
}
|
||||
|
||||
function terminalCopyRunStyle(style: TerminalCopyRunStyle): StyleInfo {
|
||||
const decorations = [
|
||||
style.underline ? "underline" : undefined,
|
||||
style.strikethrough ? "line-through" : undefined,
|
||||
style.overline ? "overline" : undefined,
|
||||
].filter((decoration): decoration is string => decoration !== undefined).join(" ");
|
||||
return {
|
||||
color: style.invisible ? "transparent" : style.dim ? dimTerminalCopyColor(style.foreground) : style.foreground,
|
||||
backgroundColor: style.background,
|
||||
fontWeight: style.bold ? "700" : undefined,
|
||||
fontStyle: style.italic ? "italic" : undefined,
|
||||
textDecorationLine: decorations === "" ? undefined : decorations,
|
||||
};
|
||||
}
|
||||
|
||||
interface TerminalSize {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -631,6 +817,7 @@ function terminalOptions(element: HTMLElement): ITerminalOptions {
|
||||
|
||||
function terminalTheme(element: HTMLElement): ITheme {
|
||||
return {
|
||||
...DEFAULT_TERMINAL_ANSI_THEME,
|
||||
background: themeColor(element, "--pi-terminal-bg", "#05070a"),
|
||||
foreground: themeColor(element, "--pi-terminal-text", "#e6edf3"),
|
||||
cursor: themeColor(element, "--pi-accent", "#58a6ff"),
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTerminalCopySnapshot,
|
||||
type TerminalCopyBufferCellSource,
|
||||
type TerminalCopyBufferLineSource,
|
||||
type TerminalCopyBufferSource,
|
||||
} from "./terminalCopySnapshot";
|
||||
|
||||
type CellColor = { mode: "default" } | { mode: "palette"; value: number } | { mode: "rgb"; value: number };
|
||||
|
||||
interface CellOptions {
|
||||
width?: number;
|
||||
foreground?: CellColor;
|
||||
background?: CellColor;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
dim?: boolean;
|
||||
underline?: boolean;
|
||||
inverse?: boolean;
|
||||
invisible?: boolean;
|
||||
strikethrough?: boolean;
|
||||
overline?: boolean;
|
||||
}
|
||||
|
||||
class TestCell implements TerminalCopyBufferCellSource {
|
||||
constructor(private readonly chars: string, private readonly options: CellOptions = {}) {}
|
||||
|
||||
getWidth(): number { return this.options.width ?? 1; }
|
||||
getChars(): string { return this.chars; }
|
||||
getCode(): number { return this.chars.codePointAt(0) ?? 0; }
|
||||
getFgColorMode(): number { return 0; }
|
||||
getBgColorMode(): number { return 0; }
|
||||
getFgColor(): number { return colorValue(this.options.foreground); }
|
||||
getBgColor(): number { return colorValue(this.options.background); }
|
||||
isBold(): number { return Number(this.options.bold === true); }
|
||||
isItalic(): number { return Number(this.options.italic === true); }
|
||||
isDim(): number { return Number(this.options.dim === true); }
|
||||
isUnderline(): number { return Number(this.options.underline === true); }
|
||||
isInverse(): number { return Number(this.options.inverse === true); }
|
||||
isInvisible(): number { return Number(this.options.invisible === true); }
|
||||
isStrikethrough(): number { return Number(this.options.strikethrough === true); }
|
||||
isOverline(): number { return Number(this.options.overline === true); }
|
||||
isFgRGB(): boolean { return this.options.foreground?.mode === "rgb"; }
|
||||
isBgRGB(): boolean { return this.options.background?.mode === "rgb"; }
|
||||
isFgPalette(): boolean { return this.options.foreground?.mode === "palette"; }
|
||||
isBgPalette(): boolean { return this.options.background?.mode === "palette"; }
|
||||
isFgDefault(): boolean { return this.options.foreground === undefined || this.options.foreground.mode === "default"; }
|
||||
isBgDefault(): boolean { return this.options.background === undefined || this.options.background.mode === "default"; }
|
||||
isAttributeDefault(): boolean {
|
||||
return this.options.foreground === undefined
|
||||
&& this.options.background === undefined
|
||||
&& this.options.bold !== true
|
||||
&& this.options.italic !== true
|
||||
&& this.options.dim !== true
|
||||
&& this.options.underline !== true
|
||||
&& this.options.inverse !== true
|
||||
&& this.options.invisible !== true
|
||||
&& this.options.strikethrough !== true
|
||||
&& this.options.overline !== true;
|
||||
}
|
||||
}
|
||||
|
||||
class TestLine implements TerminalCopyBufferLineSource {
|
||||
readonly length: number;
|
||||
|
||||
constructor(private readonly cells: (TestCell | undefined)[], readonly isWrapped = false) {
|
||||
this.length = cells.length;
|
||||
}
|
||||
|
||||
getCell(column: number, cell?: TerminalCopyBufferCellSource): TerminalCopyBufferCellSource | undefined {
|
||||
void cell;
|
||||
return this.cells[column];
|
||||
}
|
||||
}
|
||||
|
||||
class TestBuffer implements TerminalCopyBufferSource {
|
||||
readonly length: number;
|
||||
|
||||
constructor(
|
||||
private readonly lines: (TestLine | undefined)[],
|
||||
readonly baseY = 0,
|
||||
readonly cursorY = Math.max(0, lines.length - 1),
|
||||
readonly viewportY = baseY,
|
||||
) {
|
||||
this.length = lines.length;
|
||||
}
|
||||
|
||||
getLine(index: number): TerminalCopyBufferLineSource | undefined {
|
||||
return this.lines[index];
|
||||
}
|
||||
|
||||
getNullCell(): TerminalCopyBufferCellSource {
|
||||
return new TestCell("");
|
||||
}
|
||||
}
|
||||
|
||||
describe("createTerminalCopySnapshot", () => {
|
||||
it("joins wrapped physical rows into selectable logical lines", () => {
|
||||
const buffer = new TestBuffer([
|
||||
line("abc"),
|
||||
line("def", true),
|
||||
line("next"),
|
||||
]);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(buffer, 20);
|
||||
|
||||
expect(snapshot.lines.map((item) => item.text)).toEqual(["abcdef", "next"]);
|
||||
expect(snapshot.text).toBe("abcdef\nnext");
|
||||
expect(snapshot.physicalLineCount).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves palette, RGB, inverse, and text-decoration styles", () => {
|
||||
const styled = new TestLine([
|
||||
new TestCell("A", { foreground: { mode: "palette", value: 1 }, bold: true }),
|
||||
new TestCell("B", { foreground: { mode: "palette", value: 1 }, bold: true }),
|
||||
new TestCell("C", {
|
||||
foreground: { mode: "rgb", value: 0x123456 },
|
||||
background: { mode: "palette", value: 4 },
|
||||
italic: true,
|
||||
underline: true,
|
||||
strikethrough: true,
|
||||
overline: true,
|
||||
}),
|
||||
new TestCell("D", {
|
||||
foreground: { mode: "palette", value: 2 },
|
||||
background: { mode: "rgb", value: 0x010203 },
|
||||
inverse: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(new TestBuffer([styled]), 20);
|
||||
|
||||
expect(snapshot.lines[0]?.runs).toHaveLength(3);
|
||||
expect(snapshot.lines[0]?.runs[0]).toMatchObject({
|
||||
text: "AB",
|
||||
style: { foreground: "#ef2929", background: "#000000", bold: true },
|
||||
});
|
||||
expect(snapshot.lines[0]?.runs[1]).toMatchObject({
|
||||
text: "C",
|
||||
style: {
|
||||
foreground: "#123456",
|
||||
background: "#3465a4",
|
||||
italic: true,
|
||||
underline: true,
|
||||
strikethrough: true,
|
||||
overline: true,
|
||||
},
|
||||
});
|
||||
expect(snapshot.lines[0]?.runs[2]).toMatchObject({
|
||||
text: "D",
|
||||
style: { foreground: "#010203", background: "#4e9a06" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses terminal theme colors and extended ANSI overrides", () => {
|
||||
const source = new TestLine([
|
||||
new TestCell("A"),
|
||||
new TestCell("B", { foreground: { mode: "palette", value: 1 } }),
|
||||
new TestCell("C", { foreground: { mode: "palette", value: 16 } }),
|
||||
]);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 20, {
|
||||
theme: {
|
||||
foreground: "#eeeeee",
|
||||
background: "#111111",
|
||||
red: "#aa0000",
|
||||
extendedAnsi: ["#abcdef"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.lines[0]?.runs.map((run) => [run.text, run.style.foreground, run.style.background])).toEqual([
|
||||
["A", "#eeeeee", "#111111"],
|
||||
["B", "#aa0000", "#111111"],
|
||||
["C", "#abcdef", "#111111"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps interior blanks while trimming unused cells on the right", () => {
|
||||
const source = new TestLine([
|
||||
new TestCell("A"),
|
||||
new TestCell(""),
|
||||
new TestCell("B"),
|
||||
new TestCell(""),
|
||||
new TestCell(""),
|
||||
]);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 5);
|
||||
|
||||
expect(snapshot.text).toBe("A B");
|
||||
});
|
||||
|
||||
it("includes the cursor line but omits unused rows below it", () => {
|
||||
const buffer = new TestBuffer([
|
||||
line("output"),
|
||||
new TestLine([new TestCell("")]),
|
||||
new TestLine([new TestCell("")]),
|
||||
], 0, 1, 1);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(buffer, 20);
|
||||
|
||||
expect(snapshot.lines.map((item) => item.text)).toEqual(["output", ""]);
|
||||
expect(snapshot.text).toBe("output\n");
|
||||
expect(snapshot.physicalLineCount).toBe(2);
|
||||
expect(snapshot.viewportLine).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves wide and combined cells while skipping continuation cells", () => {
|
||||
const source = new TestLine([
|
||||
new TestCell("👩💻", { width: 2 }),
|
||||
new TestCell("", { width: 0 }),
|
||||
new TestCell("é"),
|
||||
new TestCell("!"),
|
||||
]);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 10);
|
||||
|
||||
expect(snapshot.text).toBe("👩💻é!");
|
||||
});
|
||||
|
||||
it("respects disabled bold-to-bright color promotion and terminal column bounds", () => {
|
||||
const source = new TestLine([
|
||||
new TestCell("A", { foreground: { mode: "palette", value: 1 }, bold: true }),
|
||||
new TestCell("B"),
|
||||
new TestCell("C"),
|
||||
]);
|
||||
|
||||
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 2, { drawBoldTextInBrightColors: false });
|
||||
|
||||
expect(snapshot.text).toBe("AB");
|
||||
expect(snapshot.lines[0]?.runs[0]?.style.foreground).toBe("#cc0000");
|
||||
});
|
||||
|
||||
it("returns an empty snapshot when there are no usable columns", () => {
|
||||
expect(createTerminalCopySnapshot(new TestBuffer([line("output")]), 0)).toEqual({
|
||||
text: "",
|
||||
lines: [],
|
||||
physicalLineCount: 0,
|
||||
viewportLine: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function line(text: string, isWrapped = false): TestLine {
|
||||
return new TestLine(Array.from(text, (character) => new TestCell(character)), isWrapped);
|
||||
}
|
||||
|
||||
function colorValue(color: CellColor | undefined): number {
|
||||
return color?.mode === "default" || color === undefined ? 0 : color.value;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
export interface TerminalCopyBufferSource {
|
||||
readonly baseY: number;
|
||||
readonly cursorY: number;
|
||||
readonly viewportY: number;
|
||||
readonly length: number;
|
||||
getLine(index: number): TerminalCopyBufferLineSource | undefined;
|
||||
getNullCell(): TerminalCopyBufferCellSource;
|
||||
}
|
||||
|
||||
export interface TerminalCopyBufferLineSource {
|
||||
readonly isWrapped: boolean;
|
||||
readonly length: number;
|
||||
getCell(column: number, cell?: TerminalCopyBufferCellSource): TerminalCopyBufferCellSource | undefined;
|
||||
}
|
||||
|
||||
export interface TerminalCopyBufferCellSource {
|
||||
getWidth(): number;
|
||||
getChars(): string;
|
||||
getCode(): number;
|
||||
getFgColorMode(): number;
|
||||
getBgColorMode(): number;
|
||||
getFgColor(): number;
|
||||
getBgColor(): number;
|
||||
isBold(): number;
|
||||
isItalic(): number;
|
||||
isDim(): number;
|
||||
isUnderline(): number;
|
||||
isInverse(): number;
|
||||
isInvisible(): number;
|
||||
isStrikethrough(): number;
|
||||
isOverline(): number;
|
||||
isFgRGB(): boolean;
|
||||
isBgRGB(): boolean;
|
||||
isFgPalette(): boolean;
|
||||
isBgPalette(): boolean;
|
||||
isFgDefault(): boolean;
|
||||
isBgDefault(): boolean;
|
||||
isAttributeDefault(): boolean;
|
||||
}
|
||||
|
||||
export interface TerminalCopyRunStyle {
|
||||
foreground: string;
|
||||
background: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
dim: boolean;
|
||||
underline: boolean;
|
||||
invisible: boolean;
|
||||
strikethrough: boolean;
|
||||
overline: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalCopyRun {
|
||||
text: string;
|
||||
style: TerminalCopyRunStyle;
|
||||
}
|
||||
|
||||
export interface TerminalCopyLine {
|
||||
text: string;
|
||||
runs: TerminalCopyRun[];
|
||||
}
|
||||
|
||||
export interface TerminalCopySnapshot {
|
||||
text: string;
|
||||
lines: TerminalCopyLine[];
|
||||
physicalLineCount: number;
|
||||
viewportLine: number;
|
||||
}
|
||||
|
||||
export interface TerminalCopySnapshotOptions {
|
||||
theme?: ITheme | undefined;
|
||||
drawBoldTextInBrightColors?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface CapturedPhysicalLine extends TerminalCopyLine {
|
||||
wrapped: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_FOREGROUND = "#ffffff";
|
||||
const DEFAULT_BACKGROUND = "#000000";
|
||||
const DEFAULT_ANSI_COLORS = [
|
||||
"#2e3436", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
|
||||
"#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
|
||||
] as const;
|
||||
const ANSI_THEME_KEYS = [
|
||||
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
||||
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
|
||||
] as const satisfies readonly (keyof ITheme)[];
|
||||
|
||||
// Pin the palette used by both xterm and its copy snapshot so a dependency
|
||||
// upgrade cannot make the interactive and selectable views drift apart.
|
||||
export const DEFAULT_TERMINAL_ANSI_THEME: ITheme = {
|
||||
black: DEFAULT_ANSI_COLORS[0],
|
||||
red: DEFAULT_ANSI_COLORS[1],
|
||||
green: DEFAULT_ANSI_COLORS[2],
|
||||
yellow: DEFAULT_ANSI_COLORS[3],
|
||||
blue: DEFAULT_ANSI_COLORS[4],
|
||||
magenta: DEFAULT_ANSI_COLORS[5],
|
||||
cyan: DEFAULT_ANSI_COLORS[6],
|
||||
white: DEFAULT_ANSI_COLORS[7],
|
||||
brightBlack: DEFAULT_ANSI_COLORS[8],
|
||||
brightRed: DEFAULT_ANSI_COLORS[9],
|
||||
brightGreen: DEFAULT_ANSI_COLORS[10],
|
||||
brightYellow: DEFAULT_ANSI_COLORS[11],
|
||||
brightBlue: DEFAULT_ANSI_COLORS[12],
|
||||
brightMagenta: DEFAULT_ANSI_COLORS[13],
|
||||
brightCyan: DEFAULT_ANSI_COLORS[14],
|
||||
brightWhite: DEFAULT_ANSI_COLORS[15],
|
||||
};
|
||||
|
||||
export function createTerminalCopySnapshot(
|
||||
buffer: TerminalCopyBufferSource,
|
||||
columns: number,
|
||||
options: TerminalCopySnapshotOptions = {},
|
||||
): TerminalCopySnapshot {
|
||||
const columnCount = Math.max(0, Math.floor(columns));
|
||||
if (buffer.length <= 0 || columnCount === 0) return { text: "", lines: [], physicalLineCount: 0, viewportLine: 0 };
|
||||
|
||||
const palette = terminalAnsiPalette(options.theme);
|
||||
const foreground = options.theme?.foreground ?? DEFAULT_FOREGROUND;
|
||||
const background = options.theme?.background ?? DEFAULT_BACKGROUND;
|
||||
const physicalLines: CapturedPhysicalLine[] = [];
|
||||
const reusableCell = buffer.getNullCell();
|
||||
let lastMeaningfulLine = -1;
|
||||
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
const sourceLine = buffer.getLine(index);
|
||||
if (sourceLine === undefined) {
|
||||
physicalLines.push({ text: "", runs: [], wrapped: false });
|
||||
continue;
|
||||
}
|
||||
const line = capturePhysicalLine(sourceLine, columnCount, reusableCell, {
|
||||
palette,
|
||||
foreground,
|
||||
background,
|
||||
drawBoldTextInBrightColors: options.drawBoldTextInBrightColors !== false,
|
||||
});
|
||||
physicalLines.push(line);
|
||||
if (line.text !== "" || line.runs.some((run) => run.style.background !== background)) lastMeaningfulLine = index;
|
||||
}
|
||||
|
||||
const cursorLine = Math.min(buffer.length - 1, Math.max(0, buffer.baseY + buffer.cursorY));
|
||||
const endLine = Math.max(lastMeaningfulLine, cursorLine);
|
||||
const includedPhysicalLines = physicalLines.slice(0, endLine + 1);
|
||||
const lines: TerminalCopyLine[] = [];
|
||||
|
||||
for (const physicalLine of includedPhysicalLines) {
|
||||
const currentLine = lines.at(-1);
|
||||
if (physicalLine.wrapped && currentLine !== undefined) {
|
||||
currentLine.text += physicalLine.text;
|
||||
appendRuns(currentLine.runs, physicalLine.runs);
|
||||
continue;
|
||||
}
|
||||
lines.push({ text: physicalLine.text, runs: physicalLine.runs.map((run) => ({ text: run.text, style: run.style })) });
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.map((line) => line.text).join("\n"),
|
||||
lines,
|
||||
physicalLineCount: includedPhysicalLines.length,
|
||||
viewportLine: Math.min(endLine, Math.max(0, buffer.viewportY)),
|
||||
};
|
||||
}
|
||||
|
||||
interface CaptureColors {
|
||||
palette: readonly string[];
|
||||
foreground: string;
|
||||
background: string;
|
||||
drawBoldTextInBrightColors: boolean;
|
||||
}
|
||||
|
||||
function capturePhysicalLine(sourceLine: TerminalCopyBufferLineSource, columns: number, reusableCell: TerminalCopyBufferCellSource, colors: CaptureColors): CapturedPhysicalLine {
|
||||
const cells: { text: string; meaningful: boolean; style: TerminalCopyRunStyle }[] = [];
|
||||
const cellCount = Math.min(columns, sourceLine.length);
|
||||
|
||||
for (let column = 0; column < cellCount; column += 1) {
|
||||
const cell = sourceLine.getCell(column, reusableCell);
|
||||
if (cell === undefined) {
|
||||
cells.push({ text: " ", meaningful: false, style: defaultRunStyle(colors) });
|
||||
continue;
|
||||
}
|
||||
const width = cell.getWidth();
|
||||
if (width === 0) continue;
|
||||
const chars = cell.getChars();
|
||||
cells.push({
|
||||
text: chars === "" ? " ".repeat(Math.max(1, width)) : chars,
|
||||
meaningful: chars !== "" || !cell.isAttributeDefault(),
|
||||
style: copyRunStyle(cell, colors),
|
||||
});
|
||||
}
|
||||
|
||||
let lastMeaningfulCell = cells.length - 1;
|
||||
while (lastMeaningfulCell >= 0 && cells[lastMeaningfulCell]?.meaningful !== true) lastMeaningfulCell -= 1;
|
||||
|
||||
const runs: TerminalCopyRun[] = [];
|
||||
let text = "";
|
||||
for (let index = 0; index <= lastMeaningfulCell; index += 1) {
|
||||
const cell = cells[index];
|
||||
if (cell === undefined) continue;
|
||||
text += cell.text;
|
||||
appendRun(runs, { text: cell.text, style: cell.style });
|
||||
}
|
||||
|
||||
return { text, runs, wrapped: sourceLine.isWrapped };
|
||||
}
|
||||
|
||||
function copyRunStyle(cell: TerminalCopyBufferCellSource, colors: CaptureColors): TerminalCopyRunStyle {
|
||||
const inverse = cell.isInverse() !== 0;
|
||||
let foreground = resolveCellColor(cell, "foreground", colors);
|
||||
let background = resolveCellColor(cell, "background", colors);
|
||||
if (inverse) [foreground, background] = [background, foreground];
|
||||
|
||||
if (cell.isBold() !== 0 && colors.drawBoldTextInBrightColors) {
|
||||
const foregroundPaletteIndex = inverse
|
||||
? cell.isBgPalette() ? cell.getBgColor() : undefined
|
||||
: cell.isFgPalette() ? cell.getFgColor() : undefined;
|
||||
if (foregroundPaletteIndex !== undefined && foregroundPaletteIndex >= 0 && foregroundPaletteIndex < 8) {
|
||||
foreground = colors.palette[foregroundPaletteIndex + 8] ?? foreground;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
foreground,
|
||||
background,
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
dim: cell.isDim() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
invisible: cell.isInvisible() !== 0,
|
||||
strikethrough: cell.isStrikethrough() !== 0,
|
||||
overline: cell.isOverline() !== 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCellColor(cell: TerminalCopyBufferCellSource, target: "foreground" | "background", colors: CaptureColors): string {
|
||||
const rgb = target === "foreground" ? cell.isFgRGB() : cell.isBgRGB();
|
||||
const palette = target === "foreground" ? cell.isFgPalette() : cell.isBgPalette();
|
||||
const value = target === "foreground" ? cell.getFgColor() : cell.getBgColor();
|
||||
if (rgb) return rgbColor(value);
|
||||
if (palette) return colors.palette[value] ?? (target === "foreground" ? colors.foreground : colors.background);
|
||||
return target === "foreground" ? colors.foreground : colors.background;
|
||||
}
|
||||
|
||||
function defaultRunStyle(colors: CaptureColors): TerminalCopyRunStyle {
|
||||
return {
|
||||
foreground: colors.foreground,
|
||||
background: colors.background,
|
||||
bold: false,
|
||||
italic: false,
|
||||
dim: false,
|
||||
underline: false,
|
||||
invisible: false,
|
||||
strikethrough: false,
|
||||
overline: false,
|
||||
};
|
||||
}
|
||||
|
||||
function appendRuns(target: TerminalCopyRun[], incoming: readonly TerminalCopyRun[]): void {
|
||||
for (const run of incoming) appendRun(target, run);
|
||||
}
|
||||
|
||||
function appendRun(runs: TerminalCopyRun[], run: TerminalCopyRun): void {
|
||||
if (run.text === "") return;
|
||||
const previous = runs.at(-1);
|
||||
if (previous !== undefined && sameRunStyle(previous.style, run.style)) {
|
||||
previous.text += run.text;
|
||||
return;
|
||||
}
|
||||
runs.push({ text: run.text, style: run.style });
|
||||
}
|
||||
|
||||
function sameRunStyle(left: TerminalCopyRunStyle, right: TerminalCopyRunStyle): boolean {
|
||||
return left.foreground === right.foreground
|
||||
&& left.background === right.background
|
||||
&& left.bold === right.bold
|
||||
&& left.italic === right.italic
|
||||
&& left.dim === right.dim
|
||||
&& left.underline === right.underline
|
||||
&& left.invisible === right.invisible
|
||||
&& left.strikethrough === right.strikethrough
|
||||
&& left.overline === right.overline;
|
||||
}
|
||||
|
||||
function terminalAnsiPalette(theme: ITheme | undefined): string[] {
|
||||
const colors: string[] = [...DEFAULT_ANSI_COLORS];
|
||||
for (let index = 0; index < ANSI_THEME_KEYS.length; index += 1) {
|
||||
const key = ANSI_THEME_KEYS[index];
|
||||
if (key === undefined) continue;
|
||||
const themedColor = theme?.[key];
|
||||
if (typeof themedColor === "string") colors[index] = themedColor;
|
||||
}
|
||||
|
||||
const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
|
||||
for (let index = 0; index < 216; index += 1) {
|
||||
const red = levels[Math.floor(index / 36) % 6] ?? 0;
|
||||
const green = levels[Math.floor(index / 6) % 6] ?? 0;
|
||||
const blue = levels[index % 6] ?? 0;
|
||||
colors.push(rgbChannels(red, green, blue));
|
||||
}
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const channel = 8 + index * 10;
|
||||
colors.push(rgbChannels(channel, channel, channel));
|
||||
}
|
||||
for (let index = 0; index < Math.min(theme?.extendedAnsi?.length ?? 0, 240); index += 1) {
|
||||
const themedColor = theme?.extendedAnsi?.[index];
|
||||
if (themedColor !== undefined) colors[index + 16] = themedColor;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
function rgbColor(value: number): string {
|
||||
return `#${(value & 0xFFFFFF).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function rgbChannels(red: number, green: number, blue: number): string {
|
||||
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user