Skip to content

Commit d47eaef

Browse files
committed
Preserve nested ANSI styles across wrapped lines
Fixes #43
1 parent 9ab8a49 commit d47eaef

2 files changed

Lines changed: 332 additions & 23 deletions

File tree

index.js

Lines changed: 224 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,233 @@ const ESCAPES = new Set([
99
ANSI_ESCAPE_CSI,
1010
]);
1111

12-
const END_CODE = 39;
1312
const ANSI_ESCAPE_BELL = '\u0007';
1413
const ANSI_CSI = '[';
1514
const ANSI_OSC = ']';
1615
const ANSI_SGR_TERMINATOR = 'm';
16+
const ANSI_SGR_RESET = 0;
17+
const ANSI_SGR_RESET_FOREGROUND = 39;
18+
const ANSI_SGR_RESET_BACKGROUND = 49;
19+
const ANSI_SGR_RESET_UNDERLINE_COLOR = 59;
20+
const ANSI_SGR_FOREGROUND_EXTENDED = 38;
21+
const ANSI_SGR_BACKGROUND_EXTENDED = 48;
22+
const ANSI_SGR_UNDERLINE_COLOR_EXTENDED = 58;
23+
const ANSI_SGR_COLOR_MODE_256 = 5;
24+
const ANSI_SGR_COLOR_MODE_RGB = 2;
1725
const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`;
18-
const ANSI_ESCAPE_REGEX = new RegExp(`^\\u001B(?:\\${ANSI_CSI}(?<code>\\d+)${ANSI_SGR_TERMINATOR}|${ANSI_ESCAPE_LINK}(?<uri>[^\\u0007\\u001B]*)(?:\\u0007|\\u001B\\\\))`);
19-
const ANSI_ESCAPE_CSI_REGEX = new RegExp(`^\\u009B(?<code>\\d+)${ANSI_SGR_TERMINATOR}`);
26+
const ANSI_ESCAPE_REGEX = new RegExp(`^\\u001B(?:\\${ANSI_CSI}(?<sgr>[0-9;]*)${ANSI_SGR_TERMINATOR}|${ANSI_ESCAPE_LINK}(?<uri>[^\\u0007\\u001B]*)(?:\\u0007|\\u001B\\\\))`);
27+
const ANSI_ESCAPE_CSI_REGEX = new RegExp(`^\\u009B(?<sgr>[0-9;]*)${ANSI_SGR_TERMINATOR}`);
28+
const ANSI_SGR_MODIFIER_CLOSE_CODES = new Set(ansiStyles.codes.values());
29+
ANSI_SGR_MODIFIER_CLOSE_CODES.delete(ANSI_SGR_RESET);
2030

2131
const segmenter = new Intl.Segmenter();
2232
const getGraphemes = string => Array.from(segmenter.segment(string), ({segment}) => segment);
2333

2434
const wrapAnsiCode = code => `${ANSI_ESCAPE}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`;
2535
const wrapAnsiHyperlink = url => `${ANSI_ESCAPE}${ANSI_ESCAPE_LINK}${url}${ANSI_ESCAPE_BELL}`;
2636

37+
const getSgrTokens = sgrParameters => {
38+
const codes = sgrParameters.split(';').map(sgrParameter => sgrParameter === '' ? ANSI_SGR_RESET : Number.parseInt(sgrParameter, 10));
39+
const sgrTokens = [];
40+
41+
for (let index = 0; index < codes.length; index++) {
42+
const code = codes[index];
43+
44+
if (!Number.isFinite(code)) {
45+
continue;
46+
}
47+
48+
if (
49+
(
50+
code === ANSI_SGR_FOREGROUND_EXTENDED
51+
|| code === ANSI_SGR_BACKGROUND_EXTENDED
52+
|| code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED
53+
)
54+
) {
55+
if (index + 1 >= codes.length) {
56+
break;
57+
}
58+
59+
const mode = codes[index + 1];
60+
61+
if (mode === ANSI_SGR_COLOR_MODE_256 && Number.isFinite(codes[index + 2])) {
62+
sgrTokens.push([code, mode, codes[index + 2]]);
63+
index += 2;
64+
continue;
65+
}
66+
67+
const red = codes[index + 2];
68+
const green = codes[index + 3];
69+
const blue = codes[index + 4];
70+
if (
71+
mode === ANSI_SGR_COLOR_MODE_RGB
72+
&& Number.isFinite(red)
73+
&& Number.isFinite(green)
74+
&& Number.isFinite(blue)
75+
) {
76+
sgrTokens.push([code, mode, red, green, blue]);
77+
index += 4;
78+
continue;
79+
}
80+
81+
break;
82+
}
83+
84+
sgrTokens.push([code]);
85+
}
86+
87+
return sgrTokens;
88+
};
89+
90+
const removeActiveStyle = (activeStyles, family) => {
91+
const activeStyleIndex = activeStyles.findIndex(activeStyle => activeStyle.family === family);
92+
93+
if (activeStyleIndex !== -1) {
94+
activeStyles.splice(activeStyleIndex, 1);
95+
}
96+
};
97+
98+
const upsertActiveStyle = (activeStyles, nextActiveStyle) => {
99+
removeActiveStyle(activeStyles, nextActiveStyle.family);
100+
activeStyles.push(nextActiveStyle);
101+
};
102+
103+
const removeModifierStylesByClose = (activeStyles, closeCode) => {
104+
for (let index = activeStyles.length - 1; index >= 0; index--) {
105+
const activeStyle = activeStyles[index];
106+
if (activeStyle.family.startsWith('modifier-') && activeStyle.close === closeCode) {
107+
activeStyles.splice(index, 1);
108+
}
109+
}
110+
};
111+
112+
const getColorStyle = (code, sgrToken) => {
113+
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97) || (code === ANSI_SGR_FOREGROUND_EXTENDED && sgrToken.length > 1)) {
114+
return {
115+
family: 'foreground',
116+
open: sgrToken.join(';'),
117+
close: ANSI_SGR_RESET_FOREGROUND,
118+
};
119+
}
120+
121+
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107) || (code === ANSI_SGR_BACKGROUND_EXTENDED && sgrToken.length > 1)) {
122+
return {
123+
family: 'background',
124+
open: sgrToken.join(';'),
125+
close: ANSI_SGR_RESET_BACKGROUND,
126+
};
127+
}
128+
129+
if (code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED && sgrToken.length > 1) {
130+
return {
131+
family: 'underlineColor',
132+
open: sgrToken.join(';'),
133+
close: ANSI_SGR_RESET_UNDERLINE_COLOR,
134+
};
135+
}
136+
};
137+
138+
const applySgrResetCode = (code, activeStyles) => {
139+
if (code === ANSI_SGR_RESET) {
140+
activeStyles.length = 0;
141+
return true;
142+
}
143+
144+
if (code === ANSI_SGR_RESET_FOREGROUND) {
145+
removeActiveStyle(activeStyles, 'foreground');
146+
return true;
147+
}
148+
149+
if (code === ANSI_SGR_RESET_BACKGROUND) {
150+
removeActiveStyle(activeStyles, 'background');
151+
return true;
152+
}
153+
154+
if (code === ANSI_SGR_RESET_UNDERLINE_COLOR) {
155+
removeActiveStyle(activeStyles, 'underlineColor');
156+
return true;
157+
}
158+
159+
if (ANSI_SGR_MODIFIER_CLOSE_CODES.has(code)) {
160+
removeModifierStylesByClose(activeStyles, code);
161+
return true;
162+
}
163+
164+
return false;
165+
};
166+
167+
const applySgrToken = (sgrToken, activeStyles) => {
168+
const [code] = sgrToken;
169+
170+
if (applySgrResetCode(code, activeStyles)) {
171+
return;
172+
}
173+
174+
const colorStyle = getColorStyle(code, sgrToken);
175+
if (colorStyle) {
176+
upsertActiveStyle(activeStyles, colorStyle);
177+
return;
178+
}
179+
180+
const close = ansiStyles.codes.get(code);
181+
if (close !== undefined && close !== ANSI_SGR_RESET) {
182+
upsertActiveStyle(activeStyles, {
183+
family: `modifier-${code}`,
184+
open: sgrToken.join(';'),
185+
close,
186+
});
187+
}
188+
};
189+
190+
const applySgrParameters = (sgrParameters, activeStyles) => {
191+
for (const sgrToken of getSgrTokens(sgrParameters)) {
192+
applySgrToken(sgrToken, activeStyles);
193+
}
194+
};
195+
196+
const applySgrResets = (sgrParameters, activeStyles) => {
197+
for (const sgrToken of getSgrTokens(sgrParameters)) {
198+
const [code] = sgrToken;
199+
applySgrResetCode(code, activeStyles);
200+
}
201+
};
202+
203+
const applyLeadingSgrResets = (string, activeStyles) => {
204+
let remainder = string;
205+
206+
while (remainder.length > 0) {
207+
if (remainder.startsWith(ANSI_ESCAPE) && remainder[1] !== '\\') {
208+
const match = ANSI_ESCAPE_REGEX.exec(remainder);
209+
if (!match) {
210+
break;
211+
}
212+
213+
if (match.groups.sgr !== undefined) {
214+
applySgrResets(match.groups.sgr, activeStyles);
215+
}
216+
217+
remainder = remainder.slice(match[0].length);
218+
continue;
219+
}
220+
221+
if (remainder.startsWith(ANSI_ESCAPE_CSI)) {
222+
const match = ANSI_ESCAPE_CSI_REGEX.exec(remainder);
223+
if (!match || match.groups.sgr === undefined) {
224+
break;
225+
}
226+
227+
applySgrResets(match.groups.sgr, activeStyles);
228+
remainder = remainder.slice(match[0].length);
229+
continue;
230+
}
231+
232+
break;
233+
}
234+
};
235+
236+
const getClosingSgrSequence = activeStyles => [...activeStyles].reverse().map(activeStyle => wrapAnsiCode(activeStyle.close)).join('');
237+
const getOpeningSgrSequence = activeStyles => activeStyles.map(activeStyle => wrapAnsiCode(activeStyle.open)).join('');
238+
27239
// Calculate the length of words split on ' ', ignoring
28240
// the extra characters added by ANSI escape codes
29241
const wordLengths = string => string.split(' ').map(word => stringWidth(word));
@@ -116,8 +328,8 @@ const exec = (string, columns, options = {}) => {
116328
}
117329

118330
let returnValue = '';
119-
let escapeCode;
120331
let escapeUrl;
332+
const activeStyles = [];
121333

122334
const lengths = wordLengths(string);
123335
let rows = [''];
@@ -187,34 +399,28 @@ const exec = (string, columns, options = {}) => {
187399

188400
if (character === ANSI_ESCAPE && pre[index + 1] !== '\\') {
189401
const {groups} = ANSI_ESCAPE_REGEX.exec(preString.slice(preStringIndex)) || {groups: {}};
190-
if (groups.code !== undefined) {
191-
const code = Number.parseInt(groups.code, 10);
192-
escapeCode = code === END_CODE ? undefined : code;
402+
if (groups.sgr !== undefined) {
403+
applySgrParameters(groups.sgr, activeStyles);
193404
} else if (groups.uri !== undefined) {
194405
escapeUrl = groups.uri.length === 0 ? undefined : groups.uri;
195406
}
196407
} else if (character === ANSI_ESCAPE_CSI) {
197408
const {groups} = ANSI_ESCAPE_CSI_REGEX.exec(preString.slice(preStringIndex)) || {groups: {}};
198-
if (groups.code !== undefined) {
199-
const code = Number.parseInt(groups.code, 10);
200-
escapeCode = code === END_CODE ? undefined : code;
409+
if (groups.sgr !== undefined) {
410+
applySgrParameters(groups.sgr, activeStyles);
201411
}
202412
}
203413

204-
const code = ansiStyles.codes.get(Number(escapeCode));
205-
206414
if (pre[index + 1] === '\n') {
207415
if (escapeUrl) {
208416
returnValue += wrapAnsiHyperlink('');
209417
}
210418

211-
if (escapeCode && code) {
212-
returnValue += wrapAnsiCode(code);
213-
}
419+
returnValue += getClosingSgrSequence(activeStyles);
214420
} else if (character === '\n') {
215-
if (escapeCode && code) {
216-
returnValue += wrapAnsiCode(escapeCode);
217-
}
421+
const openingStyles = [...activeStyles];
422+
applyLeadingSgrResets(preString.slice(preStringIndex + 1), openingStyles);
423+
returnValue += getOpeningSgrSequence(openingStyles);
218424

219425
if (escapeUrl) {
220426
returnValue += wrapAnsiHyperlink(escapeUrl);

0 commit comments

Comments
 (0)