@gajae-code/tui 0.8.1 → 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 +9 -0
- package/dist/types/animation-scheduler.d.ts +13 -0
- package/dist/types/autocomplete.d.ts +1 -0
- package/dist/types/components/editor.d.ts +11 -2
- 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/autocomplete.ts +119 -96
- package/src/components/editor.ts +283 -146
- 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 +227 -78
- package/src/utils.ts +71 -9
package/src/components/editor.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { getProjectDir, logger } from "@gajae-code/utils";
|
|
2
|
-
import
|
|
1
|
+
import { getProjectDir, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
|
|
2
|
+
import {
|
|
3
|
+
type AutocompleteProvider,
|
|
4
|
+
type CombinedAutocompleteProvider,
|
|
5
|
+
extractSlashCommandTokenPrefix,
|
|
6
|
+
} from "../autocomplete";
|
|
3
7
|
import { BracketedPasteHandler } from "../bracketed-paste";
|
|
4
8
|
import { getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
5
9
|
import { extractPrintableText, matchesKey } from "../keys";
|
|
@@ -340,10 +344,27 @@ interface EditorState {
|
|
|
340
344
|
|
|
341
345
|
interface LayoutLine {
|
|
342
346
|
text: string;
|
|
347
|
+
visibleWidth: number;
|
|
348
|
+
logicalLine: number;
|
|
343
349
|
hasCursor: boolean;
|
|
344
350
|
cursorPos?: number;
|
|
345
351
|
}
|
|
346
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
|
+
|
|
347
368
|
export interface EditorTheme {
|
|
348
369
|
borderColor: (str: string) => string;
|
|
349
370
|
selectList: SelectListTheme;
|
|
@@ -373,6 +394,18 @@ interface HistoryStorage {
|
|
|
373
394
|
|
|
374
395
|
type HistoryCursorAnchor = "start" | "end";
|
|
375
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
|
+
|
|
376
409
|
export class Editor implements Component, Focusable {
|
|
377
410
|
#state: EditorState = {
|
|
378
411
|
lines: [""],
|
|
@@ -400,9 +433,12 @@ export class Editor implements Component, Focusable {
|
|
|
400
433
|
// Store last layout width for cursor navigation
|
|
401
434
|
#lastLayoutWidth: number = 80;
|
|
402
435
|
#paddingXOverride: number | undefined;
|
|
436
|
+
#rightGutterWidth = 0;
|
|
403
437
|
#maxHeight?: number;
|
|
404
438
|
#scrollOffset: number = 0;
|
|
405
439
|
#wrappedLineCache: CachedWrappedLine[] = [];
|
|
440
|
+
#docVersion = 0;
|
|
441
|
+
#layoutCache: LayoutCache | undefined;
|
|
406
442
|
|
|
407
443
|
// Emacs-style kill ring
|
|
408
444
|
#killRing = new KillRing();
|
|
@@ -463,9 +499,25 @@ export class Editor implements Component, Focusable {
|
|
|
463
499
|
#borderStyle: EditorBorderStyle = "round";
|
|
464
500
|
#closedBorderBox = false;
|
|
465
501
|
|
|
502
|
+
#disposeTabWidthListener?: () => void;
|
|
503
|
+
|
|
466
504
|
constructor(theme: EditorTheme) {
|
|
467
505
|
this.#theme = theme;
|
|
468
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;
|
|
469
521
|
}
|
|
470
522
|
|
|
471
523
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
@@ -487,6 +539,7 @@ export class Editor implements Component, Focusable {
|
|
|
487
539
|
*/
|
|
488
540
|
setTopBorder(content: EditorTopBorder | undefined): void {
|
|
489
541
|
this.#topBorderContent = content;
|
|
542
|
+
this.#invalidateLayoutCache();
|
|
490
543
|
}
|
|
491
544
|
|
|
492
545
|
/**
|
|
@@ -494,38 +547,46 @@ export class Editor implements Component, Focusable {
|
|
|
494
547
|
*/
|
|
495
548
|
setBorderVisible(borderVisible: boolean): void {
|
|
496
549
|
this.#borderVisible = borderVisible;
|
|
550
|
+
this.#invalidateLayoutCache();
|
|
497
551
|
}
|
|
498
552
|
|
|
499
553
|
setBorderStyle(borderStyle: EditorBorderStyle): void {
|
|
500
554
|
this.#borderStyle = borderStyle;
|
|
555
|
+
this.#invalidateLayoutCache();
|
|
501
556
|
}
|
|
502
557
|
|
|
503
558
|
setClosedBorderBox(closedBorderBox: boolean): void {
|
|
504
559
|
this.#closedBorderBox = closedBorderBox;
|
|
560
|
+
this.#invalidateLayoutCache();
|
|
505
561
|
}
|
|
506
562
|
|
|
507
563
|
setPromptGutter(promptGutter: string | undefined): void {
|
|
508
564
|
this.#promptGutter = promptGutter;
|
|
565
|
+
this.#invalidateLayoutCache();
|
|
509
566
|
}
|
|
510
567
|
|
|
511
568
|
setInputPrefix(inputPrefix: string | undefined): void {
|
|
512
569
|
this.#inputPrefix = inputPrefix;
|
|
513
570
|
this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
|
|
571
|
+
this.#invalidateLayoutCache();
|
|
514
572
|
}
|
|
515
573
|
|
|
516
574
|
setPlaceholder(placeholder: string | undefined): void {
|
|
517
575
|
const trimmed = placeholder?.trim();
|
|
518
576
|
this.#placeholder = trimmed ? trimmed : undefined;
|
|
577
|
+
this.#invalidateLayoutCache();
|
|
519
578
|
}
|
|
520
579
|
|
|
521
580
|
/**
|
|
522
581
|
* Get the available width for top border content given a total terminal width.
|
|
523
|
-
* Accounts for
|
|
582
|
+
* Accounts for right gutter, border characters, and horizontal padding when visible.
|
|
524
583
|
*/
|
|
525
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);
|
|
526
587
|
const paddingX = this.#getEditorPaddingX();
|
|
527
588
|
const borderWidth = this.#getHorizontalChromeWidth(paddingX);
|
|
528
|
-
return Math.max(0,
|
|
589
|
+
return Math.max(0, renderWidth - borderWidth * 2);
|
|
529
590
|
}
|
|
530
591
|
|
|
531
592
|
/**
|
|
@@ -533,6 +594,7 @@ export class Editor implements Component, Focusable {
|
|
|
533
594
|
*/
|
|
534
595
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
535
596
|
this.#useTerminalCursor = useTerminalCursor;
|
|
597
|
+
this.#invalidateLayoutCache();
|
|
536
598
|
}
|
|
537
599
|
|
|
538
600
|
getUseTerminalCursor(): boolean {
|
|
@@ -547,6 +609,11 @@ export class Editor implements Component, Focusable {
|
|
|
547
609
|
|
|
548
610
|
setPaddingX(paddingX: number): void {
|
|
549
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;
|
|
550
617
|
}
|
|
551
618
|
|
|
552
619
|
getAutocompleteMaxVisible(): number {
|
|
@@ -636,10 +703,21 @@ export class Editor implements Component, Focusable {
|
|
|
636
703
|
this.onChange(this.getText());
|
|
637
704
|
}
|
|
638
705
|
this.#wrappedLineCache.length = 0;
|
|
706
|
+
this.#bumpDocumentVersion();
|
|
639
707
|
}
|
|
640
708
|
|
|
641
709
|
invalidate(): void {
|
|
642
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;
|
|
643
721
|
}
|
|
644
722
|
|
|
645
723
|
#getEditorPaddingX(): number {
|
|
@@ -785,12 +863,14 @@ export class Editor implements Component, Focusable {
|
|
|
785
863
|
}
|
|
786
864
|
|
|
787
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);
|
|
788
868
|
const paddingX = this.#getEditorPaddingX();
|
|
789
869
|
const borderVisible = this.#borderVisible;
|
|
790
|
-
const promptGutter = this.#getPromptGutter(
|
|
791
|
-
const contentAreaWidth = this.#getContentWidth(
|
|
870
|
+
const promptGutter = this.#getPromptGutter(renderWidth, paddingX);
|
|
871
|
+
const contentAreaWidth = this.#getContentWidth(renderWidth, paddingX);
|
|
792
872
|
const inputPrefixWidth = this.#inputPrefixWidth;
|
|
793
|
-
const layoutWidth = Math.max(1, this.#getLayoutWidth(
|
|
873
|
+
const layoutWidth = Math.max(1, this.#getLayoutWidth(renderWidth, paddingX) - inputPrefixWidth);
|
|
794
874
|
this.#lastLayoutWidth = layoutWidth;
|
|
795
875
|
|
|
796
876
|
// Box-drawing characters for the configured input box shape.
|
|
@@ -811,7 +891,7 @@ export class Editor implements Component, Focusable {
|
|
|
811
891
|
|
|
812
892
|
if (borderVisible) {
|
|
813
893
|
// Render top border: ╭─ [status content] ────────────────╮
|
|
814
|
-
const topFillWidth = Math.max(0,
|
|
894
|
+
const topFillWidth = Math.max(0, renderWidth - borderWidth * 2);
|
|
815
895
|
if (this.#topBorderContent) {
|
|
816
896
|
const { content, width: statusWidth } = this.#topBorderContent;
|
|
817
897
|
if (statusWidth <= topFillWidth) {
|
|
@@ -843,7 +923,7 @@ export class Editor implements Component, Focusable {
|
|
|
843
923
|
for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
|
|
844
924
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
845
925
|
let displayText = layoutLine.text;
|
|
846
|
-
let displayWidth =
|
|
926
|
+
let displayWidth = layoutLine.visibleWidth;
|
|
847
927
|
let cursorInPadding = false;
|
|
848
928
|
const absoluteVisibleIndex = this.#scrollOffset + visibleIndex;
|
|
849
929
|
const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
|
|
@@ -1016,7 +1096,7 @@ export class Editor implements Component, Focusable {
|
|
|
1016
1096
|
}
|
|
1017
1097
|
|
|
1018
1098
|
if (borderVisible && this.#closedBorderBox) {
|
|
1019
|
-
const bottomFillWidth = Math.max(0,
|
|
1099
|
+
const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
|
|
1020
1100
|
const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
|
|
1021
1101
|
const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
|
|
1022
1102
|
result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
|
|
@@ -1024,10 +1104,15 @@ export class Editor implements Component, Focusable {
|
|
|
1024
1104
|
|
|
1025
1105
|
// Add autocomplete list if active
|
|
1026
1106
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1027
|
-
const autocompleteResult = this.#autocompleteList.render(
|
|
1107
|
+
const autocompleteResult = this.#autocompleteList.render(renderWidth);
|
|
1028
1108
|
result.push(...autocompleteResult);
|
|
1029
1109
|
}
|
|
1030
1110
|
|
|
1111
|
+
if (rightGutterWidth > 0) {
|
|
1112
|
+
const rightGutter = padding(rightGutterWidth);
|
|
1113
|
+
return result.map(line => line + rightGutter);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1031
1116
|
return result;
|
|
1032
1117
|
}
|
|
1033
1118
|
|
|
@@ -1128,6 +1213,7 @@ export class Editor implements Component, Focusable {
|
|
|
1128
1213
|
);
|
|
1129
1214
|
|
|
1130
1215
|
this.#state.lines = result.lines;
|
|
1216
|
+
this.#bumpDocumentVersion();
|
|
1131
1217
|
this.#state.cursorLine = result.cursorLine;
|
|
1132
1218
|
this.#setCursorCol(result.cursorCol);
|
|
1133
1219
|
|
|
@@ -1167,6 +1253,7 @@ export class Editor implements Component, Focusable {
|
|
|
1167
1253
|
);
|
|
1168
1254
|
|
|
1169
1255
|
this.#state.lines = result.lines;
|
|
1256
|
+
this.#bumpDocumentVersion();
|
|
1170
1257
|
this.#state.cursorLine = result.cursorLine;
|
|
1171
1258
|
this.#setCursorCol(result.cursorCol);
|
|
1172
1259
|
result.onApplied?.();
|
|
@@ -1188,6 +1275,7 @@ export class Editor implements Component, Focusable {
|
|
|
1188
1275
|
);
|
|
1189
1276
|
|
|
1190
1277
|
this.#state.lines = result.lines;
|
|
1278
|
+
this.#bumpDocumentVersion();
|
|
1191
1279
|
this.#state.cursorLine = result.cursorLine;
|
|
1192
1280
|
this.#setCursorCol(result.cursorCol);
|
|
1193
1281
|
|
|
@@ -1308,6 +1396,7 @@ export class Editor implements Component, Focusable {
|
|
|
1308
1396
|
syncResult.prefix,
|
|
1309
1397
|
);
|
|
1310
1398
|
this.#state.lines = result.lines;
|
|
1399
|
+
this.#bumpDocumentVersion();
|
|
1311
1400
|
this.#state.cursorLine = result.cursorLine;
|
|
1312
1401
|
this.#setCursorCol(result.cursorCol);
|
|
1313
1402
|
result.onApplied?.();
|
|
@@ -1441,119 +1530,175 @@ export class Editor implements Component, Focusable {
|
|
|
1441
1530
|
return this.#wrappedLineCache.length;
|
|
1442
1531
|
}
|
|
1443
1532
|
|
|
1444
|
-
#
|
|
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);
|
|
1445
1571
|
const layoutLines: LayoutLine[] = [];
|
|
1446
1572
|
|
|
1447
|
-
if (
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1452
|
-
layoutLines.push({
|
|
1453
|
-
text: "",
|
|
1454
|
-
hasCursor: true,
|
|
1455
|
-
cursorPos: 0,
|
|
1456
|
-
});
|
|
1573
|
+
if (wrappedLine.width <= contentWidth) {
|
|
1574
|
+
layoutLines.push(
|
|
1575
|
+
this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
|
|
1576
|
+
);
|
|
1457
1577
|
return layoutLines;
|
|
1458
1578
|
}
|
|
1459
1579
|
|
|
1460
|
-
|
|
1461
|
-
for (let
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1464
|
-
const wrappedLine = this.#getWrappedLine(i, contentWidth);
|
|
1465
|
-
|
|
1466
|
-
if (wrappedLine.width <= contentWidth) {
|
|
1467
|
-
// Line fits in one layout line
|
|
1468
|
-
if (isCurrentLine) {
|
|
1469
|
-
layoutLines.push({
|
|
1470
|
-
text: line,
|
|
1471
|
-
hasCursor: true,
|
|
1472
|
-
cursorPos: this.#state.cursorCol,
|
|
1473
|
-
});
|
|
1474
|
-
} else {
|
|
1475
|
-
layoutLines.push({
|
|
1476
|
-
text: line,
|
|
1477
|
-
hasCursor: false,
|
|
1478
|
-
});
|
|
1479
|
-
}
|
|
1480
|
-
} else {
|
|
1481
|
-
// Line needs wrapping - use word-aware wrapping
|
|
1482
|
-
const chunks = wrappedLine.chunks;
|
|
1580
|
+
const chunks = wrappedLine.chunks;
|
|
1581
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1582
|
+
const chunk = chunks[chunkIndex];
|
|
1583
|
+
if (!chunk) continue;
|
|
1483
1584
|
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1585
|
+
const cursorPos = this.#state.cursorCol;
|
|
1586
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
1587
|
+
let hasCursorInChunk = false;
|
|
1588
|
+
let adjustedCursorPos = 0;
|
|
1487
1589
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
if (isLastChunk) {
|
|
1499
|
-
// Last chunk: cursor belongs here if >= startIndex
|
|
1500
|
-
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
1501
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1502
|
-
} else {
|
|
1503
|
-
// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
|
|
1504
|
-
// But we need to handle the visual position in the trimmed text
|
|
1505
|
-
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
1506
|
-
if (hasCursorInChunk) {
|
|
1507
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1508
|
-
// Clamp to text length (in case cursor was in trimmed whitespace)
|
|
1509
|
-
if (adjustedCursorPos > chunk.text.length) {
|
|
1510
|
-
adjustedCursorPos = chunk.text.length;
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
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;
|
|
1513
1600
|
}
|
|
1514
1601
|
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1515
1604
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
layoutLines.push({
|
|
1526
|
-
text: displayChunkText,
|
|
1527
|
-
hasCursor: false,
|
|
1528
|
-
});
|
|
1529
|
-
hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
|
|
1530
|
-
while (hiddenWhitespaceWidth > contentWidth) {
|
|
1531
|
-
layoutLines.push({
|
|
1532
|
-
text: padding(contentWidth),
|
|
1533
|
-
hasCursor: false,
|
|
1534
|
-
});
|
|
1535
|
-
hiddenWhitespaceWidth -= contentWidth;
|
|
1536
|
-
}
|
|
1537
|
-
displayChunkText = padding(hiddenWhitespaceWidth);
|
|
1538
|
-
displayCursorPos = hiddenWhitespaceWidth;
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
layoutLines.push({
|
|
1542
|
-
text: displayChunkText,
|
|
1543
|
-
hasCursor: true,
|
|
1544
|
-
cursorPos: displayCursorPos,
|
|
1545
|
-
});
|
|
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);
|
|
1546
1614
|
} else {
|
|
1547
|
-
layoutLines.push(
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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;
|
|
1551
1623
|
}
|
|
1552
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);
|
|
1553
1690
|
}
|
|
1554
1691
|
}
|
|
1555
1692
|
|
|
1556
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
|
+
};
|
|
1557
1702
|
return layoutLines;
|
|
1558
1703
|
}
|
|
1559
1704
|
|
|
@@ -1625,6 +1770,7 @@ export class Editor implements Component, Focusable {
|
|
|
1625
1770
|
this.#resetKillSequence();
|
|
1626
1771
|
this.#preferredVisualCol = null;
|
|
1627
1772
|
this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
|
|
1773
|
+
this.#bumpDocumentVersion();
|
|
1628
1774
|
this.#setCursorCol(transientStartCol);
|
|
1629
1775
|
|
|
1630
1776
|
while (true) {
|
|
@@ -1673,18 +1819,7 @@ export class Editor implements Component, Focusable {
|
|
|
1673
1819
|
/** Insert text at the current cursor position */
|
|
1674
1820
|
insertText(text: string): void {
|
|
1675
1821
|
this.#exitHistoryForEditing();
|
|
1676
|
-
this.#
|
|
1677
|
-
this.#recordUndoState();
|
|
1678
|
-
|
|
1679
|
-
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1680
|
-
const inserted = insertTextNfcAt(line, this.#state.cursorCol, text);
|
|
1681
|
-
|
|
1682
|
-
this.#state.lines[this.#state.cursorLine] = inserted.line;
|
|
1683
|
-
this.#setCursorCol(inserted.cursorCol);
|
|
1684
|
-
|
|
1685
|
-
if (this.onChange) {
|
|
1686
|
-
this.onChange(this.getText());
|
|
1687
|
-
}
|
|
1822
|
+
this.#insertTextAtCursor(text);
|
|
1688
1823
|
}
|
|
1689
1824
|
|
|
1690
1825
|
// All the editor methods from before...
|
|
@@ -1728,9 +1863,8 @@ export class Editor implements Component, Focusable {
|
|
|
1728
1863
|
|
|
1729
1864
|
// Check if we should trigger or update autocomplete
|
|
1730
1865
|
if (!this.#autocompleteState) {
|
|
1731
|
-
// Auto-trigger for
|
|
1732
|
-
|
|
1733
|
-
if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
|
|
1866
|
+
// Auto-trigger for slash command tokens.
|
|
1867
|
+
if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
|
|
1734
1868
|
this.#tryTriggerAutocomplete();
|
|
1735
1869
|
}
|
|
1736
1870
|
// Auto-trigger for "@" file reference (fuzzy search)
|
|
@@ -1777,6 +1911,11 @@ export class Editor implements Component, Focusable {
|
|
|
1777
1911
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1778
1912
|
this.#resetKillSequence();
|
|
1779
1913
|
this.#recordUndoState();
|
|
1914
|
+
const hadAutocomplete = this.#autocompleteState !== null;
|
|
1915
|
+
this.#cancelAutocomplete();
|
|
1916
|
+
if (hadAutocomplete) {
|
|
1917
|
+
this.onAutocompleteUpdate?.();
|
|
1918
|
+
}
|
|
1780
1919
|
|
|
1781
1920
|
this.#withUndoSuspended(() => {
|
|
1782
1921
|
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
@@ -1804,21 +1943,12 @@ export class Editor implements Component, Focusable {
|
|
|
1804
1943
|
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1805
1944
|
|
|
1806
1945
|
// Filter out non-printable characters except newlines
|
|
1807
|
-
|
|
1946
|
+
const filteredText = tabExpandedText
|
|
1808
1947
|
.split("")
|
|
1809
1948
|
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1810
1949
|
.join("");
|
|
1811
1950
|
|
|
1812
|
-
|
|
1813
|
-
// the cursor is a word character, prepend a space for better readability
|
|
1814
|
-
if (/^[/~.]/.test(filteredText)) {
|
|
1815
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1816
|
-
const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
|
|
1817
|
-
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1818
|
-
filteredText = ` ${filteredText}`;
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1951
|
+
if (filteredText.length === 0) return;
|
|
1822
1952
|
// Split into lines
|
|
1823
1953
|
const pastedLines = filteredText.split("\n");
|
|
1824
1954
|
|
|
@@ -1840,15 +1970,10 @@ export class Editor implements Component, Focusable {
|
|
|
1840
1970
|
return;
|
|
1841
1971
|
}
|
|
1842
1972
|
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
}
|
|
1848
|
-
return;
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
// 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.
|
|
1852
1977
|
this.#insertTextAtCursor(filteredText);
|
|
1853
1978
|
});
|
|
1854
1979
|
}
|
|
@@ -1893,6 +2018,7 @@ export class Editor implements Component, Focusable {
|
|
|
1893
2018
|
const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
|
|
1894
2019
|
|
|
1895
2020
|
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
2021
|
+
this.#bumpDocumentVersion();
|
|
1896
2022
|
this.#pastes.clear();
|
|
1897
2023
|
this.#pasteCounter = 0;
|
|
1898
2024
|
this.#historyIndex = -1;
|
|
@@ -2084,6 +2210,7 @@ export class Editor implements Component, Focusable {
|
|
|
2084
2210
|
#recordUndoState(): void {
|
|
2085
2211
|
if (this.#suspendUndo) return;
|
|
2086
2212
|
this.#undoStack.push(structuredClone(this.#state));
|
|
2213
|
+
this.#bumpDocumentVersion();
|
|
2087
2214
|
}
|
|
2088
2215
|
|
|
2089
2216
|
#applyUndo(): void {
|
|
@@ -2094,6 +2221,7 @@ export class Editor implements Component, Focusable {
|
|
|
2094
2221
|
this.#resetKillSequence();
|
|
2095
2222
|
this.#preferredVisualCol = null;
|
|
2096
2223
|
Object.assign(this.#state, snapshot);
|
|
2224
|
+
this.#bumpDocumentVersion();
|
|
2097
2225
|
|
|
2098
2226
|
if (this.onChange) {
|
|
2099
2227
|
this.onChange(this.getText());
|
|
@@ -2599,6 +2727,12 @@ export class Editor implements Component, Focusable {
|
|
|
2599
2727
|
: this.#state.cursorCol - 1
|
|
2600
2728
|
: undefined;
|
|
2601
2729
|
|
|
2730
|
+
// Backward from column 0: lastIndexOf clamps a negative position to 0,
|
|
2731
|
+
// which would match the character under the cursor instead of skipping it
|
|
2732
|
+
if (!isForward && searchFrom !== undefined && searchFrom < 0) {
|
|
2733
|
+
continue;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2602
2736
|
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
|
|
2603
2737
|
|
|
2604
2738
|
if (idx !== -1) {
|
|
@@ -2651,12 +2785,11 @@ export class Editor implements Component, Focusable {
|
|
|
2651
2785
|
#getSlashTokenBeforeCursor(): string | null {
|
|
2652
2786
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2653
2787
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2654
|
-
|
|
2655
|
-
return match?.[1] ?? null;
|
|
2788
|
+
return extractSlashCommandTokenPrefix(beforeCursor);
|
|
2656
2789
|
}
|
|
2657
2790
|
|
|
2658
2791
|
#isInSlashTokenContext(): boolean {
|
|
2659
|
-
return this.#getSlashTokenBeforeCursor()
|
|
2792
|
+
return this.#getSlashTokenBeforeCursor() !== null;
|
|
2660
2793
|
}
|
|
2661
2794
|
|
|
2662
2795
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
@@ -2740,7 +2873,10 @@ export class Editor implements Component, Focusable {
|
|
|
2740
2873
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2741
2874
|
|
|
2742
2875
|
// Check if we're in a slash command context
|
|
2743
|
-
if (
|
|
2876
|
+
if (
|
|
2877
|
+
(this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
|
|
2878
|
+
this.#isInSlashTokenContext()
|
|
2879
|
+
) {
|
|
2744
2880
|
this.#handleSlashCommandCompletion();
|
|
2745
2881
|
} else {
|
|
2746
2882
|
this.#forceFileAutocomplete(true);
|
|
@@ -2789,6 +2925,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2789
2925
|
);
|
|
2790
2926
|
|
|
2791
2927
|
this.#state.lines = result.lines;
|
|
2928
|
+
this.#bumpDocumentVersion();
|
|
2792
2929
|
this.#state.cursorLine = result.cursorLine;
|
|
2793
2930
|
this.#setCursorCol(result.cursorCol);
|
|
2794
2931
|
|