@alchemy.run/sigil 0.0.0-alpha.2 → 0.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +2 -4
  2. package/THIRD_PARTY_NOTICES.md +31 -22
  3. package/dist/ansi.d.ts +17 -14
  4. package/dist/ansi.js +7 -2
  5. package/dist/{devtools-BhYGjb7h.js → devtools-DbthxoD1.js} +22 -23
  6. package/dist/index.d.ts +125 -288
  7. package/dist/index.js +2460 -2864
  8. package/dist/{truncate-CBiyyZzw.js → truncate-Cr6xVFMa.js} +447 -273
  9. package/package.json +3 -9
  10. package/src/ansi/chalk.ts +8 -51
  11. package/src/ansi/cursor.ts +2 -4
  12. package/src/ansi/east-asian-width.ts +44 -0
  13. package/src/ansi/escapes.ts +4 -26
  14. package/src/ansi/index.ts +12 -14
  15. package/src/ansi/sgr.ts +2 -5
  16. package/src/ansi/slice.ts +1 -1
  17. package/src/ansi/string-width.ts +91 -206
  18. package/src/ansi/strip.ts +13 -33
  19. package/src/ansi/supports-color.ts +8 -14
  20. package/src/ansi/tokenize.ts +94 -166
  21. package/src/ansi/truncate.ts +5 -3
  22. package/src/ansi/wrap.ts +13 -14
  23. package/src/ansi-tokenizer.ts +1 -1
  24. package/src/colorize.ts +1 -1
  25. package/src/components/App.tsx +14 -14
  26. package/src/components/BackgroundContext.ts +2 -2
  27. package/src/components/Box.tsx +51 -59
  28. package/src/components/CursorContext.ts +1 -1
  29. package/src/components/ErrorBoundary.tsx +2 -1
  30. package/src/components/ErrorOverview.tsx +9 -7
  31. package/src/components/Newline.tsx +1 -0
  32. package/src/components/Spacer.tsx +2 -1
  33. package/src/components/Static.tsx +2 -1
  34. package/src/components/StderrContext.ts +0 -2
  35. package/src/components/StdinContext.ts +0 -1
  36. package/src/components/StdoutContext.ts +1 -3
  37. package/src/components/Text.tsx +7 -6
  38. package/src/components/Transform.tsx +2 -1
  39. package/src/cursor-position.ts +1 -1
  40. package/src/devtools.ts +84 -24
  41. package/src/dom.ts +7 -7
  42. package/src/env.ts +12 -0
  43. package/src/get-max-width.ts +1 -1
  44. package/src/global.d.ts +3 -4
  45. package/src/{boxes.ts → glyphs.ts} +17 -18
  46. package/src/hooks/use-animation.ts +1 -1
  47. package/src/hooks/use-app.ts +1 -1
  48. package/src/hooks/use-box-metrics.ts +1 -1
  49. package/src/hooks/use-cursor.ts +2 -2
  50. package/src/hooks/use-focus-manager.ts +1 -1
  51. package/src/hooks/use-focus.ts +5 -6
  52. package/src/hooks/use-input.ts +4 -4
  53. package/src/hooks/use-is-screen-reader-enabled.ts +1 -1
  54. package/src/hooks/use-paste.ts +2 -2
  55. package/src/hooks/use-stderr.ts +1 -1
  56. package/src/hooks/use-stdin.ts +1 -1
  57. package/src/hooks/use-stdout.ts +1 -1
  58. package/src/hooks/use-window-size.ts +2 -2
  59. package/src/index.ts +44 -44
  60. package/src/ink.tsx +536 -720
  61. package/src/input-parser.ts +1 -1
  62. package/src/instances.ts +1 -1
  63. package/src/kitty-keyboard.ts +128 -0
  64. package/src/log-update.ts +4 -4
  65. package/src/measure-element.ts +1 -1
  66. package/src/measure-text.ts +2 -2
  67. package/src/output.ts +4 -4
  68. package/src/parse-keypress.ts +4 -5
  69. package/src/parse-stack-line.ts +0 -1
  70. package/src/patch-console.ts +44 -0
  71. package/src/reconciler.ts +13 -60
  72. package/src/render-background.ts +3 -3
  73. package/src/render-border.ts +6 -6
  74. package/src/render-node-to-output.ts +13 -11
  75. package/src/render-to-string.ts +4 -4
  76. package/src/render.ts +9 -10
  77. package/src/renderer.ts +3 -3
  78. package/src/sanitize-ansi.ts +1 -1
  79. package/src/signal-exit.ts +4 -5
  80. package/src/squash-text-nodes.ts +2 -2
  81. package/src/stream.ts +2 -4
  82. package/src/styles.ts +6 -6
  83. package/src/terminal-size.ts +9 -8
  84. package/src/types.ts +1 -6
  85. package/src/utils.ts +2 -2
  86. package/src/wrap-text.ts +4 -4
  87. package/src/yoga/config.ts +2 -2
  88. package/src/yoga/core/absoluteLayout.ts +15 -15
  89. package/src/yoga/core/baseline.ts +3 -3
  90. package/src/yoga/core/cache.ts +4 -4
  91. package/src/yoga/core/calculateLayout.ts +33 -27
  92. package/src/yoga/core/config.ts +1 -1
  93. package/src/yoga/core/flexLine.ts +3 -3
  94. package/src/yoga/core/helpers.ts +3 -3
  95. package/src/yoga/core/layoutResults.ts +4 -4
  96. package/src/yoga/core/node.ts +16 -16
  97. package/src/yoga/core/pixelGrid.ts +4 -4
  98. package/src/yoga/core/style.ts +11 -11
  99. package/src/yoga/core/types.ts +2 -2
  100. package/src/yoga/index.ts +6 -6
  101. package/src/yoga/node.ts +8 -8
  102. package/src/ansi/widest-line.ts +0 -12
  103. package/src/auto-bind.ts +0 -41
  104. package/src/devtools-window-polyfill.ts +0 -73
  105. package/src/indent-string.ts +0 -16
  106. package/src/is-in-ci.ts +0 -7
  107. package/src/write-synchronized.ts +0 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alchemy.run/sigil",
