Skip to content

Commit 3bc9b47

Browse files
committed
Refactor terminal preview rendering
1 parent 18d53e3 commit 3bc9b47

9 files changed

Lines changed: 232 additions & 80 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const TERMINAL_LINE_BREAK = '\r\n';
2+
3+
export function sgr(...codes) {
4+
if (codes.length === 0) {
5+
return '';
6+
}
7+
8+
return `\x1b[${codes.join(';')}m`;
9+
}
10+
11+
export function resetSgr() {
12+
return '\x1b[0m';
13+
}
14+
15+
export function styledText(text, codes) {
16+
return `${sgr(...codes)}${text}${resetSgr()}`;
17+
}
18+
19+
export function joinTerminalLines(lines) {
20+
return lines.join(TERMINAL_LINE_BREAK);
21+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
TERMINAL_PREVIEW_COLUMNS,
3+
TERMINAL_PREVIEW_ROWS,
4+
TERMINAL_PREVIEW_SAMPLE_TEXT,
5+
} from '../../domain/terminal-preview/terminal-preview-model';
6+
import {
7+
joinTerminalLines,
8+
resetSgr,
9+
styledText,
10+
} from './ansi-terminal-sequence';
11+
12+
const ROW_LABEL_WIDTH = 6;
13+
const CELL_WIDTH = 7;
14+
const CELL_SEPARATOR = ' ';
15+
const ROW_LABEL_SEPARATOR = ' ';
16+
17+
function padRight(value, width) {
18+
return value.padEnd(width, ' ');
19+
}
20+
21+
function padLeft(value, width) {
22+
return value.padStart(width, ' ');
23+
}
24+
25+
function padCenter(value, width) {
26+
const availablePadding = Math.max(width - value.length, 0);
27+
const leftPadding = Math.floor(availablePadding / 2);
28+
const rightPadding = availablePadding - leftPadding;
29+
30+
return `${' '.repeat(leftPadding)}${value}${' '.repeat(rightPadding)}`;
31+
}
32+
33+
function renderTableHeader() {
34+
const labels = TERMINAL_PREVIEW_COLUMNS.map((column) => padCenter(column.label, CELL_WIDTH));
35+
36+
return `${padRight('', ROW_LABEL_WIDTH)}${ROW_LABEL_SEPARATOR}${labels.join(CELL_SEPARATOR)}`;
37+
}
38+
39+
function renderTableRow(row) {
40+
const label = padLeft(row.label, ROW_LABEL_WIDTH);
41+
const cells = TERMINAL_PREVIEW_COLUMNS.map((column) => {
42+
const codes = [...row.sgr, ...column.sgr];
43+
const sample = padCenter(TERMINAL_PREVIEW_SAMPLE_TEXT, CELL_WIDTH);
44+
45+
return styledText(sample, codes);
46+
});
47+
48+
return `${label}${ROW_LABEL_SEPARATOR}${cells.join(CELL_SEPARATOR)}${resetSgr()}`;
49+
}
50+
51+
export function buildColorsPreviewCommand() {
52+
return joinTerminalLines([
53+
renderTableHeader(),
54+
...TERMINAL_PREVIEW_ROWS.map((row) => renderTableRow(row)),
55+
]);
56+
}
Lines changed: 5 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,9 @@
11
import {
2-
TERMINAL_PREVIEW_COLUMNS,
3-
TERMINAL_PREVIEW_ROWS,
4-
TERMINAL_PREVIEW_SAMPLE_TEXT,
52
terminalPreviewHeaderLines,
63
terminalPreviewPromptCommand,
74
} from '../../domain/terminal-preview/terminal-preview-model';
8-
9-
const LINE_BREAK = '\r\n';
10-
const ROW_LABEL_WIDTH = 6;
11-
const CELL_WIDTH = 7;
12-
const CELL_SEPARATOR = ' ';
13-
const ROW_LABEL_SEPARATOR = ' ';
14-
15-
function sgr(...codes) {
16-
if (codes.length === 0) {
17-
return '';
18-
}
19-
20-
return `\x1b[${codes.join(';')}m`;
21-
}
22-
23-
function reset() {
24-
return '\x1b[0m';
25-
}
26-
27-
function padRight(value, width) {
28-
return value.padEnd(width, ' ');
29-
}
30-
31-
function padLeft(value, width) {
32-
return value.padStart(width, ' ');
33-
}
34-
35-
function padCenter(value, width) {
36-
const availablePadding = Math.max(width - value.length, 0);
37-
const leftPadding = Math.floor(availablePadding / 2);
38-
const rightPadding = availablePadding - leftPadding;
39-
40-
return `${' '.repeat(leftPadding)}${value}${' '.repeat(rightPadding)}`;
41-
}
42-
43-
function styledText(text, codes) {
44-
return `${sgr(...codes)}${text}${reset()}`;
45-
}
5+
import { buildColorsPreviewCommand } from './build-colors-preview-command';
6+
import { joinTerminalLines, styledText } from './ansi-terminal-sequence';
467

