Skip to content

Commit c7db88b

Browse files
AndreasArvidssonpre-commit-ci-lite[bot]pokey
authored
Cursorless everywhere in Talon (#2503)
## Oddities and todos - [ ] Talon requires a javascript file to have a Talon(or another package?) import before any other code. Right now I do that in my [`esbuild.sh`](https://github.com/cursorless-dev/cursorless/blob/100f7706553fd24ce3ccbec2d1ec30c20e7f0a3f/packages/cursorless-talon-js/esbuild.sh#L20-L22) script. This does not support esbuild watch. - The solution here is for Talon to support mjs file endings - [x] Restore num selections check for windows: [`cursorless_everywhere_talon_win.py`](https://github.com/cursorless-dev/cursorless/blob/824735f58e58ea3ca5bfb2448df801a4e7f28b1c/cursorless-everywhere-talon/cursorless_everywhere_talon_win.py#L49-L50) once Talon converts from js to py correctly - [x] Is there any point in outputting source maps to Talon? - [x] If easy, let's run in CI on all platforms ## Information * https://bellard.org/quickjs/ ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [/] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [/] I have not broken the cheatsheet --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Pokey Rule <[email protected]>
1 parent b22c621 commit c7db88b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1936
-4
lines changed

.github/workflows/test.yml

+8
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ jobs:
4545
if: runner.os == 'Linux'
4646
- run: pnpm --color test
4747
if: runner.os != 'Linux'
48+
- run: xvfb-run -a pnpm -F @cursorless/test-harness test:talonJs
49+
if: runner.os == 'Linux' && matrix.vscode_version == 'stable'
50+
- run: pnpm -F @cursorless/test-harness test:talonJs
51+
if: runner.os != 'Linux' && matrix.vscode_version == 'stable'
52+
- run: xvfb-run -a pnpm -F @cursorless/cursorless-everywhere-talon-e2e test:quickjs
53+
if: runner.os == 'Linux' && matrix.vscode_version == 'stable'
54+
- run: pnpm -F @cursorless/cursorless-everywhere-talon-e2e test:quickjs
55+
if: runner.os != 'Linux' && matrix.vscode_version == 'stable'
4856
- run: bash -x scripts/install-neovim-dependencies.sh
4957
- uses: rhysd/action-setup-vim@v1
5058
id: vim

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
out
22
dist
3+
testOut
34
node_modules
45
.vscode-test/
56
*.vsix
67
/package-lock.json
78
*.DS_Store
9+
meta.json
810
.luacheckcache
911

1012
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

.vscode/launch.json

+37-2
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130

131131
// Talon launch configs
132132
{
133-
"name": "Talon: Test",
133+
"name": "Talon: Test grammar",
134134
"type": "node",
135135
"request": "launch",
136136
"program": "${workspaceFolder}/packages/test-harness/dist/runTalonTests.cjs",
@@ -146,7 +146,7 @@
146146
]
147147
},
148148
{
149-
"name": "Talon: Test (subset)",
149+
"name": "Talon: Test grammar (subset)",
150150
"type": "node",
151151
"request": "launch",
152152
"program": "${workspaceFolder}/packages/test-harness/dist/runTalonTests.cjs",
@@ -163,6 +163,41 @@
163163
]
164164
},
165165

