From 8a9682b28f5bc218545f7e538f6aa7fc17b8ba43 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Tue, 23 Jun 2026 01:24:57 +0200 Subject: [PATCH] feat: add compose draft window --- app/blitztext_linux.py | 31 +++ app/compose_window.py | 445 +++++++++++++++++++++++++++++++++++ app/i18n.py | 30 +++ app/llm_service.py | 50 ++-- app/paste_service.py | 6 +- tests/test_compose_window.py | 248 +++++++++++++++++++ tests/test_i18n.py | 21 ++ tests/test_llm_service.py | 19 ++ tests/test_smoke_launch.py | 49 ++++ tests/test_state_machine.py | 26 ++ 10 files changed, 907 insertions(+), 18 deletions(-) create mode 100644 app/compose_window.py create mode 100644 tests/test_compose_window.py diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index d06e4d7..51bd141 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -38,6 +38,7 @@ from app.transcribe import transcribe, TranscribeError from app.paste_service import PasteService, PasteServiceError from app.history_panel import HistoryPanel +from app.compose_window import ComposeWindow from app.tts_window import TtsWindow from app.main_window import MainWindow from app.i18n import LANGUAGES, LANGUAGE_DISPLAY_NAMES, set_language, t @@ -615,6 +616,7 @@ def __init__(self, app: QApplication) -> None: # Diktat-/Verlauf-/TTS-Zustand self._dictation_mode = False self._history_panel: Optional[HistoryPanel] = None + self._compose_window: Optional[ComposeWindow] = None self._tts_window: Optional[TtsWindow] = None self._main_window: Optional[MainWindow] = None @@ -676,6 +678,9 @@ def setup_tray(self) -> None: self.action_show_window = QAction(f"🪟 {t('tray.show_window')}", self) self.action_show_window.triggered.connect(self.show_main_window) self.menu.addAction(self.action_show_window) + self.action_compose = QAction(f"✍ {t('tray.compose')}", self) + self.action_compose.triggered.connect(lambda _checked=False: self.show_compose_window()) + self.menu.addAction(self.action_compose) self.menu.addSeparator() # Actions für die fünf Workflows @@ -787,6 +792,8 @@ def _refresh_i18n_texts(self) -> None: self.app.setApplicationName(t("app.name")) if hasattr(self, "action_show_window"): self.action_show_window.setText(f"🪟 {t('tray.show_window')}") + if hasattr(self, "action_compose"): + self.action_compose.setText(f"✍ {t('tray.compose')}") if hasattr(self, "action_transcription"): self.action_transcription.setText(f"{t('workflow.transcription.name')}\tMeta+H") if hasattr(self, "action_local"): @@ -805,6 +812,9 @@ def _refresh_i18n_texts(self) -> None: self._refresh_preset_menu() if self._main_window is not None: self._main_window.setWindowTitle(t("app.name")) + compose_window = getattr(self, "_compose_window", None) + if compose_window is not None: + compose_window.retranslate_ui() self.update_tray_state() def _refresh_preset_menu(self) -> None: @@ -1091,6 +1101,25 @@ def show_tts_window(self) -> None: self._tts_window.raise_() self._tts_window.activateWindow() + def _ensure_compose_window(self) -> ComposeWindow: + if self._compose_window is None: + window = ComposeWindow(self.llm_service, self.paste_service) + try: + from app import theme + window.setWindowIcon(theme.create_app_icon()) + except Exception: # pragma: no cover - rein kosmetisch + pass + self._compose_window = window + return self._compose_window + + def show_compose_window(self, text: str = "") -> None: + window = self._ensure_compose_window() + if text: + window.set_input_text(text) + window.show() + window.raise_() + window.activateWindow() + def _on_tts_closed(self, _result: int) -> None: self._tts_window = None @@ -1203,6 +1232,8 @@ def quit_app(self) -> None: if self._history_panel is not None: self._history_panel.close() self._history_panel = None + if self._compose_window is not None: + self._compose_window.close() if self._main_window is not None: self._main_window.hide() self._main_window = None diff --git a/app/compose_window.py b/app/compose_window.py new file mode 100644 index 0000000..12fb44c --- /dev/null +++ b/app/compose_window.py @@ -0,0 +1,445 @@ +"""Compose window for manual text rewriting.""" +from __future__ import annotations + +import logging +from typing import Optional + +from PyQt6.QtCore import QObject, QThread, Qt, pyqtSignal, pyqtSlot +from PyQt6.QtGui import QCloseEvent, QKeySequence, QShortcut +from PyQt6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) + +from app.i18n import t +from app.llm_service import LLMService +from app.paste_service import PasteService, PasteServiceError +from app.workflows import WorkflowType +from app.writing_presets import WRITING_PRESET_KEYS, preset_index + +logger = logging.getLogger("blitztext.compose") + +COMPOSE_WORKFLOW_ORDER: tuple[WorkflowType, ...] = ( + WorkflowType.TEXT_IMPROVER, + WorkflowType.DAMPF_ABLASSEN, + WorkflowType.EMOJI_TEXT, +) + + +def _scrub_secret(text: str, secret: str) -> str: + if secret and text: + return text.replace(secret, "***") + return text + + +class _ComposeWorker(QObject): + """Background worker for manual text rewriting.""" + + finished = pyqtSignal(str) + error = pyqtSignal(str) + + def __init__( + self, + service: LLMService, + workflow: WorkflowType, + text: str, + writing_preset: str, + ) -> None: + super().__init__() + self._service = service + self._workflow = workflow + self._text = text + self._writing_preset = writing_preset + self._cancelled = False + + def request_cancel(self) -> None: + self._cancelled = True + + @pyqtSlot() + def run(self) -> None: + try: + if self._cancelled or QThread.currentThread().isInterruptionRequested(): + return + result = self._service.rewrite_text( + self._workflow, + self._text, + writing_preset=self._writing_preset, + ) + if self._cancelled or QThread.currentThread().isInterruptionRequested(): + return + self.finished.emit(result) + except Exception as exc: + if self._cancelled: + return + secret = getattr(self._service, "api_key", "") + self.error.emit(_scrub_secret(str(exc), secret)) + + +class ComposeWindow(QDialog): + """Modeless dialog for composing text through existing LLM workflows.""" + + def __init__( + self, + llm_service: LLMService, + paste_service: PasteService, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(parent) + self._llm_service = llm_service + self._paste_service = paste_service + self._worker_thread: Optional[QThread] = None + self._worker: Optional[_ComposeWorker] = None + self._detached_threads: list[QThread] = [] + self._busy = False + self._shortcuts: list[QShortcut] = [] + + self.setWindowTitle(t("compose.window_title")) + self.setMinimumSize(600, 500) + self.resize(760, 600) + + self._setup_ui() + self.retranslate_ui() + self._sync_state() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(10) + + header_row = QHBoxLayout() + header_row.setSpacing(8) + + self.lblWorkflow = QLabel() + header_row.addWidget(self.lblWorkflow) + + self.cmbWorkflow = QComboBox() + self.cmbWorkflow.setMinimumWidth(160) + header_row.addWidget(self.cmbWorkflow, 1) + + self.lblPreset = QLabel() + header_row.addWidget(self.lblPreset) + + self.cmbPreset = QComboBox() + self.cmbPreset.setMinimumWidth(180) + header_row.addWidget(self.cmbPreset, 1) + + self.chkVoiceRouting = QCheckBox() + self.chkVoiceRouting.setEnabled(False) + self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) + # Future hook only; phase I-1 must not alter the existing transcription path. + header_row.addWidget(self.chkVoiceRouting) + + header_row.addStretch(1) + layout.addLayout(header_row) + + self.splitMain = QSplitter(Qt.Orientation.Vertical) + self.splitMain.setChildrenCollapsible(False) + + input_panel = QWidget() + input_layout = QVBoxLayout(input_panel) + input_layout.setContentsMargins(0, 0, 0, 0) + input_layout.setSpacing(6) + + self.lblInput = QLabel() + input_layout.addWidget(self.lblInput) + + self.txtInput = QPlainTextEdit() + self.txtInput.textChanged.connect(self._sync_state) + input_layout.addWidget(self.txtInput, 1) + + action_row = QHBoxLayout() + action_row.setSpacing(8) + + self.btnAction = QPushButton() + self.btnAction.setMinimumWidth(140) + self.btnAction.clicked.connect(self._on_improve_clicked) + action_row.addWidget(self.btnAction) + + self.lblStatus = QLabel() + self.lblStatus.setVisible(False) + self.lblStatus.setWordWrap(True) + action_row.addWidget(self.lblStatus, 1) + input_layout.addLayout(action_row) + + output_panel = QWidget() + output_layout = QVBoxLayout(output_panel) + output_layout.setContentsMargins(0, 0, 0, 0) + output_layout.setSpacing(6) + + self.lblOutput = QLabel() + output_layout.addWidget(self.lblOutput) + + self.txtOutput = QPlainTextEdit() + self.txtOutput.textChanged.connect(self._sync_state) + output_layout.addWidget(self.txtOutput, 1) + + footer_row = QHBoxLayout() + footer_row.setSpacing(8) + footer_row.addStretch(1) + + self.btnCopy = QPushButton() + self.btnCopy.clicked.connect(self._on_copy_clicked) + footer_row.addWidget(self.btnCopy) + + self.btnPaste = QPushButton() + self.btnPaste.clicked.connect(self._on_paste_clicked) + footer_row.addWidget(self.btnPaste) + + self.btnClose = QPushButton() + self.btnClose.clicked.connect(self.close) + footer_row.addWidget(self.btnClose) + + output_layout.addLayout(footer_row) + + self.splitMain.addWidget(input_panel) + self.splitMain.addWidget(output_panel) + self.splitMain.setStretchFactor(0, 3) + self.splitMain.setStretchFactor(1, 2) + self.splitMain.setSizes([330, 240]) + layout.addWidget(self.splitMain, 1) + + self._install_shortcuts() + self._populate_workflow_combo() + self._populate_preset_combo() + + def _install_shortcuts(self) -> None: + self._add_shortcut(self.txtInput, "Ctrl+Return", self._on_improve_clicked) + self._add_shortcut(self.txtInput, "Ctrl+Enter", self._on_improve_clicked) + self._add_shortcut(self.txtOutput, "Ctrl+Return", self._on_paste_clicked) + self._add_shortcut(self.txtOutput, "Ctrl+Enter", self._on_paste_clicked) + self._add_shortcut(self.txtOutput, "Ctrl+Shift+V", self._on_paste_clicked) + + def _add_shortcut(self, parent: QWidget, sequence: str, callback) -> None: + shortcut = QShortcut(QKeySequence(sequence), parent) + shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) + shortcut.activated.connect(callback) + self._shortcuts.append(shortcut) + + def _populate_workflow_combo(self, selected: Optional[WorkflowType] = None) -> None: + if selected is None and hasattr(self, "cmbWorkflow"): + selected = self._selected_workflow() + self.cmbWorkflow.blockSignals(True) + self.cmbWorkflow.clear() + for workflow in COMPOSE_WORKFLOW_ORDER: + self.cmbWorkflow.addItem(t(f"workflow.{workflow.value}.name"), workflow) + target = selected or COMPOSE_WORKFLOW_ORDER[0] + index = self.cmbWorkflow.findData(target) + self.cmbWorkflow.setCurrentIndex(index if index >= 0 else 0) + self.cmbWorkflow.blockSignals(False) + + def _populate_preset_combo(self, selected: Optional[str] = None) -> None: + if selected is None and hasattr(self, "cmbPreset"): + selected = self._selected_preset() + self.cmbPreset.blockSignals(True) + self.cmbPreset.clear() + for key in WRITING_PRESET_KEYS: + self.cmbPreset.addItem(t(f"preset.{key}.name"), key) + target = selected or preset_index(self._llm_service.writing_preset) + if isinstance(target, str): + index = self.cmbPreset.findData(target) + else: + index = int(target) + self.cmbPreset.setCurrentIndex(index if index >= 0 else preset_index(self._llm_service.writing_preset)) + self.cmbPreset.blockSignals(False) + + def _selected_workflow(self) -> WorkflowType: + workflow = self.cmbWorkflow.currentData() + return workflow if isinstance(workflow, WorkflowType) else COMPOSE_WORKFLOW_ORDER[0] + + def _selected_preset(self) -> str: + preset = self.cmbPreset.currentData() + if isinstance(preset, str) and preset: + return preset + return self._llm_service.writing_preset + + def _has_input(self) -> bool: + return bool(self.txtInput.toPlainText().strip()) + + def _has_output(self) -> bool: + return bool(self.txtOutput.toPlainText().strip()) + + def _show_status(self, text: str, *, error: bool = False) -> None: + self.lblStatus.setText(text) + self.lblStatus.setVisible(bool(text)) + if error: + self.lblStatus.setStyleSheet("color: #f44336;") + else: + self.lblStatus.setStyleSheet("") + + def _hide_status(self) -> None: + self.lblStatus.clear() + self.lblStatus.setVisible(False) + self.lblStatus.setStyleSheet("") + + def _set_busy(self, busy: bool, *, keep_status: bool = False) -> None: + self._busy = busy + self.txtInput.setReadOnly(busy) + if busy: + self.btnAction.setEnabled(False) + self.btnCopy.setEnabled(False) + self.btnPaste.setEnabled(False) + self._show_status(t("compose.status.processing")) + else: + if not keep_status: + self._hide_status() + self._sync_state() + + def _sync_state(self) -> None: + if self._busy: + self.btnAction.setEnabled(False) + self.btnCopy.setEnabled(False) + self.btnPaste.setEnabled(False) + return + self.btnAction.setEnabled(self._has_input()) + has_output = self._has_output() + self.btnCopy.setEnabled(has_output) + self.btnPaste.setEnabled(has_output) + + def set_input_text(self, text: str) -> None: + self.txtInput.setPlainText(text) + self.txtOutput.clear() + self._hide_status() + self._sync_state() + + def retranslate_ui(self) -> None: + """Refresh visible text to the active UI language.""" + current_workflow = self._selected_workflow() + current_preset = self._selected_preset() + + self.setWindowTitle(t("compose.window_title")) + self.lblWorkflow.setText(t("compose.workflow.label")) + self.lblPreset.setText(t("compose.preset.label")) + self.chkVoiceRouting.setText(t("compose.voice_routing.label")) + self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) + self.lblInput.setText(t("compose.input.label")) + self.lblOutput.setText(t("compose.output.label")) + self.btnAction.setText(t("compose.button.improve")) + self.btnCopy.setText(t("compose.button.copy")) + self.btnPaste.setText(t("compose.button.insert_close")) + self.btnClose.setText(t("compose.button.close")) + + self._populate_workflow_combo(current_workflow) + self._populate_preset_combo(current_preset) + + if self._busy: + self._show_status(t("compose.status.processing")) + elif not self.lblStatus.text(): + self._hide_status() + + self._sync_state() + + def _start_worker(self, text: str) -> None: + workflow = self._selected_workflow() + writing_preset = self._selected_preset() + + thread = QThread(self) + worker = _ComposeWorker(self._llm_service, workflow, text, writing_preset) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_worker_result) + worker.error.connect(self._on_worker_error) + worker.finished.connect(thread.quit) + worker.error.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._on_worker_thread_finished) + self._worker_thread = thread + self._worker = worker + self._set_busy(True) + thread.start() + + def _cleanup_worker_state(self) -> None: + self._worker = None + self._worker_thread = None + + def _detach_worker_thread(self) -> None: + worker = self._worker + thread = self._worker_thread + if worker is not None: + worker.request_cancel() + if thread is not None: + thread.requestInterruption() + thread.quit() + if not thread.wait(1500): + try: + thread.setParent(None) + except Exception: + pass + thread._detached_worker = worker # type: ignore[attr-defined] + self._detached_threads.append(thread) + thread.finished.connect(lambda t=thread: self._on_detached_thread_finished(t)) + self._cleanup_worker_state() + + def _on_detached_thread_finished(self, thread: QThread) -> None: + self._detached_threads = [t for t in self._detached_threads if t is not thread] + thread.deleteLater() + + @pyqtSlot() + def _on_improve_clicked(self) -> None: + if self._busy: + return + text = self.txtInput.toPlainText() + if not text.strip(): + self._show_status(t("compose.status.empty_input"), error=True) + return + self._start_worker(text) + + @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._set_busy(False) + + @pyqtSlot(str) + def _on_worker_error(self, message: str) -> None: + logger.error("Compose rewrite error: %s", message) + self._show_status(message, error=True) + self._set_busy(False, keep_status=True) + + @pyqtSlot() + def _on_worker_thread_finished(self) -> None: + if self._worker_thread is not None: + self._worker_thread = None + if self._worker is not None: + self._worker = None + + @pyqtSlot() + def _on_copy_clicked(self) -> None: + text = self.txtOutput.toPlainText() + if not text.strip(): + return + try: + QApplication.clipboard().setText(text) + except Exception as exc: # pragma: no cover - defensive + self._show_status(t("compose.status.error").format(message=str(exc)), error=True) + + @pyqtSlot() + def _on_paste_clicked(self) -> None: + text = self.txtOutput.toPlainText() + if not text.strip(): + return + try: + self._paste_service.paste(text, force_autopaste=True) + except PasteServiceError as exc: + self._show_status(t("compose.status.error").format(message=str(exc)), error=True) + return + except Exception as exc: # pragma: no cover - defensive + self._show_status(t("compose.status.error").format(message=str(exc)), error=True) + return + self.close() + + def closeEvent(self, event: QCloseEvent) -> None: + if self._busy or (self._worker_thread is not None and self._worker_thread.isRunning()): + self._detach_worker_thread() + super().closeEvent(event) diff --git a/app/i18n.py b/app/i18n.py index c313823..d14bcaa 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -133,10 +133,25 @@ "history.note.merged_heading": "Diktat (zusammengefuehrt) {heading}", "history.note.merged_filename_prefix": "Diktat-zusammengefuehrt_", "tray.show_window": "Fenster anzeigen", + "tray.compose": "Text verfassen…", "tray.writing_preset": "Schreibstil-Vorlage", "tray.dictation_mode": "🎤 Diktat-Modus", "tray.history": "📋 Verlauf…", "tray.tts": "🔊 Vorlesen…", + "compose.window_title": "Text verfassen", + "compose.workflow.label": "Workflow:", + "compose.preset.label": "Schreibstil:", + "compose.voice_routing.label": "Sprachaufnahme hierher umleiten", + "compose.voice_routing.help": "TODO/Future-Hook: In Phase I-1 noch nicht verdrahtet.", + "compose.input.label": "Entwurf", + "compose.output.label": "Ergebnis", + "compose.button.improve": "Verbessern", + "compose.button.copy": "Kopieren", + "compose.button.insert_close": "Einfügen & Schließen", + "compose.button.close": "Schließen", + "compose.status.processing": "Verbessere…", + "compose.status.error": "Fehler: {message}", + "compose.status.empty_input": "Bitte zuerst einen Text eingeben.", "mainwindow.button.discard": "↺ Verwerfen", "mainwindow.button.dictation": "✎ Diktat", "mainwindow.button.history": "≡ Verlauf ({count})", @@ -292,10 +307,25 @@ "history.note.merged_heading": "Dictation (merged) {heading}", "history.note.merged_filename_prefix": "Dictation-merged_", "tray.show_window": "Show window", + "tray.compose": "Compose text…", "tray.writing_preset": "Writing style preset", "tray.dictation_mode": "🎤 Dictation mode", "tray.history": "📋 History…", "tray.tts": "🔊 Read aloud…", + "compose.window_title": "Compose Text", + "compose.workflow.label": "Workflow:", + "compose.preset.label": "Style:", + "compose.voice_routing.label": "Route voice input here", + "compose.voice_routing.help": "TODO/future hook: not wired in phase I-1 yet.", + "compose.input.label": "Draft", + "compose.output.label": "Result", + "compose.button.improve": "Improve", + "compose.button.copy": "Copy", + "compose.button.insert_close": "Insert & Close", + "compose.button.close": "Close", + "compose.status.processing": "Improving…", + "compose.status.error": "Error: {message}", + "compose.status.empty_input": "Enter some text first.", "mainwindow.button.discard": "↺ Discard", "mainwindow.button.dictation": "✎ Dictation", "mainwindow.button.history": "≡ History ({count})", diff --git a/app/llm_service.py b/app/llm_service.py index d308aae..b2a7498 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -142,6 +142,31 @@ def _custom_terms_instruction(self) -> str: + ", ".join(terms) ) + def _rewrite_for_workflow( + self, + workflow: WorkflowType, + text: str, + writing_preset: Optional[str] = None, + ) -> str: + if workflow not in LLM_WORKFLOWS: + raise LLMServiceError(f"rewrite() only allowed for LLM workflows, got {workflow!r}") + if not text or not text.strip(): + raise ValueError("text must not be empty") + + try: + if workflow == WorkflowType.DAMPF_ABLASSEN: + return self.dampf_ablassen(text, custom_system_prompt=self.dampf_system_prompt) + if workflow == WorkflowType.TEXT_IMPROVER: + preset = get_preset(writing_preset or self.writing_preset) + return self.text_improver(text, tone=self.tone, custom_prompt=preset.system_prompt) + if workflow == WorkflowType.EMOJI_TEXT: + return self.emoji_text(text, density=self.emoji_density) + raise LLMServiceError(f"Unsupported workflow: {workflow}") + except Exception as exc: + if isinstance(exc, LLMServiceError): + raise + raise LLMServiceError(f"OpenAI API-Fehler: {exc}") from exc + def dampf_ablassen(self, transcript: str, custom_system_prompt: str = "") -> str: self._check_openai() if not transcript or not transcript.strip(): @@ -212,20 +237,13 @@ def rewrite(self, workflow: WorkflowType, transcript: str) -> str: Raises: LLMServiceError: If key is missing, package missing, or API error. """ - self._check_openai() - if workflow not in LLM_WORKFLOWS: - raise LLMServiceError(f"rewrite() only allowed for LLM workflows, got {workflow!r}") + return self._rewrite_for_workflow(workflow, transcript) - try: - if workflow == WorkflowType.DAMPF_ABLASSEN: - return self.dampf_ablassen(transcript, custom_system_prompt=self.dampf_system_prompt) - if workflow == WorkflowType.TEXT_IMPROVER: - preset = get_preset(self.writing_preset) - return self.text_improver(transcript, tone=self.tone, custom_prompt=preset.system_prompt) - if workflow == WorkflowType.EMOJI_TEXT: - return self.emoji_text(transcript, density=self.emoji_density) - raise LLMServiceError(f"Unsupported workflow: {workflow}") - except Exception as exc: - if isinstance(exc, LLMServiceError): - raise - raise LLMServiceError(f"OpenAI API-Fehler: {exc}") from exc + def rewrite_text( + self, + workflow: WorkflowType, + text: str, + writing_preset: Optional[str] = None, + ) -> str: + """Direct text entry point for the compose window.""" + return self._rewrite_for_workflow(workflow, text, writing_preset=writing_preset) diff --git a/app/paste_service.py b/app/paste_service.py index 783e5fc..0b85126 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -65,11 +65,12 @@ def __init__(self, autopaste: bool = True, key_delay_ms: int = _KEY_DELAY_MS) -> self.autopaste = autopaste self.key_delay_ms = max(0, int(key_delay_ms)) - def paste(self, text: str) -> None: + def paste(self, text: str, force_autopaste: Optional[bool] = None) -> None: """Text ins Clipboard schreiben und optional einfuegen. Args: text: Der einzufuegende Text. + force_autopaste: Optionaler Override fuer den Auto-Paste-Schritt. Raises: PasteServiceError: Wenn wl-copy nicht gefunden oder hart fehlschlaegt. @@ -80,7 +81,8 @@ def paste(self, text: str) -> None: self._copy_to_clipboard(text) - if self.autopaste: + do_autopaste = self.autopaste if force_autopaste is None else bool(force_autopaste) + if do_autopaste: self._ydotool_paste() def clipboard_only(self, text: str) -> None: diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py new file mode 100644 index 0000000..a0af3c3 --- /dev/null +++ b/tests/test_compose_window.py @@ -0,0 +1,248 @@ +"""Tests for the compose window and its direct text rewrite flow.""" +from __future__ import annotations + +import os +import time + +import pytest + +from app.compose_window import ComposeWindow +from app.i18n import DEFAULT_LANGUAGE, set_language, t +from app.workflows import WorkflowType + +_GUI = os.environ.get("WHISPER_GUI_TESTS") == "1" +gui_only = pytest.mark.skipif(not _GUI, reason="benötigt WHISPER_GUI_TESTS=1 (Display)") + + +class _FakeLLMService: + def __init__(self, result: str = "OK", error: Exception | None = None) -> None: + self.result = result + self.error = error + self.calls: list[tuple[WorkflowType, str, str | None]] = [] + self.api_key = "DUMMY_COMPOSE_SECRET_TOKEN_123" + self.writing_preset = "standard" + + def rewrite_text( + self, + workflow: WorkflowType, + text: str, + writing_preset: str | None = None, + ) -> str: + self.calls.append((workflow, text, writing_preset)) + if self.error is not None: + raise self.error + return self.result + + +class _FakePasteService: + def __init__(self) -> None: + self.calls: list[tuple[str, bool | None]] = [] + + def paste(self, text: str, force_autopaste: bool | None = None) -> None: + self.calls.append((text, force_autopaste)) + + +class _FakeClipboard: + def __init__(self) -> None: + self.text = "" + + def setText(self, text: str) -> None: # noqa: N802 (Qt naming) + self.text = text + + +@pytest.fixture(autouse=True) +def reset_language(): + set_language(DEFAULT_LANGUAGE) + yield + set_language(DEFAULT_LANGUAGE) + + +@pytest.fixture +def qapp(): + from PyQt6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def compose_window(qapp): + llm = _FakeLLMService() + paste = _FakePasteService() + window = ComposeWindow(llm, paste) + window.show() + qapp.processEvents() + yield window, llm, paste + window.close() + qapp.processEvents() + + +def _wait_until(qapp, predicate, timeout_ms: int = 2500) -> None: + deadline = time.monotonic() + timeout_ms / 1000.0 + while time.monotonic() < deadline: + qapp.processEvents() + if predicate(): + return + time.sleep(0.01) + qapp.processEvents() + assert predicate(), "Timed out waiting for compose window state change" + + +@gui_only +@pytest.mark.parametrize( + ("language", "title"), + [ + ("de", "Text verfassen"), + ("en", "Compose Text"), + ], +) +def test_window_texts_follow_language(qapp, language, title): + set_language(language) + window = ComposeWindow(_FakeLLMService(), _FakePasteService()) + try: + assert window.windowTitle() == title + assert window.btnAction.text() == t("compose.button.improve") + assert window.btnCopy.text() == t("compose.button.copy") + assert window.btnPaste.text() == t("compose.button.insert_close") + assert window.btnClose.text() == t("compose.button.close") + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_window_opens_without_llm_call(compose_window): + window, llm, _paste = compose_window + + assert llm.calls == [] + assert window.btnAction.isEnabled() is False + assert window.btnCopy.isEnabled() is False + assert window.btnPaste.isEnabled() is False + assert window.lblStatus.isVisible() is False + + +@gui_only +def test_input_reaches_direct_llm_path_and_sets_output(compose_window, qapp): + window, llm, _paste = compose_window + + window.cmbWorkflow.setCurrentIndex(window.cmbWorkflow.findData(WorkflowType.TEXT_IMPROVER)) + window.cmbPreset.setCurrentIndex(window.cmbPreset.findData("email_formal")) + window.txtInput.setPlainText("Hallo Welt") + window.btnAction.click() + + _wait_until( + qapp, + lambda: not window._busy and window._worker_thread is None and window.txtOutput.toPlainText() == "OK", + ) + + assert llm.calls == [ + (WorkflowType.TEXT_IMPROVER, "Hallo Welt", "email_formal"), + ] + assert window.txtOutput.toPlainText() == "OK" + assert window.btnCopy.isEnabled() is True + assert window.btnPaste.isEnabled() is True + assert window.txtInput.isReadOnly() is False + + +@gui_only +def test_copy_writes_result_to_clipboard(compose_window, monkeypatch, qapp): + window, _llm, _paste = compose_window + from PyQt6.QtWidgets import QApplication + + clipboard = _FakeClipboard() + monkeypatch.setattr(QApplication, "clipboard", lambda: clipboard) + + window.txtOutput.setPlainText("Final result") + window.btnCopy.click() + qapp.processEvents() + + assert clipboard.text == "Final result" + + +@gui_only +def test_insert_calls_paste_service_and_closes(qapp): + llm = _FakeLLMService() + paste = _FakePasteService() + window = ComposeWindow(llm, paste) + window.show() + qapp.processEvents() + + try: + window.txtOutput.setPlainText("Final result") + window.btnPaste.click() + qapp.processEvents() + + assert paste.calls == [("Final result", True)] + assert window.isVisible() is False + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_empty_input_disables_improve(compose_window): + window, _llm, _paste = compose_window + + window.txtInput.setPlainText(" ") + assert window.btnAction.isEnabled() is False + + window.txtInput.setPlainText("Ein Satz") + assert window.btnAction.isEnabled() is True + + +@gui_only +def test_errors_are_visible_and_scrubbed(qapp): + llm = _FakeLLMService(error=RuntimeError("boom DUMMY_COMPOSE_SECRET_TOKEN_123")) + 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.lblStatus.isVisible() is True + assert "DUMMY_COMPOSE_SECRET_TOKEN_123" not in window.lblStatus.text() + assert "boom" in window.lblStatus.text() + assert window.txtOutput.toPlainText() == "" + assert window.btnCopy.isEnabled() is False + assert window.btnPaste.isEnabled() is False + assert window.btnAction.isEnabled() is True + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_no_autopaste_after_llm_success(compose_window, qapp): + """LLM-Erfolg setzt das Ergebnis, löst aber KEINEN automatischen Paste aus.""" + window, _llm, paste = compose_window + + window.txtInput.setPlainText("Hallo Welt") + window.btnAction.click() + + _wait_until( + qapp, + lambda: not window._busy and window._worker_thread is None and window.txtOutput.toPlainText() == "OK", + ) + + assert window.txtOutput.toPlainText() == "OK" + assert paste.calls == [] + assert window.isVisible() is True + + +@gui_only +def test_voice_routing_checkbox_visible_and_disabled(compose_window, qapp): + """Voice-Routing-Checkbox ist vorhanden, sichtbar und deaktiviert (Future-Hook).""" + window, _llm, _paste = compose_window + + chk = window.chkVoiceRouting + assert chk is not None + assert chk.isVisible() is True + assert chk.isEnabled() is False + assert chk.text() == t("compose.voice_routing.label") diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 3acb88b..f9f5652 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -90,6 +90,27 @@ def test_tts_and_history_namespaces_seeded(self): actual_keys = set(TRANSLATIONS["de"].keys()) assert expected_keys.issubset(actual_keys), f"Fehlende Wave-B3-Keys: {expected_keys - actual_keys}" + def test_compose_namespace_seeded(self): + """Compose-Fenster bringt alle neuen sichtbaren Schlüssel mit.""" + expected_keys = { + "tray.compose", + "compose.window_title", + "compose.workflow.label", + "compose.preset.label", + "compose.voice_routing.label", + "compose.input.label", + "compose.output.label", + "compose.button.improve", + "compose.button.copy", + "compose.button.insert_close", + "compose.button.close", + "compose.status.processing", + "compose.status.error", + "compose.status.empty_input", + } + actual_keys = set(TRANSLATIONS["de"].keys()) + assert expected_keys.issubset(actual_keys), f"Fehlende Compose-Keys: {expected_keys - actual_keys}" + class TestTranslationFunction: """Test t() Funktion.""" diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 1edb856..3698673 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -144,6 +144,25 @@ def test_rewrite_routes_to_text_improver(self, service): patched.assert_called_once_with("roh", tone=service.tone, custom_prompt="") assert result == "verbessert" + def test_rewrite_text_uses_override_preset_without_mutating_service(self, mock_client): + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + writing_preset="standard", + ) + + result = service.rewrite_text( + WorkflowType.TEXT_IMPROVER, + RAW_TRANSCRIPT, + writing_preset="email_formal", + ) + + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system_message = next(m["content"] for m in messages if m["role"] == "system") + assert result == "OK" + assert WRITING_PRESETS["email_formal"].system_prompt in system_message + assert service.writing_preset == "standard" + def test_openai_error_is_wrapped(self, service): with patch.object(service, "dampf_ablassen", side_effect=RuntimeError("API Error")): with pytest.raises(LLMServiceError, match="OpenAI API-Fehler: API Error"): diff --git a/tests/test_smoke_launch.py b/tests/test_smoke_launch.py index 0c175f6..567114a 100644 --- a/tests/test_smoke_launch.py +++ b/tests/test_smoke_launch.py @@ -69,6 +69,7 @@ def test_app_boots_idles_and_exits_clean(ui_language, tmp_path): assert win._btn_tts.toolTip() == t("mainwindow.tooltip.tts") assert win._btn_settings.toolTip() == t("mainwindow.tooltip.settings") assert win._status_label.text() == t("mainwindow.status.ready") + assert app.action_compose.text() == f"✍ {t('tray.compose')}" assert app.action_dictation.text() == t("tray.dictation_mode") assert app.action_history.text() == t("tray.history") assert app.action_tts.text() == t("tray.tts") @@ -85,3 +86,51 @@ def test_app_boots_idles_and_exits_clean(ui_language, tmp_path): if app is not None: app.stop_hotkey_worker() set_language(DEFAULT_LANGUAGE) + + +@gui_only +def test_compose_dialog_lifecycle(tmp_path): + """Compose-Dialog öffnet sich, wird wiederverwendet und übersetzt sich neu.""" + from PyQt6.QtWidgets import QApplication + + from app.blitztext_linux import BlitztextApp + from app.config import Config + + config = Config.load(tmp_path / "config.json") + config.ui_language = "de" + + qapp = QApplication.instance() or QApplication([]) + app = None + try: + with patch("app.blitztext_linux.Config.load", return_value=config): + app = BlitztextApp(qapp) + app.stop_hotkey_worker() + + # Erster Aufruf: Dialog wird erstellt und ist sichtbar. + app.show_compose_window() + qapp.processEvents() + w1 = app._compose_window + assert w1 is not None + assert w1.isVisible() is True + + # Wiederholter Aufruf: dieselbe Instanz wird wiederverwendet. + app.show_compose_window() + qapp.processEvents() + assert app._compose_window is w1 + + # Deutschsprachige Texte korrekt. + set_language("de") + w1.retranslate_ui() + qapp.processEvents() + assert "verfassen" in w1.windowTitle().lower() + + # Nach Sprachenwechsel auf EN werden übersetzte Texte gezeigt. + set_language("en") + w1.retranslate_ui() + qapp.processEvents() + assert "Compose" in w1.windowTitle() + + finally: + if app is not None: + app.stop_hotkey_worker() + set_language(DEFAULT_LANGUAGE) diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index a3d34ec..d662db6 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -96,6 +96,32 @@ def side_effect(cmd, *args, **kwargs): assert "ydotoold nicht verfügbar" in caplog.text + def test_force_autopaste_override_enables_ydotool(self): + """force_autopaste=True überschreibt autopaste=False: Clipboard + ydotool.""" + svc = PasteService(autopaste=False) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/tool"), \ + patch("app.paste_service.time.sleep"), \ + patch("app.paste_service.subprocess.run") as run_mock: + def side_effect(cmd, *args, **kwargs): + return subprocess.CompletedProcess(cmd, 0, b"", b"") + run_mock.side_effect = side_effect + svc.paste("hallo welt", force_autopaste=True) + assert run_mock.call_count == 2 + cmd_names = [call.args[0][0] for call in run_mock.call_args_list] + assert any(name in ("wl-copy", "xclip") for name in cmd_names) + assert "ydotool" in cmd_names + + def test_autopaste_false_without_override_skips_ydotool(self): + """autopaste=False ohne force_autopaste: nur Clipboard-Write, kein ydotool.""" + svc = PasteService(autopaste=False) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/tool"), \ + patch("app.paste_service.subprocess.run") as run_mock: + run_mock.return_value = subprocess.CompletedProcess([], 0, b"", b"") + svc.paste("hallo welt") + assert run_mock.call_count == 1 + cmd_names = [call.args[0][0] for call in run_mock.call_args_list] + assert "ydotool" not in cmd_names + # --------------------------------------------------------------------------- # Hilfen: minimales Fake-evdev fuer den HotkeyWorker-Event-Loop