bolotype 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bolotype/__init__.py +1 -0
- bolotype/__main__.py +3 -0
- bolotype/accessibility/__init__.py +0 -0
- bolotype/accessibility/linux_atspi.py +597 -0
- bolotype/asr/__init__.py +0 -0
- bolotype/asr/base.py +18 -0
- bolotype/asr/moonshine_asr.py +54 -0
- bolotype/asr/nemotron_asr.py +328 -0
- bolotype/cli.py +128 -0
- bolotype/config.py +204 -0
- bolotype/control.py +47 -0
- bolotype/daemon.py +366 -0
- bolotype/editor.py +121 -0
- bolotype/input/__init__.py +0 -0
- bolotype/input/linux.py +139 -0
- bolotype/installer.py +137 -0
- bolotype/llm.py +140 -0
- bolotype/resources/default_settings.json +26 -0
- bolotype/resources/default_system_prompt.txt +18 -0
- bolotype-0.1.0.dist-info/METADATA +351 -0
- bolotype-0.1.0.dist-info/RECORD +25 -0
- bolotype-0.1.0.dist-info/WHEEL +5 -0
- bolotype-0.1.0.dist-info/entry_points.txt +2 -0
- bolotype-0.1.0.dist-info/licenses/LICENSE +21 -0
- bolotype-0.1.0.dist-info/top_level.txt +1 -0
bolotype/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bolotype/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AccessibilityError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Low-level AT-SPI helpers
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
def has_state(obj, state) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
return obj.getState().contains(state)
|
|
22
|
+
except Exception:
|
|
23
|
+
try:
|
|
24
|
+
return obj.get_state_set().contains(state)
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def query_text(obj):
|
|
30
|
+
try:
|
|
31
|
+
return obj.queryText()
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def query_editable_text(obj):
|
|
37
|
+
try:
|
|
38
|
+
return obj.queryEditableText()
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def iter_children(obj):
|
|
44
|
+
try:
|
|
45
|
+
count = obj.childCount
|
|
46
|
+
except Exception:
|
|
47
|
+
try:
|
|
48
|
+
count = obj.get_child_count()
|
|
49
|
+
except Exception:
|
|
50
|
+
count = 0
|
|
51
|
+
|
|
52
|
+
for index in range(count):
|
|
53
|
+
try:
|
|
54
|
+
yield obj[index]
|
|
55
|
+
except Exception:
|
|
56
|
+
try:
|
|
57
|
+
yield obj.get_child_at_index(index)
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_fffc_only(text: str) -> bool:
|
|
63
|
+
return bool(text) and not text.replace("", "").strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_text_object(root, max_depth: int = 10):
|
|
67
|
+
"""
|
|
68
|
+
Find the best text-capable object at or below root.
|
|
69
|
+
|
|
70
|
+
Preference order:
|
|
71
|
+
1. Focused + editable + Text
|
|
72
|
+
2. Focused + Text
|
|
73
|
+
3. Editable + Text
|
|
74
|
+
4. Any Text object
|
|
75
|
+
"""
|
|
76
|
+
import pyatspi # type: ignore
|
|
77
|
+
|
|
78
|
+
candidates: list[tuple[int, int, object]] = []
|
|
79
|
+
visited: set[int] = set()
|
|
80
|
+
|
|
81
|
+
def visit(obj, depth: int) -> None:
|
|
82
|
+
if obj is None or depth > max_depth:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
object_id = id(obj)
|
|
86
|
+
if object_id in visited:
|
|
87
|
+
return
|
|
88
|
+
visited.add(object_id)
|
|
89
|
+
|
|
90
|
+
text_iface = query_text(obj)
|
|
91
|
+
editable_iface = query_editable_text(obj)
|
|
92
|
+
|
|
93
|
+
if text_iface is not None:
|
|
94
|
+
score = 0
|
|
95
|
+
|
|
96
|
+
if has_state(obj, pyatspi.STATE_FOCUSED):
|
|
97
|
+
score += 100
|
|
98
|
+
|
|
99
|
+
if has_state(obj, pyatspi.STATE_EDITABLE):
|
|
100
|
+
score += 40
|
|
101
|
+
|
|
102
|
+
if editable_iface is not None:
|
|
103
|
+
score += 40
|
|
104
|
+
|
|
105
|
+
if has_state(obj, pyatspi.STATE_ACTIVE):
|
|
106
|
+
score += 10
|
|
107
|
+
|
|
108
|
+
# Prefer deeper objects — the actual text field is commonly nested
|
|
109
|
+
# under frames, panels, documents, and browser containers.
|
|
110
|
+
candidates.append((score, depth, obj))
|
|
111
|
+
|
|
112
|
+
for child in iter_children(obj):
|
|
113
|
+
visit(child, depth + 1)
|
|
114
|
+
|
|
115
|
+
visit(root, 0)
|
|
116
|
+
|
|
117
|
+
if not candidates:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
candidates.sort(key=lambda c: (c[0], c[1]), reverse=True)
|
|
121
|
+
return candidates[0][2]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def find_real_text_node(root, max_depth: int = 12):
|
|
125
|
+
"""
|
|
126
|
+
Like resolve_text_object but explicitly skips nodes whose entire text is
|
|
127
|
+
U+FFFC (AT-SPI object-replacement placeholders used by Chrome/Electron).
|
|
128
|
+
Returns (score, node, text) or None.
|
|
129
|
+
"""
|
|
130
|
+
import pyatspi # type: ignore
|
|
131
|
+
|
|
132
|
+
candidates = []
|
|
133
|
+
|
|
134
|
+
def visit(node, depth: int) -> None:
|
|
135
|
+
if node is None or depth > max_depth:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
text_iface = query_text(node)
|
|
139
|
+
if text_iface is not None:
|
|
140
|
+
try:
|
|
141
|
+
count = text_iface.characterCount
|
|
142
|
+
value = text_iface.getText(0, count)
|
|
143
|
+
except Exception:
|
|
144
|
+
value = ""
|
|
145
|
+
|
|
146
|
+
if value and not _is_fffc_only(value):
|
|
147
|
+
score = depth # prefer deeper nodes
|
|
148
|
+
if has_state(node, pyatspi.STATE_FOCUSED):
|
|
149
|
+
score += 100
|
|
150
|
+
if has_state(node, pyatspi.STATE_EDITABLE):
|
|
151
|
+
score += 50
|
|
152
|
+
candidates.append((score, node, value))
|
|
153
|
+
|
|
154
|
+
for child in iter_children(node):
|
|
155
|
+
visit(child, depth + 1)
|
|
156
|
+
|
|
157
|
+
visit(root, 0)
|
|
158
|
+
|
|
159
|
+
if not candidates:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
163
|
+
return candidates[0]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def dump_text_subtree(node, depth: int = 0, max_depth: int = 8) -> None:
|
|
167
|
+
import pyatspi # type: ignore
|
|
168
|
+
|
|
169
|
+
if depth > max_depth:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
text = None
|
|
173
|
+
iface = query_text(node)
|
|
174
|
+
if iface is not None:
|
|
175
|
+
try:
|
|
176
|
+
text = iface.getText(0, iface.characterCount)
|
|
177
|
+
except Exception:
|
|
178
|
+
text = "<read failed>"
|
|
179
|
+
|
|
180
|
+
print(
|
|
181
|
+
" " * depth,
|
|
182
|
+
f"role={safe_role_name(node)!r}",
|
|
183
|
+
f"name={safe_name(node)!r}",
|
|
184
|
+
f"text={text!r}",
|
|
185
|
+
f"focused={has_state(node, pyatspi.STATE_FOCUSED)}",
|
|
186
|
+
f"editable={query_editable_text(node) is not None}",
|
|
187
|
+
flush=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for child in iter_children(node):
|
|
191
|
+
dump_text_subtree(child, depth + 1, max_depth)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def safe_role_name(obj) -> str:
|
|
195
|
+
try:
|
|
196
|
+
return obj.getRoleName()
|
|
197
|
+
except Exception:
|
|
198
|
+
try:
|
|
199
|
+
return obj.get_role_name()
|
|
200
|
+
except Exception:
|
|
201
|
+
return "<unknown>"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def safe_name(obj) -> str:
|
|
205
|
+
try:
|
|
206
|
+
return obj.name or ""
|
|
207
|
+
except Exception:
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def set_clipboard_text(text: str) -> None:
|
|
212
|
+
session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
|
213
|
+
if session_type == "wayland":
|
|
214
|
+
subprocess.run(["wl-copy"], input=text.encode(), check=True)
|
|
215
|
+
else:
|
|
216
|
+
subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode(), check=True)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# FocusTracker
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
class FocusTracker:
|
|
224
|
+
"""
|
|
225
|
+
Tracks the most recently focused accessible object via AT-SPI events.
|
|
226
|
+
Much faster than walking the full desktop tree on every operation.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self._lock = threading.RLock()
|
|
231
|
+
self._focused = None
|
|
232
|
+
self._last_updated = 0.0
|
|
233
|
+
self._started = False
|
|
234
|
+
self._registry_thread: Optional[threading.Thread] = None
|
|
235
|
+
|
|
236
|
+
def start(self) -> None:
|
|
237
|
+
if self._started:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
import pyatspi # type: ignore
|
|
241
|
+
|
|
242
|
+
pyatspi.Registry.registerEventListener(
|
|
243
|
+
self._on_focus_event,
|
|
244
|
+
"object:state-changed:focused",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self._registry_thread = threading.Thread(
|
|
248
|
+
target=self._run_registry_loop,
|
|
249
|
+
name="atspi-event-loop",
|
|
250
|
+
daemon=True,
|
|
251
|
+
)
|
|
252
|
+
self._registry_thread.start()
|
|
253
|
+
|
|
254
|
+
self._started = True
|
|
255
|
+
|
|
256
|
+
def stop(self) -> None:
|
|
257
|
+
if not self._started:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
import pyatspi # type: ignore
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
pyatspi.Registry.deregisterEventListener(
|
|
264
|
+
self._on_focus_event,
|
|
265
|
+
"object:state-changed:focused",
|
|
266
|
+
)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
pyatspi.Registry.stop()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
self._started = False
|
|
276
|
+
|
|
277
|
+
def _run_registry_loop(self) -> None:
|
|
278
|
+
try:
|
|
279
|
+
import pyatspi # type: ignore
|
|
280
|
+
pyatspi.Registry.start()
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
print(f"[AT-SPI event loop stopped] {exc}", flush=True)
|
|
283
|
+
|
|
284
|
+
def _on_focus_event(self, event) -> None:
|
|
285
|
+
try:
|
|
286
|
+
gained_focus = bool(event.detail1)
|
|
287
|
+
except Exception:
|
|
288
|
+
gained_focus = True
|
|
289
|
+
|
|
290
|
+
if not gained_focus:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
source = getattr(event, "source", None)
|
|
294
|
+
if source is None:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
print(
|
|
298
|
+
"[AT-SPI focus]",
|
|
299
|
+
f"role={safe_role_name(source)!r}",
|
|
300
|
+
f"name={safe_name(source)!r}",
|
|
301
|
+
f"text={query_text(source) is not None}",
|
|
302
|
+
f"editable={query_editable_text(source) is not None}",
|
|
303
|
+
flush=True,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
resolved = resolve_text_object(source)
|
|
307
|
+
|
|
308
|
+
if resolved is not None:
|
|
309
|
+
print(
|
|
310
|
+
"[AT-SPI resolved]",
|
|
311
|
+
f"role={safe_role_name(resolved)!r}",
|
|
312
|
+
f"name={safe_name(resolved)!r}",
|
|
313
|
+
f"text={query_text(resolved) is not None}",
|
|
314
|
+
f"editable={query_editable_text(resolved) is not None}",
|
|
315
|
+
flush=True,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
with self._lock:
|
|
319
|
+
self._focused = resolved or source
|
|
320
|
+
self._last_updated = time.monotonic()
|
|
321
|
+
|
|
322
|
+
def get_focused_object(self):
|
|
323
|
+
with self._lock:
|
|
324
|
+
obj = self._focused
|
|
325
|
+
|
|
326
|
+
if obj is None:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
return resolve_text_object(obj) or obj
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# Dataclasses
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
@dataclass(frozen=True)
|
|
337
|
+
class FocusedTextContext:
|
|
338
|
+
accessible: object
|
|
339
|
+
text_iface: object
|
|
340
|
+
editable_iface: object # may be None for read-only / Chrome fields
|
|
341
|
+
text: str
|
|
342
|
+
caret: int
|
|
343
|
+
selection_start: int
|
|
344
|
+
selection_end: int
|
|
345
|
+
app_name: str
|
|
346
|
+
role_name: str
|
|
347
|
+
element_name: str
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def identity(self) -> tuple[str, str, str]:
|
|
351
|
+
return (self.app_name, self.role_name, self.element_name)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True)
|
|
355
|
+
class PolishSnapshot:
|
|
356
|
+
identity: tuple[str, str, str]
|
|
357
|
+
start: int
|
|
358
|
+
original: str # text[start:end] before polish
|
|
359
|
+
replacement: str # polished text that was written
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Main surface
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
class LinuxAccessibilityTextSurface:
|
|
367
|
+
"""Read and edit the focused Linux text control through AT-SPI2."""
|
|
368
|
+
|
|
369
|
+
def __init__(self) -> None:
|
|
370
|
+
try:
|
|
371
|
+
import pyatspi # type: ignore
|
|
372
|
+
except ImportError as exc:
|
|
373
|
+
raise AccessibilityError(
|
|
374
|
+
"python3-pyatspi is unavailable. Install it and create the virtual "
|
|
375
|
+
"environment with --system-site-packages."
|
|
376
|
+
) from exc
|
|
377
|
+
self.pyatspi = pyatspi
|
|
378
|
+
self.focus_tracker = FocusTracker()
|
|
379
|
+
self.focus_tracker.start()
|
|
380
|
+
|
|
381
|
+
def close(self) -> None:
|
|
382
|
+
self.focus_tracker.stop()
|
|
383
|
+
|
|
384
|
+
def focused_context(self) -> FocusedTextContext:
|
|
385
|
+
element = self.focus_tracker.get_focused_object()
|
|
386
|
+
if element is None:
|
|
387
|
+
raise AccessibilityError(
|
|
388
|
+
"No focused accessibility object has been observed yet. "
|
|
389
|
+
"Click inside the target text field and try again."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
resolved = resolve_text_object(element)
|
|
393
|
+
if resolved is None:
|
|
394
|
+
raise AccessibilityError(
|
|
395
|
+
"The focused accessibility object does not expose a usable "
|
|
396
|
+
f"Text descendant. role={safe_role_name(element)!r}, "
|
|
397
|
+
f"name={safe_name(element)!r}"
|
|
398
|
+
)
|
|
399
|
+
focused = resolved
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
text_iface = focused.queryText()
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
raise AccessibilityError(
|
|
405
|
+
"The focused element does not expose the AT-SPI Text interface"
|
|
406
|
+
) from exc
|
|
407
|
+
|
|
408
|
+
# EditableText is optional — Chrome/Electron expose Text but not EditableText.
|
|
409
|
+
editable_iface = query_editable_text(focused)
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
character_count = int(text_iface.characterCount)
|
|
413
|
+
text = text_iface.getText(0, character_count)
|
|
414
|
+
caret = int(text_iface.caretOffset)
|
|
415
|
+
print(
|
|
416
|
+
f"[AT-SPI text] characterCount={character_count} "
|
|
417
|
+
f"caret={caret} "
|
|
418
|
+
f"first80={text[:80]!r} "
|
|
419
|
+
f"last80={text[-80:]!r}",
|
|
420
|
+
flush=True,
|
|
421
|
+
)
|
|
422
|
+
except Exception as exc:
|
|
423
|
+
raise AccessibilityError("Could not read focused text or caret") from exc
|
|
424
|
+
|
|
425
|
+
# If the resolved node only returns U+FFFC placeholders (Chrome/Electron
|
|
426
|
+
# contenteditable), search its descendants for a node with real text.
|
|
427
|
+
# Each U+FFFC in the parent maps 1:1 to a child by index, so the
|
|
428
|
+
# section-level caret directly encodes which child paragraph is focused.
|
|
429
|
+
if _is_fffc_only(text):
|
|
430
|
+
section_caret = caret # preserve before overwriting
|
|
431
|
+
print(
|
|
432
|
+
f"[AT-SPI] resolved node returned only U+FFFC "
|
|
433
|
+
f"(role={safe_role_name(focused)!r} section_caret={section_caret}), "
|
|
434
|
+
f"searching descendants...",
|
|
435
|
+
flush=True,
|
|
436
|
+
)
|
|
437
|
+
dump_text_subtree(focused)
|
|
438
|
+
|
|
439
|
+
result = None
|
|
440
|
+
if section_caret >= 0:
|
|
441
|
+
children = list(iter_children(focused))
|
|
442
|
+
print(
|
|
443
|
+
f"[AT-SPI FFFC] section has {len(children)} children, "
|
|
444
|
+
f"trying child[{section_caret}]",
|
|
445
|
+
flush=True,
|
|
446
|
+
)
|
|
447
|
+
if section_caret < len(children):
|
|
448
|
+
result = find_real_text_node(children[section_caret])
|
|
449
|
+
if result is not None:
|
|
450
|
+
print(
|
|
451
|
+
f"[AT-SPI FFFC] child[{section_caret}] resolved to "
|
|
452
|
+
f"role={safe_role_name(result[1])!r}",
|
|
453
|
+
flush=True,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if result is None:
|
|
457
|
+
print("[AT-SPI FFFC] child navigation failed, falling back to global search", flush=True)
|
|
458
|
+
result = find_real_text_node(focused)
|
|
459
|
+
|
|
460
|
+
if result is None:
|
|
461
|
+
raise AccessibilityError(
|
|
462
|
+
"AT-SPI returned only U+FFFC placeholders and no descendant "
|
|
463
|
+
"exposes real text. This field cannot be polished via AT-SPI."
|
|
464
|
+
)
|
|
465
|
+
_, focused, text = result
|
|
466
|
+
text_iface = query_text(focused)
|
|
467
|
+
editable_iface = query_editable_text(focused)
|
|
468
|
+
try:
|
|
469
|
+
caret = int(text_iface.caretOffset)
|
|
470
|
+
if caret < 0:
|
|
471
|
+
caret = len(text)
|
|
472
|
+
except Exception:
|
|
473
|
+
caret = len(text)
|
|
474
|
+
print(
|
|
475
|
+
f"[AT-SPI fallback] found real text in "
|
|
476
|
+
f"role={safe_role_name(focused)!r} "
|
|
477
|
+
f"len={len(text)} "
|
|
478
|
+
f"caret={caret} "
|
|
479
|
+
f"first80={text[:80]!r} "
|
|
480
|
+
f"last80={text[-80:]!r}",
|
|
481
|
+
flush=True,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
selection_start = caret
|
|
485
|
+
selection_end = caret
|
|
486
|
+
try:
|
|
487
|
+
if int(text_iface.getNSelections()) > 0:
|
|
488
|
+
selection_start, selection_end = text_iface.getSelection(0)
|
|
489
|
+
selection_start = int(selection_start)
|
|
490
|
+
selection_end = int(selection_end)
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
app = focused.getApplication()
|
|
496
|
+
app_name = str(getattr(app, "name", "") or "")
|
|
497
|
+
except Exception:
|
|
498
|
+
app_name = ""
|
|
499
|
+
try:
|
|
500
|
+
role_name = str(focused.getRoleName() or "")
|
|
501
|
+
except Exception:
|
|
502
|
+
role_name = ""
|
|
503
|
+
try:
|
|
504
|
+
element_name = str(focused.name or "")
|
|
505
|
+
except Exception:
|
|
506
|
+
element_name = ""
|
|
507
|
+
|
|
508
|
+
return FocusedTextContext(
|
|
509
|
+
accessible=focused,
|
|
510
|
+
text_iface=text_iface,
|
|
511
|
+
editable_iface=editable_iface,
|
|
512
|
+
text=text,
|
|
513
|
+
caret=caret,
|
|
514
|
+
selection_start=selection_start,
|
|
515
|
+
selection_end=selection_end,
|
|
516
|
+
app_name=app_name,
|
|
517
|
+
role_name=role_name,
|
|
518
|
+
element_name=element_name,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def replace_range(
|
|
522
|
+
self,
|
|
523
|
+
context: FocusedTextContext,
|
|
524
|
+
start: int,
|
|
525
|
+
end: int,
|
|
526
|
+
replacement: str,
|
|
527
|
+
keyboard_backend=None,
|
|
528
|
+
) -> None:
|
|
529
|
+
try:
|
|
530
|
+
if context.editable_iface is not None:
|
|
531
|
+
context.editable_iface.deleteText(start, end)
|
|
532
|
+
context.editable_iface.insertText(start, replacement, len(replacement))
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
if keyboard_backend is not None:
|
|
536
|
+
selected = False
|
|
537
|
+
try:
|
|
538
|
+
result = context.text_iface.setSelection(0, start, end)
|
|
539
|
+
print(f"[replace_range] setSelection(0, {start}, {end}) -> {result!r}", flush=True)
|
|
540
|
+
selected = bool(result)
|
|
541
|
+
except Exception as e:
|
|
542
|
+
print(f"[replace_range] setSelection camelCase failed: {e}", flush=True)
|
|
543
|
+
try:
|
|
544
|
+
result = context.text_iface.set_selection(0, start, end)
|
|
545
|
+
print(f"[replace_range] set_selection(0, {start}, {end}) -> {result!r}", flush=True)
|
|
546
|
+
selected = bool(result)
|
|
547
|
+
except Exception as e2:
|
|
548
|
+
print(f"[replace_range] set_selection snake_case failed: {e2}", flush=True)
|
|
549
|
+
|
|
550
|
+
print(f"[replace_range] selected={selected}", flush=True)
|
|
551
|
+
if not selected:
|
|
552
|
+
raise AccessibilityError(
|
|
553
|
+
"The application exposes text but does not allow "
|
|
554
|
+
"AT-SPI selection changes."
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
set_clipboard_text(replacement)
|
|
559
|
+
print(f"[replace_range] clipboard set, sending Ctrl+V", flush=True)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
print(f"[replace_range] set_clipboard_text failed: {e}", flush=True)
|
|
562
|
+
raise
|
|
563
|
+
|
|
564
|
+
keyboard_backend.hotkey("ctrl", "v")
|
|
565
|
+
print(f"[replace_range] Ctrl+V sent", flush=True)
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
raise AccessibilityError(
|
|
569
|
+
"Focused field has no EditableText interface and no keyboard backend was provided."
|
|
570
|
+
)
|
|
571
|
+
except AccessibilityError:
|
|
572
|
+
raise
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
import traceback
|
|
575
|
+
traceback.print_exc()
|
|
576
|
+
raise AccessibilityError("AT-SPI failed to replace the focused range") from exc
|
|
577
|
+
|
|
578
|
+
def replace_all(self, context: FocusedTextContext, replacement: str) -> None:
|
|
579
|
+
self.replace_range(context, 0, len(context.text), replacement)
|
|
580
|
+
try:
|
|
581
|
+
context.text_iface.setCaretOffset(len(replacement))
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
def restore_snapshot(self, snapshot: PolishSnapshot) -> bool:
|
|
586
|
+
current = self.focused_context()
|
|
587
|
+
if current.identity != snapshot.identity:
|
|
588
|
+
return False
|
|
589
|
+
end = snapshot.start + len(snapshot.replacement)
|
|
590
|
+
if current.text[snapshot.start:end] != snapshot.replacement:
|
|
591
|
+
return False
|
|
592
|
+
self.replace_range(current, snapshot.start, end, snapshot.original)
|
|
593
|
+
try:
|
|
594
|
+
current.text_iface.setCaretOffset(snapshot.start + len(snapshot.original))
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
return True
|
bolotype/asr/__init__.py
ADDED
|
File without changes
|
bolotype/asr/base.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ASRTranscriber(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def add_listener(self, cb: Callable[[str], None]) -> None: ...
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def start(self) -> None: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def stop(self) -> None: ...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from .base import ASRTranscriber
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MoonshineTranscriber(ASRTranscriber):
|
|
9
|
+
def __init__(self, language: str = "en") -> None:
|
|
10
|
+
self._language = language
|
|
11
|
+
self._listeners: list[Callable[[str], None]] = []
|
|
12
|
+
self._partial_listeners: list[Callable[[str], None]] = []
|
|
13
|
+
|
|
14
|
+
print(f"Loading Moonshine model for language={language!r}...")
|
|
15
|
+
from moonshine_voice import MicTranscriber, TranscriptEventListener, get_model_for_language
|
|
16
|
+
model_path, model_arch = get_model_for_language(language, 5)
|
|
17
|
+
self._transcriber = MicTranscriber(model_path=model_path, model_arch=model_arch)
|
|
18
|
+
|
|
19
|
+
listeners_ref = self._listeners
|
|
20
|
+
partial_ref = self._partial_listeners
|
|
21
|
+
|
|
22
|
+
class _Listener(TranscriptEventListener):
|
|
23
|
+
def on_line_started(self, event) -> None:
|
|
24
|
+
text = event.line.text or ""
|
|
25
|
+
for cb in partial_ref:
|
|
26
|
+
cb(text)
|
|
27
|
+
|
|
28
|
+
def on_line_text_changed(self, event) -> None:
|
|
29
|
+
text = event.line.text or ""
|
|
30
|
+
for cb in partial_ref:
|
|
31
|
+
cb(text)
|
|
32
|
+
|
|
33
|
+
def on_line_completed(self, event) -> None:
|
|
34
|
+
text = (event.line.text or "").strip()
|
|
35
|
+
if text:
|
|
36
|
+
for cb in listeners_ref:
|
|
37
|
+
cb(text)
|
|
38
|
+
|
|
39
|
+
self._transcriber.add_listener(_Listener())
|
|
40
|
+
|
|
41
|
+
def add_listener(self, cb: Callable[[str], None]) -> None:
|
|
42
|
+
self._listeners.append(cb)
|
|
43
|
+
|
|
44
|
+
def add_partial_listener(self, cb: Callable[[str], None]) -> None:
|
|
45
|
+
self._partial_listeners.append(cb)
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
self._transcriber.start()
|
|
49
|
+
|
|
50
|
+
def stop(self) -> None:
|
|
51
|
+
self._transcriber.stop()
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self._transcriber.close()
|