166+
// Talon everywhere/JS launch configs
167+
{
168+
"name": "TalonJS: Test",
169+
"type": "node",
170+
"request": "launch",
171+
"program": "${workspaceFolder}/packages/test-harness/dist/runTalonJsTests.cjs",
172+
"env": {
173+
"CURSORLESS_MODE": "test",
174+
"CURSORLESS_REPO_ROOT": "${workspaceFolder}"
175+
},
176+
"outFiles": ["${workspaceFolder}/**/out/**/*.js"],
177+
"preLaunchTask": "VSCode: Build extension and tests",
178+
"resolveSourceMapLocations": [
179+
"${workspaceFolder}/**",
180+
"!**/node_modules/**"
181+
]
182+
},
183+
{
184+
"name": "TalonJS: Test (subset)",
185+
"type": "node",
186+
"request": "launch",
187+
"program": "${workspaceFolder}/packages/test-harness/dist/runTalonJsTests.cjs",
188+
"env": {
189+
"CURSORLESS_MODE": "test",
190+
"CURSORLESS_RUN_TEST_SUBSET": "true",
191+
"CURSORLESS_REPO_ROOT": "${workspaceFolder}"
192+
},
193+
"outFiles": ["${workspaceFolder}/**/out/**/*.js"],
194+
"preLaunchTask": "VSCode: Build extension and tests",
195+
"resolveSourceMapLocations": [
196+
"${workspaceFolder}/**",
197+
"!**/node_modules/**"
198+
]
199+
},
200+
166201
// Unit tests launch configs
167202
{
168203
"name": "Unit tests: Test",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import TypedDict
2+
3+
from talon import Module
4+
5+
6+
class SelectionOffsets(TypedDict):
7+
anchor: int
8+
active: int
9+
10+
11+
class EditorState(TypedDict):
12+
text: str
13+
selections: list[SelectionOffsets]
14+
15+
16+
class EditorChange(TypedDict):
17+
text: str
18+
rangeOffset: int
19+
rangeLength: int
20+
21+
22+
class EditorEdit(TypedDict):
23+
# The new document content after the edit
24+
text: str
25+
26+
# A list of changes that were made to the document. If you can not handle
27+
# this, you can ignore it and just replace the entire document with the
28+
# value of the `text` field above.
29+
changes: list[EditorChange]
30+
31+
32+
mod = Module()
33+
34+
mod.tag("cursorless_everywhere_talon", desc="Enable cursorless everywhere in Talon")
35+
36+
37+
@mod.action_class
38+
class Actions:
39+
def cursorless_everywhere_get_editor_state() -> EditorState: # pyright: ignore [reportReturnType]
40+
"""Get the focused editor element state"""
41+
42+
def cursorless_everywhere_set_selections(
43+
selections: list[SelectionOffsets], # pyright: ignore [reportGeneralTypeIssues]
44+
):
45+
"""Set focused element selections"""
46+
47+
def cursorless_everywhere_edit_text(
48+
edit: EditorEdit, # pyright: ignore [reportGeneralTypeIssues]
49+
):
50+
"""Edit focused element text"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from talon import Context, app, ui
2+
3+
from .cursorless_everywhere_talon import (
4+
EditorEdit,
5+
EditorState,
6+
SelectionOffsets,
7+
)
8+
9+
if app.platform == "windows":
10+
from talon.windows.ax import TextRange
11+
12+
13+
# https://learn.microsoft.com/en-us/dotnet/api/system.windows.automation.text.textpatternrange?view=windowsdesktop-8.0
14+
15+
ctx = Context()
16+
17+
ctx.matches = r"""
18+
os: windows
19+
"""
20+
21+
22+
@ctx.action_class("user")
23+
class Actions:
24+
def cursorless_everywhere_get_editor_state() -> EditorState:
25+
el = ui.focused_element()
26+
27+
if "Text2" not in el.patterns:
28+
raise ValueError("Focused element is not a text element")
29+
30+
text_pattern = el.text_pattern2
31+
document_range = text_pattern.document_range
32+
caret_range = text_pattern.caret_range
33+
selection_ranges = text_pattern.selection
34+
selections: list[SelectionOffsets] = []
35+
36+
for selection_range in selection_ranges:
37+
anchor, active = get_selection(document_range, selection_range, caret_range)
38+
selections.append(
39+
{
40+
"anchor": anchor,
41+
"active": active,
42+
}
43+
)
44+
45+
return {
46+
"text": document_range.text,
47+
"selections": selections,
48+
}
49+
50+
def cursorless_everywhere_set_selections(
51+
selections: list[SelectionOffsets], # pyright: ignore [reportGeneralTypeIssues]
52+
):
53+
if selections.length != 1: # pyright: ignore [reportAttributeAccessIssue]
54+
raise ValueError("Only single selection supported")
55+
56+
selection = selections[0]
57+
anchor = selection["anchor"]
58+
active = selection["active"]
59+
60+
el = ui.focused_element()
61+
62+
if "Text2" not in el.patterns:
63+
raise ValueError("Focused element is not a text element")
64+
65+
text_pattern = el.text_pattern2
66+
document_range = text_pattern.document_range
67+
68+
set_selection(document_range, anchor, active)
69+
70+
def cursorless_everywhere_edit_text(
71+
edit: EditorEdit, # pyright: ignore [reportGeneralTypeIssues]
72+
):
73+
text = edit["text"]
74+
75+
el = ui.focused_element()
76+
77+
if "Value" not in el.patterns:
78+
raise ValueError("Focused element is not a text element")
79+
80+
el.value_pattern.value = text
81+
82+
83+
def set_selection(document_range: TextRange, anchor: int, active: int):
84+
# This happens in slack, for example. The document range starts with a
85+
# newline and selecting first character we'll make the selection go outside
86+
# of the edit box.
87+
if document_range.text.startswith("\n") and anchor == 0 and active == 0:
88+
anchor = 1
89+
active = 1
90+
91+
start = min(anchor, active)
92+
end = max(anchor, active)
93+
range = document_range.clone()
94+
range.move_endpoint_by_range("End", "Start", target=document_range)
95+
range.move_endpoint_by_unit("End", "Character", end)
96+
range.move_endpoint_by_unit("Start", "Character", start)
97+
range.select()
98+
99+
100+
def get_selection(
101+
document_range: TextRange, selection_range: TextRange, caret_range: TextRange
102+
) -> tuple[int, int]:
103+
# Make copy of the document range to avoid modifying the original
104+
range_before_selection = document_range.clone()
105+
# Move the end of the copy to the start of the selection
106+
# range_before_selection.end = selection_range.start
107+
range_before_selection.move_endpoint_by_range(
108+
"End",
109+
"Start",
110+
target=selection_range,
111+
)
112+
# The selection start offset is the length of the text before the selection
113+
start = len(range_before_selection.text)
114+
115+
range_after_selection = document_range.clone()
116+
range_after_selection.move_endpoint_by_range(
117+
"Start",
118+
"End",
119+
target=selection_range,
120+
)
121+
end = len(document_range.text) - len(range_after_selection.text)
122+
123+
# The selection is reversed if the caret is at the start of the selection
124+
is_reversed = (
125+
caret_range.compare_endpoints("Start", "Start", target=selection_range) == 0
126+
)
127+
128+
# Return as (anchor, active)
129+
return (end, start) if is_reversed else (start, end)

packages/common/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export * from "./util/object";
103103
export * from "./util/omitByDeep";
104104
export * from "./util/range";
105105
export * from "./util/regex";
106+
export * from "./util/selectionsEqual";
106107
export * from "./util/serializedMarksToTokenHats";
107108
export * from "./util/splitKey";
108109
export * from "./util/textFormatters";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { Selection } from "../types/Selection";
2+
3+
export function selectionsEqual(a: Selection[], b: Selection[]): boolean {
4+
return (
5+
a.length === b.length && a.every((selection, i) => selection.isEqual(b[i]))
6+
);
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@cursorless/cursorless-everywhere-talon-core",
3+
"version": "1.0.0",
4+
"description": "cursorless in talon js core packagee",
5+
"main": "./out/index.js",
6+
"license": "MIT",
7+
"type": "module",
8+
"scripts": {
9+
"compile:tsc": "tsc --build",
10+
"compile:esbuild": "esbuild ./src/index.ts --sourcemap --format=esm --bundle --packages=external --outfile=./out/index.js",
11+
"compile": "pnpm compile:tsc && pnpm compile:esbuild",
12+
"watch:tsc": "pnpm compile:tsc --watch",
13+
"watch:esbuild": "pnpm compile:esbuild --watch",
14+
"watch": "pnpm run --filter @cursorless/cursorless-everywhere-talon-core --parallel '/^watch:.*/'",
15+
"clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build"
16+
},
17+
"dependencies": {
18+
"@cursorless/common": "workspace:*",
19+
"@cursorless/cursorless-engine": "workspace:*",
20+
"lodash-es": "^4.17.21",
21+
"vscode-uri": "^3.0.8"
22+
},
23+
"devDependencies": {
24+
"@types/lodash-es": "4.17.0"
25+
},
26+
"types": "./out/index.d.ts",
27+
"exports": {
28+
".": {
29+
"cursorless:bundler": "./src/index.ts",
30+
"default": "./out/index.js"
31+
}
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Cursorless everywhere Talon
2+
3+
## Activate by enabling tag
4+
5+
Add a file like the following anywhere in your Talon user files. In the below example we are enabling Cursorless everywhere if we are not in vscode. Of course update this to fit your Cursorless IDE (vscode, neovim) of choice. Or globally enabled if this is your only Cursorless implementation.
6+
7+
```talon
8+
not app: vscode
9+
-
10+
11+
tag(): user.cursorless_everywhere_talon
12+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type {
2+
Command,
3+
CommandResponse,
4+
FakeCommandServerApi,
5+
HatTokenMap,
6+
IDE,
7+
NormalizedIDE,
8+
StoredTargetKey,
9+
TargetPlainObject,
10+
TextEditor,
11+
} from "@cursorless/common";
12+
import {
13+
plainObjectToTarget,
14+
type CommandApi,
15+
type StoredTargetMap,
16+
} from "@cursorless/cursorless-engine";
17+
import type { TalonJsIDE } from "./ide/TalonJsIDE";
18+
import type { TalonJsTestHelpers } from "./types/types";
19+
20+
interface Args {
21+
talonJsIDE: TalonJsIDE;
22+
normalizedIde: NormalizedIDE;
23+
injectIde: (ide: IDE) => void;
24+
commandApi: CommandApi;
25+
hatTokenMap: HatTokenMap;
26+
commandServerApi: FakeCommandServerApi;
27+
storedTargets: StoredTargetMap;
28+
}
29+
30+
export function constructTestHelpers({
31+
talonJsIDE,
32+
normalizedIde,
33+
injectIde,
34+
commandApi,
35+
hatTokenMap,
36+
commandServerApi,
37+
storedTargets,
38+
}: Args): TalonJsTestHelpers {
39+
return {
40+
talonJsIDE,
41+
ide: normalizedIde,
42+
commandServerApi,
43+
hatTokenMap,
44+
storedTargets,
45+
injectIde,
46+
47+
runCommand(command: Command): Promise<CommandResponse | unknown> {
48+
return commandApi.runCommand(command);
49+
},
50+
51+
setStoredTarget(
52+
editor: TextEditor,
53+
key: StoredTargetKey,
54+
targets: TargetPlainObject[] | undefined,
55+
): void {
56+
storedTargets.set(
57+
key,
58+
targets?.map((target) => plainObjectToTarget(editor, target)),
59+
);
60+
},
61+
};
62+
}

0 commit comments

Comments
 (0)