3
- "version": "0.0.0-alpha.2",
3
+ "version": "0.0.0-alpha.4",
4
4
  "description": "React for CLIs. A self-contained fork of Ink with an integrated TypeScript Yoga layout engine.",
5
5
  "keywords": [
6
6
  "cli",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "type": "module",
35
35
  "imports": {
36
- "#/": "./src"
36
+ "#/*": "./src/*"
37
37
  },
38
38
  "exports": {
39
39
  ".": {
@@ -70,7 +70,6 @@
70
70
  "@types/react": "^19.2.18",
71
71
  "@types/react-reconciler": "^0.33.0",
72
72
  "@types/scheduler": "^0.26.0",
73
- "@types/ws": "^8.18.1",
74
73
  "react": "^19.2.8",
75
74
  "react-devtools-core": "^7.0.1",
76
75
  "react-router": "^8.3.0",
@@ -78,14 +77,12 @@
78
77
  "typescript": "^7.0.2",
79
78
  "vite-plus": "^0.2.9",
80
79
  "vitest": "4.1.11",
81
- "ws": "^8.20.0",
82
80
  "zigpty": "^0.2.1"
83
81
  },
84
82
  "peerDependencies": {
85
83
  "@types/react": ">=19.2.0",
86
84
  "react": ">=19.2.0",
87
- "react-devtools-core": ">=6.1.2",
88
- "ws": ">=8.20.0"
85
+ "react-devtools-core": ">=6.1.2"
89
86
  },
90
87
  "peerDependenciesMeta": {
91
88
  "@types/react": {
@@ -93,9 +90,6 @@
93
90
  },
94
91
  "react-devtools-core": {
95
92
  "optional": true
96
- },
97
- "ws": {
98
- "optional": true
99
93
  }
100
94
  },
101
95
  "engines": {
package/src/ansi/chalk.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Terminal string styling with a chalk-compatible API for the styles Ink uses.
2
2
  // Ported from `chalk` (MIT, Sindre Sorhus) without the chaining builder —
3
3
  // Ink only ever applies one style per call.
4
- import { ESC } from "./escapes.ts";
4
+ import { ESC } from "#/ansi/escapes.ts";
5
5
  import {
6
6
  background,
7
7
  backgroundColorNames,
@@ -15,12 +15,12 @@ import {
15
15
  type BackgroundColorName,
16
16
  type ForegroundColorName,
17
17
  type ModifierName,
18
- } from "./sgr.ts";
18
+ } from "#/ansi/sgr.ts";
19
19
  import {
20
20
  supportsColor as supportsColorDetection,
21
21
  type ColorInfo,
22
22
  type ColorSupportLevel,
23
- } from "./supports-color.ts";
23
+ } from "#/ansi/supports-color.ts";
24
24
 
25
25
  export type { BackgroundColorName, ForegroundColorName, ModifierName };
26
26
 
@@ -38,50 +38,6 @@ const state = {
38
38
 
39
39
  // Replace every occurrence of `substring` with `substring + replacer`,
40
40
  // re-opening a style after a nested close code would have ended it.
41
- const stringReplaceAll = (string: string, substring: string, replacer: string): string => {
42
- let index = string.indexOf(substring);
43
- if (index === -1) {
44
- return string;
45
- }
46
-
47
- const substringLength = substring.length;
48
- let endIndex = 0;
49
- let returnValue = "";
50
- do {
51
- returnValue += string.slice(endIndex, index) + substring + replacer;
52
- endIndex = index + substringLength;
53
- index = string.indexOf(substring, endIndex);
54
- } while (index !== -1);
55
-
56
- returnValue += string.slice(endIndex);
57
- return returnValue;
58
- };
59
-
60
- // Close the style before every line break and reopen it after, to prevent
61
- // background bleed across lines (https://github.com/chalk/chalk/pull/92).
62
- const stringEncaseCRLFWithFirstIndex = (
63
- string: string,
64
- prefix: string,
65
- postfix: string,
66
- index: number,
67
- ): string => {
68
- let endIndex = 0;
69
- let returnValue = "";
70
- do {
71
- const gotCR = string[index - 1] === "\r";
72
- returnValue +=
73
- string.slice(endIndex, gotCR ? index - 1 : index) +
74
- prefix +
75
- (gotCR ? "\r\n" : "\n") +
76
- postfix;
77
- endIndex = index + 1;
78
- index = string.indexOf("\n", endIndex);
79
- } while (index !== -1);
80
-
81
- returnValue += string.slice(endIndex);
82
- return returnValue;
83
- };
84
-
85
41
  const applyStyle = (open: string, close: string, text: string): string => {
86
42
  if (state.level <= 0 || !text) {
87
43
  return text;
@@ -92,12 +48,13 @@ const applyStyle = (open: string, close: string, text: string): string => {
92
48
  if (string.includes(ESC)) {
93
49
  // Re-open the style wherever the text already contains its close code,
94
50
  // otherwise only the part before that code would stay styled.
95
- string = stringReplaceAll(string, close, open);
51
+ string = string.replaceAll(close, close + open);
96
52
  }
97
53
 
98
- const lfIndex = string.indexOf("\n");
99
- if (lfIndex !== -1) {
100
- string = stringEncaseCRLFWithFirstIndex(string, close, open, lfIndex);
54
+ if (string.includes("\n")) {
55
+ // Close the style before every line break and reopen it after, to prevent
56
+ // background bleed across lines (https://github.com/chalk/chalk/pull/92).
57
+ string = string.replaceAll(/\r?\n/g, `${close}$&${open}`);
101
58
  }
102
59
 
103
60
  return open + string + close;
@@ -1,10 +1,8 @@
1
+ import { cursorHide, cursorShow } from "#/ansi/escapes.ts";
1
2
  // Derived from `cli-cursor` + `restore-cursor` (MIT, Sindre Sorhus),
2
3
  // collapsed into one module. Restores the cursor on process exit once
3
4
  // `hide()` has been called, like `restore-cursor` did.
4
- import process from "node:process";
5
-
6
- import { signalExit } from "../signal-exit.ts";
7
- import { cursorHide, cursorShow } from "./escapes.ts";
5
+ import { signalExit } from "#/signal-exit.ts";
8
6
 
9
7
  type CursorStream = {
10
8
  isTTY?: boolean;
@@ -206,6 +206,50 @@ export function eastAsianWidth(
206
206
  return 1;
207
207
  }
208
208
 
209
+ /**
210
+ Grapheme-cluster width test shared by the tokenizer (slice/clip path): the
211
+ base code point's East Asian Width, plus emoji presentation rules.
212
+ */
213
+ export function isFullwidthGrapheme(grapheme: string, baseCodePoint: number): boolean {
214
+ if (isFullwidthCodePoint(baseCodePoint)) return true;
215
+ // Variation Selector 16 forces emoji presentation (2 columns wide)
216
+ if (grapheme.includes("\uFE0F")) return true;
217
+ // Regional indicator pairs form flag emoji (2 columns wide)
218
+ if (baseCodePoint >= 0x1f1e6 && baseCodePoint <= 0x1f1ff) return true;
219
+ return false;
220
+ }
221
+
222
+ /**
223
+ Wide code points not covered by string-width's CJKT script regex or emoji
224
+ regex — the fallback bucket of its block parser. A disjoint decomposition of
225
+ the `wide` table above, kept alongside it so the width data lives in one
226
+ module.
227
+ */
228
+ export const isWideNotCJKTNotEmoji = (x: number): boolean => {
229
+ return (
230
+ x === 0x231b ||
231
+ x === 0x2329 ||
232
+ (x >= 0x2ff0 && x <= 0x2fff) ||
233
+ (x >= 0x3001 && x <= 0x303e) ||
234
+ (x >= 0x3099 && x <= 0x30ff) ||
235
+ (x >= 0x3105 && x <= 0x312f) ||
236
+ (x >= 0x3131 && x <= 0x318e) ||
237
+ (x >= 0x3190 && x <= 0x31e3) ||
238
+ (x >= 0x31ef && x <= 0x321e) ||
239
+ (x >= 0x3220 && x <= 0x3247) ||
240
+ (x >= 0x3250 && x <= 0x4dbf) ||
241
+ (x >= 0xfe10 && x <= 0xfe19) ||
242
+ (x >= 0xfe30 && x <= 0xfe52) ||
243
+ (x >= 0xfe54 && x <= 0xfe66) ||
244
+ (x >= 0xfe68 && x <= 0xfe6b) ||
245
+ (x >= 0x1f200 && x <= 0x1f202) ||
246
+ (x >= 0x1f210 && x <= 0x1f23b) ||
247
+ (x >= 0x1f240 && x <= 0x1f248) ||
248
+ (x >= 0x20000 && x <= 0x2fffd) ||
249
+ (x >= 0x30000 && x <= 0x3fffd)
250
+ );
251
+ };
252
+
209
253
  export function isFullwidthCodePoint(codePoint: number): boolean {
210
254
  if (!Number.isInteger(codePoint)) {
211
255
  return false;
@@ -1,7 +1,4 @@
1
- import os from "node:os";
2
1
  // Derived from `ansi-escapes` (MIT, Sindre Sorhus), reduced to the escapes Ink uses.
3
- import process from "node:process";
4
-
5
2
  // Control characters and sequence introducers. This module is the single
6
3
  // home for escape sequences: everything else imports these instead of
7
4
  // spelling out `\u001B[...` inline.
@@ -51,29 +48,10 @@ export const eraseLines = (count: number): string => {
51
48
  return clear;
52
49
  };
53
50
 
54
- // Windows 10 builds before 10586 don't support the `3J` escape.
55
- const isOldWindows = (): boolean => {
56
- if (process.platform !== "win32") {
57
- return false;
58
- }
59
-
60
- const parts = os.release().split(".");
61
- const major = Number(parts[0]);
62
- const build = Number(parts[2] ?? 0);
63
-
64
- if (major < 10) {
65
- return true;
66
- }
67
-
68
- return major === 10 && build < 10_586;
69
- };
70
-
71
- export const clearTerminal = isOldWindows()
72
- ? `${eraseScreen}${CSI}0f`
73
- : // 1. Erases the screen (only done in case `2` is not supported)
74
- // 2. Erases the whole screen including scrollback buffer
75
- // 3. Moves cursor to the top-left position
76
- `${eraseScreen}${CSI}3J${CSI}H`;
51
+ // 1. Erases the screen (only done in case `2` is not supported)
52
+ // 2. Erases the whole screen including scrollback buffer
53
+ // 3. Moves cursor to the top-left position
54
+ export const clearTerminal = `${eraseScreen}${CSI}3J${CSI}H`;
77
55
 
78
56
  export const enterAlternativeScreen = CSI + "?1049h";
79
57
  export const exitAlternativeScreen = CSI + "?1049l";
package/src/ansi/index.ts CHANGED
@@ -3,25 +3,23 @@
3
3
  // `@alchemy.run/sigil/ansi`.
4
4
 
5
5
  // Styling
6
- export { chalk } from "./chalk.ts";
7
- export { supportsColor } from "./supports-color.ts";
8
- export * from "./sgr.ts";
6
+ export { chalk } from "#/ansi/chalk.ts";
7
+ export { supportsColor } from "#/ansi/supports-color.ts";
8
+ export * from "#/ansi/sgr.ts";
9
9
 
10
10
  // Escape sequences (raw building blocks + named sequences)
11
- export { ansiEscapes } from "./escapes.ts";
12
- export * from "./escapes.ts";
13
- export { cliCursor } from "./cursor.ts";
11
+ export * from "#/ansi/escapes.ts";
12
+ export { cliCursor } from "#/ansi/cursor.ts";
14
13
 
15
14
  // Measurement
16
- export { stringWidth } from "./string-width.ts";
17
- export { widestLine } from "./widest-line.ts";
18
- export * from "./east-asian-width.ts";
15
+ export { stringWidth, widestLine } from "#/ansi/string-width.ts";
16
+ export * from "#/ansi/east-asian-width.ts";
19
17
 
20
18
  // String manipulation
21
- export { stripAnsi, ansiRegex } from "./strip.ts";
22
- export { sliceAnsi } from "./slice.ts";
23
- export { cliTruncate as truncateAnsi } from "./truncate.ts";
24
- export { wrapAnsi } from "./wrap.ts";
19
+ export { stripAnsi } from "#/ansi/strip.ts";
20
+ export { sliceAnsi } from "#/ansi/slice.ts";
21
+ export { cliTruncate as truncateAnsi } from "#/ansi/truncate.ts";
22
+ export { wrapAnsi } from "#/ansi/wrap.ts";
25
23
 
26
24
  // Tokenizer: style-aware parsing and minimal re-emission
27
- export * from "./tokenize.ts";
25
+ export * from "#/ansi/tokenize.ts";
package/src/ansi/sgr.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // ANSI SGR style tables and color-space conversions.
2
2
  // Ported from `ansi-styles` (MIT, Sindre Sorhus).
3
- import { CSI } from "./escapes.ts";
3
+ import { CSI } from "#/ansi/escapes.ts";
4
4
 
5
5
  const ANSI_BACKGROUND_OFFSET = 10;
6
6
 
@@ -173,10 +173,7 @@ export const hexToRgb = (hex: string): [number, number, number] => {
173
173
  let [colorString] = matches;
174
174
 
175
175
  if (colorString.length === 3) {
176
- colorString = colorString
177
- .split("")
178
- .map((character) => character + character)
179
- .join("");
176
+ colorString = colorString.replaceAll(/./g, "$&$&");
180
177
  }
181
178
 
182
179
  const integer = Number.parseInt(colorString, 16);
package/src/ansi/slice.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  styledCharsToString,
9
9
  tokenize,
10
10
  type StyledChar,
11
- } from "./tokenize.ts";
11
+ } from "#/ansi/tokenize.ts";
12
12
 
13
13
  export function sliceAnsi(string: string, start: number, end?: number): string {
14
14
  const sliceEnd = end ?? Number.POSITIVE_INFINITY;
@@ -1,236 +1,121 @@
1
- import { eastAsianWidth } from "./east-asian-width.ts";
2
- // Visual width of a string: how many terminal columns it occupies.
3
- // Ported from `string-width` (MIT, Sindre Sorhus).
4
- //
5
- // Logic:
6
- // - Segment graphemes to match how terminals render clusters.
7
- // - Width rules:
8
- // 1. Skip non-printing clusters (Default_Ignorable, Control, pure
9
- // nonspacing/enclosing Mark, lone Surrogates). Tabs are ignored by design.
10
- // 2. RGI emoji clusters (\p{RGI_Emoji}) are double-width.
11
- // 3. Minimally-qualified/unqualified emoji clusters (ZWJ sequences with 2+
12
- // Extended_Pictographic, or keycap sequences) are double-width.
13
- // 4. Hangul jamo collapse each standard modern Hangul L+V or L+V+T syllable
14
- // piece to width 2. Unmatched repeated leading/vowel/trailing jamo stay
15
- // additive because that matches how the terminals we target render them.
16
- // 5. Otherwise use East Asian Width of the cluster's first visible code
17
- // point, and add widths for trailing spacing marks and Halfwidth/Fullwidth
18
- // Forms within the same cluster (e.g., dakuten/handakuten).
19
- import { C1_CSI, ESC } from "./escapes.ts";
20
- import { stripAnsi } from "./strip.ts";
21
-
22
- export type StringWidthOptions = {
23
- readonly ambiguousIsNarrow?: boolean;
24
- readonly countAnsiEscapeCodes?: boolean;
25
- };
26
-
27
- type EastAsianWidthOptions = {
28
- readonly ambiguousAsWide: boolean;
29
- };
30
-
31
- const segmenter = new Intl.Segmenter();
32
-
33
- // Whole-cluster zero-width
34
- const zeroWidthClusterRegex =
35
- /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/v;
1
+ // Derived from `fast-string-truncated-width` (MIT, Fabio Spampinato),
2
+ // reduced to plain width measurement: Sigil truncates via `truncate.ts`, and
3
+ // no caller customizes per-class widths, so those are constants here.
4
+ // Width tables live in east-asian-width.ts, the single width-data module.
36
5
 
37
- // Pick the base scalar if the cluster starts with Prepend/Format/Marks
38
- const leadingNonPrintingRegex =
39
- /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]+/v;
40
- const spacingMarkRegex = /\p{Spacing_Mark}/v;
6
+ import { isFullWidth, isWideNotCJKTNotEmoji } from "#/ansi/east-asian-width.ts";
41
7
 
42
- // RGI emoji sequences
43
- const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
44
-
45
- // Detect minimally-qualified/unqualified emoji sequences (missing VS16 but
46
- // still rendering as double-width)
47
- const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
48
- const extendedPictographicRegex = /\p{Extended_Pictographic}/gu;
49
-
50
- function isDoubleWidthNonRgiEmojiSequence(segment: string): boolean {
51
- // Real emoji clusters are < 30 chars; guard against pathological input
52
- if (segment.length > 50) {
53
- return false;
54
- }
55
-
56
- if (unqualifiedKeycapRegex.test(segment)) {
57
- return true;
58
- }
8
+ const getCodePointsLength = (input: string): number => {
9
+ let length = 0;
59
10
 
60
- // ZWJ sequences with 2+ Extended_Pictographic
61
- if (segment.includes("\u200D")) {
62
- const pictographics = segment.match(extendedPictographicRegex);
63
- return pictographics !== null && pictographics.length >= 2;
11
+ for (const _ of input) {
12
+ length += 1;
64
13
  }
65
14
 
66
- return false;
67
- }
68
-
69
- function baseVisible(segment: string): string {
70
- return segment.replace(leadingNonPrintingRegex, "");
71
- }
72
-
73
- function isZeroWidthCluster(segment: string): boolean {
74
- return zeroWidthClusterRegex.test(segment);
75
- }
76
-
77
- function isHangulLeadingJamo(codePoint: number | undefined): boolean {
78
- return (
79
- codePoint !== undefined &&
80
- ((codePoint >= 0x11_00 && codePoint <= 0x11_5f) ||
81
- (codePoint >= 0xa9_60 && codePoint <= 0xa9_7c))
82
- );
83
- }
84
-
85
- function isHangulVowelJamo(codePoint: number | undefined): boolean {
86
- return (
87
- codePoint !== undefined &&
88
- ((codePoint >= 0x11_60 && codePoint <= 0x11_a7) ||
89
- (codePoint >= 0xd7_b0 && codePoint <= 0xd7_c6))
90
- );
91
- }
92
-
93
- function isHangulTrailingJamo(codePoint: number | undefined): boolean {
94
- return (
95
- codePoint !== undefined &&
96
- ((codePoint >= 0x11_a8 && codePoint <= 0x11_ff) ||
97
- (codePoint >= 0xd7_cb && codePoint <= 0xd7_fb))
98
- );
99
- }
100
-
101
- function isHangulJamo(codePoint: number): boolean {
102
- return (
103
- isHangulLeadingJamo(codePoint) ||
104
- isHangulVowelJamo(codePoint) ||
105
- isHangulTrailingJamo(codePoint)
106
- );
107
- }
108
-
109
- function hangulClusterWidth(
110
- visibleSegment: string,
111
- eastAsianWidthOptions: EastAsianWidthOptions,
112
- ): number | undefined {
113
- const codePoints: number[] = [];
114
-
115
- for (const character of visibleSegment) {
116
- if (zeroWidthClusterRegex.test(character)) {
117
- continue;
118
- }
15
+ return length;
16
+ };
119
17
 
120
- codePoints.push(character.codePointAt(0)!);
121
- }
18
+ // Unlike the original port, CSI parameters also allow `:` so ITU T.416
19
+ // colon-parameter SGR sequences (which Sigil preserves) measure as width 0.
20
+ const ANSI_RE =
21
+ /[\u001b\u009b][[()#;?]*[0-9;:]*[0-9A-ORZcf-nqry=><]|\u001b\]8;[^;]*;.*?(?:\u0007|\u001b\u005c)/y;
22
+ const CONTROL_RE = /[\x00-\x08\x0A-\x1F\x7F-\x9F]{1,1000}/y;
23
+ const CJKT_WIDE_RE =
24
+ /(?:(?![\uFF61-\uFF9F\uFF00-\uFFEF])[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Tangut}]){1,1000}/uy;
25
+ const TAB_RE = /\t{1,1000}/y;
26
+ const EMOJI_RE =
27
+ /[\u{1F1E6}-\u{1F1FF}]{2}|\u{1F3F4}[\u{E0061}-\u{E007A}]{2}[\u{E0030}-\u{E0039}\u{E0061}-\u{E007A}]{1,3}\u{E007F}|(?:\p{Emoji}\uFE0F\u20E3?|\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation})(?:\u200D(?:\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation}|\p{Emoji}\uFE0F\u20E3?))*/uy;
28
+ const LATIN_RE = /(?:[\x20-\x7E\xA0-\xFF](?!\uFE0F)){1,1000}/y;
29
+ const MODIFIER_RE = /\p{M}+/gu;
30
+
31
+ const ANSI_WIDTH = 0;
32
+ const CONTROL_WIDTH = 0;
33
+ const TAB_WIDTH = 8;
34
+ const EMOJI_WIDTH = 2;
35
+ const FULL_WIDTH_WIDTH = 2;
36
+ const REGULAR_WIDTH = 1;
37
+ const WIDE_WIDTH = 2;
38
+
39
+ const PARSE_BLOCKS: [RegExp, number][] = [
40
+ [LATIN_RE, REGULAR_WIDTH],
41
+ [ANSI_RE, ANSI_WIDTH],
42
+ [CONTROL_RE, CONTROL_WIDTH],
43
+ [TAB_RE, TAB_WIDTH],
44
+ [EMOJI_RE, EMOJI_WIDTH],
45
+ [CJKT_WIDE_RE, WIDE_WIDTH],
46
+ ];
47
+
48
+ export const stringWidth = (input: string): number => {
49
+ let indexPrev = 0;
50
+ let index = 0;
51
+ const length = input.length;
52
+ let unmatchedStart = 0;
53
+ let unmatchedEnd = 0;
54
+ let width = 0;
122
55
 
123
- if (codePoints.length === 0) {
124
- return;
125
- }
56
+ outer: while (true) {
57
+ /* UNMATCHED */
126
58
 
127
- let width = 0;
59
+ if (unmatchedEnd > unmatchedStart || (index >= length && index > indexPrev)) {
60
+ const unmatched = input.slice(unmatchedStart, unmatchedEnd) || input.slice(indexPrev, index);
128
61
 
129
- for (let index = 0; index < codePoints.length; index++) {
130
- const codePoint = codePoints[index]!;
131
- if (!isHangulJamo(codePoint)) {
132
- if (width === 0) {
133
- return;
134
- }
62
+ for (const char of unmatched.replaceAll(MODIFIER_RE, "")) {
63
+ const codePoint = char.codePointAt(0) || 0;
135
64
 
136
- // Mixed cluster (e.g., L + precomposed syllable): use EAW for the
137
- // non-jamo remainder
138
- for (let remaining = index; remaining < codePoints.length; remaining++) {
139
- width += eastAsianWidth(codePoints[remaining]!, eastAsianWidthOptions);
65
+ width += isFullWidth(codePoint)
66
+ ? FULL_WIDTH_WIDTH
67
+ : isWideNotCJKTNotEmoji(codePoint)
68
+ ? WIDE_WIDTH
69
+ : REGULAR_WIDTH;
140
70
  }
141
71
 
142
- return width;
72
+ unmatchedStart = unmatchedEnd = 0;
143
73
  }
144
74
 
145
- // Modern Hangul L+V(+T) shapes as one syllable block. Unmatched jamo stay
146
- // additive: U+1100 U+1100 U+1161 => U+1100 + (U+1100 U+1161) => 2 + 2.
147
- if (isHangulLeadingJamo(codePoint) && isHangulVowelJamo(codePoints[index + 1])) {
148
- width += 2;
149
- index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
150
- continue;
151
- }
152
-
153
- width += eastAsianWidth(codePoint, eastAsianWidthOptions);
154
- }
75
+ /* EXITING */
155
76
 
156
- return width;
157
- }
158
-
159
- function trailingWidth(
160
- visibleSegment: string,
161
- eastAsianWidthOptions: EastAsianWidthOptions,
162
- ): number {
163
- let extra = 0;
164
- let first = true;
165
-
166
- for (const character of visibleSegment) {
167
- if (first) {
168
- first = false;
169
- continue;
77
+ if (index >= length) {
78
+ break;
170
79
  }
171
80
 
172
- if (spacingMarkRegex.test(character) || (character >= "\uFF00" && character <= "\uFFEF")) {
173
- extra += eastAsianWidth(character.codePointAt(0)!, eastAsianWidthOptions);
174
- }
175
- }
81
+ /* PARSE BLOCKS */
176
82
 
177
- return extra;
178
- }
83
+ for (let i = 0, l = PARSE_BLOCKS.length; i < l; i++) {
84
+ const [BLOCK_RE, BLOCK_WIDTH] = PARSE_BLOCKS[i]!;
179
85
 
180
- export function stringWidth(input: string, options: StringWidthOptions = {}): number {
181
- if (typeof input !== "string" || input.length === 0) {
182
- return 0;
183
- }
86
+ BLOCK_RE.lastIndex = index;
184
87
 
185
- const { ambiguousIsNarrow = true, countAnsiEscapeCodes = false } = options;
88
+ if (BLOCK_RE.test(input)) {
89
+ const lengthExtra =
90
+ BLOCK_RE === CJKT_WIDE_RE
91
+ ? getCodePointsLength(input.slice(index, BLOCK_RE.lastIndex))
92
+ : BLOCK_RE === EMOJI_RE
93
+ ? 1
94
+ : BLOCK_RE.lastIndex - index;
186
95
 
187
- let string = input;
96
+ width += lengthExtra * BLOCK_WIDTH;
97
+ unmatchedStart = indexPrev;
98
+ unmatchedEnd = index;
99
+ index = indexPrev = BLOCK_RE.lastIndex;
188
100
 
189
- // Avoid calling stripAnsi when there are no ANSI escape sequences
190
- // (ESC = 0x1B, CSI = 0x9B)
191
- if (!countAnsiEscapeCodes && (string.includes(ESC) || string.includes(C1_CSI))) {
192
- string = stripAnsi(string);
193
- }
101
+ continue outer;
102
+ }
103
+ }
194
104
 
195
- if (string.length === 0) {
196
- return 0;
197
- }
105
+ /* UNMATCHED INDEX */
198
106
 
199
- // Fast path: printable ASCII (0x20–0x7E) needs no segmenter, regex, or EAW
200
- // lookup — width equals length.
201
- if (/^[ -~]*$/.test(string)) {
202
- return string.length;
107
+ index += 1;
203
108
  }
204
109
 
205
- let width = 0;
206
- const eastAsianWidthOptions: EastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow };
207
-
208
- for (const { segment } of segmenter.segment(string)) {
209
- // Zero-width / non-printing clusters
210
- if (isZeroWidthCluster(segment)) {
211
- continue;
212
- }
213
-
214
- // Emoji width logic
215
- if (rgiEmojiRegex.test(segment) || isDoubleWidthNonRgiEmojiSequence(segment)) {
216
- width += 2;
217
- continue;
218
- }
219
-
220
- const visibleSegment = baseVisible(segment);
221
- const hangulWidth = hangulClusterWidth(visibleSegment, eastAsianWidthOptions);
222
- if (hangulWidth !== undefined) {
223
- width += hangulWidth;
224
- continue;
225
- }
110
+ return width;
111
+ };
226
112
 
227
- // Everything else: EAW of the cluster's first visible scalar
228
- const codePoint = visibleSegment.codePointAt(0)!;
229
- width += eastAsianWidth(codePoint, eastAsianWidthOptions);
113
+ export const widestLine = (text: string): number => {
114
+ let lineWidth = 0;
230
115
 
231
- // Add width for trailing spacing marks and Halfwidth/Fullwidth Forms
232
- width += trailingWidth(visibleSegment, eastAsianWidthOptions);
116
+ for (const line of text.split("\n")) {
117
+ lineWidth = Math.max(lineWidth, stringWidth(line));
233
118
  }
234
119
 
235
- return width;
236
- }
120
+ return lineWidth;
121
+ };