Skip to content

Commit 570f7c9

Browse files
committed
refactor(internal): [takeVisible] add ansi support
Signed-off-by: Lexus Drumgold <unicornware@flexdevelopment.llc>
1 parent 6ebe50b commit 570f7c9

File tree

7 files changed

+131
-7
lines changed

7 files changed

+131
-7
lines changed

__tests__/utils/hrc.mts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @file Test Utilities - hrc
3+
* @module tests/utils/hrc
4+
*/
5+
6+
import type { ToString } from '@flex-development/string-wrap'
7+
8+
/**
9+
* Make control characters in `value` human-readable.
10+
*
11+
* @this {void}
12+
*
13+
* @param {unknown} value
14+
* The string containing control characters.
15+
* Non-string values will be converted to strings (i.e. `toString(value)`)
16+
* @param {ToString | null | undefined} [toString]
17+
* Convert `value` to a string
18+
* @return {string}
19+
* The `value` with human-readable control characters
20+
*/
21+
function hrc(
22+
this: void,
23+
value: unknown,
24+
toString?: ToString | null | undefined
25+
): string {
26+
toString ??= String
27+
28+
/**
29+
* Regular expression matching control characters.
30+
*
31+
* @const {RegExp} re
32+
*/
33+
const re: RegExp = /[\u0000-\u001F\u007F-\u009F]/g
34+
35+
return toString(value).replace(re, hr)
36+
37+
/**
38+
* Convert a control `character` to a human-readable string.
39+
*
40+
* @this {void}
41+
*
42+
* @param {string} character
43+
* The control character
44+
* @return {string}
45+
* The control `character` as a human-readable string
46+
*/
47+
function hr(this: void, character: string): string {
48+
return `\\u${character.codePointAt(0)!.toString(16).padStart(4, '0')}`
49+
}
50+
}
51+
52+
export default hrc

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
"typecheck:ui": "yarn test:ui --typecheck --mode=typecheck"
117117
},
118118
"dependencies": {
119+
"@flex-development/ansi-regex": "1.0.0",
119120
"@flex-development/fsm-tokenizer": "1.0.0-alpha.1",
120121
"devlop": "1.1.0",
121122
"grapheme-splitter": "1.0.4",
@@ -127,6 +128,7 @@
127128
"@commitlint/cli": "20.1.0",
128129
"@commitlint/types": "20.0.0",
129130
"@faker-js/faker": "10.1.0",
131+
"@flex-development/colors": "2.0.0",
130132
"@flex-development/commitlint-config": "1.0.1",
131133
"@flex-development/eslint-config": "1.1.1",
132134
"@flex-development/grease": "3.0.0-alpha.9",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[1m🦄🦾🚀\\u001b[22m", "1"]) 1`] = `"\\u001b[1m🦄"`;
4+
5+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[4m0123456789\\u001b[24m", 3]) 1`] = `"\\u001b[4m012"`;
6+
7+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[32mdog\\u001b[39m", "1"]) 1`] = `"\\u001b[32md"`;
8+
9+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[32mhello\\u001b[39mworld", "5"]) 1`] = `"\\u001b[32mhello\\u001b[39m"`;
10+
311
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["0123456789", "3"]) 1`] = `"012"`;
412

513
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["🦄🦾🚀", 1]) 1`] = `"🦄"`;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[1m🦄🦾🚀\\u001b[22m", "1"]) 1`] = `"\\u001b[1m🦄"`;
4+
5+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[4m0123456789\\u001b[24m", 3]) 1`] = `"\\u001b[4m012"`;
6+
7+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[32mdog\\u001b[39m", "1"]) 1`] = `"\\u001b[32md"`;
8+
9+
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["\\u001b[32mhello\\u001b[39mworld", "5"]) 1`] = `"\\u001b[32mhello\\u001b[39m"`;
10+
311
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["0123456789", "3"]) 1`] = `"012"`;
412

513
exports[`unit:internal/takeVisible > should return longest substring that fits into \`columns\` (["🦄🦾🚀", 1]) 1`] = `"🦄"`;

src/internal/__tests__/take-visible.spec.mts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
* @module string-wrap/internal/tests/unit/takeVisible
44
*/
55

6-
import digits from '#fixtures/digits'
7-
import emojis from '#fixtures/emoji-sequence'
6+
import digitSequence from '#fixtures/digit-sequence'
7+
import emojiSequence from '#fixtures/emoji-sequence'
88
import testSubject from '#internal/take-visible'
9+
import hrc from '#tests/utils/hrc'
910
import { faker } from '@faker-js/faker'
11+
import colors from '@flex-development/colors'
1012
import { chars } from '@flex-development/fsm-tokenizer'
1113

1214
describe('unit:internal/takeVisible', () => {
@@ -18,12 +20,16 @@ describe('unit:internal/takeVisible', () => {
1820
})
1921

2022
it.each<Parameters<typeof testSubject>>([
21-
[emojis, +chars.digit1],
22-
[digits.join(chars.empty), chars.digit3]
23+
[colors.bold(emojiSequence), chars.digit1],
24+
[colors.underline(digitSequence), +chars.digit3],
25+
[colors.green('dog'), chars.digit1],
26+
[colors.green('hello') + 'world', chars.digit5],
27+
[emojiSequence, +chars.digit1],
28+
[digitSequence, chars.digit3]
2329
])('should return longest substring that fits into `columns` ([%j, %j])', (
2430
sequence,
2531
columns
2632
) => {
27-
expect(testSubject(sequence, columns)).toMatchSnapshot()
33+
expect(hrc(testSubject(sequence, columns))).toMatchSnapshot()
2834
})
2935
})

src/internal/take-visible.mts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
*/
55

66
import gs from '#internal/gs'
7+
import { ansiRegex } from '@flex-development/ansi-regex'
78
import { chars } from '@flex-development/fsm-tokenizer'
89
import stringWidth from 'string-width'
910

1011
/**
1112
* Get the longest prefix of `sequence` that
1213
* fits within the specified number of `columns`.
1314
*
15+
* > 👉 **Note**: ANSI escape codes do not count towards substring width.
16+
*
1417
* @internal
1518
*
1619
* @this {void}
@@ -29,6 +32,13 @@ function takeVisible(
2932
): string {
3033
if (+columns <= 0) return chars.empty
3134

35+
/**
36+
* Regular expression matching ANSI escape codes.
37+
*
38+
* @const {RegExp} ansi
39+
*/
40+
const ansi: RegExp = new RegExp(`^(${ansiRegex().source})`)
41+
3242
/**
3343
* List of grapheme clusters.
3444
*
@@ -50,7 +60,11 @@ function takeVisible(
5060
*/
5161
let width: number = 0
5262

63+
// build substring using graphemes.
64+
// ansi escape codes do not count towards the width of the substring.
5365
for (const [index, grapheme] of graphemes.entries()) {
66+
if (index < last) continue // skip clusters in ansi escape codes.
67+
5468
/**
5569
* The visual width of the current grapheme.
5670
*
@@ -59,11 +73,29 @@ function takeVisible(
5973
const size: number = stringWidth(grapheme)
6074

6175
// stop once column limit is reached, but ignore non-printing characters.
62-
if (size && width + size > +columns) break
76+
if (width + size > +columns && size) break
6377

64-
// include grapheme cluster in substring if it fits.
78+
// grapheme cluster fits -- include in substring.
6579
width += size // increase visual width.
6680
last = index // capture index of last grapheme cluster in substring.
81+
82+
/**
83+
* The remaining graphemes in the {@linkcode sequence}.
84+
*
85+
* @const {string[]} rest
86+
*/
87+
const rest: string[] = last ? graphemes.slice(last + 1) : graphemes
88+
89+
/**
90+
* A regular expression match array indicating whether the remaining portion
91+
* of the {@linkcode sequence} begins with an ANSI escape code.
92+
*
93+
* @const {RegExpMatchArray | null} match
94+
*/
95+
const match: RegExpMatchArray | null = rest.join(chars.empty).match(ansi)
96+
97+
// move index of last grapheme cluster to end of ansi escape code.
98+
if (match) last = index + match[0].length
6799
}
68100

69101
return graphemes.slice(0, last + 1).join(chars.empty)

yarn.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,13 @@ __metadata:
14371437
languageName: node
14381438
linkType: hard
14391439

1440+
"@flex-development/ansi-regex@npm:1.0.0":
1441+
version: 1.0.0
1442+
resolution: "@flex-development/ansi-regex@npm:1.0.0"
1443+
checksum: 10/31622b2be7ded14b8d2092e9fc8c011d865bbdfcbbfc164d688d9be228236f54369f67466d3a870cdcbbd939da9bec7cb17e44113afb6dcd2cfa1c11f5431e54
1444+
languageName: node
1445+
linkType: hard
1446+
14401447
"@flex-development/builtin-modules@npm:1.0.0":
14411448
version: 1.0.0
14421449
resolution: "@flex-development/builtin-modules@npm:1.0.0"
@@ -1451,6 +1458,13 @@ __metadata:
14511458
languageName: node
14521459
linkType: hard
14531460

1461+
"@flex-development/colors@npm:2.0.0":
1462+
version: 2.0.0
1463+
resolution: "@flex-development/colors@npm:2.0.0"
1464+
checksum: 10/127b383643e16c544549459396480133fbf51a0fb75319786547672f7358a7ee7d6e14d0b2baec076c9f7d5566448b0dc71b0ff5db5b8bbda65102d86db7576a
1465+
languageName: node
1466+
linkType: hard
1467+
14541468
"@flex-development/commitlint-config@npm:1.0.1":
14551469
version: 1.0.1
14561470
resolution: "@flex-development/commitlint-config@npm:1.0.1"
@@ -1896,6 +1910,8 @@ __metadata:
18961910
"@commitlint/cli": "npm:20.1.0"
18971911
"@commitlint/types": "npm:20.0.0"
18981912
"@faker-js/faker": "npm:10.1.0"
1913+
"@flex-development/ansi-regex": "npm:1.0.0"
1914+
"@flex-development/colors": "npm:2.0.0"
18991915
"@flex-development/commitlint-config": "npm:1.0.1"
19001916
"@flex-development/eslint-config": "npm:1.1.1"
19011917
"@flex-development/fsm-tokenizer": "npm:1.0.0-alpha.1"

0 commit comments

Comments
 (0)