Skip to content

Commit 8e0ddaf

Browse files
Add containment icons to scope visualizer (#3085)
The new category shows all scopes that intersect with your current selection. Scopes smaller than the current selection are excluded and then sorted by smallest first. The result is that the scopes at the top are the ones that match your current selection the best. If you're ever interested in different ways of targeting a particular text you just need to select that and see which scopes match. I'm not sure about the name/label. Any suggestions for something better than `Selected`? <img width="923" height="749" alt="image" src="https://github.com/user-attachments/assets/f8517721-a97e-4182-a97d-08a1e2cff6b6" />
1 parent b0af101 commit 8e0ddaf

File tree

8 files changed

+137
-14
lines changed

8 files changed

+137
-14
lines changed

packages/common/src/types/ScopeProvider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ export interface ScopeProvider {
1919
config: ScopeRangeConfig,
2020
) => ScopeRanges[];
2121

22+
/**
23+
* Get the scope ranges for the given editor and range.
24+
* @param editor The editor
25+
* @param scopeType The scope type to get ranges for
26+
* @param range The range to get scope ranges for
27+
* @returns A list of scope ranges
28+
*/
29+
provideScopeRangesForRange(
30+
editor: TextEditor,
31+
scopeType: ScopeType,
32+
range: Range,
33+
): ScopeRanges[];
34+
2235
/**
2336
* Get the iteration scope ranges for the given editor.
2437
* @param editor The editor

packages/cursorless-engine/src/cursorlessEngine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ function createScopeProvider(
175175

176176
return {
177177
provideScopeRanges: rangeProvider.provideScopeRanges,
178+
provideScopeRangesForRange: rangeProvider.provideScopeRangesForRange,
178179
provideIterationScopeRanges: rangeProvider.provideIterationScopeRanges,
179180
onDidChangeScopeRanges: rangeWatcher.onDidChangeScopeRanges,
180181
onDidChangeIterationScopeRanges:

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/getCollectionItemRemovalRange.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Range, TextEditor } from "@cursorless/common";
2-
32
import { getRangeLength } from "../../../../util/rangeUtils";
43

54
/**

packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import type {
33
IterationScopeRanges,
44
ScopeRangeConfig,
55
ScopeRanges,
6+
ScopeType,
67
TextEditor,
78
} from "@cursorless/common";
8-
9+
import { Range } from "@cursorless/common";
910
import type { ModifierStageFactory } from "../processTargets/ModifierStageFactory";
1011
import type { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory";
1112
import { getIterationRange } from "./getIterationRange";
@@ -21,6 +22,8 @@ export class ScopeRangeProvider {
2122
private modifierStageFactory: ModifierStageFactory,
2223
) {
2324
this.provideScopeRanges = this.provideScopeRanges.bind(this);
25+
this.provideScopeRangesForRange =
26+
this.provideScopeRangesForRange.bind(this);
2427
this.provideIterationScopeRanges =
2528
this.provideIterationScopeRanges.bind(this);
2629
}
@@ -45,6 +48,32 @@ export class ScopeRangeProvider {
4548
);
4649
}
4750

51+
provideScopeRangesForRange(
52+
editor: TextEditor,
53+
scopeType: ScopeType,
54+
range: Range,
55+
): ScopeRanges[] {
56+
const scopeHandler = this.scopeHandlerFactory.maybeCreate(
57+
scopeType,
58+
editor.document.languageId,
59+
);
60+
61+
if (scopeHandler == null) {
62+
return [];
63+
}
64+
65+
// Need to have a non empty intersection with the scopes
66+
if (range.isEmpty) {
67+
const offset = editor.document.offsetAt(range.start);
68+
range = new Range(
69+
editor.document.positionAt(offset - 1),
70+
editor.document.positionAt(offset + 1),
71+
);
72+
}
73+
74+
return getScopeRanges(editor, scopeHandler, range);
75+
}
76+
4877
provideIterationScopeRanges(
4978
editor: TextEditor,
5079
{ scopeType, visibleOnly, includeNestedTargets }: IterationScopeRangeConfig,

packages/cursorless-engine/src/util/rangeUtils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ export function expandToFullLine(editor: TextEditor, range: Range) {
2727
}
2828

2929
export function getRangeLength(editor: TextEditor, range: Range) {
30-
return range.isEmpty
31-
? 0
32-
: editor.document.offsetAt(range.end) -
33-
editor.document.offsetAt(range.start);
30+
if (range.isEmpty) {
31+
return 0;
32+
}
33+
if (range.isSingleLine) {
34+
return range.end.character - range.start.character;
35+
}
36+
return (
37+
editor.document.offsetAt(range.end) - editor.document.offsetAt(range.start)
38+
);
3439
}
3540

3641
/**
71 KB
Loading
Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
11
# The Cursorless sidebar
22

3-
You can say `"bar cursorless"` to show the Cursorless sidebar. Currently, the sidebar just contains a section showing a list of all scopes, organized by whether they are present and supported in the active text editor. As you type, the list of present scopes will update in real time. Clicking on a scope will visualize it using the [scope visualizer](scope-visualizer.md). Note that for legacy scopes, we can't tell whether they are present in the active text editor, so we list them under a separate Legacy section. Clicking on these scopes will not visualize them, as we also don't support visualizing legacy scopes.
3+
You can say `"bar cursorless"` to show the Cursorless sidebar.
4+
5+
## Scopes
6+
7+
- Displays all available scopes, grouped by whether they are currently present and supported in the active text editor.
8+
- The list updates in real time as you type or move your selection.
9+
- Clicking a scope highlights it using the [scope visualizer](scope-visualizer.md).
10+
- Shows your custom spoken forms for scopes.
11+
12+
### Scope icons
13+
14+
To identify the scope for a piece of code:
15+
16+
1. First select the code in your editor.
17+
2. Then look in the sidebar for the following icons:\
18+
🎯 The scope exactly matches your selection\
19+
📦 The scope contains your selection
20+
21+
![sidebar scopes](./images/sidebar-scopes.png)
22+
23+
## Tutorial
24+
25+
Interactive tutorial to learn Cursorless

packages/cursorless-vscode/src/ScopeTreeProvider.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type {
22
CursorlessCommandId,
33
ScopeProvider,
44
ScopeSupportInfo,
5+
ScopeType,
56
ScopeTypeInfo,
7+
Selection,
8+
TextEditor,
69
} from "@cursorless/common";
710
import {
811
CURSORLESS_SCOPE_TREE_VIEW_ID,
@@ -12,8 +15,11 @@ import {
1215
serializeScopeType,
1316
uriEncodeHashId,
1417
} from "@cursorless/common";
15-
import type { CustomSpokenFormGenerator } from "@cursorless/cursorless-engine";
16-
import type { VscodeApi } from "@cursorless/vscode-common";
18+
import {
19+
ide,
20+
type CustomSpokenFormGenerator,
21+
} from "@cursorless/cursorless-engine";
22+
import { type VscodeApi } from "@cursorless/vscode-common";
1723
import { isEqual } from "lodash-es";
1824
import type {
1925
Disposable,
@@ -79,7 +85,7 @@ export class ScopeTreeProvider implements TreeDataProvider<MyTreeItem> {
7985
}
8086
}
8187

82-
onDidChangeVisible(e: TreeViewVisibilityChangeEvent) {
88+
private onDidChangeVisible(e: TreeViewVisibilityChangeEvent) {
8389
if (e.visible) {
8490
if (this.visibleDisposable != null) {
8591
return;
@@ -105,6 +111,9 @@ export class ScopeTreeProvider implements TreeDataProvider<MyTreeItem> {
105111
this.scopeVisualizer.onDidChangeScopeType(() => {
106112
this._onDidChangeTreeData.fire();
107113
}),
114+
this.vscodeApi.window.onDidChangeTextEditorSelection(() => {
115+
this._onDidChangeTreeData.fire();
116+
}),
108117
);
109118
}
110119

@@ -160,6 +169,20 @@ export class ScopeTreeProvider implements TreeDataProvider<MyTreeItem> {
160169
private getScopeTypesWithSupport(
161170
scopeSupport: ScopeSupport,
162171
): ScopeSupportTreeItem[] {
172+
const getContainmentIcon = (() => {
173+
if (scopeSupport !== ScopeSupport.supportedAndPresentInEditor) {
174+
return null;
175+
}
176+
const editor = ide().activeTextEditor;
177+
if (editor == null || editor.selections.length !== 1) {
178+
return null;
179+
}
180+
const selection = editor.selections[0];
181+
return (scopeType: ScopeType) => {
182+
return this.getContainmentIcon(editor, selection, scopeType);
183+
};
184+
})();
185+
163186
return this.supportLevels
164187
.filter(
165188
(supportLevel) =>
@@ -177,6 +200,7 @@ export class ScopeTreeProvider implements TreeDataProvider<MyTreeItem> {
177200
new ScopeSupportTreeItem(
178201
supportLevel,
179202
isEqual(supportLevel.scopeType, this.scopeVisualizer.scopeType),
203+
getContainmentIcon?.(supportLevel.scopeType),
180204
),
181205
)
182206
.sort((a, b) => {
@@ -200,6 +224,33 @@ export class ScopeTreeProvider implements TreeDataProvider<MyTreeItem> {
200224
});
201225
}
202226

227+
private getContainmentIcon(
228+
editor: TextEditor,
229+
selection: Selection,
230+
scopeType: ScopeType,
231+
): string | undefined {
232+
const scopes = this.scopeProvider.provideScopeRangesForRange(
233+
editor,
234+
scopeType,
235+
selection,
236+
);
237+
238+
for (const scope of scopes) {
239+
for (const target of scope.targets) {
240+
// Scope target exactly matches selection
241+
if (target.contentRange.isRangeEqual(selection)) {
242+
return "🎯";
243+
}
244+
// Scope target contains selection
245+
if (target.contentRange.contains(selection)) {
246+
return "📦";
247+
}
248+
}
249+
}
250+
251+
return undefined;
252+
}
253+
203254
dispose() {
204255
this.visibleDisposable?.dispose();
205256
}
@@ -225,6 +276,7 @@ class ScopeSupportTreeItem extends TreeItem {
225276
constructor(
226277
public readonly scopeTypeInfo: ScopeTypeInfo,
227278
isVisualized: boolean,
279+
containmentIcon: string | undefined,
228280
) {
229281
let label: string;
230282
let tooltip: string;
@@ -250,7 +302,10 @@ class ScopeSupportTreeItem extends TreeItem {
250302
);
251303

252304
this.tooltip = tooltip == null ? tooltip : new MarkdownString(tooltip);
253-
this.description = scopeTypeInfo.humanReadableName;
305+
this.description =
306+
containmentIcon != null
307+
? `${containmentIcon} ${scopeTypeInfo.humanReadableName}`
308+
: scopeTypeInfo.humanReadableName;
254309

255310
this.command = isVisualized
256311
? {
@@ -271,9 +326,8 @@ class ScopeSupportTreeItem extends TreeItem {
271326
if (scopeTypeInfo.isLanguageSpecific) {
272327
const languageId = window.activeTextEditor?.document.languageId;
273328
if (languageId != null) {
274-
const fileExtension = getLanguageExtensionSampleFromLanguageId(
275-
window.activeTextEditor!.document.languageId,
276-
);
329+
const fileExtension =
330+
getLanguageExtensionSampleFromLanguageId(languageId);
277331
if (fileExtension != null) {
278332
this.resourceUri = URI.parse(
279333
"cursorless-dummy://dummy/dummy" + fileExtension,

0 commit comments

Comments
 (0)