Skip to content

Commit

Permalink
Add VscodeFancyRangeHighlighter
Browse files Browse the repository at this point in the history
  • Loading branch information
pokey committed Jul 17, 2023
1 parent 930ecb3 commit b8532c3
Show file tree
Hide file tree
Showing 20 changed files with 1,075 additions and 7 deletions.
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

0 comments on commit b8532c3

Please sign in to comment.