@oh-my-pi/pi-tui 17.0.4 → 17.0.5
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.
- package/CHANGELOG.md +14 -0
- package/dist/types/tui.d.ts +21 -6
- package/dist/types/utils.d.ts +5 -0
- package/package.json +3 -3
- package/src/components/box.ts +72 -30
- package/src/components/editor.ts +191 -149
- package/src/components/text.ts +31 -2
- package/src/tui.ts +105 -25
- package/src/utils.ts +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.5] - 2026-07-18
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Improved rendering performance across text, box, editor, and frame layouts by caching validated line widths and avoiding redundant Unicode width measurements.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed a performance issue where typing in the editor triggered a full UI re-render, significantly improving keystroke responsiveness.
|
|
14
|
+
- Restored text wrapping for long descriptions in the slash-command autocomplete picker to ensure readability at standard terminal widths.
|
|
15
|
+
- Prevented temporary dashboard frame updates from cluttering the terminal's native scrollback history.
|
|
16
|
+
- Added support for cleaning up tracked Kitty graphics, allowing inline images to be properly deleted before falling back to text.
|
|
17
|
+
- Fixed an issue where resizing or growing a multiplexer pane would incorrectly overwrite newly exposed rows with blank padding.
|
|
18
|
+
|
|
5
19
|
## [17.0.3] - 2026-07-17
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -82,17 +82,22 @@ export interface OverlayFocusOwner {
|
|
|
82
82
|
* FINAL — byte-stable at the current width for the component's lifetime — and
|
|
83
83
|
* commit to native scrollback as exact, audited content. Rows at/after the
|
|
84
84
|
* boundary repaint in place inside the visible window; when they scroll above
|
|
85
|
-
* the window top they
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
85
|
+
* the window top they normally commit as frozen visual snapshots.
|
|
86
|
+
*
|
|
87
|
+
* A viewport-pinned region opts out of those mutable snapshot commits. Its
|
|
88
|
+
* offscreen mutable rows are virtually clipped until the boundary advances;
|
|
89
|
+
* use this for fixed-height dashboards whose frames replace each other rather
|
|
90
|
+
* than append. A root that reports no seam commits everything that scrolls as
|
|
91
|
+
* final (shell semantics).
|
|
89
92
|
*
|
|
90
93
|
* When several root children report a seam in the same frame, the topmost one
|
|
91
|
-
* defines the boundary:
|
|
92
|
-
* first seam is already excluded.
|
|
94
|
+
* defines the boundary and pinning policy: commits are prefix-only, so
|
|
95
|
+
* everything below the first seam is already excluded.
|
|
93
96
|
*/
|
|
94
97
|
export interface NativeScrollbackLiveRegion {
|
|
95
98
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
99
|
+
/** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
|
|
100
|
+
isNativeScrollbackLiveRegionPinned?(): boolean;
|
|
96
101
|
}
|
|
97
102
|
export interface NativeScrollbackCommittedRows {
|
|
98
103
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
@@ -359,6 +364,8 @@ export declare class TUI extends Container {
|
|
|
359
364
|
* plus a full redraw on the frame after a new image exceeds the cap.
|
|
360
365
|
*/
|
|
361
366
|
setMaxInlineImages(cap: number): void;
|
|
367
|
+
/** Delete every tracked Kitty image from the terminal graphics store. */
|
|
368
|
+
clearInlineImages(): void;
|
|
362
369
|
/**
|
|
363
370
|
* Get whether scrollback divergence rebuild is enabled.
|
|
364
371
|
*/
|
|
@@ -413,6 +420,14 @@ export declare class TUI extends Container {
|
|
|
413
420
|
*/
|
|
414
421
|
resetDisplay(): void;
|
|
415
422
|
requestRender(force?: boolean, options?: RenderRequestOptions): void;
|
|
423
|
+
/**
|
|
424
|
+
* Opt `component` into subtree-only renders when input leaves focus stable.
|
|
425
|
+
*
|
|
426
|
+
* The host must explicitly request renders for every sibling mutated by the
|
|
427
|
+
* component's input callbacks. Components without this opt-in retain the
|
|
428
|
+
* legacy full-root render after input.
|
|
429
|
+
*/
|
|
430
|
+
enableScopedInputRender(component: Component): void;
|
|
416
431
|
/**
|
|
417
432
|
* Schedule a render on behalf of `component` after a self-contained change
|
|
418
433
|
* (spinner frame, blink) that cannot have affected any other component.
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ export { Ellipsis } from "@oh-my-pi/pi-natives";
|
|
|
3
3
|
export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
|
|
4
4
|
export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
|
|
5
5
|
export declare function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth;
|
|
6
|
+
export declare function getWidthConfigEpoch(): number;
|
|
7
|
+
/** Publish exact per-line visible widths for a rendered lines array. */
|
|
8
|
+
export declare function publishLineWidths(lines: readonly string[], widths: readonly number[]): void;
|
|
9
|
+
/** Exact per-line visible widths for an unchanged `lines` array under the current width config. */
|
|
10
|
+
export declare function getPublishedLineWidths(lines: readonly string[]): readonly number[] | undefined;
|
|
6
11
|
export declare function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean;
|
|
7
12
|
export declare function resetHangulCompatibilityJamoWidthForTests(): void;
|
|
8
13
|
export type TextSizingScale = 1 | 2 | 3;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.5",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.0.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.0.5",
|
|
42
42
|
"lru-cache": "11.5.2",
|
|
43
43
|
"marked": "^18.0.6"
|
|
44
44
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { Component } from "../tui";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getPaddingX,
|
|
4
|
+
getPublishedLineWidths,
|
|
5
|
+
getWidthConfigEpoch,
|
|
6
|
+
padding,
|
|
7
|
+
publishLineWidths,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "../utils";
|
|
3
10
|
|
|
4
11
|
type Cache = {
|
|
5
12
|
width: number;
|
|
13
|
+
widthEpoch: number;
|
|
6
14
|
bgSample: string | undefined;
|
|
7
15
|
borderSample: string | undefined;
|
|
8
16
|
childLines: (readonly string[])[];
|
|
17
|
+
childWidths: (readonly number[] | undefined)[];
|
|
18
|
+
childSnapshots: (readonly string[] | undefined)[];
|
|
9
19
|
result: string[];
|
|
10
20
|
};
|
|
11
21
|
|
|
@@ -122,43 +132,74 @@ export class Box implements Component {
|
|
|
122
132
|
: undefined;
|
|
123
133
|
|
|
124
134
|
// Render every child every frame (renders may carry side effects); the
|
|
125
|
-
// memo only skips re-deriving the padded/background rows.
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
// memo only skips re-deriving the padded/background rows.
|
|
136
|
+
const widthEpoch = getWidthConfigEpoch();
|
|
137
|
+
let contentRows = 0;
|
|
138
|
+
const childLines = children.map(child => {
|
|
139
|
+
const lines = child.render(contentWidth);
|
|
140
|
+
contentRows += lines.length;
|
|
141
|
+
return lines;
|
|
142
|
+
});
|
|
143
|
+
const childWidths = childLines.map(lines => getPublishedLineWidths(lines));
|
|
128
144
|
const cached = this.#cached;
|
|
129
|
-
|
|
145
|
+
if (
|
|
130
146
|
cached !== undefined &&
|
|
131
147
|
cached.width === width &&
|
|
148
|
+
cached.widthEpoch === widthEpoch &&
|
|
149
|
+
cached.widthEpoch === getWidthConfigEpoch() &&
|
|
132
150
|
cached.bgSample === bgSample &&
|
|
133
151
|
cached.borderSample === borderSample &&
|
|
134
|
-
cached.childLines.length === count
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
cached.childLines.length === count &&
|
|
153
|
+
childLines.every((lines, i) => {
|
|
154
|
+
if (cached.childLines[i] !== lines) return false;
|
|
155
|
+
const published = childWidths[i];
|
|
156
|
+
const cachedPublished = cached.childWidths[i];
|
|
157
|
+
if (published !== undefined || cachedPublished !== undefined) {
|
|
158
|
+
return published === cachedPublished;
|
|
159
|
+
}
|
|
160
|
+
const snapshot = cached.childSnapshots[i];
|
|
161
|
+
return (
|
|
162
|
+
snapshot !== undefined &&
|
|
163
|
+
snapshot.length === lines.length &&
|
|
164
|
+
lines.every((line, j) => snapshot[j] === line)
|
|
165
|
+
);
|
|
166
|
+
})
|
|
167
|
+
) {
|
|
168
|
+
return cached.result;
|
|
142
169
|
}
|
|
143
|
-
if (unchanged) return cached!.result;
|
|
144
170
|
|
|
145
171
|
const result: string[] = [];
|
|
172
|
+
// Exact visible widths of `result` rows, published only when the row
|
|
173
|
+
// bytes are `content + spaces` (no bg/border transform of unknown width).
|
|
174
|
+
const resultWidths: number[] | undefined = !border && !this.#bgFn ? [] : undefined;
|
|
146
175
|
if (contentRows > 0) {
|
|
147
176
|
const leftPad = padding(paddingX);
|
|
148
177
|
const interior: string[] = [];
|
|
178
|
+
const pushRow = (row: string, visLen: number): void => {
|
|
179
|
+
const padNeeded = Math.max(0, innerWidth - visLen);
|
|
180
|
+
const padded = padNeeded > 0 ? row + padding(padNeeded) : row;
|
|
181
|
+
interior.push(this.#bgFn ? this.#bgFn(padded) : padded);
|
|
182
|
+
resultWidths?.push(visLen + padNeeded);
|
|
183
|
+
};
|
|
149
184
|
// Top padding
|
|
150
185
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
151
|
-
|
|
186
|
+
pushRow("", 0);
|
|
152
187
|
}
|
|
153
188
|
// Content
|
|
189
|
+
let childIndex = 0;
|
|
154
190
|
for (const lines of childLines) {
|
|
155
|
-
|
|
156
|
-
|
|
191
|
+
const widths = childWidths[childIndex++];
|
|
192
|
+
for (let j = 0; j < lines.length; j++) {
|
|
193
|
+
const line = lines[j] ?? "";
|
|
194
|
+
const row = paddingX > 0 ? leftPad + line : line;
|
|
195
|
+
const carried = widths?.[j];
|
|
196
|
+
const visLen = carried !== undefined && paddingX === 0 ? carried : visibleWidth(row);
|
|
197
|
+
pushRow(row, visLen);
|
|
157
198
|
}
|
|
158
199
|
}
|
|
159
200
|
// Bottom padding
|
|
160
201
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
161
|
-
|
|
202
|
+
pushRow("", 0);
|
|
162
203
|
}
|
|
163
204
|
|
|
164
205
|
if (border) {
|
|
@@ -177,18 +218,19 @@ export class Box implements Component {
|
|
|
177
218
|
}
|
|
178
219
|
}
|
|
179
220
|
|
|
180
|
-
|
|
221
|
+
const finalWidthEpoch = getWidthConfigEpoch();
|
|
222
|
+
if (resultWidths !== undefined) publishLineWidths(result, resultWidths);
|
|
223
|
+
const childSnapshots = childLines.map((lines, i) => (childWidths[i] === undefined ? [...lines] : undefined));
|
|
224
|
+
this.#cached = {
|
|
225
|
+
width,
|
|
226
|
+
widthEpoch: finalWidthEpoch,
|
|
227
|
+
bgSample,
|
|
228
|
+
borderSample,
|
|
229
|
+
childLines,
|
|
230
|
+
childWidths,
|
|
231
|
+
childSnapshots,
|
|
232
|
+
result,
|
|
233
|
+
};
|
|
181
234
|
return result;
|
|
182
235
|
}
|
|
183
|
-
|
|
184
|
-
#applyBg(line: string, width: number): string {
|
|
185
|
-
const visLen = visibleWidth(line);
|
|
186
|
-
const padNeeded = Math.max(0, width - visLen);
|
|
187
|
-
const padded = line + padding(padNeeded);
|
|
188
|
-
|
|
189
|
-
if (this.#bgFn) {
|
|
190
|
-
return applyBackgroundToLine(padded, width, this.#bgFn);
|
|
191
|
-
}
|
|
192
|
-
return padded;
|
|
193
|
-
}
|
|
194
236
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { SymbolTheme } from "../symbols";
|
|
|
13
13
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
14
14
|
import {
|
|
15
15
|
getSegmenter,
|
|
16
|
+
getWidthConfigEpoch,
|
|
16
17
|
getWordNavKind,
|
|
17
18
|
moveWordLeft,
|
|
18
19
|
moveWordRight,
|
|
@@ -31,6 +32,7 @@ const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
31
32
|
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
32
33
|
minPrimaryColumnWidth: 12,
|
|
33
34
|
maxPrimaryColumnWidth: 32,
|
|
35
|
+
wrapDescription: true,
|
|
34
36
|
overflowSearch: false,
|
|
35
37
|
};
|
|
36
38
|
|
|
@@ -43,12 +45,15 @@ const segmenter = getSegmenter();
|
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Represents a chunk of text for word-wrap layout.
|
|
46
|
-
* Tracks
|
|
48
|
+
* Tracks the text content, its position in the original line, and its exact
|
|
49
|
+
* visible width (`width === visibleWidth(text)`, measured at build time) so
|
|
50
|
+
* layout/render never re-measure cached chunks.
|
|
47
51
|
*/
|
|
48
52
|
interface TextChunk {
|
|
49
53
|
text: string;
|
|
50
54
|
startIndex: number;
|
|
51
55
|
endIndex: number;
|
|
56
|
+
width: number;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
@@ -56,92 +61,121 @@ interface TextChunk {
|
|
|
56
61
|
* Wraps at word boundaries when possible, falling back to character-level
|
|
57
62
|
* wrapping for words longer than the available width.
|
|
58
63
|
*
|
|
64
|
+
* Widths are carried, never recomputed: the line is segmented exactly once,
|
|
65
|
+
* per-grapheme widths are measured lazily at most once each, and every chunk
|
|
66
|
+
* is a contiguous slice of `line` (no incremental string concatenation).
|
|
67
|
+
*
|
|
59
68
|
* @param line - The text line to wrap
|
|
60
69
|
* @param maxWidth - Maximum visible width per chunk
|
|
61
|
-
* @
|
|
70
|
+
* @param knownLineWidth - Caller-carried exact `visibleWidth(line)`, if already measured
|
|
71
|
+
* @returns Array of chunks with text, position, and exact visible width
|
|
62
72
|
*/
|
|
63
|
-
function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
73
|
+
function wordWrapLine(line: string, maxWidth: number, knownLineWidth?: number): TextChunk[] {
|
|
64
74
|
if (!line || maxWidth <= 0) {
|
|
65
|
-
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
75
|
+
return [{ text: "", startIndex: 0, endIndex: 0, width: 0 }];
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
const lineWidth = visibleWidth(line);
|
|
78
|
+
const lineWidth = knownLineWidth ?? visibleWidth(line);
|
|
69
79
|
if (lineWidth <= maxWidth) {
|
|
70
|
-
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
80
|
+
return [{ text: line, startIndex: 0, endIndex: line.length, width: lineWidth }];
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
// Single segmentation pass: grapheme start offsets (with end sentinel),
|
|
84
|
+
// lazily-filled grapheme widths, and word/whitespace token boundaries.
|
|
85
|
+
const gStart: number[] = [];
|
|
86
|
+
const gWidth: number[] = [];
|
|
87
|
+
interface Token {
|
|
88
|
+
startG: number;
|
|
89
|
+
endG: number;
|
|
90
|
+
startIndex: number;
|
|
91
|
+
endIndex: number;
|
|
92
|
+
isWhitespace: boolean;
|
|
93
|
+
}
|
|
94
|
+
const tokens: Token[] = [];
|
|
79
95
|
let inWhitespace = false;
|
|
80
|
-
let
|
|
81
|
-
|
|
96
|
+
let tokenStartG = 0;
|
|
97
|
+
let tokenStartIndex = 0;
|
|
98
|
+
let gCount = 0;
|
|
82
99
|
for (const seg of segmenter.segment(line)) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (currentToken === "") {
|
|
100
|
+
const graphemeIsWhitespace = getWordNavKind(seg.segment) === "whitespace";
|
|
101
|
+
if (gCount === 0) {
|
|
87
102
|
inWhitespace = graphemeIsWhitespace;
|
|
88
|
-
tokenStart = charIndex;
|
|
89
103
|
} else if (graphemeIsWhitespace !== inWhitespace) {
|
|
90
|
-
// Token type changed -
|
|
104
|
+
// Token type changed - close the current token
|
|
91
105
|
tokens.push({
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
startG: tokenStartG,
|
|
107
|
+
endG: gCount,
|
|
108
|
+
startIndex: tokenStartIndex,
|
|
109
|
+
endIndex: seg.index,
|
|
95
110
|
isWhitespace: inWhitespace,
|
|
96
111
|
});
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
tokenStartG = gCount;
|
|
113
|
+
tokenStartIndex = seg.index;
|
|
99
114
|
inWhitespace = graphemeIsWhitespace;
|
|
100
115
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
gStart.push(seg.index);
|
|
117
|
+
gWidth.push(-1);
|
|
118
|
+
gCount++;
|
|
104
119
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (currentToken) {
|
|
120
|
+
gStart.push(line.length);
|
|
121
|
+
if (gCount > tokenStartG) {
|
|
108
122
|
tokens.push({
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
startG: tokenStartG,
|
|
124
|
+
endG: gCount,
|
|
125
|
+
startIndex: tokenStartIndex,
|
|
126
|
+
endIndex: line.length,
|
|
112
127
|
isWhitespace: inWhitespace,
|
|
113
128
|
});
|
|
114
129
|
}
|
|
115
130
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
/** Exact `visibleWidth` of grapheme `g`, measured at most once. */
|
|
132
|
+
const graphemeWidth = (g: number): number => {
|
|
133
|
+
let w = gWidth[g] ?? -1;
|
|
134
|
+
if (w < 0) {
|
|
135
|
+
w = visibleWidth(line.slice(gStart[g] ?? 0, gStart[g + 1] ?? line.length));
|
|
136
|
+
gWidth[g] = w;
|
|
137
|
+
}
|
|
138
|
+
return w;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const chunks: TextChunk[] = [];
|
|
142
|
+
const pushChunk = (text: string, startIndex: number, endIndex: number): void => {
|
|
143
|
+
chunks.push({ text, startIndex, endIndex, width: visibleWidth(text) });
|
|
144
|
+
};
|
|
121
145
|
|
|
122
|
-
|
|
123
|
-
|
|
146
|
+
/** Widest grapheme prefix of [startG, endG) that fits `availableWidth`. */
|
|
147
|
+
const consumePrefixToWidth = (
|
|
148
|
+
startG: number,
|
|
149
|
+
endG: number,
|
|
150
|
+
availableWidth: number,
|
|
151
|
+
): { endG: number; len: number } => {
|
|
124
152
|
let prefixWidth = 0;
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
prefixWidth += graphemeWidth;
|
|
132
|
-
len += grapheme.length;
|
|
153
|
+
let g = startG;
|
|
154
|
+
while (g < endG) {
|
|
155
|
+
const w = graphemeWidth(g);
|
|
156
|
+
if (prefixWidth + w > availableWidth) break;
|
|
157
|
+
prefixWidth += w;
|
|
158
|
+
g++;
|
|
133
159
|
if (prefixWidth === availableWidth) break;
|
|
134
160
|
}
|
|
135
|
-
return {
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
for (
|
|
139
|
-
if (
|
|
161
|
+
return { endG: g, len: (gStart[g] ?? 0) - (gStart[startG] ?? 0) };
|
|
162
|
+
};
|
|
163
|
+
const hasWideGrapheme = (startG: number, endG: number): boolean => {
|
|
164
|
+
for (let g = startG; g < endG; g++) {
|
|
165
|
+
if (graphemeWidth(g) > 1) return true;
|
|
140
166
|
}
|
|
141
167
|
return false;
|
|
142
|
-
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Build chunks using word wrapping. The pending chunk is always the
|
|
171
|
+
// contiguous slice line[chunkStart, chunkEnd) with visible width currentWidth.
|
|
172
|
+
let chunkStart = 0;
|
|
173
|
+
let chunkEnd = 0;
|
|
174
|
+
let currentWidth = 0;
|
|
175
|
+
let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
|
|
176
|
+
|
|
143
177
|
for (const token of tokens) {
|
|
144
|
-
const tokenWidth = visibleWidth(token.
|
|
178
|
+
const tokenWidth = visibleWidth(line.slice(token.startIndex, token.endIndex));
|
|
145
179
|
|
|
146
180
|
// Skip leading whitespace at line start. Keep the skipped run mapped onto the
|
|
147
181
|
// preceding chunk (when one exists) so every cursor position resolves to a
|
|
@@ -149,7 +183,8 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
149
183
|
if (atLineStart && token.isWhitespace) {
|
|
150
184
|
const prev = chunks[chunks.length - 1];
|
|
151
185
|
if (prev) prev.endIndex = token.endIndex;
|
|
152
|
-
|
|
186
|
+
chunkStart = token.endIndex;
|
|
187
|
+
chunkEnd = token.endIndex;
|
|
153
188
|
continue;
|
|
154
189
|
}
|
|
155
190
|
atLineStart = false;
|
|
@@ -157,65 +192,49 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
157
192
|
// If this single token is wider than maxWidth, we need to break it
|
|
158
193
|
if (tokenWidth > maxWidth) {
|
|
159
194
|
// If we're mid-line, try to use the remaining width by consuming a prefix of this long token.
|
|
160
|
-
let
|
|
161
|
-
let
|
|
162
|
-
if (
|
|
195
|
+
let consumedPrefixLen = 0; // JS string index (code units) consumed from the token
|
|
196
|
+
let consumedPrefixEndG = token.startG;
|
|
197
|
+
if (chunkEnd > chunkStart && currentWidth < maxWidth) {
|
|
163
198
|
const remainingWidth = maxWidth - currentWidth;
|
|
164
|
-
const consumed = consumePrefixToWidth(token.
|
|
165
|
-
|
|
199
|
+
const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth);
|
|
200
|
+
consumedPrefixEndG = consumed.endG;
|
|
166
201
|
consumedPrefixLen = consumed.len;
|
|
167
202
|
}
|
|
168
203
|
// First, push any accumulated chunk (optionally filled with the prefix).
|
|
169
|
-
if (
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
176
|
-
currentChunk = "";
|
|
177
|
-
currentWidth = 0;
|
|
178
|
-
chunkStartIndex = token.startIndex + consumedPrefixLen;
|
|
204
|
+
if (chunkEnd > chunkStart) {
|
|
205
|
+
if (consumedPrefixLen > 0) {
|
|
206
|
+
const endIndex = token.startIndex + consumedPrefixLen;
|
|
207
|
+
pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex);
|
|
208
|
+
chunkStart = endIndex;
|
|
209
|
+
chunkEnd = endIndex;
|
|
179
210
|
} else {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
endIndex: token.startIndex,
|
|
184
|
-
});
|
|
185
|
-
currentChunk = "";
|
|
186
|
-
currentWidth = 0;
|
|
187
|
-
chunkStartIndex = token.startIndex;
|
|
211
|
+
pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, token.startIndex);
|
|
212
|
+
chunkStart = token.startIndex;
|
|
213
|
+
chunkEnd = token.startIndex;
|
|
188
214
|
}
|
|
215
|
+
currentWidth = 0;
|
|
189
216
|
}
|
|
190
217
|
// Break the remaining long token by grapheme
|
|
191
|
-
|
|
192
|
-
let
|
|
193
|
-
let
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
text: tokenChunk,
|
|
202
|
-
startIndex: tokenChunkStart,
|
|
203
|
-
endIndex: tokenCharIndex,
|
|
204
|
-
});
|
|
205
|
-
tokenChunk = grapheme;
|
|
206
|
-
tokenChunkWidth = graphemeWidth;
|
|
207
|
-
tokenChunkStart = tokenCharIndex;
|
|
218
|
+
let tcStart = token.startIndex + consumedPrefixLen;
|
|
219
|
+
let tcEnd = tcStart;
|
|
220
|
+
let tcWidth = 0;
|
|
221
|
+
for (let g = consumedPrefixEndG; g < token.endG; g++) {
|
|
222
|
+
const w = graphemeWidth(g);
|
|
223
|
+
const gEnd = gStart[g + 1] ?? line.length;
|
|
224
|
+
if (tcWidth + w > maxWidth && tcEnd > tcStart) {
|
|
225
|
+
pushChunk(line.slice(tcStart, tcEnd), tcStart, tcEnd);
|
|
226
|
+
tcStart = tcEnd;
|
|
227
|
+
tcWidth = w;
|
|
208
228
|
} else {
|
|
209
|
-
|
|
210
|
-
tokenChunkWidth += graphemeWidth;
|
|
229
|
+
tcWidth += w;
|
|
211
230
|
}
|
|
212
|
-
|
|
231
|
+
tcEnd = gEnd;
|
|
213
232
|
}
|
|
214
233
|
// Keep remainder as start of next chunk
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
234
|
+
if (tcEnd > tcStart) {
|
|
235
|
+
chunkStart = tcStart;
|
|
236
|
+
chunkEnd = tcEnd;
|
|
237
|
+
currentWidth = tcWidth;
|
|
219
238
|
}
|
|
220
239
|
continue;
|
|
221
240
|
}
|
|
@@ -224,36 +243,34 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
224
243
|
if (currentWidth + tokenWidth > maxWidth) {
|
|
225
244
|
// For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping
|
|
226
245
|
// the whole token to the next line. This avoids leaving a short ASCII word alone.
|
|
227
|
-
if (
|
|
246
|
+
if (
|
|
247
|
+
chunkEnd > chunkStart &&
|
|
248
|
+
!token.isWhitespace &&
|
|
249
|
+
currentWidth < maxWidth &&
|
|
250
|
+
hasWideGrapheme(token.startG, token.endG)
|
|
251
|
+
) {
|
|
228
252
|
const remainingWidth = maxWidth - currentWidth;
|
|
229
|
-
const consumed = consumePrefixToWidth(token.
|
|
230
|
-
if (consumed.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const remainder = token.text.slice(consumed.len);
|
|
237
|
-
currentChunk = remainder;
|
|
253
|
+
const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth);
|
|
254
|
+
if (consumed.len > 0) {
|
|
255
|
+
const endIndex = token.startIndex + consumed.len;
|
|
256
|
+
pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex);
|
|
257
|
+
const remainder = line.slice(endIndex, token.endIndex);
|
|
258
|
+
chunkStart = endIndex;
|
|
259
|
+
chunkEnd = token.endIndex;
|
|
238
260
|
currentWidth = visibleWidth(remainder);
|
|
239
|
-
chunkStartIndex = token.startIndex + consumed.len;
|
|
240
261
|
atLineStart = false;
|
|
241
262
|
continue;
|
|
242
263
|
}
|
|
243
264
|
}
|
|
244
265
|
// Push current chunk (trimming trailing whitespace for display)
|
|
245
|
-
const trimmedChunk =
|
|
266
|
+
const trimmedChunk = line.slice(chunkStart, chunkEnd).trimEnd();
|
|
246
267
|
if (trimmedChunk || chunks.length === 0) {
|
|
247
|
-
|
|
248
|
-
text: trimmedChunk,
|
|
249
|
-
startIndex: chunkStartIndex,
|
|
250
|
-
endIndex: chunkStartIndex + currentChunk.length,
|
|
251
|
-
});
|
|
268
|
+
pushChunk(trimmedChunk, chunkStart, chunkEnd);
|
|
252
269
|
} else {
|
|
253
270
|
// All-whitespace chunk collapsed away: keep its span mapped on the
|
|
254
271
|
// previous chunk so cursor positions inside it stay addressable.
|
|
255
272
|
const prev = chunks[chunks.length - 1];
|
|
256
|
-
if (prev) prev.endIndex =
|
|
273
|
+
if (prev) prev.endIndex = chunkEnd;
|
|
257
274
|
}
|
|
258
275
|
// Start new line - skip leading whitespace
|
|
259
276
|
atLineStart = true;
|
|
@@ -262,32 +279,29 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
262
279
|
// point; otherwise cursor positions inside it map to no layout line.
|
|
263
280
|
const prev = chunks[chunks.length - 1];
|
|
264
281
|
if (prev) prev.endIndex = token.endIndex;
|
|
265
|
-
|
|
282
|
+
chunkStart = token.endIndex;
|
|
283
|
+
chunkEnd = token.endIndex;
|
|
266
284
|
currentWidth = 0;
|
|
267
|
-
chunkStartIndex = token.endIndex;
|
|
268
285
|
} else {
|
|
269
|
-
|
|
286
|
+
chunkStart = token.startIndex;
|
|
287
|
+
chunkEnd = token.endIndex;
|
|
270
288
|
currentWidth = tokenWidth;
|
|
271
|
-
chunkStartIndex = token.startIndex;
|
|
272
289
|
atLineStart = false;
|
|
273
290
|
}
|
|
274
291
|
} else {
|
|
275
292
|
// Add token to current chunk
|
|
276
|
-
|
|
293
|
+
if (chunkEnd === chunkStart) chunkStart = token.startIndex;
|
|
294
|
+
chunkEnd = token.endIndex;
|
|
277
295
|
currentWidth += tokenWidth;
|
|
278
296
|
}
|
|
279
297
|
}
|
|
280
298
|
|
|
281
299
|
// Push final chunk
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
text: currentChunk,
|
|
285
|
-
startIndex: chunkStartIndex,
|
|
286
|
-
endIndex: line.length,
|
|
287
|
-
});
|
|
300
|
+
if (chunkEnd > chunkStart) {
|
|
301
|
+
pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, line.length);
|
|
288
302
|
}
|
|
289
303
|
|
|
290
|
-
return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
304
|
+
return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0, width: 0 }];
|
|
291
305
|
}
|
|
292
306
|
|
|
293
307
|
/** Visual cell column of code-unit `offset` within `text`, counted by grapheme walk. */
|
|
@@ -339,10 +353,19 @@ interface EditorState {
|
|
|
339
353
|
|
|
340
354
|
interface LayoutLine {
|
|
341
355
|
text: string;
|
|
356
|
+
/** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */
|
|
357
|
+
width: number;
|
|
342
358
|
hasCursor: boolean;
|
|
343
359
|
cursorPos?: number;
|
|
344
360
|
}
|
|
345
361
|
|
|
362
|
+
/** Per-line measurement carried across renders: exact visible width plus
|
|
363
|
+
* lazily-built wrap chunks (only populated once the line needs wrapping). */
|
|
364
|
+
interface WrapEntry {
|
|
365
|
+
width: number;
|
|
366
|
+
chunks: TextChunk[] | null;
|
|
367
|
+
}
|
|
368
|
+
|
|
346
369
|
export interface EditorTheme {
|
|
347
370
|
borderColor: (str: string) => string;
|
|
348
371
|
selectList: SelectListTheme;
|
|
@@ -396,11 +419,14 @@ export class Editor implements Component, Focusable {
|
|
|
396
419
|
|
|
397
420
|
// Store last layout width for cursor navigation
|
|
398
421
|
#lastLayoutWidth: number = 80;
|
|
399
|
-
//
|
|
400
|
-
// handlers within a frame. Line text is a
|
|
401
|
-
//
|
|
402
|
-
|
|
422
|
+
// Line measurement + word-wrap cache shared by #layoutText,
|
|
423
|
+
// #buildVisualLineMap, and key handlers within a frame. Line text is a
|
|
424
|
+
// sound key (strings are immutable); cleared on layout-width or
|
|
425
|
+
// width-config (Hangul jamo setting) change and size-bounded so stale
|
|
426
|
+
// lines don't accumulate.
|
|
427
|
+
#wrapCache = new Map<string, WrapEntry>();
|
|
403
428
|
#wrapCacheWidth = -1;
|
|
429
|
+
#wrapCacheEpoch = -1;
|
|
404
430
|
#paddingXOverride: number | undefined;
|
|
405
431
|
#maxHeight?: number;
|
|
406
432
|
#scrollOffset: number = 0;
|
|
@@ -895,7 +921,7 @@ export class Editor implements Component, Focusable {
|
|
|
895
921
|
for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
|
|
896
922
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
897
923
|
let displayText = layoutLine.text;
|
|
898
|
-
let displayWidth =
|
|
924
|
+
let displayWidth = layoutLine.width;
|
|
899
925
|
let cursorPaddingOverflow = 0;
|
|
900
926
|
let decorated = false;
|
|
901
927
|
let imeSafeCursorTail = false;
|
|
@@ -1041,7 +1067,9 @@ export class Editor implements Component, Focusable {
|
|
|
1041
1067
|
displayText = this.#decorate(displayText);
|
|
1042
1068
|
}
|
|
1043
1069
|
if (!hasCursor) {
|
|
1044
|
-
|
|
1070
|
+
// Undecorated, unsliced lines keep their carried width; any
|
|
1071
|
+
// transform above produced a new string and must be re-measured.
|
|
1072
|
+
displayWidth = displayText === layoutLine.text ? layoutLine.width : visibleWidth(displayText);
|
|
1045
1073
|
if (displayWidth > lineContentWidth) {
|
|
1046
1074
|
displayText = truncateToWidth(displayText, lineContentWidth);
|
|
1047
1075
|
displayWidth = visibleWidth(displayText);
|
|
@@ -1489,20 +1517,29 @@ export class Editor implements Component, Focusable {
|
|
|
1489
1517
|
}
|
|
1490
1518
|
}
|
|
1491
1519
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1520
|
+
/** Cached per-line measurement: exact visible width now, wrap chunks on demand. */
|
|
1521
|
+
#lineEntry(line: string, width: number): WrapEntry {
|
|
1522
|
+
const epoch = getWidthConfigEpoch();
|
|
1523
|
+
if (width !== this.#wrapCacheWidth || epoch !== this.#wrapCacheEpoch) {
|
|
1494
1524
|
this.#wrapCache.clear();
|
|
1495
1525
|
this.#wrapCacheWidth = width;
|
|
1526
|
+
this.#wrapCacheEpoch = epoch;
|
|
1496
1527
|
}
|
|
1497
|
-
let
|
|
1498
|
-
if (
|
|
1528
|
+
let entry = this.#wrapCache.get(line);
|
|
1529
|
+
if (entry === undefined) {
|
|
1499
1530
|
if (this.#wrapCache.size >= 256) {
|
|
1500
1531
|
this.#wrapCache.clear();
|
|
1501
1532
|
}
|
|
1502
|
-
|
|
1503
|
-
this.#wrapCache.set(line,
|
|
1533
|
+
entry = { width: visibleWidth(line), chunks: null };
|
|
1534
|
+
this.#wrapCache.set(line, entry);
|
|
1504
1535
|
}
|
|
1505
|
-
return
|
|
1536
|
+
return entry;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
#wrapLine(line: string, width: number): TextChunk[] {
|
|
1540
|
+
const entry = this.#lineEntry(line, width);
|
|
1541
|
+
entry.chunks ??= wordWrapLine(line, width, entry.width);
|
|
1542
|
+
return entry.chunks;
|
|
1506
1543
|
}
|
|
1507
1544
|
|
|
1508
1545
|
#layoutText(contentWidth: number): LayoutLine[] {
|
|
@@ -1512,6 +1549,7 @@ export class Editor implements Component, Focusable {
|
|
|
1512
1549
|
// Empty editor
|
|
1513
1550
|
layoutLines.push({
|
|
1514
1551
|
text: "",
|
|
1552
|
+
width: 0,
|
|
1515
1553
|
hasCursor: true,
|
|
1516
1554
|
cursorPos: 0,
|
|
1517
1555
|
});
|
|
@@ -1522,19 +1560,21 @@ export class Editor implements Component, Focusable {
|
|
|
1522
1560
|
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
1523
1561
|
const line = this.#state.lines[i] || "";
|
|
1524
1562
|
const isCurrentLine = i === this.#state.cursorLine;
|
|
1525
|
-
const lineVisibleWidth =
|
|
1563
|
+
const lineVisibleWidth = this.#lineEntry(line, contentWidth).width;
|
|
1526
1564
|
|
|
1527
1565
|
if (lineVisibleWidth <= contentWidth) {
|
|
1528
1566
|
// Line fits in one layout line
|
|
1529
1567
|
if (isCurrentLine) {
|
|
1530
1568
|
layoutLines.push({
|
|
1531
1569
|
text: line,
|
|
1570
|
+
width: lineVisibleWidth,
|
|
1532
1571
|
hasCursor: true,
|
|
1533
1572
|
cursorPos: this.#state.cursorCol,
|
|
1534
1573
|
});
|
|
1535
1574
|
} else {
|
|
1536
1575
|
layoutLines.push({
|
|
1537
1576
|
text: line,
|
|
1577
|
+
width: lineVisibleWidth,
|
|
1538
1578
|
hasCursor: false,
|
|
1539
1579
|
});
|
|
1540
1580
|
}
|
|
@@ -1575,12 +1615,14 @@ export class Editor implements Component, Focusable {
|
|
|
1575
1615
|
if (hasCursorInChunk) {
|
|
1576
1616
|
layoutLines.push({
|
|
1577
1617
|
text: chunk.text,
|
|
1618
|
+
width: chunk.width,
|
|
1578
1619
|
hasCursor: true,
|
|
1579
1620
|
cursorPos: adjustedCursorPos,
|
|
1580
1621
|
});
|
|
1581
1622
|
} else {
|
|
1582
1623
|
layoutLines.push({
|
|
1583
1624
|
text: chunk.text,
|
|
1625
|
+
width: chunk.width,
|
|
1584
1626
|
hasCursor: false,
|
|
1585
1627
|
});
|
|
1586
1628
|
}
|
|
@@ -2716,7 +2758,7 @@ export class Editor implements Component, Focusable {
|
|
|
2716
2758
|
|
|
2717
2759
|
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
2718
2760
|
const line = this.#state.lines[i] || "";
|
|
2719
|
-
const lineVisWidth =
|
|
2761
|
+
const lineVisWidth = this.#lineEntry(line, width).width;
|
|
2720
2762
|
if (line.length === 0) {
|
|
2721
2763
|
// Empty line still takes one visual line
|
|
2722
2764
|
visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
|
package/src/components/text.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { Component } from "../tui";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyBackgroundToLine,
|
|
4
|
+
getPaddingX,
|
|
5
|
+
getWidthConfigEpoch,
|
|
6
|
+
padding,
|
|
7
|
+
publishLineWidths,
|
|
8
|
+
replaceTabs,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
} from "../utils";
|
|
3
12
|
|
|
4
13
|
/**
|
|
5
14
|
* Text component - displays multi-line text with word wrapping
|
|
@@ -21,6 +30,7 @@ export class Text implements Component {
|
|
|
21
30
|
// Cache for rendered output
|
|
22
31
|
#cachedText?: string;
|
|
23
32
|
#cachedWidth?: number;
|
|
33
|
+
#cachedWidthEpoch?: number;
|
|
24
34
|
#cachedLines?: string[];
|
|
25
35
|
|
|
26
36
|
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
|
|
@@ -41,6 +51,7 @@ export class Text implements Component {
|
|
|
41
51
|
this.#text = text;
|
|
42
52
|
this.#cachedText = undefined;
|
|
43
53
|
this.#cachedWidth = undefined;
|
|
54
|
+
this.#cachedWidthEpoch = undefined;
|
|
44
55
|
this.#cachedLines = undefined;
|
|
45
56
|
return true;
|
|
46
57
|
}
|
|
@@ -49,18 +60,25 @@ export class Text implements Component {
|
|
|
49
60
|
this.#customBgFn = customBgFn;
|
|
50
61
|
this.#cachedText = undefined;
|
|
51
62
|
this.#cachedWidth = undefined;
|
|
63
|
+
this.#cachedWidthEpoch = undefined;
|
|
52
64
|
this.#cachedLines = undefined;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
invalidate(): void {
|
|
56
68
|
this.#cachedText = undefined;
|
|
57
69
|
this.#cachedWidth = undefined;
|
|
70
|
+
this.#cachedWidthEpoch = undefined;
|
|
58
71
|
this.#cachedLines = undefined;
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
render(width: number): readonly string[] {
|
|
62
75
|
// Check cache
|
|
63
|
-
if (
|
|
76
|
+
if (
|
|
77
|
+
this.#cachedLines &&
|
|
78
|
+
this.#cachedText === this.#text &&
|
|
79
|
+
this.#cachedWidth === width &&
|
|
80
|
+
this.#cachedWidthEpoch === getWidthConfigEpoch()
|
|
81
|
+
) {
|
|
64
82
|
return this.#cachedLines;
|
|
65
83
|
}
|
|
66
84
|
|
|
@@ -69,6 +87,7 @@ export class Text implements Component {
|
|
|
69
87
|
const result: string[] = [];
|
|
70
88
|
this.#cachedText = this.#text;
|
|
71
89
|
this.#cachedWidth = width;
|
|
90
|
+
this.#cachedWidthEpoch = getWidthConfigEpoch();
|
|
72
91
|
this.#cachedLines = result;
|
|
73
92
|
return result;
|
|
74
93
|
}
|
|
@@ -86,6 +105,9 @@ export class Text implements Component {
|
|
|
86
105
|
const leftMargin = padding(paddingX);
|
|
87
106
|
const rightMargin = padding(paddingX);
|
|
88
107
|
const contentLines: string[] = [];
|
|
108
|
+
// Exact visible widths of `result` rows, published only when rows are
|
|
109
|
+
// `content + spaces` (customBgFn output width is not knowable here).
|
|
110
|
+
const resultWidths: number[] | undefined = this.#customBgFn ? undefined : [];
|
|
89
111
|
|
|
90
112
|
for (const line of wrappedLines) {
|
|
91
113
|
// Add margins
|
|
@@ -99,6 +121,7 @@ export class Text implements Component {
|
|
|
99
121
|
const visibleLen = visibleWidth(lineWithMargins);
|
|
100
122
|
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
101
123
|
contentLines.push(lineWithMargins + padding(paddingNeeded));
|
|
124
|
+
resultWidths?.push(visibleLen + paddingNeeded);
|
|
102
125
|
}
|
|
103
126
|
}
|
|
104
127
|
|
|
@@ -111,10 +134,16 @@ export class Text implements Component {
|
|
|
111
134
|
}
|
|
112
135
|
|
|
113
136
|
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
|
137
|
+
if (resultWidths !== undefined) {
|
|
138
|
+
// Pad rows are exactly `width` cells wide.
|
|
139
|
+
const emptyWidths = new Array<number>(emptyLines.length).fill(width);
|
|
140
|
+
publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
|
|
141
|
+
}
|
|
114
142
|
|
|
115
143
|
// Update cache
|
|
116
144
|
this.#cachedText = this.#text;
|
|
117
145
|
this.#cachedWidth = width;
|
|
146
|
+
this.#cachedWidthEpoch = getWidthConfigEpoch();
|
|
118
147
|
this.#cachedLines = result;
|
|
119
148
|
|
|
120
149
|
return result.length > 0 ? result : [""];
|
package/src/tui.ts
CHANGED
|
@@ -194,17 +194,22 @@ export interface OverlayFocusOwner {
|
|
|
194
194
|
* FINAL — byte-stable at the current width for the component's lifetime — and
|
|
195
195
|
* commit to native scrollback as exact, audited content. Rows at/after the
|
|
196
196
|
* boundary repaint in place inside the visible window; when they scroll above
|
|
197
|
-
* the window top they
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
197
|
+
* the window top they normally commit as frozen visual snapshots.
|
|
198
|
+
*
|
|
199
|
+
* A viewport-pinned region opts out of those mutable snapshot commits. Its
|
|
200
|
+
* offscreen mutable rows are virtually clipped until the boundary advances;
|
|
201
|
+
* use this for fixed-height dashboards whose frames replace each other rather
|
|
202
|
+
* than append. A root that reports no seam commits everything that scrolls as
|
|
203
|
+
* final (shell semantics).
|
|
201
204
|
*
|
|
202
205
|
* When several root children report a seam in the same frame, the topmost one
|
|
203
|
-
* defines the boundary:
|
|
204
|
-
* first seam is already excluded.
|
|
206
|
+
* defines the boundary and pinning policy: commits are prefix-only, so
|
|
207
|
+
* everything below the first seam is already excluded.
|
|
205
208
|
*/
|
|
206
209
|
export interface NativeScrollbackLiveRegion {
|
|
207
210
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
211
|
+
/** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
|
|
212
|
+
isNativeScrollbackLiveRegionPinned?(): boolean;
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
export interface NativeScrollbackCommittedRows {
|
|
@@ -639,11 +644,9 @@ interface CursorControlResult extends HardwareCursorUpdate {
|
|
|
639
644
|
}
|
|
640
645
|
|
|
641
646
|
/**
|
|
642
|
-
* One root child's contribution to the composed frame:
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* and the child-local seam report captured at render time — replayed verbatim
|
|
646
|
-
* when a component-scoped frame reuses this segment without re-rendering.
|
|
647
|
+
* One root child's contribution to the composed frame: its rendered rows,
|
|
648
|
+
* frame span, and live-region report captured at render time. Component-scoped
|
|
649
|
+
* frames replay the seam and viewport-pinning policy without re-rendering.
|
|
647
650
|
*/
|
|
648
651
|
interface FrameSegment {
|
|
649
652
|
component: Component;
|
|
@@ -651,6 +654,7 @@ interface FrameSegment {
|
|
|
651
654
|
start: number;
|
|
652
655
|
rowCount: number;
|
|
653
656
|
liveLocalStart?: number;
|
|
657
|
+
liveRegionPinned: boolean;
|
|
654
658
|
}
|
|
655
659
|
|
|
656
660
|
/** Depth-first identity search through `Container`-shaped children. */
|
|
@@ -1042,6 +1046,7 @@ export class TUI extends Container {
|
|
|
1042
1046
|
// Exactly what is painted on the screen rows (post-composite, prepared).
|
|
1043
1047
|
#previousWindow: string[] = [];
|
|
1044
1048
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
1049
|
+
#nativeScrollbackLiveRegionPinned = false;
|
|
1045
1050
|
#fullRedrawCount = 0;
|
|
1046
1051
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
1047
1052
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
@@ -1133,6 +1138,7 @@ export class TUI extends Container {
|
|
|
1133
1138
|
// Target component -> containing root child, so animation-rate requests do
|
|
1134
1139
|
// not re-walk a huge transcript subtree every frame.
|
|
1135
1140
|
#componentRootCache = new WeakMap<Component, Component>();
|
|
1141
|
+
#scopedInputRenderComponents = new WeakSet<Component>();
|
|
1136
1142
|
|
|
1137
1143
|
// Persistent prepared frame, row-aligned with #composedFrame. Entries store
|
|
1138
1144
|
// normalized, width-fitted content rows without the per-line terminal
|
|
@@ -1165,6 +1171,7 @@ export class TUI extends Container {
|
|
|
1165
1171
|
override render(width: number): readonly string[] {
|
|
1166
1172
|
width = Math.max(1, width);
|
|
1167
1173
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
1174
|
+
this.#nativeScrollbackLiveRegionPinned = false;
|
|
1168
1175
|
const children = this.children;
|
|
1169
1176
|
const previousSegments = this.#frameSegments;
|
|
1170
1177
|
const segments: FrameSegment[] = new Array(children.length);
|
|
@@ -1185,10 +1192,12 @@ export class TUI extends Container {
|
|
|
1185
1192
|
partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
|
|
1186
1193
|
let childLines: readonly string[];
|
|
1187
1194
|
let liveLocalStart: number | undefined;
|
|
1195
|
+
let liveRegionPinned = false;
|
|
1188
1196
|
let reported: number | undefined;
|
|
1189
1197
|
if (reuse) {
|
|
1190
1198
|
childLines = previous.lines;
|
|
1191
1199
|
liveLocalStart = previous.liveLocalStart;
|
|
1200
|
+
liveRegionPinned = previous.liveRegionPinned;
|
|
1192
1201
|
} else {
|
|
1193
1202
|
// Feed the engine's committed-row claim (from the previous frame's
|
|
1194
1203
|
// emit) before rendering so the child can skip re-deriving blocks
|
|
@@ -1208,6 +1217,11 @@ export class TUI extends Container {
|
|
|
1208
1217
|
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
1209
1218
|
: childLines.length;
|
|
1210
1219
|
}
|
|
1220
|
+
if (liveLocalStart !== undefined) {
|
|
1221
|
+
liveRegionPinned =
|
|
1222
|
+
(child as Component & Partial<NativeScrollbackLiveRegion>).isNativeScrollbackLiveRegionPinned?.() ===
|
|
1223
|
+
true;
|
|
1224
|
+
}
|
|
1211
1225
|
// Consume the stability report unconditionally for implementers:
|
|
1212
1226
|
// reading re-bases the component's baseline to the state this
|
|
1213
1227
|
// compose is about to ingest (used or not, the current rows are
|
|
@@ -1224,6 +1238,7 @@ export class TUI extends Container {
|
|
|
1224
1238
|
// history.
|
|
1225
1239
|
if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
|
|
1226
1240
|
this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
|
|
1241
|
+
this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
|
|
1227
1242
|
}
|
|
1228
1243
|
if (chainStable) {
|
|
1229
1244
|
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
@@ -1252,6 +1267,7 @@ export class TUI extends Container {
|
|
|
1252
1267
|
start: offset,
|
|
1253
1268
|
rowCount: childLines.length,
|
|
1254
1269
|
liveLocalStart,
|
|
1270
|
+
liveRegionPinned,
|
|
1255
1271
|
};
|
|
1256
1272
|
offset += childLines.length;
|
|
1257
1273
|
}
|
|
@@ -1347,6 +1363,20 @@ export class TUI extends Container {
|
|
|
1347
1363
|
this.#imageBudget.setCap(cap);
|
|
1348
1364
|
}
|
|
1349
1365
|
|
|
1366
|
+
/** Delete every tracked Kitty image from the terminal graphics store. */
|
|
1367
|
+
clearInlineImages(): void {
|
|
1368
|
+
if (this.#stopped) return;
|
|
1369
|
+
this.#purgeInlineImages();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
#purgeInlineImages(): void {
|
|
1373
|
+
const transmittedIds = this.#imageBudget.takeAllTransmittedIds();
|
|
1374
|
+
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
|
|
1375
|
+
for (const id of transmittedIds) {
|
|
1376
|
+
this.terminal.write(encodeKittyDeleteImage(id));
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1350
1380
|
/**
|
|
1351
1381
|
* Get whether scrollback divergence rebuild is enabled.
|
|
1352
1382
|
*/
|
|
@@ -1771,11 +1801,7 @@ export class TUI extends Container {
|
|
|
1771
1801
|
this.#altPreviousLines = [];
|
|
1772
1802
|
this.#pendingAltExit = "";
|
|
1773
1803
|
}
|
|
1774
|
-
|
|
1775
|
-
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
1776
|
-
this.terminal.write(encodeKittyDeleteImage(id));
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1804
|
+
this.#purgeInlineImages();
|
|
1779
1805
|
this.#clearSixelProbeState();
|
|
1780
1806
|
this.#stopped = true;
|
|
1781
1807
|
this.#watchdog.stop();
|
|
@@ -1889,6 +1915,17 @@ export class TUI extends Container {
|
|
|
1889
1915
|
this.#requestOrdinaryRender();
|
|
1890
1916
|
}
|
|
1891
1917
|
|
|
1918
|
+
/**
|
|
1919
|
+
* Opt `component` into subtree-only renders when input leaves focus stable.
|
|
1920
|
+
*
|
|
1921
|
+
* The host must explicitly request renders for every sibling mutated by the
|
|
1922
|
+
* component's input callbacks. Components without this opt-in retain the
|
|
1923
|
+
* legacy full-root render after input.
|
|
1924
|
+
*/
|
|
1925
|
+
enableScopedInputRender(component: Component): void {
|
|
1926
|
+
this.#scopedInputRenderComponents.add(component);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1892
1929
|
/**
|
|
1893
1930
|
* Schedule a render on behalf of `component` after a self-contained change
|
|
1894
1931
|
* (spinner frame, blink) that cannot have affected any other component.
|
|
@@ -2368,15 +2405,23 @@ export class TUI extends Container {
|
|
|
2368
2405
|
}
|
|
2369
2406
|
}
|
|
2370
2407
|
|
|
2371
|
-
// Pass input to focused component (including Ctrl+C)
|
|
2372
|
-
// The focused component can decide how to handle Ctrl+C
|
|
2373
|
-
|
|
2408
|
+
// Pass input to focused component (including Ctrl+C).
|
|
2409
|
+
// The focused component can decide how to handle Ctrl+C.
|
|
2410
|
+
// Opted-in components only dirty their focused subtree. Unregistered
|
|
2411
|
+
// components retain the legacy full compose because their callbacks may
|
|
2412
|
+
// mutate siblings; focus changes also require the new surface to paint.
|
|
2413
|
+
const focused = this.#focusedComponent;
|
|
2414
|
+
if (focused?.handleInput) {
|
|
2374
2415
|
// Filter out key release events unless component opts in
|
|
2375
|
-
if (isKeyRelease(data) && !
|
|
2416
|
+
if (isKeyRelease(data) && !focused.wantsKeyRelease) {
|
|
2376
2417
|
return;
|
|
2377
2418
|
}
|
|
2378
|
-
|
|
2379
|
-
this.
|
|
2419
|
+
focused.handleInput(data);
|
|
2420
|
+
if (this.#focusedComponent === focused && this.#scopedInputRenderComponents.has(focused)) {
|
|
2421
|
+
this.requestComponentRender(focused);
|
|
2422
|
+
} else {
|
|
2423
|
+
this.requestRender();
|
|
2424
|
+
}
|
|
2380
2425
|
}
|
|
2381
2426
|
}
|
|
2382
2427
|
|
|
@@ -2861,6 +2906,7 @@ export class TUI extends Container {
|
|
|
2861
2906
|
// known. Ascending by frame row.
|
|
2862
2907
|
const cursorMarkers = this.#frameCursorMarkers;
|
|
2863
2908
|
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
2909
|
+
const liveRegionPinned = this.#nativeScrollbackLiveRegionPinned;
|
|
2864
2910
|
|
|
2865
2911
|
// Exactness boundary (used by the audit-zone math below). Rows below it
|
|
2866
2912
|
// are declared FINAL by the component seam: when they commit, they enter
|
|
@@ -2987,7 +3033,7 @@ export class TUI extends Container {
|
|
|
2987
3033
|
if (fullPaint) {
|
|
2988
3034
|
committedPrefixResliced = true;
|
|
2989
3035
|
windowTop = Math.max(0, frameLength - height);
|
|
2990
|
-
chunkTo = windowTop;
|
|
3036
|
+
chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
|
2991
3037
|
} else if (
|
|
2992
3038
|
frameLength <= this.#committedRows ||
|
|
2993
3039
|
(committedRowsResynced &&
|
|
@@ -3007,9 +3053,19 @@ export class TUI extends Container {
|
|
|
3007
3053
|
// "duplication, never loss" is the ED3-unsafe fallback contract.
|
|
3008
3054
|
committedPrefixResliced = true;
|
|
3009
3055
|
windowTop = Math.max(0, frameLength - height);
|
|
3010
|
-
chunkTo = windowTop;
|
|
3056
|
+
chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
|
3011
3057
|
this.#committedRows = chunkTo;
|
|
3012
3058
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
3059
|
+
} else if (geometryChanged && Math.max(0, frameLength - height) < this.#committedRows) {
|
|
3060
|
+
// Pane growth/reflow can pull rows back out of mux scrollback and into
|
|
3061
|
+
// the viewport. Rebase the commit seam to that exposed frame tail before
|
|
3062
|
+
// the forced rewrite; flooring at the old seam would paint only the live
|
|
3063
|
+
// suffix followed by blanks, then preserve that gap on every stream tick.
|
|
3064
|
+
committedPrefixResliced = true;
|
|
3065
|
+
windowTop = Math.max(0, frameLength - height);
|
|
3066
|
+
chunkTo = windowTop;
|
|
3067
|
+
this.#committedRows = windowTop;
|
|
3068
|
+
this.#committedPrefix = rawFrame.slice(0, windowTop);
|
|
3013
3069
|
} else {
|
|
3014
3070
|
// Re-anchor to the frame tail, floored at the committed boundary: a
|
|
3015
3071
|
// shrink (or overlay close) pulls the window back down, but never
|
|
@@ -3026,7 +3082,12 @@ export class TUI extends Container {
|
|
|
3026
3082
|
// history — and re-bases the audit prefix at the new width so the
|
|
3027
3083
|
// accepted wrap drift does not read as a violation on the next
|
|
3028
3084
|
// ordinary frame.
|
|
3029
|
-
chunkTo =
|
|
3085
|
+
chunkTo =
|
|
3086
|
+
hasVisibleOverlay || geometryChanged
|
|
3087
|
+
? this.#committedRows
|
|
3088
|
+
: liveRegionPinned
|
|
3089
|
+
? Math.min(windowTop, Math.max(this.#committedRows, finalBoundary))
|
|
3090
|
+
: windowTop;
|
|
3030
3091
|
if (geometryChanged) {
|
|
3031
3092
|
committedPrefixResliced = true;
|
|
3032
3093
|
this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
|
|
@@ -3246,6 +3307,25 @@ export class TUI extends Container {
|
|
|
3246
3307
|
}
|
|
3247
3308
|
|
|
3248
3309
|
const code = raw.charCodeAt(i);
|
|
3310
|
+
if (code >= 0x20 && code <= 0x7e) {
|
|
3311
|
+
// Printable-ASCII run: every char here is exactly one cell wide, so
|
|
3312
|
+
// the run is copied with a single slice instead of a per-char
|
|
3313
|
+
// slice + visibleWidth call. Stop conditions mirror the general
|
|
3314
|
+
// path: width budget (cells), source budget (maxSourceLength).
|
|
3315
|
+
if (output.length >= maxSourceLength) break;
|
|
3316
|
+
const cap = i + Math.min(safeWidth - cells, maxSourceLength - output.length);
|
|
3317
|
+
let j = i + 1;
|
|
3318
|
+
while (j < raw.length && j < cap) {
|
|
3319
|
+
const c = raw.charCodeAt(j);
|
|
3320
|
+
if (c < 0x20 || c > 0x7e) break;
|
|
3321
|
+
j++;
|
|
3322
|
+
}
|
|
3323
|
+
output += raw.slice(i, j);
|
|
3324
|
+
cells += j - i;
|
|
3325
|
+
i = j;
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3249
3329
|
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
|
|
3250
3330
|
const char = raw.slice(i, next);
|
|
3251
3331
|
const charWidth = visibleWidth(char);
|
package/src/utils.ts
CHANGED
|
@@ -30,14 +30,62 @@ export function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth
|
|
|
30
30
|
return hangulCompatibilityJamoWidth;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Monotonic epoch for width-affecting runtime configuration. Any cache or
|
|
34
|
+
// carried-width sidecar derived from `visibleWidth` results must be stamped
|
|
35
|
+
// with the epoch at computation time and discarded on mismatch, so a Hangul
|
|
36
|
+
// Compatibility Jamo width change invalidates every derived width.
|
|
37
|
+
let widthConfigEpoch = 0;
|
|
38
|
+
|
|
39
|
+
export function getWidthConfigEpoch(): number {
|
|
40
|
+
return widthConfigEpoch;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LineWidthsEntry {
|
|
44
|
+
epoch: number;
|
|
45
|
+
lines: readonly string[];
|
|
46
|
+
widths: readonly number[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Per-render-result visible widths, keyed by the exact lines array a component
|
|
50
|
+
// returned. The copied strings and widths are the single publication snapshot:
|
|
51
|
+
// they cannot be changed through either publisher array and do not retain the
|
|
52
|
+
// WeakMap key. Entries therefore die with their lines-array owners.
|
|
53
|
+
const lineWidthSidecar = new WeakMap<readonly string[], LineWidthsEntry>();
|
|
54
|
+
|
|
55
|
+
/** Publish exact per-line visible widths for a rendered lines array. */
|
|
56
|
+
export function publishLineWidths(lines: readonly string[], widths: readonly number[]): void {
|
|
57
|
+
if (lines.length !== widths.length) {
|
|
58
|
+
throw new RangeError(`Cannot publish ${widths.length} widths for ${lines.length} lines`);
|
|
59
|
+
}
|
|
60
|
+
lineWidthSidecar.set(lines, {
|
|
61
|
+
epoch: widthConfigEpoch,
|
|
62
|
+
lines: [...lines],
|
|
63
|
+
widths: Object.freeze([...widths]),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Exact per-line visible widths for an unchanged `lines` array under the current width config. */
|
|
68
|
+
export function getPublishedLineWidths(lines: readonly string[]): readonly number[] | undefined {
|
|
69
|
+
const entry = lineWidthSidecar.get(lines);
|
|
70
|
+
if (entry === undefined || entry.epoch !== widthConfigEpoch || entry.lines.length !== lines.length) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
if (entry.lines[i] !== lines[i]) return undefined;
|
|
75
|
+
}
|
|
76
|
+
return entry.widths;
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
export function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean {
|
|
34
80
|
const changed = hangulCompatibilityJamoWidth !== width;
|
|
35
81
|
hangulCompatibilityJamoWidth = width;
|
|
82
|
+
if (changed) widthConfigEpoch++;
|
|
36
83
|
nativeSetHangulCompatJamoWidthOverride(nativeHangulCompatibilityJamoOverride(width));
|
|
37
84
|
return changed;
|
|
38
85
|
}
|
|
39
86
|
|
|
40
87
|
export function resetHangulCompatibilityJamoWidthForTests(): void {
|
|
88
|
+
if (hangulCompatibilityJamoWidth !== "platform") widthConfigEpoch++;
|
|
41
89
|
hangulCompatibilityJamoWidth = "platform";
|
|
42
90
|
nativeSetHangulCompatJamoWidthOverride(0);
|
|
43
91
|
}
|