From 7e3399a74a209f5ffb98ac5ef4347a3773ae0766 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Wed, 24 Jun 2026 21:46:31 +0200 Subject: [PATCH] feat: add writing-preset combo to main window with bidirectional tray sync (Paket K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the writing-style preset selector directly in the main window (TEXT_IMPROVER only, hidden for other workflows) and keeps it in sync with the tray submenu and Settings dialog — single source of truth remains config.writing_preset. - app/main_window.py: _preset_combo below _workflow_combo; visible only for WorkflowType.TEXT_IMPROVER; adjustSize() on workflow change; set_preset(key) with blockSignals to avoid circular emission; _on_preset_changed() delegates to controller; setEnabled follows IDLE state - app/blitztext_linux.py: main_window_preset_changed() handler (no-op on same key, else Config+save+LLM rebuild+tray refresh); tray handler pushes changes to open window; _ensure_main_window() initialises combo from config; show_settings_dialog() re-syncs combo after dialog accept - tests/test_tray_preset_menu.py: TestMainWindowPresetSync class with 6 new GUI-gated tests (394 passed, no regressions) --- app/blitztext_linux.py | 18 ++++++- app/main_window.py | 33 +++++++++++++ tests/test_tray_preset_menu.py | 89 +++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 9fd0a18..845fd76 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -869,8 +869,21 @@ def _on_writing_preset_selected(self, key: str) -> None: self.config.save() self.llm_service = self._build_llm_service() self.update_menu_availability() + if self._main_window is not None: + self._main_window.set_preset(key) logger.info("Writing preset changed via tray: %s", key) + def main_window_preset_changed(self, key: str) -> None: + """Vom Hauptfenster aufgerufen, wenn die Preset-Combo geändert wird.""" + 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() + self._refresh_preset_menu() + logger.info("Writing preset changed via main window: %s", key) + def start_hotkey_worker(self) -> None: self.stop_hotkey_worker() @@ -904,8 +917,10 @@ def show_settings_dialog(self) -> None: self.llm_service = self._build_llm_service() self._refresh_i18n_texts() self.update_menu_availability() - # Preset kann im Dialog geändert worden sein -> Häkchen angleichen. + # Preset kann im Dialog geändert worden sein -> Häkchen + Combo angleichen. self._refresh_preset_menu() + if self._main_window is not None: + self._main_window.set_preset(self.config.writing_preset) # Restart hotkey listener if mode or key changed if self.hotkey_worker and ( @@ -1167,6 +1182,7 @@ def _ensure_main_window(self) -> MainWindow: if self._history_panel is not None: window.set_history_count(self._history_panel.entry_count) window.update_state(self.state, self.current_workflow, self._tray_error_message) + window.set_preset(self.config.writing_preset) self._main_window = window return self._main_window diff --git a/app/main_window.py b/app/main_window.py index 78089c0..a0a58ca 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -30,6 +30,7 @@ from app.llm_service import WorkflowType, LLM_WORKFLOWS from app.i18n import t from app import theme +from app.writing_presets import WRITING_PRESET_KEYS # Reihenfolge der Workflows in der Auswahl _WORKFLOW_ORDER = [ @@ -180,8 +181,18 @@ def _setup_ui(self) -> None: self._workflow_combo.setMinimumHeight(28) for wf in _WORKFLOW_ORDER: self._workflow_combo.addItem(t(f"workflow.{wf.value}.name"), userData=wf) + self._workflow_combo.currentIndexChanged.connect(self._on_workflow_changed) layout.addWidget(self._workflow_combo) + # Schreibstil-Preset-Auswahl (nur sichtbar bei Blitztext+) + self._preset_combo = QComboBox() + self._preset_combo.setMinimumHeight(28) + for key in WRITING_PRESET_KEYS: + self._preset_combo.addItem(t(f"preset.{key}.name"), userData=key) + self._preset_combo.currentIndexChanged.connect(self._on_preset_changed) + self._preset_combo.setVisible(False) + layout.addWidget(self._preset_combo) + # Hero: runder Record-Shutter self._btn_toggle = RecordButton() self._btn_toggle.clicked.connect(self._on_toggle_clicked) @@ -264,6 +275,18 @@ def _selected_workflow(self) -> WorkflowType: wf = self._workflow_combo.currentData() return wf if isinstance(wf, WorkflowType) else WorkflowType.TRANSCRIPTION + @pyqtSlot() + def _on_workflow_changed(self) -> None: + is_text_improver = self._selected_workflow() == WorkflowType.TEXT_IMPROVER + self._preset_combo.setVisible(is_text_improver) + self.adjustSize() + + @pyqtSlot() + def _on_preset_changed(self) -> None: + key = self._preset_combo.currentData() + if key: + self._controller.main_window_preset_changed(key) + @pyqtSlot() def _on_toggle_clicked(self) -> None: self._controller.gui_toggle_recording(self._selected_workflow()) @@ -293,6 +316,7 @@ def update_state(self, state: str, workflow: Optional[WorkflowType], error: Opti ) self._btn_discard.setEnabled(recording) self._workflow_combo.setEnabled(state == "IDLE") + self._preset_combo.setEnabled(state == "IDLE") if error: self._set_status(t("mainwindow.status.error"), theme.STATE_ERROR) @@ -343,6 +367,15 @@ def set_dictation_checked(self, checked: bool) -> None: self._btn_dictation.setChecked(checked) self._btn_dictation.blockSignals(False) + def set_preset(self, key: str) -> None: + """Setzt den Preset-Combo auf ``key`` ohne Signal auszulösen.""" + self._preset_combo.blockSignals(True) + for i in range(self._preset_combo.count()): + if self._preset_combo.itemData(i) == key: + self._preset_combo.setCurrentIndex(i) + break + self._preset_combo.blockSignals(False) + def _update_timer_label(self) -> None: if self._rec_start is None: return diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py index d162e56..2caea7e 100644 --- a/tests/test_tray_preset_menu.py +++ b/tests/test_tray_preset_menu.py @@ -1,4 +1,5 @@ -"""Tests für das Tray-Submenu „Schreibstil-Vorlage" (Paket F). +"""Tests für das Tray-Submenu „Schreibstil-Vorlage" (Paket F) und die +bidirektionale Sync Tray ↔ Hauptfenster (Paket K). Deckt das Zusammenspiel von Preset-Auswahl im Tray, Config-Persistenz und LLM-Service-Neuaufbau ab: @@ -6,6 +7,7 @@ * 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. +* Tray-Auswahl spiegelt sich im Hauptfenster-Combo (und umgekehrt). GUI-gated über ``WHISPER_GUI_TESTS=1``: die echte ``BlitztextApp`` baut Tray + QActionGroup auf und benötigt eine (Offscreen-)QApplication, analog zu @@ -136,3 +138,88 @@ def test_submenu_enabled_when_llm_available(self, tray_app, monkeypatch): tray_app.update_menu_availability() assert tray_app.menu_preset.isEnabled() is True + + +@gui_only +class TestMainWindowPresetSync: + """Bidirektionale Sync Tray ↔ Hauptfenster-Preset-Combo (Paket K).""" + + def _open_window(self, tray_app): + """Öffnet das Hauptfenster und gibt es zurück.""" + tray_app.show_main_window() + return tray_app._main_window + + def test_window_init_syncs_config_preset(self, tray_app): + """Beim Öffnen spiegelt der Combo den gespeicherten Preset.""" + target = _other_key(tray_app.config.writing_preset) + tray_app.config.writing_preset = target + + window = self._open_window(tray_app) + + assert window._preset_combo.currentData() == target + + def test_tray_change_updates_main_window(self, tray_app): + """Tray-Auswahl aktualisiert den Preset-Combo im Hauptfenster.""" + window = self._open_window(tray_app) + target = _other_key(tray_app.config.writing_preset) + + tray_app._on_writing_preset_selected(target) + + assert window._preset_combo.currentData() == target + + def test_main_window_change_updates_tray(self, tray_app): + """Preset-Änderung im Hauptfenster setzt das Tray-Häkchen.""" + self._open_window(tray_app) + target = _other_key(tray_app.config.writing_preset) + + tray_app.main_window_preset_changed(target) + + assert tray_app.preset_actions[target].isChecked() is True + assert tray_app.config.writing_preset == target + + def test_main_window_change_noop_on_same_preset(self, tray_app): + """Erneutes Setzen desselben Presets aus dem Hauptfenster ist No-Op.""" + self._open_window(tray_app) + current = tray_app.config.writing_preset + old_service = tray_app.llm_service + + tray_app.main_window_preset_changed(current) + + assert tray_app.llm_service is old_service + assert not tray_app.config.config_file.is_file() + + def test_settings_save_syncs_main_window_combo(self, tray_app, monkeypatch): + """Settings-Save mit geändertem Preset gleicht auch den Combo an.""" + from PyQt6.QtWidgets import QDialog + import app.blitztext_linux as mod + + window = self._open_window(tray_app) + target = _other_key(tray_app.config.writing_preset) + + class _FakeDialog: + def __init__(self, config): + config.writing_preset = target + + def exec(self): + return QDialog.DialogCode.Accepted + + monkeypatch.setattr(mod, "SettingsDialog", _FakeDialog) + tray_app.show_settings_dialog() + + assert window._preset_combo.currentData() == target + + def test_preset_combo_visible_only_for_text_improver(self, tray_app): + """Preset-Combo im Hauptfenster ist nur bei Blitztext+ sichtbar.""" + from app.workflows import WorkflowType + + window = self._open_window(tray_app) + + # Standard-Workflow ist TRANSCRIPTION → Combo unsichtbar + for i in range(window._workflow_combo.count()): + wf = window._workflow_combo.itemData(i) + window._workflow_combo.setCurrentIndex(i) + expected = wf == WorkflowType.TEXT_IMPROVER + assert window._preset_combo.isVisible() == expected, ( + f"Workflow {wf}: Combo sichtbar={window._preset_combo.isVisible()}, " + f"erwartet={expected}" + )