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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
bolotype/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
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()