Skip to content

Commit 54e6eba

Browse files
committed
feat(TextPainter): improve support for strings containing ansi sequences
1 parent 5781ead commit 54e6eba

File tree

5 files changed

+152
-45
lines changed

5 files changed

+152
-45
lines changed

examples/demo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,8 @@ const performanceStats = new Label({
508508
theme: baseTheme,
509509
text: new Computed(() =>
510510
`\
511-
FPS: ${fps.value.toFixed(2)}\
512-
| Components: ${tui.components.size}\
511+
${crayon.bgRed.green(`FPS: ${fps.value.toFixed(2)}`)}\
512+
| ${crayon.yellow(`Components: ${tui.components.size}`)}\
513513
| Drawn objects: ${tui.canvas.painters.length}\
514514
| Updated objects: ${tui.canvas.rerenderedObjects}\
515515
| Press CTRL+F to toggle Frame/Label visibility`

src/canvas/painters/text.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { Dependency, Subscription } from "../../signals/types.ts";
88
import { Effect } from "../../signals/effect.ts";
99
import {
1010
cropToWidth,
11+
detectMultiCodePointCharactersUsage,
1112
getMultiCodePointCharacters,
13+
reapplyCharacterStyles,
1214
textWidth,
13-
usesMultiCodePointCharacters,
1415
} from "../../utils/strings.ts";
1516
import { jinkReactiveObject, unjinkReactiveObject } from "../../signals/reactivity.ts";
1617
import { fitsInRectangle, rectangleEquals, rectangleIntersection } from "../../utils/numbers.ts";
@@ -78,7 +79,7 @@ export class TextPainter extends Painter<"text"> {
7879
this.alignHorizontally = signalify(options.alignHorizontally ?? 0);
7980

8081
this.multiCodePointSupport = signalify(
81-
options.multiCodePointSupport ?? usesMultiCodePointCharacters(this.text.peek()),
82+
options.multiCodePointSupport ?? detectMultiCodePointCharactersUsage(this.text.peek()),
8283
);
8384
this.overwriteRectangle = signalify(options.overwriteRectangle ?? false);
8485

@@ -184,7 +185,9 @@ export class TextPainter extends Painter<"text"> {
184185
}
185186

186187
if (multiCodePointSupport) {
187-
alignedLine = getMultiCodePointCharacters(alignedLine);
188+
alignedLine = reapplyCharacterStyles(
189+
getMultiCodePointCharacters(alignedLine),
190+
);
188191
}
189192

190193
if (Array.isArray(alignedLine)) {

src/components/label.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TextPainter } from "../canvas/painters/text.ts";
44
import { Computed, Signal, SignalOfObject } from "../signals/mod.ts";
55

66
import { signalify } from "../utils/signals.ts";
7-
import { splitToArray } from "../utils/strings.ts";
7+
import { detectMultiCodePointCharactersUsage, splitToArray } from "../utils/strings.ts";
88
import { Rectangle } from "../types.ts";
99

1010
/**
@@ -111,7 +111,9 @@ export class Label extends Component {
111111

112112
this.text = signalify(options.text);
113113
this.overwriteRectangle = signalify(options.overwriteRectangle ?? false);
114-
this.multiCodePointSupport = signalify(options.multiCodePointSupport ?? false);
114+
this.multiCodePointSupport = signalify(
115+
options.multiCodePointSupport ?? detectMultiCodePointCharactersUsage(this.text.peek()),
116+
);
115117
this.align = signalify(
116118
options.align ?? {
117119
vertical: 0,

src/utils/strings.ts

Lines changed: 85 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,18 @@
88
export const UNICODE_CHAR_REGEXP =
99
/\ud83c[\udffb-\udfff](?=\ud83c[\udffb-\udfff])|(?:(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40(?:\udc65|\udc73|\udc77)\udb40(?:\udc6e|\udc63|\udc6c)\udb40(?:\udc67|\udc74|\udc73)\udb40\udc7f)|[^\ud800-\udfff][\u0300-\u036f\ufe20-\ufe2f\u20d0-\u20ff\u1ab0-\u1aff\u1dc0-\u1dff]?|[\u0300-\u036f\ufe20-\ufe2f\u20d0-\u20ff\u1ab0-\u1aff\u1dc0-\u1dff]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\ud800-\udfff])[\ufe0e\ufe0f]?(?:[\u0300-\u036f\ufe20-\ufe2f\u20d0-\u20ff\u1ab0-\u1aff\u1dc0-\u1dff]|\ud83c[\udffb-\udfff])?(?:\u200d(?:[^\ud800-\udfff]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff])[\ufe0e\ufe0f]?(?:[\u0300-\u036f\ufe20-\ufe2f\u20d0-\u20ff\u1ab0-\u1aff\u1dc0-\u1dff]|\ud83c[\udffb-\udfff])?)*/g;
1010

11-
export function usesMultiCodePointCharacters(text: string | string[]): boolean {
12-
if (!text) {
13-
return false;
14-
}
15-
16-
if (Array.isArray(text)) {
11+
export function detectMultiCodePointCharactersUsage(text: string | string[]): boolean {
12+
if (!text) return false;
13+
else if (text.includes("\x1b")) return true;
14+
else if (Array.isArray(text)) {
1715
for (const line of text) {
1816
if (getMultiCodePointCharacters(line).length === line.length) {
1917
return true;
2018
}
2119
}
20+
2221
return false;
2322
}
24-
2523
return getMultiCodePointCharacters(text).length === text.length;
2624
}
2725

@@ -31,52 +29,102 @@ export function getMultiCodePointCharacters(text: string): string[] {
3129
if (!text) return empty;
3230
const matched = text.match(UNICODE_CHAR_REGEXP);
3331

34-
if (matched?.includes("\x1b")) {
35-
const arr: string[] = [];
36-
let i = 0;
37-
let ansi = 0;
38-
let lastStyle = "";
39-
for (const char of matched) {
40-
arr[i] ??= "";
41-
arr[i] += lastStyle + char;
32+
return matched ?? empty;
33+
}
34+
35+
/**
36+
* Reapplies style for each character
37+
* If given an array it does modifications on that array instead of creating a new one
38+
*
39+
* @example
40+
* ```ts
41+
* console.log(repplyCharacterStyles("\x1b[32mHi")); // "\x1b[32mH\x1b[32mi"
42+
* ```
43+
*
44+
* @example
45+
* ```ts
46+
* const arr = ["\x1b[32mH", "i"];
47+
* console.log(repplyCharacterStyles(arr)); // ["\x1b[32mH", "\x1b[32mi"];
48+
* console.log(arr); // ["\x1b[32mH", "\x1b[32mi"];
49+
* ```
50+
*/
51+
export function reapplyCharacterStyles(text: string[]): string[] {
52+
// Heuristic for skipping reapplying when text doesn't include introducer
53+
if (!text.includes("\x1b")) {
54+
return text;
55+
}
56+
57+
let i = 0;
58+
let ansi = 0;
59+
let lastStyle = "";
60+
let flushStyle = false;
4261

43-
if (char === "\x1b") {
62+
for (const char of text) {
63+
if (char === "\x1b") {
64+
// possible start of an ansi sequence
65+
++ansi;
66+
} else if (ansi === 1) {
67+
// confirm whether ansi sequence has been started
68+
if (char === "[") {
69+
lastStyle += "\x1b" + char;
4470
++ansi;
45-
lastStyle += "\x1b";
46-
} else if (ansi) {
47-
lastStyle += char;
71+
} else {
72+
ansi = 0;
73+
}
74+
} else if (ansi > 1) {
75+
lastStyle += char;
76+
77+
const isFinalByte = isFinalAnsiByte(char);
4878

49-
if (ansi === 3 && char === "m" && lastStyle[lastStyle.length - 2] === "0") {
79+
if (isFinalByte) {
80+
flushStyle = true;
81+
82+
// End of ansi sequence
83+
if (ansi === 3 && lastStyle[lastStyle.length - 2] === "0") {
84+
// Style is "\x1b[0m" – no need to store the last style when all of them got cleared
5085
lastStyle = "";
5186
}
5287

53-
if (char === "m") {
54-
ansi = 0;
55-
} else {
56-
++ansi;
57-
}
88+
ansi = 0;
5889
} else {
59-
++i;
90+
// Part of an ansi sequence
91+
++ansi;
92+
}
93+
} else {
94+
if (flushStyle) {
95+
text[i] = lastStyle + char;
6096
}
97+
98+
++i;
6199
}
100+
}
62101

63-
return arr;
102+
if (text.length > i) {
103+
while (text.length > i) {
104+
text.pop();
105+
}
64106
}
65107

66-
return matched ?? empty;
108+
return text;
109+
}
110+
111+
export function isFinalAnsiByte(character: string): boolean {
112+
const codePoint = character.charCodeAt(0);
113+
// don't include 0x70–0x7E range because its considered "private"
114+
return codePoint >= 0x40 && codePoint < 0x70;
67115
}
68116

69-
/** Strips string of all its styles */
70-
export function stripStyles(string: string): string {
117+
/** Strips text of all its styles */
118+
export function stripStyles(text: string): string {
71119
let stripped = "";
72120
let ansi = false;
73-
const len = string.length;
121+
const len = text.length;
74122
for (let i = 0; i < len; ++i) {
75-
const char = string[i];
123+
const char = text[i];
76124
if (char === "\x1b") {
77125
ansi = true;
78126
i += 2; // [ "\x1b" "[" "X" "m" ] <-- shortest ansi sequence
79-
} else if (char === "m" && ansi) {
127+
} else if (ansi && isFinalAnsiByte(char)) {
80128
ansi = false;
81129
} else if (!ansi) {
82130
stripped += char;
@@ -85,9 +133,9 @@ export function stripStyles(string: string): string {
85133
return stripped;
86134
}
87135

88-
/** Inserts {value} into {string} on given {index} */
89-
export function insertAt(string: string, index: number, value: string): string {
90-
return string.slice(0, index) + value + string.slice(index);
136+
/** Inserts {value} into {text} on given {index} */
137+
export function insertAt(text: string, index: number, value: string): string {
138+
return text.slice(0, index) + value + text.slice(index);
91139
}
92140

93141
/** Returns real {text} width */
@@ -102,7 +150,7 @@ export function textWidth(text: string, start = 0): number {
102150
if (char === "\x1b") {
103151
ansi = true;
104152
i += 2; // [ "\x1b" "[" "X" "m" ] <-- shortest ansi sequence
105-
} else if (char === "m" && ansi) {
153+
} else if (ansi && isFinalAnsiByte(char)) {
106154
ansi = false;
107155
} else if (!ansi) {
108156
width += characterWidth(char);

tests/utils/strings.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
// Copyright 2023 Im-Beast. All rights reserved. MIT license.
22

3-
import { characterWidth, insertAt, stripStyles, textWidth, UNICODE_CHAR_REGEXP } from "../../src/utils/strings.ts";
3+
import { getMultiCodePointCharacters } from "../../mod.ts";
4+
import {
5+
characterWidth,
6+
insertAt,
7+
reapplyCharacterStyles,
8+
stripStyles,
9+
textWidth,
10+
UNICODE_CHAR_REGEXP,
11+
} from "../../src/utils/strings.ts";
412
import { assertEquals } from "../deps.ts";
513

614
const unicodeString = "♥☭👀f🌏g⚠5✌💢✅💛🌻";
@@ -38,4 +46,50 @@ Deno.test("utils/strings.ts", async (t) => {
3846
assertEquals(textWidth(fullWidths.join("")), fullWidths.length * 2);
3947
assertEquals(textWidth("Hello"), 5);
4048
});
49+
50+
await t.step("getMultiCodePointCharacters()", () => {
51+
assertEquals(getMultiCodePointCharacters("dog"), ["d", "o", "g"]);
52+
assertEquals(getMultiCodePointCharacters("\x1b[32mHi\x1b[0m"), [
53+
"\x1b",
54+
"[",
55+
"3",
56+
"2",
57+
"m",
58+
"H",
59+
"i",
60+
"\x1b",
61+
"[",
62+
"0",
63+
"m",
64+
]);
65+
});
66+
67+
await t.step("reapplyCharacterStyles()", () => {
68+
assertEquals(
69+
reapplyCharacterStyles(
70+
getMultiCodePointCharacters("dog"),
71+
),
72+
["d", "o", "g"],
73+
);
74+
75+
assertEquals(
76+
reapplyCharacterStyles(
77+
getMultiCodePointCharacters("\x1b[32mHello world!"),
78+
),
79+
[
80+
"\x1b[32mH",
81+
"\x1b[32me",
82+
"\x1b[32ml",
83+
"\x1b[32ml",
84+
"\x1b[32mo",
85+
"\x1b[32m ",
86+
"\x1b[32mw",
87+
"\x1b[32mo",
88+
"\x1b[32mr",
89+
"\x1b[32ml",
90+
"\x1b[32md",
91+
"\x1b[32m!",
92+
],
93+
);
94+
});
4195
});

0 commit comments

Comments
 (0)