478
function renderPrompt({ command = null } = {}) {
489
const prompt = terminalPreviewPromptCommand();
@@ -64,39 +25,14 @@ function renderHelpLine() {
6425
return `Type ${styledText('help', [32])} for instructions on how to use fish`;
6526
}
6627

67-
function renderTableHeader() {
68-
const labels = TERMINAL_PREVIEW_COLUMNS.map((column) => padCenter(column.label, CELL_WIDTH));
69-
70-
return `${padRight('', ROW_LABEL_WIDTH)}${ROW_LABEL_SEPARATOR}${labels.join(CELL_SEPARATOR)}`;
71-
}
72-
73-
function renderTableRow(row) {
74-
const label = padLeft(row.label, ROW_LABEL_WIDTH);
75-
const cells = TERMINAL_PREVIEW_COLUMNS.map((column) => {
76-
const codes = [...row.sgr, ...column.sgr];
77-
const sample = padCenter(TERMINAL_PREVIEW_SAMPLE_TEXT, CELL_WIDTH);
78-
79-
return styledText(sample, codes);
80-
});
81-
82-
return `${label}${ROW_LABEL_SEPARATOR}${cells.join(CELL_SEPARATOR)}${reset()}`;
83-
}
84-
85-
function renderPreviewTable() {
86-
return [
87-
renderTableHeader(),
88-
...TERMINAL_PREVIEW_ROWS.map((row) => renderTableRow(row)),
89-
].join(LINE_BREAK);
90-
}
91-
9228
export function buildTerminalPreviewSequence() {
93-
return [
29+
return joinTerminalLines([
9430
terminalPreviewHeaderLines()[0],
9531
renderHelpLine(),
9632
renderPrompt({ command: terminalPreviewPromptCommand().command }),
9733
'',
98-
renderPreviewTable(),
34+
buildColorsPreviewCommand(),
9935
'',
10036
renderPrompt(),
101-
].join(LINE_BREAK);
37+
]);
10238
}

src/presentation/editor-page/components/TerminalDisplay.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default {
2626
(colors) => {
2727
this.renderTerminalPreview(colors);
2828
},
29-
{ immediate: true, deep: true }
29+
{ immediate: true }
3030
);
3131
},
3232
beforeUnmount() {

src/presentation/editor-page/terminal-preview/xterm-terminal-preview.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@ function waitForTerminalFont() {
1515
return document.fonts.load(TERMINAL_FONT_LOAD).then(() => document.fonts.ready);
1616
}
1717

18-
export function createXtermTerminalPreview(container, options = {}) {
18+
export function createXtermTerminalPreview(container, options = {}, dependencies = {}) {
19+
const TerminalClass = dependencies.TerminalClass ?? Terminal;
20+
const waitForFont = dependencies.waitForFont ?? waitForTerminalFont;
1921
let terminal = null;
2022
let disposed = false;
2123
let lastSequence = '';
24+
let renderedSequence = '';
2225
let lastTheme = options.theme;
2326

2427
function createTerminal() {
25-
return new Terminal({
28+
return new TerminalClass({
2629
allowProposedApi: false,
2730
cols: TERMINAL_COLUMNS,
2831
rows: TERMINAL_ROWS,
@@ -55,7 +58,7 @@ export function createXtermTerminalPreview(container, options = {}) {
5558
}
5659
}
5760

58-
waitForTerminalFont().then(openTerminal);
61+
waitForFont().then(openTerminal);
5962

6063
function render(sequence, theme) {
6164
lastSequence = sequence;
@@ -66,6 +69,12 @@ export function createXtermTerminalPreview(container, options = {}) {
6669
}
6770

6871
terminal.options.theme = theme;
72+
73+
if (sequence === renderedSequence) {
74+
return;
75+
}
76+
77+
renderedSequence = sequence;
6978
terminal.reset();
7079
terminal.write(sequence);
7180
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
joinTerminalLines,
4+
resetSgr,
5+
sgr,
6+
styledText,
7+
TERMINAL_LINE_BREAK,
8+
} from '../../../src/application/terminal-preview/ansi-terminal-sequence';
9+
10+
describe('ansi-terminal-sequence', () => {
11+
it('renders SGR styling primitives', () => {
12+
expect(sgr()).toBe('');
13+
expect(sgr(1, 32)).toBe('\x1b[1;32m');
14+
expect(resetSgr()).toBe('\x1b[0m');
15+
expect(styledText('help', [32])).toBe('\x1b[32mhelp\x1b[0m');
16+
});
17+
18+
it('joins terminal lines with CRLF', () => {
19+
expect(TERMINAL_LINE_BREAK).toBe('\r\n');
20+
expect(joinTerminalLines(['one', 'two'])).toBe('one\r\ntwo');
21+
});
22+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { buildColorsPreviewCommand } from '../../../src/application/terminal-preview/build-colors-preview-command';
3+
4+
function stripAnsi(value) {
5+
return value.replace(/\x1b\[[0-9;]*m/g, '');
6+
}
7+
8+
describe('buildColorsPreviewCommand', () => {
9+
it('renders the color table preview as a standalone terminal command result', () => {
10+
const commandOutput = buildColorsPreviewCommand();
11+
const plainText = stripAnsi(commandOutput);
12+
13+
expect(plainText).toContain('40m');
14+
expect(plainText).toContain('47m');
15+
expect(plainText).toContain('1;30m');
16+
expect(plainText).toContain('1;37m');
17+
expect(plainText.match(/gYw/g)).toHaveLength(162);
18+
});
19+
20+
it('keeps the first row label column aligned for long SGR labels', () => {
21+
const commandOutput = buildColorsPreviewCommand();
22+
const plainText = stripAnsi(commandOutput);
23+
24+
expect(plainText).toContain(' 1;30m gYw');
25+
});
26+
27+
it('renders colored table cells with SGR resets', () => {
28+
const commandOutput = buildColorsPreviewCommand();
29+
30+
expect(commandOutput).toContain('\x1b[1;30;40m');
31+
expect(commandOutput).toContain('\x1b[1;37;47m');
32+
expect(commandOutput).toContain('\r\n');
33+
});
34+
});

tests/application/terminal-preview/build-terminal-preview-sequence.test.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,14 @@ describe('buildTerminalPreviewSequence', () => {
1515
expect(plainText).toContain('ciembor@browser ~> ./colors.sh');
1616
expect(plainText).toContain('ciembor@browser ~>');
1717
expect(plainText).toContain('40m');
18-
expect(plainText).toContain('47m');
19-
expect(plainText).toContain('1;30m');
20-
expect(plainText).toContain('1;37m');
21-
expect(plainText.match(/gYw/g)).toHaveLength(162);
2218
});
2319

24-
it('renders colored prompt and table cells with SGR resets', () => {
20+
it('renders colored shell prompt segments with SGR resets', () => {
2521
const sequence = buildTerminalPreviewSequence();
2622

2723
expect(sequence).toContain('\x1b[32mhelp\x1b[0m');
2824
expect(sequence).toContain('\x1b[36mciembor\x1b[0m');
2925
expect(sequence).toContain('\x1b[34m./colors.sh\x1b[0m');
30-
expect(sequence).toContain('\x1b[1;30;40m');
31-
expect(sequence).toContain('\x1b[1;37;47m');
3226
expect(sequence).toContain('\r\n');
3327
});
3428
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { createXtermTerminalPreview } from '../../../src/presentation/editor-page/terminal-preview/xterm-terminal-preview';
3+
4+
function createTerminalClass() {
5+
const instances = [];
6+
7+
class TerminalClass {
8+
constructor(options) {
9+
this.options = options;
10+
this.open = vi.fn();
11+
this.reset = vi.fn();
12+
this.write = vi.fn();
13+
this.dispose = vi.fn();
14+
instances.push(this);
15+
}
16+
}
17+
18+
return { TerminalClass, instances };
19+
}
20+
21+
describe('createXtermTerminalPreview', () => {
22+
it('waits for the terminal font before opening xterm', async () => {
23+
const { TerminalClass, instances } = createTerminalClass();
24+
let resolveFont;
25+
const waitForFont = vi.fn(() => new Promise((resolve) => {
26+
resolveFont = resolve;
27+
}));
28+
const container = {};
29+
30+
createXtermTerminalPreview(container, {}, { TerminalClass, waitForFont });
31+
32+
expect(waitForFont).toHaveBeenCalledTimes(1);
33+
expect(instances).toHaveLength(0);
34+
35+
resolveFont();
36+
await Promise.resolve();
37+
38+
expect(instances).toHaveLength(1);
39+
expect(instances[0].open).toHaveBeenCalledWith(container);
40+
});
41+
42+
it('updates theme without rewriting unchanged terminal content', async () => {
43+
const { TerminalClass, instances } = createTerminalClass();
44+
const preview = createXtermTerminalPreview({}, {}, {
45+
TerminalClass,
46+
waitForFont: () => Promise.resolve(),
47+
});
48+
49+
preview.render('same-sequence', { background: '#000000' });
50+
await Promise.resolve();
51+
52+
const terminal = instances[0];
53+
expect(terminal.reset).toHaveBeenCalledTimes(1);
54+
expect(terminal.write).toHaveBeenCalledTimes(1);
55+
expect(terminal.write).toHaveBeenCalledWith('same-sequence');
56+
expect(terminal.options.theme).toEqual({ background: '#000000' });
57+
58+
preview.render('same-sequence', { background: '#111111' });
59+
60+
expect(terminal.reset).toHaveBeenCalledTimes(1);
61+
expect(terminal.write).toHaveBeenCalledTimes(1);
62+
expect(terminal.options.theme).toEqual({ background: '#111111' });
63+
});
64+
65+
it('rewrites terminal content when the sequence changes', async () => {
66+
const { TerminalClass, instances } = createTerminalClass();
67+
const preview = createXtermTerminalPreview({}, {}, {
68+
TerminalClass,
69+
waitForFont: () => Promise.resolve(),
70+
});
71+
72+
preview.render('first-sequence', {});
73+
await Promise.resolve();
74+
preview.render('second-sequence', {});
75+
76+
expect(instances[0].reset).toHaveBeenCalledTimes(2);
77+
expect(instances[0].write).toHaveBeenCalledTimes(2);
78+
expect(instances[0].write).toHaveBeenLastCalledWith('second-sequence');
79+
});
80+
});

0 commit comments

Comments
 (0)