From b8532c3c6be5cbb4a031bf37e0d74743f5e0ce62 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Mon, 17 Jul 2023 15:28:36 +0100 Subject: [PATCH] Add `VscodeFancyRangeHighlighter` --- packages/common/src/index.ts | 2 + .../common/src/util/CompositeKeyDefaultMap.ts | 38 +++ packages/common/src/util/range.ts | 22 ++ packages/cursorless-engine/package.json | 2 +- packages/cursorless-vscode/package.json | 3 + .../VSCodeScopeVisualizer/RangeTypeColors.ts | 13 + .../VscodeFancyRangeHighlighter.ts | 72 ++++++ .../VscodeFancyRangeHighlighterRenderer.ts | 156 ++++++++++++ .../decorationStyle.types.ts | 56 +++++ .../generateDecorationsForCharacterRange.ts | 28 +++ .../generateLineInfos.ts | 70 ++++++ .../handleMultipleLines.test.ts | 142 +++++++++++ .../handleMultipleLines.ts | 232 ++++++++++++++++++ .../index.ts | 1 + .../generateDecorationsForLineRange.ts | 58 +++++ .../generateDifferentiatedRanges.ts | 114 +++++++++ .../getDifferentiatedStyleMapKey.ts | 12 + .../groupDifferentiatedStyledRanges.ts | 33 +++ .../VscodeFancyRangeHighlighter/index.ts | 1 + pnpm-lock.yaml | 27 +- 20 files changed, 1075 insertions(+), 7 deletions(-) create mode 100644 packages/common/src/util/CompositeKeyDefaultMap.ts create mode 100644 packages/common/src/util/range.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/RangeTypeColors.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighter.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/VscodeFancyRangeHighlighterRenderer.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/decorationStyle.types.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateDecorationsForCharacterRange.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/generateLineInfos.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.test.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/handleMultipleLines.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForCharacterRange/index.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDecorationsForLineRange.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/generateDifferentiatedRanges.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/getDifferentiatedStyleMapKey.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/groupDifferentiatedStyledRanges.ts create mode 100644 packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/index.ts diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a4b6aabba0f..186323cc9ea 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/util/CompositeKeyDefaultMap.ts b/packages/common/src/util/CompositeKeyDefaultMap.ts new file mode 100644 index 00000000000..24c600a38d0 --- /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/range.ts b/packages/common/src/util/range.ts new file mode 100644 index 00000000000..8ea42b9d065 --- /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 f30e3aaf395..3d0dd09facd 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-vscode/package.json b/packages/cursorless-vscode/package.json index 1aba06ab319..c0ee84774dd 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -871,8 +871,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/ide/vscode/VSCodeScopeVisualizer/RangeTypeColors.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/RangeTypeColors.ts new file mode 100644 index 00000000000..b086632f4c6 --- /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 00000000000..ca4c7a9928f --- /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 00000000000..a466d41556e --- /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 00000000000..f20c439cdef --- /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 00000000000..0cebef7d097 --- /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 00000000000..9202923d0c4 --- /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 00000000000..a482068c42a --- /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 00000000000..95c83650225 --- /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 00000000000..6dcd5da6714 --- /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 00000000000..23ac9a6d334 --- /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 00000000000..08ae9488a0f --- /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 00000000000..4bda9d45850 --- /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 00000000000..22f692f04d5 --- /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 00000000000..bd61ccda888 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeFancyRangeHighlighter/index.ts @@ -0,0 +1 @@ +export * from "./VscodeFancyRangeHighlighter"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d92b9bcb4b..5b7d4487f61 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