Skip to content

Commit cd8b6a1

Browse files
authored
Add VscodeFancyRangeHighlighter (#1649)
- Depends on #1648 - Depends on #1645 - Required by #1653 ## Checklist - [ ] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [ ] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [ ] I have not broken the cheatsheet
1 parent d967c54 commit cd8b6a1

20 files changed

+1075
-7
lines changed

packages/common/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export { default as DefaultMap } from "./util/DefaultMap";
5252
export * from "./types/GeneralizedRange";
5353
export * from "./types/RangeOffsets";
5454
export * from "./util/omitByDeep";
55+
export * from "./util/range";
5556
export * from "./testUtil/isTesting";
5657
export * from "./testUtil/testConstants";
5758
export * from "./testUtil/getFixturePaths";
@@ -84,3 +85,4 @@ export * from "./extensionDependencies";
8485
export * from "./getFakeCommandServerApi";
8586
export * from "./types/TestCaseFixture";
8687
export * from "./util/getEnvironmentVariableStrict";
88+
export * from "./util/CompositeKeyDefaultMap";
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* A map that uses a composite key to store values. If a value is not found for
3+
* a given key, the default value is returned.
4+
*/
5+
export class CompositeKeyDefaultMap<K, V> {
6+
private map = new Map<string, V>();
7+
8+
constructor(
9+
private getDefaultValue: (key: K) => V,
10+
private hashFunction: (key: K) => unknown[],
11+
) {}
12+
13+
hash(key: K): string {
14+
return this.hashFunction(key).join("\u0000");
15+
}
16+
17+
get(key: K): V {
18+
const stringKey = this.hash(key);
19+
const currentValue = this.map.get(stringKey);
20+
21+
if (currentValue != null) {
22+
return currentValue;
23+
}
24+
25+
const value = this.getDefaultValue(key);
26+
this.map.set(stringKey, value);
27+
28+
return value;
29+
}
30+
31+
entries(): IterableIterator<[string, V]> {
32+
return this.map.entries();
33+
}
34+
35+
values(): IterableIterator<V> {
36+
return this.map.values();
37+
}
38+
}

packages/common/src/util/range.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { range as lodashRange } from "lodash";
2+
import { Range } from "../types/Range";
3+
import { TextEditor } from "../types/TextEditor";
4+
5+
/**
6+
* @param editor The editor containing the range
7+
* @param range The range to get the line ranges for
8+
* @returns A list of ranges, one for each line in the given range, with the
9+
* first and last ranges trimmed to the start and end of the given range.
10+
*/
11+
export function getLineRanges(editor: TextEditor, range: Range): Range[] {
12+
const { document } = editor;
13+
const lineRanges = lodashRange(range.start.line, range.end.line + 1).map(
14+
(lineNumber) => document.lineAt(lineNumber).range,
15+
);
16+
lineRanges[0] = lineRanges[0].with(range.start);
17+
lineRanges[lineRanges.length - 1] = lineRanges[lineRanges.length - 1].with(
18+
undefined,
19+
range.end,
20+
);
21+
return lineRanges;
22+
}

packages/cursorless-engine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@cursorless/common": "workspace:*",
1616
"immer": "^9.0.15",
1717
"immutability-helper": "^3.1.1",
18-
"itertools": "^1.7.1",
18+
"itertools": "^2.1.1",
1919
"lodash": "^4.17.21",
2020
"node-html-parser": "^5.3.3",
2121
"zod": "3.21.4",

packages/cursorless-vscode/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,8 +871,11 @@
871871
"@cursorless/common": "workspace:*",
872872
"@cursorless/cursorless-engine": "workspace:*",
873873
"@cursorless/vscode-common": "workspace:*",
874+
"@types/tinycolor2": "1.4.3",
875+
"itertools": "^2.1.1",
874876
"lodash": "^4.17.21",
875877
"semver": "^7.3.9",
878+
"tinycolor2": "1.6.0",
876879
"uuid": "^9.0.0",
877880
"vscode-uri": "^3.0.6"
878881
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* The colors used to render a range type, such as "domain", "content", etc.
3+
*/
4+
export interface RangeTypeColors {
5+
background: ThemeColors;
6+
borderSolid: ThemeColors;
7+
borderPorous: ThemeColors;
8+
}
9+
10+
interface ThemeColors {
11+
light: string;
12+
dark: string;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { GeneralizedRange, Range } from "@cursorless/common";
2+
import { flatmap } from "itertools";
3+
import { VscodeTextEditorImpl } from "../../VscodeTextEditorImpl";
4+
import { RangeTypeColors } from "../RangeTypeColors";
5+
import { VscodeFancyRangeHighlighterRenderer } from "./VscodeFancyRangeHighlighterRenderer";
6+
import { generateDecorationsForCharacterRange } from "./generateDecorationsForCharacterRange";
7+
import { generateDecorationsForLineRange } from "./generateDecorationsForLineRange";
8+
import { generateDifferentiatedRanges } from "./generateDifferentiatedRanges";
9+
import { DifferentiatedStyledRange } from "./decorationStyle.types";
10+
import { groupDifferentiatedStyledRanges } from "./groupDifferentiatedStyledRanges";
11+
12+
/**
13+
* A class for highlighting ranges in a VSCode editor, which does the following:
14+
*
15+
* - Uses a combination of solid lines and dotted lines to make it easier to
16+
* visualize multi-line ranges, while still making directly adjacent ranges
17+
* visually distinct.
18+
* - Works around a bug in VSCode where decorations that are touching get merged
19+
* together.
20+
* - Ensures that nested ranges are rendered after their parents, so that they
21+
* look properly nested.
22+
*/
23+
export class VscodeFancyRangeHighlighter {
24+
private renderer: VscodeFancyRangeHighlighterRenderer;
25+
26+
constructor(colors: RangeTypeColors) {
27+
this.renderer = new VscodeFancyRangeHighlighterRenderer(colors);
28+
}
29+
30+
setRanges(editor: VscodeTextEditorImpl, ranges: GeneralizedRange[]) {
31+
const decoratedRanges: Iterable<DifferentiatedStyledRange> = flatmap(
32+
// We first generate a list of differentiated ranges, which are ranges
33+
// where any ranges that are touching have different differentiation
34+
// indices. This is used to ensure that ranges that are touching are
35+
// rendered with different TextEditorDecorationTypes, so that they don't
36+
// get merged together by VSCode.
37+
generateDifferentiatedRanges(ranges),
38+
39+
// Then, we generate the actual decorations for each differentiated range.
40+
// A single range will be split into multiple decorations if it spans
41+
// multiple lines, so that we can eg use dashed lines to end lines that
42+
// are part of the same range.
43+
function* ({ range, differentiationIndex }) {
44+
const iterable =
45+
range.type === "line"
46+
? generateDecorationsForLineRange(range.start, range.end)
47+
: generateDecorationsForCharacterRange(
48+
editor,
49+
new Range(range.start, range.end),
50+
);
51+
52+
for (const { range, style } of iterable) {
53+
yield {
54+
range,
55+
differentiatedStyle: { style, differentiationIndex },
56+
};
57+
}
58+
},
59+
);
60+
61+
this.renderer.setRanges(
62+
editor,
63+
// Group the decorations so that we have a list of ranges for each
64+
// differentiated style
65+
groupDifferentiatedStyledRanges(decoratedRanges),
66+
);
67+
}
68+
69+
dispose() {
70+
this.renderer.dispose();
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { CompositeKeyDefaultMap } from "@cursorless/common";
2+
import { toVscodeRange } from "@cursorless/vscode-common";
3+
import {
4+
DecorationRangeBehavior,
5+
DecorationRenderOptions,
6+
TextEditorDecorationType,
7+
} from "vscode";
8+
import { vscodeApi } from "../../../../vscodeApi";
9+
import { VscodeTextEditorImpl } from "../../VscodeTextEditorImpl";
10+
import { RangeTypeColors } from "../RangeTypeColors";
11+
import {
12+
BorderStyle,
13+
DecorationStyle,
14+
DifferentiatedStyle,
15+
DifferentiatedStyledRangeList,
16+
} from "./decorationStyle.types";
17+
import { getDifferentiatedStyleMapKey } from "./getDifferentiatedStyleMapKey";
18+
19+
const BORDER_WIDTH = "1px";
20+
const BORDER_RADIUS = "2px";
21+
22+
/**
23+
* Handles the actual rendering of decorations for
24+
* {@link VscodeFancyRangeHighlighter}.
25+
*/
26+
export class VscodeFancyRangeHighlighterRenderer {
27+
private decorationTypes: CompositeKeyDefaultMap<
28+
DifferentiatedStyle,
29+
TextEditorDecorationType
30+
>;
31+
32+
constructor(colors: RangeTypeColors) {
33+
this.decorationTypes = new CompositeKeyDefaultMap(
34+
({ style }) => getDecorationStyle(colors, style),
35+
getDifferentiatedStyleMapKey,
36+
);
37+
}
38+
39+
/**
40+
* Renders the given ranges in the given editor.
41+
*
42+
* @param editor The editor to render the decorations in.
43+
* @param decoratedRanges A list with one element per differentiated style,
44+
* each of which contains a list of ranges to render for that style. We render
45+
* the ranges in order of increasing differentiation index.
46+
* {@link VscodeFancyRangeHighlighter} uses this to ensure that nested ranges
47+
* are rendered after their parents. Otherwise they partially interleave,
48+
* which looks bad.
49+
*/
50+
setRanges(
51+
editor: VscodeTextEditorImpl,
52+
decoratedRanges: DifferentiatedStyledRangeList[],
53+
): void {
54+
/**
55+
* Keep track of which styles have no ranges, so that we can set their
56+
* range list to `[]`
57+
*/
58+
const untouchedDecorationTypes = new Set(this.decorationTypes.values());
59+
60+
decoratedRanges.sort(
61+
(a, b) =>
62+
a.differentiatedStyle.differentiationIndex -
63+
b.differentiatedStyle.differentiationIndex,
64+
);
65+
66+
decoratedRanges.forEach(
67+
({ differentiatedStyle: styleParameters, ranges }) => {
68+
const decorationType = this.decorationTypes.get(styleParameters);
69+
70+
vscodeApi.editor.setDecorations(
71+
editor.vscodeEditor,
72+
decorationType,
73+
ranges.map(toVscodeRange),
74+
);
75+
76+
untouchedDecorationTypes.delete(decorationType);
77+
},
78+
);
79+
80+
untouchedDecorationTypes.forEach((decorationType) => {
81+
editor.vscodeEditor.setDecorations(decorationType, []);
82+
});
83+
}
84+
85+
dispose() {
86+
Array.from(this.decorationTypes.values()).forEach((decorationType) => {
87+
decorationType.dispose();
88+
});
89+
}
90+
}
91+
92+
function getDecorationStyle(
93+
colors: RangeTypeColors,
94+
borders: DecorationStyle,
95+
): TextEditorDecorationType {
96+
const options: DecorationRenderOptions = {
97+
light: {
98+
backgroundColor: colors.background.light,
99+
borderColor: getBorderColor(
100+
colors.borderSolid.light,
101+
colors.borderPorous.light,
102+
borders,
103+
),
104+
},
105+
dark: {
106+
backgroundColor: colors.background.dark,
107+
borderColor: getBorderColor(
108+
colors.borderSolid.dark,
109+
colors.borderPorous.dark,
110+
borders,
111+
),
112+
},
113+
borderStyle: getBorderStyle(borders),
114+
borderWidth: BORDER_WIDTH,
115+
borderRadius: getBorderRadius(borders),
116+
rangeBehavior: DecorationRangeBehavior.ClosedClosed,
117+
isWholeLine: borders.isWholeLine,
118+
};
119+
120+
return vscodeApi.window.createTextEditorDecorationType(options);
121+
}
122+
123+
function getBorderStyle(borders: DecorationStyle): string {
124+
return [borders.top, borders.right, borders.bottom, borders.left].join(" ");
125+
}
126+
127+
function getBorderColor(
128+
solidColor: string,
129+
porousColor: string,
130+
borders: DecorationStyle,
131+
): string {
132+
return [
133+
borders.top === BorderStyle.solid ? solidColor : porousColor,
134+
borders.right === BorderStyle.solid ? solidColor : porousColor,
135+
borders.bottom === BorderStyle.solid ? solidColor : porousColor,
136+
borders.left === BorderStyle.solid ? solidColor : porousColor,
137+
].join(" ");
138+
}
139+
140+
function getBorderRadius(borders: DecorationStyle): string {
141+
return [
142+
getSingleCornerBorderRadius(borders.top, borders.left),
143+
getSingleCornerBorderRadius(borders.top, borders.right),
144+
getSingleCornerBorderRadius(borders.bottom, borders.right),
145+
getSingleCornerBorderRadius(borders.bottom, borders.left),
146+
].join(" ");
147+
}
148+
149+
function getSingleCornerBorderRadius(side1: BorderStyle, side2: BorderStyle) {
150+
// We only round the corners if both sides are solid, as that makes them look
151+
// more finished, whereas we want the dotted borders to look unfinished / cut
152+
// off.
153+
return side1 === BorderStyle.solid && side2 === BorderStyle.solid
154+
? BORDER_RADIUS
155+
: "0px";
156+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { GeneralizedRange, Range } from "@cursorless/common";
2+
3+
export enum BorderStyle {
4+
porous = "dashed",
5+
solid = "solid",
6+
none = "none",
7+
}
8+
9+
export interface DecorationStyle {
10+
top: BorderStyle;
11+
bottom: BorderStyle;
12+
left: BorderStyle;
13+
right: BorderStyle;
14+
isWholeLine?: boolean;
15+
}
16+
17+
/**
18+
* A decoration style that is differentiated from other styles by a number. We
19+
* use this number to ensure that adjacent ranges are rendered with different
20+
* TextEditorDecorationTypes, so that they don't get merged together due to a
21+
* VSCode bug.
22+
*/
23+
export interface DifferentiatedStyle {
24+
style: DecorationStyle;
25+
26+
/**
27+
* A number that is different from the differentiation indices of any other
28+
* ranges that are touching this range.
29+
*/
30+
differentiationIndex: number;
31+
}
32+
33+
export interface StyledRange {
34+
style: DecorationStyle;
35+
range: Range;
36+
}
37+
38+
export interface DifferentiatedStyledRange {
39+
differentiatedStyle: DifferentiatedStyle;
40+
range: Range;
41+
}
42+
43+
export interface DifferentiatedStyledRangeList {
44+
differentiatedStyle: DifferentiatedStyle;
45+
ranges: Range[];
46+
}
47+
48+
export interface DifferentiatedGeneralizedRange {
49+
range: GeneralizedRange;
50+
51+
/**
52+
* A number that is different from the differentiation indices of any other
53+
* ranges that are touching this range.
54+
*/
55+
differentiationIndex: number;
56+
}

0 commit comments

Comments
 (0)