From e19278e12b95cabb0a34dbd514fb0a8b9de907d1 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sat, 20 Jun 2026 02:07:57 +0200 Subject: [PATCH 1/2] feat: Schreibstil-Presets als Tray-Submenu (Paket F) --- app/blitztext_linux.py | 52 +++++++++++++- tests/test_tray_preset_menu.py | 122 +++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 tests/test_tray_preset_menu.py diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 6295181..0c7e669 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -16,7 +16,7 @@ from PyQt6.QtCore import QObject, Qt, QThread, QThreadPool, QRunnable, QUrl, pyqtSignal, pyqtSlot from PyQt6.QtGui import ( - QAction, QBrush, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap, + QAction, QActionGroup, QBrush, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap, ) from PyQt6.QtWidgets import ( QApplication, QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, @@ -32,7 +32,7 @@ from app.config import Config, VALID_HOTKEY_KEYS from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError -from app.writing_presets import WRITING_PRESETS, WRITING_PRESET_KEYS, preset_index +from app.writing_presets import WRITING_PRESETS, WRITING_PRESET_KEYS, get_preset, preset_index from app.hotkey_service import HotkeyWorker from app.audio_recorder import AudioRecorder, AudioRecorderError from app.transcribe import transcribe, TranscribeError @@ -676,6 +676,24 @@ def setup_tray(self) -> None: self.action_emoji.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.EMOJI_TEXT)) self.menu.addAction(self.action_emoji) + # Submenu: Schreibstil-Vorlage für Blitztext+ (Text-Verbesserer). + # Exklusive, abhakbare Auswahl gespeist aus dem Preset-Katalog; die + # Vorauswahl spiegelt die persistierte config.writing_preset wider. + self.menu_preset = self.menu.addMenu("✨ Schreibstil-Vorlage") + self.preset_action_group = QActionGroup(self) + self.preset_action_group.setExclusive(True) + self.preset_actions: dict[str, QAction] = {} + for key in WRITING_PRESET_KEYS: + preset_action = QAction(WRITING_PRESETS[key].display_name, self) + preset_action.setCheckable(True) + preset_action.triggered.connect( + lambda _checked=False, preset_key=key: self._on_writing_preset_selected(preset_key) + ) + self.preset_action_group.addAction(preset_action) + self.menu_preset.addAction(preset_action) + self.preset_actions[key] = preset_action + self._refresh_preset_menu() + self.menu.addSeparator() # Diktat-Modus (Toggle): sammelt Transkripte als Notizen @@ -738,6 +756,34 @@ def update_menu_availability(self) -> None: self.action_dampf.setEnabled(available) self.action_emoji.setEnabled(available) + def _refresh_preset_menu(self) -> None: + """Spiegelt die aktuelle ``config.writing_preset`` im Preset-Submenu. + + Gemeinsamer Helper für Init (``setup_tray``) und Settings-Save: setzt das + Häkchen auf den gespeicherten Preset. ``get_preset`` liefert immer einen + gültigen Schlüssel (Fallback auf ``standard``), sodass auch ein unbekannt + gewordener Config-Wert eine konsistente Auswahl ergibt. + """ + current_key = get_preset(self.config.writing_preset).key + action = self.preset_actions.get(current_key) + if action is not None: + action.setChecked(True) + + def _on_writing_preset_selected(self, key: str) -> None: + """Übernimmt die im Tray gewählte Schreibstil-Vorlage. + + Persistiert den neuen Preset und baut den LLM-Service neu, damit + Blitztext+ ab sofort mit dem gewählten Stil arbeitet. Ein erneutes + Auswählen des bereits aktiven Presets ist ein No-Op (kein Disk-Write). + """ + if key == self.config.writing_preset: + return + self.config.writing_preset = key + self.config.save() + self.llm_service = self._build_llm_service() + self.update_menu_availability() + logger.info("Writing preset changed via tray: %s", key) + def start_hotkey_worker(self) -> None: self.stop_hotkey_worker() @@ -770,6 +816,8 @@ def show_settings_dialog(self) -> None: # Update LLM Service parameters from saved configuration self.llm_service = self._build_llm_service() self.update_menu_availability() + # Preset kann im Dialog geändert worden sein -> Häkchen angleichen. + self._refresh_preset_menu() # Restart hotkey listener if mode or key changed if self.hotkey_worker and ( diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py new file mode 100644 index 0000000..d4ccbe6 --- /dev/null +++ b/tests/test_tray_preset_menu.py @@ -0,0 +1,122 @@ +"""Tests für das Tray-Submenu „Schreibstil-Vorlage" (Paket F). + +Deckt das Zusammenspiel von Preset-Auswahl im Tray, Config-Persistenz und +LLM-Service-Neuaufbau ab: + +* Auswahl im Submenu persistiert den Preset und baut den LLMService neu. +* Das Menü spiegelt jederzeit die ``config.writing_preset`` (Häkchen). +* Ein Settings-Save mit geändertem Preset gleicht das Häkchen wieder an. + +GUI-gated über ``WHISPER_GUI_TESTS=1``: die echte ``BlitztextApp`` baut Tray + +QActionGroup auf und benötigt eine (Offscreen-)QApplication, analog zu +``tests/test_state_machine.py``. +""" +from __future__ import annotations + +import os + +import pytest + +from app.config import BlitztextConfig +from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS + +_GUI = os.environ.get("WHISPER_GUI_TESTS") == "1" +gui_only = pytest.mark.skipif(not _GUI, reason="benötigt WHISPER_GUI_TESTS=1 (Display)") + + +@pytest.fixture +def tray_app(tmp_path): + """Echte App mit isolierter Config (tmp), ohne evdev-Thread. + + Die Config wird nach dem Boot auf ein tmp-Verzeichnis umgehängt, damit der + Handler ``config.save()`` nie in die echte User-Config schreibt. Das + Preset-Menü wird danach neu synchronisiert. + """ + from PyQt6.QtWidgets import QApplication + from app.blitztext_linux import BlitztextApp + + qapp = QApplication.instance() or QApplication([]) + app = BlitztextApp(qapp) + app.stop_hotkey_worker() # kein echter evdev-Thread im Test + + app.config = BlitztextConfig(config_dir=tmp_path / ".config" / "blitztext-linux") + app._refresh_preset_menu() + yield app + app.stop_hotkey_worker() + + +def _other_key(current: str) -> str: + """Liefert einen vom aktuellen verschiedenen, gültigen Preset-Schlüssel.""" + return next(k for k in WRITING_PRESET_KEYS if k != current) + + +@gui_only +class TestPresetMenu: + def test_handler_persists_and_rebuilds_service(self, tray_app): + """Auswahl im Tray: Config gesetzt + gespeichert + Service neu gebaut.""" + target = _other_key(tray_app.config.writing_preset) + old_service = tray_app.llm_service + + tray_app._on_writing_preset_selected(target) + + assert tray_app.config.writing_preset == target + # Auf Platte persistiert: frische Instanz liest denselben Preset. + reloaded = BlitztextConfig(config_dir=tray_app.config.config_dir) + assert reloaded.writing_preset == target + # Service wurde neu konstruiert. + assert tray_app.llm_service is not old_service + + def test_handler_noop_when_same_preset(self, tray_app): + """Erneute Auswahl des aktiven Presets schreibt nicht erneut (No-Op).""" + current = tray_app.config.writing_preset + old_service = tray_app.llm_service + + tray_app._on_writing_preset_selected(current) + + assert tray_app.config.writing_preset == current + assert tray_app.llm_service is old_service + # Kein Disk-Write erfolgt -> keine Config-Datei angelegt. + assert not tray_app.config.config_file.is_file() + + def test_menu_mirrors_config(self, tray_app): + """Das Häkchen folgt der Config nach ``_refresh_preset_menu``.""" + target = _other_key(tray_app.config.writing_preset) + tray_app.config.writing_preset = target + + tray_app._refresh_preset_menu() + + assert tray_app.preset_actions[target].isChecked() is True + for key, action in tray_app.preset_actions.items(): + if key != target: + assert action.isChecked() is False + + def test_refresh_falls_back_to_standard_for_unknown(self, tray_app): + """Ein nicht im Menü vorhandener Wert wählt den Standard-Preset.""" + # Direkter Eingriff am Backing-Store umgeht die Setter-Validierung, + # um einen "verwaisten" Config-Wert zu simulieren. + tray_app.config._data["workflows"]["writing_preset"] = "gibt_es_nicht" + + tray_app._refresh_preset_menu() + + assert tray_app.preset_actions[DEFAULT_PRESET_KEY].isChecked() is True + + def test_settings_save_updates_check(self, tray_app, monkeypatch): + """Settings-Save mit geändertem Preset gleicht das Häkchen an.""" + from PyQt6.QtWidgets import QDialog + import app.blitztext_linux as mod + + target = _other_key(tray_app.config.writing_preset) + + class _FakeDialog: + def __init__(self, config): + # Dialog "speichert" den neuen Preset in dieselbe Config. + config.writing_preset = target + + def exec(self): + return QDialog.DialogCode.Accepted + + monkeypatch.setattr(mod, "SettingsDialog", _FakeDialog) + + tray_app.show_settings_dialog() + + assert tray_app.preset_actions[target].isChecked() is True From e759dd019c6aa5dc36fd426b6bfd8aa5f78bac3c Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sat, 20 Jun 2026 02:11:26 +0200 Subject: [PATCH 2/2] feat: Preset-Submenu deaktivieren ohne nutzbaren LLM-Dienst Ergaenzt das in e19278e eingefuehrte Tray-Submenu "Schreibstil-Vorlage" um die Verfuegbarkeitskopplung: update_menu_availability deaktiviert das Submenu zusammen mit den LLM-Workflows (Blitztext+/$%&!/:)), da die Schreibstil-Vorlage nur den Text-Verbesserer beeinflusst. - app/blitztext_linux.py: menu_preset.setEnabled(available). - tests/test_tray_preset_menu.py: +2 Faelle (disabled/enabled je nach is_available), jetzt 7 Tests; volle Suite 229 gruen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019c7crwbrY9q5pUeZxAEMBg --- app/blitztext_linux.py | 3 +++ tests/test_tray_preset_menu.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 0c7e669..0e5e8f8 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -755,6 +755,9 @@ def update_menu_availability(self) -> None: self.action_improver.setEnabled(available) self.action_dampf.setEnabled(available) self.action_emoji.setEnabled(available) + # Schreibstil-Vorlage wirkt nur auf Blitztext+ (Text-Verbesserer); ohne + # nutzbaren LLM-Dienst wird das Submenu mitdeaktiviert. + self.menu_preset.setEnabled(available) def _refresh_preset_menu(self) -> None: """Spiegelt die aktuelle ``config.writing_preset`` im Preset-Submenu. diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py index d4ccbe6..d162e56 100644 --- a/tests/test_tray_preset_menu.py +++ b/tests/test_tray_preset_menu.py @@ -120,3 +120,19 @@ def exec(self): tray_app.show_settings_dialog() assert tray_app.preset_actions[target].isChecked() is True + + def test_submenu_disabled_when_llm_unavailable(self, tray_app, monkeypatch): + """Ohne nutzbaren LLM-Dienst wird das Preset-Submenu deaktiviert.""" + monkeypatch.setattr(tray_app.llm_service, "is_available", lambda: False) + + tray_app.update_menu_availability() + + assert tray_app.menu_preset.isEnabled() is False + + def test_submenu_enabled_when_llm_available(self, tray_app, monkeypatch): + """Mit nutzbarem LLM-Dienst ist das Preset-Submenu auswählbar.""" + monkeypatch.setattr(tray_app.llm_service, "is_available", lambda: True) + + tray_app.update_menu_availability() + + assert tray_app.menu_preset.isEnabled() is True