@sayknow-cli/tui 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +4 -4
- 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 +77 -11
package/src/components/editor.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { getProjectDir, logger } from "@sayknow-cli/utils";
|
|
2
|
-
import
|
|
1
|
+
import { getProjectDir, logger, onDefaultTabWidthChange } from "@sayknow-cli/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;
|
|
@@ -1020,7 +1100,7 @@ export class Editor implements Component, Focusable {
|
|
|
1020
1100
|
}
|
|
1021
1101
|
|
|
1022
1102
|
if (borderVisible && this.#closedBorderBox) {
|
|
1023
|
-
const bottomFillWidth = Math.max(0,
|
|
1103
|
+
const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
|
|
1024
1104
|
const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
|
|
1025
1105
|
const bottomRightClosed = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.bottomRight}`);
|
|
1026
1106
|
result.push(bottomLeftClosed + horizontal.repeat(bottomFillWidth) + bottomRightClosed);
|
|
@@ -1028,10 +1108,15 @@ export class Editor implements Component, Focusable {
|
|
|
1028
1108
|
|
|
1029
1109
|
// Add autocomplete list if active
|
|
1030
1110
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1031
|
-
const autocompleteResult = this.#autocompleteList.render(
|
|
1111
|
+
const autocompleteResult = this.#autocompleteList.render(renderWidth);
|
|
1032
1112
|
result.push(...autocompleteResult);
|
|
1033
1113
|
}
|
|
1034
1114
|
|
|
1115
|
+
if (rightGutterWidth > 0) {
|
|
1116
|
+
const rightGutter = padding(rightGutterWidth);
|
|
1117
|
+
return result.map(line => line + rightGutter);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1035
1120
|
return result;
|
|
1036
1121
|
}
|
|
1037
1122
|
|
|
@@ -1132,6 +1217,7 @@ export class Editor implements Component, Focusable {
|
|
|
1132
1217
|
);
|
|
1133
1218
|
|
|
1134
1219
|
this.#state.lines = result.lines;
|
|
1220
|
+
this.#bumpDocumentVersion();
|
|
1135
1221
|
this.#state.cursorLine = result.cursorLine;
|
|
1136
1222
|
this.#setCursorCol(result.cursorCol);
|
|
1137
1223
|
|
|
@@ -1171,6 +1257,7 @@ export class Editor implements Component, Focusable {
|
|
|
1171
1257
|
);
|
|
1172
1258
|
|
|
1173
1259
|
this.#state.lines = result.lines;
|
|
1260
|
+
this.#bumpDocumentVersion();
|
|
1174
1261
|
this.#state.cursorLine = result.cursorLine;
|
|
1175
1262
|
this.#setCursorCol(result.cursorCol);
|
|
1176
1263
|
result.onApplied?.();
|
|
@@ -1192,6 +1279,7 @@ export class Editor implements Component, Focusable {
|
|
|
1192
1279
|
);
|
|
1193
1280
|
|
|
1194
1281
|
this.#state.lines = result.lines;
|
|
1282
|
+
this.#bumpDocumentVersion();
|
|
1195
1283
|
this.#state.cursorLine = result.cursorLine;
|
|
1196
1284
|
this.#setCursorCol(result.cursorCol);
|
|
1197
1285
|
|
|
@@ -1312,6 +1400,7 @@ export class Editor implements Component, Focusable {
|
|
|
1312
1400
|
syncResult.prefix,
|
|
1313
1401
|
);
|
|
1314
1402
|
this.#state.lines = result.lines;
|
|
1403
|
+
this.#bumpDocumentVersion();
|
|
1315
1404
|
this.#state.cursorLine = result.cursorLine;
|
|
1316
1405
|
this.#setCursorCol(result.cursorCol);
|
|
1317
1406
|
result.onApplied?.();
|
|
@@ -1445,119 +1534,175 @@ export class Editor implements Component, Focusable {
|
|
|
1445
1534
|
return this.#wrappedLineCache.length;
|
|
1446
1535
|
}
|
|
1447
1536
|
|
|
1448
|
-
#
|
|
1537
|
+
#makeLayoutCacheKey(contentWidth: number): LayoutCacheKey {
|
|
1538
|
+
return {
|
|
1539
|
+
docVersion: this.#docVersion,
|
|
1540
|
+
contentWidth,
|
|
1541
|
+
optionsKey: JSON.stringify({
|
|
1542
|
+
borderVisible: this.#borderVisible,
|
|
1543
|
+
borderStyle: this.#borderStyle,
|
|
1544
|
+
closedBorderBox: this.#closedBorderBox,
|
|
1545
|
+
inputPrefix: this.#inputPrefix,
|
|
1546
|
+
inputPrefixWidth: this.#inputPrefixWidth,
|
|
1547
|
+
placeholder: this.#placeholder,
|
|
1548
|
+
promptGutter: this.#promptGutter,
|
|
1549
|
+
useTerminalCursor: this.#useTerminalCursor,
|
|
1550
|
+
cursorOverride: this.cursorOverride,
|
|
1551
|
+
cursorOverrideWidth: this.cursorOverrideWidth,
|
|
1552
|
+
autocompleteState: this.#autocompleteState,
|
|
1553
|
+
autocompletePrefix: this.#autocompletePrefix,
|
|
1554
|
+
autocompleteHint: this.#autocompleteList?.getSelectedItem()?.hint,
|
|
1555
|
+
}),
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
#layoutLine(text: string, logicalLine: number, hasCursor: boolean, cursorPos?: number): LayoutLine {
|
|
1560
|
+
__editorPerfCounters.visibleWidthMeasurements += 1;
|
|
1561
|
+
return {
|
|
1562
|
+
text,
|
|
1563
|
+
visibleWidth: visibleWidth(text),
|
|
1564
|
+
logicalLine,
|
|
1565
|
+
hasCursor,
|
|
1566
|
+
cursorPos,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
#layoutLogicalLine(lineIndex: number, contentWidth: number): LayoutLine[] {
|
|
1571
|
+
__editorPerfCounters.layoutLogicalLinesProcessed += 1;
|
|
1572
|
+
const line = this.#state.lines[lineIndex] || "";
|
|
1573
|
+
const isCurrentLine = lineIndex === this.#state.cursorLine;
|
|
1574
|
+
const wrappedLine = this.#getWrappedLine(lineIndex, contentWidth);
|
|
1449
1575
|
const layoutLines: LayoutLine[] = [];
|
|
1450
1576
|
|
|
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
|
-
});
|
|
1577
|
+
if (wrappedLine.width <= contentWidth) {
|
|
1578
|
+
layoutLines.push(
|
|
1579
|
+
this.#layoutLine(line, lineIndex, isCurrentLine, isCurrentLine ? this.#state.cursorCol : undefined),
|
|
1580
|
+
);
|
|
1461
1581
|
return layoutLines;
|
|
1462
1582
|
}
|
|
1463
1583
|
|
|
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;
|
|
1584
|
+
const chunks = wrappedLine.chunks;
|
|
1585
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1586
|
+
const chunk = chunks[chunkIndex];
|
|
1587
|
+
if (!chunk) continue;
|
|
1487
1588
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1589
|
+
const cursorPos = this.#state.cursorCol;
|
|
1590
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
1591
|
+
let hasCursorInChunk = false;
|
|
1592
|
+
let adjustedCursorPos = 0;
|
|
1491
1593
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
if (isLastChunk) {
|
|
1503
|
-
// Last chunk: cursor belongs here if >= startIndex
|
|
1504
|
-
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
1505
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1506
|
-
} else {
|
|
1507
|
-
// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
|
|
1508
|
-
// But we need to handle the visual position in the trimmed text
|
|
1509
|
-
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
1510
|
-
if (hasCursorInChunk) {
|
|
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
|
-
}
|
|
1594
|
+
if (isCurrentLine) {
|
|
1595
|
+
if (isLastChunk) {
|
|
1596
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
1597
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1598
|
+
} else {
|
|
1599
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
1600
|
+
if (hasCursorInChunk) {
|
|
1601
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1602
|
+
if (adjustedCursorPos > chunk.text.length) {
|
|
1603
|
+
adjustedCursorPos = chunk.text.length;
|
|
1517
1604
|
}
|
|
1518
1605
|
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1519
1608
|
|
|
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
|
-
});
|
|
1609
|
+
if (hasCursorInChunk) {
|
|
1610
|
+
let displayChunkText = chunk.text;
|
|
1611
|
+
let displayCursorPos = adjustedCursorPos;
|
|
1612
|
+
if (displayCursorPos > displayChunkText.length) {
|
|
1613
|
+
let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
|
|
1614
|
+
const displayChunkWidth = visibleWidth(displayChunkText);
|
|
1615
|
+
__editorPerfCounters.visibleWidthMeasurements += 1;
|
|
1616
|
+
if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
|
|
1617
|
+
displayChunkText += padding(hiddenWhitespaceWidth);
|
|
1550
1618
|
} else {
|
|
1551
|
-
layoutLines.push(
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1619
|
+
layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, false));
|
|
1620
|
+
hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
|
|
1621
|
+
while (hiddenWhitespaceWidth > contentWidth) {
|
|
1622
|
+
layoutLines.push(this.#layoutLine(padding(contentWidth), lineIndex, false));
|
|
1623
|
+
hiddenWhitespaceWidth -= contentWidth;
|
|
1624
|
+
}
|
|
1625
|
+
displayChunkText = padding(hiddenWhitespaceWidth);
|
|
1626
|
+
displayCursorPos = hiddenWhitespaceWidth;
|
|
1555
1627
|
}
|
|
1556
1628
|
}
|
|
1629
|
+
layoutLines.push(this.#layoutLine(displayChunkText, lineIndex, true, displayCursorPos));
|
|
1630
|
+
} else {
|
|
1631
|
+
layoutLines.push(this.#layoutLine(chunk.text, lineIndex, false));
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
return layoutLines;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
#sameLayoutCacheKey(a: LayoutCacheKey, b: LayoutCacheKey): boolean {
|
|
1639
|
+
return a.docVersion === b.docVersion && a.contentWidth === b.contentWidth && a.optionsKey === b.optionsKey;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
#replaceCachedLogicalLine(cache: LayoutCache, lineIndex: number, contentWidth: number): void {
|
|
1643
|
+
const start = cache.lineStarts[lineIndex] ?? cache.lines.length;
|
|
1644
|
+
const oldCount = cache.lineCounts[lineIndex] ?? 0;
|
|
1645
|
+
const replacement = this.#layoutLogicalLine(lineIndex, contentWidth);
|
|
1646
|
+
cache.lines.splice(start, oldCount, ...replacement);
|
|
1647
|
+
cache.lineCounts[lineIndex] = replacement.length;
|
|
1648
|
+
const delta = replacement.length - oldCount;
|
|
1649
|
+
if (delta !== 0) {
|
|
1650
|
+
for (let i = lineIndex + 1; i < cache.lineStarts.length; i++) {
|
|
1651
|
+
cache.lineStarts[i] = (cache.lineStarts[i] ?? 0) + delta;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
#patchCursorInCachedLayout(cache: LayoutCache, contentWidth: number): LayoutLine[] {
|
|
1657
|
+
const previousLine = cache.cursorLine;
|
|
1658
|
+
const currentLine = this.#state.cursorLine;
|
|
1659
|
+
this.#replaceCachedLogicalLine(cache, previousLine, contentWidth);
|
|
1660
|
+
if (currentLine !== previousLine) {
|
|
1661
|
+
this.#replaceCachedLogicalLine(cache, currentLine, contentWidth);
|
|
1662
|
+
}
|
|
1663
|
+
cache.cursorLine = currentLine;
|
|
1664
|
+
cache.cursorCol = this.#state.cursorCol;
|
|
1665
|
+
return cache.lines;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
#layoutText(contentWidth: number): LayoutLine[] {
|
|
1669
|
+
__editorPerfCounters.layoutTextInvocations += 1;
|
|
1670
|
+
const key = this.#makeLayoutCacheKey(contentWidth);
|
|
1671
|
+
const cached = this.#layoutCache;
|
|
1672
|
+
if (cached && this.#sameLayoutCacheKey(cached.key, key)) {
|
|
1673
|
+
if (cached.cursorLine === this.#state.cursorLine && cached.cursorCol === this.#state.cursorCol) {
|
|
1674
|
+
return cached.lines;
|
|
1675
|
+
}
|
|
1676
|
+
return this.#patchCursorInCachedLayout(cached, contentWidth);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const layoutLines: LayoutLine[] = [];
|
|
1680
|
+
const lineStarts: number[] = [];
|
|
1681
|
+
const lineCounts: number[] = [];
|
|
1682
|
+
|
|
1683
|
+
if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
|
|
1684
|
+
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1685
|
+
lineStarts[0] = 0;
|
|
1686
|
+
lineCounts[0] = 1;
|
|
1687
|
+
layoutLines.push(this.#layoutLine("", 0, true, 0));
|
|
1688
|
+
} else {
|
|
1689
|
+
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
1690
|
+
lineStarts[i] = layoutLines.length;
|
|
1691
|
+
const logicalLayout = this.#layoutLogicalLine(i, contentWidth);
|
|
1692
|
+
lineCounts[i] = logicalLayout.length;
|
|
1693
|
+
layoutLines.push(...logicalLayout);
|
|
1557
1694
|
}
|
|
1558
1695
|
}
|
|
1559
1696
|
|
|
1560
1697
|
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1698
|
+
this.#layoutCache = {
|
|
1699
|
+
key,
|
|
1700
|
+
cursorLine: this.#state.cursorLine,
|
|
1701
|
+
cursorCol: this.#state.cursorCol,
|
|
1702
|
+
lines: layoutLines,
|
|
1703
|
+
lineStarts,
|
|
1704
|
+
lineCounts,
|
|
1705
|
+
};
|
|
1561
1706
|
return layoutLines;
|
|
1562
1707
|
}
|
|
1563
1708
|
|
|
@@ -1629,6 +1774,7 @@ export class Editor implements Component, Focusable {
|
|
|
1629
1774
|
this.#resetKillSequence();
|
|
1630
1775
|
this.#preferredVisualCol = null;
|
|
1631
1776
|
this.#state.lines[this.#state.cursorLine] = beforeTransient + afterTransient;
|
|
1777
|
+
this.#bumpDocumentVersion();
|
|
1632
1778
|
this.#setCursorCol(transientStartCol);
|
|
1633
1779
|
|
|
1634
1780
|
while (true) {
|
|
@@ -1677,18 +1823,7 @@ export class Editor implements Component, Focusable {
|
|
|
1677
1823
|
/** Insert text at the current cursor position */
|
|
1678
1824
|
insertText(text: string): void {
|
|
1679
1825
|
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
|
-
}
|
|
1826
|
+
this.#insertTextAtCursor(text);
|
|
1692
1827
|
}
|
|
1693
1828
|
|
|
1694
1829
|
// All the editor methods from before...
|
|
@@ -1732,9 +1867,8 @@ export class Editor implements Component, Focusable {
|
|
|
1732
1867
|
|
|
1733
1868
|
// Check if we should trigger or update autocomplete
|
|
1734
1869
|
if (!this.#autocompleteState) {
|
|
1735
|
-
// Auto-trigger for
|
|
1736
|
-
|
|
1737
|
-
if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
|
|
1870
|
+
// Auto-trigger for slash command tokens.
|
|
1871
|
+
if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
|
|
1738
1872
|
this.#tryTriggerAutocomplete();
|
|
1739
1873
|
}
|
|
1740
1874
|
// Auto-trigger for "@" file reference (fuzzy search)
|
|
@@ -1781,6 +1915,11 @@ export class Editor implements Component, Focusable {
|
|
|
1781
1915
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1782
1916
|
this.#resetKillSequence();
|
|
1783
1917
|
this.#recordUndoState();
|
|
1918
|
+
const hadAutocomplete = this.#autocompleteState !== null;
|
|
1919
|
+
this.#cancelAutocomplete();
|
|
1920
|
+
if (hadAutocomplete) {
|
|
1921
|
+
this.onAutocompleteUpdate?.();
|
|
1922
|
+
}
|
|
1784
1923
|
|
|
1785
1924
|
this.#withUndoSuspended(() => {
|
|
1786
1925
|
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
@@ -1808,21 +1947,12 @@ export class Editor implements Component, Focusable {
|
|
|
1808
1947
|
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1809
1948
|
|
|
1810
1949
|
// Filter out non-printable characters except newlines
|
|
1811
|
-
|
|
1950
|
+
const filteredText = tabExpandedText
|
|
1812
1951
|
.split("")
|
|
1813
1952
|
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1814
1953
|
.join("");
|
|
1815
1954
|
|
|
1816
|
-
|
|
1817
|
-
// the cursor is a word character, prepend a space for better readability
|
|
1818
|
-
if (/^[/~.]/.test(filteredText)) {
|
|
1819
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1820
|
-
const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
|
|
1821
|
-
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1822
|
-
filteredText = ` ${filteredText}`;
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1955
|
+
if (filteredText.length === 0) return;
|
|
1826
1956
|
// Split into lines
|
|
1827
1957
|
const pastedLines = filteredText.split("\n");
|
|
1828
1958
|
|
|
@@ -1844,15 +1974,10 @@ export class Editor implements Component, Focusable {
|
|
|
1844
1974
|
return;
|
|
1845
1975
|
}
|
|
1846
1976
|
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
}
|
|
1852
|
-
return;
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
// Multi-line paste - use insertTextAtCursor for proper handling
|
|
1977
|
+
// Paste is literal input, not typed input. Insert atomically so leading
|
|
1978
|
+
// trigger characters such as "/", "#", "@", ":", or path-like text do
|
|
1979
|
+
// not open or update autocomplete lists while preserving normal typed
|
|
1980
|
+
// trigger behavior.
|
|
1856
1981
|
this.#insertTextAtCursor(filteredText);
|
|
1857
1982
|
});
|
|
1858
1983
|
}
|
|
@@ -1897,6 +2022,7 @@ export class Editor implements Component, Focusable {
|
|
|
1897
2022
|
const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
|
|
1898
2023
|
|
|
1899
2024
|
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
2025
|
+
this.#bumpDocumentVersion();
|
|
1900
2026
|
this.#pastes.clear();
|
|
1901
2027
|
this.#pasteCounter = 0;
|
|
1902
2028
|
this.#historyIndex = -1;
|
|
@@ -2088,6 +2214,7 @@ export class Editor implements Component, Focusable {
|
|
|
2088
2214
|
#recordUndoState(): void {
|
|
2089
2215
|
if (this.#suspendUndo) return;
|
|
2090
2216
|
this.#undoStack.push(structuredClone(this.#state));
|
|
2217
|
+
this.#bumpDocumentVersion();
|
|
2091
2218
|
}
|
|
2092
2219
|
|
|
2093
2220
|
#applyUndo(): void {
|
|
@@ -2098,6 +2225,7 @@ export class Editor implements Component, Focusable {
|
|
|
2098
2225
|
this.#resetKillSequence();
|
|
2099
2226
|
this.#preferredVisualCol = null;
|
|
2100
2227
|
Object.assign(this.#state, snapshot);
|
|
2228
|
+
this.#bumpDocumentVersion();
|
|
2101
2229
|
|
|
2102
2230
|
if (this.onChange) {
|
|
2103
2231
|
this.onChange(this.getText());
|
|
@@ -2603,6 +2731,12 @@ export class Editor implements Component, Focusable {
|
|
|
2603
2731
|
: this.#state.cursorCol - 1
|
|
2604
2732
|
: undefined;
|
|
2605
2733
|
|
|
2734
|
+
// Backward from column 0: lastIndexOf clamps a negative position to 0,
|
|
2735
|
+
// which would match the character under the cursor instead of skipping it
|
|
2736
|
+
if (!isForward && searchFrom !== undefined && searchFrom < 0) {
|
|
2737
|
+
continue;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2606
2740
|
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
|
|
2607
2741
|
|
|
2608
2742
|
if (idx !== -1) {
|
|
@@ -2655,12 +2789,11 @@ export class Editor implements Component, Focusable {
|
|
|
2655
2789
|
#getSlashTokenBeforeCursor(): string | null {
|
|
2656
2790
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2657
2791
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2658
|
-
|
|
2659
|
-
return match?.[1] ?? null;
|
|
2792
|
+
return extractSlashCommandTokenPrefix(beforeCursor);
|
|
2660
2793
|
}
|
|
2661
2794
|
|
|
2662
2795
|
#isInSlashTokenContext(): boolean {
|
|
2663
|
-
return this.#getSlashTokenBeforeCursor()
|
|
2796
|
+
return this.#getSlashTokenBeforeCursor() !== null;
|
|
2664
2797
|
}
|
|
2665
2798
|
|
|
2666
2799
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
@@ -2744,7 +2877,10 @@ export class Editor implements Component, Focusable {
|
|
|
2744
2877
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2745
2878
|
|
|
2746
2879
|
// Check if we're in a slash command context
|
|
2747
|
-
if (
|
|
2880
|
+
if (
|
|
2881
|
+
(this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) ||
|
|
2882
|
+
this.#isInSlashTokenContext()
|
|
2883
|
+
) {
|
|
2748
2884
|
this.#handleSlashCommandCompletion();
|
|
2749
2885
|
} else {
|
|
2750
2886
|
this.#forceFileAutocomplete(true);
|
|
@@ -2793,6 +2929,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2793
2929
|
);
|
|
2794
2930
|
|
|
2795
2931
|
this.#state.lines = result.lines;
|
|
2932
|
+
this.#bumpDocumentVersion();
|
|
2796
2933
|
this.#state.cursorLine = result.cursorLine;
|
|
2797
2934
|
this.#setCursorCol(result.cursorCol);
|
|
2798
2935
|
|