diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index a9a60a0cb5..615d57bb39 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -20,3 +20,8 @@ tag: user.cursorless user.cursorless_wrap(cursorless_wrap_action, cursorless_target, cursorless_wrapper) {user.cursorless_homophone} settings: user.cursorless_show_settings_in_ide() + +{user.cursorless_show_scope_visualizer} [{user.cursorless_visualization_type}]: + user.private_cursorless_show_scope_visualizer(cursorless_scope_type, cursorless_visualization_type or "content") +{user.cursorless_hide_scope_visualizer}: + user.private_cursorless_hide_scope_visualizer() diff --git a/cursorless-talon/src/scope_visualizer.py b/cursorless-talon/src/scope_visualizer.py new file mode 100644 index 0000000000..0c7ddda9c5 --- /dev/null +++ b/cursorless-talon/src/scope_visualizer.py @@ -0,0 +1,49 @@ +from talon import Module, app + +from .csv_overrides import init_csv_and_watch_changes +from .cursorless_command_server import run_rpc_command_no_wait + +mod = Module() +mod.list("cursorless_show_scope_visualizer", desc="Show scope visualizer") +mod.list("cursorless_hide_scope_visualizer", desc="Hide scope visualizer") +mod.list( + "cursorless_visualization_type", + desc='Cursorless visualization type, e.g. "removal" or "iteration"', +) + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://www.cursorless.org/docs/user/customization/ +visualization_types = { + "removal": "removal", + "iteration": "iteration", + "content": "content", +} + + +@mod.action_class +class Actions: + def private_cursorless_show_scope_visualizer( + scope_type: dict, visualization_type: str + ): + """Shows scope visualizer""" + run_rpc_command_no_wait( + "cursorless.showScopeVisualizer", scope_type, visualization_type + ) + + def private_cursorless_hide_scope_visualizer(): + """Hides scope visualizer""" + run_rpc_command_no_wait("cursorless.hideScopeVisualizer") + + +def on_ready(): + init_csv_and_watch_changes( + "scope_visualizer", + { + "show_scope_visualizer": {"visualize": "showScopeVisualizer"}, + "hide_scope_visualizer": {"visualize nothing": "hideScopeVisualizer"}, + "visualization_type": visualization_types, + }, + ) + + +app.register("ready", on_ready) diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index c369ca3c74..37770954f9 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -43,6 +43,8 @@ export const cursorlessCommandIds = [ "cursorless.showQuickPick", "cursorless.takeSnapshot", "cursorless.toggleDecorations", + "cursorless.showScopeVisualizer", + "cursorless.hideScopeVisualizer", ] as const satisfies readonly `cursorless.${string}`[]; export type CursorlessCommandId = (typeof cursorlessCommandIds)[number]; @@ -104,4 +106,10 @@ export const cursorlessCommandDescriptions: Record< ["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand( "Toggle the cursorless modal mode", ), + ["cursorless.showScopeVisualizer"]: new HiddenCommand( + "Show the scope visualizer", + ), + ["cursorless.hideScopeVisualizer"]: new HiddenCommand( + "Hide the scope visualizer", + ), }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a4b6aabba0..186323cc9e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -52,6 +52,7 @@ export { default as DefaultMap } from "./util/DefaultMap"; export * from "./types/GeneralizedRange"; export * from "./types/RangeOffsets"; export * from "./util/omitByDeep"; +export * from "./util/range"; export * from "./testUtil/isTesting"; export * from "./testUtil/testConstants"; export * from "./testUtil/getFixturePaths"; @@ -84,3 +85,4 @@ export * from "./extensionDependencies"; export * from "./getFakeCommandServerApi"; export * from "./types/TestCaseFixture"; export * from "./util/getEnvironmentVariableStrict"; +export * from "./util/CompositeKeyDefaultMap"; diff --git a/packages/common/src/testUtil/toPlainObject.ts b/packages/common/src/testUtil/toPlainObject.ts index 3ab03b6ea3..626378603d 100644 --- a/packages/common/src/testUtil/toPlainObject.ts +++ b/packages/common/src/testUtil/toPlainObject.ts @@ -7,8 +7,6 @@ import type { } from ".."; import { FlashStyle, isLineRange } from ".."; import { Token } from "../types/Token"; -import { Position } from "../types/Position"; -import { Range } from "../types/Range"; import { Selection } from "../types/Selection"; export type PositionPlainObject = { @@ -85,7 +83,23 @@ export type SerializedMarks = { [decoratedCharacter: string]: RangePlainObject; }; -export function rangeToPlainObject(range: Range): RangePlainObject { +/** + * Simplified Position interface containing only what we need for serialization + */ +interface SimplePosition { + line: number; + character: number; +} + +/** + * Simplified Range interface containing only what we need for serialization + */ +interface SimpleRange { + start: SimplePosition; + end: SimplePosition; +} + +export function rangeToPlainObject(range: SimpleRange): RangePlainObject { return { start: positionToPlainObject(range.start), end: positionToPlainObject(range.end), @@ -104,7 +118,7 @@ export function selectionToPlainObject( export function positionToPlainObject({ line, character, -}: Position): PositionPlainObject { +}: SimplePosition): PositionPlainObject { return { line, character }; } diff --git a/packages/common/src/types/GeneralizedRange.ts b/packages/common/src/types/GeneralizedRange.ts index ba1e8161a4..1161d7b01c 100644 --- a/packages/common/src/types/GeneralizedRange.ts +++ b/packages/common/src/types/GeneralizedRange.ts @@ -60,3 +60,91 @@ export function toLineRange(range: Range): LineRange { export function toCharacterRange({ start, end }: Range): CharacterRange { return { type: "character", start, end }; } + +export function isGeneralizedRangeEqual( + a: GeneralizedRange, + b: GeneralizedRange, +): boolean { + if (a.type === "character" && b.type === "character") { + return a.start.isEqual(b.start) && a.end.isEqual(b.end); + } + + if (a.type === "line" && b.type === "line") { + return a.start === b.start && a.end === b.end; + } + + return false; +} + +/** + * Determines whether {@link a} contains {@link b}. This is true if {@link a} + * starts before or equal to the start of {@link b} and ends after or equal to + * the end of {@link b}. + * + * Note that if {@link a} is a {@link CharacterRange} and {@link b} is a + * {@link LineRange}, we require that the {@link LineRange} is fully contained + * in the {@link CharacterRange}, because otherwise it visually looks like the + * {@link LineRange} is not contained because the line range extends to the edge + * of the screen. + * @param a A generalized range + * @param b A generalized range + * @returns `true` if `a` contains `b`, `false` otherwise + */ +export function generalizedRangeContains( + a: GeneralizedRange, + b: GeneralizedRange, +): boolean { + if (a.type === "character") { + if (b.type === "character") { + // a.type === "character" && b.type === "character" + return a.start.isBeforeOrEqual(b.start) && a.end.isAfterOrEqual(b.end); + } + + // a.type === "character" && b.type === "line" + // Require that the line range is fully contained in the character range + // because otherwise it visually looks like the line range is not contained + return a.start.line < b.start && a.end.line > b.end; + } + + if (b.type === "line") { + // a.type === "line" && b.type === "line" + return a.start <= b.start && a.end >= b.end; + } + + // a.type === "line" && b.type === "character" + return a.start <= b.start.line && a.end >= b.end.line; +} + +/** + * Determines whether {@link a} touches {@link b}. This is true if {@link a} + * has any intersection with {@link b}, even if the intersection is empty. + * + * In the case where one range is a {@link CharacterRange} and the other is a + * {@link LineRange}, we return `true` if they both include at least one line + * in common. + * @param a A generalized range + * @param b A generalized range + * @returns `true` if `a` touches `b`, `false` otherwise + */ +export function generalizedRangeTouches( + a: GeneralizedRange, + b: GeneralizedRange, +): boolean { + if (a.type === "character") { + if (b.type === "character") { + // a.type === "character" && b.type === "character" + return a.start.isBeforeOrEqual(b.end) && a.end.isAfterOrEqual(b.start); + } + + // a.type === "character" && b.type === "line" + return a.start.line <= b.end && a.end.line >= b.start; + } + + if (b.type === "line") { + // a.type === "line" && b.type === "line" + return a.start <= b.end && a.end >= b.start; + } + + // a.type === "line" && b.type === "character" + return a.start <= b.end.line && a.end >= b.start.line; +} diff --git a/packages/common/src/types/generalizedRangeContains.test.ts b/packages/common/src/types/generalizedRangeContains.test.ts new file mode 100644 index 0000000000..470d5d819f --- /dev/null +++ b/packages/common/src/types/generalizedRangeContains.test.ts @@ -0,0 +1,163 @@ +import assert = require("assert"); +import { generalizedRangeContains, Position } from ".."; + +suite("generalizedRangeContains", () => { + test("character", () => { + assert.strictEqual( + generalizedRangeContains( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + ), + true, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 1), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + ), + true, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 1), + }, + ), + false, + ); + }); + + test("line", () => { + assert.strictEqual( + generalizedRangeContains( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "line", + start: 0, + end: 0, + }, + ), + true, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "line", + start: 0, + end: 1, + }, + { + type: "line", + start: 0, + end: 0, + }, + ), + true, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "line", + start: 1, + end: 1, + }, + ), + false, + ); + }); + + test("mixed", () => { + assert.strictEqual( + generalizedRangeContains( + { + type: "line", + start: 0, + end: 1, + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(1, 1), + }, + ), + true, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(1, 0), + }, + ), + false, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "character", + start: new Position(0, 0), + end: new Position(2, 0), + }, + { + type: "line", + start: 1, + end: 1, + }, + ), + true, + ); + assert.strictEqual( + generalizedRangeContains( + { + type: "character", + start: new Position(0, 0), + end: new Position(1, 0), + }, + { + type: "line", + start: 1, + end: 1, + }, + ), + false, + ); + }); +}); diff --git a/packages/common/src/types/generalizedRangeTouches.test.ts b/packages/common/src/types/generalizedRangeTouches.test.ts new file mode 100644 index 0000000000..d5d595eab0 --- /dev/null +++ b/packages/common/src/types/generalizedRangeTouches.test.ts @@ -0,0 +1,139 @@ +import assert = require("assert"); +import { GeneralizedRange, generalizedRangeTouches, Position } from ".."; + +suite("generalizedRangeTouches", () => { + test("character", () => { + testRangePair( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + true, + ); + testRangePair( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 1), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + true, + ); + testRangePair( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 1), + }, + { + type: "character", + start: new Position(0, 1), + end: new Position(0, 2), + }, + true, + ); + testRangePair( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 1), + end: new Position(0, 1), + }, + false, + ); + }); + + test("line", () => { + testRangePair( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "line", + start: 0, + end: 0, + }, + true, + ); + testRangePair( + { + type: "line", + start: 0, + end: 1, + }, + { + type: "line", + start: 0, + end: 0, + }, + true, + ); + testRangePair( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "line", + start: 1, + end: 1, + }, + false, + ); + }); + + test("mixed", () => { + testRangePair( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(1, 1), + }, + true, + ); + testRangePair( + { + type: "line", + start: 0, + end: 0, + }, + { + type: "character", + start: new Position(1, 0), + end: new Position(1, 1), + }, + false, + ); + }); +}); + +function testRangePair( + a: GeneralizedRange, + b: GeneralizedRange, + expected: boolean, +) { + assert.strictEqual(generalizedRangeTouches(a, b), expected); + assert.strictEqual(generalizedRangeTouches(b, a), expected); +} diff --git a/packages/common/src/types/isGeneralizedRangeEqual.test.ts b/packages/common/src/types/isGeneralizedRangeEqual.test.ts new file mode 100644 index 0000000000..8efcd8de11 --- /dev/null +++ b/packages/common/src/types/isGeneralizedRangeEqual.test.ts @@ -0,0 +1,117 @@ +import assert = require("assert"); +import { isGeneralizedRangeEqual } from "./GeneralizedRange"; +import { Position } from "./Position"; + +suite("isGeneralizedRangeEqual", () => { + test("character", () => { + assert.strictEqual( + isGeneralizedRangeEqual( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + ), + true, + ); + assert.strictEqual( + isGeneralizedRangeEqual( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 1), + }, + ), + false, + ); + assert.strictEqual( + isGeneralizedRangeEqual( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 0), + end: new Position(1, 0), + }, + ), + false, + ); + assert.strictEqual( + isGeneralizedRangeEqual( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { + type: "character", + start: new Position(0, 1), + end: new Position(0, 0), + }, + ), + false, + ); + }); + + test("line", () => { + assert.strictEqual( + isGeneralizedRangeEqual( + { type: "line", start: 0, end: 0 }, + { type: "line", start: 0, end: 0 }, + ), + true, + ); + assert.strictEqual( + isGeneralizedRangeEqual( + { type: "line", start: 0, end: 0 }, + { type: "line", start: 0, end: 1 }, + ), + false, + ); + assert.strictEqual( + isGeneralizedRangeEqual( + { type: "line", start: 0, end: 0 }, + { type: "line", start: 1, end: 0 }, + ), + false, + ); + }); + + test("mixed", () => { + assert.strictEqual( + isGeneralizedRangeEqual( + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + { type: "line", start: 0, end: 0 }, + ), + false, + ); + assert.strictEqual( + isGeneralizedRangeEqual( + { type: "line", start: 0, end: 0 }, + { + type: "character", + start: new Position(0, 0), + end: new Position(0, 0), + }, + ), + false, + ); + }); +}); diff --git a/packages/common/src/util/CompositeKeyDefaultMap.ts b/packages/common/src/util/CompositeKeyDefaultMap.ts new file mode 100644 index 0000000000..24c600a38d --- /dev/null +++ b/packages/common/src/util/CompositeKeyDefaultMap.ts @@ -0,0 +1,38 @@ +/** + * A map that uses a composite key to store values. If a value is not found for + * a given key, the default value is returned. + */ +export class CompositeKeyDefaultMap { + private map = new Map(); + + constructor( + private getDefaultValue: (key: K) => V, + private hashFunction: (key: K) => unknown[], + ) {} + + hash(key: K): string { + return this.hashFunction(key).join("\u0000"); + } + + get(key: K): V { + const stringKey = this.hash(key); + const currentValue = this.map.get(stringKey); + + if (currentValue != null) { + return currentValue; + } + + const value = this.getDefaultValue(key); + this.map.set(stringKey, value); + + return value; + } + + entries(): IterableIterator<[string, V]> { + return this.map.entries(); + } + + values(): IterableIterator { + return this.map.values(); + } +} diff --git a/packages/common/src/util/itertools.ts b/packages/common/src/util/itertools.ts index 5324763e23..398baf0689 100644 --- a/packages/common/src/util/itertools.ts +++ b/packages/common/src/util/itertools.ts @@ -50,3 +50,19 @@ export function partition( } return [first, second]; } + +/** + * Returns `true` if the given iterable is empty, `false` otherwise + * + * From https://github.com/sindresorhus/is-empty-iterable/blob/12d3b4f966170d9d85a2067f5326668d5bb910a0/index.js + * @param iterable The iterable to check + * @returns `true` if the iterable is empty, `false` otherwise + */ +export function isEmptyIterable(iterable: Iterable): boolean { + for (const _ of iterable) { + // eslint-disable-line no-unused-vars, no-unreachable-loop + return false; + } + + return true; +} diff --git a/packages/common/src/util/range.ts b/packages/common/src/util/range.ts new file mode 100644 index 0000000000..8ea42b9d06 --- /dev/null +++ b/packages/common/src/util/range.ts @@ -0,0 +1,22 @@ +import { range as lodashRange } from "lodash"; +import { Range } from "../types/Range"; +import { TextEditor } from "../types/TextEditor"; + +/** + * @param editor The editor containing the range + * @param range The range to get the line ranges for + * @returns A list of ranges, one for each line in the given range, with the + * first and last ranges trimmed to the start and end of the given range. + */ +export function getLineRanges(editor: TextEditor, range: Range): Range[] { + const { document } = editor; + const lineRanges = lodashRange(range.start.line, range.end.line + 1).map( + (lineNumber) => document.lineAt(lineNumber).range, + ); + lineRanges[0] = lineRanges[0].with(range.start); + lineRanges[lineRanges.length - 1] = lineRanges[lineRanges.length - 1].with( + undefined, + range.end, + ); + return lineRanges; +} diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index f30e3aaf39..3d0dd09fac 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -15,7 +15,7 @@ "@cursorless/common": "workspace:*", "immer": "^9.0.15", "immutability-helper": "^3.1.1", - "itertools": "^1.7.1", + "itertools": "^2.1.1", "lodash": "^4.17.21", "node-html-parser": "^5.3.3", "zod": "3.21.4", diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts b/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts new file mode 100644 index 0000000000..62f39039f9 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts @@ -0,0 +1,78 @@ +import { TextEditor } from "@cursorless/common"; +import { + IterationScopeRangeConfig, + IterationScopeRanges, + ScopeRangeConfig, + ScopeRanges, +} from ".."; +import { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; +import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory"; +import { getIterationRange } from "./getIterationRange"; +import { getIterationScopeRanges } from "./getIterationScopeRanges"; +import { getScopeRanges } from "./getScopeRanges"; + +/** + * Provides scope ranges for a given editor to use eg for visualizing scopes + */ +export class ScopeRangeProvider { + constructor( + private scopeHandlerFactory: ScopeHandlerFactory, + private modifierStageFactory: ModifierStageFactory, + ) { + this.provideScopeRanges = this.provideScopeRanges.bind(this); + this.provideIterationScopeRanges = + this.provideIterationScopeRanges.bind(this); + } + + provideScopeRanges( + editor: TextEditor, + { scopeType, visibleOnly }: ScopeRangeConfig, + ): ScopeRanges[] { + const scopeHandler = this.scopeHandlerFactory.create( + scopeType, + editor.document.languageId, + ); + + if (scopeHandler == null) { + return []; + } + + return getScopeRanges( + editor, + scopeHandler, + getIterationRange(editor, scopeHandler, visibleOnly), + ); + } + + provideIterationScopeRanges( + editor: TextEditor, + { scopeType, visibleOnly, includeNestedTargets }: IterationScopeRangeConfig, + ): IterationScopeRanges[] { + const { languageId } = editor.document; + const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + + if (scopeHandler == null) { + return []; + } + + const iterationScopeHandler = this.scopeHandlerFactory.create( + scopeHandler.iterationScopeType, + languageId, + ); + + if (iterationScopeHandler == null) { + return []; + } + + return getIterationScopeRanges( + editor, + iterationScopeHandler, + this.modifierStageFactory.create({ + type: "everyScope", + scopeType, + }), + getIterationRange(editor, scopeHandler, visibleOnly), + includeNestedTargets, + ); + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts new file mode 100644 index 0000000000..c91b1df70f --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts @@ -0,0 +1,121 @@ +import { Disposable } from "@cursorless/common"; +import { pull } from "lodash"; +import { + IterationScopeChangeEventCallback, + IterationScopeRangeConfig, + ScopeChangeEventCallback, + ScopeRangeConfig, +} from ".."; +import { Debouncer } from "../core/Debouncer"; +import { ide } from "../singletons/ide.singleton"; +import { ScopeRangeProvider } from "./ScopeRangeProvider"; + +/** + * Watches for changes to the scope ranges of visible editors and notifies + * listeners when they change. + */ +export class ScopeRangeWatcher { + private disposables: Disposable[] = []; + private debouncer = new Debouncer(() => this.onChange()); + private listeners: (() => void)[] = []; + + constructor(private scopeRangeProvider: ScopeRangeProvider) { + this.disposables.push( + // An Event which fires when the array of visible editors has changed. + ide().onDidChangeVisibleTextEditors(this.debouncer.run), + // An event that fires when a text document opens + ide().onDidOpenTextDocument(this.debouncer.run), + // An Event that fires when a text document closes + ide().onDidCloseTextDocument(this.debouncer.run), + // An event that is emitted when a text document is changed. This usually + // happens when the contents changes but also when other things like the + // dirty-state changes. + ide().onDidChangeTextDocument(this.debouncer.run), + ide().onDidChangeTextEditorVisibleRanges(this.debouncer.run), + this.debouncer, + ); + + this.onDidChangeScopeRanges = this.onDidChangeScopeRanges.bind(this); + this.onDidChangeIterationScopeRanges = + this.onDidChangeIterationScopeRanges.bind(this); + } + + /** + * Registers a callback to be run when the scope ranges change for any visible + * editor. The callback will be run immediately once for each visible editor + * with the current scope ranges. + * @param callback The callback to run when the scope ranges change + * @param config The configuration for the scope ranges + * @returns A {@link Disposable} which will stop the callback from running + */ + onDidChangeScopeRanges( + callback: ScopeChangeEventCallback, + config: ScopeRangeConfig, + ): Disposable { + const fn = () => { + ide().visibleTextEditors.forEach((editor) => { + callback( + editor, + this.scopeRangeProvider.provideScopeRanges(editor, config), + ); + }); + }; + + this.listeners.push(fn); + + fn(); + + return { + dispose: () => { + pull(this.listeners, fn); + }, + }; + } + + /** + * Registers a callback to be run when the iteration scope ranges change for + * any visible editor. The callback will be run immediately once for each + * visible editor with the current iteration scope ranges. + * @param callback The callback to run when the scope ranges change + * @param config The configuration for the scope ranges + * @returns A {@link Disposable} which will stop the callback from running + */ + onDidChangeIterationScopeRanges( + callback: IterationScopeChangeEventCallback, + config: IterationScopeRangeConfig, + ): Disposable { + const fn = () => { + ide().visibleTextEditors.forEach((editor) => { + callback( + editor, + this.scopeRangeProvider.provideIterationScopeRanges(editor, config), + ); + }); + }; + + this.listeners.push(fn); + + fn(); + + return { + dispose: () => { + pull(this.listeners, fn); + }, + }; + } + + private onChange() { + this.listeners.forEach((listener) => listener()); + } + + dispose(): void { + this.disposables.forEach(({ dispose }) => { + try { + dispose(); + } catch (e) { + // do nothing; some of the VSCode disposables misbehave, and we don't + // want that to prevent us from disposing the rest of the disposables + } + }); + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts b/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts new file mode 100644 index 0000000000..d9ee8f1664 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts @@ -0,0 +1,110 @@ +import { + Position, + ScopeType, + SimpleScopeTypeType, + TextEditor, + isEmptyIterable, +} from "@cursorless/common"; +import { LegacyLanguageId } from "../languages/LegacyLanguageId"; +import { languageMatchers } from "../languages/getNodeMatcher"; +import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory"; +import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; +import { ScopeSupport } from "../api/ScopeProvider"; + +/** + * Determines the level of support for a given scope type in a given editor. + * This is primarily determined by the language id of the editor, though some + * scopes are supported in all languages. + */ +export class ScopeSupportChecker { + constructor(private scopeHandlerFactory: ScopeHandlerFactory) { + this.getScopeSupport = this.getScopeSupport.bind(this); + this.getIterationScopeSupport = this.getIterationScopeSupport.bind(this); + } + + /** + * Determine the level of support for {@link scopeType} in {@link editor}, as + * determined by its language id. + * @param editor The editor to check + * @param scopeType The scope type to check + * @returns The level of support for {@link scopeType} in {@link editor} + */ + getScopeSupport(editor: TextEditor, scopeType: ScopeType): ScopeSupport { + const { languageId } = editor.document; + const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + + if (scopeHandler == null) { + return getLegacyScopeSupport(languageId, scopeType); + } + + return editorContainsScope(editor, scopeHandler) + ? ScopeSupport.supportedAndPresentInEditor + : ScopeSupport.supportedButNotPresentInEditor; + } + + /** + * Determine the level of support for the iteration scope of {@link scopeType} + * in {@link editor}, as determined by its language id. + * @param editor The editor to check + * @param scopeType The scope type to check + * @returns The level of support for the iteration scope of {@link scopeType} + * in {@link editor} + */ + getIterationScopeSupport( + editor: TextEditor, + scopeType: ScopeType, + ): ScopeSupport { + const { languageId } = editor.document; + const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + + if (scopeHandler == null) { + return getLegacyScopeSupport(languageId, scopeType); + } + + const iterationScopeHandler = this.scopeHandlerFactory.create( + scopeHandler.iterationScopeType, + languageId, + ); + + if (iterationScopeHandler == null) { + return ScopeSupport.unsupported; + } + + return editorContainsScope(editor, iterationScopeHandler) + ? ScopeSupport.supportedAndPresentInEditor + : ScopeSupport.supportedButNotPresentInEditor; + } +} + +function editorContainsScope( + editor: TextEditor, + scopeHandler: ScopeHandler, +): boolean { + return !isEmptyIterable( + scopeHandler.generateScopes(editor, new Position(0, 0), "forward"), + ); +} + +function getLegacyScopeSupport( + languageId: string, + scopeType: ScopeType, +): ScopeSupport { + switch (scopeType.type) { + case "boundedNonWhitespaceSequence": + case "surroundingPair": + return ScopeSupport.supportedLegacy; + case "notebookCell": + // FIXME: What to do here + return ScopeSupport.unsupported; + default: + if ( + languageMatchers[languageId as LegacyLanguageId]?.[ + scopeType.type as SimpleScopeTypeType + ] != null + ) { + return ScopeSupport.supportedLegacy; + } + + return ScopeSupport.unsupported; + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts b/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts new file mode 100644 index 0000000000..38ccc0215c --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts @@ -0,0 +1,64 @@ +import { Range, TextEditor } from "@cursorless/common"; +import { last } from "lodash"; +import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; + +/** + * Get the range to iterate over for the given editor. + * + * - If {@link visibleOnly} is `false`, just return the full document range. + * - Otherwise, we + * 1. take the union of all visible ranges, then + * 2. add 10 lines either side to make scrolling a bit smoother, and then + * 3. expand to the largest ancestor of the start and end of the visible + * range, so that we properly show nesting. + * @param editor The editor to get the iteration range for + * @param scopeHandler The scope handler to use + * @param visibleOnly Whether to only iterate over visible ranges + * @returns The range to iterate over + */ +export function getIterationRange( + editor: TextEditor, + scopeHandler: ScopeHandler, + visibleOnly: boolean, +): Range { + if (!visibleOnly) { + return editor.document.range; + } + + let visibleRange = editor.visibleRanges.reduce((acc, range) => + acc.union(range), + ); + + visibleRange = editor.document.range.intersection( + visibleRange.with( + visibleRange.start.translate(-10), + visibleRange.end.translate(10), + ), + )!; + + // Expand to largest ancestor of start of visible range FIXME: It's + // possible that the removal range will be bigger than the domain range, + // in which case we'll miss a scope if its removal range is visible but + // its domain range is not. I don't think we care that much; they can + // scroll, and we have the extra 10 lines on either side which might help. + const expandedStart = + last( + Array.from( + scopeHandler.generateScopes(editor, visibleRange.start, "forward", { + containment: "required", + }), + ), + )?.domain ?? visibleRange; + + // Expand to largest ancestor of end of visible range + const expandedEnd = + last( + Array.from( + scopeHandler.generateScopes(editor, visibleRange.end, "forward", { + containment: "required", + }), + ), + )?.domain ?? visibleRange; + + return expandedStart.union(expandedEnd); +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts b/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts new file mode 100644 index 0000000000..feb42bffe9 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts @@ -0,0 +1,61 @@ +import { Range, TextEditor } from "@cursorless/common"; +import { map } from "itertools"; +import { IterationScopeRanges } from ".."; +import { ModifierStage } from "../processTargets/PipelineStages.types"; +import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; +import { Target } from "../typings/target.types"; +import { getTargetRanges } from "./getTargetRanges"; + +/** + * Returns a list of teration scope ranges of type {@link iterationScopeHandler} + * within {@link iterationRange} in {@link editor}. + * @param editor The editor to check + * @param iterationScopeHandler The scope handler to use + * @param everyStage An every stage for use in determining nested targets + * @param iterationRange The range to iterate over + * @param includeIterationNestedTargets Whether to include nested targets in the + * iteration scope ranges + * @returns A list of iteration scope ranges for the given editor + */ +export function getIterationScopeRanges( + editor: TextEditor, + iterationScopeHandler: ScopeHandler, + everyStage: ModifierStage, + iterationRange: Range, + includeIterationNestedTargets: boolean, +): IterationScopeRanges[] { + return map( + iterationScopeHandler.generateScopes( + editor, + iterationRange.start, + "forward", + { + includeDescendantScopes: true, + distalPosition: iterationRange.end, + }, + ), + (scope) => { + return { + domain: scope.domain, + ranges: scope.getTargets(false).map((target) => ({ + range: target.contentRange, + targets: includeIterationNestedTargets + ? getEveryScopeLenient(everyStage, target).map(getTargetRanges) + : undefined, + })), + }; + }, + ); +} + +function getEveryScopeLenient(everyStage: ModifierStage, target: Target) { + try { + return everyStage.run(target); + } catch (err) { + if ((err as Error).name === "NoContainingScopeError") { + return []; + } + + throw err; + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts b/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts new file mode 100644 index 0000000000..56e47dde50 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts @@ -0,0 +1,30 @@ +import { Range, TextEditor } from "@cursorless/common"; +import { map } from "itertools"; +import { ScopeRanges } from ".."; +import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; +import { getTargetRanges } from "./getTargetRanges"; + +/** + * Returns a list of scope ranges of type {@link scopeHandler} within + * {@link iterationRange} in {@link editor}. + * @param editor The editor to check + * @param scopeHandler The scope handler to use + * @param iterationRange The range to iterate over + * @returns A list of scope ranges for the given editor + */ +export function getScopeRanges( + editor: TextEditor, + scopeHandler: ScopeHandler, + iterationRange: Range, +): ScopeRanges[] { + return map( + scopeHandler.generateScopes(editor, iterationRange.start, "forward", { + includeDescendantScopes: true, + distalPosition: iterationRange.end, + }), + (scope) => ({ + domain: scope.domain, + targets: scope.getTargets(false).map(getTargetRanges), + }), + ); +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts b/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts new file mode 100644 index 0000000000..5fd843310c --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts @@ -0,0 +1,12 @@ +import { toCharacterRange, toLineRange } from "@cursorless/common"; +import { Target } from "../typings/target.types"; +import { TargetRanges } from "../api/ScopeProvider"; + +export function getTargetRanges(target: Target): TargetRanges { + return { + contentRange: target.contentRange, + removalHighlightRange: target.isLine + ? toLineRange(target.getRemovalHighlightRange()) + : toCharacterRange(target.getRemovalHighlightRange()), + }; +} diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts new file mode 100644 index 0000000000..4dd323bf27 --- /dev/null +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -0,0 +1,30 @@ +import { Command, HatTokenMap, IDE } from "@cursorless/common"; +import { Snippets } from "../core/Snippets"; +import { StoredTargetMap } from "../core/StoredTargets"; +import { TestCaseRecorder } from "../testCaseRecorder/TestCaseRecorder"; +import { ScopeProvider } from "./ScopeProvider"; + +export interface CursorlessEngine { + commandApi: CommandApi; + scopeProvider: ScopeProvider; + testCaseRecorder: TestCaseRecorder; + storedTargets: StoredTargetMap; + hatTokenMap: HatTokenMap; + snippets: Snippets; + injectIde: (ide: IDE | undefined) => void; + runIntegrationTests: () => Promise; +} + +export interface CommandApi { + /** + * Runs a command. This is the core of the Cursorless engine. + * @param command The command to run + */ + runCommand(command: Command): Promise; + + /** + * Designed to run commands that come directly from the user. Ensures that + * the command args are of the correct shape. + */ + runCommandSafe(...args: unknown[]): Promise; +} diff --git a/packages/cursorless-engine/src/api/ScopeProvider.ts b/packages/cursorless-engine/src/api/ScopeProvider.ts new file mode 100644 index 0000000000..40d4bd6916 --- /dev/null +++ b/packages/cursorless-engine/src/api/ScopeProvider.ts @@ -0,0 +1,162 @@ +import { + Disposable, + GeneralizedRange, + Range, + ScopeType, + TextEditor, +} from "@cursorless/common"; + +export interface ScopeProvider { + /** + * Get the scope ranges for the given editor. + * @param editor The editor + * @param config The configuration for the scope ranges + * @returns A list of scope ranges for the given editor + */ + provideScopeRanges: ( + editor: TextEditor, + config: ScopeRangeConfig, + ) => ScopeRanges[]; + /** + * Get the iteration scope ranges for the given editor. + * @param editor The editor + * @param config The configuration for the scope ranges + * @returns A list of scope ranges for the given editor + */ + provideIterationScopeRanges: ( + editor: TextEditor, + config: IterationScopeRangeConfig, + ) => IterationScopeRanges[]; + + /** + * Registers a callback to be run when the scope ranges change for any visible + * editor. The callback will be run immediately once for each visible editor + * with the current scope ranges. + * @param callback The callback to run when the scope ranges change + * @param config The configuration for the scope ranges + * @returns A {@link Disposable} which will stop the callback from running + */ + onDidChangeScopeRanges: ( + callback: ScopeChangeEventCallback, + config: ScopeRangeConfig, + ) => Disposable; + + /** + * Registers a callback to be run when the iteration scope ranges change for + * any visible editor. The callback will be run immediately once for each + * visible editor with the current iteration scope ranges. + * @param callback The callback to run when the scope ranges change + * @param config The configuration for the scope ranges + * @returns A {@link Disposable} which will stop the callback from running + */ + onDidChangeIterationScopeRanges: ( + callback: IterationScopeChangeEventCallback, + config: IterationScopeRangeConfig, + ) => Disposable; + + /** + * Determine the level of support for {@link scopeType} in {@link editor}, as + * determined by its language id. + * @param editor The editor to check + * @param scopeType The scope type to check + * @returns The level of support for {@link scopeType} in {@link editor} + */ + getScopeSupport: (editor: TextEditor, scopeType: ScopeType) => ScopeSupport; + + /** + * Determine the level of support for the iteration scope of {@link scopeType} + * in {@link editor}, as determined by its language id. + * @param editor The editor to check + * @param scopeType The scope type to check + * @returns The level of support for the iteration scope of {@link scopeType} + * in {@link editor} + */ + getIterationScopeSupport: ( + editor: TextEditor, + scopeType: ScopeType, + ) => ScopeSupport; +} + +interface ScopeRangeConfigBase { + /** + * Whether to only include visible scopes + */ + visibleOnly: boolean; + + /** + * The scope type to use + */ + scopeType: ScopeType; +} + +export type ScopeRangeConfig = ScopeRangeConfigBase; + +export interface IterationScopeRangeConfig extends ScopeRangeConfigBase { + /** + * Whether to include nested targets in each iteration scope range + */ + includeNestedTargets: boolean; +} + +export type ScopeChangeEventCallback = ( + editor: TextEditor, + scopeRanges: ScopeRanges[], +) => void; + +export type IterationScopeChangeEventCallback = ( + editor: TextEditor, + scopeRanges: IterationScopeRanges[], +) => void; + +/** + * Contains the ranges that define a given scope, eg its {@link domain} and the + * ranges for its {@link targets}. + */ +export interface ScopeRanges { + domain: Range; + targets: TargetRanges[]; +} + +/** + * Contains the ranges that define a given target, eg its {@link contentRange} + * and the ranges for its {@link removalHighlightRange}. + */ +export interface TargetRanges { + contentRange: Range; + removalHighlightRange: GeneralizedRange; +} + +/** + * Contains the ranges that define a given iteration scope, eg its + * {@link domain}. + */ +export interface IterationScopeRanges { + domain: Range; + + /** + * A list of ranges within within which iteration will happen. There is + * almost always a single range here. There will be more than one if the + * iteration scope handler returns a scope whose `getTargets` method returns + * multiple targets. As of this writing, no scope handler returns multiple + * targets. + */ + ranges: { + /** + * The range within which iteration will happen, ie the content range for + * the target returned by the iteration scope handler. + */ + range: Range; + + /** + * The defining ranges for all targets within this iteration range. + */ + targets?: TargetRanges[]; + }[]; +} + +export enum ScopeSupport { + supportedAndPresentInEditor, + supportedButNotPresentInEditor, + supportedLegacy, + unsupported, +} diff --git a/packages/cursorless-engine/src/core/Debouncer.ts b/packages/cursorless-engine/src/core/Debouncer.ts new file mode 100644 index 0000000000..48c498839c --- /dev/null +++ b/packages/cursorless-engine/src/core/Debouncer.ts @@ -0,0 +1,37 @@ +import { ide } from "../singletons/ide.singleton"; + +/** + * Debounces a callback. Uses the `decorationDebounceDelayMs` configuration + * value to determine the debounce delay. + */ +export class Debouncer { + private timeoutHandle: NodeJS.Timeout | null = null; + + constructor( + /** The callback to debounce */ + private callback: () => void, + ) { + this.run = this.run.bind(this); + } + + run() { + if (this.timeoutHandle != null) { + clearTimeout(this.timeoutHandle); + } + + const decorationDebounceDelayMs = ide().configuration.getOwnConfiguration( + "decorationDebounceDelayMs", + ); + + this.timeoutHandle = setTimeout(() => { + this.callback(); + this.timeoutHandle = null; + }, decorationDebounceDelayMs); + } + + dispose() { + if (this.timeoutHandle != null) { + clearTimeout(this.timeoutHandle); + } + } +} diff --git a/packages/cursorless-engine/src/core/HatAllocator.ts b/packages/cursorless-engine/src/core/HatAllocator.ts index d43a7f2ead..a5ccef2384 100644 --- a/packages/cursorless-engine/src/core/HatAllocator.ts +++ b/packages/cursorless-engine/src/core/HatAllocator.ts @@ -2,6 +2,7 @@ import type { Disposable, Hats, TokenHat } from "@cursorless/common"; import { ide } from "../singletons/ide.singleton"; import tokenGraphemeSplitter from "../singletons/tokenGraphemeSplitter.singleton"; import { allocateHats } from "../util/allocateHats"; +import { Debouncer } from "./Debouncer"; import { IndividualHatMap } from "./IndividualHatMap"; interface Context { @@ -9,37 +10,37 @@ interface Context { } export class HatAllocator { - private timeoutHandle: NodeJS.Timeout | null = null; private disposables: Disposable[] = []; + private debouncer = new Debouncer(() => this.allocateHats()); constructor(private hats: Hats, private context: Context) { ide().disposeOnExit(this); - this.allocateHatsDebounced = this.allocateHatsDebounced.bind(this); - this.disposables.push( - this.hats.onDidChangeEnabledHatStyles(this.allocateHatsDebounced), - this.hats.onDidChangeIsEnabled(this.allocateHatsDebounced), + this.hats.onDidChangeEnabledHatStyles(this.debouncer.run), + this.hats.onDidChangeIsEnabled(this.debouncer.run), // An event that fires when a text document opens - ide().onDidOpenTextDocument(this.allocateHatsDebounced), + ide().onDidOpenTextDocument(this.debouncer.run), // An event that fires when a text document closes - ide().onDidCloseTextDocument(this.allocateHatsDebounced), + ide().onDidCloseTextDocument(this.debouncer.run), // An Event which fires when the active editor has changed. Note that the event also fires when the active editor changes to undefined. - ide().onDidChangeActiveTextEditor(this.allocateHatsDebounced), + ide().onDidChangeActiveTextEditor(this.debouncer.run), // An Event which fires when the array of visible editors has changed. - ide().onDidChangeVisibleTextEditors(this.allocateHatsDebounced), + ide().onDidChangeVisibleTextEditors(this.debouncer.run), // An event that is emitted when a text document is changed. This usually happens when the contents changes but also when other things like the dirty-state changes. - ide().onDidChangeTextDocument(this.allocateHatsDebounced), + ide().onDidChangeTextDocument(this.debouncer.run), // An Event which fires when the selection in an editor has changed. - ide().onDidChangeTextEditorSelection(this.allocateHatsDebounced), + ide().onDidChangeTextEditorSelection(this.debouncer.run), // An Event which fires when the visible ranges of an editor has changed. - ide().onDidChangeTextEditorVisibleRanges(this.allocateHatsDebounced), + ide().onDidChangeTextEditorVisibleRanges(this.debouncer.run), // Re-draw hats on grapheme splitting algorithm change in case they // changed their token hat splitting setting. tokenGraphemeSplitter().registerAlgorithmChangeListener( - this.allocateHatsDebounced, + this.debouncer.run, ), + + this.debouncer, ); } @@ -75,26 +76,7 @@ export class HatAllocator { ); } - allocateHatsDebounced() { - if (this.timeoutHandle != null) { - clearTimeout(this.timeoutHandle); - } - - const decorationDebounceDelayMs = ide().configuration.getOwnConfiguration( - "decorationDebounceDelayMs", - ); - - this.timeoutHandle = setTimeout(() => { - this.allocateHats(); - this.timeoutHandle = null; - }, decorationDebounceDelayMs); - } - dispose() { this.disposables.forEach(({ dispose }) => dispose()); - - if (this.timeoutHandle != null) { - clearTimeout(this.timeoutHandle); - } } } diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index ffcb7f7b3b..e1265749bb 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -1,14 +1,21 @@ import { Command, CommandServerApi, Hats, IDE } from "@cursorless/common"; import { StoredTargetMap, TestCaseRecorder, TreeSitter } from "."; +import { CursorlessEngine } from "./api/CursorlessEngineApi"; +import { ScopeProvider } from "./api/ScopeProvider"; +import { ScopeRangeProvider } from "./ScopeVisualizer/ScopeRangeProvider"; +import { ScopeSupportChecker } from "./ScopeVisualizer/ScopeSupportChecker"; import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; +import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { LanguageDefinitions } from "./languages/LanguageDefinitions"; +import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl"; +import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; +import { runCommand } from "./runCommand"; import { runIntegrationTests } from "./runIntegrationTests"; import { injectIde } from "./singletons/ide.singleton"; -import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; -import { runCommand } from "./runCommand"; +import { ScopeRangeWatcher } from "./ScopeVisualizer/ScopeRangeWatcher"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -68,6 +75,7 @@ export function createCursorlessEngine( ); }, }, + scopeProvider: createScopeProvider(languageDefinitions, storedTargets), testCaseRecorder, storedTargets, hatTokenMap, @@ -78,26 +86,31 @@ export function createCursorlessEngine( }; } -export interface CommandApi { - /** - * Runs a command. This is the core of the Cursorless engine. - * @param command The command to run - */ - runCommand(command: Command): Promise; +function createScopeProvider( + languageDefinitions: LanguageDefinitions, + storedTargets: StoredTargetMap, +): ScopeProvider { + const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); - /** - * Designed to run commands that come directly from the user. Ensures that - * the command args are of the correct shape. - */ - runCommandSafe(...args: unknown[]): Promise; -} + const rangeProvider = new ScopeRangeProvider( + scopeHandlerFactory, + new ModifierStageFactoryImpl( + languageDefinitions, + storedTargets, + scopeHandlerFactory, + ), + ); + + const rangeWatcher = new ScopeRangeWatcher(rangeProvider); + const supportChecker = new ScopeSupportChecker(scopeHandlerFactory); -export interface CursorlessEngine { - commandApi: CommandApi; - testCaseRecorder: TestCaseRecorder; - storedTargets: StoredTargetMap; - hatTokenMap: HatTokenMapImpl; - snippets: Snippets; - injectIde: (ide: IDE | undefined) => void; - runIntegrationTests: () => Promise; + return { + provideScopeRanges: rangeProvider.provideScopeRanges, + provideIterationScopeRanges: rangeProvider.provideIterationScopeRanges, + onDidChangeScopeRanges: rangeWatcher.onDidChangeScopeRanges, + onDidChangeIterationScopeRanges: + rangeWatcher.onDidChangeIterationScopeRanges, + getScopeSupport: supportChecker.getScopeSupport, + getIterationScopeSupport: supportChecker.getIterationScopeSupport, + }; } diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 2327870435..45b5881824 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -5,3 +5,5 @@ export * from "./testCaseRecorder/TestCaseRecorder"; export * from "./core/StoredTargets"; export * from "./typings/TreeSitter"; export * from "./cursorlessEngine"; +export * from "./api/CursorlessEngineApi"; +export * from "./api/ScopeProvider"; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts index 599fbbc113..a8d71ff9ec 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts @@ -14,6 +14,7 @@ const DEFAULT_REQUIREMENTS: Omit = containment: null, allowAdjacentScopes: false, skipAncestorScopes: false, + includeDescendantScopes: false, }; /** diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts index 9224802c99..730adf60e4 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts @@ -149,4 +149,17 @@ export interface ScopeIteratorRequirements { * @default false */ skipAncestorScopes: boolean; + + /** + * Indicates whether the ScopeHandler should yield a scope if it is a + * descendant of any scope that has been previously yielded. + * + * - `true` means that descendant scopes of any previously yielded scope will + * be yielded. + * - `false` means that descendant scopes of any previously yielded scope will + * not be yielded. + * + * @default false + */ + includeDescendantScopes: boolean; } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/shouldYieldScope.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/shouldYieldScope.ts index 60d29d19ea..81c6dcac1a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/shouldYieldScope.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/shouldYieldScope.ts @@ -31,9 +31,17 @@ export function shouldYieldScope( checkRequirements(initialPosition, requirements, previousScope, scope) && // Note that we're using `currentPosition` instead of `initialPosition` // below, because we want to filter out scopes that are strictly contained - // by previous scopes. + // by previous scopes. However, if we want to include descendant scopes, + // then we do use the initial position (previousScope == null || - compareTargetScopes(direction, currentPosition, previousScope, scope) < 0) + compareTargetScopes( + direction, + requirements.includeDescendantScopes + ? initialPosition + : currentPosition, + previousScope, + scope, + ) < 0) ); } diff --git a/packages/cursorless-engine/src/typings/target.types.ts b/packages/cursorless-engine/src/typings/target.types.ts index 2310901037..c0a34f0606 100644 --- a/packages/cursorless-engine/src/typings/target.types.ts +++ b/packages/cursorless-engine/src/typings/target.types.ts @@ -136,6 +136,19 @@ export interface Target { /** The range of the delimiter after the content selection */ getTrailingDelimiterTarget(): Target | undefined; getRemovalRange(): Range; + + /** + * The range that should be highlighted when the target is removed. Note that + * we can't just use `getRemovalRange()`, because when we highlight a line for + * removal, we don't know which line to highlight just based on the removal + * range. + * + * For example, assume that the document, represented as a string, is `"\n"`. + * This corresponds to a document with two empty lines. If we say `"chuck + * line"` on either line, the removal range will be the entire document, but + * we want to highlight the line that they were on when they said `"chuck + * line"`, as that is logically the line they've deleted. + */ getRemovalHighlightRange(): Range; withThatTarget(thatTarget: Target): Target; withContentRange(contentRange: Range): Target; diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/checkAndResetFakes.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/checkAndResetFakes.ts new file mode 100644 index 0000000000..662cd1bb87 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/checkAndResetFakes.ts @@ -0,0 +1,39 @@ +import { assert } from "chai"; +import * as sinon from "sinon"; +import { + createDecorationTypeCallToPlainObject, + setDecorationsCallToPlainObject, +} from "./spyCallsToPlainObject"; +import { Fakes, ExpectedArgs } from "./scopeVisualizerTest.types"; + +export function checkAndResetFakes(fakes: Fakes, expected: ExpectedArgs) { + const actual = getSpyCallsAndResetFakes(fakes); + assert.deepStrictEqual(actual, expected, JSON.stringify(actual)); +} + +function getSpyCallsAndResetFakes({ + createTextEditorDecorationType, + setDecorations, + dispose, +}: Fakes) { + return { + decorationRenderOptions: getAndResetFake( + createTextEditorDecorationType, + createDecorationTypeCallToPlainObject, + ), + decorationRanges: getAndResetFake( + setDecorations, + setDecorationsCallToPlainObject, + ), + disposedDecorationIds: getAndResetFake(dispose, ({ args: [id] }) => id), + }; +} + +function getAndResetFake( + spy: sinon.SinonSpy, + transform: (call: sinon.SinonSpyCall) => Expected, +) { + const actual = spy.getCalls().map(transform); + spy.resetHistory(); + return actual; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/colorConfig.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/colorConfig.ts new file mode 100644 index 0000000000..93467cafe8 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/colorConfig.ts @@ -0,0 +1,53 @@ +import { ScopeVisualizerColorConfig } from "@cursorless/vscode-common"; + +/** + * Fake color config to use for testing. We use an alpha of 50% and try to use + * different rgb channels where possible to make it easier to see what happens + * when we blend colors. + */ +export const COLOR_CONFIG: ScopeVisualizerColorConfig = { + dark: { + content: { + background: "#00000180", + borderPorous: "#00000280", + borderSolid: "#00000380", + }, + domain: { + background: "#01000080", + borderPorous: "#02000080", + borderSolid: "#03000080", + }, + iteration: { + background: "#00000480", + borderPorous: "#00000580", + borderSolid: "#00000680", + }, + removal: { + background: "#00010080", + borderPorous: "#00020080", + borderSolid: "#00030080", + }, + }, + light: { + content: { + background: "#00000180", + borderPorous: "#00000280", + borderSolid: "#00000380", + }, + domain: { + background: "#01000080", + borderPorous: "#02000080", + borderSolid: "#03000080", + }, + iteration: { + background: "#00000480", + borderPorous: "#00000580", + borderSolid: "#00000680", + }, + removal: { + background: "#00010080", + borderPorous: "#00020080", + borderSolid: "#00030080", + }, + }, +}; diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/injectFakes.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/injectFakes.ts new file mode 100644 index 0000000000..d444a8377a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/injectFakes.ts @@ -0,0 +1,52 @@ +import { VscodeApi, getCursorlessApi } from "@cursorless/vscode-common"; +import * as sinon from "sinon"; +import { DecorationRenderOptions, WorkspaceConfiguration } from "vscode"; +import { COLOR_CONFIG } from "./colorConfig"; +import { + Fakes, + MockDecorationType, + SetDecorationsParameters, +} from "./scopeVisualizerTest.types"; + +export async function injectFakes(): Promise { + const { vscodeApi } = (await getCursorlessApi()).testHelpers!; + + const dispose = sinon.fake<[number], void>(); + + let decorationIndex = 0; + const createTextEditorDecorationType = sinon.fake< + Parameters, + MockDecorationType + >((_options: DecorationRenderOptions) => { + const id = decorationIndex++; + return { + dispose() { + dispose(id); + }, + id, + }; + }); + + const setDecorations = sinon.fake< + SetDecorationsParameters, + ReturnType + >(); + + const getConfigurationValue = sinon.fake.returns(COLOR_CONFIG); + + sinon.replace( + vscodeApi.window, + "createTextEditorDecorationType", + createTextEditorDecorationType as any, + ); + sinon.replace(vscodeApi.editor, "setDecorations", setDecorations as any); + sinon.replace( + vscodeApi.workspace, + "getConfiguration", + sinon.fake.returns({ + get: getConfigurationValue, + } as unknown as WorkspaceConfiguration), + ); + + return { setDecorations, createTextEditorDecorationType, dispose }; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicMultilineContentTest.png b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicMultilineContentTest.png new file mode 100644 index 0000000000..72696dddb8 Binary files /dev/null and b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicMultilineContentTest.png differ diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicMultilineContentTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicMultilineContentTest.ts new file mode 100644 index 0000000000..5e7a7993a3 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicMultilineContentTest.ts @@ -0,0 +1,84 @@ +import { openNewEditor } from "@cursorless/vscode-common"; +import * as vscode from "vscode"; +import { checkAndResetFakes } from "./checkAndResetFakes"; +import { injectFakes } from "./injectFakes"; +import { ExpectedArgs } from "./scopeVisualizerTest.types"; + +/** + * Tests that the scope visualizer works with multiline content, by + * ensuring that the correct decorations are applied so that it looks + * like `./runBasicMultilineContentTest.png`. + */ +export async function runBasicMultilineContentTest() { + await openNewEditor(contents, { + languageId: "typescript", + }); + + const fakes = await injectFakes(); + + await vscode.commands.executeCommand( + "cursorless.showScopeVisualizer", + { + type: "namedFunction", + }, + "content", + ); + + checkAndResetFakes(fakes, expectedArgs); +} + +const contents = ` +function helloWorld() { + +} +`; + +const expectedArgs: ExpectedArgs = { + decorationRenderOptions: [ + { + backgroundColor: "#000001c0", + borderColor: "#010002c0 #010001c0 #010001c0 #010002c0", + borderStyle: "solid dashed dashed solid", + borderRadius: "2px 0px 0px 0px", + isWholeLine: false, + id: 0, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010001c0 #010001c0 #010001c0", + borderStyle: "none dashed none dashed", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 1, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010002c0 #010002c0 #010001c0", + borderStyle: "dashed solid solid dashed", + borderRadius: "0px 0px 2px 0px", + isWholeLine: false, + id: 2, + }, + ], + decorationRanges: [ + { + decorationId: 0, + ranges: [ + { start: { line: 1, character: 0 }, end: { line: 1, character: 23 } }, + ], + }, + { + decorationId: 1, + ranges: [ + { start: { line: 2, character: 0 }, end: { line: 2, character: 0 } }, + ], + }, + { + decorationId: 2, + ranges: [ + { start: { line: 3, character: 0 }, end: { line: 3, character: 1 } }, + ], + }, + ], + disposedDecorationIds: [], +}; diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicRemovalTest.png b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicRemovalTest.png new file mode 100644 index 0000000000..585504092e Binary files /dev/null and b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicRemovalTest.png differ diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicRemovalTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicRemovalTest.ts new file mode 100644 index 0000000000..54b701883f --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runBasicRemovalTest.ts @@ -0,0 +1,77 @@ +import { openNewEditor } from "@cursorless/vscode-common"; +import * as vscode from "vscode"; +import { checkAndResetFakes } from "./checkAndResetFakes"; +import { injectFakes } from "./injectFakes"; +import { ExpectedArgs } from "./scopeVisualizerTest.types"; + +/** + * Tests that the scope visualizer works with removal ranges, by ensuring that + * the correct decorations are applied so that it looks like + * `./runBasicRemovalTest.png`. + */ +export async function runBasicRemovalTest() { + await openNewEditor("aaa bbb"); + + const fakes = await injectFakes(); + + await vscode.commands.executeCommand( + "cursorless.showScopeVisualizer", + { + type: "token", + }, + "removal", + ); + + checkAndResetFakes(fakes, expectedArgs); +} + +const expectedArgs: ExpectedArgs = { + decorationRenderOptions: [ + { + backgroundColor: "#01000080", + borderColor: "#03000080 #03000080 #03000080 #03000080", + borderStyle: "solid solid solid solid", + borderRadius: "2px 2px 2px 2px", + isWholeLine: false, + id: 0, + }, + { + backgroundColor: "#00010080", + borderColor: "#00030080 #00030080 #00030080 #00030080", + borderStyle: "solid solid solid solid", + borderRadius: "2px 2px 2px 2px", + isWholeLine: false, + id: 1, + }, + { + backgroundColor: "#00010080", + borderColor: "#00030080 #00030080 #00030080 #00030080", + borderStyle: "solid solid solid solid", + borderRadius: "2px 2px 2px 2px", + isWholeLine: false, + id: 2, + }, + ], + decorationRanges: [ + { + decorationId: 0, + ranges: [ + { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, + { start: { line: 0, character: 4 }, end: { line: 0, character: 7 } }, + ], + }, + { + decorationId: 1, + ranges: [ + { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, + ], + }, + { + decorationId: 2, + ranges: [ + { start: { line: 0, character: 3 }, end: { line: 0, character: 7 } }, + ], + }, + ], + disposedDecorationIds: [], +}; diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runNestedMultilineContentTest.png b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runNestedMultilineContentTest.png new file mode 100644 index 0000000000..6a01785770 Binary files /dev/null and b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runNestedMultilineContentTest.png differ diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runNestedMultilineContentTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runNestedMultilineContentTest.ts new file mode 100644 index 0000000000..29570fa63d --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runNestedMultilineContentTest.ts @@ -0,0 +1,184 @@ +import { openNewEditor } from "@cursorless/vscode-common"; +import * as vscode from "vscode"; +import { injectFakes } from "./injectFakes"; +import { checkAndResetFakes } from "./checkAndResetFakes"; +import { ExpectedArgs } from "./scopeVisualizerTest.types"; + +/** + * Tests that the scope visualizer works with nested multiline content, by + * ensuring that the correct decorations are applied so that it looks like + * `./runNestedMultilineContentTest.png`. + */ +export async function runNestedMultilineContentTest() { + await openNewEditor(contents, { + languageId: "typescript", + }); + + const fakes = await injectFakes(); + + await vscode.commands.executeCommand( + "cursorless.showScopeVisualizer", + { + type: "namedFunction", + }, + "content", + ); + + checkAndResetFakes(fakes, expectedArgs); +} + +const contents = ` +function a() { + function b() { + + } +} +`; + +const expectedArgs: ExpectedArgs = { + decorationRenderOptions: [ + { + backgroundColor: "#000001c0", + borderColor: "#010002c0 #010001c0 #010001c0 #010002c0", + borderStyle: "solid dashed none solid", + borderRadius: "2px 0px 0px 0px", + isWholeLine: false, + id: 0, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010001c0 #010001c0 #010001c0", + borderStyle: "none none dashed dashed", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 1, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010001c0 #010001c0 #010001c0", + borderStyle: "dashed dashed dashed none", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 2, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010001c0 #010001c0 #010001c0", + borderStyle: "none dashed none dashed", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 3, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010001c0 #010001c0 #010001c0", + borderStyle: "dashed none none dashed", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 4, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010001c0 #010002c0 #010001c0", + borderStyle: "dashed dashed solid none", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 5, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010002c0 #010002c0 #010001c0", + borderStyle: "none solid solid dashed", + borderRadius: "0px 0px 2px 0px", + isWholeLine: false, + id: 6, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010002c0 #010001c0 #010001c0 #010002c0", + borderStyle: "solid dashed dashed solid", + borderRadius: "2px 0px 0px 0px", + isWholeLine: false, + id: 7, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010002c0 #010001c0 #010001c0 #010001c0", + borderStyle: "solid dashed none dashed", + borderRadius: "0px 0px 0px 0px", + isWholeLine: false, + id: 8, + }, + { + backgroundColor: "#000001c0", + borderColor: "#010001c0 #010002c0 #010002c0 #010001c0", + borderStyle: "dashed solid solid dashed", + borderRadius: "0px 0px 2px 0px", + isWholeLine: false, + id: 9, + }, + ], + decorationRanges: [ + { + decorationId: 0, + ranges: [ + { start: { line: 1, character: 0 }, end: { line: 1, character: 14 } }, + ], + }, + { + decorationId: 1, + ranges: [ + { start: { line: 2, character: 0 }, end: { line: 2, character: 14 } }, + ], + }, + { + decorationId: 2, + ranges: [ + { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + ], + }, + { + decorationId: 3, + ranges: [ + { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } }, + ], + }, + { + decorationId: 4, + ranges: [ + { start: { line: 4, character: 0 }, end: { line: 4, character: 1 } }, + ], + }, + { + decorationId: 5, + ranges: [ + { start: { line: 4, character: 1 }, end: { line: 4, character: 3 } }, + ], + }, + { + decorationId: 6, + ranges: [ + { start: { line: 5, character: 0 }, end: { line: 5, character: 1 } }, + ], + }, + { + decorationId: 7, + ranges: [ + { start: { line: 2, character: 2 }, end: { line: 2, character: 16 } }, + ], + }, + { + decorationId: 8, + ranges: [ + { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } }, + ], + }, + { + decorationId: 9, + ranges: [ + { start: { line: 4, character: 0 }, end: { line: 4, character: 3 } }, + ], + }, + ], + disposedDecorationIds: [], +}; diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runUpdateTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runUpdateTest.ts new file mode 100644 index 0000000000..d9f7e19fad --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/runUpdateTest.ts @@ -0,0 +1,79 @@ +import { openNewEditor } from "@cursorless/vscode-common"; +import * as vscode from "vscode"; +import { sleepWithBackoff } from "../../endToEndTestSetup"; +import { injectFakes } from "./injectFakes"; +import { checkAndResetFakes } from "./checkAndResetFakes"; +import { ExpectedArgs } from "./scopeVisualizerTest.types"; + +/** + * Tests that the scope visualizer updates correctly when the document is + * edited. + */ +export async function runUpdateTest() { + const editor = await openNewEditor("aaa"); + + const fakes = await injectFakes(); + + await vscode.commands.executeCommand( + "cursorless.showScopeVisualizer", + { + type: "token", + }, + "content", + ); + + checkAndResetFakes(fakes, expectedInitialArgs); + + await editor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 3), " bbb"); + }); + await sleepWithBackoff(100); + + checkAndResetFakes(fakes, expectedUpdatedArgs); + + await vscode.commands.executeCommand("cursorless.hideScopeVisualizer"); + + checkAndResetFakes(fakes, expectedFinalArgs); +} + +const expectedInitialArgs: ExpectedArgs = { + decorationRenderOptions: [ + { + backgroundColor: "#000001c0", + borderColor: "#010002c0 #010002c0 #010002c0 #010002c0", + borderStyle: "solid solid solid solid", + borderRadius: "2px 2px 2px 2px", + isWholeLine: false, + id: 0, + }, + ], + decorationRanges: [ + { + decorationId: 0, + ranges: [ + { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, + ], + }, + ], + disposedDecorationIds: [], +}; + +const expectedUpdatedArgs: ExpectedArgs = { + decorationRenderOptions: [], + decorationRanges: [ + { + decorationId: 0, + ranges: [ + { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, + { start: { line: 0, character: 4 }, end: { line: 0, character: 7 } }, + ], + }, + ], + disposedDecorationIds: [], +}; + +const expectedFinalArgs: ExpectedArgs = { + decorationRenderOptions: [], + decorationRanges: [], + disposedDecorationIds: [0], +}; diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts new file mode 100644 index 0000000000..c92ad46025 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts @@ -0,0 +1,30 @@ +import { commands } from "vscode"; +import asyncSafety from "../../asyncSafety"; +import { endToEndTestSetup } from "../../endToEndTestSetup"; +import { runBasicMultilineContentTest } from "./runBasicMultilineContentTest"; +import { runBasicRemovalTest } from "./runBasicRemovalTest"; +import { runNestedMultilineContentTest } from "./runNestedMultilineContentTest"; +import { runUpdateTest } from "./runUpdateTest"; + +suite("scope visualizer", async function () { + endToEndTestSetup(this); + + teardown(() => commands.executeCommand("cursorless.hideScopeVisualizer")); + + test( + "basic multiline content", + asyncSafety(() => runBasicMultilineContentTest()), + ); + test( + "nested multiline content", + asyncSafety(() => runNestedMultilineContentTest()), + ); + test( + "update", + asyncSafety(() => runUpdateTest()), + ); + test( + "basic removal", + asyncSafety(() => runBasicRemovalTest()), + ); +}); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizerTest.types.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizerTest.types.ts new file mode 100644 index 0000000000..71a9359546 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizerTest.types.ts @@ -0,0 +1,47 @@ +import { RangePlainObject } from "@cursorless/common"; +import { VscodeApi } from "@cursorless/vscode-common"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; + +export interface MockDecorationType { + dispose(): void; + id: number; +} + +export type SetDecorationsParameters = [ + editor: vscode.TextEditor, + decorationType: MockDecorationType, + ranges: readonly vscode.Range[], +]; + +export interface Fakes { + setDecorations: sinon.SinonSpy< + SetDecorationsParameters, + ReturnType + >; + createTextEditorDecorationType: sinon.SinonSpy< + Parameters, + MockDecorationType + >; + dispose: sinon.SinonSpy<[number], void>; +} + +export interface ExpectedArgs { + decorationRenderOptions: DecorationRenderOptionsPlainObject[]; + decorationRanges: DecorationRangesPlainObject[]; + disposedDecorationIds: number[]; +} + +export interface DecorationRangesPlainObject { + decorationId: number; + ranges: RangePlainObject[]; +} + +export interface DecorationRenderOptionsPlainObject { + backgroundColor: string | undefined; + borderColor: string | undefined; + borderStyle: string | undefined; + borderRadius: string | undefined; + isWholeLine: boolean; + id: number; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/spyCallsToPlainObject.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/spyCallsToPlainObject.ts new file mode 100644 index 0000000000..e4b865c129 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/spyCallsToPlainObject.ts @@ -0,0 +1,35 @@ +import { rangeToPlainObject } from "@cursorless/common"; +import { DecorationRenderOptions } from "vscode"; +import { + SetDecorationsParameters, + DecorationRangesPlainObject, + DecorationRenderOptionsPlainObject, + MockDecorationType, +} from "./scopeVisualizerTest.types"; +import { SinonSpyCall } from "sinon"; + +export function setDecorationsCallToPlainObject({ + args: [_editor, decorationType, ranges], +}: SinonSpyCall): DecorationRangesPlainObject { + return { + decorationId: decorationType.id, + ranges: ranges.map(rangeToPlainObject), + }; +} + +export function createDecorationTypeCallToPlainObject({ + args: [options], + returnValue: decorationType, +}: SinonSpyCall< + [DecorationRenderOptions], + MockDecorationType +>): DecorationRenderOptionsPlainObject { + return { + backgroundColor: options.dark?.backgroundColor?.toString(), + borderColor: options.dark?.borderColor?.toString(), + borderStyle: options.borderStyle, + borderRadius: options.borderRadius, + isWholeLine: options.isWholeLine ?? false, + id: decorationType.id, + }; +} diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 1aba06ab31..1f83481861 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -65,7 +65,9 @@ "onCommand:cursorless.showDocumentation", "onCommand:cursorless.showQuickPick", "onCommand:cursorless.takeSnapshot", - "onCommand:cursorless.toggleDecorations" + "onCommand:cursorless.toggleDecorations", + "onCommand:cursorless.showScopeVisualizer", + "onCommand:cursorless.hideScopeVisualizer" ], "main": "./extension.js", "capabilities": { @@ -168,6 +170,16 @@ "command": "cursorless.keyboard.modal.modeToggle", "title": "Cursorless: Toggle the cursorless modal mode", "enablement": "false" + }, + { + "command": "cursorless.showScopeVisualizer", + "title": "Cursorless: Show the scope visualizer", + "enablement": "false" + }, + { + "command": "cursorless.hideScopeVisualizer", + "title": "Cursorless: Hide the scope visualizer", + "enablement": "false" } ], "colors": [ @@ -399,6 +411,198 @@ }, "additionalProperties": false }, + "cursorless.scopeVisualizer.colors.dark": { + "description": "Colors to use for scope visualizer with dark themes", + "type": "object", + "properties": { + "domain": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + }, + "content": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + }, + "removal": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + }, + "iteration": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + } + }, + "default": { + "domain": { + "background": "#00e1ff18", + "borderSolid": "#ebdeec84", + "borderPorous": "#ebdeec3b" + }, + "content": { + "background": "#ad00bc5b", + "borderSolid": "#ee00ff78", + "borderPorous": "#ebdeec3b" + }, + "removal": { + "background": "#ff00002d", + "borderSolid": "#ff000078", + "borderPorous": "#ff00004a" + }, + "iteration": { + "background": "#00725f6c", + "borderSolid": "#00ffd578", + "borderPorous": "#00ffd525" + } + } + }, + "cursorless.scopeVisualizer.colors.light": { + "description": "Colors to use for scope visualizer with light themes", + "type": "object", + "properties": { + "domain": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + }, + "content": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + }, + "removal": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + }, + "iteration": { + "type": "object", + "properties": { + "background": { + "type": "string", + "format": "color" + }, + "borderSolid": { + "type": "string", + "format": "color" + }, + "borderPorous": { + "type": "string", + "format": "color" + } + } + } + }, + "default": { + "domain": { + "background": "#00e1ff18", + "borderSolid": "#19171984", + "borderPorous": "#1211123b" + }, + "content": { + "background": "#ad00bc5b", + "borderSolid": "#ee00ff78", + "borderPorous": "#ee00ff4e" + }, + "removal": { + "background": "#ff00002d", + "borderSolid": "#ff000078", + "borderPorous": "#ff00004a" + }, + "iteration": { + "background": "#00725f6c", + "borderSolid": "#00ffd578", + "borderPorous": "#00ffd525" + } + } + }, "cursorless.hatEnablement.colors": { "description": "Which colors to enable", "type": "object", @@ -871,8 +1075,11 @@ "@cursorless/common": "workspace:*", "@cursorless/cursorless-engine": "workspace:*", "@cursorless/vscode-common": "workspace:*", + "@types/tinycolor2": "1.4.3", + "itertools": "^2.1.1", "lodash": "^4.17.21", "semver": "^7.3.9", + "tinycolor2": "1.6.0", "uuid": "^9.0.0", "vscode-uri": "^3.0.6" } diff --git a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts new file mode 100644 index 0000000000..77dcc80c6a --- /dev/null +++ b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts @@ -0,0 +1,8 @@ +import { ScopeType } from "@cursorless/common"; + +export interface ScopeVisualizerCommandApi { + start(scopeType: ScopeType, visualizationType: VisualizationType): void; + stop(): void; +} + +export type VisualizationType = "content" | "removal" | "iteration"; diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index 3d2941dd2a..bb00ca3b8b 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -17,9 +17,10 @@ import { takeSnapshot, } from "@cursorless/cursorless-engine"; import { TestHelpers } from "@cursorless/vscode-common"; -import * as vscode from "vscode"; +import type { TextEditor as VscodeTextEditor } from "vscode"; import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { toVscodeEditor } from "./ide/vscode/toVscodeEditor"; +import { vscodeApi } from "./vscodeApi"; export function constructTestHelpers( commandServerApi: CommandServerApi | null, @@ -61,7 +62,7 @@ export function constructTestHelpers( }, setStoredTarget( - editor: vscode.TextEditor, + editor: VscodeTextEditor, key: StoredTargetKey, targets: TargetPlainObject[] | undefined, ): void { @@ -74,5 +75,6 @@ export function constructTestHelpers( }, hatTokenMap, runIntegrationTests, + vscodeApi, }; } diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 21ce44432f..61036295a7 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -1,13 +1,16 @@ import { FakeIDE, getFakeCommandServerApi, + IDE, isTesting, NormalizedIDE, Range, + ScopeType, TextDocument, } from "@cursorless/common"; import { createCursorlessEngine, + ScopeProvider, TreeSitter, } from "@cursorless/cursorless-engine"; import { @@ -17,7 +20,6 @@ import { ParseTreeApi, toVscodeRange, } from "@cursorless/vscode-common"; -import * as vscode from "vscode"; import { constructTestHelpers } from "./constructTestHelpers"; import { FakeFontMeasurements } from "./ide/vscode/hats/FakeFontMeasurements"; import { FontMeasurementsImpl } from "./ide/vscode/hats/FontMeasurementsImpl"; @@ -26,6 +28,15 @@ import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { KeyboardCommands } from "./keyboard/KeyboardCommands"; import { registerCommands } from "./registerCommands"; import { StatusBarItem } from "./StatusBarItem"; +import { + createVscodeScopeVisualizer, + VscodeScopeVisualizer, +} from "./ide/vscode/VSCodeScopeVisualizer"; +import { + ScopeVisualizerCommandApi, + VisualizationType, +} from "./ScopeVisualizerCommandApi"; +import { ExtensionContext, Location } from "vscode"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -36,7 +47,7 @@ import { StatusBarItem } from "./StatusBarItem"; * - Creates an entrypoint for running commands {@link CommandRunner}. */ export async function activate( - context: vscode.ExtensionContext, + context: ExtensionContext, ): Promise { const parseTreeApi = await getParseTreeApi(); @@ -63,6 +74,7 @@ export async function activate( testCaseRecorder, storedTargets, hatTokenMap, + scopeProvider, snippets, injectIde, runIntegrationTests, @@ -81,6 +93,7 @@ export async function activate( vscodeIDE, commandApi, testCaseRecorder, + createScopeVisualizerCommandApi(normalizedIde ?? vscodeIDE, scopeProvider), keyboardCommands, hats, ); @@ -104,7 +117,7 @@ export async function activate( }; } -async function createVscodeIde(context: vscode.ExtensionContext) { +async function createVscodeIde(context: ExtensionContext) { const vscodeIDE = new VscodeIDE(context); const hats = new VscodeHats( @@ -123,7 +136,7 @@ function createTreeSitter(parseTreeApi: ParseTreeApi): TreeSitter { return { getNodeAtLocation(document: TextDocument, range: Range) { return parseTreeApi.getNodeAtLocation( - new vscode.Location(document.uri, toVscodeRange(range)), + new Location(document.uri, toVscodeRange(range)), ); }, @@ -136,6 +149,31 @@ function createTreeSitter(parseTreeApi: ParseTreeApi): TreeSitter { }; } +function createScopeVisualizerCommandApi( + ide: IDE, + scopeProvider: ScopeProvider, +): ScopeVisualizerCommandApi { + let scopeVisualizer: VscodeScopeVisualizer | undefined; + + return { + start(scopeType: ScopeType, visualizationType: VisualizationType) { + scopeVisualizer?.dispose(); + scopeVisualizer = createVscodeScopeVisualizer( + ide, + scopeProvider, + scopeType, + visualizationType, + ); + scopeVisualizer.start(); + }, + + stop() { + scopeVisualizer?.dispose(); + scopeVisualizer = undefined; + }, + }; +} + // this method is called when your extension is deactivated export function deactivate() { // do nothing diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/RangeTypeColors.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/RangeTypeColors.ts new file mode 100644 index 0000000000..b086632f4c --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/RangeTypeColors.ts @@ -0,0 +1,13 @@ +/** + * The colors used to render a range type, such as "domain", "content", etc. + */ +export interface RangeTypeColors { + background: ThemeColors; + borderSolid: ThemeColors; + borderPorous: ThemeColors; +} + +interface ThemeColors { + light: string; + dark: string; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighter.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighter.ts new file mode 100644 index 0000000000..ca4c7a9928 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighter.ts @@ -0,0 +1,72 @@ +import { GeneralizedRange, Range } from "@cursorless/common"; +import { flatmap } from "itertools"; +import { VscodeTextEditorImpl } from "../../VscodeTextEditorImpl"; +import { RangeTypeColors } from "../RangeTypeColors"; +import { VscodeFancyRangeHighlighterRenderer } from "./VscodeFancyRangeHighlighterRenderer"; +import { generateDecorationsForCharacterRange } from "./generateDecorationsForCharacterRange"; +import { generateDecorationsForLineRange } from "./generateDecorationsForLineRange"; +import { generateDifferentiatedRanges } from "./generateDifferentiatedRanges"; +import { DifferentiatedStyledRange } from "./decorationStyle.types"; +import { groupDifferentiatedStyledRanges } from "./groupDifferentiatedStyledRanges"; + +/** + * A class for highlighting ranges in a VSCode editor, which does the following: + * + * - Uses a combination of solid lines and dotted lines to make it easier to + * visualize multi-line ranges, while still making directly adjacent ranges + * visually distinct. + * - Works around a bug in VSCode where decorations that are touching get merged + * together. + * - Ensures that nested ranges are rendered after their parents, so that they + * look properly nested. + */ +export class VscodeFancyRangeHighlighter { + private renderer: VscodeFancyRangeHighlighterRenderer; + + constructor(colors: RangeTypeColors) { + this.renderer = new VscodeFancyRangeHighlighterRenderer(colors); + } + + setRanges(editor: VscodeTextEditorImpl, ranges: GeneralizedRange[]) { + const decoratedRanges: Iterable = flatmap( + // We first generate a list of differentiated ranges, which are ranges + // where any ranges that are touching have different differentiation + // indices. This is used to ensure that ranges that are touching are + // rendered with different TextEditorDecorationTypes, so that they don't + // get merged together by VSCode. + generateDifferentiatedRanges(ranges), + + // Then, we generate the actual decorations for each differentiated range. + // A single range will be split into multiple decorations if it spans + // multiple lines, so that we can eg use dashed lines to end lines that + // are part of the same range. + function* ({ range, differentiationIndex }) { + const iterable = + range.type === "line" + ? generateDecorationsForLineRange(range.start, range.end) + : generateDecorationsForCharacterRange( + editor, + new Range(range.start, range.end), + ); + + for (const { range, style } of iterable) { + yield { + range, + differentiatedStyle: { style, differentiationIndex }, + }; + } + }, + ); + + this.renderer.setRanges( + editor, + // Group the decorations so that we have a list of ranges for each + // differentiated style + groupDifferentiatedStyledRanges(decoratedRanges), + ); + } + + dispose() { + this.renderer.dispose(); + } +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighterRenderer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighterRenderer.ts new file mode 100644 index 0000000000..a466d41556 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighterRenderer.ts @@ -0,0 +1,156 @@ +import { CompositeKeyDefaultMap } from "@cursorless/common"; +import { toVscodeRange } from "@cursorless/vscode-common"; +import { + DecorationRangeBehavior, + DecorationRenderOptions, + TextEditorDecorationType, +} from "vscode"; +import { vscodeApi } from "../../../../vscodeApi"; +import { VscodeTextEditorImpl } from "../../VscodeTextEditorImpl"; +import { RangeTypeColors } from "../RangeTypeColors"; +import { + BorderStyle, + DecorationStyle, + DifferentiatedStyle, + DifferentiatedStyledRangeList, +} from "./decorationStyle.types"; +import { getDifferentiatedStyleMapKey } from "./getDifferentiatedStyleMapKey"; + +const BORDER_WIDTH = "1px"; +const BORDER_RADIUS = "2px"; + +/** + * Handles the actual rendering of decorations for + * {@link VscodeFancyRangeHighlighter}. + */ +export class VscodeFancyRangeHighlighterRenderer { + private decorationTypes: CompositeKeyDefaultMap< + DifferentiatedStyle, + TextEditorDecorationType + >; + + constructor(colors: RangeTypeColors) { + this.decorationTypes = new CompositeKeyDefaultMap( + ({ style }) => getDecorationStyle(colors, style), + getDifferentiatedStyleMapKey, + ); + } + + /** + * Renders the given ranges in the given editor. + * + * @param editor The editor to render the decorations in. + * @param decoratedRanges A list with one element per differentiated style, + * each of which contains a list of ranges to render for that style. We render + * the ranges in order of increasing differentiation index. + * {@link VscodeFancyRangeHighlighter} uses this to ensure that nested ranges + * are rendered after their parents. Otherwise they partially interleave, + * which looks bad. + */ + setRanges( + editor: VscodeTextEditorImpl, + decoratedRanges: DifferentiatedStyledRangeList[], + ): void { + /** + * Keep track of which styles have no ranges, so that we can set their + * range list to `[]` + */ + const untouchedDecorationTypes = new Set(this.decorationTypes.values()); + + decoratedRanges.sort( + (a, b) => + a.differentiatedStyle.differentiationIndex - + b.differentiatedStyle.differentiationIndex, + ); + + decoratedRanges.forEach( + ({ differentiatedStyle: styleParameters, ranges }) => { + const decorationType = this.decorationTypes.get(styleParameters); + + vscodeApi.editor.setDecorations( + editor.vscodeEditor, + decorationType, + ranges.map(toVscodeRange), + ); + + untouchedDecorationTypes.delete(decorationType); + }, + ); + + untouchedDecorationTypes.forEach((decorationType) => { + editor.vscodeEditor.setDecorations(decorationType, []); + }); + } + + dispose() { + Array.from(this.decorationTypes.values()).forEach((decorationType) => { + decorationType.dispose(); + }); + } +} + +function getDecorationStyle( + colors: RangeTypeColors, + borders: DecorationStyle, +): TextEditorDecorationType { + const options: DecorationRenderOptions = { + light: { + backgroundColor: colors.background.light, + borderColor: getBorderColor( + colors.borderSolid.light, + colors.borderPorous.light, + borders, + ), + }, + dark: { + backgroundColor: colors.background.dark, + borderColor: getBorderColor( + colors.borderSolid.dark, + colors.borderPorous.dark, + borders, + ), + }, + borderStyle: getBorderStyle(borders), + borderWidth: BORDER_WIDTH, + borderRadius: getBorderRadius(borders), + rangeBehavior: DecorationRangeBehavior.ClosedClosed, + isWholeLine: borders.isWholeLine, + }; + + return vscodeApi.window.createTextEditorDecorationType(options); +} + +function getBorderStyle(borders: DecorationStyle): string { + return [borders.top, borders.right, borders.bottom, borders.left].join(" "); +} + +function getBorderColor( + solidColor: string, + porousColor: string, + borders: DecorationStyle, +): string { + return [ + borders.top === BorderStyle.solid ? solidColor : porousColor, + borders.right === BorderStyle.solid ? solidColor : porousColor, + borders.bottom === BorderStyle.solid ? solidColor : porousColor, + borders.left === BorderStyle.solid ? solidColor : porousColor, + ].join(" "); +} + +function getBorderRadius(borders: DecorationStyle): string { + return [ + getSingleCornerBorderRadius(borders.top, borders.left), + getSingleCornerBorderRadius(borders.top, borders.right), + getSingleCornerBorderRadius(borders.bottom, borders.right), + getSingleCornerBorderRadius(borders.bottom, borders.left), + ].join(" "); +} + +function getSingleCornerBorderRadius(side1: BorderStyle, side2: BorderStyle) { + // We only round the corners if both sides are solid, as that makes them look + // more finished, whereas we want the dotted borders to look unfinished / cut + // off. + return side1 === BorderStyle.solid && side2 === BorderStyle.solid + ? BORDER_RADIUS + : "0px"; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/decorationStyle.types.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/decorationStyle.types.ts new file mode 100644 index 0000000000..f20c439cde --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/decorationStyle.types.ts @@ -0,0 +1,56 @@ +import { GeneralizedRange, Range } from "@cursorless/common"; + +export enum BorderStyle { + porous = "dashed", + solid = "solid", + none = "none", +} + +export interface DecorationStyle { + top: BorderStyle; + bottom: BorderStyle; + left: BorderStyle; + right: BorderStyle; + isWholeLine?: boolean; +} + +/** + * A decoration style that is differentiated from other styles by a number. We + * use this number to ensure that adjacent ranges are rendered with different + * TextEditorDecorationTypes, so that they don't get merged together due to a + * VSCode bug. + */ +export interface DifferentiatedStyle { + style: DecorationStyle; + + /** + * A number that is different from the differentiation indices of any other + * ranges that are touching this range. + */ + differentiationIndex: number; +} + +export interface StyledRange { + style: DecorationStyle; + range: Range; +} + +export interface DifferentiatedStyledRange { + differentiatedStyle: DifferentiatedStyle; + range: Range; +} + +export interface DifferentiatedStyledRangeList { + differentiatedStyle: DifferentiatedStyle; + ranges: Range[]; +} + +export interface DifferentiatedGeneralizedRange { + range: GeneralizedRange; + + /** + * A number that is different from the differentiation indices of any other + * ranges that are touching this range. + */ + differentiationIndex: number; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateDecorationsForCharacterRange.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateDecorationsForCharacterRange.ts new file mode 100644 index 0000000000..0cebef7d09 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateDecorationsForCharacterRange.ts @@ -0,0 +1,28 @@ +import { Range, TextEditor, getLineRanges } from "@cursorless/common"; +import { BorderStyle, StyledRange } from "../decorationStyle.types"; +import { handleMultipleLines } from "./handleMultipleLines"; + +/** + * Returns an iterable of styled ranges for the given range. If the range spans + * multiple lines, we have complex logic to draw dotted / solid / no borders to ensure + * that the range is visually distinct from adjacent ranges but looks continuous. + */ +export function* generateDecorationsForCharacterRange( + editor: TextEditor, + range: Range, +): Iterable { + if (range.isSingleLine) { + yield { + range, + style: { + top: BorderStyle.solid, + right: BorderStyle.solid, + bottom: BorderStyle.solid, + left: BorderStyle.solid, + }, + }; + return; + } + + yield* handleMultipleLines(getLineRanges(editor, range)); +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateLineInfos.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateLineInfos.ts new file mode 100644 index 0000000000..9202923d0c --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateLineInfos.ts @@ -0,0 +1,70 @@ +import { Range } from "@cursorless/common"; + +/** + * Generates a line info for each line in the given range, which includes + * information about the given line range, as well as the previous and next + * lines, and whether each line is first / last, etc. For use in + * {@link handleMultipleLines}. + * @param lineRanges A list of ranges, one for each line in the given range, + * with the first and last ranges trimmed to the start and end of the original + * range. + */ +export function* generateLineInfos(lineRanges: Range[]): Iterable { + for (let i = 0; i < lineRanges.length; i++) { + const previousLine = i === 0 ? null : lineRanges[i - 1]; + const currentLine = lineRanges[i]; + const nextLine = i === lineRanges.length - 1 ? null : lineRanges[i + 1]; + + yield { + lineNumber: currentLine.start.line, + + previousLine: + previousLine == null + ? null + : { + start: previousLine.start.character, + end: previousLine.end.character, + isFirst: i === 1, + isLast: false, + }, + + currentLine: { + start: currentLine.start.character, + end: currentLine.end.character, + isFirst: i === 0, + isLast: i === lineRanges.length - 1, + }, + + nextLine: + nextLine == null + ? null + : { + start: nextLine.start.character, + end: nextLine.end.character, + isFirst: false, + isLast: i === lineRanges.length - 2, + }, + }; + } +} + +export interface LineInfo { + lineNumber: number; + previousLine: Line | null; + currentLine: Line; + nextLine: Line | null; +} + +interface Line { + /** + * Start character. Always 0, except for possibly the first line of the + * original range. + */ + start: number; + /** End character */ + end: number; + /** `true` if this line is the first line in the original range */ + isFirst: boolean; + /** `true` if this line is the last line in the original range */ + isLast: boolean; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.test.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.test.ts new file mode 100644 index 0000000000..a482068c42 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.test.ts @@ -0,0 +1,142 @@ +import assert = require("assert"); +import { BorderStyle } from "../decorationStyle.types"; +import { handleMultipleLines } from "./handleMultipleLines"; +import { Range } from "@cursorless/common"; +import { map } from "itertools"; + +const solid = BorderStyle.solid; +const porous = BorderStyle.porous; +const none = BorderStyle.none; + +/** `[start, end]` */ +type CharacterOffsets = [number, number]; + +/** `[[firstLineStart, firstLineEnd], ...restLineEnds]` */ +type Input = [CharacterOffsets, ...number[]]; + +/** `[lineNumber, [start, end], [top, right, bottom, left]` */ +type LineDecorations = [ + number, + CharacterOffsets, + [BorderStyle, BorderStyle, BorderStyle, BorderStyle], +]; + +interface TestCase { + /** + * The input to `handleMultipleLines`, in the format + * + * ``` + * [[firstLineStart, firstLineEnd], ...restLineEnds] + * ``` + * + * We use a single number for lines after the first because they always start + * at character 0. + * + * The first line will have line number 0, and the rest will count up from + * there. + */ + input: Input; + + /** + * Each entry in this array is a list of expected highlights for a single + * line, each in the format + * + * ``` + * [lineNumber, [start, end], [top, right, bottom, left] + * ``` + */ + expected: LineDecorations[]; +} + +const testCases: TestCase[] = [ + { + input: [[0, 1], 1], + expected: [ + [0, [0, 1], [solid, porous, none, solid]], + [1, [0, 1], [none, solid, solid, porous]], + ], + }, + { + input: [[1, 2], 1], + expected: [ + [0, [1, 2], [solid, porous, solid, solid]], + [1, [0, 1], [solid, solid, solid, porous]], + ], + }, + { + input: [[1, 3], 2], + expected: [ + [0, [1, 2], [solid, none, none, solid]], + [0, [2, 3], [solid, porous, solid, none]], + [1, [0, 1], [solid, none, solid, porous]], + [1, [1, 2], [none, solid, solid, none]], + ], + }, + { + input: [[0, 0], 0, 0], + expected: [ + [0, [0, 0], [solid, porous, none, solid]], + [1, [0, 0], [porous, porous, none, porous]], + [2, [0, 0], [porous, solid, solid, porous]], + ], + }, + { + input: [[2, 3], 1], + expected: [ + [0, [2, 3], [solid, porous, solid, solid]], + [1, [0, 1], [solid, solid, solid, porous]], + ], + }, + { + input: [[1, 3], 4, 2], + expected: [ + [0, [1, 3], [solid, porous, none, solid]], + + [1, [0, 1], [solid, none, none, porous]], + [1, [1, 2], [none, none, none, none]], + [1, [2, 3], [none, none, solid, none]], + [1, [3, 4], [porous, porous, solid, none]], + + [2, [0, 2], [none, solid, solid, porous]], + ], + }, + { + input: [[0, 2], 1], + expected: [ + [0, [0, 1], [solid, none, none, solid]], + [0, [1, 2], [solid, porous, solid, none]], + [1, [0, 1], [none, solid, solid, porous]], + ], + }, + { + input: [[0, 2], 1, 0], + expected: [ + [0, [0, 1], [solid, none, none, solid]], + [0, [1, 2], [solid, porous, porous, none]], + [1, [0, 1], [none, porous, solid, porous]], + [2, [0, 0], [none, solid, solid, porous]], + ], + }, +]; + +suite("handleMultipleLines", () => { + for (const testCase of testCases) { + test(JSON.stringify(testCase.input), () => { + const [firstLine, ...rest] = testCase.input; + + const actual: LineDecorations[] = map( + handleMultipleLines([ + new Range(0, firstLine[0], 0, firstLine[1]), + ...rest.map((end, index) => new Range(index + 1, 0, index + 1, end)), + ]), + ({ range, style }) => [ + range.start.line, + [range.start.character, range.end.character], + [style.top, style.right, style.bottom, style.left], + ], + ); + + assert.deepStrictEqual(actual, testCase.expected); + }); + } +}); diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.ts new file mode 100644 index 0000000000..95c8365022 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.ts @@ -0,0 +1,232 @@ +import { Range } from "@cursorless/common"; +import { + BorderStyle, + DecorationStyle, + StyledRange, +} from "../decorationStyle.types"; +import { flatmap } from "itertools"; +import { generateLineInfos, LineInfo } from "./generateLineInfos"; + +/** + * Generates decorations for a range, which has already been split up into line + * ranges. This function implements the core logic that determines how we + * render multiline ranges, ensuring that we use dotted borders to indicate line + * continuations. + * + * @param lineRanges A list of ranges, one for each line in the given range, + * with the first and last ranges trimmed to the start and end of the original + * range. + */ +export function* handleMultipleLines( + lineRanges: Range[], +): Iterable { + yield* flatmap(generateLineInfos(lineRanges), handleLine); +} + +/** + * Returns an iterable of decorations to use to render the given line. Because + * we may want to use different borders to render different parts of the line, + * depending what is above and below the line, we may yield multiple decorations + * for a single line. + * + * We move from the start of the line to the end, keeping a state machine to + * keep track of what borders we should render. At each character where the + * previous, current, or next line starts or ends, we transition states, and + * potentially yield a decoration. + * @param lineInfo Info about the line to render, including context about the + * previous and next lines. + */ +function* handleLine(lineInfo: LineInfo): Iterable { + const { lineNumber, currentLine, nextLine } = lineInfo; + + /** A list of "events", corresponding to the start or end of a line */ + const events: Event[] = getEvents(lineInfo); + + /** + * Keep track of current borders, except for `right`, which is computed on + * the fly. + */ + const currentDecoration: Omit = { + // Start with a solid top border. We'll switch to no border when previous + // line begins. Don't need to worry about porous because only the first + // line can start after char 0. + top: BorderStyle.solid, + + // Start with a solid bottom border if we're the last line, otherwise no + // border because we'll blend with the next line. + bottom: currentLine.isLast ? BorderStyle.solid : BorderStyle.none, + + // Start with a porous border if we're continuing from previous line + left: currentLine.isFirst ? BorderStyle.solid : BorderStyle.porous, + }; + + let currentOffset = currentLine.start; + let yieldedAnything = false; + + // NB: The `loop` label here allows us to break out of the loop from inside + // the switch statement. + loop: for (const event of events) { + if (event.offset > currentOffset) { + // If we've moved forward at all since the last event, yield a decoration + // for the range between the last event and this one. + yield { + range: new Range(lineNumber, currentOffset, lineNumber, event.offset), + style: { + ...currentDecoration, + // If we're done with this line, draw a right border, otherwise don't, + // so that it merges in with the next decoration for this line. + right: + event.offset === currentLine.end + ? currentLine.isLast + ? BorderStyle.solid + : BorderStyle.porous + : BorderStyle.none, + }, + }; + yieldedAnything = true; + currentDecoration.left = BorderStyle.none; + currentOffset = event.offset; + } + + switch (event.lineType) { + case LineType.previous: + // Use no top border when overlapping with previous line so it visually + // merges; otherwise use porous border to show nice cutoff effect. + currentDecoration.top = event.isLineStart + ? BorderStyle.none + : BorderStyle.porous; + break; + case LineType.current: // event.isLineStart === false + break loop; + case LineType.next: // event.isLineStart === false + currentDecoration.bottom = nextLine!.isLast + ? BorderStyle.solid + : BorderStyle.porous; + break; + } + } + + if (!yieldedAnything) { + // If current line is empty, then we didn't yield anything in the loop above, + // so yield a decoration for the whole line. + yield { + range: new Range( + lineNumber, + currentLine.start, + lineNumber, + currentLine.end, + ), + style: { + ...currentDecoration, + right: currentLine.isLast ? BorderStyle.solid : BorderStyle.porous, + }, + }; + } +} + +interface LineEventBase { + /** + * The character offset at which this event occurs. This is the offset of the + * character that is the start or end of the line, depending on whether + * `isLineStart` is true or false. + */ + offset: number; + + /** + * The type of line that this event corresponds to. + * -1: previous line + * 0: current line + * 1: next line + */ + lineType: LineType; + + /** + * Whether this event corresponds to the start of a line. If `false`, it + * corresponds to the end of a line. + */ + isLineStart: boolean; +} + +interface PreviousLineEvent extends LineEventBase { + offset: number; + lineType: LineType.previous; + isLineStart: boolean; +} + +interface CurrentLineEvent extends LineEventBase { + offset: number; + lineType: LineType.current; + isLineStart: false; +} + +interface NextLineEvent extends LineEventBase { + offset: number; + lineType: LineType.next; + isLineStart: false; +} + +type Event = PreviousLineEvent | CurrentLineEvent | NextLineEvent; + +enum LineType { + previous = -1, + current = 0, + next = 1, +} + +/** + * Generate "events" for our state machine. + * @param lineInfo Info about the line to render + * @returns A list of "events", corresponding to the start or end of a line + */ +function getEvents({ previousLine, currentLine, nextLine }: LineInfo) { + const events: Event[] = []; + + if (previousLine != null) { + events.push( + { + offset: previousLine.start, + lineType: LineType.previous, + isLineStart: true, + }, + { + offset: previousLine.end, + lineType: LineType.previous, + isLineStart: false, + }, + ); + } + + // Note that the current and next line will always start before or equal to + // our starting offset, so we don't need to add events for them. + events.push({ + offset: currentLine.end, + lineType: LineType.current, + isLineStart: false, + }); + + if (nextLine != null) { + events.push({ + offset: nextLine.end, + lineType: LineType.next, + isLineStart: false, + }); + } + + // Sort the events by offset. If two events have the same offset, we want to + // handle the current line last, so that it takes into account whether an adjacent + // line has started or ended. If two events have the same offset and line type, + // we want to handle the start event first, as we always assume we'll handle a + // line beginning before it ends. + events.sort((a, b) => { + if (a.offset === b.offset) { + if (a.lineType === LineType.current) { + return 1; + } + return a.isLineStart ? -1 : 1; + } + + return a.offset - b.offset; + }); + + return events; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/index.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/index.ts new file mode 100644 index 0000000000..6dcd5da671 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/index.ts @@ -0,0 +1 @@ +export * from "./generateDecorationsForCharacterRange"; diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForLineRange.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForLineRange.ts new file mode 100644 index 0000000000..23ac9a6d33 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForLineRange.ts @@ -0,0 +1,58 @@ +import { Range } from "@cursorless/common"; +import { BorderStyle, StyledRange } from "./decorationStyle.types"; + +export function* generateDecorationsForLineRange( + startLine: number, + endLine: number, +): Iterable { + const lineCount = endLine - startLine + 1; + + if (lineCount === 1) { + yield { + range: new Range(startLine, 0, startLine, 0), + style: { + top: BorderStyle.solid, + right: BorderStyle.none, + bottom: BorderStyle.solid, + left: BorderStyle.none, + isWholeLine: true, + }, + }; + return; + } + + yield { + range: new Range(startLine, 0, startLine, 0), + style: { + top: BorderStyle.solid, + right: BorderStyle.none, + bottom: BorderStyle.none, + left: BorderStyle.none, + isWholeLine: true, + }, + }; + + if (lineCount > 2) { + yield { + range: new Range(startLine + 1, 0, endLine - 1, 0), + style: { + top: BorderStyle.none, + right: BorderStyle.none, + bottom: BorderStyle.none, + left: BorderStyle.none, + isWholeLine: true, + }, + }; + } + + yield { + range: new Range(endLine, 0, endLine, 0), + style: { + top: BorderStyle.none, + right: BorderStyle.none, + bottom: BorderStyle.solid, + left: BorderStyle.none, + isWholeLine: true, + }, + }; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDifferentiatedRanges.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDifferentiatedRanges.ts new file mode 100644 index 0000000000..08ae9488a0 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDifferentiatedRanges.ts @@ -0,0 +1,114 @@ +import { + GeneralizedRange, + generalizedRangeContains, + generalizedRangeTouches, +} from "@cursorless/common"; + +import { max } from "lodash"; +import { DifferentiatedGeneralizedRange } from "./decorationStyle.types"; + +/** + * Given a list of generalized ranges, returns a list of differentiated ranges, + * where any ranges that are touching have different differentiation indices. + * We ensure that nested ranges have a greater differentiation index than their + * parents, so that we can then render them in order of increasing + * differentiation index to ensure that nested ranges are rendered after their + * parents, so that we don't get strange interleaving artifacts. + * @param ranges A list of generalized ranges. + * @returns An iterable of differentiated generalized ranges. + */ +export function* generateDifferentiatedRanges( + ranges: GeneralizedRange[], +): Iterable { + ranges.sort(compareGeneralizedRangesByStart); + + /** A list of ranges that may touch the current range */ + let currentRanges: DifferentiatedGeneralizedRange[] = []; + + for (const range of ranges) { + // Remove any ranges that have ended before the start of the current range. + currentRanges = [ + ...currentRanges.filter(({ range: previousRange }) => + generalizedRangeTouches(previousRange, range), + ), + ]; + + const differentiatedRange = { + range, + differentiationIndex: getDifferentiationIndex(currentRanges, range), + } as DifferentiatedGeneralizedRange; + + yield differentiatedRange; + + currentRanges.push(differentiatedRange); + } +} + +/** + * Returns the differentiation index to use for the given range, given a list of + * ranges that touch the current range. We return a differentiation index that + * differs from any of the given ranges, and is greater than any range + * containing {@link range}. + * + * @param currentRanges A list of ranges that touch the current range + * @param range The range to get the differentiation index for + * @returns The differentiation index to use for the given range + */ +function getDifferentiationIndex( + currentRanges: DifferentiatedGeneralizedRange[], + range: GeneralizedRange, +): number { + const maxContainingDifferentiationIndex = max( + currentRanges + .filter((r) => generalizedRangeContains(r.range, range)) + .map((r) => r.differentiationIndex), + ); + + let i = + maxContainingDifferentiationIndex == null + ? 0 + : maxContainingDifferentiationIndex + 1; + + for (; ; i++) { + if ( + !currentRanges.some( + ({ differentiationIndex }) => differentiationIndex === i, + ) + ) { + return i; + } + } +} + +/** + * Compares two generalized ranges by their start positions, with line ranges + * sorted before character ranges that start on the same line. + * @param a A generalized range + * @param b A generalized range + * @returns -1 if {@link a} should be sorted before {@link b}, 1 if {@link b} + * should be sorted before {@link a}, and 0 if they are equal. + */ +function compareGeneralizedRangesByStart( + a: GeneralizedRange, + b: GeneralizedRange, +): number { + if (a.type === "character") { + if (b.type === "character") { + // a.type === "character" && b.type === "character" + return a.start.compareTo(b.start); + } + + // a.type === "character" && b.type === "line" + // Line ranges are always sorted before character ranges that start on the + // same line. + return a.start.line === b.start ? 1 : a.start.line - b.start; + } + + if (b.type === "line") { + // a.type === "line" && b.type === "line" + return a.start - b.start; + } + + // a.type === "line" && b.type === "character" + return a.start === b.start.line ? -1 : a.start - b.start.line; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/getDifferentiatedStyleMapKey.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/getDifferentiatedStyleMapKey.ts new file mode 100644 index 0000000000..4bda9d4585 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/getDifferentiatedStyleMapKey.ts @@ -0,0 +1,12 @@ +import { DifferentiatedStyle } from "./decorationStyle.types"; + +/** + * Returns a list of values that uniquely definees a differentiated style, for + * use as a key in a {@link CompositeKeyDefaultMap}. + */ +export function getDifferentiatedStyleMapKey({ + style: { top, right, bottom, left, isWholeLine }, + differentiationIndex, +}: DifferentiatedStyle) { + return [top, right, bottom, left, isWholeLine ?? false, differentiationIndex]; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/groupDifferentiatedStyledRanges.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/groupDifferentiatedStyledRanges.ts new file mode 100644 index 0000000000..22f692f04d --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/groupDifferentiatedStyledRanges.ts @@ -0,0 +1,33 @@ +import { CompositeKeyDefaultMap } from "@cursorless/common"; +import { + DifferentiatedStyle, + DifferentiatedStyledRange, + DifferentiatedStyledRangeList, +} from "./decorationStyle.types"; +import { getDifferentiatedStyleMapKey } from "./getDifferentiatedStyleMapKey"; + +/** + * Given a list of differentiated styled ranges, groups them by differentiated + * style. + * + * @param decoratedRanges An iterable of differentiated styled ranges to group. + * @returns A list where each elements contains a list of ranges that have the + * same differentiated style. + */ +export function groupDifferentiatedStyledRanges( + decoratedRanges: Iterable, +): DifferentiatedStyledRangeList[] { + const decorations: CompositeKeyDefaultMap< + DifferentiatedStyle, + DifferentiatedStyledRangeList + > = new CompositeKeyDefaultMap( + (differentiatedStyle) => ({ differentiatedStyle, ranges: [] }), + getDifferentiatedStyleMapKey, + ); + + for (const { range, differentiatedStyle } of decoratedRanges) { + decorations.get(differentiatedStyle).ranges.push(range); + } + + return Array.from(decorations.values()); +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/index.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/index.ts new file mode 100644 index 0000000000..bd61ccda88 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/index.ts @@ -0,0 +1 @@ +export * from "./VscodeFancyRangeHighlighter"; diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts new file mode 100644 index 0000000000..b160f8631c --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts @@ -0,0 +1,33 @@ +import { Disposable, TextEditor, toCharacterRange } from "@cursorless/common"; +import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl"; +import { VscodeScopeVisualizer } from "./VscodeScopeVisualizer"; +import { ScopeSupport } from "@cursorless/cursorless-engine"; + +export class VscodeIterationScopeVisualizer extends VscodeScopeVisualizer { + protected getScopeSupport(editor: TextEditor): ScopeSupport { + return this.scopeProvider.getIterationScopeSupport(editor, this.scopeType); + } + + protected registerListener(): Disposable { + return this.scopeProvider.onDidChangeIterationScopeRanges( + (editor, iterationScopeRanges) => { + this.renderer.setScopes( + editor as VscodeTextEditorImpl, + iterationScopeRanges.map(({ domain, ranges }) => ({ + domain: toCharacterRange(domain), + nestedRanges: ranges.map(({ range }) => toCharacterRange(range)), + })), + ); + }, + { + scopeType: this.scopeType, + visibleOnly: true, + includeNestedTargets: false, + }, + ); + } + + protected getNestedScopeRangeType() { + return "iteration" as const; + } +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeRenderer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeRenderer.ts new file mode 100644 index 0000000000..c9004dab50 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeRenderer.ts @@ -0,0 +1,75 @@ +import { + Disposable, + GeneralizedRange, + isGeneralizedRangeEqual, +} from "@cursorless/common"; +import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl"; +import { RangeTypeColors } from "./RangeTypeColors"; +import { VscodeFancyRangeHighlighter } from "./VscodeFancyRangeHighlighter"; +import { blendRangeTypeColors } from "./blendRangeTypeColors"; + +export interface RendererScope { + domain: GeneralizedRange; + nestedRanges: GeneralizedRange[]; +} + +/** + * Responsible for rendering scopes, as used by {@link VscodeScopeVisualizer}. + * Includes a hack where we color blend domain and nested ranges that are + * identical, to reduce load on VSCode renderer and to work around some + * glitchiness. + */ +export class VscodeScopeRenderer implements Disposable { + private domainHighlighter: VscodeFancyRangeHighlighter; + private nestedRangeHighlighter: VscodeFancyRangeHighlighter; + /** + * A highlighter that blends domain and nested range colors when they have + * identical ranges + */ + private domainEqualsNestedHighlighter: VscodeFancyRangeHighlighter; + + constructor( + domainColors: RangeTypeColors, + nestedRangeColors: RangeTypeColors, + ) { + this.domainHighlighter = new VscodeFancyRangeHighlighter(domainColors); + this.nestedRangeHighlighter = new VscodeFancyRangeHighlighter( + nestedRangeColors, + ); + this.domainEqualsNestedHighlighter = new VscodeFancyRangeHighlighter( + blendRangeTypeColors(domainColors, nestedRangeColors), + ); + } + + setScopes(editor: VscodeTextEditorImpl, scopes: RendererScope[]) { + const domainRanges: GeneralizedRange[] = []; + const allNestedRanges: GeneralizedRange[] = []; + const domainEqualsNestedRanges: GeneralizedRange[] = []; + + for (const { domain, nestedRanges } of scopes) { + if ( + nestedRanges.length === 1 && + isGeneralizedRangeEqual(nestedRanges[0], domain) + ) { + domainEqualsNestedRanges.push(domain); + continue; + } + + domainRanges.push(domain); + allNestedRanges.push(...nestedRanges); + } + + this.domainHighlighter.setRanges(editor, domainRanges); + this.nestedRangeHighlighter.setRanges(editor, allNestedRanges); + this.domainEqualsNestedHighlighter.setRanges( + editor, + domainEqualsNestedRanges, + ); + } + + dispose(): void { + this.domainHighlighter.dispose(); + this.nestedRangeHighlighter.dispose(); + this.domainEqualsNestedHighlighter.dispose(); + } +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts new file mode 100644 index 0000000000..6303d6ee3c --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts @@ -0,0 +1,56 @@ +import { + Disposable, + GeneralizedRange, + TextEditor, + toCharacterRange, +} from "@cursorless/common"; +import { ScopeSupport, TargetRanges } from "@cursorless/cursorless-engine"; +import { VscodeScopeVisualizer } from "."; +import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl"; + +abstract class VscodeScopeTargetVisualizer extends VscodeScopeVisualizer { + protected abstract getTargetRange( + targetRanges: TargetRanges, + ): GeneralizedRange; + + protected getScopeSupport(editor: TextEditor): ScopeSupport { + return this.scopeProvider.getScopeSupport(editor, this.scopeType); + } + + protected registerListener(): Disposable { + return this.scopeProvider.onDidChangeScopeRanges( + (editor, scopeRanges) => { + this.renderer.setScopes( + editor as VscodeTextEditorImpl, + scopeRanges.map(({ domain, targets }) => ({ + domain: toCharacterRange(domain), + nestedRanges: targets.map((target) => this.getTargetRange(target)), + })), + ); + }, + { scopeType: this.scopeType, visibleOnly: true }, + ); + } +} + +export class VscodeScopeContentVisualizer extends VscodeScopeTargetVisualizer { + protected getTargetRange({ contentRange }: TargetRanges): GeneralizedRange { + return toCharacterRange(contentRange); + } + + protected getNestedScopeRangeType() { + return "content" as const; + } +} + +export class VscodeScopeRemovalVisualizer extends VscodeScopeTargetVisualizer { + protected getTargetRange({ + removalHighlightRange, + }: TargetRanges): GeneralizedRange { + return removalHighlightRange; + } + + protected getNestedScopeRangeType() { + return "removal" as const; + } +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts new file mode 100644 index 0000000000..1768d47e71 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts @@ -0,0 +1,96 @@ +import { + Disposable, + IDE, + ScopeType, + TextEditor, + showError, +} from "@cursorless/common"; +import { ScopeProvider, ScopeSupport } from "@cursorless/cursorless-engine"; +import { + ScopeRangeType, + ScopeVisualizerColorConfig, +} from "@cursorless/vscode-common"; +import { vscodeApi } from "../../../vscodeApi"; +import { VscodeScopeRenderer } from "./VscodeScopeRenderer"; +import { getColorsFromConfig } from "./getColorsFromConfig"; + +export abstract class VscodeScopeVisualizer { + protected renderer!: VscodeScopeRenderer; + private scopeListenerDisposable!: Disposable; + private disposables: Disposable[] = []; + + protected abstract registerListener(): Disposable; + protected abstract getNestedScopeRangeType(): ScopeRangeType; + protected abstract getScopeSupport(editor: TextEditor): ScopeSupport; + + constructor( + private ide: IDE, + protected scopeProvider: ScopeProvider, + protected scopeType: ScopeType, + ) { + this.disposables.push( + vscodeApi.workspace.onDidChangeConfiguration( + ({ affectsConfiguration }) => { + if (affectsConfiguration("cursorless.scopeVisualizer.colors")) { + this.initialize(); + } + }, + ), + ); + } + + start() { + this.initialize(); + this.checkScopeSupport(); + } + + /** + * Checks if the scope type is supported in the active editor, and shows an + * error if not. + */ + private checkScopeSupport(): void { + const editor = this.ide.activeTextEditor; + + if (editor == null) { + return; + } + + switch (this.getScopeSupport(editor)) { + case ScopeSupport.supportedAndPresentInEditor: + case ScopeSupport.supportedButNotPresentInEditor: + return; + case ScopeSupport.supportedLegacy: + case ScopeSupport.unsupported: + showError( + this.ide.messages, + "ScopeVisualizer.scopeTypeNotSupported", + `Scope type not supported for ${editor.document.languageId}, or only defined using legacy API which doesn't support visualization. See https://www.cursorless.org/docs/contributing/adding-a-new-language/ for more about how to upgrade your language.`, + ); + } + } + + /** This function is called initially, as well as whenever color config changes */ + private initialize() { + const colorConfig = vscodeApi.workspace + .getConfiguration("cursorless.scopeVisualizer") + .get("colors")!; + + this.renderer?.dispose(); + this.renderer = new VscodeScopeRenderer( + getColorsFromConfig(colorConfig, "domain"), + getColorsFromConfig(colorConfig, this.getNestedScopeRangeType()), + ); + + // Note that on color config change, we want to re-register the listener + // so that the provider will call us again with the current scope ranges + // so that we can re-render them with the new colors. + this.scopeListenerDisposable?.dispose(); + this.scopeListenerDisposable = this.registerListener(); + } + + dispose() { + this.disposables.forEach((disposable) => disposable.dispose()); + this.renderer?.dispose(); + this.scopeListenerDisposable?.dispose(); + } +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/blendRangeTypeColors.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/blendRangeTypeColors.ts new file mode 100644 index 0000000000..c884a49d36 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/blendRangeTypeColors.ts @@ -0,0 +1,77 @@ +import tinycolor = require("tinycolor2"); +import { RangeTypeColors } from "./RangeTypeColors"; + +/** + * Blends two {@link RangeTypeColors} color configurations together according to + * their alpha channels, with the top color rendered on top of the base color. + * + * @param baseColors The colors to render underneath + * @param topColors The colors to render on top + * @returns A color config that is a blend of the two color configs, with the + * top color rendered on top of the base color + */ +export function blendRangeTypeColors( + baseColors: RangeTypeColors, + topColors: RangeTypeColors, +): RangeTypeColors { + return { + background: { + light: blendColors( + baseColors.background.light, + topColors.background.light, + ), + dark: blendColors(baseColors.background.dark, topColors.background.dark), + }, + borderSolid: { + light: blendColors( + baseColors.borderSolid.light, + topColors.borderSolid.light, + ), + dark: blendColors( + baseColors.borderSolid.dark, + topColors.borderSolid.dark, + ), + }, + borderPorous: { + light: blendColors( + baseColors.borderPorous.light, + topColors.borderPorous.light, + ), + dark: blendColors( + baseColors.borderPorous.dark, + topColors.borderPorous.dark, + ), + }, + }; +} + +/** + * Blends two colors together according to their alpha channels, with the top + * color rendered on top of the base color. + * + * Basd on https://gist.github.com/JordanDelcros/518396da1c13f75ee057 + * + * @param base The color to render underneath + * @param top The color to render on top + * @returns A color that is a blend of the two colors, with the top color + * rendered on top of the base color + */ +function blendColors(base: string, top: string): string { + const baseRgba = tinycolor(base).toRgb(); + const topRgba = tinycolor(top).toRgb(); + const blendedAlpha = 1 - (1 - topRgba.a) * (1 - baseRgba.a); + + function interpolateChannel(channel: "r" | "g" | "b"): number { + return Math.round( + (topRgba[channel] * topRgba.a) / blendedAlpha + + (baseRgba[channel] * baseRgba.a * (1 - topRgba.a)) / blendedAlpha, + ); + } + + return tinycolor({ + r: interpolateChannel("r"), + g: interpolateChannel("g"), + b: interpolateChannel("b"), + a: blendedAlpha, + }).toHex8String(); +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts new file mode 100644 index 0000000000..4abd4b6515 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts @@ -0,0 +1,24 @@ +import { IDE, ScopeType } from "@cursorless/common"; +import { ScopeProvider } from "@cursorless/cursorless-engine"; +import { VisualizationType } from "../../../ScopeVisualizerCommandApi"; +import { VscodeIterationScopeVisualizer } from "./VscodeIterationScopeVisualizer"; +import { + VscodeScopeContentVisualizer, + VscodeScopeRemovalVisualizer, +} from "./VscodeScopeTargetVisualizer"; + +export function createVscodeScopeVisualizer( + ide: IDE, + scopeProvider: ScopeProvider, + scopeType: ScopeType, + visualizationType: VisualizationType, +) { + switch (visualizationType) { + case "content": + return new VscodeScopeContentVisualizer(ide, scopeProvider, scopeType); + case "removal": + return new VscodeScopeRemovalVisualizer(ide, scopeProvider, scopeType); + case "iteration": + return new VscodeIterationScopeVisualizer(ide, scopeProvider, scopeType); + } +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/getColorsFromConfig.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/getColorsFromConfig.ts new file mode 100644 index 0000000000..862767b313 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/getColorsFromConfig.ts @@ -0,0 +1,25 @@ +import { + ScopeRangeType, + ScopeVisualizerColorConfig, +} from "@cursorless/vscode-common"; +import { RangeTypeColors } from "./RangeTypeColors"; + +export function getColorsFromConfig( + config: ScopeVisualizerColorConfig, + rangeType: ScopeRangeType, +): RangeTypeColors { + return { + background: { + light: config.light[rangeType].background, + dark: config.dark[rangeType].background, + }, + borderSolid: { + light: config.light[rangeType].borderSolid, + dark: config.dark[rangeType].borderSolid, + }, + borderPorous: { + light: config.light[rangeType].borderPorous, + dark: config.dark[rangeType].borderPorous, + }, + }; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/index.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/index.ts new file mode 100644 index 0000000000..be1bb9ef7e --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/index.ts @@ -0,0 +1,2 @@ +export * from "./createVscodeScopeVisualizer"; +export * from "./VscodeScopeVisualizer"; diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index bba8787b45..21ade7d1f7 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -14,12 +14,14 @@ import { showDocumentation, showQuickPick } from "./commands"; import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { VscodeHats } from "./ide/vscode/hats/VscodeHats"; import { KeyboardCommands } from "./keyboard/KeyboardCommands"; +import { ScopeVisualizerCommandApi } from "./ScopeVisualizerCommandApi"; export function registerCommands( extensionContext: vscode.ExtensionContext, vscodeIde: VscodeIDE, commandApi: CommandApi, testCaseRecorder: TestCaseRecorder, + scopeVisualizer: ScopeVisualizerCommandApi, keyboardCommands: KeyboardCommands, hats: VscodeHats, ): void { @@ -56,6 +58,10 @@ export function registerCommands( ["cursorless.toggleDecorations"]: hats.toggle, ["cursorless.recomputeDecorationStyles"]: hats.recomputeDecorationStyles, + // Scope visualizer + ["cursorless.showScopeVisualizer"]: scopeVisualizer.start, + ["cursorless.hideScopeVisualizer"]: scopeVisualizer.stop, + // General keyboard commands ["cursorless.keyboard.escape"]: keyboardCommands.keyboardHandler.cancelActiveListener, diff --git a/packages/cursorless-vscode/src/vscodeApi.ts b/packages/cursorless-vscode/src/vscodeApi.ts new file mode 100644 index 0000000000..8275a485c3 --- /dev/null +++ b/packages/cursorless-vscode/src/vscodeApi.ts @@ -0,0 +1,19 @@ +import { workspace, window } from "vscode"; +import { VscodeApi } from "@cursorless/vscode-common"; + +/** + * A very thin wrapper around the VSCode API that allows us to mock it for + * testing. This is necessary because the test harness gets bundled separately + * from the extension code, so if we just import the VSCode API directly from + * the extension code, and from the test harness, we'll end up with two copies + * of the VSCode API, so the mocks won't work. + */ +export const vscodeApi: VscodeApi = { + workspace, + window, + editor: { + setDecorations(editor, ...args) { + return editor.setDecorations(...args); + }, + }, +}; diff --git a/packages/vscode-common/src/ScopeVisualizerColorConfig.ts b/packages/vscode-common/src/ScopeVisualizerColorConfig.ts new file mode 100644 index 0000000000..7910cf6a76 --- /dev/null +++ b/packages/vscode-common/src/ScopeVisualizerColorConfig.ts @@ -0,0 +1,17 @@ +export type ScopeRangeType = "domain" | "content" | "removal" | "iteration"; + +export interface ScopeVisualizerColorConfig { + light: ScopeVisualizerThemeColorConfig; + dark: ScopeVisualizerThemeColorConfig; +} + +type ScopeVisualizerThemeColorConfig = Record< + ScopeRangeType, + RangeTypeColorConfig +>; + +interface RangeTypeColorConfig { + background: string; + borderSolid: string; + borderPorous: string; +} diff --git a/packages/vscode-common/src/VscodeApi.ts b/packages/vscode-common/src/VscodeApi.ts new file mode 100644 index 0000000000..34048bb2b0 --- /dev/null +++ b/packages/vscode-common/src/VscodeApi.ts @@ -0,0 +1,21 @@ +import { workspace, window, TextEditor } from "vscode"; + +/** + * Subset of VSCode api that we need to be able to mock for testing + */ +export interface VscodeApi { + workspace: typeof workspace; + window: typeof window; + + /** + * Wrapper around editor api for easy mocking. Provides various + * {@link TextEditor} methods as static functions which take a text editor as + * their first argument. + */ + editor: { + setDecorations( + editor: TextEditor, + ...args: Parameters + ): ReturnType; + }; +} diff --git a/packages/vscode-common/src/getExtensionApi.ts b/packages/vscode-common/src/getExtensionApi.ts index 6c083ed85b..079f85b704 100644 --- a/packages/vscode-common/src/getExtensionApi.ts +++ b/packages/vscode-common/src/getExtensionApi.ts @@ -13,6 +13,7 @@ import type { } from "@cursorless/common"; import * as vscode from "vscode"; import type { Language, SyntaxNode, Tree } from "web-tree-sitter"; +import { VscodeApi } from "./VscodeApi"; export interface TestHelpers { ide: NormalizedIDE; @@ -42,6 +43,11 @@ export interface TestHelpers { ): Promise; runIntegrationTests(): Promise; + + /** + * A thin wrapper around the VSCode API that allows us to mock it for testing. + */ + vscodeApi: VscodeApi; } export interface CursorlessApi { diff --git a/packages/vscode-common/src/index.ts b/packages/vscode-common/src/index.ts index 15435d5cc6..c6a679e3bc 100644 --- a/packages/vscode-common/src/index.ts +++ b/packages/vscode-common/src/index.ts @@ -3,3 +3,5 @@ export * from "./notebook"; export * from "./testUtil/openNewEditor"; export * from "./vscodeUtil"; export * from "./runCommand"; +export * from "./VscodeApi"; +export * from "./ScopeVisualizerColorConfig"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d92b9bcb4..5b7d4487f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,8 +217,8 @@ importers: specifier: ^3.1.1 version: 3.1.1 itertools: - specifier: ^1.7.1 - version: 1.7.1 + specifier: ^2.1.1 + version: 2.1.1 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -387,12 +387,21 @@ importers: '@cursorless/vscode-common': specifier: workspace:* version: link:../vscode-common + '@types/tinycolor2': + specifier: 1.4.3 + version: 1.4.3 + itertools: + specifier: ^2.1.1 + version: 2.1.1 lodash: specifier: ^4.17.21 version: 4.17.21 semver: specifier: ^7.3.9 version: 7.4.0 + tinycolor2: + specifier: 1.6.0 + version: 1.6.0 uuid: specifier: ^9.0.0 version: 9.0.0 @@ -5678,6 +5687,10 @@ packages: /@types/stack-utils@2.0.1: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} + /@types/tinycolor2@1.4.3: + resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==} + dev: false + /@types/tough-cookie@4.0.2: resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} dev: true @@ -10825,10 +10838,8 @@ packages: html-escaper: 2.0.2 istanbul-lib-report: 3.0.0 - /itertools@1.7.1: - resolution: {integrity: sha512-0sC8t0HYOH0wb/mU5eLmp2g19yfhqho12Q6kCX6MGkNEEJQz97LIXzZ2bbIDyzBnQGcMixmcAtByzKjiaFkw8Q==} - dependencies: - '@babel/runtime': 7.21.0 + /itertools@2.1.1: + resolution: {integrity: sha512-T0icRZBQfWSwhdeBvJT3Sg1m3lBOv1RCD2m+vnY7F12sIInidVDLIn5Fbu1/1gAMN8XIjzkDP48ukF7mTRn/fw==} dev: false /jake@10.8.5: @@ -15579,6 +15590,10 @@ packages: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} dev: false + /tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + dev: false + /tinylogic@2.0.0: resolution: {integrity: sha512-dljTkiLLITtsjqBvTA1MRZQK/sGP4kI3UJKc3yA9fMzYbMF2RhcN04SeROVqJBIYYOoJMM8u0WDnhFwMSFQotw==} dev: true diff --git a/scripts/build-and-assemble-website.sh b/scripts/build-and-assemble-website.sh index e07b18d17a..a7a1de98e1 100755 --- a/scripts/build-and-assemble-website.sh +++ b/scripts/build-and-assemble-website.sh @@ -4,7 +4,7 @@ set -euo pipefail pnpm install pnpm compile -NODE_OPTIONS="--max-old-space-size=4096" \ +NODE_OPTIONS="--max-old-space-size=6144" \ pnpm \ --filter 'cursorless-org' \ --filter 'cursorless-org-*' \