@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66
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/package.json +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +8 -73
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +60 -412
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +85 -62
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.submit-confirm.test.tsx +32 -70
- package/src/App.test.tsx +136 -66
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tsx +243 -284
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +67 -52
- package/src/app-view-model.ts +6 -100
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +15 -1
- package/src/components/ReviewRail.stories.tsx +15 -1
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1708 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +331 -72
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/theme.ts +4 -4
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-settings-dialog.tsx +9 -0
- package/src/view-plan.ts +29 -12
- package/src/App.review-panel.test.tsx +0 -143
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-level chords of the thread view. Letters in the thread view
|
|
3
|
+
* type a comment, so everything that acts on the session as a whole - submit,
|
|
4
|
+
* share, edit, the rail - lives on ctrl chords (cmd where the terminal
|
|
5
|
+
* delivers it). The guided walk is a diff-review mode and has no chord here. Pure: the view reports whether a composer is open, this maps
|
|
6
|
+
* a key to an intent or to nothing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Intent } from "./keymap";
|
|
10
|
+
|
|
11
|
+
export interface ThreadChordKey {
|
|
12
|
+
name: string;
|
|
13
|
+
ctrl?: boolean;
|
|
14
|
+
meta?: boolean;
|
|
15
|
+
super?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ThreadChordContext {
|
|
19
|
+
/** A composer owns the keyboard: chords never fire while one is open. */
|
|
20
|
+
composing: boolean;
|
|
21
|
+
/** Collaborators annotate only; submit, edit, and share are the owner's. */
|
|
22
|
+
isOwner: boolean;
|
|
23
|
+
/** A resolved session has nothing left to submit. */
|
|
24
|
+
resolved: boolean;
|
|
25
|
+
/** The rail shows the tree: next / previous move its selection instead of the cards. */
|
|
26
|
+
treeActive: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The cheatsheet rows for the chords, in the same order they are resolved. */
|
|
30
|
+
export const THREAD_CHORD_ENTRIES = [
|
|
31
|
+
{ keys: "⌃enter", label: "submit the review" },
|
|
32
|
+
{ keys: "⌃e", label: "edit in $EDITOR" },
|
|
33
|
+
{ keys: "⌃s", label: "share" },
|
|
34
|
+
{ keys: "⌃r", label: "cycle the rail" },
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** The rail and curation chords: option (alt) plus the plan sheet's old letter. */
|
|
38
|
+
export const RAIL_CHORD_ENTRIES = [
|
|
39
|
+
{ keys: "⌥n / ⌥p", label: "next / previous card" },
|
|
40
|
+
{ keys: "⌥e", label: "edit the card" },
|
|
41
|
+
{ keys: "⌥⌫", label: "delete the card" },
|
|
42
|
+
{ keys: "⌥r", label: "rename the author" },
|
|
43
|
+
{ keys: "⌥x", label: "cut the block" },
|
|
44
|
+
{ keys: "⌥u", label: "restore the last cut" },
|
|
45
|
+
{ keys: "⌥w / ⌥s", label: "widen / narrow the rail" },
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/** The tree chords: option plus a letter, all on the rail's Tree tab. */
|
|
49
|
+
export const TREE_CHORD_ENTRIES = [
|
|
50
|
+
{ keys: "⌥t", label: "show / hide the tree" },
|
|
51
|
+
{ keys: "⌥n / ⌥p", label: "next / previous entry" },
|
|
52
|
+
{ keys: "⌥g", label: "go to the entry" },
|
|
53
|
+
{ keys: "⌥b", label: "branch off the tip" },
|
|
54
|
+
{ keys: "⌥l", label: "label a checkpoint" },
|
|
55
|
+
{ keys: "⌥f", label: "fork the path" },
|
|
56
|
+
{ keys: "⌥h", label: "fork and hand off" },
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
export function resolveThreadChord(
|
|
60
|
+
key: ThreadChordKey,
|
|
61
|
+
context: ThreadChordContext,
|
|
62
|
+
): Intent | null {
|
|
63
|
+
if (context.composing) return null;
|
|
64
|
+
const modified = Boolean(key.ctrl || key.meta || key.super);
|
|
65
|
+
|
|
66
|
+
if (!modified) return null;
|
|
67
|
+
if (key.name === "return" || key.name === "enter") {
|
|
68
|
+
if (!context.isOwner) return READ_ONLY;
|
|
69
|
+
|
|
70
|
+
return context.resolved ? null : { type: "openSubmit" };
|
|
71
|
+
}
|
|
72
|
+
if (key.ctrl) return resolveSessionChord(key.name, context);
|
|
73
|
+
if (key.meta) return resolveRailChord(key.name, context);
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The answers a blocked primitive gets - the same words the keymap uses. */
|
|
79
|
+
const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
|
|
80
|
+
const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
|
|
81
|
+
|
|
82
|
+
/** Editing, deleting, cutting, and restoring change the review: gated by role and by a verdict. */
|
|
83
|
+
function mutating(intent: Intent, context: ThreadChordContext): Intent {
|
|
84
|
+
if (!context.isOwner) return READ_ONLY;
|
|
85
|
+
if (context.resolved) return RESOLVED;
|
|
86
|
+
|
|
87
|
+
return intent;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveSessionChord(name: string, context: ThreadChordContext): Intent | null {
|
|
91
|
+
switch (name) {
|
|
92
|
+
case "e":
|
|
93
|
+
return mutating({ type: "edit" }, context);
|
|
94
|
+
case "s":
|
|
95
|
+
return context.isOwner ? { type: "share" } : READ_ONLY;
|
|
96
|
+
case "r":
|
|
97
|
+
return { type: "cycleReviewPanel" };
|
|
98
|
+
default:
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// option arrives as meta in this terminal stack; the letters mirror the
|
|
104
|
+
// plan sheet's keymap so the rail keeps its muscle memory
|
|
105
|
+
function resolveRailChord(name: string, context: ThreadChordContext): Intent | null {
|
|
106
|
+
switch (name) {
|
|
107
|
+
case "n":
|
|
108
|
+
return context.treeActive ? { type: "treeMove", direction: 1 } : { type: "nextAnnotation" };
|
|
109
|
+
case "p":
|
|
110
|
+
return context.treeActive ? { type: "treeMove", direction: -1 } : { type: "prevAnnotation" };
|
|
111
|
+
case "t":
|
|
112
|
+
return { type: "toggleTree" };
|
|
113
|
+
// the tree is the owner's: a move, a branch, or a label changes what everyone sees
|
|
114
|
+
case "g":
|
|
115
|
+
return mutating({ type: "treeGo" }, context);
|
|
116
|
+
case "b":
|
|
117
|
+
return mutating({ type: "treeBranch" }, context);
|
|
118
|
+
case "l":
|
|
119
|
+
return mutating({ type: "treeLabel" }, context);
|
|
120
|
+
// a fork can be taken from a resolved review: only the role gates it
|
|
121
|
+
case "f":
|
|
122
|
+
return context.isOwner ? { type: "treeFork" } : READ_ONLY;
|
|
123
|
+
case "h":
|
|
124
|
+
return context.isOwner ? { type: "treeForkShare" } : READ_ONLY;
|
|
125
|
+
case "e":
|
|
126
|
+
return mutating({ type: "editCard" }, context);
|
|
127
|
+
case "backspace":
|
|
128
|
+
return mutating({ type: "removeAnnotation" }, context);
|
|
129
|
+
// renaming an author is a local display choice, open to every role
|
|
130
|
+
case "r":
|
|
131
|
+
return { type: "openRename" };
|
|
132
|
+
case "x":
|
|
133
|
+
return mutating({ type: "cut" }, context);
|
|
134
|
+
case "u":
|
|
135
|
+
return mutating({ type: "restoreCuration" }, context);
|
|
136
|
+
// letters only: escape-prefixed punctuation reads as control sequences, not keys
|
|
137
|
+
case "w":
|
|
138
|
+
return { type: "resizeReviewPanel", direction: 1 };
|
|
139
|
+
case "s":
|
|
140
|
+
return { type: "resizeReviewPanel", direction: -1 };
|
|
141
|
+
default:
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isCollapsed,
|
|
4
|
+
isDoubleClick,
|
|
5
|
+
nextWordStart,
|
|
6
|
+
orderedSpan,
|
|
7
|
+
positionAt,
|
|
8
|
+
previousWordStart,
|
|
9
|
+
snapSpanToWords,
|
|
10
|
+
snapToWords,
|
|
11
|
+
spanRangeInBlock,
|
|
12
|
+
tightenSpan,
|
|
13
|
+
wordRangeAt,
|
|
14
|
+
type LineGeometry,
|
|
15
|
+
} from "./thread-selection";
|
|
16
|
+
|
|
17
|
+
const TEXT = "All persistence lives in a new module.";
|
|
18
|
+
const BULLET = "store.ts - the store";
|
|
19
|
+
// block 3 wraps onto two rows, a blank row, then bullet block 4 on one row
|
|
20
|
+
const LINES: LineGeometry[] = [
|
|
21
|
+
{ blockIndex: 3, start: 0, end: 21, x: 4, y: 10 },
|
|
22
|
+
{ blockIndex: 3, start: 22, end: 38, x: 4, y: 11 },
|
|
23
|
+
{ blockIndex: 4, start: 0, end: 20, x: 6, y: 13 },
|
|
24
|
+
];
|
|
25
|
+
const textLengthOf = (blockIndex: number): number =>
|
|
26
|
+
blockIndex === 3 ? TEXT.length : BULLET.length;
|
|
27
|
+
|
|
28
|
+
describe("positionAt", () => {
|
|
29
|
+
test("maps a cell inside a line to the character under it", () => {
|
|
30
|
+
// Act
|
|
31
|
+
const position = positionAt(LINES, 4 + 6, 10);
|
|
32
|
+
|
|
33
|
+
// Assert: column 6 of the first line is the "r" in "persistence"
|
|
34
|
+
expect(position).toEqual({ blockIndex: 3, char: 6 });
|
|
35
|
+
expect(TEXT[6]).toBe("r");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("past the right edge resolves to the position after the line's last char", () => {
|
|
39
|
+
// Assert
|
|
40
|
+
expect(positionAt(LINES, 90, 10)).toEqual({ blockIndex: 3, char: 21 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a row without text resolves to the end of the closest line above", () => {
|
|
44
|
+
// Assert: the blank row between the blocks, and a row below everything
|
|
45
|
+
expect(positionAt(LINES, 30, 12)).toEqual({ blockIndex: 3, char: 38 });
|
|
46
|
+
expect(positionAt(LINES, 30, 40)).toEqual({ blockIndex: 4, char: 20 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rows above all text resolve to the first character", () => {
|
|
50
|
+
// Assert
|
|
51
|
+
expect(positionAt(LINES, 30, 2)).toEqual({ blockIndex: 3, char: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a row of another block maps into that block", () => {
|
|
55
|
+
// Act
|
|
56
|
+
const position = positionAt(LINES, 6 + 3, 13);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(position).toEqual({ blockIndex: 4, char: 3 });
|
|
60
|
+
expect(positionAt([], 5, 10)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("orderedSpan and tightenSpan", () => {
|
|
65
|
+
test("a backward drag normalizes to document order", () => {
|
|
66
|
+
// Act
|
|
67
|
+
const span = orderedSpan({
|
|
68
|
+
anchor: { blockIndex: 4, char: 5 },
|
|
69
|
+
head: { blockIndex: 3, char: 6 },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
73
|
+
expect(span).toEqual({
|
|
74
|
+
start: { blockIndex: 3, char: 6 },
|
|
75
|
+
end: { blockIndex: 4, char: 5 },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a collapsed selection is no span", () => {
|
|
80
|
+
// Arrange
|
|
81
|
+
const selection = {
|
|
82
|
+
anchor: { blockIndex: 3, char: 6 },
|
|
83
|
+
head: { blockIndex: 3, char: 6 },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(isCollapsed(selection)).toBe(true);
|
|
88
|
+
expect(orderedSpan(selection)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a span opening at a block's end moves onto the next block", () => {
|
|
92
|
+
// Act: the pointer sat on the blank row above the bullet when the press landed
|
|
93
|
+
const tightened = tightenSpan(
|
|
94
|
+
{
|
|
95
|
+
start: { blockIndex: 3, char: TEXT.length },
|
|
96
|
+
end: { blockIndex: 4, char: 5 },
|
|
97
|
+
},
|
|
98
|
+
textLengthOf,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
expect(tightened).toEqual({
|
|
103
|
+
start: { blockIndex: 4, char: 0 },
|
|
104
|
+
end: { blockIndex: 4, char: 5 },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a span closing at a block's start moves back onto the previous block", () => {
|
|
109
|
+
// Act
|
|
110
|
+
const tightened = tightenSpan(
|
|
111
|
+
{ start: { blockIndex: 3, char: 10 }, end: { blockIndex: 4, char: 0 } },
|
|
112
|
+
textLengthOf,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
expect(tightened).toEqual({
|
|
117
|
+
start: { blockIndex: 3, char: 10 },
|
|
118
|
+
end: { blockIndex: 3, char: TEXT.length },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a span with nothing left in it is null", () => {
|
|
123
|
+
// Assert
|
|
124
|
+
expect(
|
|
125
|
+
tightenSpan(
|
|
126
|
+
{
|
|
127
|
+
start: { blockIndex: 3, char: TEXT.length },
|
|
128
|
+
end: { blockIndex: 4, char: 0 },
|
|
129
|
+
},
|
|
130
|
+
textLengthOf,
|
|
131
|
+
),
|
|
132
|
+
).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("spanRangeInBlock", () => {
|
|
137
|
+
const span = {
|
|
138
|
+
start: { blockIndex: 3, char: 22 },
|
|
139
|
+
end: { blockIndex: 5, char: 4 },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
test("the first block runs from the start to its end, middles fully, the last to the end char", () => {
|
|
143
|
+
// Assert
|
|
144
|
+
expect(spanRangeInBlock(span, 3, TEXT.length)).toEqual({
|
|
145
|
+
start: 22,
|
|
146
|
+
end: TEXT.length,
|
|
147
|
+
});
|
|
148
|
+
expect(spanRangeInBlock(span, 4, BULLET.length)).toEqual({
|
|
149
|
+
start: 0,
|
|
150
|
+
end: BULLET.length,
|
|
151
|
+
});
|
|
152
|
+
expect(spanRangeInBlock(span, 5, 30)).toEqual({ start: 0, end: 4 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("blocks outside the span have no range", () => {
|
|
156
|
+
// Assert
|
|
157
|
+
expect(spanRangeInBlock(span, 2, 10)).toBeNull();
|
|
158
|
+
expect(spanRangeInBlock(span, 6, 10)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("word rules", () => {
|
|
163
|
+
test("the boundary rule attributes trailing whitespace to the preceding word", () => {
|
|
164
|
+
// Assert: char 3 is the space after "All"
|
|
165
|
+
expect(wordRangeAt(TEXT, 3)).toEqual({ start: 0, end: 3 });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("snapToWords grows a partial range to whole words on both sides", () => {
|
|
169
|
+
// Assert: "rsistence liv" -> "persistence lives"
|
|
170
|
+
expect(snapToWords(TEXT, { start: 6, end: 19 })).toEqual({
|
|
171
|
+
start: 4,
|
|
172
|
+
end: 21,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("snapSpanToWords snaps each end of a cross-block span within its own block", () => {
|
|
177
|
+
// Act: "rsistence ... st" -> "persistence ... store.ts"
|
|
178
|
+
const snapped = snapSpanToWords(
|
|
179
|
+
{ start: { blockIndex: 3, char: 6 }, end: { blockIndex: 4, char: 2 } },
|
|
180
|
+
(blockIndex) => (blockIndex === 3 ? TEXT : BULLET),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Assert
|
|
184
|
+
expect(snapped).toEqual({
|
|
185
|
+
start: { blockIndex: 3, char: 4 },
|
|
186
|
+
end: { blockIndex: 4, char: 8 },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("word starts step in both directions and stop at the ends", () => {
|
|
191
|
+
// Assert
|
|
192
|
+
expect(nextWordStart(TEXT, 0)).toBe(4);
|
|
193
|
+
expect(nextWordStart(TEXT, 31)).toBeNull();
|
|
194
|
+
expect(previousWordStart(TEXT, 22)).toBe(16);
|
|
195
|
+
expect(previousWordStart(TEXT, 0)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("isDoubleClick", () => {
|
|
200
|
+
test("same cell within the window counts, a moved or late press does not", () => {
|
|
201
|
+
// Arrange
|
|
202
|
+
const first = { time: 1000, x: 5, y: 7 };
|
|
203
|
+
|
|
204
|
+
// Assert
|
|
205
|
+
expect(isDoubleClick(first, { time: 1300, x: 5, y: 7 })).toBe(true);
|
|
206
|
+
expect(isDoubleClick(first, { time: 1300, x: 6, y: 7 })).toBe(false);
|
|
207
|
+
expect(isDoubleClick(first, { time: 1500, x: 5, y: 7 })).toBe(false);
|
|
208
|
+
expect(isDoubleClick(null, { time: 1300, x: 5, y: 7 })).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread view's text-selection model: a pure, renderer-free description
|
|
3
|
+
* of where the caret is and what is marked, plus the hit-testing that maps a
|
|
4
|
+
* pointer cell onto a character. Character-precise (partial words), spanning
|
|
5
|
+
* wrapped lines and consecutive blocks, normalized for backward drags. Word snapping
|
|
6
|
+
* happens only where the grammar asks for it: the typing anchor for a bare
|
|
7
|
+
* caret, and double-click word mode.
|
|
8
|
+
*
|
|
9
|
+
* Isolated on purpose: the interaction can be unit-tested and benchmarked
|
|
10
|
+
* without a terminal, and the view layer only wires events to it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface TextPosition {
|
|
14
|
+
blockIndex: number;
|
|
15
|
+
char: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where one visual line of a block sits on screen, in cells. */
|
|
19
|
+
export interface LineGeometry {
|
|
20
|
+
blockIndex: number;
|
|
21
|
+
/** Char range of the block's text this line shows. */
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The held selection: `anchor` is where the press landed, `head` follows the pointer. */
|
|
29
|
+
export interface Selection {
|
|
30
|
+
anchor: TextPosition;
|
|
31
|
+
head: TextPosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CharRange {
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An ordered stretch of text, possibly across consecutive blocks. */
|
|
40
|
+
export interface TextSpan {
|
|
41
|
+
start: TextPosition;
|
|
42
|
+
end: TextPosition;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Document order: block first, then character. */
|
|
46
|
+
export function comparePositions(left: TextPosition, right: TextPosition): number {
|
|
47
|
+
return left.blockIndex - right.blockIndex || left.char - right.char;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Word boundaries of a block's text. */
|
|
51
|
+
export function wordRanges(text: string): CharRange[] {
|
|
52
|
+
const ranges: CharRange[] = [];
|
|
53
|
+
const matcher = /\S+/g;
|
|
54
|
+
let match: RegExpExecArray | null;
|
|
55
|
+
|
|
56
|
+
while ((match = matcher.exec(text)) !== null) {
|
|
57
|
+
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return ranges;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The marked word for a char offset: the word containing it, or - when the
|
|
65
|
+
* caret sits in the whitespace immediately after a word - that preceding
|
|
66
|
+
* word, never the next one.
|
|
67
|
+
*/
|
|
68
|
+
export function wordIndexAt(text: string, char: number): number {
|
|
69
|
+
const words = wordRanges(text);
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < words.length; index++) {
|
|
72
|
+
if (char < words[index]!.start) return Math.max(0, index - 1);
|
|
73
|
+
if (char < words[index]!.end) return index;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Math.max(0, words.length - 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The word range the boundary rule assigns to a char offset; null for empty text. */
|
|
80
|
+
export function wordRangeAt(text: string, char: number): CharRange | null {
|
|
81
|
+
const words = wordRanges(text);
|
|
82
|
+
|
|
83
|
+
return words[wordIndexAt(text, char)] ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map a pointer cell to a text position over every registered line. A row
|
|
88
|
+
* without text (a blank row, a card) resolves to the end of the closest line
|
|
89
|
+
* above it, rows above all text to the first character, and a cell past a
|
|
90
|
+
* line's last cell to the position after that line's last character, so a
|
|
91
|
+
* drag that runs off the right edge still selects to the line end.
|
|
92
|
+
*/
|
|
93
|
+
export function positionAt(lines: LineGeometry[], x: number, y: number): TextPosition | null {
|
|
94
|
+
if (lines.length === 0) return null;
|
|
95
|
+
const ordered = lines.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
96
|
+
const onRow = ordered.find((line) => line.y === y);
|
|
97
|
+
|
|
98
|
+
if (onRow) {
|
|
99
|
+
const column = Math.max(0, Math.min(onRow.end - onRow.start, x - onRow.x));
|
|
100
|
+
|
|
101
|
+
return { blockIndex: onRow.blockIndex, char: onRow.start + column };
|
|
102
|
+
}
|
|
103
|
+
const above = ordered.findLast((line) => line.y < y);
|
|
104
|
+
|
|
105
|
+
if (above) return { blockIndex: above.blockIndex, char: above.end };
|
|
106
|
+
const first = ordered[0]!;
|
|
107
|
+
|
|
108
|
+
return { blockIndex: first.blockIndex, char: first.start };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when nothing is held: the caret is a point, not a range. */
|
|
112
|
+
export function isCollapsed(selection: Selection): boolean {
|
|
113
|
+
return comparePositions(selection.anchor, selection.head) === 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The selection in document order, or null when collapsed. */
|
|
117
|
+
export function orderedSpan(selection: Selection): TextSpan | null {
|
|
118
|
+
if (isCollapsed(selection)) return null;
|
|
119
|
+
|
|
120
|
+
return comparePositions(selection.anchor, selection.head) < 0
|
|
121
|
+
? { start: selection.anchor, end: selection.head }
|
|
122
|
+
: { start: selection.head, end: selection.anchor };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pull a span's edges off block boundaries: opening at a block's end or
|
|
127
|
+
* closing at a block's start marks nothing there, so the edge moves onto the
|
|
128
|
+
* neighbouring block. Null when nothing remains.
|
|
129
|
+
*/
|
|
130
|
+
export function tightenSpan(
|
|
131
|
+
span: TextSpan,
|
|
132
|
+
textLengthOf: (blockIndex: number) => number,
|
|
133
|
+
): TextSpan | null {
|
|
134
|
+
let { start, end } = span;
|
|
135
|
+
|
|
136
|
+
while (start.blockIndex < end.blockIndex && start.char >= textLengthOf(start.blockIndex)) {
|
|
137
|
+
start = { blockIndex: start.blockIndex + 1, char: 0 };
|
|
138
|
+
}
|
|
139
|
+
while (end.blockIndex > start.blockIndex && end.char === 0) {
|
|
140
|
+
end = {
|
|
141
|
+
blockIndex: end.blockIndex - 1,
|
|
142
|
+
char: textLengthOf(end.blockIndex - 1),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const clampedEnd = {
|
|
146
|
+
blockIndex: end.blockIndex,
|
|
147
|
+
char: Math.min(end.char, textLengthOf(end.blockIndex)),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return comparePositions(start, clampedEnd) < 0 ? { start, end: clampedEnd } : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The part of a span that falls on one block, as a char range; null when none. */
|
|
154
|
+
export function spanRangeInBlock(
|
|
155
|
+
span: TextSpan,
|
|
156
|
+
blockIndex: number,
|
|
157
|
+
textLength: number,
|
|
158
|
+
): CharRange | null {
|
|
159
|
+
if (blockIndex < span.start.blockIndex || blockIndex > span.end.blockIndex) return null;
|
|
160
|
+
const start = blockIndex === span.start.blockIndex ? span.start.char : 0;
|
|
161
|
+
const end = blockIndex === span.end.blockIndex ? Math.min(span.end.char, textLength) : textLength;
|
|
162
|
+
|
|
163
|
+
return end > start ? { start, end } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Grow a char range outward to the word boundaries it touches (double-click word mode). */
|
|
167
|
+
export function snapToWords(text: string, range: CharRange): CharRange {
|
|
168
|
+
const words = wordRanges(text);
|
|
169
|
+
const first = words.find((word) => range.start < word.end) ?? words[words.length - 1];
|
|
170
|
+
const last = words.toReversed().find((word) => range.end > word.start) ?? words[0];
|
|
171
|
+
|
|
172
|
+
if (!first || !last) return range;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
start: Math.min(first.start, range.start),
|
|
176
|
+
end: Math.max(last.end, range.end),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Snap both ends of a span to word boundaries, each within its own block. */
|
|
181
|
+
export function snapSpanToWords(span: TextSpan, textOf: (blockIndex: number) => string): TextSpan {
|
|
182
|
+
if (span.start.blockIndex === span.end.blockIndex) {
|
|
183
|
+
const snapped = snapToWords(textOf(span.start.blockIndex), {
|
|
184
|
+
start: span.start.char,
|
|
185
|
+
end: span.end.char,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
start: { blockIndex: span.start.blockIndex, char: snapped.start },
|
|
190
|
+
end: { blockIndex: span.end.blockIndex, char: snapped.end },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const startText = textOf(span.start.blockIndex);
|
|
194
|
+
const endText = textOf(span.end.blockIndex);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
start: {
|
|
198
|
+
blockIndex: span.start.blockIndex,
|
|
199
|
+
char: snapToWords(startText, {
|
|
200
|
+
start: span.start.char,
|
|
201
|
+
end: startText.length,
|
|
202
|
+
}).start,
|
|
203
|
+
},
|
|
204
|
+
end: {
|
|
205
|
+
blockIndex: span.end.blockIndex,
|
|
206
|
+
char: snapToWords(endText, { start: 0, end: span.end.char }).end,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ClickStamp {
|
|
212
|
+
time: number;
|
|
213
|
+
x: number;
|
|
214
|
+
y: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Two presses on the same cell inside this window read as a double-click. */
|
|
218
|
+
export const DOUBLE_CLICK_WINDOW_MS = 400;
|
|
219
|
+
|
|
220
|
+
export function isDoubleClick(previous: ClickStamp | null, current: ClickStamp): boolean {
|
|
221
|
+
return (
|
|
222
|
+
previous !== null &&
|
|
223
|
+
previous.x === current.x &&
|
|
224
|
+
previous.y === current.y &&
|
|
225
|
+
current.time - previous.time <= DOUBLE_CLICK_WINDOW_MS
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Next word start after `char`, or null past the last word. */
|
|
230
|
+
export function nextWordStart(text: string, char: number): number | null {
|
|
231
|
+
const next = wordRanges(text).find((word) => word.start > char);
|
|
232
|
+
|
|
233
|
+
return next ? next.start : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Previous word start before `char`, or null before the first word. */
|
|
237
|
+
export function previousWordStart(text: string, char: number): number | null {
|
|
238
|
+
const previous = wordRanges(text)
|
|
239
|
+
.toReversed()
|
|
240
|
+
.find((word) => word.start < char);
|
|
241
|
+
|
|
242
|
+
return previous ? previous.start : null;
|
|
243
|
+
}
|