diff --git a/.claude/skills/gui-usability-review/SKILL.md b/.claude/skills/gui-usability-review/SKILL.md new file mode 100644 index 0000000..9778c31 --- /dev/null +++ b/.claude/skills/gui-usability-review/SKILL.md @@ -0,0 +1,51 @@ +--- +name: gui-usability-review +description: Reproducible usability review of the Blitztext main window - offscreen clicks, screenshots, hard accessibility checks and a blind LLM rating. Use after changing app/main_window.py, theme.py or popup/toolbar layout. +--- + +# GUI usability review + +## Purpose +Measure how quickly a user reaches common goals in the Qt main window and catch +accessibility defects, without a desktop session. + +## Trigger +Any change to `app/main_window.py`, `app/theme.py`, toolbar/popup controls or the +controller methods the window calls. + +## Inputs +- `scripts/gui_usability_check.py` (task list `TASKS`, click budgets) +- `tests/test_gui_usability.py` (hard gates) + +## Procedure +1. Run the driver: `QT_QPA_PLATFORM=offscreen .venv/bin/python scripts/gui_usability_check.py`. + It prints a fresh temp directory containing `report.json` and one PNG per click step; copy what you want to keep. +2. Hard gates (no LLM): `WHISPER_GUI_TESTS=1 QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest -q tests/test_gui_usability.py`. + Click budgets, goal reached, accessible names, keyboard focus, minimum target 22 px. +3. Blind LLM rating, at least 3 independent runs per task. The reviewer gets ONLY + the task text and the PNG sequence of that task - never code, never this file's + results. Use a fresh subagent per run. +4. Reviewer output, JSON only: + `{"task": id, "score": 1-5, "findings": [{"widget": "...", "severity": "low|medium|high", "issue": "..."}], "suggestion": "..."}` +5. Score = mean over runs. Store under `.omc/uebergabe/gui-review//`, compare with the frozen baseline. + +## Rubric (score 1-5 per task) +Findability of the control, number of steps, label clarity, feedback after the +action (is the new state visible?), error/edge handling, target size and contrast. +5 = obvious and immediate, 3 = works but needs hunting, 1 = most users fail. + +## Verification +Hard gates green; mean score not below the baseline; no high-severity finding unaddressed. + +## Rollback / abort +The driver only reads the UI with a fake controller; nothing to roll back. Abort the +review if the screenshots are blank (check `QT_QPA_PLATFORM=offscreen`). + +## Known pitfalls +- A drop-down counts as two clicks (open, choose); the driver sets the combo index directly, so budgets are assumed clicks, not measured ones. +- Offscreen renders no native Wayland decorations or real fonts; judge layout, not font metrics. +- Setting a combo to its current value emits no signal; set a different state first. +- Reviewer prompts must never include the source, or the rating is no longer blind. + +## References +`app/main_window.py`, `scripts/gui_usability_check.py`, Nielsen's 10 usability heuristics. diff --git a/README.de.md b/README.de.md index 20edda4..bdc43ab 100644 --- a/README.de.md +++ b/README.de.md @@ -209,7 +209,7 @@ Das Hauptfenster ist ein kompaktes, rahmenloses Desktop-Widget mit einheitlicher

Aktions-Menü
- Workflows und Schnellzugriff direkt am Widget. + Details zur gewählten Seite: Aktion, Stil, KI-Anbieter, Whisper-Modell. @@ -220,12 +220,13 @@ Das Hauptfenster ist ein kompaktes, rahmenloses Desktop-Widget mit einheitlicher - **Bereit (IDLE):** Grüner Statuspunkt und Zeitanzeige (`Bereit: 00:00`). - **Aufnahme (RECORDING):** Gelber Statuspunkt, sekundengenaue Zeitanzeige und animierte Wellenbalken links und rechts des Mikrofons. - **Verarbeitung (TRANSCRIBING):** Rotierender goldener Kreisbogen um das Mikrofon (`Transkribiere…`). +- **Umschalter Lokal | Mit KI:** Zwei Knöpfe direkt unter dem Mikrofon. **Lokal** = nur lokale Whisper-Transkription, nichts verlässt den Rechner. **Mit KI** = lokal transkribieren, danach bearbeitet dein Cloud-KI-Anbieter den Text. Die gewählte Seite ist gold; die Zustandszeile darunter (zum Beispiel `Lokal · Whisper base` oder `Mit KI · OpenAI · Verbessern`) zeigt, was die nächste Aufnahme tut, und öffnet per Klick das Detail-Popup. Hotkey-Workflows stellen den Umschalter auf die passende Seite, während der Aufnahme ist er gesperrt. - **Kompakte Werkzeugleiste:** - ✍ **Compose-Button** (hervorgehobenes Stift-Symbol): Öffnet das Entwurfsfenster für Texteingabe und KI-Verfeinerung. - 🕒 **Verlauf-Button** (mit Zähler-Badge): Öffnet den Transkript- und Diktat-Verlauf. - 🔊 **Vorlesen-Button** (Lautsprecher-Symbol): Öffnet das TTS-Fenster zur Sprachausgabe. - - ⚙ **Einstellungen-Button** (Zahnrad): Öffnet den Konfigurations-Dialog. - - ⋯ **Mehr-Button**: Öffnet das Popup-Menü für *Diktieren & verbessern*, Text-Aktionen (*Kürzen*, *Ausformulieren*, *Tonfall ändern*), *Eigene Anweisung*, *Sammel-Diktat* und *Verwerfen*. + - ⋯ **Mehr-Button** (gleiches Popup wie die Zustandszeile): Zeigt nur die Felder, die zur gewählten Seite passen — *Lokal*: Whisper-Modell; *Mit KI*: Aktion (*Diktieren & verbessern*, *Sachlich formulieren*, *Emojis ergänzen*), Schreibstil für *Diktieren & verbessern* (*Kürzen*, *Ausformulieren*, *Tonfall ändern*, *Eigene Anweisung*), KI-Anbieter (OpenAI oder OpenRouter; einen eigenen Endpunkt änderst du nur in den Einstellungen) und Whisper-Modell. Ein **Fertig**-Knopf schließt das Popup; *Sammel-Diktat* und *Verwerfen* stehen darunter. Der Anbieterwechsel braucht die passende API-Key-Variable, größere Whisper-Modelle fragen vorher, weil sie heruntergeladen werden können. +- **Einstellungs-Zahnrad:** Kleiner ⚙-Knopf in der Titelleiste neben Minimieren und Schließen; öffnet den Konfigurations-Dialog. *Das Widget öffnet sich beim Start sowie über den Tray-Eintrag **Fenster anzeigen** oder einen Klick auf das Tray-Icon. Schließen blendet das Widget aus — die App läuft im Hintergrund im Tray weiter.* diff --git a/README.md b/README.md index 56dbdb9..534a62d 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ The main window is a compact, frameless desktop widget with a consistent dark lo

