@gajae-code/tui 0.8.2 → 0.9.1
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 +11 -0
- package/dist/types/animation-scheduler.d.ts +13 -0
- package/dist/types/components/editor.d.ts +10 -1
- package/dist/types/components/loader.d.ts +10 -1
- package/dist/types/components/markdown.d.ts +14 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +70 -2
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +22 -4
- package/dist/types/utils.d.ts +12 -0
- package/package.json +4 -7
- package/src/animation-scheduler.ts +99 -0
- package/src/components/editor.ts +296 -167
- package/src/components/image.ts +43 -9
- package/src/components/loader.ts +36 -37
- package/src/components/markdown.ts +79 -2
- package/src/index.ts +1 -0
- package/src/stdin-buffer.ts +89 -11
- package/src/terminal-capabilities.ts +149 -7
- package/src/terminal.ts +7 -0
- package/src/tui.ts +259 -101
- package/src/utils.ts +71 -9
package/src/components/editor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getProjectDir, logger } from "@gajae-code/utils";
|
|
1
|
+
import { getProjectDir, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
|
|
2
2
|
import {
|
|
3
3
|
type AutocompleteProvider,
|
|
4
4
|
type CombinedAutocompleteProvider,
|
|
@@ -344,10 +344,27 @@ interface EditorState {
|
|
|
344
344
|
|
|
345
345
|
interface LayoutLine {
|
|
346
346
|
text: string;
|
|
347
|
+
visibleWidth: number;
|
|
348
|
+
logicalLine: number;
|
|
347
349
|
hasCursor: boolean;
|
|
348
350
|
cursorPos?: number;
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
interface LayoutCacheKey {
|
|
354
|
+
docVersion: number;
|
|
355
|
+
contentWidth: number;
|
|
356
|
+
optionsKey: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface LayoutCache {
|
|
360
|
+
key: LayoutCacheKey;
|
|
361
|
+
cursorLine: number;
|
|
362
|
+
cursorCol: number;
|
|
363
|
+
lines: LayoutLine[];
|
|
364
|
+
lineStarts: number[];
|
|
365
|
+
lineCounts: number[];
|
|
366
|
+
}
|
|
367
|
+
|
|
351
368
|
export interface EditorTheme {
|
|
352
369
|
borderColor: (str: string) => string;
|
|
353
370
|
selectList: SelectListTheme;
|
|
@@ -377,6 +394,18 @@ interface HistoryStorage {
|
|
|
377
394
|
|
|
378
395
|
type HistoryCursorAnchor = "start" | "end";
|
|
379
396
|
|
|
397
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
398
|
+
export const __editorPerfCounters = {
|
|
399
|
+
layoutTextInvocations: 0,
|
|
400
|
+
layoutLogicalLinesProcessed: 0,
|
|
401
|
+
visibleWidthMeasurements: 0,
|
|
402
|
+
reset(): void {
|
|
403
|
+
this.layoutTextInvocations = 0;
|
|
404
|
+
this.layoutLogicalLinesProcessed = 0;
|
|
405
|
+
this.visibleWidthMeasurements = 0;
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
|
|
380
409
|
export class Editor implements Component, Focusable {
|
|
381
410
|
#state: EditorState = {
|
|
382
411
|
lines: [""],
|
|
@@ -404,9 +433,12 @@ export class Editor implements Component, Focusable {
|
|
|
404
433
|
// Store last layout width for cursor navigation
|
|
405
434
|
#lastLayoutWidth: number = 80;
|
|
406
435
|
#paddingXOverride: number | undefined;
|
|
436
|
+
#rightGutterWidth = 0;
|
|
407
437
|
#maxHeight?: number;
|
|
408
438
|
#scrollOffset: number = 0;
|
|
409
439
|
#wrappedLineCache: CachedWrappedLine[] = [];
|
|
440
|
+
#docVersion = 0;
|
|
441
|
+
#layoutCache: LayoutCache | undefined;
|
|
410
442
|
|
|
411
443
|
// Emacs-style kill ring
|
|
412
444
|
#killRing = new KillRing();
|
|
@@ -467,9 +499,25 @@ export class Editor implements Component, Focusable {
|
|
|
467
499
|
#borderStyle: EditorBorderStyle = "round";
|
|
468
500
|
#closedBorderBox = false;
|
|
469
501
|
|
|
502
|
+
#disposeTabWidthListener?: () => void;
|
|
503
|
+
|
|
470
504
|
constructor(theme: EditorTheme) {
|
|
471
505
|
this.#theme = theme;
|
|
472
506
|
this.borderColor = theme.borderColor;
|
|
507
|
+
// Raw tabs can reach editor state via insertText()/autocomplete results
|
|
508
|
+
// (setText expands tabs by contract). visibleWidth + wrapping depend on the
|
|
509
|
+
// default tab width, so a runtime tab-width change must drop both caches.
|
|
510
|
+
this.#disposeTabWidthListener = onDefaultTabWidthChange(() => {
|
|
511
|
+
this.invalidate();
|
|
512
|
+
if (this.#inputPrefix !== undefined) {
|
|
513
|
+
this.#inputPrefixWidth = visibleWidth(this.#inputPrefix);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
dispose(): void {
|
|
519
|
+
this.#disposeTabWidthListener?.();
|
|
520
|
+
this.#disposeTabWidthListener = undefined;
|
|
473
521
|
}
|
|
474
522
|
|
|
475
523
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
@@ -491,6 +539,7 @@ export class Editor implements Component, Focusable {
|
|
|
491
539
|
*/
|
|
492
540
|
setTopBorder(content: EditorTopBorder | undefined): void {
|
|
493
541
|
this.#topBorderContent = content;
|
|
542
|
+
this.#invalidateLayoutCache();
|
|
494
543
|
}
|
|
495
544
|
|
|
496
545
|
/**
|
|
@@ -498,38 +547,46 @@ export class Editor implements Component, Focusable {
|
|
|
498
547
|
*/
|
|
499
548
|
setBorderVisible(borderVisible: boolean): void {
|
|
500
549
|
this.#borderVisible = borderVisible;
|
|
550
|
+
this.#invalidateLayoutCache();
|
|
501
551
|
}
|
|
502
552
|
|
|
503
553
|
setBorderStyle(borderStyle: EditorBorderStyle): void {
|
|
504
554
|
this.#borderStyle = borderStyle;
|
|
555
|
+
this.#invalidateLayoutCache();
|
|
505
556
|
}
|
|
506
557
|
|
|
507
558
|
setClosedBorderBox(closedBorderBox: boolean): void {
|
|
508
559
|
this.#closedBorderBox = closedBorderBox;
|
|
560
|
+
this.#invalidateLayoutCache();
|
|
509
561
|
}
|
|
510
562
|
|
|
511
563
|
setPromptGutter(promptGutter: string | undefined): void {
|
|
512
564
|
this.#promptGutter = promptGutter;
|
|
565
|
+
this.#invalidateLayoutCache();
|
|
513
566
|
}
|
|
514
567
|
|
|
515
568
|
setInputPrefix(inputPrefix: string | undefined): void {
|
|
516
569
|
this.#inputPrefix = inputPrefix;
|
|
517
570
|
this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
|
|
571
|
+
this.#invalidateLayoutCache();
|
|
518
572
|
}
|
|
519
573
|
|
|
520
574
|
setPlaceholder(placeholder: string | undefined): void {
|
|
521
575
|
const trimmed = placeholder?.trim();
|
|
522
576
|
this.#placeholder = trimmed ? trimmed : undefined;
|
|
577
|
+
this.#invalidateLayoutCache();
|
|
523
578
|
}
|
|
524
579
|
|
|
525
580
|
/**
|
|
526
581
|
* Get the available width for top border content given a total terminal width.
|
|
527
|
-
* Accounts for
|
|
582
|
+
* Accounts for right gutter, border characters, and horizontal padding when visible.
|
|
528
583
|
*/
|
|
529
584
|
getTopBorderAvailableWidth(terminalWidth: number): number {
|
|
585
|
+
const rightGutterWidth = Math.min(this.#rightGutterWidth, Math.max(0, terminalWidth - 1));
|
|
586
|
+
const renderWidth = Math.max(1, terminalWidth - rightGutterWidth);
|
|
530
587
|
const paddingX = this.#getEditorPaddingX();
|
|
531
588
|
const borderWidth = this.#getHorizontalChromeWidth(paddingX);
|
|
532
|
-
return Math.max(0,
|
|
589
|
+
return Math.max(0, renderWidth - borderWidth * 2);
|
|
533
590
|
}
|
|
534
591
|
|
|
535
592
|
/**
|
|
@@ -537,6 +594,7 @@ export class Editor implements Component, Focusable {
|
|
|
537
594
|
*/
|
|
538
595
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
539
596
|
this.#useTerminalCursor = useTerminalCursor;
|
|
597
|
+
this.#invalidateLayoutCache();
|
|
540
598
|
}
|
|
541
599
|
|
|
542
600
|
getUseTerminalCursor(): boolean {
|
|
@@ -551,6 +609,11 @@ export class Editor implements Component, Focusable {
|
|
|
551
609
|
|
|
552
610
|
setPaddingX(paddingX: number): void {
|
|
553
611
|
this.#paddingXOverride = Math.max(0, paddingX);
|
|
612
|
+
this.#invalidateLayoutCache();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
setRightGutterWidth(width: number): void {
|
|
616
|
+
this.#rightGutterWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
|
|
554
617
|
}
|
|
555
618
|
|
|
556
619
|
getAutocompleteMaxVisible(): number {
|
|
@@ -640,10 +703,21 @@ export class Editor implements Component, Focusable {
|
|
|
640
703
|
this.onChange(this.getText());
|
|
641
704
|
}
|
|
642
705
|
this.#wrappedLineCache.length = 0;
|
|
706
|
+
this.#bumpDocumentVersion();
|
|
643
707
|
}
|
|
644
708
|
|
|
645
709
|
invalidate(): void {
|
|
646
710
|
this.#wrappedLineCache.length = 0;
|
|
711
|
+
this.#layoutCache = undefined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
#bumpDocumentVersion(): void {
|
|
715
|
+
this.#docVersion += 1;
|
|
716
|
+
this.#layoutCache = undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
#invalidateLayoutCache(): void {
|
|
720
|
+
this.#layoutCache = undefined;
|
|
647
721
|
}
|
|
648
722
|
|
|
649
723
|
#getEditorPaddingX(): number {
|
|
@@ -789,12 +863,14 @@ export class Editor implements Component, Focusable {
|
|
|
789
863
|
}
|
|
790
864
|
|
|
791
865
|
render(width: number): string[] {
|
|
866
|
+
const rightGutterWidth = Math.min(this.#rightGutterWidth, Math.max(0, width - 1));
|
|
867
|
+
const renderWidth = Math.max(1, width - rightGutterWidth);
|
|
792
868
|
const paddingX = this.#getEditorPaddingX();
|
|
793
869
|
const borderVisible = this.#borderVisible;
|
|
794
|
-
const promptGutter = this.#getPromptGutter(
|
|
795
|
-
const contentAreaWidth = this.#getContentWidth(
|
|
870
|
+
const promptGutter = this.#getPromptGutter(renderWidth, paddingX);
|
|
871
|
+
const contentAreaWidth = this.#getContentWidth(renderWidth, paddingX);
|
|
796
872
|
const inputPrefixWidth = this.#inputPrefixWidth;
|
|
797
|
-
const layoutWidth = Math.max(1, this.#getLayoutWidth(
|
|
873
|
+
const layoutWidth = Math.max(1, this.#getLayoutWidth(renderWidth, paddingX) - inputPrefixWidth);
|
|
798
874
|
this.#lastLayoutWidth = layoutWidth;
|
|
799
875
|
|
|
800
876
|
// Box-drawing characters for the configured input box shape.
|
|
@@ -815,7 +891,7 @@ export class Editor implements Component, Focusable {
|
|
|
815
891
|
|
|
816
892
|
if (borderVisible) {
|
|
817
893
|
// Render top border: ╭─ [status content] ────────────────╮
|
|
818
|
-
const topFillWidth = Math.max(0,
|
|
894
|
+
const topFillWidth = Math.max(0, renderWidth - borderWidth * 2);
|
|
819
895
|
if (this.#topBorderContent) {
|
|
820
896
|
const { content, width: statusWidth } = this.#topBorderContent;
|
|
821
897
|
if (statusWidth <= topFillWidth) {
|
|
@@ -847,7 +923,7 @@ export class Editor implements Component, Focusable {
|
|
|
847
923
|
for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
|
|
848
924
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
849
925
|
let displayText = layoutLine.text;
|
|
850
|
-
let displayWidth =
|
|
926
|
+
let displayWidth = layoutLine.visibleWidth;
|
|
851
927
|
let cursorInPadding = false;
|
|
852
928
|
const absoluteVisibleIndex = this.#scrollOffset + visibleIndex;
|
|
853
929
|
const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
|
|
@@ -1020,7 +1096,7 @@ export class Editor implements Component, Focusable {
|
|
|
1020
1096
|
}
|
|
1021
1097
|
|
|
1022
1098
|
if (borderVisible && this.#closedBorderBox) {
|
|
1023
|
-
const bottomFillWidth = Math.max(0,
|
|
1099
|
+
const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
|
|
1024
1100
|
const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
|
|
1025
1101
|
const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
|
|
1026
1102
|
result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
|
|
@@ -1028,10 +1104,15 @@ export class Editor implements Component, Focusable {
|
|
|
1028
1104
|
|
|
1029
1105
|
// Add autocomplete list if active
|
|
1030
1106
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1031
|
-
const autocompleteResult = this.#autocompleteList.render(
|
|
1107
|
+
const autocompleteResult = this.#autocompleteList.render(renderWidth);
|
|
1032
1108
|
result.push(...autocompleteResult);
|
|
1033
1109
|
}
|
|
1034
1110
|
|
|
1111
|
+
if (rightGutterWidth > 0) {
|
|
1112
|
+
const rightGutter = padding(rightGutterWidth);
|
|
1113
|
+
return result.map(line => line + rightGutter);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1035
1116
|
return result;
|
|
1036
1117
|
}
|
|
1037
1118
|
|
|
@@ -1132,6 +1213,7 @@ export class Editor implements Component, Focusable {
|
|
|
1132
1213
|
);
|
|
1133
1214
|
|
|
1134
1215
|
this.#state.lines = result.lines;
|
|
1216
|
+
this.#bumpDocumentVersion();
|
|
1135
1217
|
this.#state.cursorLine = result.cursorLine;
|
|
1136
1218
|
this.#setCursorCol(result.cursorCol);
|
|
1137
1219
|
|
|
@@ -1171,6 +1253,7 @@ export class Editor implements Component, Focusable {
|
|
|
1171
1253
|
);
|
|
1172
1254
|
|
|
1173
1255
|
this.#state.lines = result.lines;
|
|
1256
|
+
this.#bumpDocumentVersion();
|
|
1174
1257
|
this.#state.cursorLine = result.cursorLine;
|
|
1175
1258
|
this.#setCursorCol(result.cursorCol);
|
|
1176
1259
|
result.onApplied?.();
|
|
@@ -1192,6 +1275,7 @@ export class Editor implements Component, Focusable {
|
|
|
1192
1275
|
);
|
|
1193
1276
|
|
|
1194
1277
|
this.#state.lines = result.lines;
|
|
1278
|
+
this.#bumpDocumentVersion();
|
|
1195
1279
|
this.#state.cursorLine = result.cursorLine;
|
|
1196
1280
|
this.#setCursorCol(result.cursorCol);
|
|
1197
1281
|
|
|
@@ -1312,6 +1396,7 @@ export class Editor implements Component, Focusable {
|
|
|
1312
1396
|
syncResult.prefix,
|
|
1313
1397
|
);
|
|
1314
1398
|
this.#state.lines = result.lines;
|
|
1399
|
+
this.#bumpDocumentVersion();
|
|
1315
1400
|
this.#state.cursorLine = result.cursorLine;
|
|
1316
1401
|
this.#setCursorCol(result.cursorCol);
|
|
1317
1402
|
result.onApplied?.();
|
|
@@ -1445,119 +1530,175 @@ export class Editor implements Component, Focusable {
|
|
|
1445
1530
|
return this.#wrappedLineCache.length;
|
|
1446
1531
|
}
|
|
1447
1532
|
|
|
1448
|
-
#
|
|
1533
|
+
#makeLayoutCacheKey(contentWidth: number): LayoutCacheKey {
|
|
1534
|
+
return {
|
|
1535
|
+
docVersion: this.#docVersion,
|
|
1536
|
+
contentWidth,
|
|
1537
|
+
optionsKey: JSON.stringify({
|
|
1538
|
+
borderVisible: this.#borderVisible,
|
|
1539
|
+
borderStyle: this.#borderStyle,
|
|
1540
|
+
closedBorderBox: this.#closedBorderBox,
|
|
1541
|
+
inputPrefix: this.#inputPrefix,
|
|
1542
|
+
inputPrefixWidth: this.#inputPrefixWidth,
|
|
1543
|
+
placeholder: this.#placeholder,
|
|
1544
|
+
promptGutter: this.#promptGutter,
|
|
1545
|
+
useTerminalCursor: this.#useTerminalCursor,
|
|
1546
|
+
cursorOverride: this.cursorOverride,
|
|
1547
|
+
cursorOverrideWidth: this.cursorOverrideWidth,
|
|
1548
|
+
autocompleteState: this.#autocompleteState,
|
|
1549
|
+
autocompletePrefix: this.#autocompletePrefix,
|
|
1550
|
+
autocompleteHint: this.#autocompleteList?.getSelectedItem()?.hint,
|
|
1551
|
+
}),
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
#layoutLine(text: string, logicalLine: number, hasCursor: boolean, cursorPos?: number): LayoutLine {
|
|
1556
|
+
__editorPerfCounters.visibleWidthMeasurements += 1;
|
|
1557
|
+
return {
|
|
1558
|
+
text,
|
|
1559
|
+
visibleWidth: visibleWidth(text),
|
|
1560
|
+
logicalLine,
|
|
1561
|
+
hasCursor,
|
|
1562
|
+
cursorPos,
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
#layoutLogicalLine(lineIndex: number, contentWidth: number): LayoutLine[] {
|
|
1567
|
+
__editorPerfCounters.layoutLogicalLinesProcessed += 1;
|
|
1568
|
+
const line = this.#state.lines[lineIndex] || "";
|
|
1569
|
+
const isCurrentLine = lineIndex === this.#state.cursorLine;
|
|
1570
|
+
const wrappedLine = this.#getWrappedLine(lineIndex, contentWidth);
|
|
1449
1571
|
const layoutLines: LayoutLine[] = [];
|
|
1450
1572
|
|
|
1451
|
-
if (
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1456
|
-
layoutLines.push({
|
|
1457
|
-
text: "",
|
|
1458
|
-
hasCursor: true,
|
|
1459
|
-
cursorPos: 0,
|
|
1460
|
-
});
|
|
1573
|
+
if (wrappedLine.width <= contentWidth) {
|
|
1574
|
+
layoutLines.push(
|
|
1575
|
+
this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
|
|
1576
|
+
);
|
|
1461
1577
|
return layoutLines;
|
|
1462
1578
|
}
|
|
1463
1579
|
|
|
1464
|
-
|
|
1465
|
-
for (let
|
|
1466
|
-
const
|
|
1467
|
-
|
|
1468
|
-
const wrappedLine = this.#getWrappedLine(i, contentWidth);
|
|
1469
|
-
|
|
1470
|
-
if (wrappedLine.width <= contentWidth) {
|
|
1471
|
-
// Line fits in one layout line
|
|
1472
|
-
if (isCurrentLine) {
|
|
1473
|
-
layoutLines.push({
|
|
1474
|
-
text: line,
|
|
1475
|
-
hasCursor: true,
|
|
1476
|
-
cursorPos: this.#state.cursorCol,
|
|
1477
|
-
});
|
|
1478
|
-
} else {
|
|
1479
|
-
layoutLines.push({
|
|
1480
|
-
text: line,
|
|
1481
|
-
hasCursor: false,
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
} else {
|
|
1485
|
-
// Line needs wrapping - use word-aware wrapping
|
|
1486
|
-
const chunks = wrappedLine.chunks;
|
|
1487
|
-
|
|
1488
|
-
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1489
|
-
const chunk = chunks[chunkIndex];
|
|
1490
|
-
if (!chunk) continue;
|
|
1491
|
-
|
|
1492
|
-
const cursorPos = this.#state.cursorCol;
|
|
1493
|
-
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
1580
|
+
const chunks = wrappedLine.chunks;
|
|
1581
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1582
|
+
const chunk = chunks[chunkIndex];
|
|
1583
|
+
if (!chunk) continue;
|
|
1494
1584
|
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
let adjustedCursorPos = 0;
|
|
1585
|
+
const cursorPos = this.#state.cursorCol;
|
|
1586
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
1587
|
+
let hasCursorInChunk = false;
|
|
1588
|
+
let adjustedCursorPos = 0;
|
|
1500
1589
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1512
|
-
// Clamp to text length (in case cursor was in trimmed whitespace)
|
|
1513
|
-
if (adjustedCursorPos > chunk.text.length) {
|
|
1514
|
-
adjustedCursorPos = chunk.text.length;
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1590
|
+
if (isCurrentLine) {
|
|
1591
|
+
if (isLastChunk) {
|
|
1592
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
1593
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1594
|
+
} else {
|
|
1595
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
1596
|
+
if (hasCursorInChunk) {
|
|
1597
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1598
|
+
if (adjustedCursorPos > chunk.text.length) {
|
|
1599
|
+
adjustedCursorPos = chunk.text.length;
|
|
1517
1600
|
}
|
|
1518
1601
|
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1519
1604
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
layoutLines.push({
|
|
1530
|
-
text: displayChunkText,
|
|
1531
|
-
hasCursor: false,
|
|
1532
|
-
});
|
|
1533
|
-
hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
|
|
1534
|
-
while (hiddenWhitespaceWidth > contentWidth) {
|
|
1535
|
-
layoutLines.push({
|
|
1536
|
-
text: padding(contentWidth),
|
|
1537
|
-
hasCursor: false,
|
|
1538
|
-
});
|
|
1539
|
-
hiddenWhitespaceWidth -= contentWidth;
|
|
1540
|
-
}
|
|
1541
|
-
displayChunkText = padding(hiddenWhitespaceWidth);
|
|
1542
|
-
displayCursorPos = hiddenWhitespaceWidth;
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
layoutLines.push({
|
|
1546
|
-
text: displayChunkText,
|
|
1547
|
-
hasCursor: true,
|
|
1548
|
-
cursorPos: displayCursorPos,
|
|
1549
|
-
});
|
|
1605
|
+
if (hasCursorInChunk) {
|
|
1606
|
+
let displayChunkText = chunk.text;
|
|
1607
|
+
let displayCursorPos = adjustedCursorPos;
|
|
1608
|
+
if (displayCursorPos > displayChunkText.length) {
|
|
1609
|
+
let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
|
|
1610
|
+
const displayChunkWidth = visibleWidth(displayChunkText);
|
|
1611
|
+
__editorPerfCounters.visibleWidthMeasurements += 1;
|
|
1612
|
+
if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
|
|
1613
|
+
displayChunkText += padding(hiddenWhitespaceWidth);
|
|
1550
1614
|
} else {
|
|
1551
|
-
layoutLines.push(
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1615
|
+
layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, false));
|
|
1616
|
+
hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
|
|
1617
|
+
while (hiddenWhitespaceWidth > contentWidth) {
|
|
1618
|
+
layoutLines.push(this.#layoutLine(padding(contentWidth), lineIndex, false));
|
|
1619
|
+
hiddenWhitespaceWidth -= contentWidth;
|
|
1620
|
+
}
|
|
1621
|
+
displayChunkText = padding(hiddenWhitespaceWidth);
|
|
1622
|
+
displayCursorPos = hiddenWhitespaceWidth;
|
|
1555
1623
|
}
|
|
1556
1624
|
}
|
|
1625
|
+
layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, true, displayCursorPos));
|
|
1626
|
+
} else {
|
|
1627
|
+
layoutLines.push(this.#layoutLine(chunk.text, lineIndex, false));
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return layoutLines;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
#sameLayoutCacheKey(a: LayoutCacheKey, b: LayoutCacheKey): boolean {
|
|
1635
|
+
return a.docVersion === b.docVersion && a.contentWidth === b.contentWidth && a.optionsKey === b.optionsKey;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
#replaceCachedLogicalLine(cache: LayoutCache, lineIndex: number, contentWidth: number): void {
|
|
1639
|
+
const start = cache.lineStarts[lineIndex] ?? cache.lines.length;
|
|
1640
|
+
const oldCount = cache.lineCounts[lineIndex] ?? 0;
|
|
1641
|
+
const replacement = this.#layoutLogicalLine(lineIndex, contentWidth);
|
|
1642
|
+
cache.lines.splice(start, oldCount, ...replacement);
|
|
1643
|
+
cache.lineCounts[lineIndex] = replacement.length;
|
|
1644
|
+
const delta = replacement.length - oldCount;
|
|
1645
|
+
if (delta !== 0) {
|
|
1646
|
+
for (let i = lineIndex + 1; i < cache.lineStarts.length; i++) {
|
|
1647
|
+
cache.lineStarts[i] = (cache.lineStarts[i] ?? 0) + delta;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
#patchCursorInCachedLayout(cache: LayoutCache, contentWidth: number): LayoutLine[] {
|
|
1653
|
+
const previousLine = cache.cursorLine;
|
|
1654
|
+
const currentLine = this.#state.cursorLine;
|
|
1655
|
+
this.#replaceCachedLogicalLine(cache, previousLine, contentWidth);
|
|
1656
|
+
if (currentLine !== previousLine) {
|
|
1657
|
+
this.#replaceCachedLogicalLine(cache, currentLine, contentWidth);
|
|
1658
|
+
}
|
|
1659
|
+
cache.cursorLine = currentLine;
|
|
1660
|
+
cache.cursorCol = this.#state.cursorCol;
|
|
1661
|
+
return cache.lines;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
#layoutText(contentWidth: number): LayoutLine[] {
|
|
1665
|
+
__editorPerfCounters.layoutTextInvocations += 1;
|
|
1666
|
+
const key = this.#makeLayoutCacheKey(contentWidth);
|
|
1667
|
+
const cached = this.#layoutCache;
|
|
1668
|
+
if (cached && this.#sameLayoutCacheKey(cached.key, key)) {
|
|
1669
|
+
if (cached.cursorLine === this.#state.cursorLine && cached.cursorCol === this.#state.cursorCol) {
|
|
1670
|
+
return cached.lines;
|
|
1671
|
+
}
|
|
1672
|
+
return this.#patchCursorInCachedLayout(cached, contentWidth);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const layoutLines: LayoutLine[] = [];
|
|
1676
|
+
const lineStarts: number[] = [];
|
|
1677
|
+
const lineCounts: number[] = [];
|
|
1678
|
+
|
|
1679
|
+
if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
|
|
1680
|
+
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1681
|
+
lineStarts[0] = 0;
|
|
1682
|
+
lineCounts[0] = 1;
|
|
1683
|
+
layoutLines.push(this.#layoutLine("", 0, true, 0));
|
|
1684
|
+
} else {
|
|
1685
|
+
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
1686
|
+
lineStarts[i] = layoutLines.length;
|
|
1687
|
+
const logicalLayout = this.#layoutLogicalLine(i, contentWidth);
|
|
1688
|
+
lineCounts[i] = logicalLayout.length;
|
|
1689
|
+
layoutLines.push(...logicalLayout);
|
|
1557
1690
|
}
|
|
1558
1691
|
}
|
|
1559
1692
|
|
|
1560
1693
|
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1694
|
+
this.#layoutCache = {
|
|
1695
|
+
key,
|
|
1696
|
+
cursorLine: this.#state.cursorLine,
|
|
1697
|
+
cursorCol: this.#state.cursorCol,
|
|
1698
|
+
lines: layoutLines,
|
|
1699
|
+
lineStarts,
|
|
1700
|
+
lineCounts,
|
|
1701
|
+
};
|
|
1561
1702
|
return layoutLines;
|
|
1562
1703
|
}
|
|
1563
1704
|
|
|
@@ -1629,6 +1770,7 @@ export class Editor implements Component, Focusable {
|
|
|
1629
1770
|
this.#resetKillSequence();
|
|
1630
1771
|
this.#preferredVisualCol = null;
|
|
1631
1772
|
this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
|
|
1773
|
+
this.#bumpDocumentVersion();
|
|
1632
1774
|
this.#setCursorCol(transientStartCol);
|
|
1633
1775
|
|
|
1634
1776
|
while (true) {
|
|
@@ -1677,18 +1819,7 @@ export class Editor implements Component, Focusable {
|
|
|
1677
1819
|
/** Insert text at the current cursor position */
|
|
1678
1820
|
insertText(text: string): void {
|
|
1679
1821
|
this.#exitHistoryForEditing();
|
|
1680
|
-
this.#
|
|
1681
|
-
this.#recordUndoState();
|
|
1682
|
-
|
|
1683
|
-
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1684
|
-
const inserted = insertTextNfcAt(line, this.#state.cursorCol, text);
|
|
1685
|
-
|
|
1686
|
-
this.#state.lines[this.#state.cursorLine] = inserted.line;
|
|
1687
|
-
this.#setCursorCol(inserted.cursorCol);
|
|
1688
|
-
|
|
1689
|
-
if (this.onChange) {
|
|
1690
|
-
this.onChange(this.getText());
|
|
1691
|
-
}
|
|
1822
|
+
this.#insertTextAtCursor(text);
|
|
1692
1823
|
}
|
|
1693
1824
|
|
|
1694
1825
|
// All the editor methods from before...
|
|
@@ -1779,49 +1910,48 @@ export class Editor implements Component, Focusable {
|
|
|
1779
1910
|
#handlePaste(pastedText: string): void {
|
|
1780
1911
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1781
1912
|
this.#resetKillSequence();
|
|
1782
|
-
this.#
|
|
1913
|
+
const hadAutocomplete = this.#autocompleteState !== null;
|
|
1914
|
+
this.#cancelAutocomplete();
|
|
1915
|
+
if (hadAutocomplete) {
|
|
1916
|
+
this.onAutocompleteUpdate?.();
|
|
1917
|
+
}
|
|
1783
1918
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
const
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
});
|
|
1919
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
1920
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
1921
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
1922
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
1923
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
1924
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
1925
|
+
const cp = Number(code);
|
|
1926
|
+
if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
|
|
1927
|
+
if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
|
|
1928
|
+
return match;
|
|
1929
|
+
});
|
|
1796
1930
|
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
1816
|
-
// the cursor is a word character, prepend a space for better readability
|
|
1817
|
-
if (/^[/~.]/.test(filteredText)) {
|
|
1818
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1819
|
-
const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
|
|
1820
|
-
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1821
|
-
filteredText = ` ${filteredText}`;
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1931
|
+
// Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
|
|
1932
|
+
// Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
|
|
1933
|
+
// land in the buffer as the same precomposed syllables a terminal
|
|
1934
|
+
// renders — without this, cursor column accounting drifts by
|
|
1935
|
+
// `(NFD cells − NFC cells)` and the visible glyph desyncs from the
|
|
1936
|
+
// hardware cursor. Matches the `Input` component's prior fix; this
|
|
1937
|
+
// is the same fix on the real GJC prompt component (`Editor`).
|
|
1938
|
+
const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
|
|
1939
|
+
|
|
1940
|
+
// Convert tabs to spaces (4 spaces per tab)
|
|
1941
|
+
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1942
|
+
|
|
1943
|
+
// Filter out non-printable characters except newlines
|
|
1944
|
+
const filteredText = tabExpandedText
|
|
1945
|
+
.split("")
|
|
1946
|
+
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1947
|
+
.join("");
|
|
1824
1948
|
|
|
1949
|
+
// Nothing survived filtering: the buffer is untouched, so don't record
|
|
1950
|
+
// an undo snapshot — a no-op entry would make the next undo appear dead.
|
|
1951
|
+
if (filteredText.length === 0) return;
|
|
1952
|
+
|
|
1953
|
+
this.#recordUndoState();
|
|
1954
|
+
this.#withUndoSuspended(() => {
|
|
1825
1955
|
// Split into lines
|
|
1826
1956
|
const pastedLines = filteredText.split("\n");
|
|
1827
1957
|
|
|
@@ -1843,15 +1973,10 @@ export class Editor implements Component, Focusable {
|
|
|
1843
1973
|
return;
|
|
1844
1974
|
}
|
|
1845
1975
|
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
}
|
|
1851
|
-
return;
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
// Multi-line paste - use insertTextAtCursor for proper handling
|
|
1976
|
+
// Paste is literal input, not typed input. Insert atomically so leading
|
|
1977
|
+
// trigger characters such as "/", "#", "@", ":", or path-like text do
|
|
1978
|
+
// not open or update autocomplete lists while preserving normal typed
|
|
1979
|
+
// trigger behavior.
|
|
1855
1980
|
this.#insertTextAtCursor(filteredText);
|
|
1856
1981
|
});
|
|
1857
1982
|
}
|
|
@@ -1896,6 +2021,7 @@ export class Editor implements Component, Focusable {
|
|
|
1896
2021
|
const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
|
|
1897
2022
|
|
|
1898
2023
|
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
2024
|
+
this.#bumpDocumentVersion();
|
|
1899
2025
|
this.#pastes.clear();
|
|
1900
2026
|
this.#pasteCounter = 0;
|
|
1901
2027
|
this.#historyIndex = -1;
|
|
@@ -2087,6 +2213,7 @@ export class Editor implements Component, Focusable {
|
|
|
2087
2213
|
#recordUndoState(): void {
|
|
2088
2214
|
if (this.#suspendUndo) return;
|
|
2089
2215
|
this.#undoStack.push(structuredClone(this.#state));
|
|
2216
|
+
this.#bumpDocumentVersion();
|
|
2090
2217
|
}
|
|
2091
2218
|
|
|
2092
2219
|
#applyUndo(): void {
|
|
@@ -2097,6 +2224,7 @@ export class Editor implements Component, Focusable {
|
|
|
2097
2224
|
this.#resetKillSequence();
|
|
2098
2225
|
this.#preferredVisualCol = null;
|
|
2099
2226
|
Object.assign(this.#state, snapshot);
|
|
2227
|
+
this.#bumpDocumentVersion();
|
|
2100
2228
|
|
|
2101
2229
|
if (this.onChange) {
|
|
2102
2230
|
this.onChange(this.getText());
|
|
@@ -2800,6 +2928,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2800
2928
|
);
|
|
2801
2929
|
|
|
2802
2930
|
this.#state.lines = result.lines;
|
|
2931
|
+
this.#bumpDocumentVersion();
|
|
2803
2932
|
this.#state.cursorLine = result.cursorLine;
|
|
2804
2933
|
this.#setCursorCol(result.cursorCol);
|
|
2805
2934
|
|