Skip to content

Commit e8214db

Browse files
committed
Add the scope visualizer
1 parent b8532c3 commit e8214db

31 files changed

+1423
-5
lines changed

cursorless-talon/src/cursorless.talon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ tag: user.cursorless
2020
user.cursorless_wrap(cursorless_wrap_action, cursorless_target, cursorless_wrapper)
2121

2222
{user.cursorless_homophone} settings: user.cursorless_show_settings_in_ide()
23+
24+
{user.cursorless_show_scope_visualizer} <user.cursorless_scope_type> [{user.cursorless_visualization_type}]:
25+
user.private_cursorless_show_scope_visualizer(cursorless_scope_type, cursorless_visualization_type or "content")
26+
{user.cursorless_hide_scope_visualizer}:
27+
user.private_cursorless_hide_scope_visualizer()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from talon import Module, app
2+
3+
from .csv_overrides import init_csv_and_watch_changes
4+
from .cursorless_command_server import run_rpc_command_no_wait
5+
6+
mod = Module()
7+
mod.list("cursorless_show_scope_visualizer", desc="Show scope visualizer")
8+
mod.list("cursorless_hide_scope_visualizer", desc="Hide scope visualizer")
9+
mod.list(
10+
"cursorless_visualization_type",
11+
desc='Cursorless visualization type, e.g. "removal" or "iteration"',
12+
)
13+
14+
# NOTE: Please do not change these dicts. Use the CSVs for customization.
15+
# See https://www.cursorless.org/docs/user/customization/
16+
visualization_types = {
17+
"removal": "removal",
18+
"iteration": "iteration",
19+
"content": "content",
20+
}
21+
22+
23+
@mod.action_class
24+
class Actions:
25+
def private_cursorless_show_scope_visualizer(
26+
scope_type: dict, visualization_type: str
27+
):
28+
"""Shows scope visualizer"""
29+
run_rpc_command_no_wait(
30+
"cursorless.showScopeVisualizer", scope_type, visualization_type
31+
)
32+
33+
def private_cursorless_hide_scope_visualizer():
34+
"""Hides scope visualizer"""
35+
run_rpc_command_no_wait("cursorless.hideScopeVisualizer")
36+
37+
38+
def on_ready():
39+
init_csv_and_watch_changes(
40+
"scope_visualizer",
41+
{
42+
"show_scope_visualizer": {"visualize": "showScopeVisualizer"},
43+
"hide_scope_visualizer": {"visualize nothing": "hideScopeVisualizer"},
44+
"visualization_type": visualization_types,
45+
},
46+
)
47+
48+
49+
app.register("ready", on_ready)

packages/common/src/cursorlessCommandIds.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export const cursorlessCommandIds = [
4343
"cursorless.showQuickPick",
4444
"cursorless.takeSnapshot",
4545
"cursorless.toggleDecorations",
46+
"cursorless.showScopeVisualizer",
47+
"cursorless.hideScopeVisualizer",
4648
] as const satisfies readonly `cursorless.${string}`[];
4749

4850
export type CursorlessCommandId = (typeof cursorlessCommandIds)[number];
@@ -104,4 +106,10 @@ export const cursorlessCommandDescriptions: Record<
104106
["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand(
105107
"Toggle the cursorless modal mode",
106108
),
109+
["cursorless.showScopeVisualizer"]: new HiddenCommand(
110+
"Show the scope visualizer",
111+
),
112+
["cursorless.hideScopeVisualizer"]: new HiddenCommand(
113+
"Hide the scope visualizer",
114+
),
107115
};

packages/common/src/testUtil/toPlainObject.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import type {
77
} from "..";
88
import { FlashStyle, isLineRange } from "..";
99
import { Token } from "../types/Token";
10-
import { Position } from "../types/Position";
11-
import { Range } from "../types/Range";
1210
import { Selection } from "../types/Selection";
1311

