Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -737,6 +755,37 @@ 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.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle tray preset save failures

When the config directory is unwritable or the atomic write fails, Config.save() raises from this QAction slot after the QAction has already become checked but before the LLM service is rebuilt. In that environment the tray can show a newly selected preset that was neither persisted nor applied, with only an uncaught Qt-slot exception instead of the error handling used by the settings dialog; catch the save error and refresh/revert the menu state or notify the user.

Useful? React with 👍 / 👎.

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()
Expand Down Expand Up @@ -770,6 +819,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 (
Expand Down
138 changes: 138 additions & 0 deletions tests/test_tray_preset_menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""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

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