diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index b1c4978..f2a9e68 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2507,10 +2507,17 @@ class ModelDownloadDialog(Gtk.Dialog): self.show_all() self._pulse_timeout = None + # A destroyed dialog must not leave its pulse timer firing: the + # timeout retains the widget and keeps calling back into it. + self.connect("destroy", self._on_destroy) # For Whisper, we can't track progress, so pulse if engine == "whisper": self._start_pulsing() + def _on_destroy(self, *_args: object) -> None: + """Stop the pulse timer when the dialog goes away.""" + self._stop_pulsing() + def _start_pulsing(self) -> None: """Bounce the bar, for work that reports no measurable progress.""" if self._pulse_timeout is None: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 56148aa..a373e84 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -1131,9 +1131,11 @@ class TrayIndicator: self.speech_engine.set_download_progress_callback(None) self.speech_engine.end_download() - GLib.idle_add(dialog.set_complete, True, "") - # The dialog shows its own completion; starting the transcription - # as soon as the model lands keeps the flow to a single click. + # The transcript dialog that opens next is the completion + # affordance here: the download dialog is modal, so leaving it + # open behind the transcript keeps its grab and makes the + # transcript's buttons unclickable until it is found and closed. + GLib.idle_add(dialog.destroy) _idle_once(self._start_file_transcription, path) threading.Thread(target=run, daemon=True, name="tdrz-download").start() diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 5104364..866a437 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -1166,3 +1166,44 @@ class TestAppIndicatorImportFallback(unittest.TestCase): import vocalinux.ui.tray_indicator as tray self.assertIs(tray.AppIndicator3, sentinel) + + +class TestTdrzDownloadDialog(TestTrayIndicator): + """The transcribe-path TinyDiarize download dialog must release its modal + grab once the model lands: the transcript dialog opening behind it would + otherwise be unclickable until the hidden dialog is found and closed.""" + + def _run_download( + self, download_side_effect: Exception | None = None + ) -> tuple[MagicMock, MagicMock]: + from vocalinux.ui.tray_indicator import Gtk + + mock_prompt = MagicMock() + mock_prompt.run.return_value = Gtk.ResponseType.OK + mock_dialog = MagicMock() + mock_dialog.cancelled = False + self.mock_speech_engine.try_begin_download.return_value = True + self.mock_speech_engine.download_whispercpp_model.side_effect = download_side_effect + + with ( + patch("vocalinux.ui.tray_indicator.Gtk.MessageDialog", return_value=mock_prompt), + patch("vocalinux.ui.tray_indicator.ModelDownloadDialog", return_value=mock_dialog), + patch.object(self.tray_indicator, "_start_file_transcription") as mock_start, + ): + self.tray_indicator._offer_tdrz_download("/tmp/clip.wav") + run = self.mock_thread_class.call_args.kwargs["target"] + run() + return mock_dialog, mock_start + + def test_success_destroys_modal_dialog(self) -> None: + mock_dialog, mock_start = self._run_download() + mock_dialog.destroy.assert_called_once() + mock_dialog.set_complete.assert_not_called() + mock_start.assert_called_once_with("/tmp/clip.wav") + + def test_failure_keeps_dialog_for_the_error(self) -> None: + mock_dialog, mock_start = self._run_download(Exception("network down")) + mock_dialog.set_complete.assert_called_once() + self.assertFalse(mock_dialog.set_complete.call_args[0][0]) + mock_dialog.destroy.assert_not_called() + mock_start.assert_not_called()