1412
export type PositionPlainObject = {
@@ -85,7 +83,23 @@ export type SerializedMarks = {
8583
[decoratedCharacter: string]: RangePlainObject;
8684
};
8785

88-
export function rangeToPlainObject(range: Range): RangePlainObject {
86+
/**
87+
* Simplified Position interface containing only what we need for serialization
88+
*/
89+
interface SimplePosition {
90+
line: number;
91+
character: number;
92+
}
93+
94+
/**
95+
* Simplified Range interface containing only what we need for serialization
96+
*/
97+
interface SimpleRange {
98+
start: SimplePosition;
99+
end: SimplePosition;
100+
}
101+
102+
export function rangeToPlainObject(range: SimpleRange): RangePlainObject {
89103
return {
90104
start: positionToPlainObject(range.start),
91105
end: positionToPlainObject(range.end),
@@ -104,7 +118,7 @@ export function selectionToPlainObject(
104118
export function positionToPlainObject({
105119
line,
106120
character,
107-
}: Position): PositionPlainObject {
121+
}: SimplePosition): PositionPlainObject {
108122
return { line, character };
109123
}
110124

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { assert } from "chai";
2+
import * as sinon from "sinon";
3+
import {
4+
createDecorationTypeCallToPlainObject,
5+
setDecorationsCallToPlainObject,
6+
} from "./spyCallsToPlainObject";
7+
import { Fakes, ExpectedArgs } from "./scopeVisualizerTest.types";
8+
9+
export function checkAndResetFakes(fakes: Fakes, expected: ExpectedArgs) {
10+
const actual = getSpyCallsAndResetFakes(fakes);
11+
assert.deepStrictEqual(actual, expected, JSON.stringify(actual));
12+
}
13+
14+
function getSpyCallsAndResetFakes({
15+
createTextEditorDecorationType,
16+
setDecorations,
17+
dispose,
18+
}: Fakes) {
19+
return {
20+
decorationRenderOptions: getAndResetFake(
21+
createTextEditorDecorationType,
22+
createDecorationTypeCallToPlainObject,
23+
),
24+
decorationRanges: getAndResetFake(
25+
setDecorations,
26+
setDecorationsCallToPlainObject,
27+
),
28+
disposedDecorationIds: getAndResetFake(dispose, ({ args: [id] }) => id),
29+
};
30+
}
31+
32+
function getAndResetFake<ArgList extends any[], Return, Expected>(
33+
spy: sinon.SinonSpy<ArgList, Return>,
34+
transform: (call: sinon.SinonSpyCall<ArgList, Return>) => Expected,
35+
) {
36+
const actual = spy.getCalls().map(transform);
37+
spy.resetHistory();
38+
return actual;
39+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ScopeVisualizerColorConfig } from "@cursorless/vscode-common";
2+
3+
/**
4+
* Fake color config to use for testing. We use an alpha of 50% and try to use
5+
* different rgb channels where possible to make it easier to see what happens
6+
* when we blend colors.
7+
*/
8+
export const COLOR_CONFIG: ScopeVisualizerColorConfig = {
9+
dark: {
10+
content: {
11+
background: "#00000180",
12+
borderPorous: "#00000280",
13+
borderSolid: "#00000380",
14+
},
15+
domain: {
16+
background: "#01000080",
17+
borderPorous: "#02000080",
18+
borderSolid: "#03000080",
19+
},
20+
iteration: {
21+
background: "#00000480",
22+
borderPorous: "#00000580",
23+
borderSolid: "#00000680",
24+
},
25+
removal: {
26+
background: "#00010080",
27+
borderPorous: "#00020080",
28+
borderSolid: "#00030080",
29+
},
30+
},
31+
light: {
32+
content: {
33+
background: "#00000180",
34+
borderPorous: "#00000280",
35+
borderSolid: "#00000380",
36+
},
37+
domain: {
38+
background: "#01000080",
39+
borderPorous: "#02000080",
40+
borderSolid: "#03000080",
41+
},
42+
iteration: {
43+
background: "#00000480",
44+
borderPorous: "#00000580",
45+
borderSolid: "#00000680",
46+
},
47+
removal: {
48+
background: "#00010080",
49+
borderPorous: "#00020080",
50+
borderSolid: "#00030080",
51+
},
52+
},
53+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { VscodeApi, getCursorlessApi } from "@cursorless/vscode-common";
2+
import * as sinon from "sinon";
3+
import { DecorationRenderOptions, WorkspaceConfiguration } from "vscode";
4+
import { COLOR_CONFIG } from "./colorConfig";
5+
import {
6+
Fakes,
7+
MockDecorationType,
8+
SetDecorationsParameters,
9+
} from "./scopeVisualizerTest.types";
10+
11+
export async function injectFakes(): Promise<Fakes> {
12+
const { vscodeApi } = (await getCursorlessApi()).testHelpers!;
13+
14+
const dispose = sinon.fake<[number], void>();
15+
16+
let decorationIndex = 0;
17+
const createTextEditorDecorationType = sinon.fake<
18+
Parameters<VscodeApi["window"]["createTextEditorDecorationType"]>,
19+
MockDecorationType
20+
>((_options: DecorationRenderOptions) => {
21+
const id = decorationIndex++;
22+
return {
23+
dispose() {
24+
dispose(id);
25+
},
26+
id,
27+
};
28+
});
29+
30+
const setDecorations = sinon.fake<
31+
SetDecorationsParameters,
32+
ReturnType<VscodeApi["editor"]["setDecorations"]>
33+
>();
34+
35+
const getConfigurationValue = sinon.fake.returns(COLOR_CONFIG);
36+
37+
sinon.replace(
38+
vscodeApi.window,
39+
"createTextEditorDecorationType",
40+
createTextEditorDecorationType as any,
41+
);
42+
sinon.replace(vscodeApi.editor, "setDecorations", setDecorations as any);
43+
sinon.replace(
44+
vscodeApi.workspace,
45+
"getConfiguration",
46+
sinon.fake.returns({
47+
get: getConfigurationValue,
48+
} as unknown as WorkspaceConfiguration),
49+
);
50+
51+
return { setDecorations, createTextEditorDecorationType, dispose };
52+
}
Loading
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { openNewEditor } from "@cursorless/vscode-common";
2+
import * as vscode from "vscode";
3+
import { checkAndResetFakes } from "./checkAndResetFakes";
4+
import { injectFakes } from "./injectFakes";
5+
import { ExpectedArgs } from "./scopeVisualizerTest.types";
6+
7+
/**
8+
* Tests that the scope visualizer works with multiline content, by
9+
* ensuring that the correct decorations are applied so that it looks
10+
* like `./runBasicMultilineContentTest.png`.
11+
*/
12+
export async function runBasicMultilineContentTest() {
13+
await openNewEditor(contents, {
14+
languageId: "typescript",
15+
});
16+
17+
const fakes = await injectFakes();
18+
19+
await vscode.commands.executeCommand(
20+
"cursorless.showScopeVisualizer",
21+
{
22+
type: "namedFunction",
23+
},
24+
"content",
25+
);
26+
27+
checkAndResetFakes(fakes, expectedArgs);
28+
}
29+
30+
const contents = `
31+
function helloWorld() {
32+
33+
}
34+
`;
35+
36+
const expectedArgs: ExpectedArgs = {
37+
decorationRenderOptions: [
38+
{
39+
backgroundColor: "#000001c0",
40+
borderColor: "#010002c0 #010001c0 #010001c0 #010002c0",
41+
borderStyle: "solid dashed dashed solid",
42+
borderRadius: "2px 0px 0px 0px",
43+
isWholeLine: false,
44+
id: 0,
45+
},
46+
{
47+
backgroundColor: "#000001c0",
48+
borderColor: "#010001c0 #010001c0 #010001c0 #010001c0",
49+
borderStyle: "none dashed none dashed",
50+
borderRadius: "0px 0px 0px 0px",
51+
isWholeLine: false,
52+
id: 1,
53+
},
54+
{
55+
backgroundColor: "#000001c0",
56+
borderColor: "#010001c0 #010002c0 #010002c0 #010001c0",
57+
borderStyle: "dashed solid solid dashed",
58+
borderRadius: "0px 0px 2px 0px",
59+
isWholeLine: false,
60+
id: 2,
61+
},
62+
],
63+
decorationRanges: [
64+
{
65+
decorationId: 0,
66+
ranges: [
67+
{ start: { line: 1, character: 0 }, end: { line: 1, character: 23 } },
68+
],
69+
},
70+
{
71+
decorationId: 1,
72+
ranges: [
73+
{ start: { line: 2, character: 0 }, end: { line: 2, character: 0 } },
74+
],
75+
},
76+
{
77+
decorationId: 2,
78+
ranges: [
79+
{ start: { line: 3, character: 0 }, end: { line: 3, character: 1 } },
80+
],
81+
},
82+
],
83+
disposedDecorationIds: [],
84+
};
Loading

0 commit comments

Comments
 (0)