Skip to content

Commit acec040

Browse files
Capture hat map in recorded tests. Render in docs. (#3340)
1 parent faefea1 commit acec040

118 files changed

Lines changed: 4548 additions & 58 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/app-vscode/src/constructTestHelpers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
HatTokenMap,
77
IDE,
88
NormalizedIDE,
9+
ReadOnlyHatMap,
910
ScopeProvider,
1011
SerializedMarks,
1112
StoredTargetKey,
@@ -64,6 +65,7 @@ export function constructTestHelpers(
6465
editor: TextEditor,
6566
ide: IDE,
6667
marks: SerializedMarks | undefined,
68+
hatTokenMap: ReadOnlyHatMap | undefined,
6769
): Promise<TestCaseSnapshot> {
6870
return takeSnapshot(
6971
storedTargets,
@@ -72,6 +74,7 @@ export function constructTestHelpers(
7274
editor,
7375
ide,
7476
marks,
77+
hatTokenMap,
7578
undefined,
7679
undefined,
7780
);

‎packages/app-web-docs/src/docs/components/Code.css‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,57 @@
125125
position: relative;
126126
}
127127

128-
.code-hat-default::before {
128+
.code-hat::before {
129129
content: "";
130130
position: absolute;
131131
top: 0.15em;
132132
left: 50%;
133133
width: 0.3em;
134134
height: 0.3em;
135135
border-radius: 50%;
136-
background-color: #b9b6cd;
137136
transform: translate(-50%, -100%);
138137
pointer-events: none;
139138
}
139+
140+
.code-hat-default::before {
141+
background-color: #b9b6cd;
142+
}
143+
144+
.code-hat-blue::before {
145+
background-color: #089ad3;
146+
}
147+
148+
.code-hat-green::before {
149+
background-color: #36b33f;
150+
}
151+
152+
.code-hat-red::before {
153+
background-color: #e02d28;
154+
}
155+
156+
.code-hat-pink::before {
157+
background-color: #e06caa;
158+
}
159+
160+
.code-hat-yellow::before {
161+
background-color: #e5c02c;
162+
}
163+
164+
/* Code hat referenced */
165+
166+
.code-hat-referenced::before {
167+
animation: code-hat-referenced-pulse 2s ease-in-out infinite;
168+
}
169+
170+
@keyframes code-hat-referenced-pulse {
171+
50% {
172+
background-color: var(--code-hat-referenced-color);
173+
}
174+
}
175+
176+
@media (prefers-reduced-motion: reduce) {
177+
.code-hat-referenced::before {
178+
background-color: var(--code-hat-referenced-color);
179+
animation: none;
180+
}
181+
}

‎packages/app-web-docs/src/docs/components/RecordedTestVisualizer.tsx‎

Lines changed: 80 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ import type { Dispatch, JSX, ReactNode, SetStateAction } from "react";
33
import { createContext, useContext, useMemo, useState } from "react";
44
import type { DecorationItem } from "shiki";
55
import type {
6-
SelectionPlainObject,
7-
SerializedMarks,
6+
Position,
7+
Selection,
88
TestCaseSnapshot,
99
} from "@cursorless/lib-common";
10-
import { plainObjectToSelection, splitKey } from "@cursorless/lib-common";
10+
import {
11+
plainObjectToRange,
12+
plainObjectToSelection,
13+
} from "@cursorless/lib-common";
1114
import { Code } from "./Code";
15+
import { highlightColors } from "./highlightColors";
1216
import type { RecordedTest } from "./types";
1317

1418
interface RecordedTestVisualizerContextValue {
@@ -98,12 +102,17 @@ export function RecordedTestVisualizer({
98102
url: `https://github.com/cursorless-dev/cursorless/blob/main/resources/fixtures/recorded/docs/${path}`,
99103
};
100104

105+
const renderHats =
106+
fixture.initialState.marks != null &&
107+
Object.keys(fixture.initialState.marks).length > 0;
108+
101109
return (
102110
<div className="row">
103111
<div className="col">
104112
Input
105113
<CodeState
106114
renderWhitespace={renderWhitespace}
115+
renderHats={renderHats}
107116
languageId={languageId}
108117
link={link}
109118
state={initialState}
@@ -113,6 +122,7 @@ export function RecordedTestVisualizer({
113122
Output
114123
<CodeState
115124
renderWhitespace={renderWhitespace}
125+
renderHats={renderHats}
116126
languageId={languageId}
117127
link={link}
118128
state={finalState}
@@ -124,6 +134,7 @@ export function RecordedTestVisualizer({
124134

125135
interface CodeStateProps {
126136
renderWhitespace: boolean;
137+
renderHats: boolean;
127138
languageId: string;
128139
link: {
129140
name: string;
@@ -134,16 +145,14 @@ interface CodeStateProps {
134145

135146
function CodeState({
136147
renderWhitespace,
148+
renderHats,
137149
languageId,
138150
link,
139151
state,
140152
}: CodeStateProps): JSX.Element {
141153
const decorations = useMemo(
142-
() => [
143-
...state.selections.map(toDecoration),
144-
...toMarkDecorations(state.marks, state.documentContents),
145-
],
146-
[state.selections, state.marks, state.documentContents],
154+
() => toDecorations(state, renderHats),
155+
[state, renderHats],
147156
);
148157
return (
149158
<>
@@ -166,9 +175,7 @@ function CodeState({
166175
);
167176
}
168177

169-
function toDecoration(plainSelection: SelectionPlainObject): DecorationItem {
170-
const selection = plainObjectToSelection(plainSelection);
171-
178+
function toDecoration(selection: Selection): DecorationItem {
172179
const cursorClassName = selection.isReversed
173180
? "code-cursor-before"
174181
: "code-cursor-after";
@@ -187,50 +194,81 @@ function toDecoration(plainSelection: SelectionPlainObject): DecorationItem {
187194
};
188195
}
189196

190-
/**
191-
* Converts serialized token marks to Shiki decorations around the first
192-
* occurrence of each mark's decorated character within its token.
193-
*/
194-
function toMarkDecorations(
195-
marks: SerializedMarks | undefined,
196-
documentContents: string,
197+
function toDecorations(
198+
state: TestCaseSnapshot,
199+
renderHats: boolean,
200+
): DecorationItem[] {
201+
const selections = state.selections.map(plainObjectToSelection);
202+
const hatRanges = renderHats
203+
? (state.hatTokenMap ?? []).map(({ hatRange }) =>
204+
plainObjectToRange(hatRange),
205+
)
206+
: [];
207+
208+
// Shiki rejects intersecting decorations. A zero-width cursor at the end of
209+
// a hat range intersects the hat decoration, so render both on one wrapper.
210+
// We only merge at the end because code-cursor-after recreates that exact
211+
// boundary without competing with the hat's ::before pseudo-element.
212+
const mergedCursorPositions = selections
213+
.filter(
214+
(selection) =>
215+
selection.isEmpty &&
216+
hatRanges.some(({ end }) => end.isEqual(selection.active)),
217+
)
218+
.map(({ active }) => active);
219+
220+
return [
221+
// Omit cursors that the corresponding hat decoration will render instead.
222+
...selections
223+
.filter(
224+
(selection) =>
225+
!selection.isEmpty ||
226+
!mergedCursorPositions.some((position) =>
227+
position.isEqual(selection.active),
228+
),
229+
)
230+
.map(toDecoration),
231+
...toHatDecorations(state, renderHats, mergedCursorPositions),
232+
];
233+
}
234+
235+
function toHatDecorations(
236+
state: TestCaseSnapshot,
237+
renderHats: boolean,
238+
mergedCursorPositions: readonly Position[],
197239
): DecorationItem[] {
198-
if (marks == null) {
240+
if (!renderHats || state.hatTokenMap == null) {
199241
return [];
200242
}
201243

202-
const lines = documentContents.split(/\r?\n/u);
244+
const markRanges = Object.values(state.marks ?? {}).map(plainObjectToRange);
203245

204-
return Object.entries(marks).map(([key, range]) => {
205-
if (range.start.line !== range.end.line) {
206-
throw new Error(`Mark ${key} spans multiple lines`);
207-
}
246+
return state.hatTokenMap.map(({ hatStyle, hatRange }) => {
247+
const range = plainObjectToRange(hatRange);
248+
const properties: DecorationItem["properties"] = {
249+
className: ["code-hat", `code-hat-${hatStyle}`],
250+
};
208251

209-
const line = lines[range.start.line];
252+
const isReferenced = markRanges.some((markRange) =>
253+
markRange.contains(range),
254+
);
210255

211-
if (line == null) {
212-
throw new Error(`Mark ${key} is outside the document`);
256+
if (isReferenced) {
257+
properties.className?.push("code-hat-referenced");
258+
properties.style = `--code-hat-referenced-color: ${highlightColors.content.background};`;
213259
}
214260

215-
const { hatStyle, character } = splitKey(key);
216-
const tokenText = line.slice(range.start.character, range.end.character);
217-
const characterOffset = tokenText.indexOf(character);
218-
const characterStart = range.start.character + characterOffset;
219-
220-
if (characterOffset === -1) {
221-
throw new Error(`Mark ${key} does not occur in its token`);
261+
if (mergedCursorPositions.some((position) => position.isEqual(range.end))) {
262+
// The hat uses ::before and the cursor uses ::after, allowing both
263+
// visuals to share this wrapper without overlapping Shiki decorations.
264+
properties.className?.push("code-cursor-after");
222265
}
223266

224267
return {
225-
start: { line: range.start.line, character: characterStart },
226-
end: {
227-
line: range.start.line,
228-
character: characterStart + character.length,
229-
},
268+
start: hatRange.start,
269+
end: hatRange.end,
230270
alwaysWrap: true,
231-
properties: {
232-
className: ["code-hat", `code-hat-${hatStyle}`],
233-
},
271+
properties,
234272
};
235273
});
236274
}

‎packages/lib-common/src/testUtil/TestCaseSnapshot.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
RangePlainObject,
44
SelectionPlainObject,
55
SerializedMarks,
6+
SimpleTokenHat,
67
TargetPlainObject,
78
} from "../util/toPlainObject";
89

@@ -18,6 +19,7 @@ export interface TestCaseSnapshot extends MarkKeys {
1819
// https://github.com/cursorless-dev/cursorless/issues/160
1920
visibleRanges?: RangePlainObject[];
2021
marks?: SerializedMarks;
22+
hatTokenMap?: SimpleTokenHat[];
2123
timeOffsetSeconds?: number;
2224

2325
/**

‎packages/lib-common/src/testUtil/getSnapshotForComparison.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import type {
1414
*
1515
* @param finalState The final state of the test case; we only use this to
1616
* decide which fields we care about
17-
* @param readableHatMap The hat map for extractin the marks
17+
* @param initialHatTokenMap The hat map for extractin the marks
18+
* @param getFinalHatTokenMap Gets the hat map after the command has run
1819
* @param spyIde The spy IDE
1920
* @param takeSnapshot A function that takes a snapshot of the current state of
2021
* the editor
@@ -23,8 +24,9 @@ import type {
2324
*/
2425
export async function getSnapshotForComparison(
2526
finalState: TestCaseSnapshot | undefined,
26-
readableHatMap: ReadOnlyHatMap,
27+
initialHatTokenMap: ReadOnlyHatMap,
2728
spyIde: SpyIDE,
29+
getFinalHatTokenMap: () => Promise<ReadOnlyHatMap>,
2830
takeSnapshot: TestHelpers["takeSnapshot"],
2931
): Promise<Exclude<TestCaseSnapshot, "visibleRanges">> {
3032
const excludeFields: ExcludableSnapshotField[] = [];
@@ -33,9 +35,15 @@ export async function getSnapshotForComparison(
3335
finalState?.marks == null
3436
? undefined
3537
: marksToPlainObject(
36-
extractTargetedMarks(Object.keys(finalState.marks), readableHatMap),
38+
extractTargetedMarks(
39+
Object.keys(finalState.marks),
40+
initialHatTokenMap,
41+
),
3742
);
3843

44+
const finalHatTokenMap =
45+
finalState?.hatTokenMap == null ? undefined : await getFinalHatTokenMap();
46+
3947
if (finalState?.clipboard == null) {
4048
excludeFields.push("clipboard");
4149
}
@@ -61,6 +69,7 @@ export async function getSnapshotForComparison(
6169
editor,
6270
spyIde,
6371
marks,
72+
finalHatTokenMap,
6473
);
6574

6675
return resultState;

‎packages/lib-common/src/types/HatTokenMap.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { HatStyleName } from "../ide/types/hatStyles.types";
22
import type { Range } from "./Range";
3+
import type { TextEditor } from "./TextEditor";
34
import type { Token } from "./Token";
45

56
/**
@@ -18,6 +19,7 @@ export interface TokenHat {
1819
}
1920

2021
export interface ReadOnlyHatMap {
22+
getTokenHats(editor: TextEditor): readonly Readonly<TokenHat>[];
2123
getEntries(): readonly [string, Token][];
2224
getToken(hatStyle: HatStyleName, character: string): Token | undefined;
2325
}

‎packages/lib-common/src/types/TestHelpers.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
TestCaseSnapshot,
77
} from "../testUtil/TestCaseSnapshot";
88
import type { SerializedMarks, TargetPlainObject } from "../util/toPlainObject";
9-
import type { HatTokenMap } from "./HatTokenMap";
9+
import type { HatTokenMap, ReadOnlyHatMap } from "./HatTokenMap";
1010
import type { TextEditor } from "./TextEditor";
1111

1212
export interface TestHelpers {
@@ -20,6 +20,7 @@ export interface TestHelpers {
2020
editor: TextEditor,
2121
ide: IDE,
2222
marks: SerializedMarks | undefined,
23+
hatTokenMap: ReadOnlyHatMap | undefined,
2324
): Promise<TestCaseSnapshot>;
2425

2526
setStoredTarget(

0 commit comments

Comments
 (0)