@tuidom/core 0.9.0 → 0.10.0

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.
@@ -1,5 +1,5 @@
1
+ import { segmentGraphemes } from "./segmentGraphemes.js";
1
2
  import { getCharDisplayWidth, getGraphemeDisplayWidth } from "./unicodeWidth.js";
2
- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
3
3
  const DEFAULT_TAB_SIZE = 4;
4
4
  /**
5
5
  * Maps a raw document string to an array of grapheme slots with correct
@@ -34,7 +34,7 @@ export class DisplayLine {
34
34
  // отброшенной графемы (сегменты непрерывны, поэтому это ровно граница).
35
35
  let scannedLength = raw.length;
36
36
  let truncated = false;
37
- for (const { segment, index } of segmenter.segment(raw)) {
37
+ for (const { segment, index } of segmentGraphemes(raw)) {
38
38
  if (index >= stopAfter) {
39
39
  truncated = true;
40
40
  scannedLength = index;
@@ -1,5 +1,5 @@
1
+ import { segmentGraphemes } from "./segmentGraphemes.js";
1
2
  import { getCharDisplayWidth, getGraphemeDisplayWidth } from "./unicodeWidth.js";
2
- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
3
3
  const DEFAULT_TAB_SIZE = 4;
4
4
  /**
5
5
  * Display width of `raw` in terminal columns.
@@ -23,7 +23,7 @@ const DEFAULT_TAB_SIZE = 4;
23
23
  */
