Skip to content

Commit 3bfd6aa

Browse files
committed
Write spokenForms.json from Talon
1 parent 6c6f3a6 commit 3bfd6aa

File tree

4 files changed

+207
-51
lines changed

4 files changed

+207
-51
lines changed

cursorless-talon/src/csv_overrides.py

Lines changed: 91 additions & 36 deletions
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,50 +26,74 @@
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
extra_allowed_values: Optional[list[str]] = None,
3956
allow_unknown_values: bool = False,
4057
default_list_name: Optional[str] = None,
4158
headers: list[str] = [SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER],
42-
ctx: Context = Context(),
4359
no_update_file: bool = False,
44-
pluralize_lists: Optional[list[str]] = None,
60+
pluralize_lists: list[str] = [],
4561
):
4662
"""
4763
Initialize a cursorless settings csv, creating it if necessary, and watch
4864
for changes to the csv. Talon lists will be generated based on the keys of
4965
`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
66+
list created called `user.cursorless_foo` that will contain entries from the
67+
original dict at the key `foo`, updated according to customization in the
68+
csv at
5369
54-
actions.path.talon_user() / "cursorless-settings" / filename
70+
```
71+
actions.path.talon_user() / "cursorless-settings" / filename
72+
```
5573
5674
Note that the settings directory location can be customized using the
5775
`user.cursorless_settings_directory` setting.
5876
5977
Args:
6078
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
79+
`cursorles-settings` dir
80+
default_values (ListToSpokenForms): The default values for the lists to
81+
be customized in the given csv
82+
handle_new_values (Optional[Callable[[list[SpokenFormEntry]], None]]): A
83+
callback to be called when the lists are updated
84+
extra_ignored_values (Optional[list[str]]): Don't throw an exception if
85+
any of these appear as values; just ignore them and don't add them
86+
to any list
87+
allow_unknown_values (bool): If unknown values appear, just put them in
88+
the list
89+
default_list_name (Optional[str]): If unknown values are
90+
allowed, put any unknown values in this list
91+
headers (list[str]): The headers to use for the csv
92+
no_update_file (bool): Set this to `True` to indicate that we should not
93+
update the csv. This is used generally in case there was an issue
94+
coming up with the default set of values so we don't want to persist
95+
those to disk
96+
pluralize_lists (list[str]): Create plural version of given lists
7297
"""
7398
# Don't allow both `extra_allowed_values` and `allow_unknown_values`
7499
assert not (extra_allowed_values and allow_unknown_values)
@@ -112,7 +137,7 @@ def on_watch(path, flags):
112137
allow_unknown_values=allow_unknown_values,
113138
default_list_name=default_list_name,
114139
pluralize_lists=pluralize_lists,
115-
ctx=ctx,
140+
handle_new_values=handle_new_values,
116141
)
117142

118143
fs.watch(str(file_path.parent), on_watch)
@@ -135,7 +160,7 @@ def on_watch(path, flags):
135160
allow_unknown_values=allow_unknown_values,
136161
default_list_name=default_list_name,
137162
pluralize_lists=pluralize_lists,
138-
ctx=ctx,
163+
handle_new_values=handle_new_values,
139164
)
140165
else:
141166
if not no_update_file:
@@ -148,7 +173,7 @@ def on_watch(path, flags):
148173
allow_unknown_values=allow_unknown_values,
149174
default_list_name=default_list_name,
150175
pluralize_lists=pluralize_lists,
151-
ctx=ctx,
176+
handle_new_values=handle_new_values,
152177
)
153178

154179
def unsubscribe():
@@ -184,23 +209,23 @@ def create_default_vocabulary_dicts(
184209
if active_key:
185210
updated_dict[active_key] = value2
186211
default_values_updated[key] = updated_dict
187-
assign_lists_to_context(default_ctx, default_values_updated, pluralize_lists)
212+
assign_lists_to_context(normalized_ctx, default_values_updated, pluralize_lists)
188213

189214

190215
def update_dicts(
191-
default_values: dict[str, dict],
192-
current_values: dict,
216+
default_values: ListToSpokenForms,
217+
current_values: dict[str, str],
193218
extra_ignored_values: list[str],
194219
extra_allowed_values: list[str],
195220
allow_unknown_values: bool,
196221
default_list_name: Optional[str],
197222
pluralize_lists: list[str],
198-
ctx: Context,
223+
handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]],
199224
):
200225
# 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():
226+
results_map: dict[str, ResultsListEntry] = {}
227+
for list_name, obj in default_values.items():
228+
for key, value in obj.items():
204229
results_map[value] = {"key": key, "value": value, "list": list_name}
205230

206231
# Update result with current values
@@ -211,6 +236,7 @@ def update_dicts(
211236
if value in extra_ignored_values:
212237
pass
213238
elif allow_unknown_values or value in extra_allowed_values:
239+
assert default_list_name is not None
214240
results_map[value] = {
215241
"key": key,
216242
"value": value,
@@ -221,9 +247,35 @@ def update_dicts(
221247

222248
# Convert result map back to result list
223249
results = {res["list"]: {} for res in results_map.values()}
224-
for obj in results_map.values():
250+
values: list[SpokenFormEntry] = []
251+
for list_name, id, spoken_forms in generate_spoken_forms(
252+
list(results_map.values())
253+
):
254+
for spoken_form in spoken_forms:
255+
results[list_name][spoken_form] = id
256+
values.append(
257+
SpokenFormEntry(list_name=list_name, id=id, spoken_forms=spoken_forms)
258+
)
259+
260+
# Assign result to talon context list
261+
assign_lists_to_context(ctx, results, pluralize_lists)
262+
263+
if handle_new_values is not None:
264+
handle_new_values(values)
265+
266+
267+
class ResultsListEntry(TypedDict):
268+
key: str
269+
value: str
270+
list: str
271+
272+
273+
def generate_spoken_forms(results_list: list[ResultsListEntry]):
274+
for obj in results_list:
225275
value = obj["value"]
226276
key = obj["key"]
277+
278+
spoken = []
227279
if not is_removed(key):
228280
for k in key.split("|"):
229281
if value == "pasteFromClipboard" and k.endswith(" to"):
@@ -234,10 +286,13 @@ def update_dicts(
234286
# cursorless before this change would have "paste to" as
235287
# their spoken form and so would need to say "paste to to".
236288
k = k[:-3]
237-
results[obj["list"]][k.strip()] = value
289+
spoken.append(k.strip())
238290

239-
# Assign result to talon context list
240-
assign_lists_to_context(ctx, results, pluralize_lists)
291+
yield (
292+
obj["list"],
293+
value,
294+
spoken,
295+
)
241296

242297

243298
def assign_lists_to_context(
@@ -410,7 +465,7 @@ def get_full_path(filename: str):
410465
return (settings_directory / filename).resolve()
411466

412467

413-
def get_super_values(values: dict[str, dict[str, str]]):
468+
def get_super_values(values: ListToSpokenForms):
414469
result: dict[str, str] = {}
415470
for value_dict in values.values():
416471
result.update(value_dict)

cursorless-talon/src/marks/decorated_mark.py

Lines changed: 1 addition & 1 deletion
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

Lines changed: 69 additions & 14 deletions
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"),
@@ -109,6 +161,9 @@ def update():
109161
),
110162
]
111163

164+
update_spoken_forms_output()
165+
initialized = True
166+
112167

113168
def on_watch(path, flags):
114169
if JSON_FILE.match(path):

0 commit comments

Comments
 (0)