1
1
import csv
2
+ from collections import defaultdict
2
3
from collections .abc import Container
4
+ from dataclasses import dataclass
3
5
from datetime import datetime
4
6
from pathlib import Path
5
- from typing import Optional
7
+ from typing import Callable , Iterable , Optional , TypedDict
6
8
7
9
from talon import Context , Module , actions , app , fs
8
10
25
27
desc = "The directory to use for cursorless settings csvs relative to talon user directory" ,
26
28
)
27
29
28
- default_ctx = Context ()
29
- default_ctx .matches = r"""
30
+ # The global context we use for our lists
31
+ ctx = Context ()
32
+
33
+ # A context that contains default vocabulary, for use in testing
34
+ normalized_ctx = Context ()
35
+ normalized_ctx .matches = r"""
30
36
tag: user.cursorless_default_vocabulary
31
37
"""
32
38
33
39
40
+ # Maps from Talon list name to a map from spoken form to value
41
+ ListToSpokenForms = dict [str , dict [str , str ]]
42
+
43
+
44
+ @dataclass
45
+ class SpokenFormEntry :
46
+ list_name : str
47
+ id : str
48
+ spoken_forms : list [str ]
49
+
50
+
34
51
def init_csv_and_watch_changes (
35
52
filename : str ,
36
- default_values : dict [str , dict [str , str ]],
53
+ default_values : ListToSpokenForms ,
54
+ handle_new_values : Optional [Callable [[list [SpokenFormEntry ]], None ]] = None ,
55
+ * ,
37
56
extra_ignored_values : Optional [list [str ]] = None ,
38
57
extra_allowed_values : Optional [list [str ]] = None ,
39
58
allow_unknown_values : bool = False ,
40
59
default_list_name : Optional [str ] = None ,
41
60
headers : list [str ] = [SPOKEN_FORM_HEADER , CURSORLESS_IDENTIFIER_HEADER ],
42
- ctx : Context = Context (),
43
61
no_update_file : bool = False ,
44
62
pluralize_lists : Optional [list [str ]] = None ,
45
63
):
46
64
"""
47
65
Initialize a cursorless settings csv, creating it if necessary, and watch
48
66
for changes to the csv. Talon lists will be generated based on the keys of
49
67
`default_values`. For example, if there is a key `foo`, there will be a
50
- list created called `user.cursorless_foo` that will contain entries from
51
- the original dict at the key `foo`, updated according to customization in
52
- the csv at
68
+ list created called `user.cursorless_foo` that will contain entries from the
69
+ original dict at the key `foo`, updated according to customization in the
70
+ csv at
53
71
54
- actions.path.talon_user() / "cursorless-settings" / filename
72
+ ```
73
+ actions.path.talon_user() / "cursorless-settings" / filename
74
+ ```
55
75
56
76
Note that the settings directory location can be customized using the
57
77
`user.cursorless_settings_directory` setting.
58
78
59
79
Args:
60
80
filename (str): The name of the csv file to be placed in
61
- `cursorles-settings` dir
62
- default_values (dict[str, dict]): The default values for the lists to
63
- be customized in the given csv
64
- extra_ignored_values list[str]: Don't throw an exception if any of
65
- these appear as values; just ignore them and don't add them to any list
66
- allow_unknown_values bool: If unknown values appear, just put them in the list
67
- default_list_name Optional[str]: If unknown values are allowed, put any
68
- unknown values in this list
69
- no_update_file Optional[bool]: Set this to `TRUE` to indicate that we should
70
- not update the csv. This is used generally in case there was an issue coming up with the default set of values so we don't want to persist those to disk
71
- pluralize_lists: Create plural version of given lists
81
+ `cursorles-settings` dir
82
+ default_values (ListToSpokenForms): The default values for the lists to
83
+ be customized in the given csv
84
+ handle_new_values (Optional[Callable[[list[SpokenFormEntry]], None]]): A
85
+ callback to be called when the lists are updated
86
+ extra_ignored_values (Optional[list[str]]): Don't throw an exception if
87
+ any of these appear as values; just ignore them and don't add them
88
+ to any list
89
+ allow_unknown_values (bool): If unknown values appear, just put them in
90
+ the list
91
+ default_list_name (Optional[str]): If unknown values are
92
+ allowed, put any unknown values in this list
93
+ headers (list[str]): The headers to use for the csv
94
+ no_update_file (bool): Set this to `True` to indicate that we should not
95
+ update the csv. This is used generally in case there was an issue
96
+ coming up with the default set of values so we don't want to persist
97
+ those to disk
98
+ pluralize_lists (list[str]): Create plural version of given lists
72
99
"""
73
100
# Don't allow both `extra_allowed_values` and `allow_unknown_values`
74
101
assert not (extra_allowed_values and allow_unknown_values )
@@ -112,7 +139,7 @@ def on_watch(path, flags):
112
139
allow_unknown_values = allow_unknown_values ,
113
140
default_list_name = default_list_name ,
114
141
pluralize_lists = pluralize_lists ,
115
- ctx = ctx ,
142
+ handle_new_values = handle_new_values ,
116
143
)
117
144
118
145
fs .watch (str (file_path .parent ), on_watch )
@@ -135,7 +162,7 @@ def on_watch(path, flags):
135
162
allow_unknown_values = allow_unknown_values ,
136
163
default_list_name = default_list_name ,
137
164
pluralize_lists = pluralize_lists ,
138
- ctx = ctx ,
165
+ handle_new_values = handle_new_values ,
139
166
)
140
167
else :
141
168
if not no_update_file :
@@ -148,7 +175,7 @@ def on_watch(path, flags):
148
175
allow_unknown_values = allow_unknown_values ,
149
176
default_list_name = default_list_name ,
150
177
pluralize_lists = pluralize_lists ,
151
- ctx = ctx ,
178
+ handle_new_values = handle_new_values ,
152
179
)
153
180
154
181
def unsubscribe ():
@@ -184,68 +211,92 @@ def create_default_vocabulary_dicts(
184
211
if active_key :
185
212
updated_dict [active_key ] = value2
186
213
default_values_updated [key ] = updated_dict
187
- assign_lists_to_context (default_ctx , default_values_updated , pluralize_lists )
214
+ assign_lists_to_context (normalized_ctx , default_values_updated , pluralize_lists )
188
215
189
216
190
217
def update_dicts (
191
- default_values : dict [ str , dict ] ,
192
- current_values : dict ,
218
+ default_values : ListToSpokenForms ,
219
+ current_values : dict [ str , str ] ,
193
220
extra_ignored_values : list [str ],
194
221
extra_allowed_values : list [str ],
195
222
allow_unknown_values : bool ,
196
223
default_list_name : Optional [str ],
197
224
pluralize_lists : list [str ],
198
- ctx : Context ,
225
+ handle_new_values : Optional [ Callable [[ list [ SpokenFormEntry ]], None ]] ,
199
226
):
200
227
# Create map with all default values
201
- results_map = {}
202
- for list_name , dict in default_values .items ():
203
- for key , value in dict .items ():
204
- results_map [value ] = {"key " : key , "value " : value , "list" : list_name }
228
+ results_map : dict [ str , ResultsListEntry ] = {}
229
+ for list_name , obj in default_values .items ():
230
+ for spoken , id in obj .items ():
231
+ results_map [id ] = {"spoken " : spoken , "id " : id , "list" : list_name }
205
232
206
233
# Update result with current values
207
- for key , value in current_values .items ():
234
+ for spoken , id in current_values .items ():
208
235
try :
209
- results_map [value ]["key " ] = key
236
+ results_map [id ]["spoken " ] = spoken
210
237
except KeyError :
211
- if value in extra_ignored_values :
238
+ if id in extra_ignored_values :
212
239
pass
213
- elif allow_unknown_values or value in extra_allowed_values :
214
- results_map [value ] = {
215
- "key" : key ,
216
- "value" : value ,
240
+ elif allow_unknown_values or id in extra_allowed_values :
241
+ assert default_list_name is not None
242
+ results_map [id ] = {
243
+ "spoken" : spoken ,
244
+ "id" : id ,
217
245
"list" : default_list_name ,
218
246
}
219
247
else :
220
248
raise
221
249
222
- # Convert result map back to result list
223
- results = {res ["list" ]: {} for res in results_map .values ()}
224
- for obj in results_map .values ():
225
- value = obj ["value" ]
226
- key = obj ["key" ]
227
- if not is_removed (key ):
228
- for k in key .split ("|" ):
229
- if value == "pasteFromClipboard" and k .endswith (" to" ):
250
+ spoken_form_entries = list (generate_spoken_forms (results_map .values ()))
251
+
252
+ # Assign result to talon context list
253
+ lists : ListToSpokenForms = defaultdict (dict )
254
+ for entry in spoken_form_entries :
255
+ for spoken_form in entry .spoken_forms :
256
+ lists [entry .list_name ][spoken_form ] = entry .id
257
+ assign_lists_to_context (ctx , lists , pluralize_lists )
258
+
259
+ if handle_new_values is not None :
260
+ handle_new_values (spoken_form_entries )
261
+
262
+
263
+ class ResultsListEntry (TypedDict ):
264
+ spoken : str
265
+ id : str
266
+ list : str
267
+
268
+
269
+ def generate_spoken_forms (results_list : Iterable [ResultsListEntry ]):
270
+ for obj in results_list :
271
+ id = obj ["id" ]
272
+ spoken = obj ["spoken" ]
273
+
274
+ spoken_forms = []
275
+ if not is_removed (spoken ):
276
+ for k in spoken .split ("|" ):
277
+ if id == "pasteFromClipboard" and k .endswith (" to" ):
230
278
# FIXME: This is a hack to work around the fact that the
231
279
# spoken form of the `pasteFromClipboard` action used to be
232
280
# "paste to", but now the spoken form is just "paste" and
233
281
# the "to" is part of the positional target. Users who had
234
282
# cursorless before this change would have "paste to" as
235
283
# their spoken form and so would need to say "paste to to".
236
284
k = k [:- 3 ]
237
- results [ obj [ "list" ]][ k .strip ()] = value
285
+ spoken_forms . append ( k .strip ())
238
286
239
- # Assign result to talon context list
240
- assign_lists_to_context (ctx , results , pluralize_lists )
287
+ yield SpokenFormEntry (
288
+ list_name = obj ["list" ],
289
+ id = id ,
290
+ spoken_forms = spoken_forms ,
291
+ )
241
292
242
293
243
294
def assign_lists_to_context (
244
295
ctx : Context ,
245
- results : dict ,
296
+ lists : ListToSpokenForms ,
246
297
pluralize_lists : list [str ],
247
298
):
248
- for list_name , dict in results .items ():
299
+ for list_name , dict in lists .items ():
249
300
list_singular_name = get_cursorless_list_name (list_name )
250
301
ctx .lists [list_singular_name ] = dict
251
302
if list_name in pluralize_lists :
@@ -410,7 +461,7 @@ def get_full_path(filename: str):
410
461
return (settings_directory / filename ).resolve ()
411
462
412
463
413
- def get_super_values (values : dict [ str , dict [ str , str ]] ):
464
+ def get_super_values (values : ListToSpokenForms ):
414
465
result : dict [str , str ] = {}
415
466
for value_dict in values .values ():
416
467
result .update (value_dict )
0 commit comments