Options menu
- Workflows and quick actions. + Details for the chosen side: action, style, AI provider, Whisper model. @@ -219,12 +219,13 @@ The main window is a compact, frameless desktop widget with a consistent dark lo - **Idle:** Green status dot with duration counter (`Ready: 00:00`). - **Recording:** Amber indicator, live recording timer, and animated stereo wave bars flanking the mic. - **Processing:** Rotating gold progress ring around the microphone (`Transcribing…`). +- **Local | With AI switch:** Two buttons right under the microphone. **Local** = local Whisper transcription only, nothing leaves your machine. **With AI** = local transcription, then the text is edited by your cloud AI provider. The chosen side is gold; the state line below it (for example `Local · Whisper base` or `With AI · OpenAI · Improve`) shows what the next recording does and opens the details popup when clicked. Hotkey workflows move the switch to the matching side, and it is locked while recording. - **Tool row:** - ✍ **Compose button** (highlighted pen icon): Opens the Compose window for drafting and rewriting. - 🕒 **History button** (with counter badge): Opens the transcript history with recent entries. - 🔊 **Read aloud button** (speaker icon): Opens the TTS window to synthesize speech. - - ⚙ **Settings button** (gear icon): Opens the settings dialog. - - ⋯ **More options button**: Pops up quick access to *Dictate & improve*, text improvement presets (*Shorten*, *Expand*, *Change tone*), *Custom instruction*, *Batch dictation*, and *Discard*. + - ⋯ **More options button** (same popup as the state line): Shows only the fields that apply to the chosen side — *Local*: Whisper model; *With AI*: action (*Dictate & improve*, *Make it factual*, *Add emojis*), the writing style for *Dictate & improve* (*Shorten*, *Expand*, *Change tone*, *Custom instruction*), the AI provider (OpenAI or OpenRouter; a custom endpoint is changed in the settings only), and the Whisper model. A **Done** button closes the popup; *Batch dictation* and *Discard* sit below it. Switching the AI provider needs the matching API key variable; larger Whisper models ask before they may be downloaded. +- **Settings gear:** Small ⚙ button in the title bar next to minimize and close; opens the settings dialog. *The widget opens at startup, via the tray entry **Show window**, or by clicking the tray icon. Closing only hides the widget — the app keeps running in the background tray.* diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 2824375..4bc40e3 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -1067,6 +1067,95 @@ def main_window_preset_changed(self, key: str) -> None: self._refresh_preset_menu() logger.info("Writing preset changed via main window: %s", key) + def main_window_provider_changed(self, provider: str) -> None: + """Switch between OpenAI and OpenRouter from the main window. + + A custom endpoint is only changed in the settings dialog: leaving it here would + overwrite the user's URL and key variable, and entering it here could pair a stale + URL with an OpenAI key. Without a usable key the switch is refused. + """ + cfg = self.config + if provider == cfg.llm_provider: + return + previous = (cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env) + problem = None + if provider == "custom" or cfg.llm_provider == "custom": + problem = (t("settings.save_error.title"), t("mainwindow.provider.custom_in_settings.message")) + else: + if cfg.llm_provider == "openai": + # Remember a personal OpenAI key variable so the way back restores it. + self._openai_key_env_memo = cfg.openai_api_key_env + cfg.llm_provider = provider + if provider == "openrouter": + cfg.llm_base_url = "https://openrouter.ai/api/v1" + cfg.openai_api_key_env = "OPENROUTER_API_KEY" + else: + cfg.llm_base_url = "" + cfg.openai_api_key_env = getattr( + self, "_openai_key_env_memo", DEFAULTS["openai_api_key_env"] + ) + if not cfg.resolve_llm_api_key(): + problem = ( + t("mainwindow.provider.no_key.title"), + t("mainwindow.provider.no_key.message").format(env=cfg.openai_api_key_env), + ) + if problem is None: + try: + cfg.save() + except Exception as exc: + logger.error("Failed to save LLM provider: %s", exc) + problem = ( + t("settings.save_error.title"), + t("settings.save_error.message").format(error=exc), + ) + if problem is not None: + cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env = previous + if self._main_window is not None: + self._main_window.set_llm_provider(cfg.llm_provider) + self.show_tray_warning(*problem) + return + self._rebuild_llm_service() + self.update_menu_availability() + if self._main_window is not None: + self._main_window.set_llm_provider(provider) + logger.info("LLM provider changed via main window: %s", provider) + + def _confirm_model_download(self, model: str) -> bool: + if self._main_window is not None: + # The selector popup is a Qt.Popup; a modal box over it would be unreliable. + self._main_window._options_popup.hide() + answer = QMessageBox.question( + self._main_window, + t("mainwindow.model.download.title"), + t("mainwindow.model.download.message").format(model=model), + ) + return answer == QMessageBox.StandardButton.Yes + + def main_window_model_changed(self, model: str) -> None: + """Switch the Whisper model; larger models ask first because they may download.""" + cfg = self.config + if model == cfg.model: + return + previous = cfg.model + if model not in ("tiny", "base") and not self._confirm_model_download(model): + if self._main_window is not None: + self._main_window.set_model(previous) + return + cfg.model = model + try: + cfg.save() + except Exception as exc: + cfg.model = previous + if self._main_window is not None: + self._main_window.set_model(previous) + logger.error("Failed to save model: %s", exc) + self.show_tray_warning( + t("settings.save_error.title"), + t("settings.save_error.message").format(error=exc), + ) + return + logger.info("Whisper model changed via main window: %s", model) + def start_hotkey_worker(self) -> None: self.stop_hotkey_worker() @@ -1120,6 +1209,8 @@ def show_settings_dialog(self) -> None: self._refresh_preset_menu() if self._main_window is not None: self._main_window.set_preset(self.config.writing_preset) + self._main_window.set_llm_provider(self.config.llm_provider) + self._main_window.set_model(self.config.model) # Restart hotkey listener if mode or key changed if self.hotkey_worker and ( @@ -1194,6 +1285,13 @@ def gui_toggle_recording(self, workflow: WorkflowType) -> None: Fallback wenn der Hotkey nicht greift oder keine Tastatur zur Hand ist. """ if self.state == "IDLE": + if workflow in LLM_WORKFLOWS and not self.llm_service.is_available(): + # Fail before speaking: otherwise the transcript is lost at the rewrite step. + self.show_tray_warning( + t("mainwindow.provider.no_key.title"), + t("mainwindow.provider.no_key.message").format(env=self.config.openai_api_key_env), + ) + return self._start_recording(workflow) elif self.state == "RECORDING": self._stop_recording_and_process() @@ -1456,6 +1554,8 @@ def _ensure_main_window(self) -> MainWindow: 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) + window.set_llm_provider(self.config.llm_provider) + window.set_model(self.config.model) self._main_window = window return self._main_window diff --git a/app/config.py b/app/config.py index 15a2531..73528c6 100644 --- a/app/config.py +++ b/app/config.py @@ -159,6 +159,14 @@ def save(self) -> None: tmp = self.config_file.with_suffix(".json.tmp") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC fd = os.open(str(tmp), flags, 0o600) + # Fix the mode before the file can replace the config: a failure after the + # replace would report an error although the new content is already live. + try: + os.fchmod(fd, 0o600) + except OSError: + os.close(fd) + tmp.unlink(missing_ok=True) + raise payload = _deep_copy_without_legacy_key(self._data) if not self._tts_env_explicit: payload.pop("tts_openai_api_key_env", None) @@ -166,7 +174,6 @@ def save(self) -> None: json.dump(payload, f, indent=2, ensure_ascii=False) f.write("\n") tmp.replace(self.config_file) - self.config_file.chmod(0o600) self._data = payload self._legacy_openai_api_key_present = False self._legacy_openai_api_key_value = "" diff --git a/app/i18n.py b/app/i18n.py index 69dc5d4..968acdc 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -200,7 +200,36 @@ "mainwindow.tooltip.edit_text": "Text eingeben und mit Schreibstilen oder Prompts bearbeiten (Strg+E)", "mainwindow.tooltip.tts": "Text vorlesen", "mainwindow.tooltip.settings": "Einstellungen", - "mainwindow.tooltip.more": "Weitere Aktionen", + "mainwindow.tooltip.more": "Schnellauswahl: Betriebsart, KI-Anbieter, Whisper-Modell", + "mainwindow.tooltip.workflow": "Betriebsart der nächsten Aufnahme", + "mainwindow.tooltip.preset": "Schreibstil für die Textverbesserung", + "mainwindow.mode.transcription": "Diktieren", + "mainwindow.mode.local": "Lokal diktieren", + "mainwindow.mode.text_improver": "Verbessern", + "mainwindow.mode.dampf_ablassen": "Sachlich", + "mainwindow.mode.emoji_text": "Emojis", + "mainwindow.mode.switch.local": "Lokal", + "mainwindow.mode.switch.ai": "Mit KI", + "mainwindow.mode.switch.local.tip": "Aufnahme per Klick: nur lokale Spracherkennung (Whisper), nichts geht an einen Cloud-Dienst. Die Hotkeys für KI-Aktionen bleiben aktiv.", + "mainwindow.mode.switch.ai.tip": "Spracherkennung lokal, danach Textbearbeitung durch einen KI-Anbieter in der Cloud", + "mainwindow.tooltip.mode_details": "Details zur gewählten Betriebsart öffnen", + "mainwindow.caption.action": "Aktion", + "mainwindow.button.done": "Fertig", + "mainwindow.caption.workflow": "Betriebsart", + "mainwindow.caption.preset": "Schreibstil", + "mainwindow.caption.provider": "KI-Anbieter (Cloud)", + "mainwindow.caption.model": "Whisper-Modell (lokal)", + "mainwindow.tooltip.provider": "KI-Anbieter für Textverbesserung", + "mainwindow.tooltip.model": "Whisper-Modell für die Spracherkennung (lokal)", + "mainwindow.provider.openai": "OpenAI", + "mainwindow.provider.openrouter": "OpenRouter", + "mainwindow.provider.custom": "Eigener Endpunkt", + "mainwindow.model.item": "{model}", + "mainwindow.provider.no_key.title": "KI-Schlüssel fehlt", + "mainwindow.provider.no_key.message": "Für diesen Anbieter ist die Umgebungsvariable {env} nicht gesetzt. Der Anbieter bleibt unverändert.", + "mainwindow.provider.custom_in_settings.message": "Ein eigener Endpunkt wird nur in den Einstellungen geändert. Die Auswahl bleibt unverändert.", + "mainwindow.model.download.title": "Modell wechseln", + "mainwindow.model.download.message": "Das Modell „{model}“ kann beim nächsten Diktat heruntergeladen werden (mehrere hundert MB). Jetzt wechseln?", "mainwindow.tooltip.minimize": "Minimieren", "mainwindow.tooltip.close": "Schließen (läuft im Tray weiter)", "mainwindow.status.ready": "Bereit:", @@ -418,7 +447,36 @@ "mainwindow.tooltip.edit_text": "Enter text and edit it with writing styles or prompts (Ctrl+E)", "mainwindow.tooltip.tts": "Read aloud", "mainwindow.tooltip.settings": "Settings", - "mainwindow.tooltip.more": "More actions", + "mainwindow.tooltip.more": "Quick choice: mode, AI provider, Whisper model", + "mainwindow.tooltip.workflow": "Mode of the next recording", + "mainwindow.tooltip.preset": "Writing style for text improvement", + "mainwindow.mode.transcription": "Dictate", + "mainwindow.mode.local": "Dictate locally", + "mainwindow.mode.text_improver": "Improve", + "mainwindow.mode.dampf_ablassen": "Factual", + "mainwindow.mode.emoji_text": "Emojis", + "mainwindow.mode.switch.local": "Local", + "mainwindow.mode.switch.ai": "With AI", + "mainwindow.mode.switch.local.tip": "Recording by click: local speech recognition only (Whisper), nothing is sent to a cloud service. The hotkeys for AI actions stay active.", + "mainwindow.mode.switch.ai.tip": "Local speech recognition, then text editing by a cloud AI provider", + "mainwindow.tooltip.mode_details": "Open details for the chosen mode", + "mainwindow.caption.action": "Action", + "mainwindow.button.done": "Done", + "mainwindow.caption.workflow": "Mode", + "mainwindow.caption.preset": "Writing style", + "mainwindow.caption.provider": "AI provider (cloud)", + "mainwindow.caption.model": "Whisper model (local)", + "mainwindow.tooltip.provider": "AI provider for text improvement", + "mainwindow.tooltip.model": "Whisper model for speech recognition (local)", + "mainwindow.provider.openai": "OpenAI", + "mainwindow.provider.openrouter": "OpenRouter", + "mainwindow.provider.custom": "Custom endpoint", + "mainwindow.model.item": "{model}", + "mainwindow.provider.no_key.title": "AI key missing", + "mainwindow.provider.no_key.message": "The environment variable {env} is not set for this provider. The provider stays unchanged.", + "mainwindow.provider.custom_in_settings.message": "A custom endpoint is only changed in the settings dialog. The selection stays unchanged.", + "mainwindow.model.download.title": "Switch model", + "mainwindow.model.download.message": "The model \u201c{model}\u201d may be downloaded on the next dictation (several hundred MB). Switch now?", "mainwindow.tooltip.minimize": "Minimize", "mainwindow.tooltip.close": "Close (keeps running in the tray)", "mainwindow.status.ready": "Ready:", diff --git a/app/main_window.py b/app/main_window.py index 6be4e60..70e6bd4 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -10,13 +10,13 @@ QEnterEvent, QKeySequence, QLinearGradient, QMouseEvent, QPainter, QPainterPath, QPen, QRadialGradient) -from PyQt6.QtWidgets import (QApplication, QComboBox, QFrame, +from PyQt6.QtWidgets import (QApplication, QButtonGroup, QComboBox, QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, - QLayout, QPushButton, QVBoxLayout, QWidget) + QLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget) from app import theme from app.i18n import t -from app.llm_service import WorkflowType +from app.llm_service import LLM_WORKFLOWS, WorkflowType from app.writing_presets import CUSTOM_PRESET_KEY, WRITING_PRESET_KEYS _WORKFLOW_ORDER = [ @@ -56,6 +56,12 @@ def _mic_body_path() -> QPainterPath: # Main window: a single frameless surface holds header, microphone, status and # tools. The window margins only reserve the measured reach of its shadow. _SHADOW_MARGINS = (10, 6, 10, 14) # left, top, right, bottom +# Quick choices only; the full list stays in the settings dialog. +_PROVIDER_ORDER = ("openai", "openrouter") # "custom" is added while it is active +_MODEL_ORDER = ("tiny", "base", "small", "medium", "large-v3-turbo") +# The status row ("Recording: 00:00") and the state line must never widen the window on +# fonts that are wider than the development machine's; the tool row stays centred. +_PANEL_MIN_WIDTH = 212 _PANEL_PADDING = (10, 6, 10, 10) # plus the 1 px QSS border _TOOL_GAP = 6 _TOOLBAR_TOP_GAP = 8 @@ -239,6 +245,7 @@ def __init__(self, controller, parent: Optional[QWidget] = None) -> None: self._controller = controller self._state = "IDLE" self._rec_start: Optional[float] = None + self._last_ai_workflow = WorkflowType.TEXT_IMPROVER self.setWindowTitle(t("app.name")) # The panel draws its own title and window controls. self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) @@ -250,6 +257,7 @@ def __init__(self, controller, parent: Optional[QWidget] = None) -> None: self._timer.timeout.connect(self._update_timer_label) self._setup_ui() self.update_state("IDLE", None, None) + self._refresh_mode_summary() @staticmethod def _qss() -> str: @@ -267,6 +275,7 @@ def _setup_ui(self) -> None: panel = QVBoxLayout(self._main_panel) panel.setContentsMargins(*_PANEL_PADDING) panel.setSpacing(0) + self._main_panel.setMinimumWidth(_PANEL_MIN_WIDTH) header = QHBoxLayout() header.setSpacing(2) @@ -275,6 +284,12 @@ def _setup_ui(self) -> None: self.windowTitleChanged.connect(self._title_label.setText) header.addWidget(self._title_label) header.addStretch(1) + # Rarely needed: a small header control instead of a full toolbar slot. + self._btn_settings = self._window_button( + "settings", t("mainwindow.tooltip.settings"), + self._controller.show_settings_dialog, + ) + header.addWidget(self._btn_settings) self._btn_minimize = self._window_button( "minimize", t("mainwindow.tooltip.minimize"), self.showMinimized ) @@ -301,11 +316,38 @@ def _setup_ui(self) -> None: self._timer_label.setObjectName("timerLabel") status.addWidget(self._timer_label) panel.addLayout(status) + panel.addSpacing(4) + switch = QHBoxLayout() + switch.setSpacing(0) + self._seg_local = self._segment_button(t("mainwindow.mode.switch.local"), "left") + self._seg_ai = self._segment_button(t("mainwindow.mode.switch.ai"), "right") + self._seg_local.setToolTip(t("mainwindow.mode.switch.local.tip")) + self._seg_ai.setToolTip(t("mainwindow.mode.switch.ai.tip")) + group = QButtonGroup(self) + group.setExclusive(True) + for button in (self._seg_local, self._seg_ai): + group.addButton(button) + switch.addWidget(button) + self._seg_local.setChecked(True) + self._seg_local.clicked.connect(lambda _c=False: self._set_ai_mode(False)) + self._seg_ai.clicked.connect(lambda _c=False: self._set_ai_mode(True)) + panel.addLayout(switch) + # The state line is a button: it opens the details for the chosen side. + self._mode_label = QPushButton("", self._main_panel) + self._mode_label.setObjectName("modeLabel") + self._mode_label.setFlat(True) + self._mode_label.setCursor(Qt.CursorShape.PointingHandCursor) + self._mode_label.setToolTip(t("mainwindow.tooltip.mode_details")) + self._mode_label.setAccessibleName(t("mainwindow.tooltip.mode_details")) + self._mode_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + self._mode_label.clicked.connect(self._show_options_popup) + panel.addWidget(self._mode_label) panel.addSpacing(_TOOLBAR_TOP_GAP) # The tool row is part of the same surface; its width sets the panel width. tools = QHBoxLayout() tools.setSpacing(_TOOL_GAP) + tools.addStretch(1) self._btn_edit_text = self._toolbar_button( "edit", t("mainwindow.tooltip.edit_text"), self._controller.show_compose_window, accent=True, @@ -320,19 +362,16 @@ def _setup_ui(self) -> None: self._btn_tts = self._toolbar_button( "speaker", t("mainwindow.tooltip.tts"), self._controller.show_tts_window ) - self._btn_settings = self._toolbar_button( - "settings", t("mainwindow.tooltip.settings"), - self._controller.show_settings_dialog - ) self._btn_options = self._toolbar_button( "more", t("mainwindow.tooltip.more"), self._show_options_popup ) self._btn_options.setObjectName("panelTrigger") for button in ( self._btn_edit_text, self._btn_history, self._btn_tts, - self._btn_settings, self._btn_options, + self._btn_options, ): tools.addWidget(button) + tools.addStretch(1) panel.addLayout(tools) root.addWidget(self._main_panel) self._setup_options_popup() @@ -376,26 +415,70 @@ def _setup_options_popup(self) -> None: popup = QVBoxLayout(self._popup_surface) popup.setContentsMargins(12, 12, 12, 12) popup.setSpacing(8) + self._popup_title = QLabel("", self._popup_surface) + self._popup_title.setObjectName("popupTitle") + popup.addWidget(self._popup_title) + # The master list keeps every workflow; the user only sees the two sides of the + # switch and, on the AI side, the action list below. self._workflow_combo = QComboBox(self._popup_surface) self._workflow_combo.setObjectName("popupCombo") + self._workflow_combo.setAccessibleName(t("mainwindow.tooltip.workflow")) for workflow in _WORKFLOW_ORDER: self._workflow_combo.addItem(t(f"workflow.{workflow.value}.name"), userData=workflow) self._workflow_combo.currentIndexChanged.connect(self._on_workflow_changed) - popup.addWidget(self._workflow_combo) + self._workflow_combo.hide() + self._caption_action = self._caption("action") + popup.addWidget(self._caption_action) + self._action_combo = QComboBox(self._popup_surface) + self._action_combo.setObjectName("popupCombo") + self._action_combo.setToolTip(t("mainwindow.tooltip.workflow")) + self._action_combo.setAccessibleName(t("mainwindow.tooltip.workflow")) + for workflow in _WORKFLOW_ORDER: + if workflow in LLM_WORKFLOWS: + self._action_combo.addItem(t(f"workflow.{workflow.value}.name"), userData=workflow) + self._action_combo.currentIndexChanged.connect(self._on_action_changed) + popup.addWidget(self._action_combo) self._preset_combo = QComboBox(self._popup_surface) self._preset_combo.setObjectName("popupCombo") + self._preset_combo.setToolTip(t("mainwindow.tooltip.preset")) + self._preset_combo.setAccessibleName(t("mainwindow.tooltip.preset")) for key in WRITING_PRESET_KEYS: if key == CUSTOM_PRESET_KEY: self._preset_combo.insertSeparator(self._preset_combo.count()) self._preset_combo.addItem(t(f"preset.{key}.name"), userData=key) self._preset_combo.currentIndexChanged.connect(self._on_preset_changed) - self._preset_combo.hide() + self._caption_preset = self._caption("preset") + popup.addWidget(self._caption_preset) popup.addWidget(self._preset_combo) + self._provider_combo = QComboBox(self._popup_surface) + self._provider_combo.setObjectName("popupCombo") + self._provider_combo.setToolTip(t("mainwindow.tooltip.provider")) + self._provider_combo.setAccessibleName(t("mainwindow.tooltip.provider")) + for provider in _PROVIDER_ORDER: + self._provider_combo.addItem(t(f"mainwindow.provider.{provider}"), userData=provider) + self._provider_combo.currentIndexChanged.connect(self._on_provider_changed) + self._caption_provider = self._caption("provider") + popup.addWidget(self._caption_provider) + popup.addWidget(self._provider_combo) + self._model_combo = QComboBox(self._popup_surface) + self._model_combo.setObjectName("popupCombo") + self._model_combo.setToolTip(t("mainwindow.tooltip.model")) + self._model_combo.setAccessibleName(t("mainwindow.tooltip.model")) + for model in _MODEL_ORDER: + self._add_model_item(model) + self._model_combo.currentIndexChanged.connect(self._on_model_changed) + self._caption_model = self._caption("model") + popup.addWidget(self._caption_model) + popup.addWidget(self._model_combo) self._btn_prompt = QPushButton(t("compose.custom.edit"), self._popup_surface) self._btn_prompt.setObjectName("promptButton") self._btn_prompt.clicked.connect(self._edit_prompt) self._btn_prompt.hide() popup.addWidget(self._btn_prompt) + self._btn_done = QPushButton(t("mainwindow.button.done"), self._popup_surface) + self._btn_done.setObjectName("doneButton") + self._btn_done.clicked.connect(self._options_popup.hide) + popup.addWidget(self._btn_done) self._btn_dictation = QPushButton(t("mainwindow.button.dictation"), self._popup_surface) self._btn_dictation.setObjectName("dictationToggle") self._btn_dictation.setCheckable(True) @@ -406,10 +489,12 @@ def _setup_options_popup(self) -> None: self._btn_discard.setEnabled(False) self._btn_discard.clicked.connect(self._on_discard_clicked) popup.addWidget(self._btn_discard) + self._sync_mode_widgets() self._options_popup.adjustSize() self._options_popup.hide() - def _show_options_popup(self) -> None: + def _place_options_popup(self) -> None: + """Anchor the popup's bottom edge above the options button, inside the screen.""" self._options_popup.adjustSize() anchor = self._btn_options.mapToGlobal(QPoint(self._btn_options.width(), 0)) position = QPoint(anchor.x() - self._options_popup.width(), anchor.y() - self._options_popup.height() - 4) @@ -419,6 +504,9 @@ def _show_options_popup(self) -> None: position.setX(max(area.left() + 6, min(position.x(), area.right() - self._options_popup.width() - 6))) position.setY(max(area.top() + 6, min(position.y(), area.bottom() - self._options_popup.height() - 6))) self._options_popup.move(position) + + def _show_options_popup(self) -> None: + self._place_options_popup() self._options_popup.show() self._options_popup.raise_() @@ -428,15 +516,126 @@ def _selected_workflow(self) -> WorkflowType: @pyqtSlot() def _on_workflow_changed(self) -> None: - visible = self._selected_workflow() == WorkflowType.TEXT_IMPROVER - self._preset_combo.setVisible(visible) - self._btn_prompt.setVisible(visible) - self._options_popup.adjustSize() + self._sync_mode_widgets() + + def _segment_button(self, text: str, side: str) -> QPushButton: + button = QPushButton(text, self._main_panel) + button.setObjectName("segButton") + button.setProperty("side", side) + button.setCheckable(True) + button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + return button + + def _set_ai_mode(self, ai: bool) -> None: + """The switch picks a workflow; AI remembers the last AI action.""" + if (self._selected_workflow() in LLM_WORKFLOWS) == ai: + self._sync_mode_widgets() + return # already on that side: keep the exact workflow (e.g. LOCAL, EMOJI_TEXT) + workflow = self._last_ai_workflow if ai else WorkflowType.TRANSCRIPTION + index = self._workflow_combo.findData(workflow) + if index >= 0: + self._workflow_combo.setCurrentIndex(index) + self._sync_mode_widgets() + + @pyqtSlot() + def _on_action_changed(self) -> None: + workflow = self._action_combo.currentData() + index = self._workflow_combo.findData(workflow) + if index >= 0 and workflow != self._selected_workflow(): + self._workflow_combo.setCurrentIndex(index) + + def _sync_mode_widgets(self) -> None: + """Make the switch, the popup fields and the state line agree with the workflow.""" + if not hasattr(self, "_btn_done") or not hasattr(self, "_seg_ai"): + return + workflow = self._selected_workflow() + ai = workflow in LLM_WORKFLOWS + improver = workflow == WorkflowType.TEXT_IMPROVER + if ai: + self._last_ai_workflow = workflow + self._seg_ai.setChecked(ai) + self._seg_local.setChecked(not ai) + self._action_combo.blockSignals(True) + index = self._action_combo.findData(workflow) + if index >= 0: + self._action_combo.setCurrentIndex(index) + self._action_combo.blockSignals(False) + self._popup_title.setText(t("mainwindow.mode.switch.ai" if ai else "mainwindow.mode.switch.local")) + # Fields that do not apply disappear instead of being greyed out. + for widget in (self._caption_action, self._action_combo, + self._caption_provider, self._provider_combo): + widget.setVisible(ai) + for widget in (self._caption_preset, self._preset_combo, self._btn_prompt): + widget.setVisible(improver) + self._refresh_mode_summary() + if self._options_popup.isVisible(): + self._place_options_popup() # grew or shrank: keep the bottom edge anchored + else: + self._options_popup.adjustSize() def _edit_prompt(self) -> None: self._options_popup.hide() self._controller.show_custom_prompt() + def _refresh_mode_summary(self) -> None: + """One line under the status: what the next recording will do.""" + if not hasattr(self, "_mode_label") or not hasattr(self, "_model_combo"): + return + workflow = self._selected_workflow() + model = self._model_combo.currentData() or "" + if workflow in LLM_WORKFLOWS: + parts = [t("mainwindow.mode.switch.ai"), self._provider_combo.currentText(), + t(f"mainwindow.mode.{workflow.value}")] + else: + parts = [t("mainwindow.mode.switch.local"), f"Whisper {model}".strip()] + self._mode_label.setText(" · ".join(p for p in parts if p)) + + def _caption(self, key: str) -> QLabel: + label = QLabel(t(f"mainwindow.caption.{key}"), self._popup_surface) + label.setObjectName("popupCaption") + return label + + def _add_model_item(self, model: str) -> None: + self._model_combo.addItem(t("mainwindow.model.item").format(model=model), userData=model) + + @pyqtSlot() + def _on_provider_changed(self) -> None: + self._refresh_mode_summary() + provider = self._provider_combo.currentData() + if provider: + self._controller.main_window_provider_changed(provider) + + @pyqtSlot() + def _on_model_changed(self) -> None: + self._refresh_mode_summary() + model = self._model_combo.currentData() + if model: + self._controller.main_window_model_changed(model) + + def set_llm_provider(self, provider: str) -> None: + """Show the active provider without notifying the controller.""" + if self._provider_combo.findData(provider) < 0: + self._provider_combo.blockSignals(True) + self._provider_combo.addItem(t(f"mainwindow.provider.{provider}"), userData=provider) + self._provider_combo.blockSignals(False) + self._select_silently(self._provider_combo, provider) + + def set_model(self, model: str) -> None: + """Show the active Whisper model, adding it if it is not a quick choice.""" + if self._model_combo.findData(model) < 0: + self._model_combo.blockSignals(True) + self._add_model_item(model) + self._model_combo.blockSignals(False) + self._select_silently(self._model_combo, model) + + def _select_silently(self, combo: QComboBox, data: str) -> None: + index = combo.findData(data) + if index >= 0: + combo.blockSignals(True) + combo.setCurrentIndex(index) + combo.blockSignals(False) + self._refresh_mode_summary() + @pyqtSlot() def _on_preset_changed(self) -> None: key = self._preset_combo.currentData() @@ -465,7 +664,11 @@ def update_state(self, state: str, workflow: Optional[WorkflowType], error: Opti self._btn_toggle.setEnabled(state in ("IDLE", "RECORDING")) self._btn_toggle.set_mode("RECORDING" if recording else "PROCESSING" if busy else "IDLE") self._btn_discard.setEnabled(recording) - for control in (self._workflow_combo, self._preset_combo, self._btn_prompt): + for control in ( + self._workflow_combo, self._action_combo, self._btn_prompt, + self._provider_combo, self._model_combo, self._preset_combo, + self._seg_local, self._seg_ai, + ): control.setEnabled(state == "IDLE") if error: self._set_status(t("mainwindow.status.error"), theme.STATE_ERROR) diff --git a/app/theme.py b/app/theme.py index 7946175..4b0a0d1 100644 --- a/app/theme.py +++ b/app/theme.py @@ -338,6 +338,25 @@ border-radius: 14px; }} QLabel#panelTitle {{ color: {APP_TEXT_DIM}; font-size: 12px; font-weight: 600; padding-left: 2px; }} +QPushButton#modeLabel {{ + background: transparent; border: none; color: {APP_TEXT_DIM}; + font-size: 11px; padding: 0; min-height: 22px; max-height: 22px; +}} +QPushButton#modeLabel:hover {{ color: {APP_TEXT}; }} +QPushButton#segButton {{ + background: transparent; border: 1px solid {APP_LINE_SOFT}; color: {APP_TEXT_DIM}; + font-weight: 600; padding: 0; min-height: 24px; max-height: 24px; min-width: 76px; +}} +QPushButton#segButton[side="left"] {{ border-top-left-radius: 9px; border-bottom-left-radius: 9px; }} +QPushButton#segButton[side="right"] {{ border-top-right-radius: 9px; border-bottom-right-radius: 9px; }} +QPushButton#segButton:hover {{ background: {APP_SURFACE_HOVER}; }} +QPushButton#segButton:checked {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 {BLITZ_300}, stop:0.55 {BLITZ_500}, stop:1 #a57c35); + border-color: rgba(255, 236, 177, 0.62); color: #1b1608; +}} +QLabel#popupTitle {{ color: {APP_TEXT}; font-size: 13px; font-weight: 700; }} +QPushButton#doneButton {{ color: {BLITZ_300}; font-weight: 600; }} QLabel#statusLabel {{ color: {APP_TEXT}; font-size: 12px; font-weight: 600; }} QLabel#timerLabel {{ color: {APP_TEXT_DIM}; font-family: monospace; font-size: 12px; }} QPushButton#windowControl {{ @@ -379,6 +398,7 @@ stop:0 #fff0bd, stop:0.50 {BLITZ_400}, stop:1 #bb9145); }} QComboBox#popupCombo {{ min-height: 22px; }} +QLabel#popupCaption {{ color: {APP_TEXT_DIM}; font-size: 11px; margin-top: 4px; }} QPushButton#dictationToggle {{ color: {BLITZ_300}; }} QPushButton#discardButton {{ color: {STATE_ERROR}; }} """ diff --git a/docs/screenshots/linux/Banner-de.png b/docs/screenshots/linux/Banner-de.png index f8b5126..3bf9b9f 100644 Binary files a/docs/screenshots/linux/Banner-de.png and b/docs/screenshots/linux/Banner-de.png differ diff --git a/docs/screenshots/linux/Banner-en.png b/docs/screenshots/linux/Banner-en.png index 658a458..1fe6574 100644 Binary files a/docs/screenshots/linux/Banner-en.png and b/docs/screenshots/linux/Banner-en.png differ diff --git a/docs/screenshots/linux/demo-de.gif b/docs/screenshots/linux/demo-de.gif index 81ea811..edac9ca 100644 Binary files a/docs/screenshots/linux/demo-de.gif and b/docs/screenshots/linux/demo-de.gif differ diff --git a/docs/screenshots/linux/demo-en.gif b/docs/screenshots/linux/demo-en.gif index 15b2d48..44def0c 100644 Binary files a/docs/screenshots/linux/demo-en.gif and b/docs/screenshots/linux/demo-en.gif differ diff --git a/docs/screenshots/linux/history-de.png b/docs/screenshots/linux/history-de.png index 462e7be..0520dbb 100644 Binary files a/docs/screenshots/linux/history-de.png and b/docs/screenshots/linux/history-de.png differ diff --git a/docs/screenshots/linux/history-en.png b/docs/screenshots/linux/history-en.png index 8111f98..ec13528 100644 Binary files a/docs/screenshots/linux/history-en.png and b/docs/screenshots/linux/history-en.png differ diff --git a/docs/screenshots/linux/main-idle-de.png b/docs/screenshots/linux/main-idle-de.png index 33432cb..77e2aa3 100644 Binary files a/docs/screenshots/linux/main-idle-de.png and b/docs/screenshots/linux/main-idle-de.png differ diff --git a/docs/screenshots/linux/main-idle-en.png b/docs/screenshots/linux/main-idle-en.png index ef51fd2..36b2012 100644 Binary files a/docs/screenshots/linux/main-idle-en.png and b/docs/screenshots/linux/main-idle-en.png differ diff --git a/docs/screenshots/linux/main-menu-de.png b/docs/screenshots/linux/main-menu-de.png index 9944f29..6014d29 100644 Binary files a/docs/screenshots/linux/main-menu-de.png and b/docs/screenshots/linux/main-menu-de.png differ diff --git a/docs/screenshots/linux/main-menu-en.png b/docs/screenshots/linux/main-menu-en.png index e27e5bb..ea35b30 100644 Binary files a/docs/screenshots/linux/main-menu-en.png and b/docs/screenshots/linux/main-menu-en.png differ diff --git a/docs/screenshots/linux/main-processing-de.png b/docs/screenshots/linux/main-processing-de.png index 9bba84a..1de609d 100644 Binary files a/docs/screenshots/linux/main-processing-de.png and b/docs/screenshots/linux/main-processing-de.png differ diff --git a/docs/screenshots/linux/main-processing-en.png b/docs/screenshots/linux/main-processing-en.png index 8cf2f25..347001a 100644 Binary files a/docs/screenshots/linux/main-processing-en.png and b/docs/screenshots/linux/main-processing-en.png differ diff --git a/docs/screenshots/linux/main-recording-de.png b/docs/screenshots/linux/main-recording-de.png index 9a325ee..5923831 100644 Binary files a/docs/screenshots/linux/main-recording-de.png and b/docs/screenshots/linux/main-recording-de.png differ diff --git a/docs/screenshots/linux/main-recording-en.png b/docs/screenshots/linux/main-recording-en.png index eccdf92..d13f9d2 100644 Binary files a/docs/screenshots/linux/main-recording-en.png and b/docs/screenshots/linux/main-recording-en.png differ diff --git a/docs/screenshots/linux/social-preview.png b/docs/screenshots/linux/social-preview.png index 59bdb42..e5e56b9 100644 Binary files a/docs/screenshots/linux/social-preview.png and b/docs/screenshots/linux/social-preview.png differ diff --git a/scripts/_make_screenshots.py b/scripts/_make_screenshots.py index e6581f7..53184e3 100644 --- a/scripts/_make_screenshots.py +++ b/scripts/_make_screenshots.py @@ -439,6 +439,8 @@ def _render_language_set(out_dir: Path, lang: str) -> None: show_settings_dialog=lambda *a, **k: None, show_tts_window=lambda *a, **k: None, main_window_preset_changed=lambda *a, **k: None, + main_window_provider_changed=lambda *a, **k: None, + main_window_model_changed=lambda *a, **k: None, ) # 1. Main Window States diff --git a/scripts/gui_usability_check.py b/scripts/gui_usability_check.py new file mode 100644 index 0000000..d69b1bc --- /dev/null +++ b/scripts/gui_usability_check.py @@ -0,0 +1,202 @@ +"""Drive the main window offscreen, count clicks, grab screenshots, audit widgets. + +Usage: QT_QPA_PLATFORM=offscreen python scripts/gui_usability_check.py +Writes report.json plus one PNG per step into a fresh temp directory and prints its path. The PNGs and the task text feed +a blind reviewer; the audit part needs no LLM. +""" +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +MIN_TARGET = 22 # px, the smallest allowed clickable control (header buttons) + +# id, title, click budget +TASKS = ( + ("A_provider", "Switch the AI provider to OpenRouter", 4), + ("B_preset", "Pick the writing style 'Kürzen' for text improvement", 4), + ("C_dictate", "Dictate with text improvement (start and stop)", 3), + ("D_model", "Switch the Whisper model to 'tiny'", 3), + ("E_settings", "Open the settings dialog", 1), +) + + +class _Recorder: + def __init__(self) -> None: + self.calls: list[tuple] = [] + + def __getattr__(self, name): + def call(*args, **kwargs): + self.calls.append((name, *args)) + return call + + +class _Session: + """Counts clicks and writes one screenshot per user action.""" + + def __init__(self, window, app, outdir: Path, task_id: str) -> None: + self.window, self.app, self.outdir, self.task_id = window, app, outdir, task_id + self.clicks = 0 + self.steps: list[dict] = [] + + def _shot(self, action: str) -> None: + self.app.processEvents() + popup = self.window._options_popup + target = popup if popup.isVisible() else self.window + path = self.outdir / f"{self.task_id}_{len(self.steps) + 1:02d}.png" + target.grab().save(str(path)) + self.steps.append({"action": action, "screenshot": str(path)}) + + def click(self, button, label: str) -> None: + self.clicks += 1 + button.click() + self._shot(f"click: {label}") + + def choose(self, combo, data, label: str) -> None: + """Opening a drop-down and picking an entry is two clicks.""" + self.clicks += 2 + combo.setCurrentIndex(combo.findData(data)) + self._shot(f"choose: {label}") + + +def _controller_task(task_id, window, app, outdir, recorder): + s = _Session(window, app, outdir, task_id) + from app.workflows import WorkflowType + + if task_id == "A_provider": + s.click(window._seg_ai, "Mit KI") + s.click(window._mode_label, "Zustandszeile (Details)") + s.choose(window._provider_combo, "openrouter", "KI-Anbieter") + goal = ("provider", "openrouter") in recorder.calls + elif task_id == "B_preset": + s.click(window._seg_ai, "Mit KI") + s.click(window._mode_label, "Zustandszeile (Details)") + s.choose(window._preset_combo, "shorten", "Vorlage") + goal = ("main_window_preset_changed", "shorten") in recorder.calls + goal = goal or ("preset", "shorten") in recorder.calls + elif task_id == "C_dictate": + s.click(window._seg_ai, "Mit KI") + window.update_state("IDLE", WorkflowType.TEXT_IMPROVER, None) + s.click(window._btn_toggle, "Start") + # The real controller moves the window through its states; mirror that. + window.update_state("RECORDING", WorkflowType.TEXT_IMPROVER, None) + s._shot("state: recording") + s.click(window._btn_toggle, "Stopp") + window.update_state("LLM_REWRITING", WorkflowType.TEXT_IMPROVER, None) + s._shot("state: improving") + toggles = [c for c in recorder.calls if c[0] == "gui_toggle_recording"] + goal = len(toggles) == 2 and toggles[0][1] == WorkflowType.TEXT_IMPROVER + elif task_id == "D_model": + window.set_model("base") + s.click(window._mode_label, "Zustandszeile (Details)") + s.choose(window._model_combo, "tiny", "Whisper-Modell") + goal = ("model", "tiny") in recorder.calls + else: + s.click(window._btn_settings, "Zahnrad") + goal = ("show_settings_dialog",) in recorder.calls + _shot_settings_dialog(s) + window._options_popup.hide() + return {"clicks": s.clicks, "steps": s.steps, "goal_reached": bool(goal)} + + +def _shot_settings_dialog(session) -> None: + """The fake controller opens nothing, so grab what the real dialog looks like.""" + import tempfile + + from app.blitztext_linux import SettingsDialog + from app.config import BlitztextConfig + + with tempfile.TemporaryDirectory() as tmp: + dialog = SettingsDialog(BlitztextConfig(config_dir=Path(tmp))) + dialog.show() + session.app.processEvents() + path = session.outdir / f"{session.task_id}_{len(session.steps) + 1:02d}.png" + dialog.grab().save(str(path)) + session.steps.append({"action": "result: settings dialog opens", "screenshot": str(path)}) + dialog.close() + + +def audit_widgets(window) -> list[dict]: + """Return accessibility findings for every interactive widget of the window.""" + from PyQt6.QtCore import Qt + from PyQt6.QtWidgets import QComboBox, QPushButton + + findings: list[dict] = [] + window._show_options_popup() + # The popup is a child of the window, so one pass sees every control once. + for widget in window.findChildren((QPushButton, QComboBox)): + name = widget.objectName() or type(widget).__name__ + label = widget.accessibleName() or widget.toolTip() or getattr(widget, "text", lambda: "")() + size = widget.size() if widget.isVisible() else widget.sizeHint() + if not label: + findings.append({"widget": name, "severity": "high", "issue": "no accessible name, tooltip or text"}) + if widget.focusPolicy() == Qt.FocusPolicy.NoFocus: + findings.append({"widget": name, "severity": "medium", "issue": "not reachable by keyboard"}) + if min(size.width(), size.height()) < MIN_TARGET: + findings.append({ + "widget": name, "severity": "high", + "issue": f"target {size.width()}x{size.height()} px is below {MIN_TARGET} px", + }) + window._options_popup.hide() + return findings + + +def run_checks(outdir: Path) -> dict: + from PyQt6.QtWidgets import QApplication + + from app.main_window import MainWindow + + outdir.mkdir(parents=True, exist_ok=True) + app = QApplication.instance() or QApplication([]) + tasks = [] + for task_id, title, budget in TASKS: + recorder = _Recorder() + # Provider/model/preset calls arrive under their controller method names. + controller = SimpleNamespace( + gui_toggle_recording=lambda wf, r=recorder: r.calls.append(("gui_toggle_recording", wf)), + gui_discard=lambda r=recorder: r.calls.append(("gui_discard",)), + set_dictation_mode=lambda e, r=recorder: r.calls.append(("dictation", e)), + show_compose_window=lambda r=recorder: r.calls.append(("compose",)), + show_custom_prompt=lambda r=recorder: r.calls.append(("prompt",)), + show_history_panel=lambda r=recorder: r.calls.append(("history",)), + show_tts_window=lambda r=recorder: r.calls.append(("tts",)), + show_settings_dialog=lambda r=recorder: r.calls.append(("show_settings_dialog",)), + main_window_preset_changed=lambda k, r=recorder: r.calls.append(("preset", k)), + main_window_provider_changed=lambda p, r=recorder: r.calls.append(("provider", p)), + main_window_model_changed=lambda m, r=recorder: r.calls.append(("model", m)), + ) + window = MainWindow(controller) + window.show() + app.processEvents() + result = _controller_task(task_id, window, app, outdir, recorder) + window.close() + tasks.append({"id": task_id, "title": title, "budget": budget, **result}) + window = MainWindow(SimpleNamespace(**{ + n: (lambda *a, **k: None) for n in ( + "gui_toggle_recording gui_discard set_dictation_mode show_compose_window show_custom_prompt " + "show_history_panel show_tts_window show_settings_dialog main_window_preset_changed " + "main_window_provider_changed main_window_model_changed").split() + })) + window.show() + app.processEvents() + audit = audit_widgets(window) + window.close() + report = {"tasks": tasks, "audit": audit} + (outdir / "report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8") + return report + + +if __name__ == "__main__": + # No path argument on purpose: the output always goes to a fresh temp directory. + out = Path(tempfile.mkdtemp(prefix="gui-usability-")) + result = run_checks(out) + print(f"report: {out / 'report.json'}") + for task in result["tasks"]: + print(f"{task['id']}: {task['clicks']} clicks (budget {task['budget']}), goal={task['goal_reached']}") + for finding in result["audit"]: + print(f"AUDIT {finding['severity']}: {finding['widget']} - {finding['issue']}") diff --git a/tests/test_config.py b/tests/test_config.py index d55fd56..3570866 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ import pytest -from app.config import BlitztextConfig +from app.config import BlitztextConfig, ConfigError @pytest.fixture @@ -810,3 +810,24 @@ def test_goal_unknown_saved_id_falls_back_without_deleting_custom_prompt(config_ assert loaded.writing_preset == "standard" assert loaded.compose_custom_preset_text == custom_prompt + + +def test_failed_mode_change_never_replaces_the_existing_config(tmp_path, monkeypatch): + """A save error must leave the file on disk exactly as it was.""" + import os + + cfg = BlitztextConfig(config_dir=tmp_path) + cfg.model = "base" + cfg.save() + before = cfg.config_file.read_text(encoding="utf-8") + + def broken_fchmod(fd, mode): + raise OSError("chmod refused") + + monkeypatch.setattr(os, "fchmod", broken_fchmod) + cfg.model = "tiny" + with pytest.raises(ConfigError): + cfg.save() + + assert cfg.config_file.read_text(encoding="utf-8") == before + assert not cfg.config_file.with_suffix(".json.tmp").exists() diff --git a/tests/test_gui_usability.py b/tests/test_gui_usability.py new file mode 100644 index 0000000..b12aff1 --- /dev/null +++ b/tests/test_gui_usability.py @@ -0,0 +1,70 @@ +"""Hard usability gates for the main window (click budgets, accessibility). + +The same driver feeds ``scripts/gui_usability_check.py``, which also writes +screenshots for the blind LLM review (see ``.claude/skills/gui-usability-review``). +""" +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path + +import pytest + +_GUI = os.environ.get("WHISPER_GUI_TESTS") == "1" +gui_only = pytest.mark.skipif(not _GUI, reason="benötigt WHISPER_GUI_TESTS=1 (Display)") + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "gui_usability_check.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("gui_usability_check", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def report(tmp_path_factory): + if not _GUI: + pytest.skip("benötigt WHISPER_GUI_TESTS=1 (Display)") + return _load().run_checks(tmp_path_factory.mktemp("usability")) + + +@gui_only +def test_every_task_stays_within_its_click_budget(report): + over = [(t["id"], t["clicks"], t["budget"]) for t in report["tasks"] if t["clicks"] > t["budget"]] + assert over == [] + + +@gui_only +def test_every_task_reached_its_goal(report): + assert [t["id"] for t in report["tasks"] if not t["goal_reached"]] == [] + + +@gui_only +def test_switching_provider_or_model_stays_cheap(report): + by_id = {t["id"]: t for t in report["tasks"]} + assert by_id["A_provider"]["clicks"] <= 4 + assert by_id["C_dictate"]["clicks"] <= 3 + assert by_id["D_model"]["clicks"] <= 3 + assert by_id["E_settings"]["clicks"] == 1 + + +@gui_only +def test_every_step_has_a_screenshot(report): + for task in report["tasks"]: + for step in task["steps"]: + assert Path(step["screenshot"]).is_file() + + +@gui_only +def test_no_blocking_accessibility_findings(report): + blocking = [f for f in report["audit"] if f["severity"] == "high"] + assert blocking == [] + + +@gui_only +def test_report_is_json_serialisable(report): + json.dumps(report) diff --git a/tests/test_main_window_widget.py b/tests/test_main_window_widget.py index 3bdea94..538cb22 100644 --- a/tests/test_main_window_widget.py +++ b/tests/test_main_window_widget.py @@ -39,6 +39,12 @@ def show_custom_prompt(self) -> None: def main_window_preset_changed(self, key: str) -> None: self.calls.append(("preset", key)) + def main_window_provider_changed(self, provider: str) -> None: + self.calls.append(("provider", provider)) + + def main_window_model_changed(self, model: str) -> None: + self.calls.append(("model", model)) + def show_history_panel(self) -> None: self.calls.append("history") @@ -175,7 +181,7 @@ def test_options_are_shown_only_in_transient_popup(qapp): window._btn_options.click() qapp.processEvents() - assert window._workflow_combo.isVisible() + assert window._model_combo.isVisible() assert window._btn_dictation.isVisible() assert window._btn_discard.isVisible() @@ -486,8 +492,12 @@ def test_header_microphone_status_and_tools_stack_inside_the_panel(qapp): assert close.bottom() <= record.top() assert record.bottom() <= status.top() assert status.bottom() < options.top() - # Header controls and tool row share the panel's right edge. - assert close.right() == options.right() + # The header reaches the panel's right edge; the tool row is centred in the panel. + from app.main_window import _PANEL_PADDING + + assert close.right() == inner.right() - _PANEL_PADDING[2] + tools_left = window._btn_edit_text.geometry().left() + assert abs((tools_left - inner.left()) - (inner.right() - options.right())) <= 1 assert window._btn_minimize.geometry().right() < close.left() assert abs(record.center().x() - inner.center().x()) <= 1 @@ -498,7 +508,7 @@ def test_toolbar_buttons_keep_full_size_after_stylesheet_polish(qapp): window = _shown_window(qapp) buttons = (window._btn_edit_text, window._btn_history, window._btn_tts, - window._btn_settings, window._btn_options) + window._btn_options) for button in buttons: assert button.height() == theme.TOOL_BUTTON_SIZE @@ -518,15 +528,15 @@ def test_window_size_follows_compact_content_with_small_shadow_margins(qapp): panel = window._main_panel.geometry() assert window.size() == window.layout().sizeHint() - assert 200 <= panel.width() <= 250 - assert 170 <= panel.height() <= 220 + assert 170 <= panel.width() <= 250 + assert 170 <= panel.height() <= 260 assert panel.left() <= 12 assert window.width() - panel.right() - 1 <= 12 assert panel.top() <= 8 assert window.height() - panel.bottom() - 1 <= 16 - assert window.width() <= 260 and window.height() <= 236 - # The panel has no dead filler around its content. - assert panel.size() == window._main_panel.sizeHint() + assert window.width() <= 260 and window.height() <= 282 + # The panel has no dead filler around its content (it only keeps its minimum width). + assert panel.size() == window._main_panel.sizeHint().expandedTo(window._main_panel.minimumSize()) @gui_only @@ -668,3 +678,149 @@ def pixel(point: QPointF): gold = pixel(QPointF(inner.x(), bolt.boundingRect().top() - 4)) assert gold.red() > 150 and gold.green() > 110 and gold.red() - gold.blue() > 60 assert paint_errors == [] + + +@gui_only +def test_settings_gear_is_small_and_sits_in_the_header(qapp): + from app import theme + + window = _shown_window(qapp) + gear = window._btn_settings + + assert gear.width() == theme.WINDOW_BUTTON_SIZE < theme.TOOL_BUTTON_SIZE + assert gear.height() == theme.WINDOW_BUTTON_SIZE + assert gear.geometry().bottom() < window._btn_toggle.geometry().top() + assert gear.geometry().right() < window._btn_minimize.geometry().left() + assert gear.accessibleName() and gear.toolTip() + + +@gui_only +def test_mode_combos_live_in_the_popup_and_report_changes(qapp): + from app.main_window import MainWindow + + controller = _Controller() + window = MainWindow(controller) + window.show() + qapp.processEvents() + + providers = [window._provider_combo.itemData(i) for i in range(window._provider_combo.count())] + assert providers == ["openai", "openrouter"] # custom only shows while active + models = [window._model_combo.itemData(i) for i in range(window._model_combo.count())] + assert models[:3] == ["tiny", "base", "small"] + + window._provider_combo.setCurrentIndex(1) + window._model_combo.setCurrentIndex(2) + assert ("provider", "openrouter") in controller.calls + assert ("model", "small") in controller.calls + + +@gui_only +def test_mode_combos_sync_without_feedback_and_lock_while_busy(qapp): + from app.main_window import MainWindow + + controller = _Controller() + window = MainWindow(controller) + + window.set_llm_provider("custom") + window.set_model("large-v3") # not in the quick list: must still be shown + assert window._provider_combo.currentData() == "custom" + assert window._model_combo.currentData() == "large-v3" + assert controller.calls == [] + + window.update_state("RECORDING", None, None) + assert not window._provider_combo.isEnabled() + assert not window._model_combo.isEnabled() + window.update_state("IDLE", None, None) + assert window._provider_combo.isEnabled() and window._model_combo.isEnabled() + + +@gui_only +def test_every_popup_combo_has_a_visible_caption_naming_its_job(qapp): + from PyQt6.QtWidgets import QLabel + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + window._seg_ai.click() + window._show_options_popup() + qapp.processEvents() + + captions = {w.text() for w in window._popup_surface.findChildren(QLabel) + if w.objectName() == "popupCaption" and w.isVisible()} + assert {"Aktion", "KI-Anbieter (Cloud)", "Whisper-Modell (lokal)"} <= captions + # Entries show only the value; the caption says what it is. + assert window._provider_combo.currentText() == "OpenAI" + assert window._model_combo.currentText() == "tiny" + window._options_popup.hide() + + +@gui_only +def test_main_window_shows_the_active_mode_without_opening_the_popup(qapp): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + assert window._mode_label.isVisible() + + window.set_model("base") + window.update_state("IDLE", WorkflowType.TRANSCRIPTION, None) + assert window._mode_label.text() == "Lokal · Whisper base" + + window.set_llm_provider("openrouter") + window.update_state("IDLE", WorkflowType.TEXT_IMPROVER, None) + assert window._mode_label.text() == "Mit KI · OpenRouter · Verbessern" + + window.update_state("IDLE", WorkflowType.TRANSCRIPTION, None) + window.set_model("tiny") + assert window._mode_label.text().endswith("tiny") + + +@gui_only +def test_options_button_names_what_is_behind_it(qapp): + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + for text in (window._btn_options.toolTip(), window._btn_options.accessibleName()): + assert "KI" in text and "Whisper" in text + + +@gui_only +def test_mode_summary_is_filled_right_after_construction(qapp): + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + assert window._mode_label.text() == "Lokal · Whisper tiny" + + +@gui_only +def test_provider_selector_stays_usable_in_every_mode_when_idle(qapp): + """Switching the provider must not need a mode change first (3-click budget).""" + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + for workflow in WorkflowType: + window.update_state("IDLE", workflow, None) + assert window._provider_combo.isEnabled(), workflow + + +@gui_only +def test_wide_status_text_never_widens_the_window(qapp): + """CI fonts are wider than local ones; text must not drive the window width.""" + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + size = window.size() + font = window.font() + font.setPointSize(font.pointSize() + 4) + window.setFont(font) + window._main_panel.setFont(font) + window.update_state("RECORDING", WorkflowType.TEXT_IMPROVER, None) + window.set_llm_provider("openrouter") + window.set_history_count(999) + qapp.processEvents() + assert window.size() == size diff --git a/tests/test_mode_switch_ui.py b/tests/test_mode_switch_ui.py new file mode 100644 index 0000000..ee3c7c4 --- /dev/null +++ b/tests/test_mode_switch_ui.py @@ -0,0 +1,191 @@ +"""Main window: the visible Local | With AI switch and the popup that follows it.""" +from __future__ import annotations + +import os + +import pytest + +_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(autouse=True) +def _german_ui(): + from app.i18n import get_language, set_language + + previous = get_language() + set_language("de") + yield + set_language(previous) + + +@pytest.fixture +def qapp(): + from PyQt6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +class _Controller: + def __init__(self) -> None: + self.calls: list[object] = [] + + def __getattr__(self, name): + def call(*args, **kwargs): + self.calls.append((name, *args)) + return call + + +def _window(qapp, controller=None): + from app.main_window import MainWindow + + window = MainWindow(controller or _Controller()) + window.show() + qapp.processEvents() + return window + + +@gui_only +def test_switch_is_visible_and_starts_on_local(qapp): + from app.workflows import WorkflowType + + window = _window(qapp) + assert window._seg_local.isVisible() and window._seg_ai.isVisible() + assert window._seg_local.isChecked() and not window._seg_ai.isChecked() + assert window._selected_workflow() == WorkflowType.TRANSCRIPTION + assert window._seg_local.text() == "Lokal" and window._seg_ai.text() == "Mit KI" + + +@gui_only +def test_clicking_the_switch_selects_the_workflow(qapp): + from app.workflows import WorkflowType + + controller = _Controller() + window = _window(qapp, controller) + window._seg_ai.click() + assert window._selected_workflow() == WorkflowType.TEXT_IMPROVER + assert window._seg_ai.isChecked() and not window._seg_local.isChecked() + window._btn_toggle.click() + assert ("gui_toggle_recording", WorkflowType.TEXT_IMPROVER) in controller.calls + window._seg_local.click() + assert window._selected_workflow() == WorkflowType.TRANSCRIPTION + assert window._seg_local.isChecked() + + +@gui_only +def test_switch_follows_the_workflow_set_by_a_hotkey(qapp): + from app.workflows import WorkflowType + + window = _window(qapp) + window.update_state("RECORDING", WorkflowType.EMOJI_TEXT, None) + assert window._seg_ai.isChecked() + window.update_state("IDLE", WorkflowType.LOCAL, None) + assert window._seg_local.isChecked() + window.update_state("IDLE", WorkflowType.DAMPF_ABLASSEN, None) + assert window._seg_ai.isChecked() + + +@gui_only +def test_switch_is_locked_while_busy(qapp): + window = _window(qapp) + window.update_state("RECORDING", None, None) + assert not window._seg_local.isEnabled() and not window._seg_ai.isEnabled() + window.update_state("IDLE", None, None) + assert window._seg_local.isEnabled() and window._seg_ai.isEnabled() + + +@gui_only +def test_state_line_names_local_or_ai_and_opens_the_popup(qapp): + window = _window(qapp) + window.set_model("base") + window.set_llm_provider("openai") + assert window._mode_label.text() == "Lokal · Whisper base" + window._seg_ai.click() + assert window._mode_label.text() == "Mit KI · OpenAI · Verbessern" + assert "base" not in window._mode_label.text() + window._mode_label.click() + assert window._options_popup.isVisible() + window._options_popup.hide() + + +@gui_only +def test_popup_shows_only_fields_that_apply(qapp): + from app.workflows import WorkflowType + + window = _window(qapp) + window._show_options_popup() + qapp.processEvents() + + def shown(): + return {name for name, w in { + "action": window._action_combo, "preset": window._preset_combo, + "provider": window._provider_combo, "model": window._model_combo, + "prompt": window._btn_prompt, + }.items() if w.isVisible()} + + assert shown() == {"model"} + window._seg_ai.click() + assert shown() == {"action", "preset", "provider", "model", "prompt"} + window.update_state("IDLE", WorkflowType.EMOJI_TEXT, None) + assert shown() == {"action", "provider", "model"} + window._seg_local.click() + assert shown() == {"model"} + window._options_popup.hide() + + +@gui_only +def test_popup_has_a_title_and_a_done_button_that_closes_it(qapp): + window = _window(qapp) + window._show_options_popup() + assert window._popup_title.text() == "Lokal" + window._seg_ai.click() + assert window._popup_title.text() == "Mit KI" + assert window._btn_done.text() == "Fertig" + window._btn_done.click() + assert not window._options_popup.isVisible() + + +@gui_only +def test_action_combo_lists_only_ai_actions_and_sets_the_workflow(qapp): + from app.llm_service import LLM_WORKFLOWS + + window = _window(qapp) + window._seg_ai.click() + items = [window._action_combo.itemData(i) for i in range(window._action_combo.count())] + assert set(items) == set(LLM_WORKFLOWS) + target = next(w for w in items if w != window._selected_workflow()) + window._action_combo.setCurrentIndex(window._action_combo.findData(target)) + assert window._selected_workflow() == target + + +@gui_only +def test_clicking_the_side_that_is_already_active_keeps_the_workflow(qapp): + from app.workflows import WorkflowType + + window = _window(qapp) + window.update_state("IDLE", WorkflowType.LOCAL, None) + window._seg_local.click() + assert window._selected_workflow() == WorkflowType.LOCAL + window.update_state("IDLE", WorkflowType.EMOJI_TEXT, None) + window._seg_ai.click() + assert window._selected_workflow() == WorkflowType.EMOJI_TEXT + + +@gui_only +def test_open_popup_keeps_its_bottom_edge_when_it_grows(qapp): + window = _window(qapp) + window.move(300, 380) # low enough that the screen edge does not clamp the popup + qapp.processEvents() + window._show_options_popup() + qapp.processEvents() + bottom_before = window._options_popup.geometry().bottom() + window._seg_ai.click() + qapp.processEvents() + assert window._options_popup.geometry().bottom() == bottom_before + window._options_popup.hide() + + +@gui_only +def test_local_tooltip_is_honest_about_hotkeys(qapp): + window = _window(qapp) + assert "Hotkey" in window._seg_local.toolTip() diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py index a66f013..dce9686 100644 --- a/tests/test_tray_preset_menu.py +++ b/tests/test_tray_preset_menu.py @@ -239,22 +239,17 @@ def exec(self): assert window._preset_combo.currentData() == target - def test_preset_combo_visible_only_for_text_improver(self, tray_app): - """Preset-Combo im Options-Popup ist nur bei Blitztext+ sichtbar.""" + def test_preset_combo_is_only_shown_where_it_acts(self, tray_app): + """The style only exists for text improvement; elsewhere the field is gone.""" from app.workflows import WorkflowType window = self._open_window(tray_app) window._btn_options.click() - # 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}" - ) + assert window._preset_combo.isVisible() == (wf == WorkflowType.TEXT_IMPROVER), wf window._options_popup.hide() # --- Goal 04: Tray und Hauptfenster zeigen dieselben Kernaktionen ----------- @@ -332,3 +327,178 @@ def exec(self): assert tray_app.llm_service is not old_service assert compose._llm_service is tray_app.llm_service + + +@gui_only +class TestModeSwitch: + def test_provider_switch_needs_a_key_and_reverts_without_one(self, tray_app, monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + main = tray_app._ensure_main_window() + before = (tray_app.config.llm_provider, tray_app.config.llm_base_url) + + tray_app.main_window_provider_changed("openrouter") + + assert (tray_app.config.llm_provider, tray_app.config.llm_base_url) == before + assert main._provider_combo.currentData() == before[0] + + def test_provider_switch_with_key_persists_and_rebuilds(self, tray_app, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "dummy-not-a-real-key") + main = tray_app._ensure_main_window() + old_service = tray_app.llm_service + + tray_app.main_window_provider_changed("openrouter") + + assert tray_app.config.llm_provider == "openrouter" + assert tray_app.config.llm_base_url == "https://openrouter.ai/api/v1" + assert tray_app.config.openai_api_key_env == "OPENROUTER_API_KEY" + assert tray_app.llm_service is not old_service + assert main._provider_combo.currentData() == "openrouter" + on_disk = BlitztextConfig(config_dir=tray_app.config.config_dir) + assert on_disk.llm_provider == "openrouter" + + def test_provider_switch_back_to_openai_restores_defaults(self, tray_app, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "dummy-not-a-real-key") + monkeypatch.setenv("OPENAI_API_KEY", "dummy-not-a-real-key") + tray_app.main_window_provider_changed("openrouter") + tray_app.main_window_provider_changed("openai") + + assert tray_app.config.llm_provider == "openai" + assert tray_app.config.llm_base_url == "" + assert tray_app.config.openai_api_key_env == "OPENAI_API_KEY" + + def test_model_switch_persists_and_skips_noop(self, tray_app, monkeypatch): + monkeypatch.setattr(tray_app, "_confirm_model_download", lambda model: True) + tray_app.main_window_model_changed("tiny") + assert tray_app.config.model == "tiny" + assert BlitztextConfig(config_dir=tray_app.config.config_dir).model == "tiny" + + def test_heavy_model_switch_asks_first_and_can_be_declined(self, tray_app, monkeypatch): + main = tray_app._ensure_main_window() + tray_app.config.model = "base" + asked = [] + monkeypatch.setattr( + tray_app, "_confirm_model_download", lambda model: asked.append(model) or False + ) + + tray_app.main_window_model_changed("small") + + assert asked == ["small"] + assert tray_app.config.model == "base" + assert main._model_combo.currentData() == "base" + + +@gui_only +class TestModeSwitchHardening: + def test_leaving_a_custom_endpoint_never_overwrites_it(self, tray_app, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "dummy-not-a-real-key") + cfg = tray_app.config + cfg.llm_provider = "custom" + cfg.llm_base_url = "https://llm.example.org/v1" + cfg.openai_api_key_env = "MY_CUSTOM_KEY" + tray_app._ensure_main_window().set_llm_provider("custom") + + tray_app.main_window_provider_changed("openrouter") + + assert (cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env) == ( + "custom", "https://llm.example.org/v1", "MY_CUSTOM_KEY") + assert tray_app._main_window._provider_combo.currentData() == "custom" + + def test_custom_is_not_a_target_of_the_quick_switch(self, tray_app, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "dummy-not-a-real-key") + cfg = tray_app.config + cfg.llm_provider = "openai" + cfg.llm_base_url = "https://stale.example.org/v1" + + tray_app.main_window_provider_changed("custom") + + assert cfg.llm_provider == "openai" + + def test_custom_key_variable_survives_openai_openrouter_round_trip(self, tray_app, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "dummy-not-a-real-key") + monkeypatch.setenv("WORK_OPENAI_KEY", "dummy-not-a-real-key") + cfg = tray_app.config + cfg.llm_provider = "openai" + cfg.openai_api_key_env = "WORK_OPENAI_KEY" + + tray_app.main_window_provider_changed("openrouter") + tray_app.main_window_provider_changed("openai") + + assert cfg.openai_api_key_env == "WORK_OPENAI_KEY" + assert cfg.llm_base_url == "" + + def test_failed_provider_save_restores_state_and_combo(self, tray_app, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "dummy-not-a-real-key") + main = tray_app._ensure_main_window() + before = (tray_app.config.llm_provider, tray_app.config.llm_base_url, + tray_app.config.openai_api_key_env) + monkeypatch.setattr(tray_app.config, "save", lambda: (_ for _ in ()).throw(OSError("disk full"))) + + tray_app.main_window_provider_changed("openrouter") + + assert (tray_app.config.llm_provider, tray_app.config.llm_base_url, + tray_app.config.openai_api_key_env) == before + assert main._provider_combo.currentData() == before[0] + + def test_failed_model_save_restores_state_and_does_not_latch_an_error(self, tray_app, monkeypatch): + main = tray_app._ensure_main_window() + tray_app.config.model = "base" + monkeypatch.setattr(tray_app.config, "save", lambda: (_ for _ in ()).throw(OSError("disk full"))) + + tray_app.main_window_model_changed("tiny") + + assert tray_app.config.model == "base" + assert main._model_combo.currentData() == "base" + assert tray_app._tray_error_message in (None, "") + + def test_unchanged_model_is_a_noop(self, tray_app, monkeypatch): + tray_app.config.model = "base" + calls = [] + monkeypatch.setattr(tray_app.config, "save", lambda: calls.append(1)) + monkeypatch.setattr(tray_app, "_confirm_model_download", lambda m: calls.append("ask") or True) + + tray_app.main_window_model_changed("base") + + assert calls == [] + + def test_model_prompt_closes_the_popup_first(self, tray_app, monkeypatch): + main = tray_app._ensure_main_window() + main._show_options_popup() + seen = [] + from PyQt6.QtWidgets import QMessageBox + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: seen.append(main._options_popup.isVisible()) or QMessageBox.StandardButton.No), + ) + + tray_app.main_window_model_changed("small") + + assert seen == [False] + + +@gui_only +class TestAiGate: + def test_ai_recording_without_a_key_is_refused_before_recording(self, tray_app, monkeypatch): + from app.workflows import WorkflowType + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + tray_app.config._legacy_openai_api_key_value = "" + tray_app._rebuild_llm_service() + started = [] + monkeypatch.setattr(tray_app, "_start_recording", lambda wf: started.append(wf)) + + tray_app.gui_toggle_recording(WorkflowType.TEXT_IMPROVER) + tray_app.gui_toggle_recording(WorkflowType.TRANSCRIPTION) + + assert started == [WorkflowType.TRANSCRIPTION] + + def test_ai_recording_with_a_key_starts(self, tray_app, monkeypatch): + from app.workflows import WorkflowType + + monkeypatch.setenv("OPENAI_API_KEY", "dummy-not-a-real-key") + tray_app._rebuild_llm_service() + started = [] + monkeypatch.setattr(tray_app, "_start_recording", lambda wf: started.append(wf)) + + tray_app.gui_toggle_recording(WorkflowType.TEXT_IMPROVER) + + assert started == [WorkflowType.TEXT_IMPROVER]