Skip to content

Commit a04fff4

Browse files
committed
Support custom spoken forms for graphemes
1 parent 95ae15f commit a04fff4

File tree

9 files changed

+156
-27
lines changed

9 files changed

+156
-27
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import re
2+
import typing
3+
from collections import defaultdict
4+
from typing import Iterator, Mapping
5+
from uu import Error
6+
7+
from talon import app, registry
8+
9+
from .spoken_forms_output import SpokenFormOutputEntry
10+
11+
grapheme_capture_name = "user.any_alphanumeric_key"
12+
13+
14+
def get_grapheme_spoken_form_entries() -> list[SpokenFormOutputEntry]:
15+
return [
16+
{
17+
"type": "grapheme",
18+
"id": id,
19+
"spokenForms": spoken_forms,
20+
}
21+
for symbol_list in generate_lists_from_capture(grapheme_capture_name)
22+
for id, spoken_forms in get_id_to_spoken_form_map(symbol_list).items()
23+
]
24+
25+
26+
def generate_lists_from_capture(capture_name) -> Iterator[str]:
27+
"""
28+
Given the name of a capture, yield the names of each list that the capture
29+
expands to. Note that we are somewhat strict about the format of the
30+
capture rule, and will not handle all possible cases.
31+
"""
32+
if capture_name.startswith("self."):
33+
capture_name = "user." + capture_name[5:]
34+
try:
35+
rule = registry.captures[capture_name][0].rule.rule
36+
except Error:
37+
app.notify("Error constructing spoken forms for graphemes")
38+
print(f"Error getting rule for capture {capture_name}")
39+
return
40+
rule = rule.strip()
41+
if rule.startswith("(") and rule.endswith(")"):
42+
rule = rule[1:-1]
43+
rule = rule.strip()
44+
components = re.split(r"\s*\|\s*", rule)
45+
for component in components:
46+
if component.startswith("<") and component.endswith(">"):
47+
yield from generate_lists_from_capture(component[1:-1])
48+
elif component.startswith("{") and component.endswith("}"):
49+
component = component[1:-1]
50+
if component.startswith("self."):
51+
component = "user." + component[5:]
52+
yield component
53+
else:
54+
app.notify("Error constructing spoken forms for graphemes")
55+
print(
56+
f"Unexpected component {component} while processing rule {rule} for capture {capture_name}"
57+
)
58+
59+
60+
def get_id_to_spoken_form_map(list_name: str) -> Mapping[str, list[str]]:
61+
"""
62+
Given the name of a Talon list, return a mapping from the values in that
63+
list to the list of spoken forms that map to the given value.
64+
"""
65+
try:
66+
raw_list = typing.cast(dict[str, str], registry.lists[list_name][0]).copy()
67+
except Error:
68+
app.notify(f"Error getting list {list_name}")
69+
return {}
70+
71+
inverted_list: defaultdict[str, list[str]] = defaultdict(list)
72+
for key, value in raw_list.items():
73+
inverted_list[value].append(key)
74+
75+
return inverted_list

cursorless-talon/src/spoken_forms.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pathlib import Path
33
from typing import Callable, Concatenate, ParamSpec, TypeVar
44

5-
from talon import app, fs
5+
from talon import app, cron, fs, registry
66

77
from .actions.actions import ACTION_LIST_NAMES
88
from .csv_overrides import (
@@ -11,6 +11,10 @@
1111
SpokenFormEntry,
1212
init_csv_and_watch_changes,
1313
)
14+
from .get_grapheme_spoken_form_entries import (
15+
get_grapheme_spoken_form_entries,
16+
grapheme_capture_name,
17+
)
1418
from .marks.decorated_mark import init_hats
1519
from .spoken_forms_output import SpokenFormsOutput
1620

@@ -99,14 +103,17 @@ def update():
99103
def update_spoken_forms_output():
100104
spoken_forms_output.write(
101105
[
102-
{
103-
"type": LIST_TO_TYPE_MAP[entry.list_name],
104-
"id": entry.id,
105-
"spokenForms": entry.spoken_forms,
106-
}
107-
for spoken_form_list in custom_spoken_forms.values()
108-
for entry in spoken_form_list
109-
if entry.list_name in LIST_TO_TYPE_MAP
106+
*[
107+
{
108+
"type": LIST_TO_TYPE_MAP[entry.list_name],
109+
"id": entry.id,
110+
"spokenForms": entry.spoken_forms,
111+
}
112+
for spoken_form_list in custom_spoken_forms.values()
113+
for entry in spoken_form_list
114+
if entry.list_name in LIST_TO_TYPE_MAP
115+
],
116+
*get_grapheme_spoken_form_entries(),
110117
]
111118
)
112119

@@ -184,9 +191,30 @@ def on_watch(path, flags):
184191
update()
185192

186193

194+
update_captures_cron = None
195+
196+
197+
def update_captures_debounced(updated_captures: set[str]):
198+
if grapheme_capture_name not in updated_captures:
199+
return
200+
201+
global update_captures_cron
202+
cron.cancel(update_captures_cron)
203+
update_captures_cron = cron.after("100ms", update_captures)
204+
205+
206+
def update_captures():
207+
global update_captures_cron
208+
update_captures_cron = None
209+
210+
update()
211+
212+
187213
def on_ready():
188214
update()
189215

216+
registry.register("update_captures", update_captures_debounced)
217+
190218
fs.watch(str(JSON_FILE.parent), on_watch)
191219

192220

cursorless-talon/src/spoken_forms_output.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
STATE_JSON_VERSION_NUMBER = 0
99

1010

