Skip to content

Commit dbe101f

Browse files
Setting for hat shape overrides (#1853)
![image](https://github.com/cursorless-dev/cursorless/assets/3511326/c4dfb684-4fc5-4337-ba07-25753359a602) * added hidden setting `cursorless.private.hatShapesDir` * Also made svg parser more robust ## 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 --------- Co-authored-by: Pokey Rule <[email protected]>
1 parent 072443a commit dbe101f

File tree

6 files changed

+164
-40
lines changed

6 files changed

+164
-40
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
tags: [enhancement, talon]
3-
mergeDate: 2023-09-10
43
pullRequest: 1875
4+
mergeDate: 2023-09-10
55
---
66

7-
- Added `cursorless_insert` action to the public Talon api. This api enables you to define custom grammars for cursorless text insertion. See the [talon-side api docs](https://www.cursorless.org/docs/user/customization/#public-talon-actions) for more
7+
- Added `cursorless_insert` action to the public Talon api. This api enables you to define custom grammars for Cursorless text insertion. See the [talon-side api docs](https://www.cursorless.org/docs/user/customization/#public-talon-actions) for more

packages/common/src/util/walkAsync.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@ import { flatten } from "lodash";
66
* Note: Returns full paths
77
* Based on https://gist.github.com/kethinov/6658166#gistcomment-1941504
88
* @param dir
9-
* @param filelist
9+
* @param fileEnding If defined, only return files ending with this string. Eg `.txt`
1010
* @returns
1111
*/
12-
export const walkFiles = async (dir: string): Promise<string[]> => {
12+
export const walkFiles = async (
13+
dir: string,
14+
fileEnding?: string,
15+
): Promise<string[]> => {
1316
const dirEntries = await readdir(dir, { withFileTypes: true });
1417

15-
return flatten(
18+
const files = flatten(
1619
await Promise.all(
1720
dirEntries.map(async (dirent) => {
1821
const filePath = path.join(dir, dirent.name);
1922
return dirent.isDirectory() ? await walkFiles(filePath) : [filePath];
2023
}),
2124
),
2225
);
26+
27+
if (fileEnding != null) {
28+
return files.filter((file) => file.endsWith(fileEnding));
29+
}
30+
31+
return files;
2332
};

packages/cursorless-engine/src/core/Snippets.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,6 @@ export class Snippets {
236236
}
237237
}
238238

239-
async function getSnippetPaths(snippetsDir: string) {
240-
return (await walkFiles(snippetsDir)).filter((path) =>
241-
path.endsWith(CURSORLESS_SNIPPETS_SUFFIX),
242-
);
239+
function getSnippetPaths(snippetsDir: string) {
240+
return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX);
243241
}

packages/cursorless-vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,7 @@
838838
}
839839
},
840840
"cursorless.experimental.snippetsDir": {
841-
"description": "Directory containing snippets for use in cursorless",
841+
"description": "Directory containing snippets for use in Cursorless",
842842
"type": "string"
843843
},
844844
"cursorless.experimental.keyboard.modal.keybindings.actions": {

packages/cursorless-vscode/src/ide/vscode/hats/VscodeHatRenderer.ts

Lines changed: 146 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
import { readFileSync } from "fs";
1+
import {
2+
Listener,
3+
Notifier,
4+
PathChangeListener,
5+
walkFiles,
6+
} from "@cursorless/common";
27
import { cloneDeep, isEqual } from "lodash";
3-
import { join } from "path";
8+
import * as fs from "node:fs";
9+
import * as path from "node:path";
410
import * as vscode from "vscode";
11+
import VscodeEnabledHatStyleManager, {
12+
ExtendedHatStyleMap,
13+
} from "../VscodeEnabledHatStyleManager";
14+
import { HAT_SHAPES, HatShape, VscodeHatStyleName } from "../hatStyles.types";
15+
import { FontMeasurements } from "./FontMeasurements";
516
import getHatThemeColors from "./getHatThemeColors";
617
import {
7-
defaultShapeAdjustments,
818
DEFAULT_HAT_HEIGHT_EM,
919
DEFAULT_VERTICAL_OFFSET_EM,
1020
IndividualHatAdjustmentMap,
21+
defaultShapeAdjustments,
1122
} from "./shapeAdjustments";
12-
import { Listener, Notifier } from "@cursorless/common";
13-
import { FontMeasurements } from "./FontMeasurements";
14-
import { HatShape, HAT_SHAPES, VscodeHatStyleName } from "../hatStyles.types";
15-
import VscodeEnabledHatStyleManager, {
16-
ExtendedHatStyleMap,
17-
} from "../VscodeEnabledHatStyleManager";
23+
24+
const CURSORLESS_HAT_SHAPES_SUFFIX = ".svg";
1825

1926
type HatDecorationMap = Partial<
2027
Record<VscodeHatStyleName, vscode.TextEditorDecorationType>
@@ -39,11 +46,24 @@ const hatConfigSections = [
3946
* hats. The decision about which hat styles should be available is up to
4047
* {@link VscodeEnabledHatStyles}
4148
*/
49+
50+
const SETTING_SECTION_HAT_SHAPES_DIR = "cursorless.private";
51+
const SETTING_NAME_HAT_SHAPES_DIR = "hatShapesDir";
52+
const hatShapesDirSettingId = `${SETTING_SECTION_HAT_SHAPES_DIR}.${SETTING_NAME_HAT_SHAPES_DIR}`;
53+
54+
interface SvgInfo {
55+
svg: string;
56+
svgHeightPx: number;
57+
svgWidthPx: number;
58+
}
59+
4260
export default class VscodeHatRenderer {
4361
private decorationMap!: HatDecorationMap;
4462
private disposables: vscode.Disposable[] = [];
4563
private notifier: Notifier<[]> = new Notifier();
4664
private lastSeenEnabledHatStyles: ExtendedHatStyleMap = {};
65+
private hatsDirWatcherDisposable?: vscode.Disposable;
66+
private hatShapeOverrides: Record<string, string> = {};
4767

4868
constructor(
4969
private extensionContext: vscode.ExtensionContext,
@@ -57,7 +77,9 @@ export default class VscodeHatRenderer {
5777
this.disposables.push(
5878
vscode.workspace.onDidChangeConfiguration(
5979
async ({ affectsConfiguration }) => {
60-
if (
80+
if (affectsConfiguration(hatShapesDirSettingId)) {
81+
await this.updateHatsDirWatcher();
82+
} else if (
6183
hatConfigSections.some((section) => affectsConfiguration(section))
6284
) {
6385
await this.recomputeDecorations();
@@ -88,6 +110,7 @@ export default class VscodeHatRenderer {
88110

89111
async init() {
90112
await this.constructDecorations();
113+
await this.updateHatsDirWatcher();
91114
}
92115

93116
/**
@@ -99,6 +122,52 @@ export default class VscodeHatRenderer {
99122
return this.decorationMap[hatStyle];
100123
}
101124

125+
private async updateHatsDirWatcher() {
126+
this.hatsDirWatcherDisposable?.dispose();
127+
128+
const hatsDir = vscode.workspace
129+
.getConfiguration(SETTING_SECTION_HAT_SHAPES_DIR)
130+
.get<string>(SETTING_NAME_HAT_SHAPES_DIR)!;
131+
132+
if (hatsDir) {
133+
await this.updateShapeOverrides(hatsDir);
134+
135+
if (fs.existsSync(hatsDir)) {
136+
this.hatsDirWatcherDisposable = watchDir(hatsDir, () =>
137+
this.updateShapeOverrides(hatsDir),
138+
);
139+
}
140+
} else {
141+
this.hatShapeOverrides = {};
142+
await this.recomputeDecorations();
143+
}
144+
}
145+
146+
private async updateShapeOverrides(hatShapesDir: string) {
147+
this.hatShapeOverrides = {};
148+
const files = await this.getHatShapePaths(hatShapesDir);
149+
150+
for (const file of files) {
151+
const name = path.basename(file, CURSORLESS_HAT_SHAPES_SUFFIX);
152+
this.hatShapeOverrides[name] = file;
153+
}
154+
155+
await this.recomputeDecorations();
156+
}
157+
158+
private async getHatShapePaths(hatShapesDir: string) {
159+
try {
160+
return await walkFiles(hatShapesDir, CURSORLESS_HAT_SHAPES_SUFFIX);
161+
} catch (error) {
162+
void vscode.window.showErrorMessage(
163+
`Error with cursorless hat shapes dir "${hatShapesDir}": ${
164+
(error as Error).message
165+
}`,
166+
);
167+
return [];
168+
}
169+
}
170+
102171
private destroyDecorations() {
103172
Object.values(this.decorationMap).forEach((decoration) => {
104173
decoration.dispose();
@@ -160,7 +229,16 @@ export default class VscodeHatRenderer {
160229
this.decorationMap = Object.fromEntries(
161230
Object.entries(this.enabledHatStyles.hatStyleMap).map(
162231
([styleName, { color, shape }]) => {
163-
const { svg, svgWidthPx, svgHeightPx } = hatSvgMap[shape];
232+
const svgInfo = hatSvgMap[shape];
233+
234+
if (svgInfo == null) {
235+
return [
236+
styleName,
237+
vscode.window.createTextEditorDecorationType({}),
238+
];
239+
}
240+
241+
const { svg, svgWidthPx, svgHeightPx } = svgInfo;
164242

165243
const { light, dark } = getHatThemeColors(color);
166244

@@ -194,17 +272,36 @@ export default class VscodeHatRenderer {
194272
);
195273
}
196274

197-
private constructColoredSvgDataUri(originalSvg: string, color: string) {
275+
private checkSvg(shape: HatShape, svg: string) {
276+
let isOk = true;
277+
198278
if (
199-
originalSvg.match(/fill="[^"]+"/) == null &&
200-
originalSvg.match(/fill:[^;]+;/) == null
279+
svg.match(/fill="(?!none)[^"]+"/) == null &&
280+
svg.match(/fill:(?!none)[^;]+;/) == null
201281
) {
202-
throw Error("Raw svg doesn't have fill");
282+
vscode.window.showErrorMessage(
283+
`Raw svg '${shape}' is missing 'fill' property`,
284+
);
285+
isOk = false;
203286
}
204287

288+
const viewBoxMatch = svg.match(/viewBox="([^"]+)"/);
289+
290+
if (viewBoxMatch == null) {
291+
vscode.window.showErrorMessage(
292+
`Raw svg '${shape}' is missing 'viewBox' property`,
293+
);
294+
isOk = false;
295+
}
296+
297+
return isOk;
298+
}
299+
300+
private constructColoredSvgDataUri(originalSvg: string, color: string) {
205301
const svg = originalSvg
206-
.replace(/fill="[^"]+"/, `fill="${color}"`)
207-
.replace(/fill:[^;]+;/, `fill:${color};`);
302+
.replace(/fill="(?!none)[^"]+"/g, `fill="${color}"`)
303+
.replace(/fill:(?!none)[^;]+;/g, `fill:${color};`)
304+
.replace(/\r?\n/g, " ");
208305

209306
const encoded = encodeURIComponent(svg);
210307

@@ -227,16 +324,22 @@ export default class VscodeHatRenderer {
227324
shape: HatShape,
228325
scaleFactor: number,
229326
hatVerticalOffsetEm: number,
230-
) {
231-
const iconPath = join(
232-
this.extensionContext.extensionPath,
233-
"images",
234-
"hats",
235-
`${shape}.svg`,
236-
);
237-
const rawSvg = readFileSync(iconPath, "utf8");
327+
): SvgInfo | null {
328+
const iconPath =
329+
this.hatShapeOverrides[shape] ??
330+
path.join(
331+
this.extensionContext.extensionPath,
332+
"images",
333+
"hats",
334+
`${shape}.svg`,
335+
);
336+
const rawSvg = fs.readFileSync(iconPath, "utf8");
238337
const { characterWidth, characterHeight, fontSize } = fontMeasurements;
239338

339+
if (!this.checkSvg(shape, rawSvg)) {
340+
return null;
341+
}
342+
240343
const { originalViewBoxHeight, originalViewBoxWidth } =
241344
this.getViewBoxDimensions(rawSvg);
242345

@@ -289,10 +392,7 @@ export default class VscodeHatRenderer {
289392
}
290393

291394
private getViewBoxDimensions(rawSvg: string) {
292-
const viewBoxMatch = rawSvg.match(/viewBox="([^"]+)"/);
293-
if (viewBoxMatch == null) {
294-
throw Error("View box not found in svg");
295-
}
395+
const viewBoxMatch = rawSvg.match(/viewBox="([^"]+)"/)!;
296396

297397
const originalViewBoxString = viewBoxMatch[1];
298398
const [_0, _1, originalViewBoxWidthStr, originalViewBoxHeightStr] =
@@ -306,6 +406,23 @@ export default class VscodeHatRenderer {
306406

307407
dispose() {
308408
this.destroyDecorations();
409+
this.hatsDirWatcherDisposable?.dispose();
309410
this.disposables.forEach(({ dispose }) => dispose());
310411
}
311412
}
413+
414+
function watchDir(
415+
path: string,
416+
onDidChange: PathChangeListener,
417+
): vscode.Disposable {
418+
const hatsDirWatcher = vscode.workspace.createFileSystemWatcher(
419+
new vscode.RelativePattern(path, `**/*${CURSORLESS_HAT_SHAPES_SUFFIX}`),
420+
);
421+
422+
return vscode.Disposable.from(
423+
hatsDirWatcher,
424+
hatsDirWatcher.onDidChange(onDidChange),
425+
hatsDirWatcher.onDidCreate(onDidChange),
426+
hatsDirWatcher.onDidDelete(onDidChange),
427+
);
428+
}

packages/cursorless-vscode/src/ide/vscode/hats/VscodeHats.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { VscodeHatStyleName } from "../hatStyles.types";
1515
import VscodeEnabledHatStyleManager from "../VscodeEnabledHatStyleManager";
1616
import type { VscodeIDE } from "../VscodeIDE";
1717
import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl";
18-
import VscodeHatRenderer from "./VscodeHatRenderer";
1918
import { FontMeasurements } from "./FontMeasurements";
19+
import VscodeHatRenderer from "./VscodeHatRenderer";
2020

2121
export class VscodeHats implements Hats {
2222
private enabledHatStyleManager: VscodeEnabledHatStyleManager;

0 commit comments

Comments
 (0)