Skip to content

Commit 3b87860

Browse files
committed
Write spokenForms.json from Talon
1 parent d80f28e commit 3b87860

File tree

4 files changed

+207
-52
lines changed

4 files changed

+207
-52
lines changed

cursorless-talon/src/csv_overrides.py

+91-37
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import csv
22
from collections.abc import Container
3+
from dataclasses import dataclass
34
from datetime import datetime
45
from pathlib import Path
5-
from typing import Optional
6+
from typing import Callable, Optional, TypedDict
67

78
from talon import Context, Module, actions, app, fs
89

@@ -25,49 +26,73 @@
2526
desc="The directory to use for cursorless settings csvs relative to talon user directory",
2627
)
2728

28-
default_ctx = Context()
29-
default_ctx.matches = r"""
29+
# The global context we use for our lists
30+
ctx = Context()
31+
32+
# A context that contains default vocabulary, for use in testing
33+
normalized_ctx = Context()
34+
normalized_ctx.matches = r"""
3035
tag: user.cursorless_default_vocabulary
3136
"""
3237

3338

39+
# Maps from Talon list name to a map from spoken form to value
40+
ListToSpokenForms = dict[str, dict[str, str]]
41+
42+
43+
@dataclass
44+
class SpokenFormEntry:
45+
list_name: str
46+
id: str
47+
spoken_forms: list[str]
48+
49+
3450
def init_csv_and_watch_changes(
3551
filename: str,
36-
default_values: dict[str, dict[str, str]],
52+
default_values: ListToSpokenForms,
53+
handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]] = None,
3754
extra_ignored_values: Optional[list[str]] = None,
3855
allow_unknown_values: bool = False,
3956
default_list_name: Optional[str] = None,
4057
headers: list[str] = [SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER],
41-
ctx: Context = Context(),
4258
no_update_file: bool = False,
43-
pluralize_lists: Optional[list[str]] = [],
59+
pluralize_lists: list[str] = [],
4460
):
4561
"""
4662
Initialize a cursorless settings csv, creating it if necessary, and watch
4763
for changes to the csv. Talon lists will be generated based on the keys of
4864
`default_values`. For example, if there is a key `foo`, there will be a
49-
list created called `user.cursorless_foo` that will contain entries from
50-
the original dict at the key `foo`, updated according to customization in
51-
the csv at
65+
list created called `user.cursorless_foo` that will contain entries from the
66+
original dict at the key `foo`, updated according to customization in the
67+
csv at
5268
53-
actions.path.talon_user() / "cursorless-settings" / filename
69+
```
70+
actions.path.talon_user() / "cursorless-settings" / filename
71+
```
5472
5573
Note that the settings directory location can be customized using the
5674
`user.cursorless_settings_directory` setting.
5775
5876
Args:
5977
filename (str): The name of the csv file to be placed in
60-
`cursorles-settings` dir
61-
default_values (dict[str, dict]): The default values for the lists to
62-
be customized in the given csv
63-
extra_ignored_values list[str]: Don't throw an exception if any of
64-
these appear as values; just ignore them and don't add them to any list
65-
allow_unknown_values bool: If unknown values appear, just put them in the list
66-
default_list_name Optional[str]: If unknown values are allowed, put any
67-
unknown values in this list
68-
no_update_file Optional[bool]: Set this to `TRUE` to indicate that we should
69-
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
70-
pluralize_lists: Create plural version of given lists
78+
`cursorles-settings` dir
79+
default_values (ListToSpokenForms): The default values for the lists to
80+
be customized in the given csv
81+
handle_new_values (Optional[Callable[[list[SpokenFormEntry]], None]]): A
82+
callback to be called when the lists are updated
83+
extra_ignored_values (Optional[list[str]]): Don't throw an exception if
84+
any of these appear as values; just ignore them and don't add them
85+
to any list
86+
allow_unknown_values (bool): If unknown values appear, just put them in
87+
the list
88+
default_list_name (Optional[str]): If unknown values are
89+
allowed, put any unknown values in this list
90+
headers (list[str]): The headers to use for the csv
91+
no_update_file (bool): Set this to `True` to indicate that we should not
92+
update the csv. This is used generally in case there was an issue
93+
coming up with the default set of values so we don't want to persist
94+
those to disk
95+
pluralize_lists (list[str]): Create plural version of given lists
7196
"""
7297
if extra_ignored_values is None:
7398
extra_ignored_values = []
@@ -96,7 +121,7 @@ def on_watch(path, flags):
96121
allow_unknown_values,
97122
default_list_name,
98123
pluralize_lists,
99-
ctx,
124+
handle_new_values,
100125
)
101126

102127
fs.watch(str(file_path.parent), on_watch)
@@ -117,7 +142,7 @@ def on_watch(path, flags):
117142
allow_unknown_values,
118143
default_list_name,
119144
pluralize_lists,
120-
ctx,
145+
handle_new_values,
121146
)
122147
else:
123148
if not no_update_file:
@@ -129,7 +154,7 @@ def on_watch(path, flags):
129154
allow_unknown_values,
130155
default_list_name,
131156
pluralize_lists,
132-
ctx,
157+
handle_new_values,
133158
)
134159

135160
def unsubscribe():
@@ -165,22 +190,22 @@ def create_default_vocabulary_dicts(
165190
if active_key:
166191
updated_dict[active_key] = value2
167192
default_values_updated[key] = updated_dict
168-
assign_lists_to_context(default_ctx, default_values_updated, pluralize_lists)
193+
assign_lists_to_context(normalized_ctx, default_values_updated, pluralize_lists)
169194

170195

171196
def update_dicts(
172-
default_values: dict[str, dict],
173-
current_values: dict,
197+
default_values: ListToSpokenForms,
198+
current_values: dict[str, str],
174199
extra_ignored_values: list[str],
175200
allow_unknown_values: bool,
176201
default_list_name: Optional[str],
177202
pluralize_lists: list[str],
178-
ctx: Context,
203+
handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]],
179204
):
180205
# Create map with all default values
181-
results_map = {}
182-
for list_name, dict in default_values.items():
183-
for key, value in dict.items():
206+
results_map: dict[str, ResultsListEntry] = {}
207+
for list_name, obj in default_values.items():
208+
for key, value in obj.items():
184209
results_map[value] = {"key": key, "value": value, "list": list_name}
185210

186211
# Update result with current values
@@ -190,7 +215,7 @@ def update_dicts(
190215
except KeyError:
191216
if value in extra_ignored_values:
192217
pass
193-
elif allow_unknown_values:
218+
elif allow_unknown_values and default_list_name is not None:
194219
results_map[value] = {
195220
"key": key,
196221
"value": value,
@@ -201,9 +226,35 @@ def update_dicts(
201226

202227
# Convert result map back to result list
203228
results = {res["list"]: {} for res in results_map.values()}
204-
for obj in results_map.values():
229+
values: list[SpokenFormEntry] = []
230+
for list_name, id, spoken_forms in generate_spoken_forms(
231+
list(results_map.values())
232+
):
233+
for spoken_form in spoken_forms:
234+
results[list_name][spoken_form] = id
235+
values.append(
236+
SpokenFormEntry(list_name=list_name, id=id, spoken_forms=spoken_forms)
237+
)
238+
239+
# Assign result to talon context list
240+
assign_lists_to_context(ctx, results, pluralize_lists)
241+
242+
if handle_new_values is not None:
243+
handle_new_values(values)
244+
245+
246+
class ResultsListEntry(TypedDict):
247+
key: str
248+
value: str
249+
list: str
250+
251+
252+
def generate_spoken_forms(results_list: list[ResultsListEntry]):
253+
for obj in results_list:
205254
value = obj["value"]
206255
key = obj["key"]
256+
257+
spoken = []
207258
if not is_removed(key):
208259
for k in key.split("|"):
209260
if value == "pasteFromClipboard" and k.endswith(" to"):
@@ -214,10 +265,13 @@ def update_dicts(
214265
# cursorless before this change would have "paste to" as
215266
# their spoken form and so would need to say "paste to to".
216267
k = k[:-3]
217-
results[obj["list"]][k.strip()] = value
268+
spoken.append(k.strip())
218269

219-
# Assign result to talon context list
220-
assign_lists_to_context(ctx, results, pluralize_lists)
270+
yield (
271+
obj["list"],
272+
value,
273+
spoken,
274+
)
221275

222276

223277
def assign_lists_to_context(
@@ -386,7 +440,7 @@ def get_full_path(filename: str):
386440
return (settings_directory / filename).resolve()
387441

388442

389-
def get_super_values(values: dict[str, dict[str, str]]):
443+
def get_super_values(values: ListToSpokenForms):
390444
result: dict[str, str] = {}
391445
for value_dict in values.values():
392446
result.update(value_dict)

cursorless-talon/src/marks/decorated_mark.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str])
138138
"hat_color": active_hat_colors,
139139
"hat_shape": active_hat_shapes,
140140
},
141-
[*hat_colors.values(), *hat_shapes.values()],
141+
extra_ignored_values=[*hat_colors.values(), *hat_shapes.values()],
142142
no_update_file=is_shape_error or is_color_error,
143143
)
144144

cursorless-talon/src/spoken_forms.py

+69-14
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,30 @@
44

55
from talon import app, fs
66

7-
from .csv_overrides import SPOKEN_FORM_HEADER, init_csv_and_watch_changes
7+
from .csv_overrides import (
8+
SPOKEN_FORM_HEADER,
9+
ListToSpokenForms,
10+
SpokenFormEntry,
11+
init_csv_and_watch_changes,
12+
)
813
from .marks.decorated_mark import init_hats
14+
from .spoken_forms_output import SpokenFormsOutput
915

1016
JSON_FILE = Path(__file__).parent / "spoken_forms.json"
1117
disposables: list[Callable] = []
1218

1319

14-
def watch_file(spoken_forms: dict, filename: str) -> Callable:
15-
return init_csv_and_watch_changes(
16-
filename,
17-
spoken_forms[filename],
18-
)
19-
20-
2120
P = ParamSpec("P")
2221
R = TypeVar("R")
2322

2423

2524
def auto_construct_defaults(
26-
spoken_forms: dict[str, dict[str, dict[str, str]]],
27-
f: Callable[Concatenate[str, dict[str, dict[str, str]], P], R],
25+
spoken_forms: dict[str, ListToSpokenForms],
26+
handle_new_values: Callable[[str, list[SpokenFormEntry]], None],
27+
f: Callable[
28+
Concatenate[str, ListToSpokenForms, Callable[[list[SpokenFormEntry]], None], P],
29+
R,
30+
],
2831
):
2932
"""
3033
Decorator that automatically constructs the default values for the
@@ -37,17 +40,38 @@ def auto_construct_defaults(
3740
of `init_csv_and_watch_changes` to remove the `default_values` parameter.
3841
3942
Args:
40-
spoken_forms (dict[str, dict[str, dict[str, str]]]): The spoken forms
41-
f (Callable[Concatenate[str, dict[str, dict[str, str]], P], R]): Will always be `init_csv_and_watch_changes`
43+
spoken_forms (dict[str, ListToSpokenForms]): The spoken forms
44+
handle_new_values (Callable[[ListToSpokenForms], None]): A callback to be called when the lists are updated
45+
f (Callable[Concatenate[str, ListToSpokenForms, P], R]): Will always be `init_csv_and_watch_changes`
4246
"""
4347

4448
def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R:
4549
default_values = spoken_forms[filename]
46-
return f(filename, default_values, *args, **kwargs)
50+
return f(
51+
filename,
52+
default_values,
53+
lambda new_values: handle_new_values(filename, new_values),
54+
*args,
55+
**kwargs,
56+
)
4757

4858
return ret
4959

5060

61+
# Maps from Talon list name to the type of the value in that list, e.g.
62+
# `pairedDelimiter` or `simpleScopeTypeType`
63+
# FIXME: This is a hack until we generate spoken_forms.json from Typescript side
64+
# At that point we can just include its type as part of that file
65+
LIST_TO_TYPE_MAP = {
66+
"wrapper_selectable_paired_delimiter": "pairedDelimiter",
67+
"selectable_only_paired_delimiter": "pairedDelimiter",
68+
"wrapper_only_paired_delimiter": "pairedDelimiter",
69+
"surrounding_pair_scope_type": "pairedDelimiter",
70+
"scope_type": "simpleScopeTypeType",
71+
"custom_regex_scope_type": "customRegex",
72+
}
73+
74+
5175
def update():
5276
global disposables
5377

@@ -57,7 +81,35 @@ def update():
5781
with open(JSON_FILE, encoding="utf-8") as file:
5882
spoken_forms = json.load(file)
5983

60-
handle_csv = auto_construct_defaults(spoken_forms, init_csv_and_watch_changes)
84+
initialized = False
85+
custom_spoken_forms: dict[str, list[SpokenFormEntry]] = {}
86+
spoken_forms_output = SpokenFormsOutput()
87+
spoken_forms_output.init()
88+
89+
def update_spoken_forms_output():
90+
spoken_forms_output.write(
91+
[
92+
{
93+
"type": LIST_TO_TYPE_MAP[entry.list_name],
94+
"id": entry.id,
95+
"spokenForms": entry.spoken_forms,
96+
}
97+
for spoken_form_list in custom_spoken_forms.values()
98+
for entry in spoken_form_list
99+
if entry.list_name in LIST_TO_TYPE_MAP
100+
]
101+
)
102+
103+
def handle_new_values(csv_name: str, values: list[SpokenFormEntry]):
104+
custom_spoken_forms[csv_name] = values
105+
if initialized:
106+
# On first run, we just do one update at the end, so we suppress
107+
# writing until we get there
108+
update_spoken_forms_output()
109+
110+
handle_csv = auto_construct_defaults(
111+
spoken_forms, handle_new_values, init_csv_and_watch_changes
112+
)
61113

62114
disposables = [
63115
handle_csv("actions.csv"),
@@ -107,6 +159,9 @@ def update():
107159
),
108160
]
109161

162+
update_spoken_forms_output()
163+
initialized = True
164+
110165

111166
def on_watch(path, flags):
112167
if JSON_FILE.match(path):

0 commit comments

Comments
 (0)