diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index c7e5fa2..21aa2b7 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -21,6 +21,30 @@ logger = logging.getLogger(__name__) # dependency checking to provide better error messages for pip/pipx users +def _should_append_trailing_space() -> bool: + """Return whether completed transcriptions should get a trailing space. + + Reads config.json from disk on each call so Settings toggles take effect + immediately. TrayIndicator and main() each construct their own + ConfigManager, so an in-memory read from main's instance would miss + Settings writes (same pattern as TextInjector._should_copy_to_clipboard). + """ + try: + import json + import os + + from .utils.paths import config_dir + + config_path = os.path.join(config_dir(), "config.json") + if os.path.exists(config_path): + with open(config_path, "r") as f: + config = json.load(f) + return bool(config.get("text_injection", {}).get("append_trailing_space", True)) + except Exception as e: + logger.debug(f"Could not read append_trailing_space setting: {e}") + return True + + def parse_arguments(): """Parse command line arguments.""" parser = argparse.ArgumentParser(prog="vocalinux", description="Vocalinux") @@ -434,9 +458,9 @@ def main(): # # text_callback(text: str) # Called on the recognition thread when a transcription segment - # is finalised. The wrapper below strips whitespace, inserts - # inter-segment spaces, injects the text, and records it so - # "delete that" can undo it. + # is finalised. The wrapper below strips whitespace, optionally + # appends a trailing space (or legacy leading separator), injects + # the text, and records it so "delete that" can undo it. # # action_callback(action: str) -> bool # Called when a voice command (e.g. "undo", "select all") is @@ -452,24 +476,20 @@ def main(): """Bridge between speech engine text events and the text injector. Called on the recognition thread with each finalised transcription - segment. Strips leading/trailing whitespace (whisper tokenizer - sometimes prepends spaces), inserts a single space between - consecutive segments, then injects via TextInjector. + segment. Strips leading whitespace and trailing spaces/tabs (but + preserves trailing newlines from voice commands), then either + appends a trailing space (default) or uses the legacy in-session + leading-space separator, and injects via TextInjector. Args: text: Raw transcription segment from the speech engine. """ - text_to_inject = text.strip() + # Preserve trailing newlines ("new line" / "new paragraph"); only + # strip spaces/tabs that whisper sometimes wraps around tokens. + text_to_inject = text.lstrip().rstrip(" \t") if not text_to_inject: return - # Add a separating space between consecutive dictation segments, - # but never for the very first segment (avoids unwanted leading space - # when starting dictation in an empty text field). - if action_handler.last_injected_text and action_handler.last_injected_text.strip(): - text_to_inject = " " + text_to_inject - logger.debug("Added space separator before new segment") - # Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing) auto_capitalize = config_manager.get("text_injection", "auto_capitalize") if auto_capitalize and speech_engine.engine == "vosk": @@ -477,6 +497,23 @@ def main(): text_to_inject = capitalize_sentences(text_to_inject) + # Read from disk so the Settings toggle applies without restart + # (main and tray each own a ConfigManager instance). + append_trailing_space = _should_append_trailing_space() + + if append_trailing_space: + # Put the separator into the previous field so the next session + # (push-to-talk / toggle) continues cleanly without needing + # cross-session memory — and without a leading space in empty + # fields. Skip after newlines from "new line" / "new paragraph". + if not text_to_inject.endswith((" ", "\t", "\n")): + text_to_inject += " " + logger.debug("Appended trailing space after transcription segment") + elif action_handler.last_injected_text and action_handler.last_injected_text.strip(): + # Legacy: leading space between consecutive in-session segments. + text_to_inject = " " + text_to_inject + logger.debug("Added space separator before new segment") + success = text_system.inject_text(text_to_inject) if success: action_handler.set_last_injected_text(text_to_inject) diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 932a865..3897c4f 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -75,6 +75,10 @@ DEFAULT_CONFIG = { "text_injection": { "copy_to_clipboard": False, # Disabled by default; users can enable in Settings "auto_capitalize": True, # Capitalize first letter of each sentence + # Append a trailing space after each completed transcription segment so + # the next dictation session (push-to-talk / toggle) continues cleanly + # without glueing onto the previous sentence ("Hello.This"). + "append_trailing_space": True, }, "advanced": { "power_user_mode": False, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 57f4a47..8b16309 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1922,6 +1922,18 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Auto-capitalize {'enabled' if enabled else 'disabled'}") return False + def _on_append_trailing_space_toggled(self, widget, state): + """Handle toggle of the trailing space after dictation switch.""" + if self._initializing or self._applying_settings: + return False + + enabled = bool(state) + logger.info(f"Append trailing space toggled: {enabled}") + self.config_manager.set("text_injection", "append_trailing_space", enabled) + self.config_manager.save_settings() + logger.info(f"Append trailing space {'enabled' if enabled else 'disabled'}") + return False + def _on_sound_effects_toggled(self, widget, state): if self._initializing or self._applying_settings: return False @@ -2200,9 +2212,26 @@ class SettingsDialog(Gtk.Dialog): ) output_group.add_row(copy_to_clipboard_row) + self.append_trailing_space_switch = Gtk.Switch() + self.append_trailing_space_switch.set_tooltip_text( + "Append a space after each completed transcription so the next " + "dictation session continues without gluing onto the previous text " + '(e.g. "Hello. This" instead of "Hello.This").' + ) + append_trailing_space_row = PreferenceRow( + title="Trailing Space After Dictation", + subtitle="Insert a space after each completed transcription segment", + widget=self.append_trailing_space_switch, + keywords=("space", "spacing", "punctuation", "push-to-talk"), + ) + output_group.add_row(append_trailing_space_row) + self.recognition_settings_tab.pack_start(output_group, False, False, 0) self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled) self.auto_capitalize_switch.connect("state-set", self._on_auto_capitalize_toggled) + self.append_trailing_space_switch.connect( + "state-set", self._on_append_trailing_space_toggled + ) if not silero_active: vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -3131,11 +3160,13 @@ class SettingsDialog(Gtk.Dialog): start_minimized = ui_settings.get("start_minimized", False) copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False) auto_capitalize = text_injection_settings.get("auto_capitalize", True) + append_trailing_space = text_injection_settings.get("append_trailing_space", True) self.autostart_switch.set_active(autostart_enabled) self.start_minimized_switch.set_active(start_minimized) self.copy_to_clipboard_switch.set_active(copy_to_clipboard) self.auto_capitalize_switch.set_active(auto_capitalize) + self.append_trailing_space_switch.set_active(append_trailing_space) self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled()) auto_pause_settings = self.config_manager.get_settings().get("auto_pause", {}) diff --git a/tests/test_main.py b/tests/test_main.py index c4c0287..5bb6924 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -318,12 +318,14 @@ class TestMainModule(unittest.TestCase): text_callback("World") calls = [call.args[0] for call in mock_text_instance.inject_text.call_args_list] - self.assertEqual(calls, ["Hello.", " World"]) + self.assertEqual(calls, ["Hello. ", "World "]) state_callback(RecognitionState.IDLE) mock_text_instance.inject_text.reset_mock() text_callback("Next session") - mock_text_instance.inject_text.assert_called_once_with("Next session") + # Trailing space persists in the previous field; next session starts clean + # (no leading space) but still gets its own trailing space. + mock_text_instance.inject_text.assert_called_once_with("Next session ") def _run_main_and_get_text_callback( self, @@ -402,7 +404,7 @@ class TestMainModule(unittest.TestCase): ) text_callback("hello world. goodbye") - mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye") + mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye ") @patch("vocalinux.main.check_dependencies") @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") @@ -431,7 +433,7 @@ class TestMainModule(unittest.TestCase): ) text_callback("hello world. goodbye") - mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye") + mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ") @patch("vocalinux.main.check_dependencies") @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") @@ -460,7 +462,7 @@ class TestMainModule(unittest.TestCase): ) text_callback("hello world. goodbye") - mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye") + mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ") @patch("vocalinux.main.check_dependencies") @patch("vocalinux.ui.action_handler.ActionHandler") @@ -895,7 +897,7 @@ class TestMainConfigPrecedence(unittest.TestCase): class TestTextCallbackSpacing(unittest.TestCase): """Test spacing logic in text_callback_wrapper.""" - def _make_callback(self): + def _make_callback(self, append_trailing_space: bool = True): """Build text_callback_wrapper with mocked dependencies.""" from vocalinux.ui.action_handler import ActionHandler @@ -904,10 +906,13 @@ class TestTextCallbackSpacing(unittest.TestCase): action_handler = ActionHandler(text_system) def text_callback_wrapper(text: str): - text_to_inject = text.strip() + text_to_inject = text.lstrip().rstrip(" \t") if not text_to_inject: return - if action_handler.last_injected_text and action_handler.last_injected_text.strip(): + if append_trailing_space: + if not text_to_inject.endswith((" ", "\t", "\n")): + text_to_inject += " " + elif action_handler.last_injected_text and action_handler.last_injected_text.strip(): text_to_inject = " " + text_to_inject success = text_system.inject_text(text_to_inject) if success: @@ -919,25 +924,26 @@ class TestTextCallbackSpacing(unittest.TestCase): return text_callback_wrapper, on_state_change, text_system, action_handler - def test_first_segment_has_no_leading_space(self): + def test_first_segment_has_trailing_space(self): cb, _, text_system, _ = self._make_callback() cb("Hello world") - text_system.inject_text.assert_called_once_with("Hello world") + text_system.inject_text.assert_called_once_with("Hello world ") - def test_subsequent_segment_gets_space_separator(self): + def test_subsequent_segment_has_trailing_space_not_leading(self): cb, _, text_system, _ = self._make_callback() cb("Hello") cb("world") calls = [c.args[0] for c in text_system.inject_text.call_args_list] - self.assertEqual(calls, ["Hello", " world"]) + self.assertEqual(calls, ["Hello ", "world "]) - def test_reset_clears_leading_space(self): + def test_cross_session_keeps_trailing_space_without_leading_space(self): cb, on_state_change, text_system, _ = self._make_callback() cb("first session") on_state_change(RecognitionState.IDLE) text_system.inject_text.reset_mock() cb("second session") - text_system.inject_text.assert_called_once_with("second session") + # No leading space (empty-field safe); trailing space still appended. + text_system.inject_text.assert_called_once_with("second session ") def test_whitespace_only_input_is_skipped(self): cb, _, text_system, _ = self._make_callback() @@ -947,22 +953,27 @@ class TestTextCallbackSpacing(unittest.TestCase): def test_input_with_leading_space_is_stripped(self): cb, _, text_system, _ = self._make_callback() cb(" Hello world") - text_system.inject_text.assert_called_once_with("Hello world") + text_system.inject_text.assert_called_once_with("Hello world ") - def test_multiple_segments_all_get_separators(self): + def test_multiple_segments_all_get_trailing_spaces(self): cb, _, text_system, _ = self._make_callback() cb("one") cb("two") cb("three") calls = [c.args[0] for c in text_system.inject_text.call_args_list] - self.assertEqual(calls, ["one", " two", " three"]) + self.assertEqual(calls, ["one ", "two ", "three "]) def test_space_after_punctuation_segment(self): cb, _, text_system, _ = self._make_callback() cb("Hello.") cb("World") calls = [c.args[0] for c in text_system.inject_text.call_args_list] - self.assertEqual(calls, ["Hello.", " World"]) + self.assertEqual(calls, ["Hello. ", "World "]) + + def test_newline_segment_does_not_get_trailing_space(self): + cb, _, text_system, _ = self._make_callback() + cb("Hello.\n") + text_system.inject_text.assert_called_once_with("Hello.\n") def test_processing_to_listening_keeps_segment_spacing(self): cb, on_state_change, text_system, _ = self._make_callback() @@ -971,8 +982,157 @@ class TestTextCallbackSpacing(unittest.TestCase): on_state_change(RecognitionState.LISTENING) cb("World") calls = [c.args[0] for c in text_system.inject_text.call_args_list] + self.assertEqual(calls, ["Hello. ", "World "]) + + def test_legacy_mode_uses_leading_space_in_session(self): + cb, _, text_system, _ = self._make_callback(append_trailing_space=False) + cb("Hello.") + cb("World") + calls = [c.args[0] for c in text_system.inject_text.call_args_list] self.assertEqual(calls, ["Hello.", " World"]) + def test_legacy_mode_clears_leading_space_across_sessions(self): + cb, on_state_change, text_system, _ = self._make_callback(append_trailing_space=False) + cb("first session") + on_state_change(RecognitionState.IDLE) + text_system.inject_text.reset_mock() + cb("second session") + text_system.inject_text.assert_called_once_with("second session") + + +class TestShouldAppendTrailingSpace(unittest.TestCase): + """Test disk-backed trailing-space setting reader.""" + + def test_reads_setting_from_disk_with_true_default(self): + import json + import os + import tempfile + + from vocalinux.main import _should_append_trailing_space + + with tempfile.TemporaryDirectory() as d: + with patch("vocalinux.utils.paths.config_dir", return_value=d): + # No config file -> default True + self.assertTrue(_should_append_trailing_space()) + + cfg = os.path.join(d, "config.json") + with open(cfg, "w") as f: + json.dump({"text_injection": {}}, f) + self.assertTrue(_should_append_trailing_space()) + + with open(cfg, "w") as f: + json.dump({"text_injection": {"append_trailing_space": False}}, f) + self.assertFalse(_should_append_trailing_space()) + + with open(cfg, "w") as f: + json.dump({"text_injection": {"append_trailing_space": True}}, f) + self.assertTrue(_should_append_trailing_space()) + + def test_returns_true_when_config_read_fails(self): + from vocalinux.main import _should_append_trailing_space + + with patch("vocalinux.utils.paths.config_dir", side_effect=OSError("boom")): + self.assertTrue(_should_append_trailing_space()) + + +class TestMainCallbackTrailingSpaceEdges(unittest.TestCase): + """Exercise trailing-space edge paths through the real main() callback.""" + + def _boot_under_patches(self, *, append_trailing_space: bool = True, inject_ok: bool = True): + """Return (exit_stack, text_cb, mock_text) with patches still active.""" + from contextlib import ExitStack + + stack = ExitStack() + stack.enter_context(patch("vocalinux.main.check_dependencies", return_value=True)) + mock_config_cls = stack.enter_context(patch("vocalinux.ui.config_manager.ConfigManager")) + mock_config = MagicMock() + mock_config.get_settings.return_value = { + "speech_recognition": {}, + "general": {"first_run": False}, + } + mock_config.get.return_value = False # auto_capitalize off + mock_config_cls.return_value = mock_config + + mock_speech_cls = stack.enter_context( + patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") + ) + mock_speech = MagicMock() + mock_speech.engine = "whisper_cpp" + mock_speech_cls.return_value = mock_speech + + mock_text_cls = stack.enter_context( + patch("vocalinux.text_injection.text_injector.TextInjector") + ) + mock_text = MagicMock() + mock_text.inject_text.return_value = inject_ok + mock_text_cls.return_value = mock_text + + mock_tray_cls = stack.enter_context(patch("vocalinux.ui.tray_indicator.TrayIndicator")) + mock_tray_cls.return_value = MagicMock() + + stack.enter_context(patch("vocalinux.ui.logging_manager.initialize_logging")) + stack.enter_context( + patch( + "vocalinux.main._should_append_trailing_space", + return_value=append_trailing_space, + ) + ) + mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments")) + stack.enter_context(patch("sys.argv", ["vocalinux"])) + + mock_args = MagicMock() + mock_args.debug = False + mock_args.model = "tiny" + mock_args.engine = "whisper_cpp" + mock_args.language = "en-us" + mock_args.wayland = False + mock_args.start_minimized = False + mock_parse.return_value = mock_args + main() + + text_cb = mock_speech.register_text_callback.call_args.args[0] + return stack, text_cb, mock_text + + def test_whitespace_only_is_skipped_through_main(self): + stack, text_cb, mock_text = self._boot_under_patches() + try: + text_cb(" \t ") + mock_text.inject_text.assert_not_called() + finally: + stack.close() + + def test_newline_segment_skips_trailing_space_through_main(self): + stack, text_cb, mock_text = self._boot_under_patches() + try: + text_cb("Hello.\n") + mock_text.inject_text.assert_called_once_with("Hello.\n") + finally: + stack.close() + + def test_legacy_mode_adds_leading_space_in_session(self): + stack, text_cb, mock_text = self._boot_under_patches(append_trailing_space=False) + try: + text_cb("Hello.") + text_cb("World") + calls = [c.args[0] for c in mock_text.inject_text.call_args_list] + self.assertEqual(calls, ["Hello.", " World"]) + finally: + stack.close() + + def test_failed_inject_does_not_remember_text(self): + stack, text_cb, mock_text = self._boot_under_patches( + append_trailing_space=False, inject_ok=False + ) + try: + text_cb("Hello.") + mock_text.inject_text.reset_mock() + mock_text.inject_text.return_value = True + text_cb("World") + # Failure means last_injected stays empty; next segment has no leading space + mock_text.inject_text.assert_called_once_with("World") + finally: + stack.close() + if __name__ == "__main__": unittest.main()