@sayknow-cli/tui 0.6.6 → 0.6.8
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 +15 -0
- package/dist/types/components/editor.d.ts +8 -0
- package/dist/types/components/sayknow-pet.d.ts +5 -2
- package/dist/types/tui.d.ts +16 -0
- package/package.json +3 -3
- package/src/components/editor.ts +36 -0
- package/src/components/sayknow-pet.ts +56 -4
- package/src/tui.ts +59 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.8] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- `renderPetHalfBlocks(..., { scale: "compact" })` draws the pet's own sprite downscaled to 12×5 (eyes kept by feature-first pixel merging), replacing the redrawn `"half"` octopus. Every pose has the same height.
|
|
10
|
+
|
|
11
|
+
## [0.6.7] - 2026-09-28
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `Component.handleClick(line, column)`: top-level children that are not focused can take left clicks, hit-tested against the rows they rendered in the last frame; returning true consumes the click before selection and the focused component.
|
|
16
|
+
- `TUI.setMouseEnabled(enabled)`, `TUI.mouseEnabled` and `TUI.getFocusedComponent()`: switch terminal mouse reporting at runtime and ask which component has keyboard focus.
|
|
17
|
+
- `Editor.setInputBackground(sgr)` paints a background band under every borderless input row (held across colored spans and the cursor); `Editor.isBrowsingHistory()`.
|
|
18
|
+
- `renderPetHalfBlocks(..., { scale: "half" })` draws the small 8×4 octopus.
|
|
19
|
+
|
|
5
20
|
## [0.6.6] - 2026-09-28
|
|
6
21
|
|
|
7
22
|
### Added
|
|
@@ -76,6 +76,14 @@ export declare class Editor implements Component, Focusable {
|
|
|
76
76
|
* precedence over `setPromptGutter` and is ignored while the border is visible.
|
|
77
77
|
*/
|
|
78
78
|
setRailGutter(glyph: string | undefined): void;
|
|
79
|
+
/**
|
|
80
|
+
* Paint a background band (an SGR sequence such as `\x1b[48;2;36;30;24m`) under
|
|
81
|
+
* every input row of a borderless editor, so the line being typed stands out from
|
|
82
|
+
* the transcript. Undefined removes it. Ignored while the border is visible.
|
|
83
|
+
*/
|
|
84
|
+
setInputBackground(background: string | undefined): void;
|
|
85
|
+
/** True while Up/Down are walking prompt history rather than editing. */
|
|
86
|
+
isBrowsingHistory(): boolean;
|
|
79
87
|
setInputPrefix(inputPrefix: string | undefined): void;
|
|
80
88
|
setPlaceholder(placeholder: string | undefined): void;
|
|
81
89
|
/**
|
|
@@ -129,7 +129,10 @@ export declare function buildSayknowPixelFrames(options: {
|
|
|
129
129
|
* Draw a pet frame as text with half blocks, one terminal row per two pixel rows,
|
|
130
130
|
* so the same octopus shows up where no pixel protocol is available (the launch
|
|
131
131
|
* card). Fully transparent pixel rows at the top and bottom are cropped; columns
|
|
132
|
-
* are kept so every frame of the animation keeps the same width.
|
|
132
|
+
* are kept so every frame of the animation keeps the same width. `scale: "compact"`
|
|
133
|
+
* draws the same sprite at three quarters size (12 columns, 5 rows).
|
|
133
134
|
*/
|
|
134
|
-
export declare function renderPetHalfBlocks(frame: SayknowPixelFrameName, skin?: PetSkinId, colorMode?: "truecolor" | "256color"
|
|
135
|
+
export declare function renderPetHalfBlocks(frame: SayknowPixelFrameName, skin?: PetSkinId, colorMode?: "truecolor" | "256color", options?: {
|
|
136
|
+
scale?: "full" | "compact";
|
|
137
|
+
}): string[];
|
|
135
138
|
export {};
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -44,6 +44,13 @@ export interface Component {
|
|
|
44
44
|
handleInput?(data: string): void;
|
|
45
45
|
/** Optional handler for terminal mouse events when component has focus. */
|
|
46
46
|
handleMouse?(event: MouseEvent): void;
|
|
47
|
+
/**
|
|
48
|
+
* Optional click hook for a top-level child that is not focused: called with the
|
|
49
|
+
* row (within this component's last rendered lines) and column of a left click.
|
|
50
|
+
* Return true to consume the click. Needs mouse reporting (`enableMouse` or
|
|
51
|
+
* {@link TUI.setMouseEnabled}).
|
|
52
|
+
*/
|
|
53
|
+
handleClick?(line: number, column: number): boolean;
|
|
47
54
|
/**
|
|
48
55
|
* If true, component receives key release events (Kitty protocol).
|
|
49
56
|
* Default is false - release events are filtered out.
|
|
@@ -298,6 +305,15 @@ export declare class TUI extends Container {
|
|
|
298
305
|
/** Check if there are any visible overlays */
|
|
299
306
|
hasOverlay(): boolean;
|
|
300
307
|
invalidate(): void;
|
|
308
|
+
/**
|
|
309
|
+
* Turn terminal mouse reporting on or off while running. With it on, the terminal
|
|
310
|
+
* sends clicks and the wheel to the app instead of doing its own selection (most
|
|
311
|
+
* terminals still select with Shift held).
|
|
312
|
+
*/
|
|
313
|
+
setMouseEnabled(enabled: boolean): void;
|
|
314
|
+
get mouseEnabled(): boolean;
|
|
315
|
+
/** The component that currently receives keyboard input. */
|
|
316
|
+
getFocusedComponent(): Component | null;
|
|
301
317
|
start(): void;
|
|
302
318
|
/**
|
|
303
319
|
* Wait for a specific render request generation to be written successfully.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.6.
|
|
42
|
-
"@sayknow-cli/utils": "0.6.
|
|
41
|
+
"@sayknow-cli/natives": "0.6.8",
|
|
42
|
+
"@sayknow-cli/utils": "0.6.8",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -428,6 +428,8 @@ export class Editor implements Component, Focusable {
|
|
|
428
428
|
cursorOverrideWidth: number | undefined;
|
|
429
429
|
#promptGutter: string | undefined;
|
|
430
430
|
#railGutter: string | undefined;
|
|
431
|
+
/** SGR background painted under every borderless input row (the composer band). */
|
|
432
|
+
#inputBackground: string | undefined;
|
|
431
433
|
#inputPrefix: string | undefined;
|
|
432
434
|
#inputPrefixWidth = 0;
|
|
433
435
|
#placeholder: string | undefined;
|
|
@@ -578,6 +580,21 @@ export class Editor implements Component, Focusable {
|
|
|
578
580
|
this.#invalidateLayoutCache();
|
|
579
581
|
}
|
|
580
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Paint a background band (an SGR sequence such as `\x1b[48;2;36;30;24m`) under
|
|
585
|
+
* every input row of a borderless editor, so the line being typed stands out from
|
|
586
|
+
* the transcript. Undefined removes it. Ignored while the border is visible.
|
|
587
|
+
*/
|
|
588
|
+
setInputBackground(background: string | undefined): void {
|
|
589
|
+
this.#inputBackground = background || undefined;
|
|
590
|
+
this.#invalidateLayoutCache();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** True while Up/Down are walking prompt history rather than editing. */
|
|
594
|
+
isBrowsingHistory(): boolean {
|
|
595
|
+
return this.#historyIndex !== -1;
|
|
596
|
+
}
|
|
597
|
+
|
|
581
598
|
setInputPrefix(inputPrefix: string | undefined): void {
|
|
582
599
|
this.#inputPrefix = inputPrefix;
|
|
583
600
|
this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
|
|
@@ -935,6 +952,7 @@ export class Editor implements Component, Focusable {
|
|
|
935
952
|
}
|
|
936
953
|
|
|
937
954
|
// Render each layout line
|
|
955
|
+
const inputRowsStart = result.length;
|
|
938
956
|
// Emit hardware cursor marker only when focused and not showing autocomplete
|
|
939
957
|
const emitCursorMarker = this.focused && !this.#autocompleteState;
|
|
940
958
|
const lineContentWidth = Math.max(0, contentAreaWidth - inputPrefixWidth);
|
|
@@ -1123,6 +1141,13 @@ export class Editor implements Component, Focusable {
|
|
|
1123
1141
|
}
|
|
1124
1142
|
}
|
|
1125
1143
|
|
|
1144
|
+
const background = this.#inputBackground;
|
|
1145
|
+
if (background && !borderVisible) {
|
|
1146
|
+
for (let row = inputRowsStart; row < result.length; row++) {
|
|
1147
|
+
result[row] = paintBackground(result[row]!, background, renderWidth);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1126
1151
|
if (borderVisible && this.#closedBorderBox) {
|
|
1127
1152
|
const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
|
|
1128
1153
|
const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
|
|
@@ -1582,6 +1607,7 @@ export class Editor implements Component, Focusable {
|
|
|
1582
1607
|
placeholder: this.#placeholder,
|
|
1583
1608
|
promptGutter: this.#promptGutter,
|
|
1584
1609
|
railGutter: this.#railGutter,
|
|
1610
|
+
inputBackground: this.#inputBackground,
|
|
1585
1611
|
useTerminalCursor: this.#useTerminalCursor,
|
|
1586
1612
|
cursorOverride: this.cursorOverride,
|
|
1587
1613
|
cursorOverrideWidth: this.cursorOverrideWidth,
|
|
@@ -3105,3 +3131,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
3105
3131
|
return null;
|
|
3106
3132
|
}
|
|
3107
3133
|
}
|
|
3134
|
+
|
|
3135
|
+
/**
|
|
3136
|
+
* Hold a background under a whole row: re-assert it after every reset inside the row
|
|
3137
|
+
* (cursor, ghost text and colored spans all end with one) and pad to `width`.
|
|
3138
|
+
*/
|
|
3139
|
+
function paintBackground(line: string, background: string, width: number): string {
|
|
3140
|
+
const fill = Math.max(0, width - visibleWidth(line));
|
|
3141
|
+
const held = line.replace(/\x1b\[(?:0)?m|\x1b\[49m/g, match => `${match}${background}`);
|
|
3142
|
+
return `${background}${held}${padding(fill)}\x1b[0m`;
|
|
3143
|
+
}
|
|
@@ -434,24 +434,76 @@ export function buildSayknowPixelFrames(options: {
|
|
|
434
434
|
return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Pixel priority when several pixels merge into one on downscale: features first, so
|
|
439
|
+
* the eyes survive (a block holding a pupil stays a pupil), then outline over body.
|
|
440
|
+
*/
|
|
441
|
+
const DOWNSCALE_PRIORITY = "VWGwKrRbHhA";
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The pet's own sprite, scaled to `size` columns (rows in proportion; nearest block, feature-first),
|
|
445
|
+
* so compact surfaces show the same octopus rather than a redraw. A block becomes
|
|
446
|
+
* transparent only when most of it is.
|
|
447
|
+
*/
|
|
448
|
+
function downscaleGrid(grid: readonly string[], size: number, palette: Palette, rowCount?: number): string[] {
|
|
449
|
+
const sourceRows = grid.length;
|
|
450
|
+
const source = grid[0]?.length ?? 0;
|
|
451
|
+
// Rows in proportion to columns unless a height is asked for.
|
|
452
|
+
const rows = rowCount ?? Math.round((sourceRows * size) / source);
|
|
453
|
+
const out: string[] = [];
|
|
454
|
+
for (let row = 0; row < rows; row++) {
|
|
455
|
+
const rowStart = Math.floor((row * sourceRows) / rows);
|
|
456
|
+
const rowEnd = Math.floor(((row + 1) * sourceRows) / rows);
|
|
457
|
+
let line = "";
|
|
458
|
+
for (let col = 0; col < size; col++) {
|
|
459
|
+
const colStart = Math.floor((col * source) / size);
|
|
460
|
+
const colEnd = Math.floor(((col + 1) * source) / size);
|
|
461
|
+
const cells: string[] = [];
|
|
462
|
+
for (let y = rowStart; y < rowEnd; y++) for (let x = colStart; x < colEnd; x++) cells.push(grid[y]![x] ?? ".");
|
|
463
|
+
const opaque = cells.filter(ch => palette[ch] != null);
|
|
464
|
+
line +=
|
|
465
|
+
opaque.length * 2 < cells.length
|
|
466
|
+
? "."
|
|
467
|
+
: ([...DOWNSCALE_PRIORITY].find(ch => opaque.includes(ch)) ?? opaque[0]!);
|
|
468
|
+
}
|
|
469
|
+
out.push(line);
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Pixel size of the compact pet: three quarters of the 16×16 sprite (12 columns, 5 rows). */
|
|
475
|
+
const COMPACT_PET_PIXELS = 12;
|
|
476
|
+
/** Terminal rows of the compact pet (two pixel rows each). */
|
|
477
|
+
const COMPACT_PET_ROWS = 5;
|
|
478
|
+
|
|
437
479
|
/**
|
|
438
480
|
* Draw a pet frame as text with half blocks, one terminal row per two pixel rows,
|
|
439
481
|
* so the same octopus shows up where no pixel protocol is available (the launch
|
|
440
482
|
* card). Fully transparent pixel rows at the top and bottom are cropped; columns
|
|
441
|
-
* are kept so every frame of the animation keeps the same width.
|
|
483
|
+
* are kept so every frame of the animation keeps the same width. `scale: "compact"`
|
|
484
|
+
* draws the same sprite at three quarters size (12 columns, 5 rows).
|
|
442
485
|
*/
|
|
443
486
|
export function renderPetHalfBlocks(
|
|
444
487
|
frame: SayknowPixelFrameName,
|
|
445
488
|
skin: PetSkinId = "red",
|
|
446
489
|
colorMode: "truecolor" | "256color" = "truecolor",
|
|
490
|
+
options: { scale?: "full" | "compact" } = {},
|
|
447
491
|
): string[] {
|
|
448
|
-
const grid = PIXEL_GRIDS[frame];
|
|
449
492
|
const palette = PET_SKINS[skin].palette;
|
|
493
|
+
const compact = options.scale === "compact";
|
|
494
|
+
// Compact: the sprite's own pixel rows without its empty top and bottom margins (the
|
|
495
|
+
// same for every pose), scaled to 12×10 pixels = 12 columns × 5 rows. Cropping per pose
|
|
496
|
+
// would change the height while the tentacles move and make the card jump.
|
|
497
|
+
const grid = compact
|
|
498
|
+
? downscaleGrid(PIXEL_GRIDS[frame].slice(1, 15), COMPACT_PET_PIXELS, palette, COMPACT_PET_ROWS * 2)
|
|
499
|
+
: PIXEL_GRIDS[frame];
|
|
450
500
|
const opaque = (row: string): boolean => [...row].some(ch => palette[ch] != null);
|
|
451
501
|
let top = 0;
|
|
452
|
-
while (top < grid.length && !opaque(grid[top]!)) top++;
|
|
453
502
|
let bottom = grid.length - 1;
|
|
454
|
-
|
|
503
|
+
if (!compact) {
|
|
504
|
+
while (top < grid.length && !opaque(grid[top]!)) top++;
|
|
505
|
+
while (bottom > top && !opaque(grid[bottom]!)) bottom--;
|
|
506
|
+
}
|
|
455
507
|
const format = colorMode === "truecolor" ? "ansi-16m" : "ansi-256";
|
|
456
508
|
const fg = (rgb: Rgb): string => Bun.color(`rgb(${rgb[0]},${rgb[1]},${rgb[2]})`, format) ?? "";
|
|
457
509
|
const bg = (rgb: Rgb): string => fg(rgb).replace("\x1b[38;", "\x1b[48;");
|
package/src/tui.ts
CHANGED
|
@@ -132,6 +132,14 @@ export interface Component {
|
|
|
132
132
|
/** Optional handler for terminal mouse events when component has focus. */
|
|
133
133
|
handleMouse?(event: MouseEvent): void;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Optional click hook for a top-level child that is not focused: called with the
|
|
137
|
+
* row (within this component's last rendered lines) and column of a left click.
|
|
138
|
+
* Return true to consume the click. Needs mouse reporting (`enableMouse` or
|
|
139
|
+
* {@link TUI.setMouseEnabled}).
|
|
140
|
+
*/
|
|
141
|
+
handleClick?(line: number, column: number): boolean;
|
|
142
|
+
|
|
135
143
|
/**
|
|
136
144
|
* If true, component receives key release events (Kitty protocol).
|
|
137
145
|
* Default is false - release events are filtered out.
|
|
@@ -760,6 +768,9 @@ export class TUI extends Container {
|
|
|
760
768
|
#mouseSelectionStart: MouseSelectionPoint | null = null;
|
|
761
769
|
#mouseSelectionEnd: MouseSelectionPoint | null = null;
|
|
762
770
|
#mouseSelectionDragged = false;
|
|
771
|
+
/** Rows each top-level child occupied in the last frame, for click hit-testing. */
|
|
772
|
+
#childRows = new Map<Component, { start: number; count: number }>();
|
|
773
|
+
#mouseEnabled = false;
|
|
763
774
|
#viewportOutputSource: ViewportOutputSource | null = null;
|
|
764
775
|
#manualOutputNotice = false;
|
|
765
776
|
#manualTranscriptLineCount = 0;
|
|
@@ -828,6 +839,7 @@ export class TUI extends Container {
|
|
|
828
839
|
} = {},
|
|
829
840
|
) {
|
|
830
841
|
super();
|
|
842
|
+
this.#mouseEnabled = options.enableMouse === true;
|
|
831
843
|
this.terminal = terminal;
|
|
832
844
|
if (showHardwareCursor !== undefined) {
|
|
833
845
|
this.#showHardwareCursor = showHardwareCursor;
|
|
@@ -1315,13 +1327,34 @@ export class TUI extends Container {
|
|
|
1315
1327
|
for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
|
|
1316
1328
|
}
|
|
1317
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* Turn terminal mouse reporting on or off while running. With it on, the terminal
|
|
1332
|
+
* sends clicks and the wheel to the app instead of doing its own selection (most
|
|
1333
|
+
* terminals still select with Shift held).
|
|
1334
|
+
*/
|
|
1335
|
+
setMouseEnabled(enabled: boolean): void {
|
|
1336
|
+
if (this.#mouseEnabled === enabled) return;
|
|
1337
|
+
this.#mouseEnabled = enabled;
|
|
1338
|
+
if (!enabled) this.#clearMouseSelection();
|
|
1339
|
+
if (!this.#stopped) this.terminal.setMouseEnabled?.(enabled);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
get mouseEnabled(): boolean {
|
|
1343
|
+
return this.#mouseEnabled;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** The component that currently receives keyboard input. */
|
|
1347
|
+
getFocusedComponent(): Component | null {
|
|
1348
|
+
return this.#focusedComponent;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1318
1351
|
start(): void {
|
|
1319
1352
|
this.#stopped = false;
|
|
1320
1353
|
this.#terminalUnavailable = false;
|
|
1321
1354
|
// Seed the observed width so a spurious post-start resize event (iTerm2 tab
|
|
1322
1355
|
// activation, the self-sent SIGWINCH after resume) is not read as a reflow.
|
|
1323
1356
|
this.#lastObservedWidth = this.terminal.columns;
|
|
1324
|
-
this.terminal.setMouseEnabled?.(this
|
|
1357
|
+
this.terminal.setMouseEnabled?.(this.#mouseEnabled);
|
|
1325
1358
|
this.terminal.start(
|
|
1326
1359
|
data => this.#handleInput(data),
|
|
1327
1360
|
() => {
|
|
@@ -2022,6 +2055,10 @@ export class TUI extends Container {
|
|
|
2022
2055
|
this.#clearMouseSelection();
|
|
2023
2056
|
this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
|
|
2024
2057
|
} else if (mouse.kind === "click") {
|
|
2058
|
+
if (this.#dispatchChildClick(mouse)) {
|
|
2059
|
+
this.requestRender(false, "mouse");
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2025
2062
|
this.#beginMouseSelection(mouse);
|
|
2026
2063
|
const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
|
|
2027
2064
|
if (focusedOverlay) {
|
|
@@ -2096,6 +2133,22 @@ export class TUI extends Container {
|
|
|
2096
2133
|
}
|
|
2097
2134
|
}
|
|
2098
2135
|
|
|
2136
|
+
/** Give a click to the top-level child under it, if that child takes clicks and no overlay is focused. */
|
|
2137
|
+
#dispatchChildClick(mouse: MouseEvent): boolean {
|
|
2138
|
+
if (this.#childRows.size === 0) return false;
|
|
2139
|
+
if (this.overlayStack.some(overlay => overlay.component === this.#focusedComponent)) return false;
|
|
2140
|
+
const point = this.#mouseSelectionPoint(mouse);
|
|
2141
|
+
if (point === null) return false;
|
|
2142
|
+
for (const [child, rows] of this.#childRows) {
|
|
2143
|
+
if (point.line < rows.start || point.line >= rows.start + rows.count) continue;
|
|
2144
|
+
if (child.handleClick?.(point.line - rows.start, point.column)) {
|
|
2145
|
+
this.#clearMouseSelection();
|
|
2146
|
+
return true;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2099
2152
|
#mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint | null {
|
|
2100
2153
|
if (this.#manualViewportTop === undefined) {
|
|
2101
2154
|
return { line: this.#viewportTopRow + mouse.y - 1, column: mouse.x - 1 };
|
|
@@ -2919,9 +2972,14 @@ export class TUI extends Container {
|
|
|
2919
2972
|
const renderedChildren = new Map<Component, string[]>();
|
|
2920
2973
|
let anchorFrame: ViewportAnchorFrame | null = null;
|
|
2921
2974
|
const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
|
|
2975
|
+
this.#childRows.clear();
|
|
2922
2976
|
for (const child of this.children) {
|
|
2923
2977
|
const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
|
|
2924
2978
|
renderedChildren.set(child, rendered.lines);
|
|
2979
|
+
// Children above the pinned suffix keep these rows: padding and suffix trimming
|
|
2980
|
+
// only touch the pinned tail.
|
|
2981
|
+
if (child.handleClick)
|
|
2982
|
+
this.#childRows.set(child, { start: renderedLines.length, count: rendered.lines.length });
|
|
2925
2983
|
if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
|
|
2926
2984
|
anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
|
|
2927
2985
|
}
|