@schovest/pi-tui 0.7.3
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/README.md +791 -0
- package/dist/autocomplete.d.ts +57 -0
- package/dist/autocomplete.d.ts.map +1 -0
- package/dist/autocomplete.js +646 -0
- package/dist/autocomplete.js.map +1 -0
- package/dist/components/box.d.ts +22 -0
- package/dist/components/box.d.ts.map +1 -0
- package/dist/components/box.js +104 -0
- package/dist/components/box.js.map +1 -0
- package/dist/components/cancellable-loader.d.ts +22 -0
- package/dist/components/cancellable-loader.d.ts.map +1 -0
- package/dist/components/cancellable-loader.js +35 -0
- package/dist/components/cancellable-loader.js.map +1 -0
- package/dist/components/editor.d.ts +254 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +1916 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/image.d.ts +28 -0
- package/dist/components/image.d.ts.map +1 -0
- package/dist/components/image.js +89 -0
- package/dist/components/image.js.map +1 -0
- package/dist/components/input.d.ts +37 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +385 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/loader.d.ts +31 -0
- package/dist/components/loader.d.ts.map +1 -0
- package/dist/components/loader.js +69 -0
- package/dist/components/loader.js.map +1 -0
- package/dist/components/markdown.d.ts +96 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +648 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/select-list.d.ts +50 -0
- package/dist/components/select-list.d.ts.map +1 -0
- package/dist/components/select-list.js +161 -0
- package/dist/components/select-list.js.map +1 -0
- package/dist/components/settings-list.d.ts +50 -0
- package/dist/components/settings-list.d.ts.map +1 -0
- package/dist/components/settings-list.js +186 -0
- package/dist/components/settings-list.js.map +1 -0
- package/dist/components/spacer.d.ts +12 -0
- package/dist/components/spacer.d.ts.map +1 -0
- package/dist/components/spacer.js +23 -0
- package/dist/components/spacer.js.map +1 -0
- package/dist/components/text.d.ts +19 -0
- package/dist/components/text.d.ts.map +1 -0
- package/dist/components/text.js +89 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/truncated-text.d.ts +13 -0
- package/dist/components/truncated-text.d.ts.map +1 -0
- package/dist/components/truncated-text.js +51 -0
- package/dist/components/truncated-text.js.map +1 -0
- package/dist/editor-component.d.ts +39 -0
- package/dist/editor-component.d.ts.map +1 -0
- package/dist/editor-component.js +2 -0
- package/dist/editor-component.js.map +1 -0
- package/dist/fuzzy.d.ts +16 -0
- package/dist/fuzzy.d.ts.map +1 -0
- package/dist/fuzzy.js +110 -0
- package/dist/fuzzy.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/keybindings.d.ts +193 -0
- package/dist/keybindings.d.ts.map +1 -0
- package/dist/keybindings.js +174 -0
- package/dist/keybindings.js.map +1 -0
- package/dist/keys.d.ts +184 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +1181 -0
- package/dist/keys.js.map +1 -0
- package/dist/kill-ring.d.ts +28 -0
- package/dist/kill-ring.d.ts.map +1 -0
- package/dist/kill-ring.js +44 -0
- package/dist/kill-ring.js.map +1 -0
- package/dist/native-modifiers.d.ts +3 -0
- package/dist/native-modifiers.d.ts.map +1 -0
- package/dist/native-modifiers.js +53 -0
- package/dist/native-modifiers.js.map +1 -0
- package/dist/stdin-buffer.d.ts +60 -0
- package/dist/stdin-buffer.d.ts.map +1 -0
- package/dist/stdin-buffer.js +406 -0
- package/dist/stdin-buffer.js.map +1 -0
- package/dist/terminal-image.d.ts +90 -0
- package/dist/terminal-image.d.ts.map +1 -0
- package/dist/terminal-image.js +366 -0
- package/dist/terminal-image.js.map +1 -0
- package/dist/terminal.d.ts +121 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +464 -0
- package/dist/terminal.js.map +1 -0
- package/dist/tui.d.ts +347 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +1608 -0
- package/dist/tui.js.map +1 -0
- package/dist/undo-stack.d.ts +17 -0
- package/dist/undo-stack.d.ts.map +1 -0
- package/dist/undo-stack.js +25 -0
- package/dist/undo-stack.js.map +1 -0
- package/dist/utils.d.ts +101 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +1108 -0
- package/dist/utils.js.map +1 -0
- package/dist/word-navigation.d.ts +25 -0
- package/dist/word-navigation.d.ts.map +1 -0
- package/dist/word-navigation.js +96 -0
- package/dist/word-navigation.js.map +1 -0
- package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
- package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
- package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/package.json +47 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TUI implementation with differential rendering
|
|
3
|
+
*/
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
6
|
+
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
7
|
+
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, snapColToGraphemeBoundary, stripAnsi, visibleWidth, } from "./utils.js";
|
|
8
|
+
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
|
9
|
+
function extractKittyImageIds(line) {
|
|
10
|
+
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
|
11
|
+
if (sequenceStart === -1)
|
|
12
|
+
return [];
|
|
13
|
+
const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
|
|
14
|
+
const paramsEnd = line.indexOf(";", paramsStart);
|
|
15
|
+
if (paramsEnd === -1)
|
|
16
|
+
return [];
|
|
17
|
+
const params = line.slice(paramsStart, paramsEnd);
|
|
18
|
+
for (const param of params.split(",")) {
|
|
19
|
+
const [key, value] = param.split("=", 2);
|
|
20
|
+
if (key !== "i" || value === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
const id = Number(value);
|
|
23
|
+
if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
|
|
24
|
+
return [id];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
/** Type guard to check if a component implements Focusable */
|
|
30
|
+
export function isFocusable(component) {
|
|
31
|
+
return component !== null && "focused" in component;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Cursor position marker - APC (Application Program Command) sequence.
|
|
35
|
+
* This is a zero-width escape sequence that terminals ignore.
|
|
36
|
+
* Components emit this at the cursor position when focused.
|
|
37
|
+
* TUI finds and strips this marker, then positions the hardware cursor there.
|
|
38
|
+
*/
|
|
39
|
+
export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
|
40
|
+
export { visibleWidth };
|
|
41
|
+
const AUTO_SCROLL_INTERVAL_MS = 100;
|
|
42
|
+
const AUTO_SCROLL_ROWS = 3;
|
|
43
|
+
/** Parse a SizeValue into absolute value given a reference size */
|
|
44
|
+
function parseSizeValue(value, referenceSize) {
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (typeof value === "number")
|
|
48
|
+
return value;
|
|
49
|
+
// Parse percentage string like "50%"
|
|
50
|
+
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
51
|
+
if (match) {
|
|
52
|
+
return Math.floor((referenceSize * parseFloat(match[1])) / 100);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
function isTermuxSession() {
|
|
57
|
+
return Boolean(process.env.TERMUX_VERSION);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Container - a component that contains other components
|
|
61
|
+
*/
|
|
62
|
+
export class Container {
|
|
63
|
+
children = [];
|
|
64
|
+
addChild(component) {
|
|
65
|
+
this.children.push(component);
|
|
66
|
+
}
|
|
67
|
+
removeChild(component) {
|
|
68
|
+
const index = this.children.indexOf(component);
|
|
69
|
+
if (index !== -1) {
|
|
70
|
+
this.children.splice(index, 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
clear() {
|
|
74
|
+
this.children = [];
|
|
75
|
+
}
|
|
76
|
+
invalidate() {
|
|
77
|
+
for (const child of this.children) {
|
|
78
|
+
child.invalidate?.();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
render(width) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
for (const child of this.children) {
|
|
84
|
+
const childLines = child.render(width);
|
|
85
|
+
for (const line of childLines) {
|
|
86
|
+
lines.push(line);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* TUI - Main class for managing terminal UI with differential rendering
|
|
94
|
+
*/
|
|
95
|
+
export class TUI extends Container {
|
|
96
|
+
terminal;
|
|
97
|
+
previousLines = [];
|
|
98
|
+
previousKittyImageIds = new Set();
|
|
99
|
+
previousWidth = 0;
|
|
100
|
+
previousHeight = 0;
|
|
101
|
+
focusedComponent = null;
|
|
102
|
+
inputListeners = new Set();
|
|
103
|
+
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
104
|
+
onDebug;
|
|
105
|
+
renderRequested = false;
|
|
106
|
+
renderTimer;
|
|
107
|
+
lastRenderAt = 0;
|
|
108
|
+
static MIN_RENDER_INTERVAL_MS = 16;
|
|
109
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: used in render and cursor positioning
|
|
110
|
+
cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
111
|
+
hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
112
|
+
showHardwareCursor = process.env.PI_HARDWARE_CURSOR !== "0";
|
|
113
|
+
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
114
|
+
maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
115
|
+
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
116
|
+
fullRedrawCount = 0;
|
|
117
|
+
stopped = false;
|
|
118
|
+
fixedBottomCount = 0;
|
|
119
|
+
// Scrollable viewport top in the full-lines buffer; used for mouse coordinate mapping
|
|
120
|
+
currentScrollableViewportTop = 0;
|
|
121
|
+
// Full-lines buffer (pre-overlay, pre-highlight) cached each render for selection
|
|
122
|
+
// coordinate mapping and text extraction. Equals [...scrollableLines, ...fixedLines].
|
|
123
|
+
currentFullLines = [];
|
|
124
|
+
// Overlay-composited lines (post-overlay, pre-highlight) cached each render for text extraction and grapheme snapping when an overlay covers a row
|
|
125
|
+
currentCompositedLines = [];
|
|
126
|
+
currentScrollableLinesLength = 0;
|
|
127
|
+
autoScrollDirection = -1;
|
|
128
|
+
selection = null;
|
|
129
|
+
scrollOffset = 0;
|
|
130
|
+
autoFollow = true;
|
|
131
|
+
previousScrollableLineCount = 0;
|
|
132
|
+
lastScrollableViewport = 0;
|
|
133
|
+
autoScrollTimer = null;
|
|
134
|
+
mouseListenerRemover;
|
|
135
|
+
// Cached overlay layouts from the most recent render, used for selection clip
|
|
136
|
+
renderedOverlayLayouts = [];
|
|
137
|
+
// Scroll event debouncing: aggregate consecutive wheel events
|
|
138
|
+
pendingScrollDelta = 0;
|
|
139
|
+
scrollDebounceTimer = null;
|
|
140
|
+
static SCROLL_DEBOUNCE_MS = 8;
|
|
141
|
+
// Timestamp of the last mouse wheel event, used to suppress spurious
|
|
142
|
+
// arrow-key sequences that some terminals emit alongside scroll events.
|
|
143
|
+
lastMouseWheelTime = 0;
|
|
144
|
+
static MOUSE_WHEEL_SUPPRESS_MS = 50;
|
|
145
|
+
onCopySelection;
|
|
146
|
+
onScrollOffsetChange;
|
|
147
|
+
// Overlay stack for modal components rendered on top of base content
|
|
148
|
+
focusOrderCounter = 0;
|
|
149
|
+
overlayStack = [];
|
|
150
|
+
overlayFocusRestore = { status: "inactive" };
|
|
151
|
+
constructor(terminal, showHardwareCursor) {
|
|
152
|
+
super();
|
|
153
|
+
this.terminal = terminal;
|
|
154
|
+
if (showHardwareCursor !== undefined) {
|
|
155
|
+
this.showHardwareCursor = showHardwareCursor;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
get fullRedraws() {
|
|
159
|
+
return this.fullRedrawCount;
|
|
160
|
+
}
|
|
161
|
+
getShowHardwareCursor() {
|
|
162
|
+
return this.showHardwareCursor;
|
|
163
|
+
}
|
|
164
|
+
setShowHardwareCursor(enabled) {
|
|
165
|
+
if (this.showHardwareCursor === enabled)
|
|
166
|
+
return;
|
|
167
|
+
this.showHardwareCursor = enabled;
|
|
168
|
+
if (enabled) {
|
|
169
|
+
// Set steady bar cursor shape when enabling hardware cursor
|
|
170
|
+
this.terminal.write("\x1b[6 q");
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
this.terminal.hideCursor();
|
|
174
|
+
}
|
|
175
|
+
this.requestRender();
|
|
176
|
+
}
|
|
177
|
+
getClearOnShrink() {
|
|
178
|
+
return this.clearOnShrink;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Set whether to trigger full re-render when content shrinks.
|
|
182
|
+
* When true (default), empty rows are cleared when content shrinks.
|
|
183
|
+
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
184
|
+
*/
|
|
185
|
+
setClearOnShrink(enabled) {
|
|
186
|
+
this.clearOnShrink = enabled;
|
|
187
|
+
}
|
|
188
|
+
setFocus(component) {
|
|
189
|
+
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
|
|
190
|
+
}
|
|
191
|
+
setFocusInternal({ component, overlayFocusRestore, }) {
|
|
192
|
+
const previousFocus = this.focusedComponent;
|
|
193
|
+
let nextFocus = component;
|
|
194
|
+
const previousFocusedOverlay = previousFocus
|
|
195
|
+
? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
|
|
196
|
+
: undefined;
|
|
197
|
+
const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
|
|
198
|
+
const restoreState = this.getVisibleOverlayFocusRestore();
|
|
199
|
+
if (nextFocus && !nextFocusIsOverlay) {
|
|
200
|
+
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
|
|
201
|
+
if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
|
|
202
|
+
nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
this.overlayFocusRestore = {
|
|
206
|
+
status: "blocked",
|
|
207
|
+
overlay: restoreState.overlay,
|
|
208
|
+
blockedBy: nextFocus,
|
|
209
|
+
resume: restoreState.resume,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else if (previousFocusedOverlay &&
|
|
214
|
+
restoreState.status !== "inactive" &&
|
|
215
|
+
restoreState.overlay === previousFocusedOverlay &&
|
|
216
|
+
!this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
|
|
217
|
+
this.overlayFocusRestore = {
|
|
218
|
+
status: "blocked",
|
|
219
|
+
overlay: previousFocusedOverlay,
|
|
220
|
+
blockedBy: nextFocus,
|
|
221
|
+
resume: { status: "restore-overlay" },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (nextFocus === null) {
|
|
226
|
+
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
|
|
227
|
+
nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
|
|
228
|
+
}
|
|
229
|
+
else if (overlayFocusRestore === "clear") {
|
|
230
|
+
this.clearOverlayFocusRestore();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (isFocusable(this.focusedComponent)) {
|
|
234
|
+
this.focusedComponent.focused = false;
|
|
235
|
+
}
|
|
236
|
+
this.focusedComponent = nextFocus;
|
|
237
|
+
if (isFocusable(nextFocus)) {
|
|
238
|
+
nextFocus.focused = true;
|
|
239
|
+
}
|
|
240
|
+
const focusedOverlay = nextFocus
|
|
241
|
+
? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
|
|
242
|
+
: undefined;
|
|
243
|
+
if (focusedOverlay) {
|
|
244
|
+
this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
clearOverlayFocusRestore() {
|
|
248
|
+
this.overlayFocusRestore = { status: "inactive" };
|
|
249
|
+
}
|
|
250
|
+
clearOverlayFocusRestoreFor(overlay) {
|
|
251
|
+
if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
|
|
252
|
+
this.clearOverlayFocusRestore();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
resolveBlockedOverlayFocusResume(restoreState) {
|
|
256
|
+
if (restoreState.resume.status === "restore-overlay")
|
|
257
|
+
return restoreState.overlay.component;
|
|
258
|
+
this.clearOverlayFocusRestore();
|
|
259
|
+
return restoreState.resume.target;
|
|
260
|
+
}
|
|
261
|
+
getVisibleOverlayFocusRestore() {
|
|
262
|
+
const restoreState = this.overlayFocusRestore;
|
|
263
|
+
if (restoreState.status === "inactive")
|
|
264
|
+
return restoreState;
|
|
265
|
+
if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
|
|
266
|
+
return { status: "inactive" };
|
|
267
|
+
}
|
|
268
|
+
return restoreState;
|
|
269
|
+
}
|
|
270
|
+
isOverlayFocusAncestor(entry, component) {
|
|
271
|
+
const visited = new Set();
|
|
272
|
+
let current = entry.preFocus;
|
|
273
|
+
while (current && !visited.has(current)) {
|
|
274
|
+
visited.add(current);
|
|
275
|
+
if (current === component)
|
|
276
|
+
return true;
|
|
277
|
+
current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
retargetOverlayPreFocus(removed) {
|
|
282
|
+
for (const overlay of this.overlayStack) {
|
|
283
|
+
if (overlay !== removed && overlay.preFocus === removed.component) {
|
|
284
|
+
overlay.preFocus = removed.preFocus;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
isComponentMounted(component) {
|
|
289
|
+
return this.children.some((child) => this.containsComponent(child, component));
|
|
290
|
+
}
|
|
291
|
+
containsComponent(root, target) {
|
|
292
|
+
if (root === target)
|
|
293
|
+
return true;
|
|
294
|
+
if (!(root instanceof Container))
|
|
295
|
+
return false;
|
|
296
|
+
return root.children.some((child) => this.containsComponent(child, target));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Show an overlay component with configurable positioning and sizing.
|
|
300
|
+
* Returns a handle to control the overlay's visibility.
|
|
301
|
+
*/
|
|
302
|
+
showOverlay(component, options) {
|
|
303
|
+
const entry = {
|
|
304
|
+
component,
|
|
305
|
+
...(options === undefined ? {} : { options }),
|
|
306
|
+
preFocus: this.focusedComponent,
|
|
307
|
+
hidden: false,
|
|
308
|
+
focusOrder: ++this.focusOrderCounter,
|
|
309
|
+
};
|
|
310
|
+
this.overlayStack.push(entry);
|
|
311
|
+
// Only focus if overlay is actually visible
|
|
312
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
313
|
+
this.setFocus(component);
|
|
314
|
+
}
|
|
315
|
+
if (!this.showHardwareCursor) {
|
|
316
|
+
this.terminal.hideCursor();
|
|
317
|
+
}
|
|
318
|
+
this.requestRender();
|
|
319
|
+
// Return handle for controlling this overlay
|
|
320
|
+
return {
|
|
321
|
+
hide: () => {
|
|
322
|
+
const index = this.overlayStack.indexOf(entry);
|
|
323
|
+
if (index !== -1) {
|
|
324
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
325
|
+
this.retargetOverlayPreFocus(entry);
|
|
326
|
+
this.overlayStack.splice(index, 1);
|
|
327
|
+
// Restore focus if this overlay had focus
|
|
328
|
+
if (this.focusedComponent === component) {
|
|
329
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
330
|
+
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
331
|
+
}
|
|
332
|
+
if (this.overlayStack.length === 0 && !this.showHardwareCursor) {
|
|
333
|
+
this.terminal.hideCursor();
|
|
334
|
+
}
|
|
335
|
+
this.requestRender();
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
setHidden: (hidden) => {
|
|
339
|
+
if (entry.hidden === hidden)
|
|
340
|
+
return;
|
|
341
|
+
entry.hidden = hidden;
|
|
342
|
+
// Update focus when hiding/showing
|
|
343
|
+
if (hidden) {
|
|
344
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
345
|
+
// If this overlay had focus, move focus to next visible or preFocus
|
|
346
|
+
if (this.focusedComponent === component) {
|
|
347
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
348
|
+
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// Restore focus to this overlay when showing (if it's actually visible)
|
|
353
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
354
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
355
|
+
this.setFocus(component);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
this.requestRender();
|
|
359
|
+
},
|
|
360
|
+
isHidden: () => entry.hidden,
|
|
361
|
+
focus: () => {
|
|
362
|
+
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
|
|
363
|
+
return;
|
|
364
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
365
|
+
this.setFocus(component);
|
|
366
|
+
this.requestRender();
|
|
367
|
+
},
|
|
368
|
+
unfocus: (unfocusOptions) => {
|
|
369
|
+
const isFocused = this.focusedComponent === component;
|
|
370
|
+
const restoreState = this.overlayFocusRestore;
|
|
371
|
+
const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
|
|
372
|
+
if (!isFocused && !hasPendingRestore)
|
|
373
|
+
return;
|
|
374
|
+
if (restoreState.status === "blocked" &&
|
|
375
|
+
restoreState.overlay === entry &&
|
|
376
|
+
this.focusedComponent === restoreState.blockedBy) {
|
|
377
|
+
if (unfocusOptions) {
|
|
378
|
+
this.overlayFocusRestore = {
|
|
379
|
+
status: "blocked",
|
|
380
|
+
overlay: entry,
|
|
381
|
+
blockedBy: restoreState.blockedBy,
|
|
382
|
+
resume: { status: "focus-target", target: unfocusOptions.target },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
this.clearOverlayFocusRestore();
|
|
387
|
+
}
|
|
388
|
+
this.requestRender();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
392
|
+
if (isFocused || unfocusOptions) {
|
|
393
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
394
|
+
const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
|
|
395
|
+
this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
|
|
396
|
+
}
|
|
397
|
+
this.requestRender();
|
|
398
|
+
},
|
|
399
|
+
isFocused: () => this.focusedComponent === component,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** Hide the topmost overlay and restore previous focus. */
|
|
403
|
+
hideOverlay() {
|
|
404
|
+
const overlay = this.overlayStack[this.overlayStack.length - 1];
|
|
405
|
+
if (!overlay)
|
|
406
|
+
return;
|
|
407
|
+
this.clearOverlayFocusRestoreFor(overlay);
|
|
408
|
+
this.retargetOverlayPreFocus(overlay);
|
|
409
|
+
this.overlayStack.pop();
|
|
410
|
+
if (this.focusedComponent === overlay.component) {
|
|
411
|
+
// Find topmost visible overlay, or fall back to preFocus
|
|
412
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
413
|
+
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
414
|
+
}
|
|
415
|
+
if (this.overlayStack.length === 0)
|
|
416
|
+
this.terminal.hideCursor();
|
|
417
|
+
this.requestRender();
|
|
418
|
+
}
|
|
419
|
+
/** Check if there are any visible overlays */
|
|
420
|
+
hasOverlay() {
|
|
421
|
+
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
|
422
|
+
}
|
|
423
|
+
/** Check if an overlay entry is currently visible */
|
|
424
|
+
isOverlayVisible(entry) {
|
|
425
|
+
if (entry.hidden)
|
|
426
|
+
return false;
|
|
427
|
+
if (entry.options?.visible) {
|
|
428
|
+
return entry.options.visible(this.terminal.columns, this.terminal.rows);
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
/** Find the visual-frontmost visible capturing overlay, if any */
|
|
433
|
+
getTopmostVisibleOverlay() {
|
|
434
|
+
let topmost;
|
|
435
|
+
for (const overlay of this.overlayStack) {
|
|
436
|
+
if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
|
|
437
|
+
continue;
|
|
438
|
+
if (!topmost || overlay.focusOrder > topmost.focusOrder) {
|
|
439
|
+
topmost = overlay;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return topmost;
|
|
443
|
+
}
|
|
444
|
+
invalidate() {
|
|
445
|
+
super.invalidate();
|
|
446
|
+
for (const overlay of this.overlayStack)
|
|
447
|
+
overlay.component.invalidate?.();
|
|
448
|
+
}
|
|
449
|
+
start() {
|
|
450
|
+
this.stopped = false;
|
|
451
|
+
this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
|
|
452
|
+
if (!this.showHardwareCursor) {
|
|
453
|
+
this.terminal.hideCursor();
|
|
454
|
+
}
|
|
455
|
+
this.queryCellSize();
|
|
456
|
+
this.setupMouseListener();
|
|
457
|
+
this.requestRender();
|
|
458
|
+
}
|
|
459
|
+
addInputListener(listener) {
|
|
460
|
+
this.inputListeners.add(listener);
|
|
461
|
+
return () => {
|
|
462
|
+
this.inputListeners.delete(listener);
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
removeInputListener(listener) {
|
|
466
|
+
this.inputListeners.delete(listener);
|
|
467
|
+
}
|
|
468
|
+
queryCellSize() {
|
|
469
|
+
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
470
|
+
if (!getCapabilities().images) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// Query terminal for cell size in pixels: CSI 16 t
|
|
474
|
+
// Response format: CSI 6 ; height ; width t
|
|
475
|
+
this.terminal.write("\x1b[16t");
|
|
476
|
+
}
|
|
477
|
+
stop() {
|
|
478
|
+
this.stopped = true;
|
|
479
|
+
if (this.renderTimer) {
|
|
480
|
+
clearTimeout(this.renderTimer);
|
|
481
|
+
this.renderTimer = undefined;
|
|
482
|
+
}
|
|
483
|
+
if (this.scrollDebounceTimer) {
|
|
484
|
+
clearTimeout(this.scrollDebounceTimer);
|
|
485
|
+
this.scrollDebounceTimer = null;
|
|
486
|
+
}
|
|
487
|
+
this.clearAutoScrollTimer();
|
|
488
|
+
this.removeMouseListener();
|
|
489
|
+
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
490
|
+
if (this.previousLines.length > 0) {
|
|
491
|
+
const targetRow = this.previousLines.length; // Line after the last content
|
|
492
|
+
const lineDiff = targetRow - this.hardwareCursorRow;
|
|
493
|
+
if (lineDiff > 0) {
|
|
494
|
+
this.terminal.write(`\x1b[${lineDiff}B`);
|
|
495
|
+
}
|
|
496
|
+
else if (lineDiff < 0) {
|
|
497
|
+
this.terminal.write(`\x1b[${-lineDiff}A`);
|
|
498
|
+
}
|
|
499
|
+
this.terminal.write("\r\n");
|
|
500
|
+
}
|
|
501
|
+
this.terminal.showCursor();
|
|
502
|
+
// Reset cursor shape to user default on exit
|
|
503
|
+
this.terminal.write("\x1b[0 q");
|
|
504
|
+
this.terminal.stop();
|
|
505
|
+
}
|
|
506
|
+
setupMouseListener() {
|
|
507
|
+
const stdinBuffer = this.terminal.stdinBuffer;
|
|
508
|
+
if (!stdinBuffer)
|
|
509
|
+
return;
|
|
510
|
+
const handler = (event) => this.handleMouseEvent(event);
|
|
511
|
+
stdinBuffer.on("mouse", handler);
|
|
512
|
+
this.mouseListenerRemover = () => {
|
|
513
|
+
stdinBuffer.removeListener("mouse", handler);
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
removeMouseListener() {
|
|
517
|
+
if (this.mouseListenerRemover) {
|
|
518
|
+
this.mouseListenerRemover();
|
|
519
|
+
this.mouseListenerRemover = undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
handleMouseEvent(event) {
|
|
523
|
+
if (event.type === "mouseWheel") {
|
|
524
|
+
this.lastMouseWheelTime = Date.now();
|
|
525
|
+
if (this.focusedComponent?.handleInput) {
|
|
526
|
+
const direction = event.button === 64 ? "scrollUp" : "scrollDown";
|
|
527
|
+
const consumed = this.focusedComponent.handleInput(`\x1b[${direction}`);
|
|
528
|
+
this.requestRender();
|
|
529
|
+
if (consumed)
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Aggregate scroll delta for debouncing: first event flushes immediately,
|
|
533
|
+
// subsequent events within the debounce window are accumulated and flushed once
|
|
534
|
+
const delta = event.button === 64 ? AUTO_SCROLL_ROWS : -AUTO_SCROLL_ROWS;
|
|
535
|
+
this.pendingScrollDelta += delta;
|
|
536
|
+
if (!this.scrollDebounceTimer) {
|
|
537
|
+
// First event: flush immediately for responsive feel
|
|
538
|
+
this.flushPendingScroll();
|
|
539
|
+
this.scrollDebounceTimer = setTimeout(() => {
|
|
540
|
+
this.scrollDebounceTimer = null;
|
|
541
|
+
this.flushPendingScroll();
|
|
542
|
+
}, TUI.SCROLL_DEBOUNCE_MS);
|
|
543
|
+
}
|
|
544
|
+
// Otherwise: delta is accumulated, will be flushed when timer fires
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (event.button !== 0)
|
|
548
|
+
return;
|
|
549
|
+
if (event.type === "mouseDown") {
|
|
550
|
+
const screenRow = event.row - 1;
|
|
551
|
+
const rawCol = event.col - 1;
|
|
552
|
+
const bufferRow = this.screenToBufferRow(screenRow);
|
|
553
|
+
const clip = this.getSelectionClipForRow(screenRow);
|
|
554
|
+
const line = clip != null && screenRow < this.currentCompositedLines.length
|
|
555
|
+
? this.currentCompositedLines[screenRow]
|
|
556
|
+
: this.currentFullLines[bufferRow];
|
|
557
|
+
const col = line != null ? snapColToGraphemeBoundary(line, rawCol) : rawCol;
|
|
558
|
+
this.selection = {
|
|
559
|
+
active: true,
|
|
560
|
+
anchorRow: bufferRow,
|
|
561
|
+
anchorCol: col,
|
|
562
|
+
focusRow: bufferRow,
|
|
563
|
+
focusCol: col,
|
|
564
|
+
};
|
|
565
|
+
this.requestRender();
|
|
566
|
+
}
|
|
567
|
+
else if (event.type === "mouseMove" && this.selection) {
|
|
568
|
+
const screenRow = event.row - 1;
|
|
569
|
+
const rawCol = event.col - 1;
|
|
570
|
+
const bufferRow = this.screenToBufferRow(screenRow);
|
|
571
|
+
const clip = this.getSelectionClipForRow(screenRow);
|
|
572
|
+
const line = clip != null && screenRow < this.currentCompositedLines.length
|
|
573
|
+
? this.currentCompositedLines[screenRow]
|
|
574
|
+
: this.currentFullLines[bufferRow];
|
|
575
|
+
const col = line != null ? snapColToGraphemeBoundary(line, rawCol) : rawCol;
|
|
576
|
+
this.selection.focusRow = bufferRow;
|
|
577
|
+
this.selection.focusCol = col;
|
|
578
|
+
if (event.row <= 1) {
|
|
579
|
+
this.startAutoScroll(-1);
|
|
580
|
+
}
|
|
581
|
+
else if (event.row >= this.lastScrollableViewport) {
|
|
582
|
+
// Trigger downward autoScroll at the bottom of the scrollable area,
|
|
583
|
+
// not the terminal bottom — the latter would require crossing any fixed
|
|
584
|
+
// bottom region (e.g. the input box) before scrolling kicks in.
|
|
585
|
+
this.startAutoScroll(1);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
this.clearAutoScrollTimer();
|
|
589
|
+
}
|
|
590
|
+
this.requestRender();
|
|
591
|
+
}
|
|
592
|
+
else if (event.type === "mouseUp" && this.selection) {
|
|
593
|
+
const sel = this.selection;
|
|
594
|
+
// Skip copy on plain click (no drag) to avoid polluting clipboard
|
|
595
|
+
const hasDragged = sel.anchorRow !== sel.focusRow || sel.anchorCol !== sel.focusCol;
|
|
596
|
+
const text = hasDragged ? this.extractSelectionText() : "";
|
|
597
|
+
this.clearAutoScrollTimer();
|
|
598
|
+
this.selection = null;
|
|
599
|
+
this.requestRender();
|
|
600
|
+
if (text.trim() && this.onCopySelection) {
|
|
601
|
+
this.onCopySelection(text);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
getMaxScrollOffset() {
|
|
606
|
+
const width = this.terminal.columns;
|
|
607
|
+
const height = this.terminal.rows;
|
|
608
|
+
const childLines = [];
|
|
609
|
+
for (const child of this.children) {
|
|
610
|
+
childLines.push(child.render(width));
|
|
611
|
+
}
|
|
612
|
+
const fixedCount = this.fixedBottomCount;
|
|
613
|
+
let scrollableLines = 0;
|
|
614
|
+
let fixedLines = 0;
|
|
615
|
+
for (let i = 0; i < childLines.length; i++) {
|
|
616
|
+
if (i >= childLines.length - fixedCount) {
|
|
617
|
+
fixedLines += childLines[i].length;
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
scrollableLines += childLines[i].length;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const scrollableViewport = Math.max(0, height - fixedLines);
|
|
624
|
+
return Math.max(0, scrollableLines - scrollableViewport);
|
|
625
|
+
}
|
|
626
|
+
/** Flush pending scroll delta and trigger a single render */
|
|
627
|
+
flushPendingScroll() {
|
|
628
|
+
if (this.pendingScrollDelta === 0)
|
|
629
|
+
return;
|
|
630
|
+
const delta = this.pendingScrollDelta;
|
|
631
|
+
this.pendingScrollDelta = 0;
|
|
632
|
+
if (delta > 0) {
|
|
633
|
+
this.autoFollow = false;
|
|
634
|
+
this.scrollOffset = Math.min(this.getMaxScrollOffset(), this.scrollOffset + delta);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
this.scrollOffset = Math.max(0, this.scrollOffset + delta);
|
|
638
|
+
if (this.scrollOffset === 0)
|
|
639
|
+
this.autoFollow = true;
|
|
640
|
+
}
|
|
641
|
+
this.onScrollOffsetChange?.(this.scrollOffset);
|
|
642
|
+
this.requestRender();
|
|
643
|
+
}
|
|
644
|
+
getScrollOffset() {
|
|
645
|
+
return this.scrollOffset;
|
|
646
|
+
}
|
|
647
|
+
setScrollOffset(offset) {
|
|
648
|
+
this.scrollOffset = offset;
|
|
649
|
+
this.autoFollow = offset === 0;
|
|
650
|
+
this.onScrollOffsetChange?.(offset);
|
|
651
|
+
this.requestRender();
|
|
652
|
+
}
|
|
653
|
+
resetScrollOffset() {
|
|
654
|
+
this.scrollOffset = 0;
|
|
655
|
+
this.autoFollow = true;
|
|
656
|
+
this.onScrollOffsetChange?.(0);
|
|
657
|
+
this.requestRender();
|
|
658
|
+
}
|
|
659
|
+
getAutoFollow() {
|
|
660
|
+
return this.autoFollow;
|
|
661
|
+
}
|
|
662
|
+
setAutoFollow(value) {
|
|
663
|
+
if (this.autoFollow === value)
|
|
664
|
+
return;
|
|
665
|
+
this.autoFollow = value;
|
|
666
|
+
if (value) {
|
|
667
|
+
this.scrollOffset = 0;
|
|
668
|
+
this.onScrollOffsetChange?.(0);
|
|
669
|
+
this.requestRender();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
setFixedBottomCount(count) {
|
|
673
|
+
this.fixedBottomCount = count;
|
|
674
|
+
}
|
|
675
|
+
startAutoScroll(direction) {
|
|
676
|
+
// 同方向已运行则不重启,避免定时器堆积
|
|
677
|
+
if (this.autoScrollTimer && this.autoScrollDirection === direction)
|
|
678
|
+
return;
|
|
679
|
+
this.clearAutoScrollTimer();
|
|
680
|
+
this.autoFollow = false;
|
|
681
|
+
this.autoScrollDirection = direction;
|
|
682
|
+
this.autoScrollTimer = setInterval(() => {
|
|
683
|
+
const maxOffset = this.getMaxScrollOffset();
|
|
684
|
+
if (direction < 0) {
|
|
685
|
+
// 向旧内容方向滚动(视口顶向缓冲区起始扩展)
|
|
686
|
+
if (this.scrollOffset >= maxOffset)
|
|
687
|
+
return;
|
|
688
|
+
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + AUTO_SCROLL_ROWS);
|
|
689
|
+
if (this.selection) {
|
|
690
|
+
// focus 指向视口顶行(刚滚入视野的更旧内容)
|
|
691
|
+
this.selection.focusRow = this.currentScrollableViewportTop;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
// 向新内容方向滚动(视口顶向缓冲区末尾收缩)
|
|
696
|
+
if (this.scrollOffset <= 0)
|
|
697
|
+
return;
|
|
698
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - AUTO_SCROLL_ROWS);
|
|
699
|
+
if (this.selection) {
|
|
700
|
+
// focus 指向视口底行(刚滚入视野的更新内容)
|
|
701
|
+
this.selection.focusRow = this.currentScrollableViewportTop + this.lastScrollableViewport - 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
this.onScrollOffsetChange?.(this.scrollOffset);
|
|
705
|
+
this.requestRender();
|
|
706
|
+
}, AUTO_SCROLL_INTERVAL_MS);
|
|
707
|
+
}
|
|
708
|
+
clearAutoScrollTimer() {
|
|
709
|
+
if (this.autoScrollTimer) {
|
|
710
|
+
clearInterval(this.autoScrollTimer);
|
|
711
|
+
this.autoScrollTimer = null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Clear the current selection and stop any active autoScroll.
|
|
716
|
+
* Called by overlays when their internal scroll changes, to prevent
|
|
717
|
+
* stale selection highlighting on shifted content.
|
|
718
|
+
*/
|
|
719
|
+
clearSelection() {
|
|
720
|
+
this.clearAutoScrollTimer();
|
|
721
|
+
this.selection = null;
|
|
722
|
+
this.requestRender();
|
|
723
|
+
}
|
|
724
|
+
extractSelectionText() {
|
|
725
|
+
if (!this.selection)
|
|
726
|
+
return "";
|
|
727
|
+
const sel = this.selection;
|
|
728
|
+
const lines = this.currentFullLines;
|
|
729
|
+
if (lines.length === 0)
|
|
730
|
+
return "";
|
|
731
|
+
const startRow = Math.min(sel.anchorRow, sel.focusRow);
|
|
732
|
+
const endRow = Math.max(sel.anchorRow, sel.focusRow);
|
|
733
|
+
let startCol;
|
|
734
|
+
let endCol;
|
|
735
|
+
if (startRow === endRow) {
|
|
736
|
+
startCol = Math.min(sel.anchorCol, sel.focusCol);
|
|
737
|
+
endCol = Math.max(sel.anchorCol, sel.focusCol);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
startCol = startRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
|
|
741
|
+
endCol = endRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
|
|
742
|
+
}
|
|
743
|
+
const parts = [];
|
|
744
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
745
|
+
if (row < 0 || row >= lines.length)
|
|
746
|
+
continue;
|
|
747
|
+
// Apply selectionClip from overlays that cover this screen row
|
|
748
|
+
const screenRow = this.bufferToScreenRow(row);
|
|
749
|
+
const clip = screenRow >= 0 ? this.getSelectionClipForRow(screenRow) : null;
|
|
750
|
+
// Overlay-covered rows: extract from composited lines (overlay content).
|
|
751
|
+
// Non-overlay rows: extract from full buffer (supports cross-viewport selection).
|
|
752
|
+
const line = clip != null && screenRow >= 0 && screenRow < this.currentCompositedLines.length
|
|
753
|
+
? this.currentCompositedLines[screenRow]
|
|
754
|
+
: lines[row];
|
|
755
|
+
const rowStartCol = row === startRow ? startCol : 0;
|
|
756
|
+
const rowEndCol = row === endRow ? endCol : visibleWidth(line) - 1;
|
|
757
|
+
let clipStart = rowStartCol;
|
|
758
|
+
let clipEnd = rowEndCol;
|
|
759
|
+
if (clip) {
|
|
760
|
+
// Clip the selection range to the selectable region within the overlay
|
|
761
|
+
clipStart = Math.max(clipStart, clip.col);
|
|
762
|
+
clipEnd = Math.min(clipEnd, clip.col + clip.width - 1);
|
|
763
|
+
}
|
|
764
|
+
if (clipStart > clipEnd)
|
|
765
|
+
continue;
|
|
766
|
+
const extracted = stripAnsi(sliceByColumn(line, clipStart, clipEnd - clipStart + 1));
|
|
767
|
+
// Trim trailing whitespace from composited lines since compositeLineAt
|
|
768
|
+
// pads them to terminal width with spaces, followed by ANSI resets.
|
|
769
|
+
parts.push(clip ? extracted.trimEnd() : extracted);
|
|
770
|
+
}
|
|
771
|
+
return parts.join("\n");
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Get the selection clip region for a given screen row.
|
|
775
|
+
* If any overlay with a selectionClip callback covers this row,
|
|
776
|
+
* returns the clip region to restrict selection to that area.
|
|
777
|
+
*/
|
|
778
|
+
getSelectionClipForRow(screenRow) {
|
|
779
|
+
for (const layout of this.renderedOverlayLayouts) {
|
|
780
|
+
// Check if this screen row falls within the overlay's row range
|
|
781
|
+
if (screenRow >= layout.row && screenRow < layout.row + layout.height) {
|
|
782
|
+
if (layout.selectionClip) {
|
|
783
|
+
const clip = layout.selectionClip(screenRow);
|
|
784
|
+
if (clip) {
|
|
785
|
+
// Adjust clip col to be relative to the overlay's position
|
|
786
|
+
return { col: layout.col + clip.col, width: clip.width };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Convert a screen row (0-indexed terminal row) to a buffer-absolute row index
|
|
795
|
+
* in `this.currentFullLines`. Used by mouseDown/mouseMove to set selection
|
|
796
|
+
* anchor/focus in stable buffer coordinates that survive scrolling.
|
|
797
|
+
*
|
|
798
|
+
* - Screen rows in the scrollable area map to:
|
|
799
|
+
* currentScrollableViewportTop + screenRow
|
|
800
|
+
* - Screen rows in the fixed area map to:
|
|
801
|
+
* currentScrollableLinesLength + (screenRow - lastScrollableViewport)
|
|
802
|
+
*/
|
|
803
|
+
screenToBufferRow(screenRow) {
|
|
804
|
+
const svp = this.lastScrollableViewport;
|
|
805
|
+
if (screenRow < svp) {
|
|
806
|
+
return this.currentScrollableViewportTop + screenRow;
|
|
807
|
+
}
|
|
808
|
+
return this.currentScrollableLinesLength + (screenRow - svp);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Convert a buffer-absolute row index back to a screen row.
|
|
812
|
+
* Returns -1 if the buffer row is a scrollable line currently outside the viewport.
|
|
813
|
+
* Fixed-area buffer rows always map to a visible screen row.
|
|
814
|
+
*/
|
|
815
|
+
bufferToScreenRow(bufferRow) {
|
|
816
|
+
const scrollableLen = this.currentScrollableLinesLength;
|
|
817
|
+
if (bufferRow < scrollableLen) {
|
|
818
|
+
const screenRow = bufferRow - this.currentScrollableViewportTop;
|
|
819
|
+
if (screenRow < 0 || screenRow >= this.lastScrollableViewport)
|
|
820
|
+
return -1;
|
|
821
|
+
return screenRow;
|
|
822
|
+
}
|
|
823
|
+
return this.lastScrollableViewport + (bufferRow - scrollableLen);
|
|
824
|
+
}
|
|
825
|
+
applySelectionHighlight(newLines, height) {
|
|
826
|
+
if (!this.selection)
|
|
827
|
+
return;
|
|
828
|
+
const sel = this.selection;
|
|
829
|
+
const startBufferRow = Math.min(sel.anchorRow, sel.focusRow);
|
|
830
|
+
const endBufferRow = Math.max(sel.anchorRow, sel.focusRow);
|
|
831
|
+
let startCol;
|
|
832
|
+
let endCol;
|
|
833
|
+
if (startBufferRow === endBufferRow) {
|
|
834
|
+
startCol = Math.min(sel.anchorCol, sel.focusCol);
|
|
835
|
+
endCol = Math.max(sel.anchorCol, sel.focusCol);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
startCol = startBufferRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
|
|
839
|
+
endCol = endBufferRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
|
|
840
|
+
}
|
|
841
|
+
for (let bufferRow = startBufferRow; bufferRow <= endBufferRow; bufferRow++) {
|
|
842
|
+
const screenRow = this.bufferToScreenRow(bufferRow);
|
|
843
|
+
if (screenRow < 0 || screenRow >= height)
|
|
844
|
+
continue;
|
|
845
|
+
if (bufferRow < 0 || bufferRow >= this.currentFullLines.length)
|
|
846
|
+
continue;
|
|
847
|
+
const targetLine = newLines[screenRow];
|
|
848
|
+
if (!targetLine || isImageLine(targetLine))
|
|
849
|
+
continue;
|
|
850
|
+
const lineVisibleWidth = visibleWidth(targetLine);
|
|
851
|
+
let colStart = bufferRow === startBufferRow ? Math.min(startCol, lineVisibleWidth) : 0;
|
|
852
|
+
let colEnd = bufferRow === endBufferRow ? Math.min(endCol, lineVisibleWidth - 1) : lineVisibleWidth - 1;
|
|
853
|
+
// Apply selectionClip from overlays that cover this screen row
|
|
854
|
+
const clip = this.getSelectionClipForRow(screenRow);
|
|
855
|
+
if (clip) {
|
|
856
|
+
colStart = Math.max(colStart, clip.col);
|
|
857
|
+
colEnd = Math.min(colEnd, clip.col + clip.width - 1);
|
|
858
|
+
}
|
|
859
|
+
if (colStart > colEnd)
|
|
860
|
+
continue;
|
|
861
|
+
const before = sliceByColumn(targetLine, 0, colStart, true);
|
|
862
|
+
const highlighted = sliceByColumn(targetLine, colStart, colEnd - colStart + 1);
|
|
863
|
+
const after = colEnd + 1 < lineVisibleWidth ? sliceByColumn(targetLine, colEnd + 1, lineVisibleWidth - colEnd - 1) : "";
|
|
864
|
+
// Preserve reverse video across SGR resets inside the highlighted region.
|
|
865
|
+
// \x1b[0m resets ALL attributes including \x1b[7m (reverse video),
|
|
866
|
+
// which would break the selection highlight. Re-apply \x1b[7m after each reset.
|
|
867
|
+
const preservedHighlight = highlighted.replace(/\x1b\[0m/g, "\x1b[0m\x1b[7m");
|
|
868
|
+
newLines[screenRow] = `${before}\x1b[7m${preservedHighlight}\x1b[27m${after}`;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
requestRender(force = false) {
|
|
872
|
+
if (force) {
|
|
873
|
+
this.previousLines = [];
|
|
874
|
+
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
875
|
+
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
876
|
+
this.cursorRow = 0;
|
|
877
|
+
this.hardwareCursorRow = 0;
|
|
878
|
+
this.maxLinesRendered = 0;
|
|
879
|
+
this.previousViewportTop = 0;
|
|
880
|
+
if (this.renderTimer) {
|
|
881
|
+
clearTimeout(this.renderTimer);
|
|
882
|
+
this.renderTimer = undefined;
|
|
883
|
+
}
|
|
884
|
+
this.renderRequested = true;
|
|
885
|
+
process.nextTick(() => {
|
|
886
|
+
if (this.stopped || !this.renderRequested) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this.renderRequested = false;
|
|
890
|
+
this.lastRenderAt = performance.now();
|
|
891
|
+
this.doRender();
|
|
892
|
+
});
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (this.renderRequested)
|
|
896
|
+
return;
|
|
897
|
+
this.renderRequested = true;
|
|
898
|
+
process.nextTick(() => this.scheduleRender());
|
|
899
|
+
}
|
|
900
|
+
scheduleRender() {
|
|
901
|
+
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const elapsed = performance.now() - this.lastRenderAt;
|
|
905
|
+
const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
906
|
+
this.renderTimer = setTimeout(() => {
|
|
907
|
+
this.renderTimer = undefined;
|
|
908
|
+
if (this.stopped || !this.renderRequested) {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
this.renderRequested = false;
|
|
912
|
+
this.lastRenderAt = performance.now();
|
|
913
|
+
this.doRender();
|
|
914
|
+
if (this.renderRequested) {
|
|
915
|
+
this.scheduleRender();
|
|
916
|
+
}
|
|
917
|
+
}, delay);
|
|
918
|
+
}
|
|
919
|
+
handleInput(data) {
|
|
920
|
+
if (this.inputListeners.size > 0) {
|
|
921
|
+
let current = data;
|
|
922
|
+
for (const listener of this.inputListeners) {
|
|
923
|
+
const result = listener(current);
|
|
924
|
+
if (result?.consume) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (result?.data !== undefined) {
|
|
928
|
+
current = result.data;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (current.length === 0) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
data = current;
|
|
935
|
+
}
|
|
936
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
937
|
+
if (this.consumeCellSizeResponse(data)) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
// Global debug key handler (Shift+Ctrl+D)
|
|
941
|
+
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
942
|
+
this.onDebug();
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
// If focused component is an overlay, verify it's still visible
|
|
946
|
+
// (visibility can change due to terminal resize or visible() callback)
|
|
947
|
+
const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
|
|
948
|
+
if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
|
|
949
|
+
// Focused overlay is no longer visible, redirect to topmost visible overlay
|
|
950
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
951
|
+
if (topVisible) {
|
|
952
|
+
this.setFocus(topVisible.component);
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
|
|
959
|
+
if (!focusIsOverlay) {
|
|
960
|
+
const restoreState = this.getVisibleOverlayFocusRestore();
|
|
961
|
+
if (restoreState.status === "eligible") {
|
|
962
|
+
this.setFocus(restoreState.overlay.component);
|
|
963
|
+
}
|
|
964
|
+
else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
|
|
965
|
+
if (restoreState.resume.status === "restore-overlay") {
|
|
966
|
+
this.setFocus(restoreState.overlay.component);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
this.clearOverlayFocusRestore();
|
|
970
|
+
this.setFocus(restoreState.resume.target);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Suppress arrow keys that arrive shortly after a mouse wheel event.
|
|
975
|
+
// Some terminals (or intermediate layers like tmux) occasionally convert
|
|
976
|
+
// scroll events into arrow-key sequences; without this guard those
|
|
977
|
+
// spurious keys trigger unwanted actions such as input-history navigation.
|
|
978
|
+
if (Date.now() - this.lastMouseWheelTime < TUI.MOUSE_WHEEL_SUPPRESS_MS &&
|
|
979
|
+
(matchesKey(data, "up") || matchesKey(data, "down"))) {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
// Pass input to focused component first. If it returns true, it consumed the input.
|
|
983
|
+
if (this.focusedComponent?.handleInput) {
|
|
984
|
+
// Filter out key release events unless component opts in
|
|
985
|
+
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const consumed = this.focusedComponent.handleInput(data);
|
|
989
|
+
this.requestRender();
|
|
990
|
+
if (consumed) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// Fallback: TUI handles scroll keys when focused component didn't consume them
|
|
995
|
+
if (matchesKey(data, "pageUp")) {
|
|
996
|
+
const pageSize = this.lastScrollableViewport || this.terminal.rows - 2;
|
|
997
|
+
this.setScrollOffset(this.scrollOffset + pageSize);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (matchesKey(data, "pageDown")) {
|
|
1001
|
+
const pageSize = this.lastScrollableViewport || this.terminal.rows - 2;
|
|
1002
|
+
this.setScrollOffset(Math.max(0, this.scrollOffset - pageSize));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
// Ctrl+Home / Ctrl+End: scroll to top/bottom of content
|
|
1006
|
+
if (matchesKey(data, "ctrl+home")) {
|
|
1007
|
+
this.setScrollOffset(this.getMaxScrollOffset());
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (matchesKey(data, "ctrl+end")) {
|
|
1011
|
+
this.setScrollOffset(0);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
consumeCellSizeResponse(data) {
|
|
1016
|
+
// Response format: ESC [ 6 ; height ; width t
|
|
1017
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
1018
|
+
if (!match) {
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
const heightPx = parseInt(match[1], 10);
|
|
1022
|
+
const widthPx = parseInt(match[2], 10);
|
|
1023
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
1024
|
+
return true;
|
|
1025
|
+
}
|
|
1026
|
+
setCellDimensions({ widthPx, heightPx });
|
|
1027
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
1028
|
+
this.invalidate();
|
|
1029
|
+
this.requestRender();
|
|
1030
|
+
return true;
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Resolve overlay layout from options.
|
|
1034
|
+
* Returns { width, row, col, maxHeight } for rendering.
|
|
1035
|
+
*/
|
|
1036
|
+
resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
|
|
1037
|
+
const opt = options ?? {};
|
|
1038
|
+
// Parse margin (clamp to non-negative)
|
|
1039
|
+
const margin = typeof opt.margin === "number"
|
|
1040
|
+
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
1041
|
+
: (opt.margin ?? {});
|
|
1042
|
+
const marginTop = Math.max(0, margin.top ?? 0);
|
|
1043
|
+
const marginRight = Math.max(0, margin.right ?? 0);
|
|
1044
|
+
const marginBottom = Math.max(0, margin.bottom ?? 0);
|
|
1045
|
+
const marginLeft = Math.max(0, margin.left ?? 0);
|
|
1046
|
+
// Available space after margins
|
|
1047
|
+
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
|
|
1048
|
+
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
|
|
1049
|
+
// === Resolve width ===
|
|
1050
|
+
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
|
1051
|
+
// Apply minWidth
|
|
1052
|
+
if (opt.minWidth !== undefined) {
|
|
1053
|
+
width = Math.max(width, opt.minWidth);
|
|
1054
|
+
}
|
|
1055
|
+
// Clamp to available space
|
|
1056
|
+
width = Math.max(1, Math.min(width, availWidth));
|
|
1057
|
+
// === Resolve maxHeight ===
|
|
1058
|
+
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
|
1059
|
+
// Clamp to available space
|
|
1060
|
+
if (maxHeight !== undefined) {
|
|
1061
|
+
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
1062
|
+
}
|
|
1063
|
+
// Effective overlay height (may be clamped by maxHeight)
|
|
1064
|
+
const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
|
|
1065
|
+
// === Resolve position ===
|
|
1066
|
+
let row;
|
|
1067
|
+
let col;
|
|
1068
|
+
if (opt.row !== undefined) {
|
|
1069
|
+
if (typeof opt.row === "string") {
|
|
1070
|
+
// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
|
|
1071
|
+
const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
|
|
1072
|
+
if (match) {
|
|
1073
|
+
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
|
1074
|
+
const percent = parseFloat(match[1]) / 100;
|
|
1075
|
+
row = marginTop + Math.floor(maxRow * percent);
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
// Invalid format, fall back to center
|
|
1079
|
+
row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
// Absolute row position
|
|
1084
|
+
row = opt.row;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
// Anchor-based (default: center)
|
|
1089
|
+
const anchor = opt.anchor ?? "center";
|
|
1090
|
+
row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
|
|
1091
|
+
}
|
|
1092
|
+
if (opt.col !== undefined) {
|
|
1093
|
+
if (typeof opt.col === "string") {
|
|
1094
|
+
// Percentage: 0% = left, 100% = right (overlay stays within bounds)
|
|
1095
|
+
const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
|
|
1096
|
+
if (match) {
|
|
1097
|
+
const maxCol = Math.max(0, availWidth - width);
|
|
1098
|
+
const percent = parseFloat(match[1]) / 100;
|
|
1099
|
+
col = marginLeft + Math.floor(maxCol * percent);
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
// Invalid format, fall back to center
|
|
1103
|
+
col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
// Absolute column position
|
|
1108
|
+
col = opt.col;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
// Anchor-based (default: center)
|
|
1113
|
+
const anchor = opt.anchor ?? "center";
|
|
1114
|
+
col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
|
|
1115
|
+
}
|
|
1116
|
+
// Apply offsets
|
|
1117
|
+
if (opt.offsetY !== undefined)
|
|
1118
|
+
row += opt.offsetY;
|
|
1119
|
+
if (opt.offsetX !== undefined)
|
|
1120
|
+
col += opt.offsetX;
|
|
1121
|
+
// Clamp to terminal bounds (respecting margins)
|
|
1122
|
+
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
1123
|
+
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
|
|
1124
|
+
return { width, row, col, maxHeight };
|
|
1125
|
+
}
|
|
1126
|
+
resolveAnchorRow(anchor, height, availHeight, marginTop) {
|
|
1127
|
+
switch (anchor) {
|
|
1128
|
+
case "top-left":
|
|
1129
|
+
case "top-center":
|
|
1130
|
+
case "top-right":
|
|
1131
|
+
return marginTop;
|
|
1132
|
+
case "bottom-left":
|
|
1133
|
+
case "bottom-center":
|
|
1134
|
+
case "bottom-right":
|
|
1135
|
+
return marginTop + availHeight - height;
|
|
1136
|
+
case "left-center":
|
|
1137
|
+
case "center":
|
|
1138
|
+
case "right-center":
|
|
1139
|
+
return marginTop + Math.floor((availHeight - height) / 2);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
resolveAnchorCol(anchor, width, availWidth, marginLeft) {
|
|
1143
|
+
switch (anchor) {
|
|
1144
|
+
case "top-left":
|
|
1145
|
+
case "left-center":
|
|
1146
|
+
case "bottom-left":
|
|
1147
|
+
return marginLeft;
|
|
1148
|
+
case "top-right":
|
|
1149
|
+
case "right-center":
|
|
1150
|
+
case "bottom-right":
|
|
1151
|
+
return marginLeft + availWidth - width;
|
|
1152
|
+
case "top-center":
|
|
1153
|
+
case "center":
|
|
1154
|
+
case "bottom-center":
|
|
1155
|
+
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
1159
|
+
compositeOverlays(lines, termWidth, termHeight) {
|
|
1160
|
+
const result = [...lines];
|
|
1161
|
+
// Pre-render all visible overlays and calculate positions
|
|
1162
|
+
const rendered = [];
|
|
1163
|
+
let minLinesNeeded = result.length;
|
|
1164
|
+
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
|
|
1165
|
+
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
|
|
1166
|
+
for (const entry of visibleEntries) {
|
|
1167
|
+
const { component, options } = entry;
|
|
1168
|
+
// Get layout with height=0 first to determine width and maxHeight
|
|
1169
|
+
// (width and maxHeight don't depend on overlay height)
|
|
1170
|
+
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
1171
|
+
// Render component at calculated width
|
|
1172
|
+
let overlayLines = component.render(width);
|
|
1173
|
+
// Apply maxHeight if specified
|
|
1174
|
+
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
|
1175
|
+
overlayLines = overlayLines.slice(0, maxHeight);
|
|
1176
|
+
}
|
|
1177
|
+
// Get final row/col with actual overlay height
|
|
1178
|
+
const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
1179
|
+
rendered.push({
|
|
1180
|
+
overlayLines,
|
|
1181
|
+
row,
|
|
1182
|
+
col,
|
|
1183
|
+
w: width,
|
|
1184
|
+
background: options?.background,
|
|
1185
|
+
selectionClip: options?.selectionClip,
|
|
1186
|
+
});
|
|
1187
|
+
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
1188
|
+
}
|
|
1189
|
+
// Cache overlay layouts for selection clip calculations
|
|
1190
|
+
this.renderedOverlayLayouts = rendered.map(({ row, col, w, overlayLines, selectionClip }) => ({
|
|
1191
|
+
row,
|
|
1192
|
+
col,
|
|
1193
|
+
width: w,
|
|
1194
|
+
height: overlayLines.length,
|
|
1195
|
+
selectionClip,
|
|
1196
|
+
}));
|
|
1197
|
+
// Pad to at least terminal height so overlays have screen-relative positions.
|
|
1198
|
+
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
|
1199
|
+
// inflation that pushed content into scrollback on terminal widen.
|
|
1200
|
+
const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
|
|
1201
|
+
// Extend result with empty lines if content is too short for overlay placement or working area
|
|
1202
|
+
while (result.length < workingHeight) {
|
|
1203
|
+
result.push("");
|
|
1204
|
+
}
|
|
1205
|
+
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
1206
|
+
// Composite each overlay
|
|
1207
|
+
for (const { overlayLines, row, col, w, background: bg } of rendered) {
|
|
1208
|
+
for (let i = 0; i < overlayLines.length; i++) {
|
|
1209
|
+
const idx = viewportStart + row + i;
|
|
1210
|
+
if (idx >= 0 && idx < result.length) {
|
|
1211
|
+
// Defensive: truncate overlay line to declared width before compositing
|
|
1212
|
+
// (components should already respect width, but this ensures it)
|
|
1213
|
+
const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
|
|
1214
|
+
let baseLine = result[idx];
|
|
1215
|
+
if (bg) {
|
|
1216
|
+
baseLine = this.applyOverlayBackground(baseLine, col, w, bg, termWidth);
|
|
1217
|
+
}
|
|
1218
|
+
result[idx] = this.compositeLineAt(baseLine, truncatedOverlayLine, col, w, termWidth);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return result;
|
|
1223
|
+
}
|
|
1224
|
+
static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
1225
|
+
applyLineResets(lines) {
|
|
1226
|
+
const reset = TUI.SEGMENT_RESET;
|
|
1227
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1228
|
+
const line = lines[i];
|
|
1229
|
+
if (!isImageLine(line)) {
|
|
1230
|
+
lines[i] = normalizeTerminalOutput(line) + reset;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return lines;
|
|
1234
|
+
}
|
|
1235
|
+
collectKittyImageIds(lines) {
|
|
1236
|
+
const ids = new Set();
|
|
1237
|
+
for (const line of lines) {
|
|
1238
|
+
for (const id of extractKittyImageIds(line)) {
|
|
1239
|
+
ids.add(id);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return ids;
|
|
1243
|
+
}
|
|
1244
|
+
deleteKittyImages(ids) {
|
|
1245
|
+
let buffer = "";
|
|
1246
|
+
for (const id of ids) {
|
|
1247
|
+
buffer += deleteKittyImage(id);
|
|
1248
|
+
}
|
|
1249
|
+
return buffer;
|
|
1250
|
+
}
|
|
1251
|
+
expandLastChangedForKittyImages(firstChanged, lastChanged) {
|
|
1252
|
+
let expandedLastChanged = lastChanged;
|
|
1253
|
+
for (let i = firstChanged; i < this.previousLines.length; i++) {
|
|
1254
|
+
if (extractKittyImageIds(this.previousLines[i]).length > 0) {
|
|
1255
|
+
expandedLastChanged = Math.max(expandedLastChanged, i);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return expandedLastChanged;
|
|
1259
|
+
}
|
|
1260
|
+
deleteChangedKittyImages(firstChanged, lastChanged) {
|
|
1261
|
+
if (firstChanged < 0 || lastChanged < firstChanged)
|
|
1262
|
+
return "";
|
|
1263
|
+
const ids = new Set();
|
|
1264
|
+
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
|
|
1265
|
+
for (let i = firstChanged; i <= maxLine; i++) {
|
|
1266
|
+
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
|
|
1267
|
+
ids.add(id);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return this.deleteKittyImages(ids);
|
|
1271
|
+
}
|
|
1272
|
+
/** Apply background fill to the overlay region of a base line. */
|
|
1273
|
+
applyOverlayBackground(baseLine, startCol, overlayWidth, bg, totalWidth) {
|
|
1274
|
+
if (isImageLine(baseLine))
|
|
1275
|
+
return baseLine;
|
|
1276
|
+
const afterStart = startCol + overlayWidth;
|
|
1277
|
+
const segments = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
1278
|
+
const r = TUI.SEGMENT_RESET;
|
|
1279
|
+
const beforePad = Math.max(0, startCol - segments.beforeWidth);
|
|
1280
|
+
const overlayFill = bg + " ".repeat(overlayWidth) + r;
|
|
1281
|
+
const afterTarget = Math.max(0, totalWidth - Math.max(startCol, segments.beforeWidth) - overlayWidth);
|
|
1282
|
+
const afterPad = Math.max(0, afterTarget - segments.afterWidth);
|
|
1283
|
+
return segments.before + " ".repeat(beforePad) + r + overlayFill + segments.after + " ".repeat(afterPad);
|
|
1284
|
+
}
|
|
1285
|
+
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
1286
|
+
compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
|
|
1287
|
+
if (isImageLine(baseLine))
|
|
1288
|
+
return baseLine;
|
|
1289
|
+
// Single pass through baseLine extracts both before and after segments
|
|
1290
|
+
const afterStart = startCol + overlayWidth;
|
|
1291
|
+
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
1292
|
+
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
|
|
1293
|
+
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
1294
|
+
// Pad segments to target widths
|
|
1295
|
+
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
1296
|
+
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
1297
|
+
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
1298
|
+
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
1299
|
+
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
1300
|
+
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
1301
|
+
// Compose result
|
|
1302
|
+
const r = TUI.SEGMENT_RESET;
|
|
1303
|
+
const result = base.before +
|
|
1304
|
+
" ".repeat(beforePad) +
|
|
1305
|
+
r +
|
|
1306
|
+
overlay.text +
|
|
1307
|
+
" ".repeat(overlayPad) +
|
|
1308
|
+
r +
|
|
1309
|
+
base.after +
|
|
1310
|
+
" ".repeat(afterPad);
|
|
1311
|
+
// CRITICAL: Always verify and truncate to terminal width.
|
|
1312
|
+
// This is the final safeguard against width overflow which would crash the TUI.
|
|
1313
|
+
// Width tracking can drift from actual visible width due to:
|
|
1314
|
+
// - Complex ANSI/OSC sequences (hyperlinks, colors)
|
|
1315
|
+
// - Wide characters at segment boundaries
|
|
1316
|
+
// - Edge cases in segment extraction
|
|
1317
|
+
const resultWidth = visibleWidth(result);
|
|
1318
|
+
if (resultWidth <= totalWidth) {
|
|
1319
|
+
return result;
|
|
1320
|
+
}
|
|
1321
|
+
// Truncate with strict=true to ensure we don't exceed totalWidth
|
|
1322
|
+
return sliceByColumn(result, 0, totalWidth, true);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Find and extract cursor position from rendered lines.
|
|
1326
|
+
* Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
|
|
1327
|
+
* Only scans the bottom terminal height lines (visible viewport).
|
|
1328
|
+
* @param lines - Rendered lines to search
|
|
1329
|
+
* @param height - Terminal height (visible viewport size)
|
|
1330
|
+
* @returns Cursor position { row, col } or null if no marker found
|
|
1331
|
+
*/
|
|
1332
|
+
extractCursorPosition(lines, height) {
|
|
1333
|
+
// Only scan the bottom `height` lines (visible viewport)
|
|
1334
|
+
const viewportTop = Math.max(0, lines.length - height);
|
|
1335
|
+
for (let row = lines.length - 1; row >= viewportTop; row--) {
|
|
1336
|
+
const line = lines[row];
|
|
1337
|
+
const markerIndex = line.indexOf(CURSOR_MARKER);
|
|
1338
|
+
if (markerIndex !== -1) {
|
|
1339
|
+
// Calculate visual column (width of text before marker)
|
|
1340
|
+
const beforeMarker = line.slice(0, markerIndex);
|
|
1341
|
+
const col = visibleWidth(beforeMarker);
|
|
1342
|
+
// Strip marker from the line
|
|
1343
|
+
lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
|
|
1344
|
+
return { row, col };
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
renderingSuspended = false;
|
|
1350
|
+
suspendRendering() {
|
|
1351
|
+
this.renderingSuspended = true;
|
|
1352
|
+
if (this.renderTimer) {
|
|
1353
|
+
clearTimeout(this.renderTimer);
|
|
1354
|
+
this.renderTimer = undefined;
|
|
1355
|
+
}
|
|
1356
|
+
this.renderRequested = false;
|
|
1357
|
+
}
|
|
1358
|
+
resumeRendering() {
|
|
1359
|
+
this.renderingSuspended = false;
|
|
1360
|
+
this.requestRender(true);
|
|
1361
|
+
}
|
|
1362
|
+
doRender() {
|
|
1363
|
+
if (this.stopped || this.renderingSuspended)
|
|
1364
|
+
return;
|
|
1365
|
+
const width = this.terminal.columns;
|
|
1366
|
+
const height = this.terminal.rows;
|
|
1367
|
+
// Render all children to get full content
|
|
1368
|
+
const childLines = [];
|
|
1369
|
+
for (const child of this.children) {
|
|
1370
|
+
childLines.push(child.render(width));
|
|
1371
|
+
}
|
|
1372
|
+
// Split children into scrollable (top) and fixed (bottom)
|
|
1373
|
+
// The fixedBottomCount property determines how many trailing children
|
|
1374
|
+
// are always pinned to the bottom of the screen
|
|
1375
|
+
const fixedCount = this.fixedBottomCount;
|
|
1376
|
+
const scrollableLines = [];
|
|
1377
|
+
const fixedLines = [];
|
|
1378
|
+
for (let i = 0; i < childLines.length; i++) {
|
|
1379
|
+
if (i >= childLines.length - fixedCount) {
|
|
1380
|
+
fixedLines.push(...childLines[i]);
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
scrollableLines.push(...childLines[i]);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
// Calculate available space for scrollable area
|
|
1387
|
+
const fixedHeight = fixedLines.length;
|
|
1388
|
+
const scrollableViewport = Math.max(0, height - fixedHeight);
|
|
1389
|
+
this.lastScrollableViewport = scrollableViewport;
|
|
1390
|
+
// Apply scrollOffset to scrollable content only
|
|
1391
|
+
const maxScroll = Math.max(0, scrollableLines.length - scrollableViewport);
|
|
1392
|
+
const lineCountDelta = scrollableLines.length - this.previousScrollableLineCount;
|
|
1393
|
+
if (lineCountDelta > 0) {
|
|
1394
|
+
if (this.autoFollow) {
|
|
1395
|
+
this.scrollOffset = 0;
|
|
1396
|
+
}
|
|
1397
|
+
else if (this.scrollOffset > 0) {
|
|
1398
|
+
this.scrollOffset += lineCountDelta;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (this.scrollOffset > maxScroll)
|
|
1402
|
+
this.scrollOffset = maxScroll;
|
|
1403
|
+
this.previousScrollableLineCount = scrollableLines.length;
|
|
1404
|
+
// Cache full lines (pre-overlay, pre-highlight) for selection text extraction
|
|
1405
|
+
// and absolute-row coordinate mapping
|
|
1406
|
+
this.currentFullLines = [...scrollableLines, ...fixedLines];
|
|
1407
|
+
this.currentScrollableLinesLength = scrollableLines.length;
|
|
1408
|
+
// Determine visible scrollable lines
|
|
1409
|
+
const scrollableViewportTop = Math.max(0, scrollableLines.length - scrollableViewport - this.scrollOffset);
|
|
1410
|
+
const visibleScrollable = scrollableLines.slice(scrollableViewportTop, scrollableViewportTop + scrollableViewport);
|
|
1411
|
+
// Combine: visible scrollable content + fixed bottom content
|
|
1412
|
+
let newLines = [...visibleScrollable, ...fixedLines];
|
|
1413
|
+
// Pad to fill screen height if content is shorter
|
|
1414
|
+
while (newLines.length < height)
|
|
1415
|
+
newLines.push("");
|
|
1416
|
+
// Truncate to screen height
|
|
1417
|
+
newLines = newLines.slice(0, height);
|
|
1418
|
+
// Composite overlays (clear stale layout cache first, then let compositeOverlays refill it)
|
|
1419
|
+
this.renderedOverlayLayouts = [];
|
|
1420
|
+
if (this.overlayStack.length > 0) {
|
|
1421
|
+
newLines = this.compositeOverlays([...newLines], width, height);
|
|
1422
|
+
}
|
|
1423
|
+
// Cache composited lines (post-overlay, pre-highlight) for overlay text extraction
|
|
1424
|
+
this.currentCompositedLines = [...newLines];
|
|
1425
|
+
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
1426
|
+
newLines = this.applyLineResets(newLines);
|
|
1427
|
+
// Update viewport-top state before selection highlight, so bufferToScreenRow
|
|
1428
|
+
// inside applySelectionHighlight uses the correct viewport offset.
|
|
1429
|
+
this.currentScrollableViewportTop = scrollableViewportTop;
|
|
1430
|
+
// Selection highlight: buffer-absolute row iteration via bufferToScreenRow
|
|
1431
|
+
this.applySelectionHighlight(newLines, height);
|
|
1432
|
+
const renderChanged = () => {
|
|
1433
|
+
this.fullRedrawCount += 1;
|
|
1434
|
+
// Sync mode + cursor home + sequential line output (no full screen clear).
|
|
1435
|
+
// \x1b[?2026h defers display until \x1b[?2026l for atomic update.
|
|
1436
|
+
// \x1b[K clears to end-of-line after each line to remove residual content
|
|
1437
|
+
// from previous renders, replacing the flicker-causing \x1b[2J clear screen.
|
|
1438
|
+
// Uses sequential \r\n output (like the original) for minimal bandwidth.
|
|
1439
|
+
let buffer = `\x1b[?2026h${TUI.CURSOR_HIDE_PREFIX}\x1b[H`;
|
|
1440
|
+
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
1441
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
1442
|
+
if (i > 0)
|
|
1443
|
+
buffer += "\r\n";
|
|
1444
|
+
buffer += newLines[i];
|
|
1445
|
+
buffer += "\x1b[K";
|
|
1446
|
+
}
|
|
1447
|
+
buffer += this.buildHardwareCursorSequence(cursorPos, newLines.length);
|
|
1448
|
+
buffer += "\x1b[?2026l";
|
|
1449
|
+
this.terminal.write(buffer);
|
|
1450
|
+
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1451
|
+
this.maxLinesRendered = newLines.length;
|
|
1452
|
+
this.previousViewportTop = scrollableViewportTop;
|
|
1453
|
+
this.previousLines = newLines;
|
|
1454
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1455
|
+
this.previousWidth = width;
|
|
1456
|
+
this.previousHeight = height;
|
|
1457
|
+
};
|
|
1458
|
+
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
1459
|
+
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
1460
|
+
if (this.previousLines.length === 0) {
|
|
1461
|
+
renderChanged();
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (widthChanged || (heightChanged && !isTermuxSession())) {
|
|
1465
|
+
renderChanged();
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
|
|
1469
|
+
renderChanged();
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
let firstChanged = -1;
|
|
1473
|
+
let lastChanged = -1;
|
|
1474
|
+
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
|
1475
|
+
for (let i = 0; i < maxLines; i++) {
|
|
1476
|
+
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
|
1477
|
+
const newLine = i < newLines.length ? newLines[i] : "";
|
|
1478
|
+
if (oldLine !== newLine) {
|
|
1479
|
+
if (firstChanged === -1)
|
|
1480
|
+
firstChanged = i;
|
|
1481
|
+
lastChanged = i;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (newLines.length > this.previousLines.length) {
|
|
1485
|
+
if (firstChanged === -1)
|
|
1486
|
+
firstChanged = this.previousLines.length;
|
|
1487
|
+
lastChanged = newLines.length - 1;
|
|
1488
|
+
}
|
|
1489
|
+
if (firstChanged !== -1)
|
|
1490
|
+
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
|
|
1491
|
+
if (firstChanged === -1) {
|
|
1492
|
+
// No content changed, but cursor position may have — wrap in
|
|
1493
|
+
// sync mode so the cursor move is atomic with the frame.
|
|
1494
|
+
const seq = this.buildHardwareCursorSequence(cursorPos, newLines.length);
|
|
1495
|
+
this.terminal.write(`\x1b[?2026h${TUI.CURSOR_HIDE_PREFIX}${seq}\x1b[?2026l`);
|
|
1496
|
+
this.previousViewportTop = scrollableViewportTop;
|
|
1497
|
+
this.previousHeight = height;
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
// If >50% of visible lines changed, do a full re-render (more efficient than incremental)
|
|
1501
|
+
if (lastChanged - firstChanged + 1 > height * 0.5 || firstChanged < 0) {
|
|
1502
|
+
renderChanged();
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
// All changes are in deleted lines (nothing to render, just clear)
|
|
1506
|
+
if (firstChanged >= newLines.length) {
|
|
1507
|
+
if (this.previousLines.length > newLines.length) {
|
|
1508
|
+
let buffer = "\x1b[?2026h";
|
|
1509
|
+
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
1510
|
+
// Move to end of new content (clamp to 0 for empty content)
|
|
1511
|
+
const targetRow = Math.max(0, newLines.length - 1);
|
|
1512
|
+
if (targetRow < this.previousViewportTop) {
|
|
1513
|
+
renderChanged();
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
const lineDiff = targetRow - this.hardwareCursorRow;
|
|
1517
|
+
if (lineDiff > 0)
|
|
1518
|
+
buffer += `\x1b[${lineDiff}B`;
|
|
1519
|
+
else if (lineDiff < 0)
|
|
1520
|
+
buffer += `\x1b[${-lineDiff}A`;
|
|
1521
|
+
buffer += "\r";
|
|
1522
|
+
// Clear extra lines without scrolling
|
|
1523
|
+
const extraLines = this.previousLines.length - newLines.length;
|
|
1524
|
+
if (extraLines > height) {
|
|
1525
|
+
renderChanged();
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const clearStartOffset = newLines.length === 0 ? 0 : 1;
|
|
1529
|
+
if (extraLines > 0 && clearStartOffset > 0) {
|
|
1530
|
+
buffer += `\x1b[${clearStartOffset}B`;
|
|
1531
|
+
}
|
|
1532
|
+
for (let i = 0; i < extraLines; i++) {
|
|
1533
|
+
buffer += "\r\x1b[2K";
|
|
1534
|
+
if (i < extraLines - 1)
|
|
1535
|
+
buffer += "\x1b[1B";
|
|
1536
|
+
}
|
|
1537
|
+
const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
|
|
1538
|
+
if (moveBack > 0) {
|
|
1539
|
+
buffer += `\x1b[${moveBack}A`;
|
|
1540
|
+
}
|
|
1541
|
+
buffer += "\x1b[?2026l";
|
|
1542
|
+
this.terminal.write(buffer);
|
|
1543
|
+
this.cursorRow = targetRow;
|
|
1544
|
+
this.hardwareCursorRow = targetRow;
|
|
1545
|
+
}
|
|
1546
|
+
this.terminal.write(this.buildHardwareCursorSequence(cursorPos, newLines.length));
|
|
1547
|
+
this.previousLines = newLines;
|
|
1548
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1549
|
+
this.previousWidth = width;
|
|
1550
|
+
this.previousHeight = height;
|
|
1551
|
+
this.previousViewportTop = scrollableViewportTop;
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
let buffer = `\x1b[?2026h${TUI.CURSOR_HIDE_PREFIX}`;
|
|
1555
|
+
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
|
|
1556
|
+
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
1557
|
+
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
1558
|
+
buffer += `\x1b[${i + 1};1H\x1b[2K\x1b[0m${newLines[i]}`;
|
|
1559
|
+
}
|
|
1560
|
+
if (newLines.length < this.previousLines.length) {
|
|
1561
|
+
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
1562
|
+
buffer += `\x1b[${i + 1};1H\x1b[2K`;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
buffer += this.buildHardwareCursorSequence(cursorPos, newLines.length);
|
|
1566
|
+
buffer += "\x1b[?2026l";
|
|
1567
|
+
this.terminal.write(buffer);
|
|
1568
|
+
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1569
|
+
this.previousLines = newLines;
|
|
1570
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
1571
|
+
this.previousWidth = width;
|
|
1572
|
+
this.previousHeight = height;
|
|
1573
|
+
this.previousViewportTop = scrollableViewportTop;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Build escape sequences for positioning the hardware cursor.
|
|
1577
|
+
* Returned string should be appended to the render buffer *before*
|
|
1578
|
+
* the sync-mode terminator (\x1b[?2026l) so the cursor state change
|
|
1579
|
+
* is atomic with the content update and never causes a visible flash.
|
|
1580
|
+
* @param cursorPos The cursor position extracted from rendered output, or null
|
|
1581
|
+
* @param totalLines Total number of rendered lines
|
|
1582
|
+
*/
|
|
1583
|
+
buildHardwareCursorSequence(cursorPos, totalLines) {
|
|
1584
|
+
if (!cursorPos || totalLines <= 0) {
|
|
1585
|
+
return "\x1b[?25l";
|
|
1586
|
+
}
|
|
1587
|
+
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
|
1588
|
+
const targetCol = Math.max(0, cursorPos.col);
|
|
1589
|
+
this.hardwareCursorRow = targetRow;
|
|
1590
|
+
if (this.showHardwareCursor) {
|
|
1591
|
+
// DECSCUSR Ps=6: steady bar cursor — non-blinking, unobtrusive,
|
|
1592
|
+
// and ensures IME frameworks track the cursor position reliably.
|
|
1593
|
+
return `\x1b[${targetRow + 1};${targetCol + 1}H\x1b[6 q\x1b[?25h`;
|
|
1594
|
+
}
|
|
1595
|
+
// Hide then move — both inside the sync-mode buffer so the
|
|
1596
|
+
// terminal never shows the cursor at an intermediate position.
|
|
1597
|
+
return `\x1b[?25l\x1b[${targetRow + 1};${targetCol + 1}H`;
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Cursor-hide prefix to place at the start of every render buffer,
|
|
1601
|
+
* before any content updates. Guarantees the hardware cursor is
|
|
1602
|
+
* invisible before the terminal processes line changes, which
|
|
1603
|
+
* prevents the cursor from briefly appearing at stale positions
|
|
1604
|
+
* on terminals with imperfect sync-mode support.
|
|
1605
|
+
*/
|
|
1606
|
+
static CURSOR_HIDE_PREFIX = "\x1b[?25l";
|
|
1607
|
+
}
|
|
1608
|
+
//# sourceMappingURL=tui.js.map
|