diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index b6b8bd3..551b2db 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -744,7 +744,9 @@ def main(): def _normalize_segment_text(text: str) -> str: """Segment text as it is injected: stripped, auto-capitalized (Vosk).""" - normalized = text.lstrip().rstrip(" \t") + # Strip only horizontal whitespace: "new line" / "new paragraph" + # produce leading newlines a bare lstrip() would silently destroy. + normalized = text.lstrip(" \t").rstrip(" \t") # Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing) if ( normalized @@ -1186,8 +1188,8 @@ def main(): """Bridge between speech engine text events and the text injector. Called on the recognition thread with each finalised transcription - segment. Strips leading whitespace and trailing spaces/tabs (but - preserves trailing newlines from voice commands), then hands the + segment. Strips leading and trailing spaces/tabs (but preserves + newlines from voice commands), then hands the segment to the single post-processing worker — a pass-through when no script is configured — which applies the separator rules and injects via TextInjector in spoken order. diff --git a/src/vocalinux/speech_recognition/command_processor.py b/src/vocalinux/speech_recognition/command_processor.py index 5cbe4e4..8c07ce0 100644 --- a/src/vocalinux/speech_recognition/command_processor.py +++ b/src/vocalinux/speech_recognition/command_processor.py @@ -22,6 +22,11 @@ _ATTACH_LEFT_REPLACEMENTS = {".", ",", "?", "!", ";", ":"} # line-break command just inserted (``\s*`` would collapse "new line" + "period"). _ATTACH_LEFT_WS = r"[^\S\n]*" +# Sentence-final punctuation engines may wrap around a standalone command +# utterance ("New line."). Dropped only when the whole segment is command +# phrases — the same marks stay untouched on real text. +_EDGE_PUNCTUATION = ".,!?;:…" + # Base English text commands (always present). _BASE_TEXT_COMMANDS = { # Line commands @@ -252,22 +257,36 @@ class CommandProcessor: # If no exact match found, fallback to generic processing else: - processed_text = text.strip() + # Engines add sentence-final punctuation to an utterance that is + # only a command ("New line.", "Select all."). When the segment is + # nothing but command phrases and whitespace once edge punctuation + # is dropped, substitute on that stripped core so the stray mark + # cannot leak into the output; real text keeps its punctuation. + core = text.strip().strip(_EDGE_PUNCTUATION).strip() + residual = self.format_cmd_regex.sub( + "", self.action_cmd_regex.sub("", self.text_cmd_regex.sub("", core)) + ) + segment_text = core if core and not residual.strip() else text + processed_text = segment_text.strip() # Handle action commands for cmd, action in self.action_commands.items(): cmd_pattern = r"\b" + re.escape(cmd) + r"\b" - if re.search(cmd_pattern, text, re.IGNORECASE): + if re.search(cmd_pattern, segment_text, re.IGNORECASE): actions.append(action) # Check if there's text after the command - match = re.search(r"\b" + re.escape(cmd) + r"\s+(.*)", text, re.IGNORECASE) + match = re.search( + r"\b" + re.escape(cmd) + r"\s+(.*)", segment_text, re.IGNORECASE + ) if match: remaining_text = match.group(1).strip() # Only add space if there's text before the command cmd_match = re.search( - r"^(.*?)\b" + re.escape(cmd) + r"\b", text, re.IGNORECASE + r"^(.*?)\b" + re.escape(cmd) + r"\b", + segment_text, + re.IGNORECASE, ) if cmd_match and cmd_match.group(1).strip(): processed_text = " " + remaining_text @@ -302,9 +321,11 @@ class CommandProcessor: for cmd, format_type in self.format_commands.items(): cmd_pattern = r"\b" + re.escape(cmd) + r"\b" - if re.search(cmd_pattern, text, re.IGNORECASE): + if re.search(cmd_pattern, segment_text, re.IGNORECASE): # Handle format command that modifies next word - match = re.search(r"\b" + re.escape(cmd) + r"\s+(\w+)", text, re.IGNORECASE) + match = re.search( + r"\b" + re.escape(cmd) + r"\s+(\w+)", segment_text, re.IGNORECASE + ) if match: word = match.group(1) if format_type == "capitalize_next": @@ -320,7 +341,7 @@ class CommandProcessor: processed_text = re.sub( r"\b" + re.escape(cmd) + r"\s+" + re.escape(word) + r"\b", replacement, - text, + segment_text, flags=re.IGNORECASE, ) else: @@ -347,8 +368,14 @@ def capitalize_sentences(text: str) -> str: if not text: return text - # Capitalize first character - result = text[0].upper() + text[1:] + # Capitalize the first letter, skipping leading whitespace a voice + # command can leave behind ("new line hello" -> "\n hello"). + first_letter = re.match(r"\s*([a-z])", text) + if first_letter: + idx = first_letter.start(1) + result = text[:idx] + text[idx].upper() + text[idx + 1 :] + else: + result = text[0].upper() + text[1:] # Capitalize after sentence-ending punctuation + whitespace result = re.sub( diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 2db1bde..76e3d78 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -1622,7 +1622,10 @@ class IBusTextInjector: Returns: True if injection was successful, False otherwise """ - if not text or not text.strip(): + # Same contract as TextInjector.inject_text: space/tab-only input has + # no key to send, but a newline-only segment from "new line" must + # reach the engine socket (the engine commits it as text) (#955). + if not text or not text.strip(" \t"): logger.debug("Empty text provided, skipping injection") return True diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index f91b647..a64ae70 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -1612,7 +1612,10 @@ class TextInjector: """ self.last_typed_count = 0 - if not text or not text.strip(): + # A segment carrying only spaces/tabs has no key to deliver and is + # skipped, but one holding a newline still types: "new line" + # produces a bare "\n" the backends send as Return (#955). + if not text or not text.strip(" \t"): logger.debug("Empty text provided, skipping injection") return True diff --git a/tests/test_command_processor.py b/tests/test_command_processor.py index 8d01e8d..47fa6c9 100644 --- a/tests/test_command_processor.py +++ b/tests/test_command_processor.py @@ -512,3 +512,83 @@ class TestCommandPhrases(unittest.TestCase): self.assertEqual(aliases[f"point d{apostrophe}interrogation"], "?") self.assertEqual(aliases[f"point d{apostrophe}exclamation"], "!") self.assertEqual(aliases["point"], ".") + + +class TestStandaloneCommandPunctuation(unittest.TestCase): + """Engine sentence-final punctuation on standalone commands (issue #955). + + whisper.cpp transcripts arrive capitalized and punctuated ("New line."), + which used to leave a stray "." after the substituted newline — or, for + punctuation commands, a doubled mark. Edge punctuation is dropped only + when the whole segment is command phrases; real text keeps it. + """ + + def setUp(self) -> None: + """Set up for tests.""" + self.processor = CommandProcessor() + + def test_standalone_line_breaks_with_engine_punctuation(self) -> None: + """Punctuated standalone line-break commands still emit the break.""" + cases = [ + ("New line.", "\n"), + ("new line.", "\n"), + ("New line!", "\n"), + ("New line?", "\n"), + ("new line...", "\n"), + (", new line", "\n"), + ("New paragraph.", "\n\n"), + ("new paragraph!", "\n\n"), + ] + for input_text, expected in cases: + with self.subTest(input_text=input_text): + result, actions = self.processor.process_text(input_text) + self.assertEqual(result, expected) + self.assertEqual(actions, []) + + def test_standalone_punctuation_commands_with_engine_punctuation(self) -> None: + """Punctuated standalone punctuation commands emit one mark, not two.""" + cases = [ + ("Period.", "."), + ("Full stop.", "."), + ("Comma.", ","), + ("Question mark?", "?"), + ] + for input_text, expected in cases: + with self.subTest(input_text=input_text): + result, actions = self.processor.process_text(input_text) + self.assertEqual(result, expected) + self.assertEqual(actions, []) + + def test_standalone_action_commands_with_engine_punctuation(self) -> None: + """Punctuated standalone action commands fire without stray text.""" + cases = [ + ("Delete that.", ["delete_last"]), + ("Select all.", ["select_all"]), + ("Undo!", ["undo"]), + ("Redo?", ["redo"]), + ] + for input_text, expected_actions in cases: + with self.subTest(input_text=input_text): + result, actions = self.processor.process_text(input_text) + self.assertEqual(result, "") + self.assertEqual(actions, expected_actions) + + def test_real_text_keeps_engine_sentence_punctuation(self) -> None: + """Non-command utterances keep the punctuation the engine emitted.""" + cases = [ + ("Hello world.", "Hello world.", []), + ("capitalize word.", "Word.", []), + ("the end full stop", "the end.", []), + ("scratch that previous text.", "previous text.", ["delete_last"]), + ] + for input_text, expected_text, expected_actions in cases: + with self.subTest(input_text=input_text): + result, actions = self.processor.process_text(input_text) + self.assertEqual(result, expected_text) + self.assertEqual(actions, expected_actions) + + def test_punctuation_only_segment_is_not_eaten(self) -> None: + """A segment that is only punctuation stays text, not a command.""" + result, actions = self.processor.process_text("...") + self.assertEqual(result, "...") + self.assertEqual(actions, []) diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index baf770d..87a3cbe 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -698,6 +698,59 @@ class TestIBusTextInjector(unittest.TestCase): with self.assertRaises(RuntimeError): IBusTextInjector() + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir") + @patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH") + @patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=True) + @patch("vocalinux.text_injection.ibus_engine.sync_xwayland_layout_from_gnome") + @patch( + "vocalinux.text_injection.ibus_engine.get_current_xkb_layout", + return_value=("", "", ""), + ) + def test_newline_only_text_reaches_engine_socket( + self, + mock_xkb: MagicMock, + mock_sync_xwayland: MagicMock, + mock_active: MagicMock, + mock_socket_path: MagicMock, + mock_ensure_dir: MagicMock, + ) -> None: + """A bare newline from "new line" goes to the engine socket (#955).""" + mock_socket_path.exists.return_value = True + + from vocalinux.text_injection.ibus_engine import IBusTextInjector + + injector = IBusTextInjector(auto_activate=False) + + sent = {} + + class FakeSock: + def settimeout(self, _timeout: float) -> None: + pass + + def connect(self, path: str) -> None: + sent["path"] = path + + def sendall(self, data: bytes) -> None: + sent["data"] = data + + def recv(self, _n: int) -> bytes: + return b"OK" + + def __enter__(self) -> "FakeSock": + return self + + def __exit__(self, *args: object) -> bool: + return False + + with patch( + "vocalinux.text_injection.ibus_engine.socket.socket", + return_value=FakeSock(), + ): + self.assertTrue(injector.inject_text("\n")) + + self.assertEqual(sent["data"], b"\n") + @patch("vocalinux.text_injection.ibus_engine.stop_engine_process") @patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value=None) @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) diff --git a/tests/test_main.py b/tests/test_main.py index cd5450c..f6cbfe3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1729,6 +1729,65 @@ class TestMainCallbackTrailingSpaceEdges(unittest.TestCase): boot.stack.close() +class TestMainCallbackLineBreakCommands(unittest.TestCase): + """Standalone line-break segments through the real main() callback (#955). + + "new line" / "new paragraph" expand to leading newlines, which the old + bare ``lstrip()`` destroyed — the segment was dropped or only its stray + punctuation survived. Normalization now strips only spaces/tabs. + """ + + def test_standalone_newline_is_injected(self) -> None: + """A segment that is only a line break reaches the injector verbatim.""" + boot = _boot_main_callbacks() + try: + boot.text_cb("\n").result(timeout=10) + boot.text_system.inject_text.assert_called_once_with("\n") + finally: + boot.stack.close() + + def test_standalone_paragraph_break_is_injected(self) -> None: + """A paragraph break reaches the injector verbatim, no trailing space.""" + boot = _boot_main_callbacks() + try: + boot.text_cb("\n\n").result(timeout=10) + boot.text_system.inject_text.assert_called_once_with("\n\n") + finally: + boot.stack.close() + + def test_leading_newline_before_text_is_preserved(self) -> None: + """A command starting a mixed segment keeps its line break.""" + boot = _boot_main_callbacks() + try: + boot.text_cb("\n hello").result(timeout=10) + boot.text_system.inject_text.assert_called_once_with("\n hello ") + finally: + boot.stack.close() + + def test_horizontal_whitespace_still_stripped(self) -> None: + """Spaces and tabs are still stripped on both sides.""" + boot = _boot_main_callbacks() + try: + boot.text_cb(" \t hello \t ").result(timeout=10) + boot.text_system.inject_text.assert_called_once_with("hello ") + finally: + boot.stack.close() + + def test_vosk_capitalize_skips_leading_newline(self) -> None: + """Vosk auto-capitalize uppercases the first letter after "\n".""" + boot = _boot_main_callbacks() + try: + boot.mock_speech.engine = "vosk" + boot.mock_config.get.side_effect = lambda section, key, *a, **k: (section, key) == ( + "text_injection", + "auto_capitalize", + ) + boot.text_cb("\nhello").result(timeout=10) + boot.text_system.inject_text.assert_called_once_with("\nHello ") + finally: + boot.stack.close() + + class TestPadRoutingCallbacks(unittest.TestCase): """Pad capture routing through the real main() callback wrappers (#726). diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index 30b4a6e..988ea76 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -1098,6 +1098,47 @@ class TestInjectText(unittest.TestCase): self.assertTrue(mock_run.called) self.assertTrue(result) + def test_inject_newline_only_segment_reaches_backend(self) -> None: + """A bare "\n" from "new line" is typed, not skipped as empty (#955).""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.X11) + with patch.object(obj, "_inject_with_xdotool") as mock_xdotool: + self.assertTrue(obj.inject_text("\n")) + mock_xdotool.assert_called_once_with("\n") + self.assertEqual(obj.last_typed_count, 1) + + def test_inject_paragraph_break_segment_reaches_backend(self) -> None: + """A bare "\n\n" from "new paragraph" is typed, not skipped (#955).""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.X11) + with patch.object(obj, "_inject_with_xdotool") as mock_xdotool: + self.assertTrue(obj.inject_text("\n\n")) + mock_xdotool.assert_called_once_with("\n\n") + self.assertEqual(obj.last_typed_count, 2) + + def test_inject_newline_segment_reaches_ibus(self) -> None: + """The IBus backend also receives a bare newline segment (#955).""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.X11_IBUS) + mock_ibus = MagicMock() + mock_ibus.inject_text.return_value = True + obj._ibus_injector = mock_ibus + self.assertTrue(obj.inject_text("\n")) + mock_ibus.inject_text.assert_called_once_with("\n") + + def test_inject_empty_string_still_skipped(self) -> None: + """A truly empty segment is still a cheap no-op.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.X11) + with patch.object(obj, "_inject_with_xdotool") as mock_xdotool: + self.assertTrue(obj.inject_text("")) + mock_xdotool.assert_not_called() + self.assertEqual(obj.last_typed_count, 0) + class TestLogWindowInfo(unittest.TestCase): def test_log_x11(self):