11-
class SpokenFormEntry(TypedDict):
11+
class SpokenFormOutputEntry(TypedDict):
1212
type: str
1313
id: str
1414
spokenForms: list[str]
@@ -29,7 +29,7 @@ def init(self):
2929
print(error_message)
3030
app.notify(error_message)
3131

32-
def write(self, spoken_forms: list[SpokenFormEntry]):
32+
def write(self, spoken_forms: list[SpokenFormOutputEntry]):
3333
with open(SPOKEN_FORMS_OUTPUT_PATH, "w", encoding="UTF-8") as out:
3434
try:
3535
out.write(

packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ suite("CustomSpokenFormGeneratorImpl", async function () {
1919
id: "setSelection",
2020
spokenForms: ["bar"],
2121
},
22+
{
23+
type: "grapheme",
24+
id: "a",
25+
spokenForms: ["alabaster"],
26+
},
2227
];
2328
},
2429
onDidChange: () => ({ dispose() {} }),
@@ -33,7 +38,7 @@ suite("CustomSpokenFormGeneratorImpl", async function () {
3338
}),
3439
{
3540
type: "success",
36-
spokenForms: ["foo air"],
41+
spokenForms: ["foo alabaster"],
3742
},
3843
);
3944
assert.deepStrictEqual(

packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
numberToSpokenForm,
2121
ordinalToSpokenForm,
2222
} from "./defaultSpokenForms/numbers";
23-
import { characterToSpokenForm } from "./defaultSpokenForms/characters";
2423
import { SpokenFormComponentMap } from "./getSpokenFormComponentMap";
2524
import { SpokenFormComponent } from "./SpokenFormComponent";
2625

@@ -234,7 +233,11 @@ export class PrimitiveTargetSpokenFormGenerator {
234233
case "glyph":
235234
return [
236235
this.spokenFormMap.complexScopeTypeType.glyph,
237-
characterToSpokenForm(scopeType.character),
236+
getSpokenFormStrict(
237+
this.spokenFormMap.grapheme,
238+
"grapheme",
239+
scopeType.character,
240+
),
238241
];
239242
case "surroundingPair": {
240243
const pair = this.spokenFormMap.pairedDelimiter[scopeType.delimiter];
@@ -274,14 +277,20 @@ export class PrimitiveTargetSpokenFormGenerator {
274277
switch (mark.type) {
275278
case "decoratedSymbol": {
276279
const [color, shape] = mark.symbolColor.split("-");
277-
const components: string[] = [];
280+
const components: SpokenFormComponent[] = [];
278281
if (color !== "default") {
279282
components.push(hatColorToSpokenForm(color));
280283
}
281284
if (shape != null) {
282285
components.push(hatShapeToSpokenForm(shape));
283286
}
284-
components.push(characterToSpokenForm(mark.character));
287+
components.push(
288+
getSpokenFormStrict(
289+
this.spokenFormMap.grapheme,
290+
"grapheme",
291+
mark.character,
292+
),
293+
);
285294
return components;
286295
}
287296

@@ -375,3 +384,17 @@ function pluralize(name: SpokenFormComponent): SpokenFormComponent {
375384
function pluralizeString(name: string): string {
376385
return `${name}s`;
377386
}
387+
388+
function getSpokenFormStrict(
389+
map: Readonly<Record<string, SpokenFormComponent>>,
390+
typeName: string,
391+
key: string,
392+
): SpokenFormComponent {
393+
const spokenForm = map[key];
394+
395+
if (spokenForm == null) {
396+
throw new NoSpokenFormError(`${typeName} '${key}'`);
397+
}
398+
399+
return spokenForm;
400+
}

packages/cursorless-engine/src/scopeProviders/TalonSpokenForms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const SUPPORTED_ENTRY_TYPES = [
2424
"pairedDelimiter",
2525
"action",
2626
"customAction",
27+
"grapheme",
2728
] as const;
2829

2930
type SupportedEntryType = (typeof SUPPORTED_ENTRY_TYPES)[number];

packages/cursorless-engine/src/spokenForms/SpokenFormType.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export interface SpokenFormMapKeyTypes {
4242
* custom actions corresponding to id's of VSCode commands.
4343
*/
4444
customAction: string;
45+
46+
/**
47+
* Individual characters / graphemes, eg `a` or `/`.
48+
*/
49+
grapheme: string;
4550
}
4651

4752
/**

packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DefaultSpokenFormMapDefinition } from "./defaultSpokenFormMap.types";
2+
import { graphemeDefaultSpokenForms } from "./graphemes";
23
import { isDisabledByDefault, isPrivate } from "./spokenFormMapUtil";
34

45
/**
@@ -211,4 +212,5 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = {
211212
// nextHomophone: "phones",
212213
},
213214
customAction: {},
215+
grapheme: graphemeDefaultSpokenForms,
214216
};
Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22

3-
import { NoSpokenFormError } from "../NoSpokenFormError";
4-
53
// https://github.com/talonhub/community/blob/9acb6c9659bb0c9b794a7b7126d025603b4ed726/core/keys/keys.py
64

75
const alphabet = Object.fromEntries(
@@ -59,16 +57,8 @@ const symbols = {
5957
"\uFFFD": "special",
6058
};
6159

62-
const characters: Record<string, string> = {
60+
export const graphemeDefaultSpokenForms: Record<string, string> = {
6361
...alphabet,
6462
...digits,
6563
...symbols,
6664
};
67-
68-
export function characterToSpokenForm(char: string): string {
69-
const result = characters[char];
70-
if (result == null) {
71-
throw new NoSpokenFormError(`Unknown character '${char}'`);
72-
}
73-
return result;
74-
}

0 commit comments

Comments
 (0)