24
24
  export function measureTextWidth(raw, tabSize = DEFAULT_TAB_SIZE, stopAfter = Infinity) {
25
25
  let column = 0;
26
- for (const { segment, index } of segmenter.segment(raw)) {
26
+ for (const { segment, index } of segmentGraphemes(raw)) {
27
27
  if (index >= stopAfter)
28
28
  break;
29
29
  if (segment === "\t") {
@@ -0,0 +1,24 @@
1
+ /** One grapheme cluster of a string — the shape of `Intl.SegmentData` callers use. */
2
+ export interface GraphemeSegment {
3
+ /** The grapheme cluster. */
4
+ readonly segment: string;
5
+ /** Its start offset in the source string (code units). */
6
+ readonly index: number;
7
+ }
8
+ /**
9
+ * Grapheme segmentation of `raw`, lazy as `Intl.Segmenter.segment`: a caller
10
+ * that stops early never touches the tail.
11
+ *
12
+ * Text where every code unit is a grapheme of its own (ASCII, Latin, Cyrillic,
13
+ * Greek, punctuation, box drawing, symbols, icon PUA) is split by code units
14
+ * without ICU — the same answer, but without loading ICU's break iterator,
15
+ * which costs a first frame ~15 ms. From the first code unit outside that set
16
+ * the rest of the string goes to `Intl.Segmenter`. VS Code does the same with
17
+ * its `isBasicASCII` fast paths and its own grapheme break table.
18
+ *
19
+ * The segmenter is created on first need, not at module load: a native `Intl`
20
+ * object held by a module-level binding cannot be serialized into a V8 startup
21
+ * snapshot, and hosts build one from the whole engine graph
22
+ * (`npm run check:snapshot`).
23
+ */
24
+ export declare function segmentGraphemes(raw: string): Iterable<GraphemeSegment>;
@@ -0,0 +1,87 @@
1
+ let segmenter = null;
2
+ /**
3
+ * BMP code points that are always a grapheme of their own next to each other:
4
+ * inclusive `[from, to]`, ascending. Beyond the Latin-1 + Latin Extended
5
+ * prefix checked inline in {@link isStandalone}.
6
+ *
7
+ * Every code point here has the UAX #29 Grapheme_Cluster_Break value `Other`
8
+ * or `Control` (CR and LF excluded). Between two such code points there is
9
+ * always a break: GB4/GB5 for `Control`, GB999 for `Other`, and the rules that
10
+ * could keep two of them together (GB9c, GB11, GB12/13) need an `Extend`,
11
+ * `ZWJ` or regional indicator right before the break. The exhaustive test in
12
+ * `segmentGraphemes.test.ts` checks every code point here against ICU.
13
+ */
14
+ const STANDALONE_RANGES = [
15
+ [0x0370, 0x0482], // Greek, Cyrillic up to the combining titlo
16
+ [0x048a, 0x052f], // Cyrillic, Cyrillic Supplement
17
+ [0x2010, 0x2027], // dashes, quotes, bullets, ellipsis
18
+ [0x2030, 0x205e], // per mille, primes, punctuation
19
+ [0x2070, 0x20cf], // super/subscripts, currency
20
+ [0x2100, 0x23ff], // letterlike, number forms, arrows, math, technical
21
+ [0x2400, 0x27bf], // control pictures, box drawing, blocks, shapes, symbols, dingbats
22
+ [0x27c0, 0x2bff], // math, arrows, braille, symbols
23
+ [0xe000, 0xf8ff], // Private Use Area — icon fonts
24
+ ];
25
+ /**
26
+ * `true` when code unit `c` always forms a grapheme of its own next to another
27
+ * such code unit: everything below U+0300 except CR and LF, plus
28
+ * {@link STANDALONE_RANGES}.
29
+ */
30
+ function isStandalone(c) {
31
+ if (c < 0x0300)
32
+ return c !== 0x0a && c !== 0x0d;
33
+ for (const [from, to] of STANDALONE_RANGES) {
34
+ if (c < from)
35
+ return false;
36
+ if (c <= to)
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+ function segmentWithIcu(raw) {
42
+ segmenter ??= new Intl.Segmenter("en", { granularity: "grapheme" });
43
+ return segmenter.segment(raw);
44
+ }
45
+ function* segmentFrom(raw) {
46
+ let i = 0;
47
+ let standalone = raw.length > 0 && isStandalone(raw.charCodeAt(0));
48
+ while (i < raw.length) {
49
+ const nextStandalone = i + 1 >= raw.length || isStandalone(raw.charCodeAt(i + 1));
50
+ // Break before `i` is certain (start of string, or two standalone code
51
+ // units); the cluster at `i` is one code unit only if its right
52
+ // neighbour is standalone too.
53
+ if (!standalone || !nextStandalone)
54
+ break;
55
+ yield { segment: raw[i], index: i };
56
+ i++;
57
+ standalone = nextStandalone;
58
+ }
59
+ if (i >= raw.length)
60
+ return;
61
+ // The rest goes to ICU from a certain break: no rule looks back across a
62
+ // break between two standalone code units, so the tail segments as it
63
+ // would inside the whole string.
64
+ const tail = i === 0 ? raw : raw.slice(i);
65
+ for (const { segment, index } of segmentWithIcu(tail)) {
66
+ yield { segment, index: index + i };
67
+ }
68
+ }
69
+ /**
70
+ * Grapheme segmentation of `raw`, lazy as `Intl.Segmenter.segment`: a caller
71
+ * that stops early never touches the tail.
72
+ *
73
+ * Text where every code unit is a grapheme of its own (ASCII, Latin, Cyrillic,
74
+ * Greek, punctuation, box drawing, symbols, icon PUA) is split by code units
75
+ * without ICU — the same answer, but without loading ICU's break iterator,
76
+ * which costs a first frame ~15 ms. From the first code unit outside that set
77
+ * the rest of the string goes to `Intl.Segmenter`. VS Code does the same with
78
+ * its `isBasicASCII` fast paths and its own grapheme break table.
79
+ *
80
+ * The segmenter is created on first need, not at module load: a native `Intl`
81
+ * object held by a module-level binding cannot be serialized into a V8 startup
82
+ * snapshot, and hosts build one from the whole engine graph
83
+ * (`npm run check:snapshot`).
84
+ */
85
+ export function segmentGraphemes(raw) {
86
+ return segmentFrom(raw);
87
+ }
@@ -3,6 +3,23 @@ import { TerminalScreen } from "../rendering/terminalScreen.js";
3
3
  import { FocusManager } from "./events/focusManager.js";
4
4
  import { MouseEventDispatcher } from "./events/mouseEventDispatcher.js";
5
5
  import { type TUIElement } from "./tuiElement.js";
6
+ /**
7
+ * Сколько занял кадр по фазам {@link TuiApplication.renderFrame}, мс. Для
8
+ * профилирования хостом (трасса старта, бенчи): кадр — layout → стили →
9
+ * damage-обход → отрисовка повреждённых областей → flush в бэкенд (diff +
10
+ * запись в терминал).
11
+ */
12
+ export interface IFrameTimings {
13
+ /** Номер кадра — {@link TuiApplication.frameCount} после этого кадра. */
14
+ readonly frame: number;
15
+ /** Начало кадра, `performance.now()`. */
16
+ readonly start: number;
17
+ readonly layout: number;
18
+ readonly style: number;
19
+ readonly damage: number;
20
+ readonly paint: number;
21
+ readonly flush: number;
22
+ }
6
23
  export declare class TuiApplication {
7
24
  backend: ITerminalBackend;
8
25
  root: TUIElement | null;
@@ -17,6 +34,11 @@ export declare class TuiApplication {
17
34
  * кадре, а не молчаливым «не показывается / не кликается».
18
35
  */
19
36
  validateTreeAfterRender: boolean;
37
+ /**
38
+ * Наблюдатель фаз кадра: зовётся после каждого кадра с таймингами
39
+ * {@link IFrameTimings}. Пока он null, кадр не читает часы вовсе.
40
+ */
41
+ onFrameTimings: ((timings: IFrameTimings) => void) | null;
20
42
  private frameCounter;
21
43
  private pinnedKeypressTarget;
22
44
  private needsFullRepaint;
@@ -23,6 +23,11 @@ export class TuiApplication {
23
23
  * кадре, а не молчаливым «не показывается / не кликается».
24
24
  */
25
25
  validateTreeAfterRender = false;
26
+ /**
27
+ * Наблюдатель фаз кадра: зовётся после каждого кадра с таймингами
28
+ * {@link IFrameTimings}. Пока он null, кадр не читает часы вовсе.
29
+ */
30
+ onFrameTimings = null;
26
31
  // Монотонный счётчик отрисованных кадров. Инкремент на каждый renderFrame —
27
32
  // инспектор ждёт «рендер устоялся» (frameCount не менялся + нет
28
33
  // запланированного рендера), не завися от sleep. См. inspector/idleWaiter.
@@ -47,6 +52,8 @@ export class TuiApplication {
47
52
  renderFrame() {
48
53
  if (this.root) {
49
54
  this.frameCounter++;
55
+ const observer = this.onFrameTimings;
56
+ const start = observer === null ? 0 : performance.now();
50
57
  // Root at (0, 0) — top-left of screen. globalPosition производный
51
58
  // (localPosition корня без родителя), выставлять его не нужно.
52
59
  this.root.localPosition = new Offset(0, 0);
@@ -54,7 +61,9 @@ export class TuiApplication {
54
61
  // validateTree не зависят от damage); стили гейтятся внутри.
55
62
  const constraints = BoxConstraints.tight(this.screen.size);
56
63
  this.root.layout(constraints);
64
+ const layoutEnd = observer === null ? 0 : performance.now();
57
65
  this.root.performStyleResolution(ROOT_STYLE_CONTEXT);
66
+ const styleEnd = observer === null ? 0 : performance.now();
58
67
  // Damage-обход — всегда: и собирает области, и актуализирует
59
68
  // lastPaintedRect/флаги (даже когда кадр будет полным).
60
69
  const damage = new DamageList();
@@ -62,6 +71,7 @@ export class TuiApplication {
62
71
  damage.add(detached);
63
72
  }
64
73
  this.root.collectDamage(damage, new Point(0, 0));
74
+ const damageEnd = observer === null ? 0 : performance.now();
65
75
  const screenBounds = new Rect(new Point(0, 0), this.screen.size);
66
76
  let rects;
67
77
  if (this.needsFullRepaint) {
@@ -91,8 +101,21 @@ export class TuiApplication {
91
101
  }
92
102
  this.root.render(new RenderContext(this.screen, new Offset(0, 0), rect));
93
103
  }
104
+ const paintEnd = observer === null ? 0 : performance.now();
94
105
  // Всегда: пустой damage — пустой diff, ноль байт в терминал.
95
106
  this.screen.flush(this.backend);
107
+ if (observer !== null) {
108
+ const flushEnd = performance.now();
109
+ observer({
110
+ frame: this.frameCounter,
111
+ start,
112
+ layout: layoutEnd - start,
113
+ style: styleEnd - layoutEnd,
114
+ damage: damageEnd - styleEnd,
115
+ paint: paintEnd - damageEnd,
116
+ flush: flushEnd - paintEnd,
117
+ });
118
+ }
96
119
  if (this.validateTreeAfterRender) {
97
120
  assertValidTree(this.root);
98
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuidom/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Experimental terminal UI DOM — core: element tree, flex layout, grid rendering, input parsing. API is unstable.",
6
6
  "license": "MIT",