python-voiceio 0.9.10__tar.gz → 0.9.11__tar.gz

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.
Files changed (133) hide show
  1. {python_voiceio-0.9.10/python_voiceio.egg-info → python_voiceio-0.9.11}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.10 → python_voiceio-0.9.11/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/SOURCES.txt +3 -0
  5. python_voiceio-0.9.11/tests/test_ibus_engine_limits.py +129 -0
  6. python_voiceio-0.9.11/tests/test_ibus_textlimit.py +94 -0
  7. python_voiceio-0.9.11/voiceio/__init__.py +1 -0
  8. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/ibus/engine.py +14 -7
  9. python_voiceio-0.9.11/voiceio/ibus/textlimit.py +66 -0
  10. python_voiceio-0.9.10/voiceio/__init__.py +0 -1
  11. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/LICENSE +0 -0
  12. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/README.md +0 -0
  13. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/dependency_links.txt +0 -0
  14. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/entry_points.txt +0 -0
  15. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/requires.txt +0 -0
  16. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/top_level.txt +0 -0
  17. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/setup.cfg +0 -0
  18. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_adjudicate.py +0 -0
  19. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_app_wiring.py +0 -0
  20. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_audio_quality.py +0 -0
  21. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_audit.py +0 -0
  22. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_autocorrect.py +0 -0
  23. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_autocorrect_state.py +0 -0
  24. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_backend_probes.py +0 -0
  25. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_cli.py +0 -0
  26. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_clipboard_read.py +0 -0
  27. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_commands.py +0 -0
  28. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_concurrency_lockdown.py +0 -0
  29. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_config.py +0 -0
  30. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_correct_batch.py +0 -0
  31. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_corrections.py +0 -0
  32. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_evaluate.py +0 -0
  33. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_fallback.py +0 -0
  34. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_health.py +0 -0
  35. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_hints.py +0 -0
  36. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_history.py +0 -0
  37. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_ibus_pending.py +0 -0
  38. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_ibus_ping.py +0 -0
  39. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_ibus_typer.py +0 -0
  40. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_llm.py +0 -0
  41. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_llm_api.py +0 -0
  42. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_numbers.py +0 -0
  43. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_platform.py +0 -0
  44. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_postcorrect.py +0 -0
  45. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_postprocess.py +0 -0
  46. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_prebuffer.py +0 -0
  47. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_prompt.py +0 -0
  48. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_recorder_integration.py +0 -0
  49. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_retention.py +0 -0
  50. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_robustness.py +0 -0
  51. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_security_hardening.py +0 -0
  52. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_snapshots.py +0 -0
  53. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_streaming.py +0 -0
  54. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_tokens.py +0 -0
  55. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_transcriber.py +0 -0
  56. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_tts.py +0 -0
  57. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_vad.py +0 -0
  58. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_vocabulary.py +0 -0
  59. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_wizard.py +0 -0
  60. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_wordfreq.py +0 -0
  61. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/__main__.py +0 -0
  62. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/app.py +0 -0
  63. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/audit.py +0 -0
  64. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/autocorrect.py +0 -0
  65. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/autocorrect_state.py +0 -0
  66. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/backends.py +0 -0
  67. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/cli.py +0 -0
  68. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/clipboard_read.py +0 -0
  69. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/commands.py +0 -0
  70. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/config.py +0 -0
  71. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/consent.py +0 -0
  72. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/corrections.py +0 -0
  73. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/demo.py +0 -0
  74. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/evaluate.py +0 -0
  75. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/feedback.py +0 -0
  76. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/health.py +0 -0
  77. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hints.py +0 -0
  78. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/history.py +0 -0
  79. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/__init__.py +0 -0
  80. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/base.py +0 -0
  81. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/chain.py +0 -0
  82. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/evdev.py +0 -0
  83. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/pynput_backend.py +0 -0
  84. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/socket_backend.py +0 -0
  85. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/ibus/__init__.py +0 -0
  86. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/ibus/pending.py +0 -0
  87. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/llm.py +0 -0
  88. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/llm_api.py +0 -0
  89. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/models/__init__.py +0 -0
  90. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/models/silero_vad.onnx +0 -0
  91. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/numbers.py +0 -0
  92. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/pidlock.py +0 -0
  93. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/platform.py +0 -0
  94. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/postcorrect.py +0 -0
  95. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/postprocess.py +0 -0
  96. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/prompt.py +0 -0
  97. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/recorder.py +0 -0
  98. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/retention.py +0 -0
  99. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/service.py +0 -0
  100. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/snapshots.py +0 -0
  101. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/__init__.py +0 -0
  102. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/commit.wav +0 -0
  103. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/start.wav +0 -0
  104. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/stop.wav +0 -0
  105. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/streaming.py +0 -0
  106. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tokens.py +0 -0
  107. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/transcriber.py +0 -0
  108. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/__init__.py +0 -0
  109. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/_icons.py +0 -0
  110. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/_indicator.py +0 -0
  111. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/_pystray.py +0 -0
  112. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/__init__.py +0 -0
  113. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/base.py +0 -0
  114. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/chain.py +0 -0
  115. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/edge_engine.py +0 -0
  116. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/espeak.py +0 -0
  117. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/piper_engine.py +0 -0
  118. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/player.py +0 -0
  119. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/__init__.py +0 -0
  120. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/base.py +0 -0
  121. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/chain.py +0 -0
  122. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/clipboard.py +0 -0
  123. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/ibus.py +0 -0
  124. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/pynput_type.py +0 -0
  125. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/wtype.py +0 -0
  126. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/xdotool.py +0 -0
  127. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/ydotool.py +0 -0
  128. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/vad.py +0 -0
  129. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/vocab_stats.py +0 -0
  130. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/vocabulary.py +0 -0
  131. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/wizard.py +0 -0
  132. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/wordfreq.py +0 -0
  133. {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.10
3
+ Version: 0.9.11
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.9.10"
7
+ version = "0.9.11"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.10
3
+ Version: 0.9.11
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -26,8 +26,10 @@ tests/test_fallback.py
26
26
  tests/test_health.py
27
27
  tests/test_hints.py
28
28
  tests/test_history.py
29
+ tests/test_ibus_engine_limits.py
29
30
  tests/test_ibus_pending.py
30
31
  tests/test_ibus_ping.py
32
+ tests/test_ibus_textlimit.py
31
33
  tests/test_ibus_typer.py
32
34
  tests/test_llm.py
33
35
  tests/test_llm_api.py
@@ -99,6 +101,7 @@ voiceio/hotkeys/socket_backend.py
99
101
  voiceio/ibus/__init__.py
100
102
  voiceio/ibus/engine.py
101
103
  voiceio/ibus/pending.py
104
+ voiceio/ibus/textlimit.py
102
105
  voiceio/models/__init__.py
103
106
  voiceio/models/silero_vad.onnx
104
107
  voiceio/sounds/__init__.py
@@ -0,0 +1,129 @@
1
+ """Regression guard: the engine must never hand IBus an oversized string.
2
+
3
+ ``test_ibus_textlimit`` proves the limiter itself is correct. This pins the
4
+ engine to actually routing through it, so the crash cannot come back via an
5
+ edit that bypasses the limiter. A stub stands in for the IBus GObject bindings,
6
+ which are a system package and are not installed in the test venv.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import sys
12
+ import types
13
+
14
+ import pytest
15
+
16
+ from voiceio.ibus.textlimit import MAX_TEXT_BYTES
17
+
18
+
19
+ class _Text:
20
+ """Stand-in for IBus.Text that just remembers its string."""
21
+
22
+ def __init__(self, string: str):
23
+ self.string = string
24
+
25
+ @staticmethod
26
+ def new_from_string(string: str) -> "_Text":
27
+ return _Text(string)
28
+
29
+ def append_attribute(self, *args) -> None:
30
+ pass
31
+
32
+
33
+ class _Recorder:
34
+ """Stands in for ``self``, recording what the engine sends to IBus."""
35
+
36
+ def __init__(self):
37
+ self.committed: list[str] = []
38
+ self.preedits: list[str] = []
39
+ self.hides = 0
40
+
41
+ def hide_preedit_text(self) -> None:
42
+ self.hides += 1
43
+
44
+ def commit_text(self, text: _Text) -> None:
45
+ self.committed.append(text.string)
46
+
47
+ def update_preedit_text(self, text: _Text, cursor: int, visible: bool) -> None:
48
+ self.preedits.append(text.string)
49
+
50
+
51
+ @pytest.fixture
52
+ def engine(monkeypatch):
53
+ ibus = types.SimpleNamespace(
54
+ Engine=type("Engine", (), {}),
55
+ Factory=type("Factory", (), {}),
56
+ Text=_Text,
57
+ AttrType=types.SimpleNamespace(UNDERLINE=1),
58
+ AttrUnderline=types.SimpleNamespace(SINGLE=1),
59
+ )
60
+ repository = types.ModuleType("gi.repository")
61
+ repository.IBus = ibus
62
+ repository.GLib = types.SimpleNamespace()
63
+ repository.GObject = types.SimpleNamespace()
64
+ gi = types.ModuleType("gi")
65
+ gi.require_version = lambda *a, **k: None
66
+ gi.repository = repository
67
+
68
+ monkeypatch.setitem(sys.modules, "gi", gi)
69
+ monkeypatch.setitem(sys.modules, "gi.repository", repository)
70
+ sys.modules.pop("voiceio.ibus.engine", None)
71
+ module = importlib.import_module("voiceio.ibus.engine")
72
+ yield module.VoiceIOEngine
73
+ sys.modules.pop("voiceio.ibus.engine", None)
74
+
75
+
76
+ @pytest.fixture
77
+ def long_note() -> str:
78
+ """A transcript the size of the ones that crashed Ghostty and Obsidian."""
79
+ return " ".join(f"word{i}" for i in range(900))
80
+
81
+
82
+ def _nbytes(text: str) -> int:
83
+ return len(text.encode("utf-8"))
84
+
85
+
86
+ def test_commit_chunks_stay_under_the_limit(engine, long_note):
87
+ rec = _Recorder()
88
+ engine.commit(rec, long_note)
89
+ assert len(rec.committed) > 1
90
+ assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in rec.committed)
91
+
92
+
93
+ def test_commit_delivers_the_whole_note(engine, long_note):
94
+ rec = _Recorder()
95
+ engine.commit(rec, long_note)
96
+ assert "".join(rec.committed) == long_note
97
+
98
+
99
+ def test_commit_hides_preedit_first(engine, long_note):
100
+ rec = _Recorder()
101
+ engine.commit(rec, long_note)
102
+ assert rec.hides == 1
103
+
104
+
105
+ def test_empty_commit_sends_nothing(engine):
106
+ rec = _Recorder()
107
+ engine.commit(rec, "")
108
+ assert rec.committed == []
109
+ assert rec.hides == 1
110
+
111
+
112
+ def test_preedit_stays_under_the_limit(engine, long_note):
113
+ rec = _Recorder()
114
+ engine.preedit(rec, long_note)
115
+ assert len(rec.preedits) == 1
116
+ assert _nbytes(rec.preedits[0]) <= MAX_TEXT_BYTES
117
+
118
+
119
+ def test_short_preedit_is_shown_verbatim(engine):
120
+ rec = _Recorder()
121
+ engine.preedit(rec, "hello there")
122
+ assert rec.preedits == ["hello there"]
123
+
124
+
125
+ def test_empty_preedit_hides_instead(engine):
126
+ rec = _Recorder()
127
+ engine.preedit(rec, "")
128
+ assert rec.preedits == []
129
+ assert rec.hides == 1
@@ -0,0 +1,94 @@
1
+ """Tests for the IBus text size limits.
2
+
3
+ The bug these guard against: a preedit or commit string above libwayland's
4
+ 4096-byte per-message buffer makes the compositor drop the focused
5
+ application's connection, killing it mid-dictation.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from voiceio.ibus.textlimit import (
12
+ MAX_TEXT_BYTES,
13
+ TRUNCATION_MARK,
14
+ split_utf8,
15
+ tail_utf8,
16
+ )
17
+
18
+ # The real 4096-byte ceiling every chunk has to clear, with room for the
19
+ # neighbouring events GNOME Shell may pack into the same buffer flush.
20
+ WAYLAND_BUFFER_BYTES = 4096
21
+
22
+
23
+ def _nbytes(text: str) -> int:
24
+ return len(text.encode("utf-8"))
25
+
26
+
27
+ @pytest.fixture
28
+ def long_note() -> str:
29
+ """A transcript the size of the ones that crashed Ghostty and Obsidian."""
30
+ return " ".join(f"word{i}" for i in range(900))
31
+
32
+
33
+ def test_short_text_is_one_chunk():
34
+ assert split_utf8("hello there") == ["hello there"]
35
+
36
+
37
+ def test_empty_text_yields_no_chunks():
38
+ assert split_utf8("") == []
39
+
40
+
41
+ def test_chunks_rejoin_to_the_original(long_note):
42
+ assert "".join(split_utf8(long_note)) == long_note
43
+
44
+
45
+ def test_every_chunk_fits_the_wayland_buffer(long_note):
46
+ chunks = split_utf8(long_note)
47
+ assert len(chunks) > 1
48
+ assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in chunks)
49
+ assert all(_nbytes(c) < WAYLAND_BUFFER_BYTES for c in chunks)
50
+
51
+
52
+ def test_multibyte_characters_are_never_cut_in_half():
53
+ # No spaces, so splitting has to fall back to character boundaries.
54
+ text = "é€𝄞" * 2000
55
+ chunks = split_utf8(text)
56
+ assert "".join(chunks) == text
57
+ assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in chunks)
58
+
59
+
60
+ def test_splits_prefer_word_boundaries(long_note):
61
+ # Every chunk but the last should end at a space, so commits do not land
62
+ # mid-word in editors that treat each commit as a unit.
63
+ chunks = split_utf8(long_note)
64
+ assert all(c.endswith(" ") for c in chunks[:-1])
65
+
66
+
67
+ def test_a_run_without_spaces_still_splits():
68
+ text = "x" * (MAX_TEXT_BYTES * 3)
69
+ chunks = split_utf8(text)
70
+ assert "".join(chunks) == text
71
+ assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in chunks)
72
+
73
+
74
+ def test_short_preedit_is_untouched():
75
+ assert tail_utf8("hello there") == "hello there"
76
+
77
+
78
+ def test_long_preedit_is_bounded_and_marked(long_note):
79
+ shown = tail_utf8(long_note)
80
+ assert _nbytes(shown) <= MAX_TEXT_BYTES
81
+ assert shown.startswith(TRUNCATION_MARK)
82
+
83
+
84
+ def test_preedit_keeps_the_most_recent_words(long_note):
85
+ # The tail is what the speaker is saying right now — that is the half worth
86
+ # showing when the preview has to be trimmed.
87
+ assert tail_utf8(long_note).endswith(long_note[-200:])
88
+
89
+
90
+ def test_preedit_tail_survives_multibyte_text():
91
+ text = "é" * 4000
92
+ shown = tail_utf8(text)
93
+ assert _nbytes(shown) <= MAX_TEXT_BYTES
94
+ assert shown == TRUNCATION_MARK + "é" * ((MAX_TEXT_BYTES - _nbytes(TRUNCATION_MARK)) // 2)
@@ -0,0 +1 @@
1
+ __version__ = "0.9.11"
@@ -30,6 +30,7 @@ from voiceio.ibus import (
30
30
  acquire_singleton_lock,
31
31
  )
32
32
  from voiceio.ibus.pending import PendingBuffer
33
+ from voiceio.ibus.textlimit import split_utf8, tail_utf8
33
34
 
34
35
  log = logging.getLogger(__name__)
35
36
 
@@ -38,6 +39,11 @@ log = logging.getLogger(__name__)
38
39
  _lock_fd: int | None = None
39
40
  ENGINE_NAME = "voiceio"
40
41
  COMPONENT_NAME = "org.voiceio.ibus"
42
+ # A datagram longer than the buffer we hand recvfrom() is truncated silently,
43
+ # which would inject half a dictation with no error anywhere. Sized above the
44
+ # largest datagram a sender can produce (SO_SNDBUF, ~208K by default) so that
45
+ # cannot happen.
46
+ RECV_BYTES = 262144
41
47
 
42
48
 
43
49
  class VoiceIOEngine(IBus.Engine):
@@ -69,24 +75,25 @@ class VoiceIOEngine(IBus.Engine):
69
75
  return False
70
76
 
71
77
  def preedit(self, text: str) -> None:
72
- """Show text as preedit (underlined preview)."""
78
+ """Show text as preedit (underlined preview), tail-first once long."""
73
79
  if not text:
74
80
  self.hide_preedit_text()
75
81
  return
76
- ibus_text = IBus.Text.new_from_string(text)
82
+ shown = tail_utf8(text)
83
+ ibus_text = IBus.Text.new_from_string(shown)
77
84
  ibus_text.append_attribute(
78
85
  IBus.AttrType.UNDERLINE,
79
86
  IBus.AttrUnderline.SINGLE,
80
87
  0,
81
- len(text),
88
+ len(shown),
82
89
  )
83
- self.update_preedit_text(ibus_text, len(text), True)
90
+ self.update_preedit_text(ibus_text, len(shown), True)
84
91
 
85
92
  def commit(self, text: str) -> None:
86
93
  """Clear preedit and commit final text."""
87
94
  self.hide_preedit_text()
88
- if text:
89
- self.commit_text(IBus.Text.new_from_string(text))
95
+ for chunk in split_utf8(text):
96
+ self.commit_text(IBus.Text.new_from_string(chunk))
90
97
 
91
98
  def clear(self) -> None:
92
99
  """Clear preedit without committing."""
@@ -150,7 +157,7 @@ def _socket_listener(mainloop: GLib.MainLoop) -> None:
150
157
 
151
158
  while mainloop.is_running():
152
159
  try:
153
- data, addr = sock.recvfrom(65536)
160
+ data, addr = sock.recvfrom(RECV_BYTES)
154
161
  except socket.timeout:
155
162
  continue
156
163
  except OSError:
@@ -0,0 +1,66 @@
1
+ """Size limits for text handed to IBus.
2
+
3
+ libwayland gives every client connection a fixed 4096-byte message buffer.
4
+ GNOME Shell relays our preedit and commit strings to the focused application as
5
+ single ``zwp_text_input_v3`` events, so a string that does not fit makes the
6
+ compositor tear down that application's connection — it dies mid-dictation,
7
+ whatever it happens to be. Shell can pack several events into one flush, so the
8
+ budget here leaves real headroom rather than sitting just under 4096.
9
+
10
+ Kept free of any ``gi`` dependency so it can be unit-tested without the IBus
11
+ GObject bindings installed.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ MAX_TEXT_BYTES = 2048
16
+ TRUNCATION_MARK = "… "
17
+
18
+
19
+ def _is_continuation(byte: int) -> bool:
20
+ return byte & 0xC0 == 0x80
21
+
22
+
23
+ def split_utf8(text: str, limit: int = MAX_TEXT_BYTES) -> list[str]:
24
+ """Split ``text`` into chunks of at most ``limit`` UTF-8 bytes.
25
+
26
+ The chunks concatenate back to exactly ``text``, so committing them in order
27
+ is indistinguishable to the target application from a single commit. A split
28
+ lands on a space when one falls in the back half of the chunk, and on a
29
+ character boundary otherwise, so no multi-byte character is ever cut in half.
30
+ """
31
+ data = text.encode("utf-8")
32
+ if len(data) <= limit:
33
+ return [text] if text else []
34
+
35
+ chunks: list[str] = []
36
+ start = 0
37
+ while start < len(data):
38
+ end = min(start + limit, len(data))
39
+ if end < len(data):
40
+ space = data.rfind(b" ", start + limit // 2, end)
41
+ if space != -1:
42
+ end = space + 1
43
+ else:
44
+ while end > start and _is_continuation(data[end]):
45
+ end -= 1
46
+ chunks.append(data[start:end].decode("utf-8"))
47
+ start = end
48
+ return chunks
49
+
50
+
51
+ def tail_utf8(text: str, limit: int = MAX_TEXT_BYTES) -> str:
52
+ """Return the trailing ``limit`` UTF-8 bytes of ``text``, marked if trimmed.
53
+
54
+ The preedit preview is one replaceable string rather than an append, so it
55
+ cannot be chunked the way a commit can — it has to be bounded instead. Only
56
+ the preview is shortened; the full text still reaches the application when
57
+ the dictation commits.
58
+ """
59
+ data = text.encode("utf-8")
60
+ if len(data) <= limit:
61
+ return text
62
+
63
+ start = len(data) - (limit - len(TRUNCATION_MARK.encode("utf-8")))
64
+ while start < len(data) and _is_continuation(data[start]):
65
+ start += 1
66
+ return TRUNCATION_MARK + data[start:].decode("utf-8")
@@ -1 +0,0 @@
1
- __version__ = "0.9.10"
File without changes