diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index e93671d..82fdee8 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -177,6 +177,7 @@ def main(): sys.exit(1) # Now it's safe to import GTK-dependent modules + from .common_types import RecognitionState from .speech_recognition import recognition_manager from .text_injection import text_injector from .ui import tray_indicator @@ -292,35 +293,19 @@ def main(): # Create a wrapper function to track injected text for action handler def text_callback_wrapper(text: str): - """Wrapper to track injected text and handle it.""" - # Check if we need to add a space before the new text - text_to_inject = text + """Wrapper to track injected text and handle spacing between segments.""" + # Strip any leading/trailing whitespace from the incoming text as a + # safety net (whisper tokenizer sometimes prepends spaces to tokens) + text_to_inject = text.strip() + if not text_to_inject: + return - # Punctuation and characters that typically need a space after them - chars_needing_space = { - ".", - ",", - "!", - "?", - ";", - ":", - ")", - "]", - "}", - "-", - "_", - # Also quotes - '"', - "'", - } - - # If last injected text exists and ends with a character that needs space, - # add a space before the new text + # 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(): - last_char = action_handler.last_injected_text[-1] - if last_char in chars_needing_space: - text_to_inject = " " + text_to_inject - logger.debug(f"Added space before new text (last char: '{last_char}')") + text_to_inject = " " + text_to_inject + logger.debug("Added space separator before new segment") success = text_system.inject_text(text_to_inject) if success: @@ -330,6 +315,12 @@ def main(): speech_engine.register_text_callback(text_callback_wrapper) speech_engine.register_action_callback(action_handler.handle_action) + def on_state_change(state: RecognitionState): + if state == RecognitionState.LISTENING: + action_handler.set_last_injected_text("") + + speech_engine.register_state_callback(on_state_change) + # Initialize and start the system tray indicator indicator = tray_indicator.TrayIndicator( speech_engine=speech_engine, diff --git a/tests/test_main.py b/tests/test_main.py index 10920f8..b358c4a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -233,6 +233,7 @@ class TestMainModule(unittest.TestCase): mock_speech_instance.register_action_callback.assert_called_once_with( mock_action_instance.handle_action ) + mock_speech_instance.register_state_callback.assert_called_once() # Verify the tray indicator was started mock_tray_instance.run.assert_called_once() @@ -648,5 +649,74 @@ class TestMainConfigPrecedence(unittest.TestCase): self.assertEqual(call_kwargs["audio_device_index"], 2) +class TestTextCallbackSpacing(unittest.TestCase): + """Test spacing logic in text_callback_wrapper.""" + + def _make_callback(self): + """Build text_callback_wrapper with mocked dependencies.""" + from vocalinux.ui.action_handler import ActionHandler + + text_system = MagicMock() + text_system.inject_text.return_value = True + action_handler = ActionHandler(text_system) + + def text_callback_wrapper(text: str): + text_to_inject = text.strip() + if not text_to_inject: + return + if 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: + action_handler.set_last_injected_text(text) + + return text_callback_wrapper, text_system, action_handler + + def test_first_segment_has_no_leading_space(self): + cb, text_system, _ = self._make_callback() + cb("Hello world") + text_system.inject_text.assert_called_once_with("Hello world") + + def test_subsequent_segment_gets_space_separator(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"]) + + def test_reset_clears_leading_space(self): + cb, text_system, ah = self._make_callback() + cb("first session") + ah.set_last_injected_text("") + text_system.inject_text.reset_mock() + cb("second session") + text_system.inject_text.assert_called_once_with("second session") + + def test_whitespace_only_input_is_skipped(self): + cb, text_system, _ = self._make_callback() + cb(" ") + text_system.inject_text.assert_not_called() + + 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") + + def test_multiple_segments_all_get_separators(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"]) + + 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"]) + + if __name__ == "__main__": unittest.main()