diff --git a/packages/common/src/types/ScopeProvider.ts b/packages/common/src/types/ScopeProvider.ts index 2e987b84d4..6d25b7613d 100644 --- a/packages/common/src/types/ScopeProvider.ts +++ b/packages/common/src/types/ScopeProvider.ts @@ -19,6 +19,19 @@ export interface ScopeProvider { config: ScopeRangeConfig, ) => ScopeRanges[]; + /** + * Get the scope ranges for the given editor and range. + * @param editor The editor + * @param scopeType The scope type to get ranges for + * @param range The range to get scope ranges for + * @returns A list of scope ranges + */ + provideScopeRangesForRange( + editor: TextEditor, + scopeType: ScopeType, + range: Range, + ): ScopeRanges[]; + /** * Get the iteration scope ranges for the given editor. * @param editor The editor diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 57375bc3ec..d60dbb3057 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -175,6 +175,7 @@ function createScopeProvider( return { provideScopeRanges: rangeProvider.provideScopeRanges, + provideScopeRangesForRange: rangeProvider.provideScopeRangesForRange, provideIterationScopeRanges: rangeProvider.provideIterationScopeRanges, onDidChangeScopeRanges: rangeWatcher.onDidChangeScopeRanges, onDidChangeIterationScopeRanges: diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/getCollectionItemRemovalRange.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/getCollectionItemRemovalRange.ts index e08b72b82a..d06cee56ca 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/getCollectionItemRemovalRange.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/getCollectionItemRemovalRange.ts @@ -1,5 +1,4 @@ import type { Range, TextEditor } from "@cursorless/common"; - import { getRangeLength } from "../../../../util/rangeUtils"; /** diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts index 5ae8efc6b0..971e234af8 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts @@ -3,9 +3,10 @@ import type { IterationScopeRanges, ScopeRangeConfig, ScopeRanges, + ScopeType, TextEditor, } from "@cursorless/common"; - +import { Range } from "@cursorless/common"; import type { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; import type { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory"; import { getIterationRange } from "./getIterationRange"; @@ -21,6 +22,8 @@ export class ScopeRangeProvider { private modifierStageFactory: ModifierStageFactory, ) { this.provideScopeRanges = this.provideScopeRanges.bind(this); + this.provideScopeRangesForRange = + this.provideScopeRangesForRange.bind(this); this.provideIterationScopeRanges = this.provideIterationScopeRanges.bind(this); } @@ -45,6 +48,32 @@ export class ScopeRangeProvider { ); } + provideScopeRangesForRange( + editor: TextEditor, + scopeType: ScopeType, + range: Range, + ): ScopeRanges[] { + const scopeHandler = this.scopeHandlerFactory.maybeCreate( + scopeType, + editor.document.languageId, + ); + + if (scopeHandler == null) { + return []; + } + + // Need to have a non empty intersection with the scopes + if (range.isEmpty) { + const offset = editor.document.offsetAt(range.start); + range = new Range( + editor.document.positionAt(offset - 1), + editor.document.positionAt(offset + 1), + ); + } + + return getScopeRanges(editor, scopeHandler, range); + } + provideIterationScopeRanges( editor: TextEditor, { scopeType, visibleOnly, includeNestedTargets }: IterationScopeRangeConfig, diff --git a/packages/cursorless-engine/src/util/rangeUtils.ts b/packages/cursorless-engine/src/util/rangeUtils.ts index bf2b5d17c8..5edcb8a170 100644 --- a/packages/cursorless-engine/src/util/rangeUtils.ts +++ b/packages/cursorless-engine/src/util/rangeUtils.ts @@ -27,10 +27,15 @@ export function expandToFullLine(editor: TextEditor, range: Range) { } export function getRangeLength(editor: TextEditor, range: Range) { - return range.isEmpty - ? 0 - : editor.document.offsetAt(range.end) - - editor.document.offsetAt(range.start); + if (range.isEmpty) { + return 0; + } + if (range.isSingleLine) { + return range.end.character - range.start.character; + } + return ( + editor.document.offsetAt(range.end) - editor.document.offsetAt(range.start) + ); } /** diff --git a/packages/cursorless-org-docs/src/docs/user/images/sidebar-scopes.png b/packages/cursorless-org-docs/src/docs/user/images/sidebar-scopes.png new file mode 100644 index 0000000000..19e651842f Binary files /dev/null and b/packages/cursorless-org-docs/src/docs/user/images/sidebar-scopes.png differ diff --git a/packages/cursorless-org-docs/src/docs/user/scope-sidebar.md b/packages/cursorless-org-docs/src/docs/user/scope-sidebar.md index 5d26688583..bf015bb95e 100644 --- a/packages/cursorless-org-docs/src/docs/user/scope-sidebar.md +++ b/packages/cursorless-org-docs/src/docs/user/scope-sidebar.md @@ -1,3 +1,25 @@ # The Cursorless sidebar -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. +You can say `"bar cursorless"` to show the Cursorless sidebar. + +## Scopes + +- Displays all available scopes, grouped by whether they are currently present and supported in the active text editor. +- The list updates in real time as you type or move your selection. +- Clicking a scope highlights it using the [scope visualizer](scope-visualizer.md). +- Shows your custom spoken forms for scopes. + +### Scope icons + +To identify the scope for a piece of code: + +1. First select the code in your editor. +2. Then look in the sidebar for the following icons:\ + 🎯 The scope exactly matches your selection\ + 📦 The scope contains your selection + +![sidebar scopes](./images/sidebar-scopes.png) + +## Tutorial + +Interactive tutorial to learn Cursorless diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts index 0c88f39a15..df38f06cba 100644 --- a/packages/cursorless-vscode/src/ScopeTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -2,7 +2,10 @@ import type { CursorlessCommandId, ScopeProvider, ScopeSupportInfo, + ScopeType, ScopeTypeInfo, + Selection, + TextEditor, } from "@cursorless/common"; import { CURSORLESS_SCOPE_TREE_VIEW_ID, @@ -12,8 +15,11 @@ import { serializeScopeType, uriEncodeHashId, } from "@cursorless/common"; -import type { CustomSpokenFormGenerator } from "@cursorless/cursorless-engine"; -import type { VscodeApi } from "@cursorless/vscode-common"; +import { + ide, + type CustomSpokenFormGenerator, +} from "@cursorless/cursorless-engine"; +import { type VscodeApi } from "@cursorless/vscode-common"; import { isEqual } from "lodash-es"; import type { Disposable, @@ -79,7 +85,7 @@ export class ScopeTreeProvider implements TreeDataProvider { } } - onDidChangeVisible(e: TreeViewVisibilityChangeEvent) { + private onDidChangeVisible(e: TreeViewVisibilityChangeEvent) { if (e.visible) { if (this.visibleDisposable != null) { return; @@ -105,6 +111,9 @@ export class ScopeTreeProvider implements TreeDataProvider { this.scopeVisualizer.onDidChangeScopeType(() => { this._onDidChangeTreeData.fire(); }), + this.vscodeApi.window.onDidChangeTextEditorSelection(() => { + this._onDidChangeTreeData.fire(); + }), ); } @@ -160,6 +169,20 @@ export class ScopeTreeProvider implements TreeDataProvider { private getScopeTypesWithSupport( scopeSupport: ScopeSupport, ): ScopeSupportTreeItem[] { + const getContainmentIcon = (() => { + if (scopeSupport !== ScopeSupport.supportedAndPresentInEditor) { + return null; + } + const editor = ide().activeTextEditor; + if (editor == null || editor.selections.length !== 1) { + return null; + } + const selection = editor.selections[0]; + return (scopeType: ScopeType) => { + return this.getContainmentIcon(editor, selection, scopeType); + }; + })(); + return this.supportLevels .filter( (supportLevel) => @@ -177,6 +200,7 @@ export class ScopeTreeProvider implements TreeDataProvider { new ScopeSupportTreeItem( supportLevel, isEqual(supportLevel.scopeType, this.scopeVisualizer.scopeType), + getContainmentIcon?.(supportLevel.scopeType), ), ) .sort((a, b) => { @@ -200,6 +224,33 @@ export class ScopeTreeProvider implements TreeDataProvider { }); } + private getContainmentIcon( + editor: TextEditor, + selection: Selection, + scopeType: ScopeType, + ): string | undefined { + const scopes = this.scopeProvider.provideScopeRangesForRange( + editor, + scopeType, + selection, + ); + + for (const scope of scopes) { + for (const target of scope.targets) { + // Scope target exactly matches selection + if (target.contentRange.isRangeEqual(selection)) { + return "🎯"; + } + // Scope target contains selection + if (target.contentRange.contains(selection)) { + return "📦"; + } + } + } + + return undefined; + } + dispose() { this.visibleDisposable?.dispose(); } @@ -225,6 +276,7 @@ class ScopeSupportTreeItem extends TreeItem { constructor( public readonly scopeTypeInfo: ScopeTypeInfo, isVisualized: boolean, + containmentIcon: string | undefined, ) { let label: string; let tooltip: string; @@ -250,7 +302,10 @@ class ScopeSupportTreeItem extends TreeItem { ); this.tooltip = tooltip == null ? tooltip : new MarkdownString(tooltip); - this.description = scopeTypeInfo.humanReadableName; + this.description = + containmentIcon != null + ? `${containmentIcon} ${scopeTypeInfo.humanReadableName}` + : scopeTypeInfo.humanReadableName; this.command = isVisualized ? { @@ -271,9 +326,8 @@ class ScopeSupportTreeItem extends TreeItem { if (scopeTypeInfo.isLanguageSpecific) { const languageId = window.activeTextEditor?.document.languageId; if (languageId != null) { - const fileExtension = getLanguageExtensionSampleFromLanguageId( - window.activeTextEditor!.document.languageId, - ); + const fileExtension = + getLanguageExtensionSampleFromLanguageId(languageId); if (fileExtension != null) { this.resourceUri = URI.parse( "cursorless-dummy://dummy/dummy" + fileExtension,