1
- import { readFileSync } from "fs" ;
1
+ import {
2
+ Listener ,
3
+ Notifier ,
4
+ PathChangeListener ,
5
+ walkFiles ,
6
+ } from "@cursorless/common" ;
2
7
import { cloneDeep , isEqual } from "lodash" ;
3
- import { join } from "path" ;
8
+ import * as fs from "node:fs" ;
9
+ import * as path from "node:path" ;
4
10
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" ;
5
16
import getHatThemeColors from "./getHatThemeColors" ;
6
17
import {
7
- defaultShapeAdjustments ,
8
18
DEFAULT_HAT_HEIGHT_EM ,
9
19
DEFAULT_VERTICAL_OFFSET_EM ,
10
20
IndividualHatAdjustmentMap ,
21
+ defaultShapeAdjustments ,
11
22
} 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" ;
18
25
19
26
type HatDecorationMap = Partial <
20
27
Record < VscodeHatStyleName , vscode . TextEditorDecorationType >
@@ -39,11 +46,24 @@ const hatConfigSections = [
39
46
* hats. The decision about which hat styles should be available is up to
40
47
* {@link VscodeEnabledHatStyles}
41
48
*/
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
+
42
60
export default class VscodeHatRenderer {
43
61
private decorationMap ! : HatDecorationMap ;
44
62
private disposables : vscode . Disposable [ ] = [ ] ;
45
63
private notifier : Notifier < [ ] > = new Notifier ( ) ;
46
64
private lastSeenEnabledHatStyles : ExtendedHatStyleMap = { } ;
65
+ private hatsDirWatcherDisposable ?: vscode . Disposable ;
66
+ private hatShapeOverrides : Record < string , string > = { } ;
47
67
48
68
constructor (
49
69
private extensionContext : vscode . ExtensionContext ,
@@ -57,7 +77,9 @@ export default class VscodeHatRenderer {
57
77
this . disposables . push (
58
78
vscode . workspace . onDidChangeConfiguration (
59
79
async ( { affectsConfiguration } ) => {
60
- if (
80
+ if ( affectsConfiguration ( hatShapesDirSettingId ) ) {
81
+ await this . updateHatsDirWatcher ( ) ;
82
+ } else if (
61
83
hatConfigSections . some ( ( section ) => affectsConfiguration ( section ) )
62
84
) {
63
85
await this . recomputeDecorations ( ) ;
@@ -88,6 +110,7 @@ export default class VscodeHatRenderer {
88
110
89
111
async init ( ) {
90
112
await this . constructDecorations ( ) ;
113
+ await this . updateHatsDirWatcher ( ) ;
91
114
}
92
115
93
116
/**
@@ -99,6 +122,52 @@ export default class VscodeHatRenderer {
99
122
return this . decorationMap [ hatStyle ] ;
100
123
}
101
124
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
+
102
171
private destroyDecorations ( ) {
103
172
Object . values ( this . decorationMap ) . forEach ( ( decoration ) => {
104
173
decoration . dispose ( ) ;
@@ -160,7 +229,16 @@ export default class VscodeHatRenderer {
160
229
this . decorationMap = Object . fromEntries (
161
230
Object . entries ( this . enabledHatStyles . hatStyleMap ) . map (
162
231
( [ 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 ;
164
242
165
243
const { light, dark } = getHatThemeColors ( color ) ;
166
244
@@ -194,17 +272,36 @@ export default class VscodeHatRenderer {
194
272
) ;
195
273
}
196
274
197
- private constructColoredSvgDataUri ( originalSvg : string , color : string ) {
275
+ private checkSvg ( shape : HatShape , svg : string ) {
276
+ let isOk = true ;
277
+
198
278
if (
199
- originalSvg . match ( / f i l l = " [ ^ " ] + " / ) == null &&
200
- originalSvg . match ( / f i l l : [ ^ ; ] + ; / ) == null
279
+ svg . match ( / f i l l = " (? ! n o n e ) [ ^ " ] + " / ) == null &&
280
+ svg . match ( / f i l l : (? ! n o n e ) [ ^ ; ] + ; / ) == null
201
281
) {
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 ;
203
286
}
204
287
288
+ const viewBoxMatch = svg . match ( / v i e w B o x = " ( [ ^ " ] + ) " / ) ;
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 ) {
205
301
const svg = originalSvg
206
- . replace ( / f i l l = " [ ^ " ] + " / , `fill="${ color } "` )
207
- . replace ( / f i l l : [ ^ ; ] + ; / , `fill:${ color } ;` ) ;
302
+ . replace ( / f i l l = " (? ! n o n e ) [ ^ " ] + " / g, `fill="${ color } "` )
303
+ . replace ( / f i l l : (? ! n o n e ) [ ^ ; ] + ; / g, `fill:${ color } ;` )
304
+ . replace ( / \r ? \n / g, " " ) ;
208
305
209
306
const encoded = encodeURIComponent ( svg ) ;
210
307
@@ -227,16 +324,22 @@ export default class VscodeHatRenderer {
227
324
shape : HatShape ,
228
325
scaleFactor : number ,
229
326
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" ) ;
238
337
const { characterWidth, characterHeight, fontSize } = fontMeasurements ;
239
338
339
+ if ( ! this . checkSvg ( shape , rawSvg ) ) {
340
+ return null ;
341
+ }
342
+
240
343
const { originalViewBoxHeight, originalViewBoxWidth } =
241
344
this . getViewBoxDimensions ( rawSvg ) ;
242
345
@@ -289,10 +392,7 @@ export default class VscodeHatRenderer {
289
392
}
290
393
291
394
private getViewBoxDimensions ( rawSvg : string ) {
292
- const viewBoxMatch = rawSvg . match ( / v i e w B o x = " ( [ ^ " ] + ) " / ) ;
293
- if ( viewBoxMatch == null ) {
294
- throw Error ( "View box not found in svg" ) ;
295
- }
395
+ const viewBoxMatch = rawSvg . match ( / v i e w B o x = " ( [ ^ " ] + ) " / ) ! ;
296
396
297
397
const originalViewBoxString = viewBoxMatch [ 1 ] ;
298
398
const [ _0 , _1 , originalViewBoxWidthStr , originalViewBoxHeightStr ] =
@@ -306,6 +406,23 @@ export default class VscodeHatRenderer {
306
406
307
407
dispose ( ) {
308
408
this . destroyDecorations ( ) ;
409
+ this . hatsDirWatcherDisposable ?. dispose ( ) ;
309
410
this . disposables . forEach ( ( { dispose } ) => dispose ( ) ) ;
310
411
}
311
412
}
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
+ }
0 commit comments