@gajae-code/tui 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -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/tui.d.ts +13 -4
- package/dist/types/utils.d.ts +12 -0
- package/package.json +3 -3
- package/src/animation-scheduler.ts +99 -0
- package/src/components/editor.ts +264 -138
- 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/tui.ts +206 -77
- 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...
|
|
@@ -1780,6 +1911,11 @@ export class Editor implements Component, Focusable {
|
|
|
1780
1911
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1781
1912
|
this.#resetKillSequence();
|
|
1782
1913
|
this.#recordUndoState();
|
|
1914
|
+
const hadAutocomplete = this.#autocompleteState !== null;
|
|
1915
|
+
this.#cancelAutocomplete();
|
|
1916
|
+
if (hadAutocomplete) {
|
|
1917
|
+
this.onAutocompleteUpdate?.();
|
|
1918
|
+
}
|
|
1783
1919
|
|
|
1784
1920
|
this.#withUndoSuspended(() => {
|
|
1785
1921
|
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
@@ -1807,21 +1943,12 @@ export class Editor implements Component, Focusable {
|
|
|
1807
1943
|
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1808
1944
|
|
|
1809
1945
|
// Filter out non-printable characters except newlines
|
|
1810
|
-
|
|
1946
|
+
const filteredText = tabExpandedText
|
|
1811
1947
|
.split("")
|
|
1812
1948
|
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1813
1949
|
.join("");
|
|
1814
1950
|
|
|
1815
|
-
|
|
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
|
-
}
|
|
1824
|
-
|
|
1951
|
+
if (filteredText.length === 0) return;
|
|
1825
1952
|
// Split into lines
|
|
1826
1953
|
const pastedLines = filteredText.split("\n");
|
|
1827
1954
|
|
|
@@ -1843,15 +1970,10 @@ export class Editor implements Component, Focusable {
|
|
|
1843
1970
|
return;
|
|
1844
1971
|
}
|
|
1845
1972
|
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
}
|
|
1851
|
-
return;
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
// Multi-line paste - use insertTextAtCursor for proper handling
|
|
1973
|
+
// Paste is literal input, not typed input. Insert atomically so leading
|
|
1974
|
+
// trigger characters such as "/", "#", "@", ":", or path-like text do
|
|
1975
|
+
// not open or update autocomplete lists while preserving normal typed
|
|
1976
|
+
// trigger behavior.
|
|
1855
1977
|
this.#insertTextAtCursor(filteredText);
|
|
1856
1978
|
});
|
|
1857
1979
|
}
|
|
@@ -1896,6 +2018,7 @@ export class Editor implements Component, Focusable {
|
|
|
1896
2018
|
const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
|
|
1897
2019
|
|
|
1898
2020
|
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
2021
|
+
this.#bumpDocumentVersion();
|
|
1899
2022
|
this.#pastes.clear();
|
|
1900
2023
|
this.#pasteCounter = 0;
|
|
1901
2024
|
this.#historyIndex = -1;
|
|
@@ -2087,6 +2210,7 @@ export class Editor implements Component, Focusable {
|
|
|
2087
2210
|
#recordUndoState(): void {
|
|
2088
2211
|
if (this.#suspendUndo) return;
|
|
2089
2212
|
this.#undoStack.push(structuredClone(this.#state));
|
|
2213
|
+
this.#bumpDocumentVersion();
|
|
2090
2214
|
}
|
|
2091
2215
|
|
|
2092
2216
|
#applyUndo(): void {
|
|
@@ -2097,6 +2221,7 @@ export class Editor implements Component, Focusable {
|
|
|
2097
2221
|
this.#resetKillSequence();
|
|
2098
2222
|
this.#preferredVisualCol = null;
|
|
2099
2223
|
Object.assign(this.#state, snapshot);
|
|
2224
|
+
this.#bumpDocumentVersion();
|
|
2100
2225
|
|
|
2101
2226
|
if (this.onChange) {
|
|
2102
2227
|
this.onChange(this.getText());
|
|
@@ -2800,6 +2925,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2800
2925
|
);
|
|
2801
2926
|
|
|
2802
2927
|
this.#state.lines = result.lines;
|
|
2928
|
+
this.#bumpDocumentVersion();
|
|
2803
2929
|
this.#state.cursorLine = result.cursorLine;
|
|
2804
2930
|
this.#setCursorCol(result.cursorCol);
|
|
2805
2931
|
|