diff --git a/app/compose_window.py b/app/compose_window.py index 12fb44c..31e8f41 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -34,6 +34,10 @@ WorkflowType.EMOJI_TEXT, ) +# In-memory ring buffer of successful generations for the current window +# session. Oldest variants are trimmed once the cap is exceeded. +MAX_COMPOSE_VARIANTS = 10 + def _scrub_secret(text: str, secret: str) -> str: if secret and text: @@ -101,6 +105,9 @@ def __init__( self._detached_threads: list[QThread] = [] self._busy = False self._shortcuts: list[QShortcut] = [] + # In-memory variant history (one entry per successful generation). + self._variants: list[str] = [] + self._variant_index: int = -1 self.setWindowTitle(t("compose.window_title")) self.setMinimumSize(600, 500) @@ -175,11 +182,31 @@ def _setup_ui(self) -> None: output_layout.setContentsMargins(0, 0, 0, 0) output_layout.setSpacing(6) + output_header = QHBoxLayout() + output_header.setSpacing(8) + self.lblOutput = QLabel() - output_layout.addWidget(self.lblOutput) + output_header.addWidget(self.lblOutput) + + output_header.addStretch(1) + + self.btnPrev = QPushButton("◀") + self.btnPrev.setMaximumWidth(40) + self.btnPrev.clicked.connect(self._on_prev_variant) + output_header.addWidget(self.btnPrev) + + self.lblVariantCounter = QLabel() + output_header.addWidget(self.lblVariantCounter) + + self.btnNext = QPushButton("▶") + self.btnNext.setMaximumWidth(40) + self.btnNext.clicked.connect(self._on_next_variant) + output_header.addWidget(self.btnNext) + + output_layout.addLayout(output_header) self.txtOutput = QPlainTextEdit() - self.txtOutput.textChanged.connect(self._sync_state) + self.txtOutput.textChanged.connect(self._on_output_text_changed) output_layout.addWidget(self.txtOutput, 1) footer_row = QHBoxLayout() @@ -294,6 +321,7 @@ def _set_busy(self, busy: bool, *, keep_status: bool = False) -> None: self._sync_state() def _sync_state(self) -> None: + self._update_variant_nav() if self._busy: self.btnAction.setEnabled(False) self.btnCopy.setEnabled(False) @@ -304,9 +332,73 @@ def _sync_state(self) -> None: self.btnCopy.setEnabled(has_output) self.btnPaste.setEnabled(has_output) + def _update_variant_nav(self) -> None: + total = len(self._variants) + has_variants = total > 0 + self.btnPrev.setVisible(has_variants) + self.btnNext.setVisible(has_variants) + self.lblVariantCounter.setVisible(has_variants) + if not has_variants: + self.lblVariantCounter.setText(t("compose.variant.none")) + self.btnPrev.setEnabled(False) + self.btnNext.setEnabled(False) + return + self.lblVariantCounter.setText( + t("compose.variant.counter").format( + current=self._variant_index + 1, total=total + ) + ) + at_border_start = self._variant_index <= 0 + at_border_end = self._variant_index >= total - 1 + self.btnPrev.setEnabled(not self._busy and not at_border_start) + self.btnNext.setEnabled(not self._busy and not at_border_end) + + def _set_output_guarded(self, text: str) -> None: + """Set the output field without registering it as a manual edit.""" + self.txtOutput.blockSignals(True) + try: + self.txtOutput.setPlainText(text) + finally: + self.txtOutput.blockSignals(False) + + def _append_variant(self, text: str) -> None: + self._variants.append(text) + if len(self._variants) > MAX_COMPOSE_VARIANTS: + self._variants.pop(0) + self._variant_index = len(self._variants) - 1 + self._set_output_guarded(text) + + def _show_current_variant(self) -> None: + self._set_output_guarded(self._variants[self._variant_index]) + self._sync_state() + + @pyqtSlot() + def _on_prev_variant(self) -> None: + if self._busy or self._variant_index <= 0: + return + self._variant_index -= 1 + self._show_current_variant() + + @pyqtSlot() + def _on_next_variant(self) -> None: + if self._busy or self._variant_index >= len(self._variants) - 1: + return + self._variant_index += 1 + self._show_current_variant() + + @pyqtSlot() + def _on_output_text_changed(self) -> None: + # A genuine manual edit updates the active variant in place; guarded + # programmatic updates (navigation/generation) never reach this slot. + if 0 <= self._variant_index < len(self._variants): + self._variants[self._variant_index] = self.txtOutput.toPlainText() + self._sync_state() + def set_input_text(self, text: str) -> None: self.txtInput.setPlainText(text) - self.txtOutput.clear() + self._variants = [] + self._variant_index = -1 + self._set_output_guarded("") self._hide_status() self._sync_state() @@ -326,6 +418,8 @@ def retranslate_ui(self) -> None: self.btnCopy.setText(t("compose.button.copy")) self.btnPaste.setText(t("compose.button.insert_close")) self.btnClose.setText(t("compose.button.close")) + self.btnPrev.setToolTip(t("compose.variant.prev")) + self.btnNext.setToolTip(t("compose.variant.next")) self._populate_workflow_combo(current_workflow) self._populate_preset_combo(current_preset) @@ -396,9 +490,7 @@ def _on_improve_clicked(self) -> None: @pyqtSlot(str) def _on_worker_result(self, result_text: str) -> None: logger.info("Compose rewrite success (%d chars)", len(result_text)) - self.txtOutput.blockSignals(True) - self.txtOutput.setPlainText(result_text) - self.txtOutput.blockSignals(False) + self._append_variant(result_text) self._set_busy(False) @pyqtSlot(str) diff --git a/app/i18n.py b/app/i18n.py index d14bcaa..4aea72b 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -152,6 +152,10 @@ "compose.status.processing": "Verbessere…", "compose.status.error": "Fehler: {message}", "compose.status.empty_input": "Bitte zuerst einen Text eingeben.", + "compose.variant.counter": "Variante {current} / {total}", + "compose.variant.prev": "Vorherige Variante", + "compose.variant.next": "Nächste Variante", + "compose.variant.none": "Keine Varianten", "mainwindow.button.discard": "↺ Verwerfen", "mainwindow.button.dictation": "✎ Diktat", "mainwindow.button.history": "≡ Verlauf ({count})", @@ -326,6 +330,10 @@ "compose.status.processing": "Improving…", "compose.status.error": "Error: {message}", "compose.status.empty_input": "Enter some text first.", + "compose.variant.counter": "Variant {current} / {total}", + "compose.variant.prev": "Previous variant", + "compose.variant.next": "Next variant", + "compose.variant.none": "No variants", "mainwindow.button.discard": "↺ Discard", "mainwindow.button.dictation": "✎ Dictation", "mainwindow.button.history": "≡ History ({count})", diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index a0af3c3..9bcccf0 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -6,8 +6,8 @@ import pytest -from app.compose_window import ComposeWindow -from app.i18n import DEFAULT_LANGUAGE, set_language, t +from app.compose_window import MAX_COMPOSE_VARIANTS, ComposeWindow +from app.i18n import DEFAULT_LANGUAGE, missing_keys, set_language, t from app.workflows import WorkflowType _GUI = os.environ.get("WHISPER_GUI_TESTS") == "1" @@ -246,3 +246,256 @@ def test_voice_routing_checkbox_visible_and_disabled(compose_window, qapp): assert chk.isVisible() is True assert chk.isEnabled() is False assert chk.text() == t("compose.voice_routing.label") + + +# --------------------------------------------------------------------------- +# Paket I-2: Varianten-Verlauf +# --------------------------------------------------------------------------- + + +def _run_generation(window, llm, qapp, text: str, result: str) -> None: + """Trigger one successful LLM generation and wait for completion.""" + before = len(llm.calls) + llm.result = result + window.txtInput.setPlainText(text) + window.btnAction.click() + _wait_until( + qapp, + lambda: len(llm.calls) > before + and not window._busy + and window._worker_thread is None, + ) + _wait_until(qapp, lambda: window.txtOutput.toPlainText() == result) + + +def _counter(current: int, total: int) -> str: + return t("compose.variant.counter").format(current=current, total=total) + + +@gui_only +def test_first_generation_creates_single_variant(compose_window, qapp): + window, llm, _paste = compose_window + + _run_generation(window, llm, qapp, "Eingabe", "Erstes Ergebnis") + + assert window._variants == ["Erstes Ergebnis"] + assert window._variant_index == 0 + assert window.txtOutput.toPlainText() == "Erstes Ergebnis" + assert window.lblVariantCounter.isVisible() is True + assert window.lblVariantCounter.text() == _counter(1, 1) + assert window.btnPrev.isEnabled() is False + assert window.btnNext.isEnabled() is False + + +@gui_only +def test_second_generation_appends_and_moves_index_to_end(compose_window, qapp): + window, llm, _paste = compose_window + + _run_generation(window, llm, qapp, "A", "Var 1") + _run_generation(window, llm, qapp, "B", "Var 2") + + assert window._variants == ["Var 1", "Var 2"] + assert window._variant_index == 1 + assert window.txtOutput.toPlainText() == "Var 2" + assert window.lblVariantCounter.text() == _counter(2, 2) + + +@gui_only +def test_navigation_updates_output_counter_and_button_state(compose_window, qapp): + window, llm, _paste = compose_window + + _run_generation(window, llm, qapp, "A", "Var 1") + _run_generation(window, llm, qapp, "B", "Var 2") + + # At the end: next disabled, prev enabled. + assert window.btnNext.isEnabled() is False + assert window.btnPrev.isEnabled() is True + + window.btnPrev.click() + qapp.processEvents() + assert window._variant_index == 0 + assert window.txtOutput.toPlainText() == "Var 1" + assert window.lblVariantCounter.text() == _counter(1, 2) + assert window.btnPrev.isEnabled() is False + assert window.btnNext.isEnabled() is True + + window.btnNext.click() + qapp.processEvents() + assert window._variant_index == 1 + assert window.txtOutput.toPlainText() == "Var 2" + assert window.lblVariantCounter.text() == _counter(2, 2) + assert window.btnNext.isEnabled() is False + + +@gui_only +def test_ring_buffer_trims_oldest_variant(compose_window, qapp): + window, llm, _paste = compose_window + + for i in range(MAX_COMPOSE_VARIANTS + 1): + _run_generation(window, llm, qapp, f"In {i}", f"V{i}") + + assert len(window._variants) == MAX_COMPOSE_VARIANTS + # Oldest ("V0") trimmed, newest at the end. + assert window._variants[0] == "V1" + assert window._variants[-1] == f"V{MAX_COMPOSE_VARIANTS}" + assert window._variant_index == MAX_COMPOSE_VARIANTS - 1 + assert window.lblVariantCounter.text() == _counter( + MAX_COMPOSE_VARIANTS, MAX_COMPOSE_VARIANTS + ) + + +@gui_only +def test_copy_and_paste_use_displayed_variant_after_navigation(qapp, monkeypatch): + from PyQt6.QtWidgets import QApplication + + llm = _FakeLLMService() + paste = _FakePasteService() + window = ComposeWindow(llm, paste) + window.show() + qapp.processEvents() + clipboard = _FakeClipboard() + monkeypatch.setattr(QApplication, "clipboard", lambda: clipboard) + + try: + _run_generation(window, llm, qapp, "A", "Var 1") + _run_generation(window, llm, qapp, "B", "Var 2") + + window.btnPrev.click() + qapp.processEvents() + assert window.txtOutput.toPlainText() == "Var 1" + + window.btnCopy.click() + qapp.processEvents() + assert clipboard.text == "Var 1" + + window.btnPaste.click() + qapp.processEvents() + assert paste.calls[-1] == ("Var 1", True) + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_set_input_text_clears_variant_history(compose_window, qapp): + window, llm, _paste = compose_window + + _run_generation(window, llm, qapp, "A", "Var 1") + _run_generation(window, llm, qapp, "B", "Var 2") + assert len(window._variants) == 2 + + window.set_input_text("Neuer Kontext") + + assert window._variants == [] + assert window._variant_index == -1 + assert window.txtOutput.toPlainText() == "" + assert window.lblVariantCounter.isVisible() is False + assert window.btnPrev.isVisible() is False + assert window.btnNext.isVisible() is False + + +@gui_only +def test_error_run_creates_no_variant(qapp): + llm = _FakeLLMService(error=RuntimeError("boom")) + paste = _FakePasteService() + window = ComposeWindow(llm, paste) + window.show() + qapp.processEvents() + + try: + window.txtInput.setPlainText("Bitte umschreiben") + window.btnAction.click() + _wait_until( + qapp, + lambda: not window._busy + and window._worker_thread is None + and bool(window.lblStatus.text()), + ) + + assert window._variants == [] + assert window._variant_index == -1 + assert window.lblVariantCounter.isVisible() is False + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_manual_edit_persists_in_place_across_navigation(compose_window, qapp): + window, llm, _paste = compose_window + + _run_generation(window, llm, qapp, "A", "Var 1") + _run_generation(window, llm, qapp, "B", "Var 2") + + # Manual edit of the currently displayed (second) variant. + window.txtOutput.setPlainText("Var 2 editiert") + qapp.processEvents() + assert window._variants[1] == "Var 2 editiert" + + window.btnPrev.click() + qapp.processEvents() + assert window.txtOutput.toPlainText() == "Var 1" + + window.btnNext.click() + qapp.processEvents() + assert window.txtOutput.toPlainText() == "Var 2 editiert" + assert window._variants[0] == "Var 1" + + +@gui_only +def test_navigation_disabled_while_busy(qapp): + import threading + + release = threading.Event() + + class _BlockingLLM(_FakeLLMService): + def rewrite_text(self, workflow, text, writing_preset=None): + self.calls.append((workflow, text, writing_preset)) + release.wait(2.0) + return self.result + + llm = _BlockingLLM(result="Var 2") + paste = _FakePasteService() + window = ComposeWindow(llm, paste) + window.show() + qapp.processEvents() + + try: + _run_generation(window, llm, qapp, "A", "Var 1") + assert window.btnPrev.isEnabled() is False # single variant border + + # Start a second, blocking generation. + release.clear() + window.txtInput.setPlainText("B") + window.btnAction.click() + _wait_until(qapp, lambda: window._busy) + + assert window._busy is True + assert window.btnPrev.isEnabled() is False + assert window.btnNext.isEnabled() is False + + release.set() + _wait_until( + qapp, + lambda: not window._busy and window._worker_thread is None, + ) + finally: + release.set() + window.close() + qapp.processEvents() + + +@gui_only +@pytest.mark.parametrize("language", ["de", "en"]) +def test_variant_i18n_keys_present_and_complete(qapp, language): + set_language(language) + window = ComposeWindow(_FakeLLMService(), _FakePasteService()) + try: + assert missing_keys() == set() + counter = t("compose.variant.counter").format(current=1, total=2) + assert "1" in counter and "2" in counter + assert t("compose.variant.prev") != "compose.variant.prev" + assert t("compose.variant.next") != "compose.variant.next" + finally: + window.close() + qapp.processEvents()