Skip to content

Add VscodeFancyRangeHighlighter #1649

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -84,3 +85,4 @@ export * from "./extensionDependencies";
export * from "./getFakeCommandServerApi";
export * from "./types/TestCaseFixture";
export * from "./util/getEnvironmentVariableStrict";
export * from "./util/CompositeKeyDefaultMap";
38 changes: 38 additions & 0 deletions packages/common/src/util/CompositeKeyDefaultMap.ts
Original file line number Diff line number Diff line change
@@ -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<K, V> {
private map = new Map<string, V>();

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<V> {
return this.map.values();
}
}
22 changes: 22 additions & 0 deletions packages/common/src/util/range.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/cursorless-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/cursorless-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<DifferentiatedStyledRange> = 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();
}
}
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading