diff --git a/.gitignore b/.gitignore index 7934cb7..1fbc15f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ .env.* secrets.env .venv/ +scratch/ +prototypes/ diff --git a/README.de.md b/README.de.md index 26336fd..f6fa322 100644 --- a/README.de.md +++ b/README.de.md @@ -38,7 +38,7 @@ Blitztext Linux ist ein Hotkey-gesteuerter Sprachassistent, der gesprochene Spra ## Features - **Mehrsprachige Oberfläche (EN/DE):** Schalte die App-Oberfläche zwischen Deutsch und Englisch um – unter **Einstellungen → Allgemein → „Sprache der Oberfläche"** (die Änderung greift nach einem Neustart der App). -- **Compose-Fenster:** Text eintippen oder einfügen, einen Workflow und Schreibstil wählen und von der KI umschreiben lassen — ganz ohne Mikrofon. Mit Tonfall-Auswahl, eigenem Preset, Varianten-Verlauf und Signatur-Unterstützung. +- **Compose-Fenster:** Text eintippen, einfügen oder hineindiktieren, eine Text-Aktion wählen und von der KI umschreiben lassen — ganz ohne Mikrofon. Mit Tonfall-Auswahl, eigener Anweisung samt Prompt-Prüfung, Varianten-Navigation und Signatur-Unterstützung. - **OpenRouter & eigene LLM-Endpunkte:** Nutze OpenRouter oder eine beliebige OpenAI-kompatible API als Alternative zu OpenAI für alle KI-Workflows. - **Audio-Export:** Speichere die Ausgabe der Vorlesefunktion direkt als Audiodatei. - **Eigennamen / Begriffe:** Erweitere das Vokabular der KI um eigene Begriffe, Namen oder Fachwörter für perfekte Transkriptionen. @@ -53,21 +53,21 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast | Workflow | Hotkey | LLM? | Beschreibung | | :--- | :--- | :---: | :--- | -| **Blitztext** | Alt (halten) | ❌ | Standard: Nimmt auf, solange die Taste gehalten wird, transkribiert und fügt den Text ein. Sehr kurze Tastendrücke unter 150 ms werden im Halten-Modus als Fehldruck verworfen. Aufnahmetaste und Halten/Umschalten-Modus sind unter **Einstellungen → Spracherkennung** konfigurierbar. | -| **Blitztext Lokal** | Meta + Shift + H | ❌ | Erzwingt eine reine **Offline-Transkription**. | -| **Blitztext+** | Meta + Shift + T | ✅ | Formuliert deine Aufnahme professionell via LLM um. | -| **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotionale Entladung: Wandelt Frust in eine sachliche Nachricht um. | -| **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. | +| **Diktieren** | Alt (halten) | ❌ | Standard: Nimmt auf, solange die Taste gehalten wird, transkribiert und fügt den Text ein. Sehr kurze Tastendrücke unter 150 ms werden im Halten-Modus als Fehldruck verworfen. Aufnahmetaste und Halten/Umschalten-Modus sind unter **Einstellungen → Spracherkennung** konfigurierbar. | +| **Diktieren · lokal** | Meta + Shift + H | ❌ | Erzwingt eine reine **Offline-Transkription**. | +| **Diktieren & verbessern** | Meta + Shift + T | ✅ | Transkribiert und schreibt das Ergebnis mit der gewählten Text-Aktion um. | +| **Sachlich formulieren** | Meta + Shift + D | ✅ | Emotionale Entladung: Wandelt Frust in eine sachliche Nachricht um. | +| **Emojis ergänzen** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. | > [!NOTE] -> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **API-Key** voraus. Siehe [Secrets](#secrets) weiter unten für die Konfiguration. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung. +> **LLM-Workflows** (`Diktieren & verbessern`, `Sachlich formulieren`, `Emojis ergänzen`) setzen einen gültigen **API-Key** voraus. Siehe [Secrets](#secrets) weiter unten für die Konfiguration. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung. ### KI-Workflows -Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstellungen findest du unter **Einstellungen → KI-Workflows**: +Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstellungen findest du unter **Einstellungen → Text & KI**:
- KI-Workflow-Einstellungen + Einstellungen: Text & KI

@@ -81,7 +81,7 @@ Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstell > systemd-User-Service laden diese Datei beim nächsten Start. Eine vollständige > Anleitung mit Dateiformat und Berechtigungen steht unter [Secrets](#secrets). -**LLM-Anbieter.** Blitztext unterstützt drei Anbieter-Modi, wählbar unter **Einstellungen → KI-Workflows → „LLM-Anbieter"**: +**LLM-Anbieter.** Blitztext unterstützt drei Anbieter-Modi, wählbar unter **Einstellungen → Text & KI → „LLM-Anbieter"**: | Anbieter | Wann verwenden | | :--- | :--- | @@ -91,52 +91,57 @@ Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstell Für OpenRouter `base_url` auf `https://openrouter.ai/api/v1` setzen und Modell wählen (z. B. `openai/gpt-4o`). Der Name der API-Key-Umgebungsvariable wird unter „API-Key-Umgebung" eingestellt. -**Schreibstil-Vorlagen.** Für den Workflow **Blitztext+** (Text-Verbesserer) gibt es vorgefertigte Schreibstil-Vorlagen, die du unter **Einstellungen → KI-Workflows → „Schreibstil-Vorlage"** oder direkt im **Compose-Fenster** auswählst: +**Text-Aktionen.** Das Umschreiben läuft über fünf klar getrennte Aktionen. Du wählst sie unter **Einstellungen → Text & KI → „Text-Aktion"**, im Compose-Fenster oder im ⋯-Menü des Haupt-Widgets: -| Vorlage | Wirkung | +| Text-Aktion | Wirkung | | --- | --- | -| **Standard (Text verbessern)** | Bisheriges Verhalten – sauber formatierter Text, der gewählte **Tonfall** greift. | -| **E-Mail – formell** | Höfliche E-Mail in der Sie-Form mit klarer Struktur. | -| **E-Mail – locker** | Freundliche E-Mail in der Du-Form. | -| **Stichpunkte** | Gliedert den Inhalt in prägnante Stichpunkte. | -| **Zusammenfassung** | Knappe, sachliche Zusammenfassung der Kernaussagen. | -| **Persönlich (Du-Form)** | Klarer Text in der persönlichen Du-Form. | -| **Höflich (Sie-Form)** | Klarer Text in der höflichen Sie-Form. | -| **Kurz & präzise** | Maximal knapp, ohne Füllwörter und Wiederholungen. | -| **Eigenes Preset…** | Ein freier System-Prompt, den du selbst unter **Einstellungen → Allgemein → „Eigenes Preset (Compose)"** festlegst. | - -> Bei **Standard** wird zusätzlich der eingestellte **Tonfall** angewendet. Jede andere Vorlage bringt ihren eigenen Schreibstil mit und überschreibt den Tonfall. Eigennamen/Begriffe bleiben in allen Vorlagen erhalten. +| **Text verbessern** | Standard: sauber formatierter Text mit korrigierter Grammatik und Zeichensetzung; der gewählte **Ziel-Tonfall** greift. | +| **Kürzen** | Entfernt Füllwörter, Wiederholungen und Umwege, behält aber alle wesentlichen Informationen. | +| **Ausformulieren** | Macht aus Notizen und Fragmenten zusammenhängenden Fließtext — ohne neue Fakten zu ergänzen. | +| **Tonfall ändern** | Ändert gezielt nur den Tonfall (locker / neutral / professionell) und lässt Inhalt, Sprache und Absicht unverändert. | +| **Eigene Anweisung** | Deine eigene Anweisung aus dem Compose-Fenster („Wie soll dein Text klingen?“). **Speichern & für Diktate verwenden** überträgt sie zusätzlich auf den Workflow „Diktieren & verbessern“. | + +> Der **Ziel-Tonfall** wirkt bei **Text verbessern** und **Tonfall ändern**. Ältere Konfigurationen mit den früheren neun Schreibstil-Vorlagen werden automatisch auf die nächstliegende Aktion migriert (z. B. „E-Mail – formell“ → **Tonfall ändern**, professionell). Eigennamen/Begriffe bleiben in allen Aktionen erhalten. ### Compose-Fenster -Das **Compose-Fenster** (`✍ Compose…` im Tray-Kontextmenü) ermöglicht das Umschreiben beliebiger Texte mit der KI — ganz ohne Sprachaufnahme. Es eignet sich ideal zum Überarbeiten fertiger Entwürfe, E-Mails oder Notizen. +Das **Compose-Fenster** (`✍ Text bearbeiten…` im Tray-Menü oder über das Stift-Symbol im Hauptfenster-Widget) ermöglicht das Entwerfen, Umschreiben und Verfeinern von Texten mit der KI — ganz ohne Sprachaufnahme. Es eignet sich ideal zum Überarbeiten von E-Mails, Notizen oder Nachrichten, bevor sie eingefügt werden.

- Compose-Fenster + Compose-Fenster

-**Öffnen:** Klick auf das Tray-Icon → **✍ Compose…** +**Öffnen:** Klick auf das Tray-Icon → **✍ Text bearbeiten…** oder Klick auf das goldene Stift-Symbol im Hauptfenster-Widget. **Was du im Compose-Fenster tun kannst:** | Element | Beschreibung | | :--- | :--- | -| **Entwurf (linkes Feld)** | Text eintippen oder einfügen, der umgeschrieben werden soll. | -| **Workflow** | Wähle zwischen Blitztext+ (Text-Verbesserer), Blitztext $%&! (Dampfablassen) oder Blitztext :) (Emojis). | -| **Schreibstil-Vorlage** | Vorlage auswählen oder **Eigenes Preset…** für einen vollständig freien System-Prompt. | -| **Tonfall** | Locker, neutral oder professionell. Aktiv nur bei **Standard**-Preset + **Blitztext+**; bei allen anderen Vorlagen ausgegraut (Tooltip erklärt warum). | -| **Verbessern** | Sendet den Entwurf an die KI und zeigt das Ergebnis im rechten Feld. | -| **Varianten-Verlauf** | Die letzten 10 generierten Ergebnisse der aktuellen Sitzung werden als scrollbare Liste gespeichert — Klick auf einen Eintrag stellt ihn wieder her. | -| **Signatur** | Hängt deine gespeicherte Signatur an (konfiguriert unter **Einstellungen → Allgemein**). Ersetzt automatisch gängige KI-generierte Platzhalter wie `[Your Name]`, `[Ihr Name]`, `[Vorname Nachname]`, `[Signature]` u. Ä. — kein verlorener Platzhalter bleibt zurück. | +| **Text-Aktion** | Wähle zwischen *Text verbessern*, *Kürzen*, *Ausformulieren*, *Tonfall ändern* oder *Eigene Anweisung*. | +| **Tonfall** | Erscheint dynamisch bei Auswahl von *Tonfall ändern* (Locker, Neutral, Professionell). | +| **Eigene Anweisung …** | Klappt eine eigene Prompt-Karte aus (*„Wie soll dein Text klingen?“*). **Verbessern** wendet sie für den aktuellen Entwurf an; **Speichern & für Diktate verwenden** speichert sie dauerhaft für kommende Sitzungen und Diktat-Workflows. | +| **Prompt prüfen** | Zeigt den exakt zusammengestellten System-Prompt samt Anweisungen vor dem Senden an das Sprachmodell. | +| **Sprachaufnahme hierher umleiten** | Ist diese Option aktiv, landen laufende Diktataufnahmen direkt im Entwurfsfeld statt in der aktiven Anwendung. | +| **Entwurf (linkes Feld)** | Text eintippen, einfügen oder direkt per Sprache hineindiktieren. | +| **Ergebnis (rechtes Feld)** | Zeigt die KI-generierte Fassung direkt neben dem Ausgangstext. | +| **Varianten-Navigation** | Die in dieser Sitzung erzeugten Ergebnisse bleiben verfügbar — über die Pfeile ‹ › oberhalb des Ergebnisfelds durchblätterbar. | | **Kopieren** | Kopiert das Ergebnis in die Zwischenablage. | -| **Einfügen & Schließen** | Fügt das Ergebnis direkt in die aktive Anwendung ein und schließt das Fenster. | +| **Einfügen & Schließen** | Fügt das Ergebnis direkt in die aktive Anwendung ein und schließt das Compose-Fenster. | + +
+
+ Compose-Fenster mit eigener Anweisung und Prompt-Prüfung +
+ Eigene KI-Anweisungen direkt im Compose-Fenster formulieren und den System-Prompt vorab prüfen. +

+
> [!NOTE] -> Signatur und eigener Preset-Text werden unter **Einstellungen → Allgemein** konfiguriert. Setze dort „Signatur für das Compose-Fenster" und aktiviere „Nach jeder Generierung automatisch anhängen", wenn die Signatur bei jedem Ergebnis ergänzt werden soll. +> Signatur und eigener Preset-Text werden unter **Einstellungen → Allgemein** konfiguriert („Signatur für Compose-Fenster“ und „Automatisch nach Generierung anfügen“). Gängige KI-Platzhalter wie `[Ihr Name]` oder `[Your Name]` werden dabei automatisch sauber ersetzt. -### Tray-Symbol: Statusfarben +### Tray-Symbol und Kontextmenü Das Mikrofon im System-Tray ist dein Indikator für den aktuellen Zustand: @@ -167,35 +172,61 @@ Das Mikrofon im System-Tray ist dein Indikator für den aktuellen Zustand: -Das Tray-Kontextmenü gibt dir schnellen Zugriff auf alle Workflows, das Compose-Fenster, Schreibstil-Vorlagen, Diktat-Modus, Verlauf und Einstellungen: +Das Tray-Kontextmenü gibt dir schnellen Zugriff auf alle Workflows, das Compose-Fenster, Text-Aktionen, Diktat-Modus, Verlauf und Einstellungen:

- Tray-Kontextmenü + Tray-Kontextmenü

> [!NOTE] > Steht im Desktop-Environment kein Tray-Bereich zur Verfügung, fällt das Icon auf das System-Theme `audio-input-microphone` zurück; die Farbkodierung greift dann ggf. nicht. -### Hauptfenster +### Hauptfenster: Single-Panel-Widget -Das Hauptfenster ist dein grafisches Kontrollzentrum — nützlich, wenn Hotkeys blockiert sind oder du lieber mit der Maus arbeitest: +Das Hauptfenster ist ein kompaktes, rahmenloses Desktop-Widget mit einheitlicher dunkler Ästhetik, edlen Gold-Akzenten und direkter visueller Rückmeldung:
-
- Hauptfenster -

+ + + + + + + +
+

+ Bereit
+ Wartet auf Sprach- oder Mausklick-Eingabe. +
+

+ Aufnahme
+ Laufender Timer und Audiowellen-Balken. +
+

+ Verarbeitung
+ Rotierender goldener Fortschrittsring. +
+

+ Aktions-Menü
+ Workflows und Schnellzugriff direkt am Widget. +
-- **Workflow-Dropdown:** Alle 5 Aufnahmemodi zur Auswahl. -- **Schreibstil-Vorlage:** Sichtbar wenn **Blitztext+** gewählt ist — Preset direkt im Hauptfenster wählen. Änderungen werden sofort mit dem Tray synchronisiert. -- **Start/Stopp-Button:** Klick zum Starten oder Beenden einer Aufnahme. -- **Abbruch:** Bricht die aktuelle Aufnahme ohne Transkription ab. -- **Diktat / Verlauf:** Schnellzugriff auf den Diktat-Modus und den Transkript-Verlauf. -- **Vorlesen / Einstellungen:** Öffnet das Vorlese-Fenster oder den Einstellungs-Dialog. +- **Zentrales Gold-Mikrofon:** Markanter kreisrunder Button mit metallischem Farbverlauf und Blitz-Aussparung. Klick startet oder beendet die Aufnahme. +- **Dynamische visuelle Rückmeldung:** + - **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…`). +- **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*. -*Das Fenster öffnet sich beim Start sowie über den Tray-Eintrag **Fenster anzeigen** oder einen Klick auf das Tray-Icon. Schließen versteckt das Fenster nur — die App läuft im Tray weiter.* +*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.* ### Diktat, Verlauf und Vorlesen @@ -203,7 +234,8 @@ Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen:

- Verlauf + Verlauf +    Vorlesen

@@ -358,10 +390,10 @@ Der Einstellungs-Dialog hat drei Tabs:
Einstellungen: Spracherkennung
Spracherkennung — Whisper-Modell, Backend, Sprache, Hotkey-Modus und Aufnahmetaste.

- Einstellungen: KI-Workflows -
KI-Workflows — LLM-Anbieter, API-Key, Base-URL, Modell, Tonfall und Schreibstil-Vorlage.

+ Einstellungen: Text & KI +
Text & KI — System-Prompt, API-Key-Umgebung, LLM-Anbieter, Base-URL, Modell, Text-Aktion und Emoji-Dichte.

Einstellungen: Allgemein -
Allgemein — Auto-Paste, Diktat-Ordner, Verlaufsgröße, Sprache der Oberfläche und Signatur.

+
Allgemein — Auto-Paste, Diktat-Notizordner, Verlaufsgröße, Sprache der Oberfläche und Signatur.

> [!IMPORTANT] @@ -429,7 +461,7 @@ Der Einstellungs-Dialog hat drei Tabs: - **tts_openai_model** / **tts_openai_voice**: Modell und Stimme für OpenAI Cloud-TTS (Standard: `gpt-4o-mini-tts`, `marin`). - **tts_openai_consent**: `true`, sobald die einmalige Datenschutz-Bestätigung für Cloud-TTS erteilt wurde. Standard: `false`. - **tts_speed**: Sprechgeschwindigkeit für „Vorlesen" als Multiplikator. Standard: `1.0`. -- **workflows**: Feintuning von Tonalität (`text_improver_tone`), Schreibstil-Vorlage (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`). +- **workflows**: Feintuning von Tonalität (`text_improver_tone`), Text-Aktion (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`). --- @@ -438,7 +470,7 @@ Der Einstellungs-Dialog hat drei Tabs: API-Keys werden niemals in `config.json` gespeichert — sie werden zur Laufzeit aus Umgebungsvariablen gelesen. -**Empfohlen: `secrets.env`.** Lege deine(n) Key(s) in `~/.config/blitztext-linux/secrets.env` ab, ein `NAME=WERT`-Paar pro Zeile — der Variablenname richtet sich nach dem unter **Einstellungen → KI-Workflows → „API-Key-Umgebung"** gewählten Anbieter (z. B. die OpenAI-Variable für OpenAI, die OpenRouter-Variable für OpenRouter), `WERT` ist der geheime Key von diesem Anbieter: +**Empfohlen: `secrets.env`.** Lege deine(n) Key(s) in `~/.config/blitztext-linux/secrets.env` ab, ein `NAME=WERT`-Paar pro Zeile — der Variablenname richtet sich nach dem unter **Einstellungen → Text & KI → „API-Key-Umgebung"** gewählten Anbieter (z. B. die OpenAI-Variable für OpenAI, die OpenRouter-Variable für OpenRouter), `WERT` ist der geheime Key von diesem Anbieter: ```bash = @@ -446,8 +478,8 @@ API-Keys werden niemals in `config.json` gespeichert — sie werden zur Laufzeit `./run.sh` und der systemd-User-Service laden diese Datei automatisch. `config.json` speichert nur den *Namen* der zu lesenden Umgebungsvariable (`openai_api_key_env`), niemals den Key selbst. -- Welche Variable verwendet wird, hängt von **Einstellungen → KI-Workflows → „API-Key-Umgebung"** und dem gewählten LLM-Anbieter ab (`OPENAI_API_KEY` für OpenAI, `OPENROUTER_API_KEY` für OpenRouter, oder ein eigener Name für einen eigenen Endpunkt). -- Ohne gültigen Key sind die LLM-Workflows (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) und OpenAI Cloud-TTS deaktiviert bzw. schlagen mit einer Fehlermeldung fehl; lokale Transkription und Piper-TTS funktionieren weiterhin. +- Welche Variable verwendet wird, hängt von **Einstellungen → Text & KI → „API-Key-Umgebung"** und dem gewählten LLM-Anbieter ab (`OPENAI_API_KEY` für OpenAI, `OPENROUTER_API_KEY` für OpenRouter, oder ein eigener Name für einen eigenen Endpunkt). +- Ohne gültigen Key sind die LLM-Workflows (`Diktieren & verbessern`, `Sachlich formulieren`, `Emojis ergänzen`) und OpenAI Cloud-TTS deaktiviert bzw. schlagen mit einer Fehlermeldung fehl; lokale Transkription und Piper-TTS funktionieren weiterhin. - Committe niemals `secrets.env`, API-Keys oder Tokens in git. Sollte ein Key jemals offengelegt werden (z. B. versehentlich committet oder in ein Issue eingefügt), rotiere ihn sofort beim Anbieter. - `config.json` wird mit restriktiven Rechten (`0o600`) geschrieben; die gleiche Erwartung gilt für `secrets.env`. @@ -514,7 +546,7 @@ Dieses Projekt wurde mit Unterstützung künstlicher Intelligenz (AI-assisted) e │ ├── transcribe.py # Whisper-Transkription │ ├── tts_window.py # Vorlese-Fenster mit Audio-Export │ ├── workflows.py # Workflow-Definitionen -│ └── writing_presets.py # Schreibstil-Vorlagen-Definitionen +│ └── writing_presets.py # Text-Aktionen und Migration alter Presets ├── packaging/flatpak/ # Experimenteller Flatpak-MVP-Spike (siehe oben) ├── tests/ # Test-Suite └── README.md # Englische Fassung (diese Datei: README.de.md) diff --git a/README.md b/README.md index 4ceaa6e..dc530f3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- Blitztext Linux Banner + Blitztext Linux Banner

Blitztext Linux

Your local AI voice assistant for Linux desktops on Wayland

@@ -38,7 +38,7 @@ Blitztext Linux is a hotkey-driven voice assistant that turns speech into text a ## Features - **Multilingual interface (EN/DE):** Switch the app interface between German and English under **Settings → General → "Interface language"** (takes effect after restarting the app). -- **Compose window:** Type or paste any text, select a workflow and writing style, and let the AI rewrite it — no microphone needed. Includes tone selector, custom preset, variant history, and signature support. +- **Compose window:** Type, paste, or dictate any text, pick a text action, and let the AI rewrite it — no microphone needed. Includes tone selector, custom instruction with prompt inspection, variant navigation, and signature support. - **OpenRouter & custom LLM endpoints:** Use OpenRouter or any OpenAI-compatible API as an alternative to OpenAI for all AI workflows. - **Audio export:** Save read-aloud output as an audio file directly from the Read Aloud window. - **Custom names / terms:** Extend the AI's vocabulary with your own terms, names, or technical words for perfect transcriptions. @@ -53,21 +53,21 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have | Workflow | Hotkey | LLM? | Description | | :--- | :--- | :---: | :--- | -| **Blitztext** | Alt (hold) | ❌ | Default: records while the key is held, transcribes, and pastes the text. Very short presses below 150 ms are discarded as accidental taps in hold mode. Recording key and hold/toggle mode are configurable under **Settings → Speech Recognition**. | -| **Blitztext Local** | Meta + Shift + H | ❌ | Forces a pure **offline transcription**. | -| **Blitztext+** | Meta + Shift + T | ✅ | Rephrases your recording professionally via LLM. | -| **Blitztext $%&!** | Meta + Shift + D | ✅ | Emotional release: turns frustration into a matter-of-fact message. | -| **Blitztext :)** | Meta + Shift + E | ✅ | Enriches your message with fitting emojis. | +| **Dictate** | Alt (hold) | ❌ | Default: records while the key is held, transcribes, and pastes the text. Very short presses below 150 ms are discarded as accidental taps in hold mode. Recording key and hold/toggle mode are configurable under **Settings → Speech Recognition**. | +| **Dictate · local** | Meta + Shift + H | ❌ | Forces a pure **offline transcription**. | +| **Dictate & improve** | Meta + Shift + T | ✅ | Transcribes and rewrites the result with the selected text action. | +| **Make it factual** | Meta + Shift + D | ✅ | Emotional release: turns frustration into a matter-of-fact message. | +| **Add emojis** | Meta + Shift + E | ✅ | Enriches your message with fitting emojis. | > [!NOTE] -> **LLM workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) require a valid **API key**. See [Secrets](#secrets) below for how to configure it. Without a key, these functions are disabled in the menu and via hotkeys, or result in an error message. +> **LLM workflows** (`Dictate & improve`, `Make it factual`, `Add emojis`) require a valid **API key**. See [Secrets](#secrets) below for how to configure it. Without a key, these functions are disabled in the menu and via hotkeys, or result in an error message. ### AI workflows -The AI workflows help with phrasing, tone, and emojis. You'll find the relevant settings under **Settings → AI Workflows**: +The AI workflows help with phrasing, tone, and emojis. You'll find the relevant settings under **Settings → Text & AI**:
- AI workflow settings + Settings: Text & AI

@@ -80,7 +80,7 @@ The AI workflows help with phrasing, tone, and emojis. You'll find the relevant > and the systemd user service load that file on the next start. See > [Secrets](#secrets) for the complete file format and permission instructions. -**LLM providers.** Blitztext supports three provider modes, selectable under **Settings → AI Workflows → "LLM provider"**: +**LLM providers.** Blitztext supports three provider modes, selectable under **Settings → Text & AI → "LLM provider"**: | Provider | When to use | | :--- | :--- | @@ -90,50 +90,55 @@ The AI workflows help with phrasing, tone, and emojis. You'll find the relevant For OpenRouter, set `base_url` to `https://openrouter.ai/api/v1` and choose your model (e.g. `openai/gpt-4o`). The API key environment variable name is configured under "API key environment". -**Writing-style presets.** For the **Blitztext+** workflow (text improver) there are ready-made writing-style presets that you select under **Settings → AI Workflows → "Writing-style preset"** or directly in the **Compose window**: +**Text actions.** Rewriting is built around five clearly separated actions. You select them under **Settings → Text & AI → "Text action"**, in the Compose window, or in the ⋯ menu of the main widget: -| Preset | Effect | +| Text action | Effect | | --- | --- | -| **Standard (improve text)** | Previous behavior – cleanly formatted text, the selected **tone** applies. | -| **Email – formal** | Polite email in the formal form with a clear structure. | -| **Email – casual** | Friendly email in the informal form. | -| **Bullet points** | Structures the content into concise bullet points. | -| **Summary** | Concise, factual summary of the key statements. | -| **Personal (informal)** | Clear text in a personal, informal tone. | -| **Polite (formal)** | Clear text in a polite, formal tone. | -| **Short & precise** | As concise as possible, without filler words and repetitions. | -| **Custom preset…** | A free-form system prompt you define yourself under **Settings → General → "Custom preset (Compose)"**. | - -> With **Standard**, the configured **tone** (casual / neutral / professional) is additionally applied. Every other preset brings its own writing style and overrides the tone setting. Custom names/terms are preserved in all presets. +| **Improve text** | Default: cleanly formatted text with corrected grammar and punctuation; the selected **target tone** applies. | +| **Shorten** | Removes filler words, repetitions, and detours while keeping every essential piece of information. | +| **Expand** | Turns notes and fragments into coherent prose — without adding new facts. | +| **Change tone** | Changes the tone only (casual / neutral / professional) and leaves content, language, and intent untouched. | +| **Custom instruction** | Your own instruction from the Compose window ("How should your text sound?"). **Save & use for dictation** also applies it to the Dictate & improve workflow. | + +> The **target tone** applies to **Improve text** and **Change tone**. Older configurations with the previous nine writing-style presets are migrated automatically to the closest action (e.g. "Email – formal" → **Change tone**, professional). Custom names/terms are preserved in every action. ### Compose window -The **Compose window** (`✍ Compose…` in the tray menu) lets you rewrite any text using the AI — without recording your voice. It is ideal for editing existing drafts, emails, or notes. +The **Compose window** (`✍ Edit text…` in the tray menu or the pen button in the main widget) lets you draft, rewrite, and refine text using the AI — without recording your voice. It is ideal for polishing emails, notes, or messages before pasting them anywhere.

- Compose window + Compose window

-**How to open:** Click the tray icon → **✍ Compose…** +**How to open:** Click the tray icon → **✍ Edit text…** or click the gold pen button in the main window widget. **What you can do in the Compose window:** | Element | Description | | :--- | :--- | -| **Draft (left pane)** | Type or paste the text you want to rewrite. | -| **Workflow** | Choose between Blitztext+ (text improver), Blitztext $%&! (steam release), or Blitztext :) (emojis). | -| **Writing-style preset** | Select a preset or **Custom preset…** for a fully custom system prompt. | -| **Tone** | Choose casual, neutral, or professional. Active only when **Standard** preset + **Blitztext+** is selected; grayed out for all other presets (a tooltip explains why). | -| **Improve** | Sends your draft to the AI and shows the result in the right pane. | -| **Variant history** | The last 10 generated results within the current session are kept as a scrollable list — click any entry to restore it. | -| **Signature** | Appends your saved signature (configured under **Settings → General**). Automatically replaces common AI-generated placeholders such as `[Your Name]`, `[Ihr Name]`, `[Vorname Nachname]`, `[Signature]`, and similar — so no stray placeholder is ever left behind. | -| **Copy** | Copies the result to the clipboard. | -| **Insert & Close** | Pastes the result directly into the active application and closes the window. | +| **Text action selector** | Choose between *Improve text*, *Shorten*, *Expand*, *Change tone*, or *Custom instruction*. | +| **Tone selector** | Appears dynamically when *Change tone* is selected (Casual, Neutral, Professional). | +| **Custom instruction …** | Expands an inline prompt editor (*"How should your text sound?"*). **Improve** uses this instruction for the current draft; **Save & use for dictation** persists it for future sessions and selects it for dictation workflows. | +| **Inspect prompt** | Inspect the exact system prompt and instructions before submitting to the LLM. | +| **Route voice input here** | When checked, active voice dictations are inserted directly into the draft field instead of being pasted to the active application. | +| **Draft (left pane)** | Type, paste, or dictate the text you want to rewrite. | +| **Result (right pane)** | Shows the AI-generated output side by side with your draft. | +| **Variant navigation** | The results generated in this session stay available — step through them with the ‹ › arrows above the result pane. | +| **Copy** | Copies the generated result to the clipboard. | +| **Insert & Close** | Pastes the result directly into the active application and closes the compose window. | + +
+
+ Compose window with custom prompt inspection +
+ Inspect and edit the LLM instruction directly inside the Compose window. +

+
> [!NOTE] -> The signature and custom preset text are configured under **Settings → General**. Set "Signature for Compose window" and toggle "Automatically append after generation" if you want the signature added to every result. +> Signature and custom preset text are configured under **Settings → General**. Set "Compose Window Signature" and toggle "Auto-append after generation" if you want your signature automatically added to every result. Common AI placeholders (like `[Your Name]`) are cleanly replaced. ### Tray icon and context menu @@ -166,35 +171,61 @@ The microphone in the system tray is your indicator of the current state:
-The tray context menu gives you quick access to all workflows, the compose window, writing-style presets, dictation mode, history, and settings: +The tray context menu gives you quick access to all workflows, the compose window, text actions, dictation mode, history, and settings:

- Tray context menu + Tray context menu

> [!NOTE] > If no tray area is available in the desktop environment, the icon falls back to the system theme `audio-input-microphone`; the color coding may then not apply. -### Main window +### Main window: Single-panel widget -The main window is your graphical control center — useful when hotkeys are blocked or you prefer mouse control: +The main window is a compact, frameless desktop widget with a consistent dark look, gold accents, and immediate visual feedback:
-
- Main window -

+ + + + + + + +
+

+ Idle
+ Ready, standing by for input. +
+

+ Recording
+ Live timer and stereo wave bars. +
+

+ Processing
+ Animated gold spinning ring. +
+

+ Options menu
+ Workflows and quick actions. +
-- **Workflow dropdown:** Select from all 5 recording modes. -- **Writing-style preset:** Visible when **Blitztext+** is selected — pick your preset directly in the main window. Changes sync to the tray instantly. -- **Start/Stop button:** Click to begin or end a recording. -- **Discard:** Cancels the current recording without transcription. -- **Dictation / History:** Quick access to dictation mode and the transcript history. -- **Read aloud / Settings:** Open the read-aloud window or the settings dialog. +- **Central gold microphone:** Prominent circular button with metallic gradient and lightning bolt cut-out. Click to toggle recording on and off. +- **Dynamic visual feedback:** + - **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…`). +- **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*. -*The window opens at startup and via the tray entry **Show window** or a click on the tray icon. Closing only hides the window — the app keeps running in the tray.* +*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.* ### Dictation, history, and read-aloud @@ -202,7 +233,8 @@ In addition to the workflows, the tool offers three convenience functions:

- History + History +    Read aloud

@@ -356,8 +388,8 @@ The settings dialog has three tabs:
Settings: Speech Recognition
Speech Recognition — Whisper model, backend, language, hotkey mode, and recording key.

- Settings: AI Workflows -
AI Workflows — LLM provider, API key, base URL, model, tone, and writing-style preset.

+ Settings: Text & AI +
Text & AI — system prompt, API key environment, LLM provider, base URL, model, text action, and emoji density.

Settings: General
General — Auto-Paste, dictation folder, history size, interface language, and signature.

@@ -427,7 +459,7 @@ The settings dialog has three tabs: - **tts_openai_model** / **tts_openai_voice**: Model and voice for OpenAI Cloud TTS (default: `gpt-4o-mini-tts`, `marin`). - **tts_openai_consent**: `true` once the one-time privacy confirmation for Cloud TTS has been granted. Default: `false`. - **tts_speed**: Speech speed multiplier for "Read aloud". Default: `1.0`. -- **workflows**: Fine-tuning of tonality (`text_improver_tone`), writing-style preset (`writing_preset`), emojis (`emoji_density`), and the steam-release prompt (`dampf_system_prompt`). +- **workflows**: Fine-tuning of tonality (`text_improver_tone`), text action (`writing_preset`), emojis (`emoji_density`), and the steam-release prompt (`dampf_system_prompt`). --- @@ -436,7 +468,7 @@ The settings dialog has three tabs: API keys are never stored in `config.json` — they are read from environment variables at runtime. -**Recommended: `secrets.env`.** Place your key(s) in `~/.config/blitztext-linux/secrets.env`, one `NAME=VALUE` pair per line — the variable name matches the provider selected under **Settings → AI Workflows → "API key environment"** (e.g. the OpenAI variable for OpenAI, the OpenRouter variable for OpenRouter), and `VALUE` is the secret key you got from that provider: +**Recommended: `secrets.env`.** Place your key(s) in `~/.config/blitztext-linux/secrets.env`, one `NAME=VALUE` pair per line — the variable name matches the provider selected under **Settings → Text & AI → "API key environment"** (e.g. the OpenAI variable for OpenAI, the OpenRouter variable for OpenRouter), and `VALUE` is the secret key you got from that provider: ```bash = @@ -444,8 +476,8 @@ API keys are never stored in `config.json` — they are read from environment va `./run.sh` and the systemd user service load this file automatically. `config.json` only stores the *name* of the environment variable to read (`openai_api_key_env`), never the key itself. -- Which variable is used depends on **Settings → AI Workflows → "API key environment"** and the selected LLM provider (`OPENAI_API_KEY` for OpenAI, `OPENROUTER_API_KEY` for OpenRouter, or a custom name for a custom endpoint). -- Without a valid key, the LLM workflows (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) and OpenAI Cloud TTS are disabled or fail with an error message; local transcription and Piper TTS keep working. +- Which variable is used depends on **Settings → Text & AI → "API key environment"** and the selected LLM provider (`OPENAI_API_KEY` for OpenAI, `OPENROUTER_API_KEY` for OpenRouter, or a custom name for a custom endpoint). +- Without a valid key, the LLM workflows (`Dictate & improve`, `Make it factual`, `Add emojis`) and OpenAI Cloud TTS are disabled or fail with an error message; local transcription and Piper TTS keep working. - Never commit `secrets.env`, API keys, or tokens to git. If a key is ever exposed (e.g. committed by accident or pasted into an issue), rotate it immediately with the provider. - `config.json` is written with restrictive permissions (`0o600`); keep the same expectation for `secrets.env`. @@ -512,7 +544,7 @@ This project was designed with the support of artificial intelligence (AI-assist │ ├── transcribe.py # Whisper transcription │ ├── tts_window.py # Read Aloud window with audio export │ ├── workflows.py # Workflow definitions -│ └── writing_presets.py # Writing-style preset definitions +│ └── writing_presets.py # Text action definitions and legacy preset migration ├── packaging/flatpak/ # Experimental Flatpak MVP spike (see above) ├── tests/ # Test suite └── README.md # This document (German version: README.de.md) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 5499319..5369c73 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -51,8 +51,9 @@ from app.compose_window import ComposeWindow from app.tts_window import TtsWindow from app.main_window import MainWindow -from app.i18n import LANGUAGES, LANGUAGE_DISPLAY_NAMES, set_language, t +from app.i18n import LANGUAGES, LANGUAGE_DISPLAY_NAMES, set_language, t, tq from app import notify as notify_service +from app import theme from app import __version__ as APP_VERSION # Set up module logger @@ -155,12 +156,9 @@ def resizeEvent(self, event) -> None: # type: ignore[override] def create_help_label(text: str) -> QLabel: """Create a styled small help label for config fields.""" - from app import theme - label = QLabel(text) - label.setStyleSheet( - f"color: {theme.APP_TEXT_DIM}; font-size: 11px; padding: 1px 0 8px 2px;" - ) + label.setProperty("fieldHelp", True) + label.setProperty("role", "secondary") label.setWordWrap(True) return label @@ -170,9 +168,10 @@ class SettingsDialog(QDialog): def __init__(self, config: Config, parent: Optional[QWidget] = None) -> None: super().__init__(parent) + self.setObjectName("appDialog") self.config = config self.setWindowTitle(t("settings.window_title")) - self.resize(580, 560) + self.resize(520, 430) self.init_ui() @staticmethod @@ -187,8 +186,8 @@ def _scrollable(content: QWidget) -> QScrollArea: def init_ui(self) -> None: layout = QVBoxLayout(self) - layout.setContentsMargins(15, 15, 15, 15) - layout.setSpacing(15) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) # Tabs self.tabs = QTabWidget() @@ -241,7 +240,7 @@ def init_ui(self) -> None: form_whisper.addRow(t("settings.record_key.label"), self.combo_transcription_key) form_whisper.addRow(create_help_label(t("settings.record_key.help"))) - self.tabs.addTab(self._scrollable(tab_whisper), t("settings.tab.speech")) + self.tabs.addTab(self._scrollable(tab_whisper), tq("settings.tab.speech")) # Tab 2: LLM (KI) tab_llm = QWidget() @@ -265,7 +264,7 @@ def init_ui(self) -> None: t("settings.api_key.legacy_notice") ) self.lbl_legacy_api_key_notice.setWordWrap(True) - self.lbl_legacy_api_key_notice.setStyleSheet("color: #b26a00; font-size: 10px;") + self.lbl_legacy_api_key_notice.setProperty("status", "warning") api_key_layout.addWidget(self.lbl_legacy_api_key_notice) else: self.lbl_legacy_api_key_notice = None @@ -291,7 +290,7 @@ def init_ui(self) -> None: t("settings.base_url.unsafe_legacy_notice") ) self.lbl_unsafe_llm_base_url_notice.setWordWrap(True) - self.lbl_unsafe_llm_base_url_notice.setStyleSheet("color: #b26a00; font-size: 10px;") + self.lbl_unsafe_llm_base_url_notice.setProperty("status", "warning") base_url_layout.addWidget(self.lbl_unsafe_llm_base_url_notice) else: self.lbl_unsafe_llm_base_url_notice = None @@ -329,8 +328,16 @@ def init_ui(self) -> None: self.edit_compose_custom_preset.setMaximumHeight(110) self.combo_emoji = QComboBox() - self.combo_emoji.addItems(["wenig", "mittel", "viel"]) - self.combo_emoji.setCurrentText(self.config.emoji_density) + for density, label_key in ( + ("wenig", "settings.emoji_density.low"), + ("mittel", "settings.emoji_density.medium"), + ("viel", "settings.emoji_density.high"), + ): + # The stored value stays language independent; only the caption is translated. + self.combo_emoji.addItem(t(label_key), density) + self.combo_emoji.setCurrentIndex( + max(0, self.combo_emoji.findData(self.config.emoji_density)) + ) self.edit_dampf_prompt = QPlainTextEdit() self.edit_dampf_prompt.setPlainText(self.config.dampf_system_prompt) @@ -373,18 +380,21 @@ def init_ui(self) -> None: form_llm.addRow(create_help_label(t("settings.llm_model.help"))) form_llm.addRow(t("settings.tone.label"), self.combo_tone) + self._llm_form = form_llm + self.combo_writing_preset.currentIndexChanged.connect(self._update_settings_tone) form_llm.addRow(t("settings.writing_preset.label"), self.combo_writing_preset) form_llm.addRow(create_help_label(t("settings.writing_preset.help"))) - form_llm.addRow(t("settings.compose_custom_preset.label"), self.edit_compose_custom_preset) - form_llm.addRow(create_help_label(t("settings.compose_custom_preset.help"))) + form_llm.insertRow(0, t("compose.custom.label"), self.edit_compose_custom_preset) + form_llm.insertRow(1, create_help_label(t("compose.custom.settings_help"))) form_llm.addRow(t("settings.emoji_density.label"), self.combo_emoji) form_llm.addRow(t("settings.dampf_prompt.label"), self.edit_dampf_prompt) form_llm.addRow(create_help_label(t("settings.dampf_prompt.help"))) form_llm.addRow(t("settings.custom_terms.label"), custom_terms_widget) form_llm.addRow(create_help_label(t("settings.custom_terms.help"))) + self._update_settings_tone() - self.tabs.addTab(self._scrollable(tab_llm), t("settings.tab.workflows")) + self.tabs.addTab(self._scrollable(tab_llm), tq("settings.tab.workflows")) # Tab 3: Allgemein tab_general = QWidget() @@ -433,19 +443,60 @@ def init_ui(self) -> None: # Dezente Versionsanzeige ganz unten auf der letzten Einstellungsseite version_label = QLabel(t("settings.version").format(version=APP_VERSION)) - version_label.setStyleSheet("color: gray; font-size: 9px;") + version_label.setProperty("role", "muted") version_label.setAlignment(Qt.AlignmentFlag.AlignRight) form_general.addRow(version_label) - self.tabs.addTab(self._scrollable(tab_general), t("settings.tab.general")) + self.tabs.addTab(self._scrollable(tab_general), tq("settings.tab.general")) layout.addWidget(self.tabs) + def _target_widgets(item) -> list[QWidget]: + if item is None: + return [] + widget = item.widget() + if widget is not None: + layout = widget.layout() + if layout is not None: + targets = [widget] + for i in range(layout.count()): + targets.extend(_target_widgets(layout.itemAt(i))) + return targets + return [widget] + layout = item.layout() + if layout is not None: + targets = [] + for i in range(layout.count()): + targets.extend(_target_widgets(layout.itemAt(i))) + return targets + return [] + + # Put supplemental explanations on their fields, keeping forms short. + # Security notices and the direct prompt-editor link remain visible. + for form in (form_whisper, form_llm, form_general): + form.setVerticalSpacing(8) + for row in range(form.rowCount()): + item = form.itemAt(row, QFormLayout.ItemRole.SpanningRole) + help_label = item.widget() if item is not None else None + if not isinstance(help_label, QLabel) or not help_label.property("fieldHelp"): + continue + if help_label.text() in (t("compose.custom.settings_help"), t("settings.api_key_env.help")): + continue + for role in (QFormLayout.ItemRole.LabelRole, QFormLayout.ItemRole.FieldRole): + previous = form.itemAt(row - 1, role) if row else None + for widget in _target_widgets(previous): + if widget.property("status") == "warning": + continue + widget.setToolTip(help_label.text()) + widget.setAccessibleDescription(help_label.text()) + form.setRowVisible(row, False) + # Dialog Button Box button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) button_save = button_box.button(QDialogButtonBox.StandardButton.Save) if button_save is not None: button_save.setText(t("button.save")) + button_save.setObjectName("primaryAction") button_cancel = button_box.button(QDialogButtonBox.StandardButton.Cancel) if button_cancel is not None: button_cancel.setText(t("button.cancel")) @@ -453,13 +504,24 @@ def init_ui(self) -> None: button_box.rejected.connect(self.reject) layout.addWidget(button_box) + def _update_settings_tone(self) -> None: + """Expose the tone control only when the selected action needs it.""" + self._llm_form.setRowVisible( + self.combo_tone, self.combo_writing_preset.currentData() == "change_tone" + ) + def _refresh_api_key_status(self) -> None: env_name = self.edit_api_key_env.text().strip() or self.config.openai_api_key_env env_value = os.environ.get(env_name, "").strip() - status = "gesetzt" if env_value else "nicht gesetzt" + status = t( + "settings.api_key.status_set" if env_value else "settings.api_key.status_unset" + ) self.lbl_api_key_status.setText( t("settings.api_key.status").format(status=status, env_name=env_name) ) + theme.set_status_role( + self.lbl_api_key_status, "success" if env_value else "warning" + ) def _on_llm_provider_changed(self) -> None: provider = self.combo_llm_provider.currentData() @@ -559,7 +621,7 @@ def save_settings(self) -> None: self.config.text_improver_tone = self.combo_tone.currentData() self.config.writing_preset = self.combo_writing_preset.currentData() self.config.compose_custom_preset_text = self.edit_compose_custom_preset.toPlainText() - self.config.emoji_density = self.combo_emoji.currentText() + self.config.emoji_density = self.combo_emoji.currentData() self.config.dampf_system_prompt = self.edit_dampf_prompt.toPlainText().strip() self.config.custom_terms = self._collect_custom_terms() @@ -740,11 +802,11 @@ def _rebuild_llm_service(self) -> None: def setup_tray(self) -> None: self.tray_icon = QSystemTrayIcon(self) self._tray_icons = { - "IDLE": self._create_microphone_icon(QColor("#2e7d32")), - "RECORDING": self._create_microphone_icon(QColor("#c62828")), - "TRANSCRIBING": self._create_microphone_icon(QColor("#ef6c00")), - "LLM_REWRITING": self._create_microphone_icon(QColor("#ef6c00")), - "ERROR": self._create_microphone_icon(QColor("#757575")), + "IDLE": self._create_microphone_icon(QColor(theme.STATE_IDLE)), + "RECORDING": self._create_microphone_icon(QColor(theme.STATE_RECORDING)), + "TRANSCRIBING": self._create_microphone_icon(QColor(theme.STATE_PROCESSING)), + "LLM_REWRITING": self._create_microphone_icon(QColor(theme.STATE_PROCESSING)), + "ERROR": self._create_microphone_icon(QColor(theme.STATE_ERROR)), } # Load standard icon fallback @@ -761,43 +823,50 @@ def setup_tray(self) -> None: # Create menu self.menu = QMenu() + self.menu.setObjectName("trayMenu") # Fenster anzeigen (grafischer Fallback) - self.action_show_window = QAction(f"🪟 {t('tray.show_window')}", self) + self.action_show_window = QAction(t("tray.show_window"), self) + self.action_show_window.setIcon(theme.create_app_icon()) self.action_show_window.triggered.connect(self.show_main_window) self.menu.addAction(self.action_show_window) - self.action_compose = QAction(f"✍ {t('tray.compose')}", self) + self.action_compose = QAction(t("tray.compose"), self) + self.action_compose.setIcon(theme.create_ui_icon("edit")) self.action_compose.triggered.connect(lambda _checked=False: self.show_compose_window()) self.menu.addAction(self.action_compose) + self.action_custom_prompt = QAction(t("compose.custom.edit"), self) + self.action_custom_prompt.setIcon(theme.create_ui_icon("edit", theme.BLITZ_400)) + self.action_custom_prompt.triggered.connect(self.show_custom_prompt) + self.menu.addAction(self.action_custom_prompt) self.menu.addSeparator() # Actions für die fünf Workflows self.action_transcription = QAction( - f"{t('workflow.transcription.name')}\t{hotkey_display_name(self.config.transcription_hotkey)}", self + f"{tq('workflow.transcription.name')}\t{hotkey_display_name(self.config.transcription_hotkey)}", self ) self.action_transcription.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.TRANSCRIPTION)) self.menu.addAction(self.action_transcription) - self.action_local = QAction(f"{t('workflow.local.name')}\tMeta+Shift+H", self) + self.action_local = QAction(f"{tq('workflow.local.name')}\tMeta+Shift+H", self) self.action_local.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.LOCAL)) self.menu.addAction(self.action_local) - self.action_improver = QAction(f"{t('workflow.text_improver.name')}\tMeta+Shift+T", self) + self.action_improver = QAction(f"{tq('workflow.text_improver.name')}\tMeta+Shift+T", self) self.action_improver.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.TEXT_IMPROVER)) self.menu.addAction(self.action_improver) - self.action_dampf = QAction(f"{t('workflow.dampf_ablassen.name')}\tMeta+Shift+D", self) + self.action_dampf = QAction(f"{tq('workflow.dampf_ablassen.name')}\tMeta+Shift+D", self) self.action_dampf.triggered.connect(lambda: self._trigger_menu_workflow(WorkflowType.DAMPF_ABLASSEN)) self.menu.addAction(self.action_dampf) - self.action_emoji = QAction(f"{t('workflow.emoji_text.name')}\tMeta+Shift+E", self) + self.action_emoji = QAction(f"{tq('workflow.emoji_text.name')}\tMeta+Shift+E", self) 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(f"✨ {t('tray.writing_preset')}") + self.menu_preset = self.menu.addMenu(t("tray.writing_preset")) self.preset_action_group = QActionGroup(self) self.preset_action_group.setExclusive(True) self.preset_actions: dict[str, QAction] = {} @@ -824,23 +893,26 @@ def setup_tray(self) -> None: # Verlauf anzeigen self.action_history = QAction(t("tray.history"), self) + self.action_history.setIcon(theme.create_ui_icon("history")) self.action_history.triggered.connect(self.show_history_panel) self.menu.addAction(self.action_history) # Vorlesen (TTS) self.action_tts = QAction(t("tray.tts"), self) + self.action_tts.setIcon(theme.create_ui_icon("speaker")) self.action_tts.triggered.connect(self.show_tts_window) self.menu.addAction(self.action_tts) self.menu.addSeparator() # Settings action - self.action_settings = QAction(f"⚙ {t('tray.settings')}...", self) + self.action_settings = QAction(f"{t('tray.settings')}…", self) + self.action_settings.setIcon(theme.create_ui_icon("settings")) self.action_settings.triggered.connect(self.show_settings_dialog) self.menu.addAction(self.action_settings) # Quit action - self.action_quit = QAction(f"✕ {t('tray.quit')}", self) + self.action_quit = QAction(t("tray.quit"), self) self.action_quit.triggered.connect(self.quit_app) self.menu.addAction(self.action_quit) @@ -883,25 +955,27 @@ def _refresh_i18n_texts(self) -> None: """Aktualisiert Texte, die nach Settings-Save bereits existieren.""" self.app.setApplicationName(t("app.name")) if hasattr(self, "action_show_window"): - self.action_show_window.setText(f"🪟 {t('tray.show_window')}") + self.action_show_window.setText(t("tray.show_window")) if hasattr(self, "action_compose"): - self.action_compose.setText(f"✍ {t('tray.compose')}") + self.action_compose.setText(t("tray.compose")) + if hasattr(self, "action_custom_prompt"): + self.action_custom_prompt.setText(t("compose.custom.edit")) if hasattr(self, "action_transcription"): self.action_transcription.setText( - f"{t('workflow.transcription.name')}\t{hotkey_display_name(self.config.transcription_hotkey)}" + f"{tq('workflow.transcription.name')}\t{hotkey_display_name(self.config.transcription_hotkey)}" ) if hasattr(self, "action_local"): - self.action_local.setText(f"{t('workflow.local.name')}\tMeta+Shift+H") + self.action_local.setText(f"{tq('workflow.local.name')}\tMeta+Shift+H") if hasattr(self, "action_improver"): - self.action_improver.setText(f"{t('workflow.text_improver.name')}\tMeta+Shift+T") + self.action_improver.setText(f"{tq('workflow.text_improver.name')}\tMeta+Shift+T") if hasattr(self, "action_dampf"): - self.action_dampf.setText(f"{t('workflow.dampf_ablassen.name')}\tMeta+Shift+D") + self.action_dampf.setText(f"{tq('workflow.dampf_ablassen.name')}\tMeta+Shift+D") if hasattr(self, "action_emoji"): - self.action_emoji.setText(f"{t('workflow.emoji_text.name')}\tMeta+Shift+E") + self.action_emoji.setText(f"{tq('workflow.emoji_text.name')}\tMeta+Shift+E") if hasattr(self, "menu_preset"): - self.menu_preset.setTitle(f"✨ {t('tray.writing_preset')}") - self.action_settings.setText(f"⚙ {t('tray.settings')}...") - self.action_quit.setText(f"✕ {t('tray.quit')}") + self.menu_preset.setTitle(t("tray.writing_preset")) + self.action_settings.setText(f"{t('tray.settings')}…") + self.action_quit.setText(t("tray.quit")) if hasattr(self, "preset_actions"): self._refresh_preset_menu() if self._main_window is not None: @@ -1224,7 +1298,7 @@ def _ensure_history_panel(self) -> HistoryPanel: notes_folder=self.config.notes_folder, ) panel.setWindowTitle(t("history.window_title")) - panel.resize(320, 440) + panel.resize(420, 500) panel.merged.connect(self._on_dictation_merged) panel.count_changed.connect(self._on_history_count_changed) self._history_panel = panel @@ -1285,8 +1359,8 @@ def show_tts_window(self) -> None: def _ensure_compose_window(self) -> ComposeWindow: if self._compose_window is None: window = ComposeWindow(self.llm_service, self.paste_service, self.config) + window.prompt_saved.connect(self._on_custom_prompt_saved) try: - from app import theme window.setWindowIcon(theme.create_app_icon()) except Exception: # pragma: no cover - rein kosmetisch pass @@ -1301,6 +1375,18 @@ def show_compose_window(self, text: str = "") -> None: window.raise_() window.activateWindow() + def show_custom_prompt(self) -> None: + """Open the one shared instruction editor from the window or tray.""" + self.show_compose_window() + self._ensure_compose_window().edit_custom_prompt() + + def _on_custom_prompt_saved(self) -> None: + self._rebuild_llm_service() + self._refresh_preset_menu() + self.update_menu_availability() + if self._main_window is not None: + self._main_window.set_preset(CUSTOM_PRESET_KEY) + def _compose_voice_routing_enabled(self) -> bool: window = self._compose_window return bool( @@ -1324,7 +1410,6 @@ def _ensure_main_window(self) -> MainWindow: if self._main_window is None: window = MainWindow(self) try: - from app import theme window.setWindowIcon(theme.create_app_icon()) except Exception: # pragma: no cover - rein kosmetisch pass @@ -1338,7 +1423,11 @@ def _ensure_main_window(self) -> MainWindow: def show_main_window(self) -> None: window = self._ensure_main_window() - window.show() + # The frameless panel can be minimized; show() alone would keep it iconified. + if window.isMinimized(): + window.showNormal() + else: + window.show() window.raise_() window.activateWindow() @@ -1454,9 +1543,8 @@ def main() -> int: app.setApplicationName(t("app.name")) app.setQuitOnLastWindowClosed(False) - # Design-System: Glass-Theme + Marken-App-Icon (Mikrofon + Blitz) + # Central design system and branded application icon. try: - from app import theme theme.apply_theme(app) app.setWindowIcon(theme.create_app_icon()) except Exception as exc: # pragma: no cover - rein kosmetisch diff --git a/app/compose_window.py b/app/compose_window.py index 23b7c9f..18fd2a2 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -12,6 +12,7 @@ QCheckBox, QComboBox, QDialog, + QFrame, QGridLayout, QHBoxLayout, QLabel, @@ -22,9 +23,10 @@ QWidget, ) -from app.i18n import t +from app.i18n import t, tq +from app import theme from app.llm_service import LLMService -from app.config import Config, VALID_TONES +from app.config import Config, ConfigError, VALID_TONES from app.paste_service import PasteService, PasteServiceError from app.workflows import WorkflowType from app.writing_presets import ( @@ -82,12 +84,14 @@ def __init__( parent: Optional[QWidget] = None, ) -> None: super().__init__(parent) + self.setObjectName("appDialog") self.setWindowTitle(t("compose.prompt_preview.title")) self.setModal(True) self.resize(640, 500) layout = QVBoxLayout(self) - layout.setSpacing(8) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) layout.addWidget(QLabel(t("compose.prompt_preview.system_label"))) self._system_edit = QPlainTextEdit(system_prompt) @@ -102,6 +106,7 @@ def __init__( btn_row = QHBoxLayout() btn_row.addStretch(1) btn_send = QPushButton(t("compose.prompt_preview.send_btn")) + btn_send.setObjectName("primaryAction") btn_send.setDefault(True) btn_send.clicked.connect(self.accept) btn_row.addWidget(btn_send) @@ -177,6 +182,8 @@ def run(self) -> None: class ComposeWindow(QDialog): """Modeless dialog for composing text through existing LLM workflows.""" + prompt_saved = pyqtSignal() + def __init__( self, llm_service: LLMService, @@ -185,9 +192,11 @@ def __init__( parent: Optional[QWidget] = None, ) -> None: super().__init__(parent) + self.setObjectName("appDialog") self._llm_service = llm_service self._paste_service = paste_service self._config = config + self._saved_custom_prompt = config.compose_custom_preset_text self._worker_thread: Optional[QThread] = None self._worker: Optional[_ComposeWorker] = None self._detached_threads: list[QThread] = [] @@ -198,8 +207,8 @@ def __init__( self._variant_index: int = -1 self.setWindowTitle(t("compose.window_title")) - self.setMinimumSize(600, 500) - self.resize(760, 600) + self.setMinimumSize(560, 360) + self.resize(620, 400) self._setup_ui() self.retranslate_ui() @@ -207,25 +216,29 @@ def __init__( def _setup_ui(self) -> None: layout = QVBoxLayout(self) - layout.setContentsMargins(14, 14, 14, 14) - layout.setSpacing(10) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) header_grid = QGridLayout() header_grid.setSpacing(8) - self.lblWorkflow = QLabel() - header_grid.addWidget(self.lblWorkflow, 0, 0) + # Keep workflow routing compatible; the editor exposes one action list. + self.lblWorkflow = QLabel(self) + self.lblWorkflow.hide() - self.cmbWorkflow = QComboBox() - self.cmbWorkflow.setMinimumWidth(160) - header_grid.addWidget(self.cmbWorkflow, 0, 1) + self.cmbWorkflow = QComboBox(self) + self.cmbWorkflow.hide() self.lblPreset = QLabel() - header_grid.addWidget(self.lblPreset, 0, 2) + header_grid.addWidget(self.lblPreset, 0, 0) self.cmbPreset = QComboBox() - self.cmbPreset.setMinimumWidth(180) - header_grid.addWidget(self.cmbPreset, 0, 3) + self.cmbPreset.setMinimumWidth(150) + header_grid.addWidget(self.cmbPreset, 0, 1) + + self.btnOwnPrompt = QPushButton() + self.btnOwnPrompt.clicked.connect(self.edit_custom_prompt) + header_grid.addWidget(self.btnOwnPrompt, 0, 2, 1, 2) self.lblTone = QLabel() header_grid.addWidget(self.lblTone, 1, 0) @@ -237,19 +250,36 @@ def _setup_ui(self) -> None: self.chkVoiceRouting = QCheckBox() self.chkVoiceRouting.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) - header_grid.addWidget(self.chkVoiceRouting, 1, 2, 1, 2) + header_grid.addWidget(self.chkVoiceRouting, 2, 0, 1, 4) header_grid.setColumnStretch(1, 1) header_grid.setColumnStretch(3, 1) layout.addLayout(header_grid) - self.splitMain = QSplitter(Qt.Orientation.Vertical) + self.promptPanel = QFrame() + self.promptPanel.setObjectName("sectionSurface") + prompt_layout = QVBoxLayout(self.promptPanel) + prompt_layout.setContentsMargins(12, 10, 12, 12) + prompt_layout.setSpacing(7) + self.lblOwnPrompt = QLabel() + prompt_layout.addWidget(self.lblOwnPrompt) + self.txtOwnPrompt = QPlainTextEdit() + self.txtOwnPrompt.setPlainText(self._config.compose_custom_preset_text) + self.txtOwnPrompt.setFixedHeight(64) + prompt_layout.addWidget(self.txtOwnPrompt) + self.btnSavePrompt = QPushButton() + self.btnSavePrompt.clicked.connect(self._save_custom_prompt) + prompt_layout.addWidget(self.btnSavePrompt, 0, Qt.AlignmentFlag.AlignRight) + layout.addWidget(self.promptPanel) + + self.splitMain = QSplitter(Qt.Orientation.Horizontal) self.splitMain.setChildrenCollapsible(False) - input_panel = QWidget() + input_panel = QFrame() + input_panel.setObjectName("sectionSurface") input_layout = QVBoxLayout(input_panel) - input_layout.setContentsMargins(0, 0, 0, 0) - input_layout.setSpacing(6) + input_layout.setContentsMargins(12, 10, 12, 12) + input_layout.setSpacing(8) self.lblInput = QLabel() input_layout.addWidget(self.lblInput) @@ -262,6 +292,7 @@ def _setup_ui(self) -> None: action_row.setSpacing(8) self.btnAction = QPushButton() + self.btnAction.setObjectName("primaryAction") self.btnAction.setMinimumWidth(140) self.btnAction.clicked.connect(self._on_improve_clicked) action_row.addWidget(self.btnAction) @@ -276,10 +307,11 @@ def _setup_ui(self) -> None: action_row.addWidget(self.lblStatus, 1) input_layout.addLayout(action_row) - output_panel = QWidget() + output_panel = QFrame() + output_panel.setObjectName("sectionSurface") output_layout = QVBoxLayout(output_panel) - output_layout.setContentsMargins(0, 0, 0, 0) - output_layout.setSpacing(6) + output_layout.setContentsMargins(12, 10, 12, 12) + output_layout.setSpacing(8) output_header = QHBoxLayout() output_header.setSpacing(8) @@ -289,7 +321,10 @@ def _setup_ui(self) -> None: output_header.addStretch(1) - self.btnPrev = QPushButton("◀") + self.btnPrev = QPushButton() + self.btnPrev.setObjectName("iconButton") + self.btnPrev.setIcon(theme.create_ui_icon("previous")) + self.btnPrev.setToolTip(t("compose.variant.prev")) self.btnPrev.setMaximumWidth(40) self.btnPrev.clicked.connect(self._on_prev_variant) output_header.addWidget(self.btnPrev) @@ -297,7 +332,10 @@ def _setup_ui(self) -> None: self.lblVariantCounter = QLabel() output_header.addWidget(self.lblVariantCounter) - self.btnNext = QPushButton("▶") + self.btnNext = QPushButton() + self.btnNext.setObjectName("iconButton") + self.btnNext.setIcon(theme.create_ui_icon("next")) + self.btnNext.setToolTip(t("compose.variant.next")) self.btnNext.setMaximumWidth(40) self.btnNext.clicked.connect(self._on_next_variant) output_header.addWidget(self.btnNext) @@ -326,16 +364,18 @@ def _setup_ui(self) -> None: self.btnClose = QPushButton() self.btnClose.clicked.connect(self.close) - footer_row.addWidget(self.btnClose) + # The native close control already closes this modeless window. + self.btnClose.setParent(self) + self.btnClose.hide() - output_layout.addLayout(footer_row) self.splitMain.addWidget(input_panel) self.splitMain.addWidget(output_panel) self.splitMain.setStretchFactor(0, 3) self.splitMain.setStretchFactor(1, 2) - self.splitMain.setSizes([330, 240]) + self.splitMain.setSizes([290, 290]) layout.addWidget(self.splitMain, 1) + layout.addLayout(footer_row) self._install_shortcuts() self._populate_workflow_combo() @@ -422,16 +462,14 @@ def _on_selectors_changed(self) -> None: self._update_tone_state() def _update_tone_state(self) -> None: - """Sichtbarkeit/Aktivierung des Tonfall-Selektors je nach Workflow+Vorlage. - - Tonfall ist im Text-Verbesserer (Blitztext+) für „Standard“ und - „Tonfall ändern“ aktiv. Bei allen anderen Aktionen bleibt der Selektor - sichtbar, aber ausgegraut. Bei Dampf/Emoji entfällt er. - """ + """Show only controls needed for the selected action.""" workflow = self._selected_workflow() is_text_improver = workflow == WorkflowType.TEXT_IMPROVER - self.lblTone.setVisible(is_text_improver) - self.cmbTone.setVisible(is_text_improver) + uses_tone = is_text_improver and self._selected_preset() == "change_tone" + self.lblTone.setVisible(uses_tone) + self.cmbTone.setVisible(uses_tone) + self.promptPanel.setVisible(is_text_improver and self._selected_preset() == CUSTOM_PRESET_KEY) + self.btnOwnPrompt.setVisible(self._selected_preset() != CUSTOM_PRESET_KEY) if not is_text_improver: return uses_tone = self._selected_preset() in { @@ -454,18 +492,18 @@ def _has_output(self) -> bool: def _show_status(self, text: str, *, error: bool = False) -> None: self.lblStatus.setText(text) self.lblStatus.setVisible(bool(text)) - if error: - self.lblStatus.setStyleSheet("color: #f44336;") - else: - self.lblStatus.setStyleSheet("") + theme.set_status_role(self.lblStatus, "error" if error else None) def _hide_status(self) -> None: self.lblStatus.clear() self.lblStatus.setVisible(False) - self.lblStatus.setStyleSheet("") + theme.set_status_role(self.lblStatus, None) def _set_busy(self, busy: bool, *, keep_status: bool = False) -> None: self._busy = busy + for control in (self.cmbPreset, self.cmbTone, self.btnOwnPrompt, self.btnSavePrompt): + control.setEnabled(not busy) + self.txtOwnPrompt.setReadOnly(busy) self.txtInput.setReadOnly(busy) if busy: self.btnAction.setEnabled(False) @@ -603,6 +641,9 @@ def _on_append_signature_clicked(self) -> None: def set_llm_service(self, llm_service: LLMService) -> None: """Verwendet bei der nächsten Aktion den aktuellen Controller-Service.""" self._llm_service = llm_service + if self.txtOwnPrompt.toPlainText() == self._saved_custom_prompt: + self.txtOwnPrompt.setPlainText(self._config.compose_custom_preset_text) + self._saved_custom_prompt = self._config.compose_custom_preset_text def set_input_text(self, text: str) -> None: self.txtInput.setPlainText(text) @@ -635,6 +676,11 @@ def retranslate_ui(self) -> None: self.setWindowTitle(t("compose.window_title")) self.lblWorkflow.setText(t("compose.workflow.label")) self.lblPreset.setText(t("compose.preset.label")) + self.btnOwnPrompt.setText(t("compose.custom.edit")) + self.lblOwnPrompt.setText(t("compose.custom.label")) + self.txtOwnPrompt.setAccessibleName(t("compose.custom.label")) + self.txtOwnPrompt.setPlaceholderText(t("compose.custom.placeholder")) + self.btnSavePrompt.setText(tq("compose.custom.save")) self.lblTone.setText(t("compose.tone.label")) self.chkVoiceRouting.setText(t("compose.voice_routing.label")) self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) @@ -644,7 +690,7 @@ def retranslate_ui(self) -> None: self.btnShowPrompt.setText(t("compose.btn.show_prompt")) self.btnShowPrompt.setToolTip(t("compose.prompt_preview.tooltip")) self.btnCopy.setText(t("compose.button.copy")) - self.btnPaste.setText(t("compose.button.insert_close")) + self.btnPaste.setText(tq("compose.button.insert_close")) self.btnClose.setText(t("compose.button.close")) self.btnSignature.setText(t("compose.btn_append_signature")) self.btnSignature.setToolTip(t("compose.tooltip_append_signature")) @@ -666,15 +712,47 @@ def retranslate_ui(self) -> None: def _start_worker(self, text: str) -> None: workflow = self._selected_workflow() tone = self._selected_tone() + custom = self.txtOwnPrompt.toPlainText().strip() if self._selected_preset() == CUSTOM_PRESET_KEY else None + if custom == "": + self._show_status(t("compose.custom.empty"), error=True) + self.txtOwnPrompt.setFocus() + return worker = _ComposeWorker( self._llm_service, workflow, text, self._selected_preset(), tone=tone, + custom_prompt=custom, ) self._launch_worker(worker) + def edit_custom_prompt(self) -> None: + """Open the instruction field without requiring a draft or an API call.""" + self.cmbWorkflow.setCurrentIndex(self.cmbWorkflow.findData(WorkflowType.TEXT_IMPROVER)) + self.cmbPreset.setCurrentIndex(self.cmbPreset.findData(CUSTOM_PRESET_KEY)) + self.txtOwnPrompt.setFocus() + + def _save_custom_prompt(self) -> None: + prompt = self.txtOwnPrompt.toPlainText().strip() + if not prompt: + self._show_status(t("compose.custom.empty"), error=True) + return + previous = self._config.compose_custom_preset_text + previous_preset = self._config.writing_preset + self._config.compose_custom_preset_text = prompt + self._config.writing_preset = CUSTOM_PRESET_KEY + try: + self._config.save() + except ConfigError: + self._config.compose_custom_preset_text = previous + self._config.writing_preset = previous_preset + self._show_status(t("compose.custom.save_error"), error=True) + return + self._saved_custom_prompt = prompt + self.prompt_saved.emit() + self._show_status(t("compose.custom.saved")) + def _launch_worker(self, worker: _ComposeWorker) -> None: """Gemeinsames Thread-Wiring für Standard- und Raw-Prompt-Worker.""" thread = QThread(self) @@ -739,10 +817,15 @@ def _on_show_prompt_clicked(self) -> None: workflow = self._selected_workflow() tone = self._selected_tone() + if self._selected_preset() == CUSTOM_PRESET_KEY and not self.txtOwnPrompt.toPlainText().strip(): + self._show_status(t("compose.custom.empty"), error=True) + self.txtOwnPrompt.setFocus() + return system_prompt = self._llm_service.build_system_prompt( workflow, writing_preset=self._selected_preset(), tone=tone, + custom_prompt=self.txtOwnPrompt.toPlainText().strip() if self._selected_preset() == CUSTOM_PRESET_KEY else None, ) dialog = PromptEditorDialog(system_prompt, text.strip(), parent=self) if dialog.exec() == QDialog.DialogCode.Accepted: diff --git a/app/history_panel.py b/app/history_panel.py index d54837f..c272bbf 100644 --- a/app/history_panel.py +++ b/app/history_panel.py @@ -24,6 +24,7 @@ ) from app.i18n import t +from app import theme logger = logging.getLogger("blitztext.history") @@ -159,11 +160,8 @@ class HistoryEntryWidget(QFrame): def __init__(self, entry: HistoryEntry, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.entry = entry + self.setObjectName("historyEntry") self.setFrameShape(QFrame.Shape.StyledPanel) - self.setStyleSheet( - "HistoryEntryWidget { background: palette(base); border-radius: 4px; " - "border: 1px solid palette(mid); margin: 1px 0; }" - ) # Diese Timer muessen dem Widget gehoeren. Ein statischer # QTimer.singleShot mit Lambda kann nach deleteLater() noch feuern und @@ -182,20 +180,25 @@ def __init__(self, entry: HistoryEntry, parent: Optional[QWidget] = None) -> Non top_row = QHBoxLayout() meta_text = t("history.entry.meta").format(timestamp=entry.timestamp, count=entry.word_count) if entry.is_dictation: - meta_text = f"\U0001f3a4 {meta_text}" + meta_text = t("history.entry.dictation").format(meta=meta_text) meta_label = QLabel(meta_text) - meta_label.setStyleSheet("color: palette(mid); font-size: 11px;") + meta_label.setProperty("role", "muted") top_row.addWidget(meta_label, 1) - self._btn_copy = QPushButton("\U0001f4cb") + self._btn_copy = QPushButton() + self._btn_copy.setObjectName("iconButton") + self._btn_copy.setIcon(theme.create_ui_icon("copy")) self._btn_copy.setToolTip(t("history.tooltip.copy")) - self._btn_copy.setFixedSize(28, 24) + self._btn_copy.setFixedSize(32, 30) self._btn_copy.clicked.connect(self._copy_to_clipboard) top_row.addWidget(self._btn_copy) - btn_delete = QPushButton("✕") + btn_delete = QPushButton() + btn_delete.setObjectName("iconButton") + btn_delete.setProperty("danger", True) + btn_delete.setIcon(theme.create_ui_icon("delete", theme.STATE_ERROR)) btn_delete.setToolTip(t("history.tooltip.delete")) - btn_delete.setFixedSize(28, 24) + btn_delete.setFixedSize(32, 30) btn_delete.clicked.connect(lambda: self.deleted.emit(self.entry)) top_row.addWidget(btn_delete) @@ -203,31 +206,28 @@ def __init__(self, entry: HistoryEntry, parent: Optional[QWidget] = None) -> Non preview_label = QLabel(entry.preview) preview_label.setWordWrap(True) - preview_label.setStyleSheet("font-size: 12px;") layout.addWidget(preview_label) def _copy_to_clipboard(self) -> None: _clipboard_write(self.entry.text) - self._btn_copy.setText("✓") - self._btn_copy.setStyleSheet("color: #4caf50; font-weight: bold;") + self._btn_copy.setIcon(theme.create_ui_icon("check", theme.STATE_IDLE)) + self._btn_copy.setProperty("success", True) + theme.repolish(self._btn_copy) self._copy_reset_timer.start(1500) def _reset_copy_button(self) -> None: - self._btn_copy.setText("\U0001f4cb") - self._btn_copy.setStyleSheet("") + self._btn_copy.setIcon(theme.create_ui_icon("copy")) + self._btn_copy.setProperty("success", False) + theme.repolish(self._btn_copy) def highlight(self) -> None: - self.setStyleSheet( - "HistoryEntryWidget { background: #fff3cd; border-radius: 4px; " - "border: 1px solid #ffc107; margin: 1px 0; }" - ) + self.setProperty("highlighted", True) + theme.repolish(self) self._highlight_reset_timer.start(800) def _reset_highlight(self) -> None: - self.setStyleSheet( - "HistoryEntryWidget { background: palette(base); border-radius: 4px; " - "border: 1px solid palette(mid); margin: 1px 0; }" - ) + self.setProperty("highlighted", False) + theme.repolish(self) class HistoryPanel(QWidget): @@ -243,6 +243,7 @@ def __init__( parent: Optional[QWidget] = None, ) -> None: super().__init__(parent) + self.setObjectName("appWindow") self._max_entries = max(10, min(100, max_entries)) self.notes_folder = notes_folder self._entries: List[HistoryEntry] = [] @@ -263,12 +264,12 @@ def __init__( def _setup_ui(self) -> None: layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(4) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) header_row = QHBoxLayout() self._header_label = QLabel(t("history.header").format(count=0)) - self._header_label.setStyleSheet("font-size: 13px; font-weight: bold;") + self._header_label.setProperty("role", "title") header_row.addWidget(self._header_label, 1) self._btn_merge = QPushButton(t("history.button.merge")) @@ -297,6 +298,12 @@ def _setup_ui(self) -> None: self._scroll.setWidget(self._list_container) layout.addWidget(self._scroll) + self._empty_label = QLabel(t("history.empty"), self._list_container) + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_label.setWordWrap(True) + self._empty_label.setProperty("role", "muted") + self._list_layout.insertWidget(0, self._empty_label, 1) + def add_entry(self, text: str, is_dictation: bool = False, source: Optional[str] = None) -> None: if not text or not text.strip(): return @@ -306,6 +313,7 @@ def add_entry(self, text: str, is_dictation: bool = False, source: Optional[str] if source == "dictation": save_dictation_note(self.notes_folder, entry.text) self._entries.insert(0, entry) + self._empty_label.hide() while len(self._entries) > self._max_entries: removed = self._entries.pop() @@ -321,6 +329,7 @@ def add_entry(self, text: str, is_dictation: bool = False, source: Optional[str] self._update_header() self._update_merge_button() + self._empty_label.setVisible(not self._entries) def _on_entry_deleted(self, entry: HistoryEntry) -> None: try: @@ -330,6 +339,7 @@ def _on_entry_deleted(self, entry: HistoryEntry) -> None: self._remove_widget_for_entry(entry) self._update_header() self._update_merge_button() + self._empty_label.setVisible(not self._entries) def _remove_widget_for_entry(self, entry: HistoryEntry) -> None: for widget in self._entry_widgets: @@ -343,7 +353,8 @@ def _on_clear_clicked(self) -> None: if not self._clear_armed: self._clear_armed = True self._btn_clear.setText(t("history.button.confirm_clear")) - self._btn_clear.setStyleSheet("color: #f44336; font-weight: bold;") + self._btn_clear.setProperty("danger", True) + theme.repolish(self._btn_clear) self._clear_disarm_timer.start(3000) else: self.clear_all() @@ -351,7 +362,8 @@ def _on_clear_clicked(self) -> None: def _disarm_clear(self) -> None: self._clear_armed = False self._btn_clear.setText(t("history.button.clear_all")) - self._btn_clear.setStyleSheet("") + self._btn_clear.setProperty("danger", False) + theme.repolish(self._btn_clear) def clear_all(self) -> None: self._entries.clear() @@ -361,9 +373,11 @@ def clear_all(self) -> None: self._entry_widgets.clear() self._clear_armed = False self._btn_clear.setText(t("history.button.clear_all")) - self._btn_clear.setStyleSheet("") + self._btn_clear.setProperty("danger", False) + theme.repolish(self._btn_clear) self._update_header() self._update_merge_button() + self._empty_label.show() def _merge_dictation(self) -> None: dictation_texts = [e.text for e in reversed(self._entries) if e.is_dictation] @@ -376,12 +390,14 @@ def _merge_dictation(self) -> None: self.merged.emit(path) self._btn_merge.setText(t("history.status.saved") if path else t("history.status.copied")) - self._btn_merge.setStyleSheet("color: #4caf50; font-weight: bold;") + self._btn_merge.setProperty("success", True) + theme.repolish(self._btn_merge) self._merge_reset_timer.start(2500) def _reset_merge_button(self) -> None: self._btn_merge.setText(t("history.button.merge")) - self._btn_merge.setStyleSheet("") + self._btn_merge.setProperty("success", False) + theme.repolish(self._btn_merge) def _update_header(self) -> None: count = len(self._entries) diff --git a/app/i18n.py b/app/i18n.py index 0461d2e..69dc5d4 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -22,7 +22,7 @@ "settings.window_title": "Blitztext Einstellungen", "settings.tab.general": "Allgemein", "settings.tab.speech": "Spracherkennung", - "settings.tab.workflows": "KI-Workflows", + "settings.tab.workflows": "Text & KI", "settings.whisper_model.label": "Whisper-Modell:", "settings.whisper_model.help": "Wählen Sie die Modellgröße. Größere Modelle sind genauer, benötigen aber mehr Ressourcen.", "settings.transcription_backend.label": "Transkription-Backend:", @@ -38,6 +38,8 @@ "settings.api_key_env.label": "API-Key-Umgebung:", "settings.api_key_env.help": "Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen (secrets.env). Für OpenRouter z. B. OPENROUTER_API_KEY.", "settings.api_key.status": "Status: {status} ({env_name})", + "settings.api_key.status_set": "gesetzt", + "settings.api_key.status_unset": "nicht gesetzt", "settings.api_key.legacy_notice": "Legacy openai_api_key gefunden. Er wird beim nächsten Speichern entfernt.", "settings.llm_provider.label": "LLM-Anbieter:", "settings.llm_provider.custom_endpoint": "Eigener Endpunkt", @@ -54,6 +56,9 @@ "settings.compose_custom_preset.help": "Bestehender freier System-Prompt für die getrennte Aktion „Eigener Prompt“.", "settings.compose_custom_preset.placeholder": "z. B. Formuliere den Text als sachliche Pressemitteilung…", "settings.emoji_density.label": "Emoji-Dichte:", + "settings.emoji_density.low": "wenig", + "settings.emoji_density.medium": "mittel", + "settings.emoji_density.high": "viel", "settings.dampf_prompt.label": "Dampf-Umschreiber Prompt:", "settings.dampf_prompt.placeholder": "Standard-Systemprompt verwenden...", "settings.dampf_prompt.help": "Eigener System-Prompt, um wütende Aussagen in eine professionelle Form umzuschreiben.", @@ -135,16 +140,26 @@ "history.status.saved": "✓ Gespeichert", "history.status.copied": "✓ Kopiert", "history.entry.meta": "{timestamp} · {count} Wörter", + "history.entry.dictation": "Diktat · {meta}", + "history.empty": "Noch keine Transkriptionen.\nDein nächstes Diktat erscheint hier.", "history.note.heading": "Diktat {heading}", "history.note.merged_heading": "Diktat (zusammengeführt) {heading}", "history.note.merged_filename_prefix": "Diktat-zusammengeführt_", "tray.show_window": "Fenster anzeigen", - "tray.compose": "Text verfassen…", + "tray.compose": "Text bearbeiten…", "tray.writing_preset": "Text-Aktion", - "tray.dictation_mode": "🎤 Diktat-Modus", - "tray.history": "📋 Verlauf…", - "tray.tts": "🔊 Vorlesen…", - "compose.window_title": "Text verfassen", + "tray.dictation_mode": "Diktate sammeln", + "tray.history": "Verlauf…", + "tray.tts": "Vorlesen…", + "compose.window_title": "Text bearbeiten", + "compose.custom.edit": "Eigene Anweisung …", + "compose.custom.label": "Wie soll dein Text klingen?", + "compose.custom.placeholder": "Zum Beispiel: Schreibe kurz und freundlich. Keine Einleitung, keine Emojis. Behalte alle Fakten bei.", + "compose.custom.save": "Speichern & für Diktate verwenden", + "compose.custom.saved": "Gespeichert und für „Diktieren & verbessern“ ausgewählt.", + "compose.custom.empty": "Bitte deine Anweisung in das Feld oben eingeben.", + "compose.custom.save_error": "Anweisung konnte nicht gespeichert werden. Dein Entwurf bleibt erhalten.", + "compose.custom.settings_help": "Diesen Prompt kannst du auch direkt unter „Text bearbeiten → Eigene Anweisung“ eingeben und testen.", "compose.workflow.label": "Workflow:", "compose.preset.label": "Text-Aktion:", "compose.tone.label": "Ziel-Tonfall:", @@ -170,23 +185,27 @@ "compose.variant.prev": "Vorherige Variante", "compose.variant.next": "Nächste Variante", "compose.variant.none": "Keine Varianten", - "compose.btn.show_prompt": "Schreibstil / Prompt bearbeiten", + "compose.btn.show_prompt": "Prompt prüfen", "compose.prompt_preview.title": "Prompt-Vorschau", "compose.prompt_preview.system_label": "System-Prompt:", "compose.prompt_preview.user_label": "Nutzernachricht:", "compose.prompt_preview.send_btn": "Senden", "compose.prompt_preview.cancel_btn": "Abbrechen", "compose.prompt_preview.tooltip": "Prompt vor dem Senden anzeigen & bearbeiten", - "mainwindow.button.discard": "↺ Verwerfen", - "mainwindow.button.dictation": "✎ Diktat", - "mainwindow.button.history": "≡ Verlauf ({count})", - "mainwindow.button.edit_text": "✍ Text bearbeiten", + "mainwindow.button.discard": "Verwerfen", + "mainwindow.button.dictation": "Sammel-Diktat", + "mainwindow.button.dictation_active": "Sammel-Diktat (Aktiv)", + "mainwindow.button.history": "Verlauf ({count} Transkriptionen)", + "mainwindow.button.edit_text": "Text bearbeiten", "mainwindow.tooltip.edit_text": "Text eingeben und mit Schreibstilen oder Prompts bearbeiten (Strg+E)", - "mainwindow.tooltip.tts": "Vorlesen", + "mainwindow.tooltip.tts": "Text vorlesen", "mainwindow.tooltip.settings": "Einstellungen", - "mainwindow.status.ready": "Bereit", - "mainwindow.status.error": "Fehler", - "mainwindow.status.recording": "Aufnahme läuft…", + "mainwindow.tooltip.more": "Weitere Aktionen", + "mainwindow.tooltip.minimize": "Minimieren", + "mainwindow.tooltip.close": "Schließen (läuft im Tray weiter)", + "mainwindow.status.ready": "Bereit:", + "mainwindow.status.error": "Fehler:", + "mainwindow.status.recording": "Aufnahme läuft:", "mainwindow.status.transcribing": "Transkribiere…", "mainwindow.status.processing": "Verarbeite mit KI…", "history.window_title": "Blitztext – Verlauf", @@ -201,16 +220,16 @@ "error.recording.title": "Aufnahme-Fehler", "error.recording.start_failed": "Aufnahme konnte nicht gestartet werden: {error}", "error.recording.stop_failed": "Aufnahme konnte nicht sauber gestoppt werden: {error}", - "workflow.transcription.name": "🎙 Blitztext", - "workflow.local.name": "🔒 Blitztext Lokal", - "workflow.text_improver.name": "✨ Blitztext+", - "workflow.dampf_ablassen.name": "🔥 Blitztext $%&!", - "workflow.emoji_text.name": "😊 Blitztext :)", - "preset.standard.name": "Standard / Text verbessern", + "workflow.transcription.name": "Diktieren", + "workflow.local.name": "Diktieren · lokal", + "workflow.text_improver.name": "Diktieren & verbessern", + "workflow.dampf_ablassen.name": "Sachlich formulieren", + "workflow.emoji_text.name": "Emojis ergänzen", + "preset.standard.name": "Text verbessern", "preset.shorten.name": "Kürzen", "preset.expand.name": "Ausformulieren", "preset.change_tone.name": "Tonfall ändern", - "preset.custom.name": "Eigener Prompt", + "preset.custom.name": "Eigene Anweisung", }, "en": { "app.name": "Blitztext", @@ -221,7 +240,7 @@ "settings.window_title": "Blitztext Settings", "settings.tab.general": "General", "settings.tab.speech": "Speech Recognition", - "settings.tab.workflows": "AI Workflows", + "settings.tab.workflows": "Text & AI", "settings.whisper_model.label": "Whisper model:", "settings.whisper_model.help": "Choose the model size. Larger models are more accurate but require more resources.", "settings.transcription_backend.label": "Transcription backend:", @@ -237,6 +256,8 @@ "settings.api_key_env.label": "API key environment:", "settings.api_key_env.help": "Only the environment variable name is stored. The key itself is read from os.environ (secrets.env). For OpenRouter, for example OPENROUTER_API_KEY.", "settings.api_key.status": "Status: {status} ({env_name})", + "settings.api_key.status_set": "set", + "settings.api_key.status_unset": "not set", "settings.api_key.legacy_notice": "Legacy openai_api_key found. It will be removed the next time settings are saved.", "settings.llm_provider.label": "LLM provider:", "settings.llm_provider.custom_endpoint": "Custom endpoint", @@ -253,7 +274,10 @@ "settings.compose_custom_preset.help": "Existing free system prompt for the separate 'Custom prompt' action.", "settings.compose_custom_preset.placeholder": "e.g. Rewrite the text as a factual press release…", "settings.emoji_density.label": "Emoji density:", - "settings.dampf_prompt.label": "Dampf rewrite prompt:", + "settings.emoji_density.low": "few", + "settings.emoji_density.medium": "medium", + "settings.emoji_density.high": "many", + "settings.dampf_prompt.label": "Steam-release prompt:", "settings.dampf_prompt.placeholder": "Use default system prompt...", "settings.dampf_prompt.help": "Custom system prompt for rewriting angry statements into a professional form.", "settings.custom_terms.label": "Proper names / terms:", @@ -334,16 +358,26 @@ "history.status.saved": "✓ Saved", "history.status.copied": "✓ Copied", "history.entry.meta": "{timestamp} · {count} words", + "history.entry.dictation": "Dictation · {meta}", + "history.empty": "No transcripts yet.\nYour next dictation will appear here.", "history.note.heading": "Dictation {heading}", "history.note.merged_heading": "Dictation (merged) {heading}", "history.note.merged_filename_prefix": "Dictation-merged_", "tray.show_window": "Show window", - "tray.compose": "Compose text…", + "tray.compose": "Edit text…", "tray.writing_preset": "Text action", - "tray.dictation_mode": "🎤 Dictation mode", - "tray.history": "📋 History…", - "tray.tts": "🔊 Read aloud…", - "compose.window_title": "Compose Text", + "tray.dictation_mode": "Collect dictations", + "tray.history": "History…", + "tray.tts": "Read aloud…", + "compose.window_title": "Edit text", + "compose.custom.edit": "Custom instruction …", + "compose.custom.label": "How should your text sound?", + "compose.custom.placeholder": "For example: Write briefly and warmly. No introduction, no emojis. Keep every fact.", + "compose.custom.save": "Save & use for dictation", + "compose.custom.saved": "Saved and selected for 'Dictate & improve'.", + "compose.custom.empty": "Enter your instruction in the field above first.", + "compose.custom.save_error": "Could not save the instruction. Your draft is still here.", + "compose.custom.settings_help": "You can also enter and test this prompt directly in Edit text → Custom instruction.", "compose.workflow.label": "Workflow:", "compose.preset.label": "Text action:", "compose.tone.label": "Target tone:", @@ -369,23 +403,27 @@ "compose.variant.prev": "Previous variant", "compose.variant.next": "Next variant", "compose.variant.none": "No variants", - "compose.btn.show_prompt": "Edit style / prompt", + "compose.btn.show_prompt": "Inspect prompt", "compose.prompt_preview.title": "Prompt Preview", "compose.prompt_preview.system_label": "System Prompt:", "compose.prompt_preview.user_label": "User Message:", "compose.prompt_preview.send_btn": "Send", "compose.prompt_preview.cancel_btn": "Cancel", "compose.prompt_preview.tooltip": "Preview & edit prompt before sending", - "mainwindow.button.discard": "↺ Discard", - "mainwindow.button.dictation": "✎ Dictation", - "mainwindow.button.history": "≡ History ({count})", - "mainwindow.button.edit_text": "✍ Edit text", + "mainwindow.button.discard": "Discard", + "mainwindow.button.dictation": "Collect dictation", + "mainwindow.button.dictation_active": "Collect dictation (active)", + "mainwindow.button.history": "History ({count} transcripts)", + "mainwindow.button.edit_text": "Edit text", "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.status.ready": "Ready", - "mainwindow.status.error": "Error", - "mainwindow.status.recording": "Recording…", + "mainwindow.tooltip.more": "More actions", + "mainwindow.tooltip.minimize": "Minimize", + "mainwindow.tooltip.close": "Close (keeps running in the tray)", + "mainwindow.status.ready": "Ready:", + "mainwindow.status.error": "Error:", + "mainwindow.status.recording": "Recording:", "mainwindow.status.transcribing": "Transcribing…", "mainwindow.status.processing": "Processing with AI…", "history.window_title": "Blitztext – History", @@ -400,16 +438,16 @@ "error.recording.title": "Recording error", "error.recording.start_failed": "Recording could not be started: {error}", "error.recording.stop_failed": "Recording could not be stopped cleanly: {error}", - "workflow.transcription.name": "🎙 Blitztext", - "workflow.local.name": "🔒 Blitztext Local", - "workflow.text_improver.name": "✨ Blitztext+", - "workflow.dampf_ablassen.name": "🔥 Blitztext $%&!", - "workflow.emoji_text.name": "😊 Blitztext :)", - "preset.standard.name": "Standard / Improve text", + "workflow.transcription.name": "Dictate", + "workflow.local.name": "Dictate · local", + "workflow.text_improver.name": "Dictate & improve", + "workflow.dampf_ablassen.name": "Make it factual", + "workflow.emoji_text.name": "Add emojis", + "preset.standard.name": "Improve text", "preset.shorten.name": "Shorten", "preset.expand.name": "Expand", "preset.change_tone.name": "Change tone", - "preset.custom.name": "Custom prompt", + "preset.custom.name": "Custom instruction", }, } @@ -468,6 +506,21 @@ def t(key: str) -> str: return key +def tq(key: str) -> str: + """Übersetzung für Qt-Widgets, die "&" als Mnemonic-Marker lesen. + + Buttons, Tabs und Menüeinträge verschlucken sonst das kaufmännische Und + (aus "Insert & Close" wird "Insert Close" mit unterstrichenem Leerzeichen). + + Args: + key: Übersetzungs-Schlüssel. + + Returns: + Übersetzter String mit maskiertem "&". + """ + return t(key).replace("&", "&&") + + def missing_keys() -> set[str]: """Prüfe Vollständigkeits-Invariante. diff --git a/app/main_window.py b/app/main_window.py index 5406633..6be4e60 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -1,39 +1,24 @@ -"""Hauptfenster fuer BlitztextLinux (Glass-Redesign). - -Grafischer Fallback zum globalen Hotkey: Start/Stopp per Maus-Klick, -Workflow-Auswahl, Verwerfen, Diktat, Textbearbeitung, Verlauf, Vorlesen und Einstellungen. - -Das Design folgt dem Blitztext Design System (Glass-Idiom): runder Amber- -Record-„Shutter" als Hero, Status-Punkt + Timer, weiche Pill-Buttons fuer -Verwerfen/Diktat, eine klare Textbearbeitungs-Aktion und runde Icon-Buttons -fuer Vorlesen/Einstellungen. - -Das Fenster ist rein praesentational — die gesamte Aufnahme-/State-Logik -bleibt im Controller (`BlitztextApp`). Beim Schliessen wird es nur versteckt, -die App laeuft im Tray weiter. -""" +"""Neumorphic Blitztext desktop widget with transient secondary controls.""" from __future__ import annotations +import math import time from typing import Optional -from PyQt6.QtCore import Qt, QTimer, pyqtSlot, QRectF -from PyQt6.QtGui import QBrush, QCloseEvent, QColor, QKeySequence, QPainter, QPen, QRadialGradient -from PyQt6.QtWidgets import ( - QComboBox, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, - QWidget, -) - -from app.llm_service import WorkflowType, LLM_WORKFLOWS -from app.i18n import t +from PyQt6.QtCore import QPoint, QPointF, QRectF, QSize, Qt, QTimer, pyqtSlot +from PyQt6.QtGui import (QBrush, QCloseEvent, QColor, QConicalGradient, + QEnterEvent, QKeySequence, QLinearGradient, + QMouseEvent, QPainter, QPainterPath, QPen, + QRadialGradient) +from PyQt6.QtWidgets import (QApplication, QComboBox, QFrame, + QGraphicsDropShadowEffect, QHBoxLayout, QLabel, + QLayout, QPushButton, QVBoxLayout, QWidget) + from app import theme +from app.i18n import t +from app.llm_service import WorkflowType from app.writing_presets import CUSTOM_PRESET_KEY, WRITING_PRESET_KEYS -# Reihenfolge der Workflows in der Auswahl _WORKFLOW_ORDER = [ WorkflowType.TRANSCRIPTION, WorkflowType.LOCAL, @@ -42,32 +27,73 @@ WorkflowType.EMOJI_TEXT, ] +# RecordButton geometry, relative to the widget center. Face and microphone +# artwork keep their design coordinates and are scaled to _FACE_DIAMETER. +_ART_DIAMETER = 98 +_FACE_DIAMETER = 80 +_ART_SCALE = _FACE_DIAMETER / _ART_DIAMETER +_RING_DIAMETER = 92 +_RING_PEN = 4 +_WAVE_BARS = 5 +_WAVE_PITCH = 6 +_WAVE_WIDTH = 3.0 +_WAVE_CLEARANCE = 52 # first bar sits just outside the processing ring +_WAVE_MAX_HEIGHT = 28 -class RecordButton(QPushButton): - """Runder Aufnahme-„Shutter" im Glass-Idiom. +# Microphone capsule in design coordinates. The official logo bolt is cut out +# of it, so the dark face shows through as part of the microphone. +_MIC_CAPSULE = QRectF(-11, -31, 22, 49) - Zeichnet einen Mikrofon-/Stop-/Spinner-Glyph auf einer amberfarbenen - bzw. roten Kreisflaeche. Der Text (`Start`/`Stopp`) bleibt gesetzt, wird - aber nicht gemalt — so bleibt `text()` fuer Tests/Logik nutzbar. - """ + +def _mic_body_path() -> QPainterPath: + body = QPainterPath() + body.addRoundedRect(_MIC_CAPSULE, 11, 11) + return body.subtracted(theme.brand_bolt_path(_MIC_CAPSULE)) + + +_MIC_BODY = _mic_body_path() + +# 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 +_PANEL_PADDING = (10, 6, 10, 10) # plus the 1 px QSS border +_TOOL_GAP = 6 +_TOOLBAR_TOP_GAP = 8 +_HISTORY_BUTTON_WIDTH = 48 + + +def _shadow(widget: QWidget, blur: float, y: float, alpha: int) -> None: + effect = QGraphicsDropShadowEffect(widget) + effect.setBlurRadius(blur) + effect.setOffset(0, y) + effect.setColor(QColor(5, 6, 8, alpha)) + widget.setGraphicsEffect(effect) + + +class RecordButton(QPushButton): + """Gold microphone button with recording waves and a processing ring.""" def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) - self._mode = "IDLE" # IDLE | RECORDING | PROCESSING + self._mode = "IDLE" self._phase = 0.0 - self.setFixedSize(68, 68) + self._hovered = False + self._pressed = False + self.setObjectName("recordButton") + self.setFixedSize(theme.RECORD_BUTTON_WIDTH, theme.RECORD_BUTTON_HEIGHT) self.setCursor(Qt.CursorShape.PointingHandCursor) self.setFlat(True) - self._anim = QTimer(self) - self._anim.setInterval(60) + self._anim.setInterval(40) self._anim.timeout.connect(self._tick) + @property + def mode(self) -> str: + return self._mode + def set_mode(self, mode: str) -> None: - if mode not in ("IDLE", "RECORDING", "PROCESSING"): - mode = "IDLE" - self._mode = mode - if mode in ("RECORDING", "PROCESSING"): + self._mode = mode if mode in ("IDLE", "RECORDING", "PROCESSING") else "IDLE" + if self._mode in ("RECORDING", "PROCESSING"): if not self._anim.isActive(): self._anim.start() else: @@ -76,226 +102,340 @@ def set_mode(self, mode: str) -> None: self.update() def _tick(self) -> None: - self._phase = (self._phase + 0.08) % 1.0 + # 0.03 per 40 ms: one calm cycle every ~1.3 s. + self._phase = (self._phase + 0.03) % 1.0 self.update() - def paintEvent(self, _event) -> None: # noqa: N802 (Qt naming) - p = QPainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - size = min(self.width(), self.height()) - margin = 6 - d = size - 2 * margin - rect = QRectF(margin, margin, d, d) - cx, cy = self.width() / 2, self.height() / 2 + def _center(self) -> QPointF: + return QPointF(self.width() / 2, self.height() / 2) - if self._mode == "RECORDING": - base, hi = QColor("#c62828"), QColor("#e04545") - elif self._mode == "PROCESSING": - base, hi = QColor("#262a31"), QColor("#383e46") - else: - base, hi = QColor(theme.BLITZ_500), QColor("#edbe35") + def hitButton(self, pos: QPoint) -> bool: # noqa: N802 + """Only the round microphone toggles; the wave areas are decoration.""" + center = self._center() + return math.hypot(pos.x() - center.x(), pos.y() - center.y()) <= _RING_DIAMETER / 2 - grad = QRadialGradient(cx, margin + d * 0.32, d) - grad.setColorAt(0.0, hi) - grad.setColorAt(1.0, base) - p.setPen(Qt.PenStyle.NoPen) - p.setBrush(QBrush(grad)) - p.drawEllipse(rect) + def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 + self._hovered = True + self.update() + super().enterEvent(event) - # Feiner innerer Highlight-Ring statt grossflaechigem Glanz - p.setPen(QPen(QColor(255, 255, 255, 36), 1)) - p.setBrush(Qt.BrushStyle.NoBrush) - p.drawEllipse(rect.adjusted(1, 1, -1, -1)) + def leaveEvent(self, event) -> None: # noqa: N802 + self._hovered = False + self._pressed = False + self.update() + super().leaveEvent(event) - # Pulsierender Ring waehrend der Aufnahme (die eine bewusste Animation) - if self._mode == "RECORDING": - spread = margin * self._phase - ring = QRectF(margin - spread, margin - spread, - d + 2 * spread, d + 2 * spread) - alpha = int(150 * (1.0 - self._phase)) - p.setPen(QPen(QColor(229, 72, 72, alpha), 2)) - p.setBrush(Qt.BrushStyle.NoBrush) - p.drawEllipse(ring) - - p.translate(cx, cy) + def mousePressEvent(self, event) -> None: # noqa: N802 + # Presses beside the microphone are ignored by Qt and move the window. + self._pressed = self.hitButton(event.position().toPoint()) + self.update() + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event) -> None: # noqa: N802 + self._pressed = False + self.update() + super().mouseReleaseEvent(event) + + def paintEvent(self, _event) -> None: # noqa: N802 + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + # PyQt6 only accepts QPointF here; a TypeError in paintEvent aborts the app. + center = self._center() if self._mode == "RECORDING": - self._draw_stop(p) - elif self._mode == "PROCESSING": - self._draw_spinner(p) - else: - self._draw_mic(p, QColor("#3a2a04")) - p.end() - - def _draw_mic(self, p: QPainter, color: QColor) -> None: - p.setPen(QPen(color, 2.2, Qt.PenStyle.SolidLine, - Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) - p.setBrush(QBrush(color)) - p.drawRoundedRect(QRectF(-4.5, -11.5, 9, 15), 4.5, 4.5) - p.setBrush(Qt.BrushStyle.NoBrush) - p.drawArc(QRectF(-8, -6, 16, 15).toRect(), 200 * 16, 140 * 16) - p.drawLine(0, 7, 0, 12) - p.drawLine(-5, 12, 5, 12) - - def _draw_stop(self, p: QPainter) -> None: - p.setPen(Qt.PenStyle.NoPen) - p.setBrush(QBrush(QColor("#ffffff"))) - p.drawRoundedRect(QRectF(-8.5, -8.5, 17, 17), 5, 5) - - def _draw_spinner(self, p: QPainter) -> None: - p.setPen(QPen(QColor(255, 255, 255, 60), 3, Qt.PenStyle.SolidLine, - Qt.PenCapStyle.RoundCap)) - p.setBrush(Qt.BrushStyle.NoBrush) - p.drawEllipse(QRectF(-12, -12, 24, 24)) - p.setPen(QPen(QColor("#ffffff"), 3, Qt.PenStyle.SolidLine, - Qt.PenCapStyle.RoundCap)) - start = int(-self._phase * 360 * 16) - p.drawArc(QRectF(-12, -12, 24, 24).toRect(), start, 90 * 16) + self._draw_waves(painter, center) + + radius = _FACE_DIAMETER / 2 + circle = QRectF(center.x() - radius, center.y() - radius, _FACE_DIAMETER, _FACE_DIAMETER) + shadow_offset = 2 if self._pressed else 5 + painter.setPen(QPen(QColor(4, 5, 6, 165), 5)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(circle.translated(2, shadow_offset)) + rim = QLinearGradient(circle.topLeft(), circle.bottomRight()) + for stop, color in ((0, "#fff0bd"), (.24, "#c8aa68"), (.62, "#625337"), (1, "#e0c17e")): + rim.setColorAt(stop, QColor(color)) + painter.setPen(QPen(QBrush(rim), 2)) + face = QRadialGradient(center.x() - 13 * _ART_SCALE, center.y() - 25 * _ART_SCALE, + 83 * _ART_SCALE) + face_colors = ( + ((0, "#474a4e"), (.52, "#303338"), (1, "#1a1c1f")) + if self._hovered + else ((0, "#3b3e43"), (.52, "#282b2f"), (1, "#17191b")) + ) + for stop, color in face_colors: + face.setColorAt(stop, QColor(color)) + painter.setBrush(QBrush(face)) + painter.drawEllipse(circle) + painter.setPen(QPen(QColor(255, 255, 255, 20), 1)) + painter.drawEllipse(circle.adjusted(3, 3, -3, -3)) + + if self._mode == "PROCESSING": + progress = QConicalGradient(center, -90 - self._phase * 360) + progress.setColorAt(0, QColor("#ffe6a1")) + progress.setColorAt(.70, QColor("#b28b43")) + progress.setColorAt(.71, QColor(178, 139, 67, 20)) + progress.setColorAt(1, QColor(178, 139, 67, 20)) + painter.setPen(QPen(QBrush(progress), _RING_PEN, Qt.PenStyle.SolidLine, + Qt.PenCapStyle.RoundCap)) + painter.setBrush(Qt.BrushStyle.NoBrush) + ring_radius = _RING_DIAMETER / 2 + painter.drawArc(QRectF(center.x() - ring_radius, center.y() - ring_radius, + _RING_DIAMETER, _RING_DIAMETER), 0, 360 * 16) + self._draw_mic(painter, center) + + def _draw_waves(self, painter: QPainter, center: QPointF) -> None: + """Mirrored gold level bars rippling outward beside the microphone.""" + painter.save() + painter.setPen(Qt.PenStyle.NoPen) + for index in range(_WAVE_BARS): + offset = _WAVE_CLEARANCE + index * _WAVE_PITCH + envelope = 1.0 - index / _WAVE_BARS + swing = 0.5 + 0.5 * math.sin(self._phase * math.tau - index * 0.85) + height = 4 + (_WAVE_MAX_HEIGHT - 4) * envelope * (0.35 + 0.65 * swing) + painter.setBrush(QColor(240, 199, 105, 235 - index * 18)) + for x in (center.x() - offset, center.x() + offset): + painter.drawRoundedRect( + QRectF(x - _WAVE_WIDTH / 2, center.y() - height / 2, _WAVE_WIDTH, height), + _WAVE_WIDTH / 2, _WAVE_WIDTH / 2, + ) + painter.restore() + + def _draw_mic(self, painter: QPainter, center: QPointF) -> None: + painter.save() + painter.translate(center) + painter.scale(_ART_SCALE, _ART_SCALE) + gold =QLinearGradient(-12, -30, 16, 41) + for stop, color in ((0, "#fff1ba"), (.28, "#f2d68d"), (.53, "#a98240"), + (.76, "#f7dc91"), (1, "#8d6934")): + gold.setColorAt(stop, QColor(color)) + brush = QBrush(gold) + painter.setPen(QPen(QColor("#fce8ae"), 1.1)) + painter.setBrush(brush) + painter.drawPath(_MIC_BODY) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(brush, 5.2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawArc(QRectF(-20, -8, 40, 40), 180 * 16, 180 * 16) + painter.drawLine(0, 31, 0, 41) + painter.drawLine(-12, 41, 12, 41) + painter.restore() + + +class DictationPopup(QFrame): + def __init__(self, owner: "MainWindow") -> None: + super().__init__(owner, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) + self.setObjectName("dictationPopup") + self.setFixedWidth(248) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) class MainWindow(QWidget): - """Klickbare Oberflaeche fuer Aufnahme-Steuerung und Komfort-Funktionen.""" + """One frameless surface with header, microphone, status and tools. + + Secondary options stay in a transient popup and never enlarge the surface. + """ def __init__(self, controller, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._controller = controller self._state = "IDLE" self._rec_start: Optional[float] = None - self.setWindowTitle(t("app.name")) - self.setFixedWidth(256) - self.resize(256, 232) - + # The panel draws its own title and window controls. + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setObjectName("mainWidgetWindow") + self.setStyleSheet(self._qss()) self._timer = QTimer(self) self._timer.setInterval(1000) self._timer.timeout.connect(self._update_timer_label) - self._setup_ui() self.update_state("IDLE", None, None) - def _setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 10, 12, 12) - layout.setSpacing(8) - - # Workflow-Auswahl (Karten-Pill) - self._workflow_combo = QComboBox() - 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) + @staticmethod + def _qss() -> str: + return theme.MAIN_WINDOW_QSS - # Schreibstil-Preset-Auswahl (nur sichtbar bei Blitztext+) - self._preset_combo = QComboBox() - self._preset_combo.setMinimumHeight(28) - 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.setVisible(False) - layout.addWidget(self._preset_combo) + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(*_SHADOW_MARGINS) + root.setSpacing(0) + # Size the native window from its visible surface instead of a fixed guess. + root.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + self._main_panel = QFrame(self) + self._main_panel.setObjectName("mainPanel") + _shadow(self._main_panel, 14, 4, 150) + panel = QVBoxLayout(self._main_panel) + panel.setContentsMargins(*_PANEL_PADDING) + panel.setSpacing(0) + + header = QHBoxLayout() + header.setSpacing(2) + self._title_label = QLabel(self.windowTitle(), self._main_panel) + self._title_label.setObjectName("panelTitle") + self.windowTitleChanged.connect(self._title_label.setText) + header.addWidget(self._title_label) + header.addStretch(1) + self._btn_minimize = self._window_button( + "minimize", t("mainwindow.tooltip.minimize"), self.showMinimized + ) + header.addWidget(self._btn_minimize) + self._btn_close = self._window_button( + "close", t("mainwindow.tooltip.close"), self.close + ) + self._btn_close.setProperty("role", "close") + header.addWidget(self._btn_close) + panel.addLayout(header) - # Hero: runder Record-Shutter - self._btn_toggle = RecordButton() + self._btn_toggle = RecordButton(self._main_panel) self._btn_toggle.clicked.connect(self._on_toggle_clicked) - hero_row = QHBoxLayout() - hero_row.addStretch() - hero_row.addWidget(self._btn_toggle) - hero_row.addStretch() - layout.addLayout(hero_row) - - # Statuszeile: Punkt + Text + Timer in einer kompakten Zeile - status_row = QHBoxLayout() - status_row.setSpacing(6) - status_row.addStretch() + panel.addWidget(self._btn_toggle, 0, Qt.AlignmentFlag.AlignHCenter) + status = QHBoxLayout() + status.setSpacing(6) + status.setAlignment(Qt.AlignmentFlag.AlignCenter) self._rec_indicator = QLabel("●") - self._rec_indicator.setStyleSheet(f"color: {theme.STATE_IDLE}; font-size: 10px;") - status_row.addWidget(self._rec_indicator) + status.addWidget(self._rec_indicator) self._status_label = QLabel(t("mainwindow.status.ready")) - self._status_label.setStyleSheet("font-size: 12px; font-weight: 600;") - status_row.addWidget(self._status_label) + self._status_label.setObjectName("statusLabel") + status.addWidget(self._status_label) self._timer_label = QLabel("00:00") - self._timer_label.setStyleSheet( - "font-size: 12px; font-family: monospace; font-weight: 600; " - f"color: {theme.APP_TEXT_FAINT};" + self._timer_label.setObjectName("timerLabel") + status.addWidget(self._timer_label) + panel.addLayout(status) + 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) + self._btn_edit_text = self._toolbar_button( + "edit", t("mainwindow.tooltip.edit_text"), + self._controller.show_compose_window, accent=True, ) - status_row.addWidget(self._timer_label) - status_row.addStretch() - layout.addLayout(status_row) - - # Sekundaerzeile: Verwerfen + Diktat (Pills) - sec_row = QHBoxLayout() - sec_row.setSpacing(6) - self._btn_discard = QPushButton(t("mainwindow.button.discard")) - self._btn_discard.setMinimumHeight(28) - self._btn_discard.setStyleSheet("border-radius: 14px; font-weight: 600;") - self._btn_discard.setEnabled(False) - self._btn_discard.clicked.connect(self._on_discard_clicked) - sec_row.addWidget(self._btn_discard) - - self._btn_dictation = QPushButton(t("mainwindow.button.dictation")) - self._btn_dictation.setMinimumHeight(28) - self._btn_dictation.setStyleSheet("border-radius: 14px; font-weight: 600;") - self._btn_dictation.setCheckable(True) - self._btn_dictation.clicked.connect(self._on_dictation_clicked) - sec_row.addWidget(self._btn_dictation) - layout.addLayout(sec_row) - - # Eigene Zeile: zentrale manuelle Textbearbeitung bleibt auch bei der - # kompakten Fensterbreite vollständig beschriftet. - self._btn_edit_text = QPushButton(t("mainwindow.button.edit_text")) - self._btn_edit_text.setMinimumHeight(30) - self._btn_edit_text.setStyleSheet("border-radius: 15px; font-weight: 600;") - self._btn_edit_text.setToolTip(t("mainwindow.tooltip.edit_text")) - self._btn_edit_text.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self._btn_edit_text.setAccessibleName(t("mainwindow.button.edit_text")) self._btn_edit_text.setShortcut(QKeySequence("Ctrl+E")) - self._btn_edit_text.clicked.connect( - lambda _checked=False: self._controller.show_compose_window() + self._btn_history = self._toolbar_button( + "history", t("mainwindow.button.history").format(count=0), + self._controller.show_history_panel, width=_HISTORY_BUTTON_WIDTH, + text="0", ) - layout.addWidget(self._btn_edit_text) - - # Unterzeile: Verlauf (mit Zaehler), Vorlesen, Einstellungen - bottom_row = QHBoxLayout() - bottom_row.setSpacing(6) - self._btn_history = QPushButton(t("mainwindow.button.history").format(count=0)) - self._btn_history.setMinimumHeight(28) - self._btn_history.setStyleSheet(f"border-radius: 14px; color: {theme.APP_TEXT_DIM};") - self._btn_history.clicked.connect(self._controller.show_history_panel) - bottom_row.addWidget(self._btn_history, 1) - - self._btn_tts = QPushButton("♪") - self._btn_tts.setFixedSize(28, 28) - self._btn_tts.setStyleSheet( - f"border-radius: 14px; font-size: 13px; color: {theme.APP_TEXT_DIM}; padding: 0;" + self._btn_tts = self._toolbar_button( + "speaker", t("mainwindow.tooltip.tts"), self._controller.show_tts_window ) - self._btn_tts.setToolTip(t("mainwindow.tooltip.tts")) - self._btn_tts.clicked.connect(self._controller.show_tts_window) - bottom_row.addWidget(self._btn_tts) - - self._btn_settings = QPushButton("⚙︎") - self._btn_settings.setFixedSize(28, 28) - self._btn_settings.setStyleSheet( - f"border-radius: 14px; font-size: 13px; color: {theme.APP_TEXT_DIM}; padding: 0;" + self._btn_settings = self._toolbar_button( + "settings", t("mainwindow.tooltip.settings"), + self._controller.show_settings_dialog ) - self._btn_settings.setToolTip(t("mainwindow.tooltip.settings")) - self._btn_settings.clicked.connect(self._controller.show_settings_dialog) - bottom_row.addWidget(self._btn_settings) - layout.addLayout(bottom_row) - - # ------------------------------------------------------------------ - # Aktionen - # ------------------------------------------------------------------ + 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, + ): + tools.addWidget(button) + panel.addLayout(tools) + root.addWidget(self._main_panel) + self._setup_options_popup() + + def _toolbar_button( + self, icon_name: str, tooltip: str, callback, *, width: int = theme.TOOL_BUTTON_SIZE, + accent: bool = False, text: str = "", + ) -> QPushButton: + button = QPushButton(text, self._main_panel) + button.setObjectName("goldAction" if accent else "toolbarButton") + icon_color = "#2b210e" if accent else theme.APP_TEXT_DIM + button.setIcon(theme.create_ui_icon(icon_name, icon_color, theme.TOOL_ICON_SIZE)) + button.setIconSize(QSize(theme.TOOL_ICON_SIZE, theme.TOOL_ICON_SIZE)) + button.setFixedSize(width, theme.TOOL_BUTTON_SIZE) + button.setToolTip(tooltip) + button.setAccessibleName(tooltip) + button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + button.clicked.connect(callback) + return button + + def _window_button(self, icon_name: str, tooltip: str, callback) -> QPushButton: + button = QPushButton(self._main_panel) + button.setObjectName("windowControl") + button.setIcon(theme.create_ui_icon(icon_name, theme.APP_TEXT_DIM, 14)) + button.setIconSize(QSize(14, 14)) + button.setFixedSize(theme.WINDOW_BUTTON_SIZE, theme.WINDOW_BUTTON_SIZE) + button.setToolTip(tooltip) + button.setAccessibleName(tooltip) + button.setFocusPolicy(Qt.FocusPolicy.TabFocus) + button.clicked.connect(lambda _checked=False: callback()) + return button + + def _setup_options_popup(self) -> None: + self._options_popup = DictationPopup(self) + outer = QVBoxLayout(self._options_popup) + outer.setContentsMargins(16, 16, 16, 20) + self._popup_surface = QFrame(self._options_popup) + self._popup_surface.setObjectName("popupSurface") + _shadow(self._popup_surface, 30, 8, 185) + outer.addWidget(self._popup_surface) + popup = QVBoxLayout(self._popup_surface) + popup.setContentsMargins(12, 12, 12, 12) + popup.setSpacing(8) + self._workflow_combo = QComboBox(self._popup_surface) + self._workflow_combo.setObjectName("popupCombo") + 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._preset_combo = QComboBox(self._popup_surface) + self._preset_combo.setObjectName("popupCombo") + 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() + popup.addWidget(self._preset_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_dictation = QPushButton(t("mainwindow.button.dictation"), self._popup_surface) + self._btn_dictation.setObjectName("dictationToggle") + self._btn_dictation.setCheckable(True) + self._btn_dictation.clicked.connect(self._on_dictation_clicked) + popup.addWidget(self._btn_dictation) + self._btn_discard = QPushButton(t("mainwindow.button.discard"), self._popup_surface) + self._btn_discard.setObjectName("discardButton") + self._btn_discard.setEnabled(False) + self._btn_discard.clicked.connect(self._on_discard_clicked) + popup.addWidget(self._btn_discard) + self._options_popup.adjustSize() + self._options_popup.hide() + + def _show_options_popup(self) -> None: + 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) + screen = QApplication.screenAt(anchor) or QApplication.primaryScreen() + if screen: + area = screen.availableGeometry() + 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) + self._options_popup.show() + self._options_popup.raise_() def _selected_workflow(self) -> WorkflowType: - wf = self._workflow_combo.currentData() - return wf if isinstance(wf, WorkflowType) else WorkflowType.TRANSCRIPTION + workflow = self._workflow_combo.currentData() + return workflow if isinstance(workflow, 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() + visible = self._selected_workflow() == WorkflowType.TEXT_IMPROVER + self._preset_combo.setVisible(visible) + self._btn_prompt.setVisible(visible) + self._options_popup.adjustSize() + + def _edit_prompt(self) -> None: + self._options_popup.hide() + self._controller.show_custom_prompt() @pyqtSlot() def _on_preset_changed(self) -> None: @@ -309,35 +449,27 @@ def _on_toggle_clicked(self) -> None: @pyqtSlot() def _on_discard_clicked(self) -> None: + self._options_popup.hide() self._controller.gui_discard() @pyqtSlot() def _on_dictation_clicked(self) -> None: + self._update_dictation_text() self._controller.set_dictation_mode(self._btn_dictation.isChecked()) - # ------------------------------------------------------------------ - # Vom Controller aufgerufen - # ------------------------------------------------------------------ - def update_state(self, state: str, workflow: Optional[WorkflowType], error: Optional[str]) -> None: self._state = state recording = state == "RECORDING" busy = state in ("TRANSCRIBING", "LLM_REWRITING") - - # Text bleibt fuer Tests/Logik gesetzt; der Shutter malt den Glyph. self._btn_toggle.setText("Stopp" if recording else "Start") self._btn_toggle.setEnabled(state in ("IDLE", "RECORDING")) - self._btn_toggle.set_mode( - "RECORDING" if recording else ("PROCESSING" if busy else "IDLE") - ) + self._btn_toggle.set_mode("RECORDING" if recording else "PROCESSING" if busy else "IDLE") self._btn_discard.setEnabled(recording) - self._workflow_combo.setEnabled(state == "IDLE") - self._preset_combo.setEnabled(state == "IDLE") - + for control in (self._workflow_combo, self._preset_combo, self._btn_prompt): + control.setEnabled(state == "IDLE") if error: self._set_status(t("mainwindow.status.error"), theme.STATE_ERROR) elif recording: - # Der Workflow steht bereits im Dropdown darueber — kein Suffix noetig. self._set_status(t("mainwindow.status.recording"), theme.STATE_RECORDING) elif state == "TRANSCRIBING": self._set_status(t("mainwindow.status.transcribing"), theme.STATE_PROCESSING) @@ -345,60 +477,76 @@ def update_state(self, state: str, workflow: Optional[WorkflowType], error: Opti self._set_status(t("mainwindow.status.processing"), theme.STATE_PROCESSING) else: self._set_status(t("mainwindow.status.ready"), theme.STATE_IDLE) - + self._timer_label.setVisible(not busy and not error) if recording: if self._rec_start is None: self._rec_start = time.monotonic() self._update_timer_label() self._timer.start() - self._timer_label.setStyleSheet( - "font-size: 12px; font-family: monospace; font-weight: 600; " - f"color: {theme.APP_TEXT};" - ) else: self._timer.stop() self._rec_start = None self._timer_label.setText("00:00") - self._timer_label.setStyleSheet( - "font-size: 12px; font-family: monospace; font-weight: 600; " - f"color: {theme.APP_TEXT_FAINT};" - ) - _ = busy + if workflow is not None: + self._select_workflow(workflow) + + def _select_workflow(self, workflow: WorkflowType) -> None: + for index in range(self._workflow_combo.count()): + if self._workflow_combo.itemData(index) == workflow: + self._workflow_combo.blockSignals(True) + self._workflow_combo.setCurrentIndex(index) + self._workflow_combo.blockSignals(False) + self._on_workflow_changed() + return def _set_status(self, text: str, color: str) -> None: - # Statuspunkt traegt die Statusfarbe; der Text bleibt neutral — - # ruhiger als eine voll eingefaerbte „Ampel"-Zeile. - text_color = theme.APP_TEXT_DIM if color == theme.STATE_IDLE else theme.APP_TEXT self._status_label.setText(text) - self._status_label.setStyleSheet( - f"font-size: 12px; font-weight: 600; color: {text_color};" - ) - self._rec_indicator.setStyleSheet(f"color: {color}; font-size: 10px;") + self._rec_indicator.setStyleSheet(f"color: {color}; font-size: 11px;") def set_history_count(self, count: int) -> None: - self._btn_history.setText(t("mainwindow.button.history").format(count=count)) + label = t("mainwindow.button.history").format(count=count) + self._btn_history.setText(str(count)) + self._btn_history.setToolTip(label) + self._btn_history.setAccessibleName(label) def set_dictation_checked(self, checked: bool) -> None: self._btn_dictation.blockSignals(True) self._btn_dictation.setChecked(checked) self._btn_dictation.blockSignals(False) + self._update_dictation_text() + + def _update_dictation_text(self) -> None: + key = ( + "mainwindow.button.dictation_active" + if self._btn_dictation.isChecked() + else "mainwindow.button.dictation" + ) + self._btn_dictation.setText(t(key)) 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) + for index in range(self._preset_combo.count()): + if self._preset_combo.itemData(index) == key: + self._preset_combo.setCurrentIndex(index) break self._preset_combo.blockSignals(False) def _update_timer_label(self) -> None: - if self._rec_start is None: - return - elapsed = int(time.monotonic() - self._rec_start) - self._timer_label.setText(f"{elapsed // 60:02d}:{elapsed % 60:02d}") - - def closeEvent(self, event: QCloseEvent) -> None: - # Nicht beenden — nur verstecken; App laeuft im Tray weiter. + if self._rec_start is not None: + elapsed = int(time.monotonic() - self._rec_start) + self._timer_label.setText(f"{elapsed // 60:02d}:{elapsed % 60:02d}") + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + """Move the frameless window from any non-interactive part of the panel.""" + if event.button() == Qt.MouseButton.LeftButton: + handle = self.windowHandle() + # The compositor performs the move; required on Wayland. + if handle is not None and handle.startSystemMove(): + event.accept() + return + super().mousePressEvent(event) + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 + self._options_popup.hide() event.ignore() self.hide() diff --git a/app/theme.py b/app/theme.py index ad6fd4c..7946175 100644 --- a/app/theme.py +++ b/app/theme.py @@ -1,113 +1,186 @@ -"""Design-System fuer BlitztextLinux (Breeze-Dark / Glass-Idiom). - -Uebersetzt das exportierte Blitztext Design System (Tokens, Glass-UI-Kit) in -PyQt6: ein globales QSS-Stylesheet plus das Marken-App-Icon (Mikrofon + Blitz). - -Farb- und Radius-Werte stammen 1:1 aus `tokens/colors.css` und -`tokens/spacing.css` des Design-Systems: - - Breeze-Dark-Flaechen (#1b1e20 / #2a2e32 / #31363b / #41464c) - - Brand-Amber --blitz-500 #e0a90f / --blitz-300 #f2cd4f - - Breeze-Blau #3daee9 (OS-Akzent, Fokus) - - Status: idle #2e7d32, recording #c62828, processing #ef6c00, error #757575 -""" +"""Central dark-neumorphic design system for the native Blitztext UI.""" from __future__ import annotations +import re +import xml.etree.ElementTree as ET +from functools import lru_cache from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap +from PyQt6.QtCore import QPointF, QRectF, Qt +from PyQt6.QtGui import ( + QBrush, + QColor, + QIcon, + QPainter, + QPainterPath, + QPen, + QPixmap, + QPolygonF, +) # --- Token-Konstanten (aus dem Design-System) ----------------------------- -BLITZ_500 = "#e0a90f" -BLITZ_400 = "#ecbb2a" -BLITZ_300 = "#f2cd4f" - -BREEZE_VIEW = "#1b1e20" -BREEZE_WINDOW = "#2a2e32" -BREEZE_BUTTON = "#31363b" -BREEZE_BUTTON_HOV = "#3b4045" -BREEZE_LINE = "#41464c" -BREEZE_BLUE = "#3daee9" - -APP_TEXT = "#f4f6f8" -APP_TEXT_DIM = "#9ba2ab" -APP_TEXT_FAINT = "#6e757e" - -# Feine Hairline-Borders statt voller Linienfarbe (dezenter Glass-Look) -HAIRLINE = "rgba(255, 255, 255, 0.08)" -HAIRLINE_STRONG = "rgba(255, 255, 255, 0.16)" - -STATE_IDLE = "#2e7d32" -STATE_RECORDING = "#c62828" -STATE_PROCESSING = "#ef6c00" -STATE_ERROR = "#757575" +BLITZ_500 = "#d6b260" +BLITZ_400 = "#e6c878" +BLITZ_300 = "#f4dda0" + +APP_BACKGROUND = "#151719" +APP_WINDOW = "#1b1d20" +APP_SURFACE = "#24272b" +APP_SURFACE_RAISED = "#2c3035" +APP_SURFACE_HOVER = "#353a40" +APP_SURFACE_SUNK = "#17191c" +APP_SURFACE_DISABLED = "#202327" +APP_LINE = "#3b4047" +APP_LINE_SOFT = "rgba(255, 255, 255, 0.07)" +APP_LINE_STRONG = "rgba(255, 255, 255, 0.14)" + +# Compatibility aliases used by older widgets while they share the new world. +BREEZE_VIEW = APP_SURFACE_SUNK +BREEZE_WINDOW = APP_WINDOW +BREEZE_BUTTON = APP_SURFACE_RAISED +BREEZE_BUTTON_HOV = APP_SURFACE_HOVER +BREEZE_LINE = APP_LINE +BREEZE_BLUE = BLITZ_500 + +APP_TEXT = "#f2f1ed" +APP_TEXT_DIM = "#bab8b1" +APP_TEXT_FAINT = "#8e8d88" + +HAIRLINE = APP_LINE_SOFT +HAIRLINE_STRONG = APP_LINE_STRONG + +STATE_IDLE = "#86c99a" +STATE_RECORDING = BLITZ_400 +STATE_PROCESSING = BLITZ_300 +STATE_ERROR = "#e88787" +STATE_WARNING = "#e7bd70" + +# Record button: round microphone plus room for the recording waves beside it. +RECORD_BUTTON_WIDTH = 160 +RECORD_BUTTON_HEIGHT = 98 +TOOL_BUTTON_SIZE = 32 +TOOL_ICON_SIZE = 18 +WINDOW_BUTTON_SIZE = 22 ASSETS_DIR = Path(__file__).resolve().parent / "assets" +# Official Blitztext mark (identical to blitztextweb/brand/logo-mark-dark.svg). +BRAND_MARK_SVG = ASSETS_DIR / "logo-mark-dark.svg" +_BRAND_BOLT_FILL = "#e0a90f" +# Same geometry as the asset; only used if the asset cannot be read. +_BRAND_CAPSULE_FALLBACK = (16.0, 6.0, 16.0, 26.0) +_BRAND_BOLT_FALLBACK = "M26 11l-6 9h4l-2 7 6-9.5h-4z" + -# --- Globales QSS-Stylesheet ---------------------------------------------- -# Modernes Breeze-Dark mit weichen Rundungen, dezenten Hairlines und einem -# Breeze-Blau-Fokusring. Bewusst zurueckhaltend, damit es im KDE-Tray sauber -# wirkt und Dialoge (Einstellungen, Verlauf) konsistent aussehen. APP_QSS = f""" * {{ color: {APP_TEXT}; font-size: 12px; + selection-background-color: {BLITZ_500}; + selection-color: #19150d; }} -QWidget {{ - background-color: {BREEZE_WINDOW}; +QWidget, QDialog {{ + background-color: {APP_WINDOW}; }} QLabel {{ background: transparent; }} +QLabel[role="secondary"] {{ color: {APP_TEXT_DIM}; }} +QLabel[role="muted"] {{ color: {APP_TEXT_FAINT}; }} +QLabel[role="title"] {{ font-size: 16px; font-weight: 650; }} +QLabel[status="success"] {{ color: {STATE_IDLE}; }} +QLabel[status="warning"] {{ color: {STATE_WARNING}; }} +QLabel[status="error"] {{ color: {STATE_ERROR}; }} + +QFrame#dialogSurface, QFrame#sectionSurface {{ + background: {APP_SURFACE}; + border: 1px solid {APP_LINE_SOFT}; + border-radius: 14px; +}} +QFrame#historyEntry {{ + background: {APP_SURFACE}; + border: 1px solid {APP_LINE_SOFT}; + border-radius: 11px; + margin: 2px; +}} +QFrame#historyEntry[highlighted="true"] {{ + background: #373123; + border-color: rgba(214, 178, 96, 0.50); +}} -/* Standard-Buttons: kompakt, Hairline-Border, klare Zustaende */ QPushButton {{ - background-color: {BREEZE_BUTTON}; - border: 1px solid {HAIRLINE}; - border-radius: 8px; - padding: 3px 8px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 {APP_SURFACE_RAISED}, stop:1 {APP_SURFACE}); + border: 1px solid {APP_LINE_SOFT}; + border-radius: 9px; + padding: 7px 12px; color: {APP_TEXT}; + min-height: 18px; }} QPushButton:hover {{ - background-color: {BREEZE_BUTTON_HOV}; - border-color: {HAIRLINE_STRONG}; + background: {APP_SURFACE_HOVER}; + border-color: {APP_LINE_STRONG}; +}} +QPushButton#primaryAction {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 {BLITZ_300}, stop:0.48 {BLITZ_500}, stop:1 #a57c35); + color: #1b160c; + font-weight: 700; + border-color: rgba(255, 236, 177, 0.64); +}} +QPushButton#primaryAction:disabled {{ + background: {APP_SURFACE_DISABLED}; + color: {APP_TEXT_FAINT}; + border-color: {APP_LINE_SOFT}; }} QPushButton:pressed {{ - background-color: #272b2f; - border-color: {HAIRLINE}; + background: {APP_SURFACE_SUNK}; + border-color: rgba(214, 178, 96, 0.28); }} QPushButton:focus {{ - border-color: rgba(61, 174, 233, 0.65); + border-color: rgba(230, 200, 120, 0.78); outline: none; }} QPushButton:disabled {{ color: {APP_TEXT_FAINT}; - background-color: #2d3135; + background: {APP_SURFACE_DISABLED}; border-color: rgba(255, 255, 255, 0.04); }} QPushButton:checked {{ - background-color: rgba(224, 169, 15, 0.14); - border-color: rgba(224, 169, 15, 0.40); + background: #3b3424; + border-color: rgba(214, 178, 96, 0.48); color: {BLITZ_300}; }} +QPushButton#iconButton, QPushButton#toolbarButton, QPushButton#goldAction, +QPushButton#panelTrigger {{ padding: 0; min-height: 0; }} +QPushButton#recordButton {{ + background: transparent; + border: none; + padding: 0; + min-width: {RECORD_BUTTON_WIDTH}px; + max-width: {RECORD_BUTTON_WIDTH}px; + min-height: {RECORD_BUTTON_HEIGHT}px; + max-height: {RECORD_BUTTON_HEIGHT}px; +}} +QPushButton#iconButton {{ border-radius: 8px; }} +QPushButton[danger="true"] {{ color: {STATE_ERROR}; }} +QPushButton[success="true"] {{ color: {STATE_IDLE}; }} -/* Eingaben & Auswahl */ -QComboBox, QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit {{ - background-color: {BREEZE_VIEW}; - border: 1px solid {HAIRLINE}; - border-radius: 8px; - padding: 4px 8px; - selection-background-color: {BREEZE_BLUE}; +QComboBox, QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit, QListWidget {{ + background: {APP_SURFACE_SUNK}; + border: 1px solid {APP_LINE_SOFT}; + border-radius: 9px; + padding: 7px 9px; }} -QComboBox:hover, QLineEdit:hover {{ - border-color: {HAIRLINE_STRONG}; +QComboBox:hover, QLineEdit:hover, QPlainTextEdit:hover, QTextEdit:hover, +QListWidget:hover {{ + border-color: {APP_LINE_STRONG}; }} QComboBox:focus, QLineEdit:focus, QSpinBox:focus, -QPlainTextEdit:focus, QTextEdit:focus {{ - border-color: rgba(61, 174, 233, 0.55); +QPlainTextEdit:focus, QTextEdit:focus, QListWidget:focus {{ + border-color: rgba(230, 200, 120, 0.70); }} QComboBox::drop-down {{ border: none; @@ -119,32 +192,36 @@ height: 12px; }} QComboBox QAbstractItemView {{ - background-color: {BREEZE_VIEW}; - border: 1px solid {HAIRLINE_STRONG}; - border-radius: 8px; - selection-background-color: {BREEZE_BLUE}; + background: {APP_SURFACE}; + border: 1px solid {APP_LINE_STRONG}; + border-radius: 10px; + padding: 4px; + selection-background-color: #453b27; + selection-color: {BLITZ_300}; outline: none; }} -/* Tabs (Einstellungen) */ QTabWidget::pane {{ - border: 1px solid {HAIRLINE}; - border-radius: 10px; + background: {APP_SURFACE}; + border: 1px solid {APP_LINE_SOFT}; + border-radius: 12px; top: -1px; }} QTabBar::tab {{ - background: transparent; + background: {APP_SURFACE_SUNK}; color: {APP_TEXT_DIM}; - padding: 6px 12px; - border: none; + padding: 8px 14px; + border: 1px solid {APP_LINE_SOFT}; border-bottom: 2px solid transparent; }} QTabBar::tab:selected {{ - color: {APP_TEXT}; + background: {APP_SURFACE}; + color: {BLITZ_300}; border-bottom: 2px solid {BLITZ_500}; }} QTabBar::tab:hover:!selected {{ color: {APP_TEXT}; + background: {APP_SURFACE_RAISED}; }} QCheckBox {{ @@ -155,37 +232,332 @@ spacing: 8px; }} QCheckBox:focus {{ - border-color: {BLITZ_500}; + border-color: rgba(230, 200, 120, 0.70); +}} +QCheckBox::indicator {{ + width: 16px; + height: 16px; + background: {APP_SURFACE_SUNK}; + border: 1px solid {APP_LINE_STRONG}; + border-radius: 5px; +}} +QCheckBox::indicator:checked {{ + background: {BLITZ_500}; + border-color: {BLITZ_300}; }} QToolTip {{ - background-color: {BREEZE_VIEW}; + background: {APP_SURFACE_RAISED}; color: {APP_TEXT}; - border: 1px solid {HAIRLINE_STRONG}; - padding: 3px 6px; + border: 1px solid {APP_LINE_STRONG}; + border-radius: 7px; + padding: 5px 8px; }} -QScrollBar:vertical {{ +QMenu {{ + background: {APP_SURFACE}; + border: 1px solid {APP_LINE_STRONG}; + border-radius: 11px; + padding: 7px; +}} +QMenu::item {{ + background: transparent; + border-radius: 7px; + padding: 7px 26px 7px 12px; +}} +QMenu::item:selected {{ + background: {APP_SURFACE_HOVER}; + color: {BLITZ_300}; +}} +QMenu::item:disabled {{ color: {APP_TEXT_FAINT}; }} +QMenu::separator {{ + height: 1px; + background: {APP_LINE_SOFT}; + margin: 6px 9px; +}} +QMenu::indicator:checked {{ + background: {BLITZ_500}; + border-radius: 4px; +}} + +QAbstractScrollArea, QAbstractScrollArea::viewport {{ + background: {APP_SURFACE_SUNK}; + border: none; +}} +QScrollArea {{ + border: 1px solid {APP_LINE_SOFT}; + border-radius: 10px; +}} +QScrollBar:vertical, QScrollBar:horizontal {{ background: transparent; width: 8px; + height: 8px; margin: 0; }} -QScrollBar::handle:vertical {{ - background: #4b515a; +QScrollBar::handle:vertical, QScrollBar::handle:horizontal {{ + background: #555b62; border-radius: 4px; min-height: 28px; + min-width: 28px; }} -QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ +QScrollBar::add-line, QScrollBar::sub-line {{ + width: 0; height: 0; }} +QSplitter::handle {{ + background: {APP_LINE_SOFT}; + width: 2px; + margin: 8px 5px; +}} +QListWidget::item {{ + border-radius: 6px; + padding: 5px; +}} +QListWidget::item:selected {{ + background: #453b27; + color: {BLITZ_300}; +}} +QDialogButtonBox {{ + background: transparent; +}} +""" + + +MAIN_WINDOW_QSS = f""" +QWidget#mainWidgetWindow {{ background: transparent; }} +QFrame#mainPanel {{ + background: qradialgradient(cx:0.32, cy:0.20, radius:1.05, + stop:0 #30343a, stop:0.58 {APP_SURFACE}, stop:1 #1e2125); + border: 1px solid {APP_LINE_SOFT}; + border-radius: 18px; +}} +QFrame#dictationPopup {{ background: transparent; border: none; }} +QFrame#popupSurface {{ + background: {APP_SURFACE}; + border: 1px solid {APP_LINE_STRONG}; + border-radius: 14px; +}} +QLabel#panelTitle {{ color: {APP_TEXT_DIM}; font-size: 12px; font-weight: 600; padding-left: 2px; }} +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 {{ + background: transparent; + border: 1px solid transparent; + border-radius: 7px; + padding: 0; + min-height: {WINDOW_BUTTON_SIZE - 2}px; + max-height: {WINDOW_BUTTON_SIZE - 2}px; +}} +QPushButton#windowControl:hover {{ background: {APP_SURFACE_HOVER}; border-color: {APP_LINE_SOFT}; }} +QPushButton#windowControl[role="close"]:hover {{ + background: rgba(232, 135, 135, 0.22); + border-color: rgba(232, 135, 135, 0.36); +}} +QPushButton#toolbarButton, QPushButton#panelTrigger, QPushButton#goldAction {{ + /* QSS min-height would otherwise reset the fixed Python size to 0. */ + min-height: {TOOL_BUTTON_SIZE - 2}px; + max-height: {TOOL_BUTTON_SIZE - 2}px; +}} +QPushButton#toolbarButton, QPushButton#panelTrigger {{ + background: transparent; + border: 1px solid transparent; + border-radius: 9px; + color: {APP_TEXT_DIM}; +}} +QPushButton#toolbarButton:hover, QPushButton#panelTrigger:hover {{ + background: {APP_SURFACE_HOVER}; + border-color: {APP_LINE_SOFT}; +}} +QPushButton#goldAction {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 {BLITZ_300}, stop:0.50 {BLITZ_500}, stop:1 #a57c35); + border: 1px solid rgba(255, 236, 177, 0.62); + border-radius: 9px; +}} +QPushButton#goldAction:hover {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #fff0bd, stop:0.50 {BLITZ_400}, stop:1 #bb9145); +}} +QComboBox#popupCombo {{ min-height: 22px; }} +QPushButton#dictationToggle {{ color: {BLITZ_300}; }} +QPushButton#discardButton {{ color: {STATE_ERROR}; }} """ def apply_theme(app) -> None: - """Wendet das Blitztext-Glass-Theme global auf die QApplication an.""" + """Apply the shared dark-neumorphic theme to the QApplication.""" app.setStyleSheet(APP_QSS) +def repolish(widget) -> None: + """Re-evaluate QSS after a dynamic property changes.""" + style = widget.style() + style.unpolish(widget) + style.polish(widget) + widget.update() + + +def set_status_role(label, role: str | None) -> None: + """Apply a semantic status role without embedding colors in feature code.""" + label.setProperty("status", role or "") + repolish(label) + + +def create_ui_icon(name: str, color: str = APP_TEXT_DIM, size: int = 22) -> QIcon: + """Draw a consistent, dependency-free line icon for native controls.""" + scale = 2 + pixmap = QPixmap(size * scale, size * scale) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.scale(scale, scale) + pen = QPen(QColor(color), 1.8, Qt.PenStyle.SolidLine, + Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + center = size / 2 + + if name == "edit": + painter.drawLine(QPointF(5, 16.5), QPointF(15.8, 5.7)) + painter.drawLine(QPointF(14.2, 4.8), QPointF(17.2, 7.8)) + painter.drawLine(QPointF(4.5, 17.5), QPointF(8, 16.7)) + elif name == "history": + painter.drawArc(QRectF(4, 4, 14, 14), 35 * 16, 292 * 16) + painter.drawLine(QPointF(4.3, 5.2), QPointF(4.3, 9)) + painter.drawLine(QPointF(4.3, 5.2), QPointF(8, 5.2)) + painter.drawLine(QPointF(center, 7), QPointF(center, center + .5)) + painter.drawLine(QPointF(center, center + .5), QPointF(14.5, 13)) + elif name == "speaker": + path = QPainterPath(QPointF(4, 9)) + path.lineTo(8, 9) + path.lineTo(12, 5.5) + path.lineTo(12, 16.5) + path.lineTo(8, 13) + path.lineTo(4, 13) + path.closeSubpath() + painter.drawPath(path) + painter.drawArc(QRectF(11, 7, 7, 8), -55 * 16, 110 * 16) + painter.drawArc(QRectF(10, 4.5, 11, 13), -52 * 16, 104 * 16) + elif name == "settings": + painter.drawEllipse(QRectF(center - 2.4, center - 2.4, 4.8, 4.8)) + painter.drawEllipse(QRectF(center - 6, center - 6, 12, 12)) + for dx, dy in ((0, -8), (0, 8), (-8, 0), (8, 0), + (-5.7, -5.7), (5.7, 5.7), (-5.7, 5.7), (5.7, -5.7)): + start = QPointF(center + dx * .73, center + dy * .73) + end = QPointF(center + dx, center + dy) + painter.drawLine(start, end) + elif name == "more": + painter.setBrush(QColor(color)) + painter.setPen(Qt.PenStyle.NoPen) + for x in (6, center, size - 6): + painter.drawEllipse(QPointF(x, center), 1.4, 1.4) + elif name == "minimize": + painter.drawLine(QPointF(size * .28, center), QPointF(size * .72, center)) + elif name == "close": + near, far = size * .3, size * .7 + painter.drawLine(QPointF(near, near), QPointF(far, far)) + painter.drawLine(QPointF(near, far), QPointF(far, near)) + elif name == "copy": + painter.drawRoundedRect(QRectF(7, 5, 10, 12), 2, 2) + painter.drawRoundedRect(QRectF(4, 8, 10, 11), 2, 2) + elif name == "delete": + painter.drawLine(QPointF(6, 7), QPointF(16, 7)) + painter.drawLine(QPointF(9, 4.5), QPointF(13, 4.5)) + painter.drawRoundedRect(QRectF(7, 7, 8, 11), 1.5, 1.5) + painter.drawLine(QPointF(10, 10), QPointF(10, 15)) + painter.drawLine(QPointF(12.5, 10), QPointF(12.5, 15)) + elif name == "check": + painter.drawLine(QPointF(5, 11), QPointF(9, 15)) + painter.drawLine(QPointF(9, 15), QPointF(17, 7)) + elif name in {"previous", "next"}: + direction = -1 if name == "previous" else 1 + painter.drawLine( + QPointF(center + direction * 3, 6), + QPointF(center - direction * 3, center), + ) + painter.drawLine( + QPointF(center - direction * 3, center), + QPointF(center + direction * 3, size - 6), + ) + + painter.end() + pixmap.setDevicePixelRatio(scale) + return QIcon(pixmap) + + +def _svg_path_points(d: str) -> list[tuple[float, float]]: + """Parse a single straight-line SVG subpath (M/L/H/V/Z, absolute or relative).""" + tokens = re.findall(r"[A-Za-z]|-?(?:\d+\.?\d*|\.\d+)", d) + points: list[tuple[float, float]] = [] + x = y = 0.0 + command = "" + index = 0 + while index < len(tokens): + token = tokens[index] + if token.isalpha(): + command = token + index += 1 + if command in "Zz": + break + continue + relative = command.islower() + kind = command.upper() + if kind in ("M", "L"): + dx, dy = float(tokens[index]), float(tokens[index + 1]) + index += 2 + x, y = (x + dx, y + dy) if relative else (dx, dy) + if kind == "M": + # Further coordinate pairs after a moveto are implicit linetos. + command = "l" if relative else "L" + elif kind in ("H", "V"): + value = float(tokens[index]) + index += 1 + if kind == "H": + x = x + value if relative else value + else: + y = y + value if relative else value + else: + raise ValueError(f"unsupported SVG path command: {command!r}") + points.append((x, y)) + return points + + +@lru_cache(maxsize=1) +def _brand_bolt_geometry() -> tuple[tuple[float, ...], tuple[tuple[float, float], ...]]: + """Capsule rect and bolt polygon of the official mark, in SVG units.""" + try: + ns = "{http://www.w3.org/2000/svg}" + root = ET.parse(BRAND_MARK_SVG).getroot() + rect = root.find(f"{ns}rect") + bolt = next(path for path in root.iter(f"{ns}path") + if path.get("fill", "").lower() == _BRAND_BOLT_FILL) + capsule = tuple(float(rect.get(key)) for key in ("x", "y", "width", "height")) + points = _svg_path_points(bolt.get("d")) + except (OSError, ET.ParseError, StopIteration, AttributeError, + TypeError, ValueError, IndexError): + capsule, points = _BRAND_CAPSULE_FALLBACK, _svg_path_points(_BRAND_BOLT_FALLBACK) + return capsule, tuple(points) + + +def brand_bolt_path(capsule: QRectF) -> QPainterPath: + """Official Blitztext bolt placed in ``capsule`` like in the logo's microphone. + + One uniform scale (capsule width to logo capsule width) keeps the original + angles and the bolt-to-capsule proportion; the bolt stays centered. + """ + (logo_x, logo_y, logo_w, logo_h), points = _brand_bolt_geometry() + scale = capsule.width() / logo_w + origin_x, origin_y = logo_x + logo_w / 2, logo_y + logo_h / 2 + center = capsule.center() + path = QPainterPath() + path.addPolygon(QPolygonF([ + QPointF(center.x() + (x - origin_x) * scale, center.y() + (y - origin_y) * scale) + for x, y in points + ])) + path.closeSubpath() + return path + + def create_app_icon() -> QIcon: """Marken-App-Icon: Mikrofon (hell) + Blitz (amber) auf dunklem Grund. @@ -201,7 +573,7 @@ def create_app_icon() -> QIcon: def _painted_app_icon() -> QIcon: - """Fallback-Icon im Code gezeichnet (Mikrofon + Blitz, Breeze-Dark).""" + """Fallback icon drawn in the shared dark-neumorphic palette.""" pixmap = QPixmap(64, 64) pixmap.fill(Qt.GlobalColor.transparent) diff --git a/app/tts_window.py b/app/tts_window.py index 845b5a3..0add820 100644 --- a/app/tts_window.py +++ b/app/tts_window.py @@ -16,6 +16,7 @@ QComboBox, QDialog, QFileDialog, + QFrame, QHBoxLayout, QLabel, QLineEdit, @@ -27,6 +28,7 @@ ) from app.i18n import t +from app import theme PIPER_VENV_PATH = str(Path(__file__).resolve().parents[1] / ".venv" / "bin" / "piper") VOICES_DIR = Path.home() / ".local" / "share" / "piper-voices" @@ -75,6 +77,12 @@ def _scrub_secret(text: str, secret: str) -> str: return text +def _set_status(label: QLabel, text: str, role: Optional[str] = None) -> None: + """Set status copy and its shared semantic theme role together.""" + label.setText(text) + theme.set_status_role(label, role) + + def _find_piper() -> Optional[str]: if os.path.isfile(PIPER_VENV_PATH) and os.access(PIPER_VENV_PATH, os.X_OK): return PIPER_VENV_PATH @@ -296,6 +304,7 @@ class TtsWindow(QDialog): def __init__(self, config, parent: Optional[QWidget] = None) -> None: super().__init__(parent) + self.setObjectName("appDialog") self._config = config self.setWindowTitle(t("tts.window_title")) self.resize(430, 340) @@ -320,13 +329,19 @@ def __init__(self, config, parent: Optional[QWidget] = None) -> None: def _setup_ui(self) -> None: layout = QVBoxLayout(self) - layout.setContentsMargins(10, 8, 10, 8) - layout.setSpacing(6) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) self._text_edit = QTextEdit() self._text_edit.setPlaceholderText(t("tts.text.placeholder")) layout.addWidget(self._text_edit, 1) + controls = QFrame(self) + controls.setObjectName("sectionSurface") + controls_layout = QVBoxLayout(controls) + controls_layout.setContentsMargins(12, 12, 12, 12) + controls_layout.setSpacing(8) + provider_row = QHBoxLayout() provider_row.setSpacing(6) provider_row.addWidget(QLabel(t("tts.provider.label"))) @@ -335,7 +350,7 @@ def _setup_ui(self) -> None: self._provider_combo.addItem(t("tts.provider.openai_cloud"), "openai") self._provider_combo.currentIndexChanged.connect(self._on_provider_changed) provider_row.addWidget(self._provider_combo, 1) - layout.addLayout(provider_row) + controls_layout.addLayout(provider_row) voice_row = QHBoxLayout() voice_row.setSpacing(6) @@ -343,7 +358,7 @@ def _setup_ui(self) -> None: self._voice_combo = QComboBox() self._voice_combo.currentIndexChanged.connect(self._on_voice_changed) voice_row.addWidget(self._voice_combo, 1) - layout.addLayout(voice_row) + controls_layout.addLayout(voice_row) model_row = QHBoxLayout() model_row.setSpacing(6) @@ -352,7 +367,7 @@ def _setup_ui(self) -> None: self._model_edit.setPlaceholderText(OPENAI_TTS_MODEL_DEFAULT) self._model_edit.editingFinished.connect(self._on_model_changed) model_row.addWidget(self._model_edit, 1) - layout.addLayout(model_row) + controls_layout.addLayout(model_row) speed_row = QHBoxLayout() speed_row.setSpacing(6) @@ -368,7 +383,8 @@ def _setup_ui(self) -> None: self._speed_combo.setCurrentIndex(idx if idx >= 0 else 2) self._speed_combo.currentIndexChanged.connect(self._on_speed_changed) speed_row.addWidget(self._speed_combo) - layout.addLayout(speed_row) + controls_layout.addLayout(speed_row) + layout.addWidget(controls) self._status_label = QLabel("") self._status_label.setWordWrap(True) @@ -396,6 +412,7 @@ def _setup_ui(self) -> None: btn_row.addWidget(self._btn_pause) self._btn_speak = QPushButton(t("tts.button.speak")) + self._btn_speak.setObjectName("primaryAction") self._btn_speak.clicked.connect(self._on_speak_clicked) btn_row.addWidget(self._btn_speak) @@ -441,18 +458,14 @@ def _refresh_status_hint(self) -> None: if provider == "openai": service = CloudTtsService(self._config) if service.is_available(): - self._status_label.setText(t("tts.status.openai_ready")) - self._status_label.setStyleSheet("color: #4caf50;") + _set_status(self._status_label, t("tts.status.openai_ready"), "success") else: - self._status_label.setText(_openai_tts_install_hint()) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, _openai_tts_install_hint(), "error") else: if self._piper_path: - self._status_label.setText(t("tts.status.piper_ready")) - self._status_label.setStyleSheet("color: #4caf50;") + _set_status(self._status_label, t("tts.status.piper_ready"), "success") else: - self._status_label.setText(_piper_install_hint()) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, _piper_install_hint(), "error") self._update_speak_button_state() def _update_speak_button_state(self) -> None: @@ -518,8 +531,7 @@ def _abort_export(self, status_text: str) -> None: self._cleanup_export_temp() self._cleanup_active_wav() self._pending_export_path = None - self._status_label.setText(status_text) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, status_text, "error") self._btn_speak.setText(t("tts.button.speak")) self._btn_pause.setEnabled(False) self._is_paused = False @@ -654,13 +666,11 @@ def _on_export_clicked(self) -> None: text = self._current_tts_text() if not text: self._invalidate_status_clear() - self._status_label.setText(t("tts.status.no_text")) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.no_text"), "error") return if not _find_ffmpeg(): self._invalidate_status_clear() - self._status_label.setText(t("tts.status.export_ffmpeg_missing")) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.export_ffmpeg_missing"), "error") self._schedule_status_clear(2500) return default_name = t("tts.export.default_filename_prefix") + datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".ogg" @@ -702,8 +712,7 @@ def _start_tts(self, text: Optional[str] = None) -> None: text = self._current_tts_text() if not text: self._invalidate_status_clear() - self._status_label.setText(t("tts.status.no_text")) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.no_text"), "error") return self._last_tts_text = text self._btn_replay.setEnabled(True) @@ -717,15 +726,13 @@ def _start_tts(self, text: Optional[str] = None) -> None: def _start_piper_tts(self, text: str) -> None: if not self._piper_path: self._invalidate_status_clear() - self._status_label.setText(_piper_install_hint()) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, _piper_install_hint(), "error") return model_path = self._current_voice() if not model_path: self._invalidate_status_clear() - self._status_label.setText(t("tts.status.no_voice_path")) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.no_voice_path"), "error") return wav_path = self._prepare_new_tts_job() @@ -740,8 +747,7 @@ def _start_piper_tts(self, text: str) -> None: proc.errorOccurred.connect(self._on_tts_error) self._piper_proc = proc - self._status_label.setText(t("tts.status.synthesis")) - self._status_label.setStyleSheet("") + _set_status(self._status_label, t("tts.status.synthesis")) self._btn_speak.setText(t("tts.button.stop")) self._update_speak_button_state() @@ -752,15 +758,13 @@ def _start_piper_tts(self, text: str) -> None: def _start_cloud_tts(self, text: str) -> None: if not self._config.tts_openai_consent: self._invalidate_status_clear() - self._status_label.setText(t("tts.status.openai_not_confirmed")) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.openai_not_confirmed"), "error") self._update_speak_button_state() return service = CloudTtsService(self._config) if not service.is_available(): self._invalidate_status_clear() - self._status_label.setText(_openai_tts_install_hint()) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, _openai_tts_install_hint(), "error") self._update_speak_button_state() return @@ -779,8 +783,7 @@ def _start_cloud_tts(self, text: str) -> None: self._cloud_thread = thread self._cloud_worker = worker - self._status_label.setText(t("tts.status.cloud_synthesis")) - self._status_label.setStyleSheet("") + _set_status(self._status_label, t("tts.status.cloud_synthesis")) self._btn_speak.setText(t("tts.button.stop")) self._btn_pause.setEnabled(False) thread.start() @@ -815,8 +818,7 @@ def _start_export_process(self, input_wav: str) -> None: export_proc.errorOccurred.connect(self._on_tts_error) self._export_proc = export_proc - self._status_label.setText(t("tts.status.exporting")) - self._status_label.setStyleSheet("") + _set_status(self._status_label, t("tts.status.exporting")) self._btn_speak.setText(t("tts.button.stop")) self._btn_pause.setEnabled(False) self._is_paused = False @@ -860,8 +862,7 @@ def _stop_tts(self) -> None: self._btn_pause.setEnabled(False) self._is_paused = False self._btn_pause.setText(t("tts.button.pause")) - self._status_label.setText(t("tts.status.cancelled")) - self._status_label.setStyleSheet("color: #ff9800;") + _set_status(self._status_label, t("tts.status.cancelled"), "warning") self._update_speak_button_state() self._schedule_status_clear(2000) @@ -938,8 +939,7 @@ def _on_cloud_error(self, message: str) -> None: self._cleanup_export_temp() self._cleanup_active_wav() self._invalidate_status_clear() - self._status_label.setText(t("tts.status.error").format(message=message)) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.error").format(message=message), "error") self._btn_speak.setText(t("tts.button.speak")) self._btn_pause.setEnabled(False) self._is_paused = False @@ -966,8 +966,7 @@ def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) - self._cleanup_active_wav() msg = stderr or f"Exit {exit_code}" self._invalidate_status_clear() - self._status_label.setText(t("tts.status.error").format(message=msg)) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.error").format(message=msg), "error") self._btn_speak.setText(t("tts.button.speak")) self._update_speak_button_state() self._schedule_status_clear(2500) @@ -981,8 +980,11 @@ def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) - if not wav_path: self._invalidate_status_clear() - self._status_label.setText(t("tts.status.error").format(message=t("tts.status.missing_wav_output"))) - self._status_label.setStyleSheet("color: #f44336;") + _set_status( + self._status_label, + t("tts.status.error").format(message=t("tts.status.missing_wav_output")), + "error", + ) self._btn_speak.setText(t("tts.button.speak")) self._update_speak_button_state() self._schedule_status_clear(2500) @@ -1010,16 +1012,14 @@ def _on_aplay_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) - self._is_paused = False self._btn_pause.setText(t("tts.button.pause")) if exit_status == QProcess.ExitStatus.NormalExit and exit_code == 0: - self._status_label.setText(t("tts.status.done")) - self._status_label.setStyleSheet("color: #4caf50;") + _set_status(self._status_label, t("tts.status.done"), "success") else: stderr = "" if proc is not None: stderr = bytes(proc.readAllStandardError()).decode("utf-8", "replace").strip() msg = stderr or f"Exit {exit_code}" self._invalidate_status_clear() - self._status_label.setText(t("tts.status.error").format(message=msg)) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.error").format(message=msg), "error") if proc is not None: proc.deleteLater() self._cleanup_export_temp() @@ -1043,14 +1043,12 @@ def _on_export_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) if exit_status == QProcess.ExitStatus.NormalExit and exit_code == 0 and export_temp_path and output_path: try: os.replace(export_temp_path, output_path) - self._status_label.setText(t("tts.status.export_done")) - self._status_label.setStyleSheet("color: #4caf50;") + _set_status(self._status_label, t("tts.status.export_done"), "success") self._export_temp_path = None clear_status_later = True except OSError as exc: self._invalidate_status_clear() - self._status_label.setText(t("tts.status.error").format(message=str(exc))) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.error").format(message=str(exc)), "error") self._cleanup_export_temp() else: stderr = "" @@ -1058,8 +1056,7 @@ def _on_export_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) stderr = bytes(proc.readAllStandardError()).decode("utf-8", "replace").strip() msg = stderr or f"Exit {exit_code}" self._invalidate_status_clear() - self._status_label.setText(t("tts.status.error").format(message=msg)) - self._status_label.setStyleSheet("color: #f44336;") + _set_status(self._status_label, t("tts.status.error").format(message=msg), "error") self._cleanup_export_temp() if proc is not None: proc.deleteLater() @@ -1078,7 +1075,7 @@ def _on_tts_error(self, error: QProcess.ProcessError) -> None: self._status_label.setText(_piper_install_hint() if self._current_provider() == "piper" else _openai_tts_install_hint()) else: self._status_label.setText(t("tts.status.error").format(message=error.name)) - self._status_label.setStyleSheet("color: #f44336;") + theme.set_status_role(self._status_label, "error") self._btn_speak.setText(t("tts.button.speak")) self._btn_pause.setEnabled(False) self._is_paused = False diff --git a/docs/screenshots/linux/Banner-de.png b/docs/screenshots/linux/Banner-de.png index d1097be..f8b5126 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 6fb249e..658a458 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/Banner.png b/docs/screenshots/linux/Banner.png deleted file mode 100644 index 6fb249e..0000000 Binary files a/docs/screenshots/linux/Banner.png and /dev/null differ diff --git a/docs/screenshots/linux/compose-de.png b/docs/screenshots/linux/compose-de.png index d913727..785c674 100644 Binary files a/docs/screenshots/linux/compose-de.png and b/docs/screenshots/linux/compose-de.png differ diff --git a/docs/screenshots/linux/compose-en.png b/docs/screenshots/linux/compose-en.png index 077e839..6edbb27 100644 Binary files a/docs/screenshots/linux/compose-en.png and b/docs/screenshots/linux/compose-en.png differ diff --git a/docs/screenshots/linux/compose-prompt-de.png b/docs/screenshots/linux/compose-prompt-de.png new file mode 100644 index 0000000..60a72e8 Binary files /dev/null and b/docs/screenshots/linux/compose-prompt-de.png differ diff --git a/docs/screenshots/linux/compose-prompt-en.png b/docs/screenshots/linux/compose-prompt-en.png new file mode 100644 index 0000000..c09ad25 Binary files /dev/null and b/docs/screenshots/linux/compose-prompt-en.png differ diff --git a/docs/screenshots/linux/demo-de.gif b/docs/screenshots/linux/demo-de.gif index be2477d..81ea811 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 6edfd02..15b2d48 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 149fce4..462e7be 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 3c6e8ce..8111f98 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 new file mode 100644 index 0000000..33432cb Binary files /dev/null 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 new file mode 100644 index 0000000..ef51fd2 Binary files /dev/null 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 new file mode 100644 index 0000000..9944f29 Binary files /dev/null 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 new file mode 100644 index 0000000..e27e5bb Binary files /dev/null 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 new file mode 100644 index 0000000..9bba84a Binary files /dev/null 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 new file mode 100644 index 0000000..8cf2f25 Binary files /dev/null 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 new file mode 100644 index 0000000..9a325ee Binary files /dev/null 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 new file mode 100644 index 0000000..eccdf92 Binary files /dev/null and b/docs/screenshots/linux/main-recording-en.png differ diff --git a/docs/screenshots/linux/main-window-de.png b/docs/screenshots/linux/main-window-de.png deleted file mode 100644 index ec28428..0000000 Binary files a/docs/screenshots/linux/main-window-de.png and /dev/null differ diff --git a/docs/screenshots/linux/main-window-en.png b/docs/screenshots/linux/main-window-en.png deleted file mode 100644 index 539bf3d..0000000 Binary files a/docs/screenshots/linux/main-window-en.png and /dev/null differ diff --git a/docs/screenshots/linux/main-window-recording-de.png b/docs/screenshots/linux/main-window-recording-de.png deleted file mode 100644 index 3b728b8..0000000 Binary files a/docs/screenshots/linux/main-window-recording-de.png and /dev/null differ diff --git a/docs/screenshots/linux/main-window-recording-en.png b/docs/screenshots/linux/main-window-recording-en.png deleted file mode 100644 index 6a7a92e..0000000 Binary files a/docs/screenshots/linux/main-window-recording-en.png and /dev/null differ diff --git a/docs/screenshots/linux/settings-ai-de.png b/docs/screenshots/linux/settings-ai-de.png new file mode 100644 index 0000000..6db7193 Binary files /dev/null and b/docs/screenshots/linux/settings-ai-de.png differ diff --git a/docs/screenshots/linux/settings-ai-en.png b/docs/screenshots/linux/settings-ai-en.png new file mode 100644 index 0000000..b0207f2 Binary files /dev/null and b/docs/screenshots/linux/settings-ai-en.png differ diff --git a/docs/screenshots/linux/settings-ai-workflows-de.png b/docs/screenshots/linux/settings-ai-workflows-de.png deleted file mode 100644 index 897b2f4..0000000 Binary files a/docs/screenshots/linux/settings-ai-workflows-de.png and /dev/null differ diff --git a/docs/screenshots/linux/settings-ai-workflows-en.png b/docs/screenshots/linux/settings-ai-workflows-en.png deleted file mode 100644 index 4b880ff..0000000 Binary files a/docs/screenshots/linux/settings-ai-workflows-en.png and /dev/null differ diff --git a/docs/screenshots/linux/settings-general-de.png b/docs/screenshots/linux/settings-general-de.png index aa2cc07..97bdcf9 100644 Binary files a/docs/screenshots/linux/settings-general-de.png and b/docs/screenshots/linux/settings-general-de.png differ diff --git a/docs/screenshots/linux/settings-general-en.png b/docs/screenshots/linux/settings-general-en.png index 94e65b4..46cd26e 100644 Binary files a/docs/screenshots/linux/settings-general-en.png and b/docs/screenshots/linux/settings-general-en.png differ diff --git a/docs/screenshots/linux/settings-speech-de.png b/docs/screenshots/linux/settings-speech-de.png index d656408..66e7276 100644 Binary files a/docs/screenshots/linux/settings-speech-de.png and b/docs/screenshots/linux/settings-speech-de.png differ diff --git a/docs/screenshots/linux/settings-speech-en.png b/docs/screenshots/linux/settings-speech-en.png index 6600c21..4ffbf0d 100644 Binary files a/docs/screenshots/linux/settings-speech-en.png and b/docs/screenshots/linux/settings-speech-en.png differ diff --git a/docs/screenshots/linux/social-preview.png b/docs/screenshots/linux/social-preview.png new file mode 100644 index 0000000..59bdb42 Binary files /dev/null and b/docs/screenshots/linux/social-preview.png differ diff --git a/docs/screenshots/linux/tray-menu-de.png b/docs/screenshots/linux/tray-menu-de.png index e49a28a..7df8b0a 100644 Binary files a/docs/screenshots/linux/tray-menu-de.png and b/docs/screenshots/linux/tray-menu-de.png differ diff --git a/docs/screenshots/linux/tray-menu-en.png b/docs/screenshots/linux/tray-menu-en.png index 31f0b58..aa0e19a 100644 Binary files a/docs/screenshots/linux/tray-menu-en.png and b/docs/screenshots/linux/tray-menu-en.png differ diff --git a/docs/screenshots/linux/tts-de.png b/docs/screenshots/linux/tts-de.png index 041f400..afc891f 100644 Binary files a/docs/screenshots/linux/tts-de.png and b/docs/screenshots/linux/tts-de.png differ diff --git a/docs/screenshots/linux/tts-en.png b/docs/screenshots/linux/tts-en.png index 9f4052f..483385d 100644 Binary files a/docs/screenshots/linux/tts-en.png and b/docs/screenshots/linux/tts-en.png differ diff --git a/scripts/_make_demo_gif.py b/scripts/_make_demo_gif.py index a03bd09..ce4fc3b 100644 --- a/scripts/_make_demo_gif.py +++ b/scripts/_make_demo_gif.py @@ -320,6 +320,8 @@ def _grab_app_states(lang: str) -> dict[str, Image.Image]: gui_toggle_recording=lambda *a, **k: None, gui_discard=lambda *a, **k: None, set_dictation_mode=lambda *a, **k: None, + show_compose_window=lambda *a, **k: None, + show_custom_prompt=lambda *a, **k: None, show_history_panel=lambda *a, **k: None, show_settings_dialog=lambda *a, **k: None, show_tts_window=lambda *a, **k: None, @@ -328,10 +330,12 @@ def _grab_app_states(lang: str) -> dict[str, Image.Image]: window = MainWindow(controller) window.show() - def grab(state: str, timer_text: str | None = None) -> Image.Image: + def grab(state: str, timer_text: str | None = None, phase: float = 0.0) -> Image.Image: window.update_state(state, None, None) if timer_text is not None: window._timer_label.setText(timer_text) + if state in ("RECORDING", "TRANSCRIBING"): + window._btn_toggle._phase = phase for _ in range(10): app.processEvents() with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle: @@ -346,10 +350,10 @@ def grab(state: str, timer_text: str | None = None) -> Image.Image: states = { "idle": grab("IDLE"), - "recording-1": grab("RECORDING", "00:01"), - "recording-2": grab("RECORDING", "00:02"), - "recording-3": grab("RECORDING", "00:03"), - "transcribing": grab("TRANSCRIBING"), + "recording-1": grab("RECORDING", "00:01", 0.15), + "recording-2": grab("RECORDING", "00:02", 0.45), + "recording-3": grab("RECORDING", "00:03", 0.75), + "transcribing": grab("TRANSCRIBING", phase=0.25), } window.close() return states diff --git a/scripts/_make_screenshots.py b/scripts/_make_screenshots.py index 49e10bf..e6581f7 100644 --- a/scripts/_make_screenshots.py +++ b/scripts/_make_screenshots.py @@ -19,7 +19,7 @@ from types import SimpleNamespace from PIL import Image, ImageDraw, ImageFilter, ImageFont -from PyQt6.QtCore import Qt +from PyQt6.QtCore import QPoint, Qt from PyQt6.QtWidgets import QApplication from app import theme @@ -27,7 +27,7 @@ from app.compose_window import ComposeWindow from app.config import BlitztextConfig from app.history_panel import HistoryPanel -from app.i18n import set_language, t +from app.i18n import set_language, t, tq from app.llm_service import LLMService, WorkflowType from app.main_window import MainWindow from app.paste_service import PasteService @@ -47,15 +47,14 @@ LANG_COPY = { "en": { "banner": "Banner-en.png", - "social": "Banner.png", - "hero": "Your local AI voice assistant for KDE Plasma & Wayland", + "hero": "Your local AI voice assistant for Linux", "sub": "Record speech, transcribe locally or online, optionally rewrite it with AI, and paste it directly into the active app.", "flow": "Record • Transcribe • Rewrite • Paste", - "feature_title": "What is new in v0.8.0", + "feature_title": "Current highlights", "tag_new": "NEW", "chips": [ ("Compose window", "Draft, refine and compare AI-rewritten text before pasting it anywhere."), - ("Preset in main window", "Pick your writing style in the main window — changes sync to the tray instantly."), + ("Single-panel widget", "Unified frameless surface with gold microphone, waves and tool row."), ("Tone & template control", "Choose tone and writing template directly inside the Compose window."), ("Prompt transparency", "Inspect and edit the AI system prompt before running a rewrite."), ], @@ -67,24 +66,25 @@ }, "compose_input": "Please help me write a concise follow-up email for our product meeting earlier today.", "compose_output": "Hi team,\n\nThank you for the productive discussion today. Here are the key action items we agreed on:\n\n• Finalise the API contract by Friday\n• Schedule a follow-up review for next Tuesday\n• Share the updated roadmap with stakeholders\n\nLet me know if I missed anything.\n\nBest,", + "custom_prompt": "Write a friendly and concise message. Keep all key action items direct.", "history_entries": [ ("Please move tomorrow's team sync to 10:00.", False), ("Could you send me the updated rollout plan afterwards?", True), ("The draft is ready and stored in the shared project folder.", False), ], "tts_text": "Read this short summary aloud with the current voice settings.", + "tts_voice": "en_US-amy-medium", }, "de": { "banner": "Banner-de.png", - "social": None, - "hero": "Dein lokaler KI-Sprachassistent für KDE Plasma & Wayland", + "hero": "Dein lokaler KI-Sprachassistent für Linux", "sub": "Sprache aufnehmen, lokal oder online transkribieren, optional mit KI umformulieren und direkt in die aktive Anwendung einfügen.", - "flow": "Aufnehmen • Transkribieren • Umformulieren • Direkt einfügen", - "feature_title": "Neu in v0.8.0", + "flow": "Aufnehmen • Transkribieren • Umschreiben • Einfügen", + "feature_title": "Aktuelle Highlights", "tag_new": "NEU", "chips": [ ("Entwurfsfenster", "Text entwerfen, verfeinern und KI-Varianten vergleichen, bevor du einfügst."), - ("Preset im Hauptfenster", "Schreibstil direkt im Hauptfenster wählen – Änderungen bleiben mit dem Tray synchron."), + ("Single-Panel-Widget", "Kompakte rahmenlose Einzelfläche mit Gold-Mikrofon, Wellen und Werkzeugleiste."), ("Tonfall & Vorlage", "Tonfall und Schreibvorlage direkt im Entwurfsfenster steuern."), ("Prompt-Transparenz", "KI-Systemprompt vor der Ausführung einsehen und anpassen."), ], @@ -96,12 +96,14 @@ }, "compose_input": "Bitte hilf mir, eine knappe Nachfass-E-Mail zu unserem heutigen Produktmeeting zu schreiben.", "compose_output": "Hallo zusammen,\n\nvielen Dank für die produktive Diskussion heute. Hier die vereinbarten Aufgaben:\n\n• API-Vertrag bis Freitag finalisieren\n• Review-Termin für nächsten Dienstag eintragen\n• Aktualisierte Roadmap an Stakeholder verteilen\n\nBitte meldet euch, falls ich etwas vergessen habe.\n\nViele Grüße,", + "custom_prompt": "Formuliere eine freundliche, knappe Mitteilung. Halte alle Aufgaben direkt fest.", "history_entries": [ ("Bitte verschiebe das Team-Meeting morgen auf 10 Uhr.", False), ("Kannst du mir danach den aktualisierten Rollout-Plan schicken?", True), ("Der Entwurf ist fertig und liegt im gemeinsamen Projektordner.", False), ], "tts_text": "Lies diese kurze Zusammenfassung mit den aktuellen Spracheinstellungen vor.", + "tts_voice": "de_DE-thorsten-medium", }, } @@ -117,13 +119,13 @@ def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.I return ImageFont.load_default() -FONT_TITLE = _font(56, bold=True) -FONT_SUBTITLE = _font(25, bold=False) -FONT_FLOW = _font(24, bold=True) -FONT_FEATURE_TITLE = _font(21, bold=True) +FONT_TITLE = _font(52, bold=True) +FONT_SUBTITLE = _font(21, bold=False) +FONT_FLOW = _font(17, bold=True) +FONT_FEATURE_TITLE = _font(20, bold=True) FONT_CARD_TITLE = _font(19, bold=True) -FONT_CARD_TEXT = _font(15, bold=False) -FONT_LABEL = _font(15, bold=True) +FONT_CARD_TEXT = _font(14, bold=False) +FONT_LABEL = _font(14, bold=True) app: QApplication | None = None @@ -260,20 +262,25 @@ def _make_banner(lang: str, out_dir: Path) -> None: draw.rectangle((88, 94, 92, 116), fill=(8, 16, 30, 255)) draw.arc((68, 100, 112, 132), 20, 160, fill=(70, 194, 255, 255), width=4) - draw.text((150, 54), "Blitztext Linux", font=FONT_TITLE, fill=TEXT_PRIMARY) - draw.text((150, 126), copy["hero"], font=FONT_SUBTITLE, fill=TEXT_SECONDARY) - draw.text((150, 170), copy["sub"], font=FONT_CARD_TEXT, fill="#c7d8ea") - draw.text((150, 208), copy["flow"], font=FONT_FLOW, fill=ACCENT) - draw.text((150, 258), copy["feature_title"], font=FONT_FEATURE_TITLE, fill=TEXT_PRIMARY) + y = 44 + draw.text((150, y), "Blitztext Linux", font=FONT_TITLE, fill=TEXT_PRIMARY) + y += FONT_TITLE.size + 10 + y = _draw_multiline(draw, copy["hero"], (150, y), 540, FONT_SUBTITLE, TEXT_SECONDARY, line_gap=4) + y += 8 + y = _draw_multiline(draw, copy["sub"], (150, y), 540, FONT_CARD_TEXT, "#c7d8ea", line_gap=4) + y += 12 + draw.text((150, y), copy["flow"], font=FONT_FLOW, fill=ACCENT) + y += FONT_FLOW.size + 12 + draw.text((150, y), copy["feature_title"], font=FONT_FEATURE_TITLE, fill=TEXT_PRIMARY) card_positions = [ - (60, 304), - (396, 304), - (60, 444), - (396, 444), + (60, 268), + (370, 268), + (60, 448), + (370, 448), ] - card_w = 304 - card_h = 118 + card_w = 290 + card_h = 162 for index, ((title, desc), (x0, y0)) in enumerate(zip(copy["chips"], card_positions, strict=False)): x1 = x0 + card_w y1 = y0 + card_h @@ -284,17 +291,17 @@ def _make_banner(lang: str, out_dir: Path) -> None: _draw_multiline(draw, desc, (x0 + 16, y0 + 84), card_w - 30, FONT_CARD_TEXT, TEXT_SECONDARY, line_gap=4) screenshots = { - "main": out_dir / f"main-window-{lang}.png", + "main": out_dir / f"main-idle-{lang}.png", "compose": out_dir / f"compose-{lang}.png", - "workflows": out_dir / f"settings-ai-workflows-{lang}.png", + "workflows": out_dir / f"settings-ai-{lang}.png", "tray": out_dir / f"tray-menu-{lang}.png", } placements = [ - (screenshots["main"], (824, 74), (220, 260), copy["labels"]["main"]), - (screenshots["compose"], (1046, 74), (194, 260), copy["labels"]["compose"]), - (screenshots["workflows"], (790, 350), (250, 236), copy["labels"]["workflows"]), - (screenshots["tray"], (1054, 332), (186, 254), copy["labels"]["tray"]), + (screenshots["main"], (730, 52), (230, 230), copy["labels"]["main"]), + (screenshots["compose"], (1000, 52), (230, 230), copy["labels"]["compose"]), + (screenshots["workflows"], (730, 342), (230, 230), copy["labels"]["workflows"]), + (screenshots["tray"], (1000, 342), (230, 230), copy["labels"]["tray"]), ] for image_path, (x, y), size, label in placements: @@ -309,10 +316,64 @@ def _make_banner(lang: str, out_dir: Path) -> None: out_path = out_dir / copy["banner"] canvas.convert("RGB").save(out_path, quality=95) print(f" ✓ {out_path.name}") - if copy["social"]: - social_path = out_dir / copy["social"] - canvas.convert("RGB").save(social_path, quality=95) - print(f" ✓ {social_path.name}") + + +def _make_social_preview(screenshot_dir: Path, out_path: Path) -> None: + canvas = Image.new("RGBA", CANVAS_SIZE, BACKGROUND_BOTTOM) + bg = Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg) + for i in range(CANVAS_SIZE[1]): + ratio = i / max(1, CANVAS_SIZE[1] - 1) + top = tuple(int(int(BACKGROUND_TOP[j:j + 2], 16) * (1 - ratio) + int(BACKGROUND_BOTTOM[j:j + 2], 16) * ratio) for j in (1, 3, 5)) + bg_draw.line((0, i, CANVAS_SIZE[0], i), fill=top + (255,)) + canvas.alpha_composite(bg) + + draw = ImageDraw.Draw(canvas) + x_logo, y_logo = 70, 195 + # Logo + draw.ellipse((x_logo, y_logo, x_logo + 88, y_logo + 88), fill=(13, 150, 255, 255), outline=(70, 194, 255, 255), width=3) + draw.ellipse((x_logo + 29, y_logo + 26, x_logo + 59, y_logo + 56), fill=(8, 16, 30, 255)) + draw.rectangle((x_logo + 41, y_logo + 50, x_logo + 47, y_logo + 79), fill=(8, 16, 30, 255)) + draw.arc((x_logo + 18, y_logo + 59, x_logo + 70, y_logo + 98), 20, 160, fill=(70, 194, 255, 255), width=4) + + # Title & Subtitle + font_title = _font(56, bold=True) + draw.text((x_logo + 110, y_logo + 14), "Blitztext Linux", font=font_title, fill=TEXT_PRIMARY) + font_sub = _font(24, bold=False) + y_sub = y_logo + 118 + draw.text((x_logo, y_sub), "Your local AI voice assistant for Linux", font=font_sub, fill=TEXT_SECONDARY) + + # Flow + font_flow = _font(21, bold=True) + y_flow = y_sub + 46 + draw.text((x_logo, y_flow), "Record • Transcribe • Rewrite • Paste", font=font_flow, fill=ACCENT) + + def _add_window_shadow(base: Image.Image, img: Image.Image, x: int, y: int, radius: int = 30, offset: tuple[int, int] = (0, 15), opacity: int = 220) -> None: + shadow = Image.new("RGBA", base.size, (0, 0, 0, 0)) + s_draw = ImageDraw.Draw(shadow) + sx = x + offset[0] + sy = y + offset[1] + s_draw.rounded_rectangle((sx, sy, sx + img.width, sy + img.height), radius=20, fill=(0, 0, 0, opacity)) + shadow = shadow.filter(ImageFilter.GaussianBlur(radius)) + base.alpha_composite(shadow) + base.alpha_composite(img, (x, y)) + + img_compose = Image.open(screenshot_dir / "compose-en.png").convert("RGBA") + img_recording = Image.open(screenshot_dir / "main-recording-en.png").convert("RGBA") + img_idle = Image.open(screenshot_dir / "main-idle-en.png").convert("RGBA") + + compose_scaled = img_compose.resize((550, 355), Image.Resampling.LANCZOS) + _add_window_shadow(canvas, compose_scaled, 660, 70, radius=30, offset=(0, 14), opacity=200) + + rec_scaled = img_recording.resize((284, 252), Image.Resampling.LANCZOS) + _add_window_shadow(canvas, rec_scaled, 580, 295, radius=34, offset=(0, 16), opacity=230) + + idle_scaled = img_idle.resize((242, 215), Image.Resampling.LANCZOS) + _add_window_shadow(canvas, idle_scaled, 920, 335, radius=28, offset=(0, 14), opacity=210) + + out_path.parent.mkdir(parents=True, exist_ok=True) + canvas.convert("RGB").save(out_path, quality=95) + print(f" ✓ {out_path.name}") class _FakeLLMService(LLMService): @@ -347,7 +408,7 @@ def paste(self, text: str, force_autopaste=None) -> None: def _tab_index(tabs, key: str) -> int: """Resolve a settings tab by its i18n key, independent of tab order or language.""" - target = t(key) + target = tq(key) for index in range(tabs.count()): if tabs.tabText(index) == target: return index @@ -361,65 +422,134 @@ def _render_language_set(out_dir: Path, lang: str) -> None: with tempfile.TemporaryDirectory(prefix=f"blitztext-assets-{lang}-") as tmp_dir: config = BlitztextConfig(config_dir=Path(tmp_dir)) config.ui_language = lang - config.writing_preset = "kurz_praezise" + config.writing_preset = "shorten" config.llm_provider = "openai" - config.tts_provider = "openai" - config.tts_openai_consent = True - config.notes_folder = str(Path.home() / "Blitztext-Notes") + # Piper is the default provider and keeps the shot free of cloud errors. + config.tts_provider = "piper" + config.notes_folder = "/home/user/Blitztext-Notes" + config.compose_custom_preset_text = copy.get("custom_prompt", "") controller = SimpleNamespace( gui_toggle_recording=lambda *a, **k: None, gui_discard=lambda *a, **k: None, set_dictation_mode=lambda *a, **k: None, + show_compose_window=lambda *a, **k: None, + show_custom_prompt=lambda *a, **k: None, show_history_panel=lambda *a, **k: 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 = MainWindow(controller) - # Switch to TEXT_IMPROVER so the writing-style preset combo is visible - for i in range(main_window._workflow_combo.count()): - if main_window._workflow_combo.itemData(i) == WorkflowType.TEXT_IMPROVER: - main_window._workflow_combo.setCurrentIndex(i) - _process_events() - break - _grab(main_window, out_dir / f"main-window-{lang}.png") - main_window.update_state("RECORDING", None, None) - _grab(main_window, out_dir / f"main-window-recording-{lang}.png") - main_window.close() + # 1. Main Window States + main_win = MainWindow(controller) + main_win.set_history_count(3) + main_win.show() + + # 1.1 Idle + main_win.update_state("IDLE", None, None) + _grab(main_win, out_dir / f"main-idle-{lang}.png") + + # 1.2 Recording + main_win.update_state("RECORDING", None, None) + main_win._timer_label.setText("00:04") + main_win._btn_toggle._phase = 0.35 + _grab(main_win, out_dir / f"main-recording-{lang}.png") + + # 1.3 Processing + main_win.update_state("TRANSCRIBING", None, None) + main_win._btn_toggle._phase = 0.25 + _grab(main_win, out_dir / f"main-processing-{lang}.png") + + # 1.4 Main Menu (Popup open above the options button) + main_win.update_state("IDLE", None, None) + main_win._select_workflow(WorkflowType.TEXT_IMPROVER) + main_win._options_popup.show() + _process_events(12) + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f1: + w_tmp = Path(f1.name) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f2: + p_tmp = Path(f2.name) + try: + main_win.grab().save(str(w_tmp)) + main_win._options_popup.grab().save(str(p_tmp)) + img_w = Image.open(w_tmp).convert("RGBA") + img_p = Image.open(p_tmp).convert("RGBA") + btn_pos = main_win._btn_options.mapTo(main_win, QPoint(main_win._btn_options.width(), 0)) + canvas_w = max(img_w.width, img_p.width + 20) + popup_x = max(0, btn_pos.x() - img_p.width + 16) + popup_y = 0 + w_x = (canvas_w - img_w.width) // 2 + w_y = img_p.height - 16 + canvas_h = w_y + img_w.height + menu_comp = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) + menu_comp.alpha_composite(img_p, (popup_x, popup_y)) + menu_comp.alpha_composite(img_w, (w_x, w_y)) + menu_comp_path = out_dir / f"main-menu-{lang}.png" + menu_comp.save(str(menu_comp_path)) + print(f" ✓ {menu_comp_path.name}") + finally: + w_tmp.unlink(missing_ok=True) + p_tmp.unlink(missing_ok=True) + main_win._options_popup.hide() + main_win.close() + + # 2. Compose Window + compose = ComposeWindow(_FakeLLMService(), _FakePasteService(), config) + compose.set_input_text(copy["compose_input"]) + compose.txtOutput.setPlainText(copy["compose_output"]) + _grab(compose, out_dir / f"compose-{lang}.png") + + # 2.1 Compose with Prompt Editor expanded + compose.edit_custom_prompt() + compose.resize(620, 500) + _grab(compose, out_dir / f"compose-prompt-{lang}.png") + compose.close() + + # 3. Settings Dialog settings = SettingsDialog(config) + # Per tab height so every form is fully visible without empty filler space. settings.tabs.setCurrentIndex(_tab_index(settings.tabs, "settings.tab.general")) + settings.resize(560, 500) _grab(settings, out_dir / f"settings-general-{lang}.png") + settings.tabs.setCurrentIndex(_tab_index(settings.tabs, "settings.tab.workflows")) + settings.resize(560, 700) + _grab(settings, out_dir / f"settings-ai-{lang}.png") settings.tabs.setCurrentIndex(_tab_index(settings.tabs, "settings.tab.speech")) + settings.resize(560, 440) _grab(settings, out_dir / f"settings-speech-{lang}.png") - settings.tabs.setCurrentIndex(_tab_index(settings.tabs, "settings.tab.workflows")) - _grab(settings, out_dir / f"settings-ai-workflows-{lang}.png") settings.close() + # 4. History Panel history = HistoryPanel(max_entries=50, notes_folder="") history.resize(420, 460) for text, merged in copy["history_entries"]: history.add_entry(text, is_dictation=merged) _grab(history, out_dir / f"history-{lang}.png") - # Let entry-add animations settle before the panel is torn down - time.sleep(0.9) + time.sleep(0.5) _process_events(6) history.close() + # 5. TTS Window tts = TtsWindow(config) + tts.resize(520, 360) tts.set_text(copy["tts_text"]) + # The build host has no Piper voices installed; show a representative + # entry instead of the empty-state label. + tts._voice_combo.blockSignals(True) + tts._voice_combo.clear() + tts._voice_combo.addItem(copy["tts_voice"], userData=copy["tts_voice"]) + tts._voice_combo.setEnabled(True) + tts._voice_combo.blockSignals(False) _grab(tts, out_dir / f"tts-{lang}.png") tts.close() - compose = ComposeWindow(_FakeLLMService(), _FakePasteService(), config) - compose.set_input_text(copy["compose_input"]) - compose.txtOutput.setPlainText(copy["compose_output"]) - _grab(compose, out_dir / f"compose-{lang}.png") - compose.close() - + # 6. Tray Menu _capture_tray_menu(config, lang, out_dir / f"tray-menu-{lang}.png") + + # 7. Banner _make_banner(lang, out_dir) @@ -429,12 +559,14 @@ def main() -> int: out_dir.mkdir(parents=True, exist_ok=True) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") app = QApplication(sys.argv) - # Wie im echten App-Start (blitztext_linux.main): Breeze-Dark-Glass-Theme, + # Wie im echten App-Start (blitztext_linux.main): zentrales App-Theme, # damit die Screenshots die tatsaechliche Produkt-Optik zeigen. theme.apply_theme(app) for lang in ("en", "de"): print(f"Generating assets for {lang} …") _render_language_set(out_dir, lang) + print("Generating social preview …") + _make_social_preview(out_dir, out_dir / "social-preview.png") print("Done.") return 0 diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 172455e..e675b39 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -8,7 +8,7 @@ from app.compose_window import MAX_COMPOSE_VARIANTS, ComposeWindow from app.config import Config -from app.i18n import DEFAULT_LANGUAGE, missing_keys, set_language, t +from app.i18n import DEFAULT_LANGUAGE, missing_keys, set_language, t, tq from app.workflows import WorkflowType _GUI = os.environ.get("WHISPER_GUI_TESTS") == "1" @@ -139,8 +139,8 @@ def _wait_until(qapp, predicate, timeout_ms: int = 2500) -> None: @pytest.mark.parametrize( ("language", "title", "prompt_label"), [ - ("de", "Text verfassen", "Schreibstil / Prompt bearbeiten"), - ("en", "Compose Text", "Edit style / prompt"), + ("de", "Text bearbeiten", "Prompt prüfen"), + ("en", "Edit text", "Inspect prompt"), ], ) def test_window_texts_follow_language(qapp, language, title, prompt_label): @@ -152,7 +152,7 @@ def test_window_texts_follow_language(qapp, language, title, prompt_label): assert window.btnShowPrompt.text() == prompt_label assert window.btnShowPrompt.toolTip() == t("compose.prompt_preview.tooltip") assert window.btnCopy.text() == t("compose.button.copy") - assert window.btnPaste.text() == t("compose.button.insert_close") + assert window.btnPaste.text() == tq("compose.button.insert_close") assert window.btnClose.text() == t("compose.button.close") finally: window.close() @@ -832,7 +832,7 @@ def _select_preset(window, preset_key: str) -> None: @gui_only -def test_tone_selector_visible_and_enabled_for_standard(qapp): +def test_tone_selector_hidden_for_standard(qapp): window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) window.show() qapp.processEvents() @@ -841,7 +841,7 @@ def test_tone_selector_visible_and_enabled_for_standard(qapp): _select_preset(window, "shorten") _select_preset(window, "standard") qapp.processEvents() - assert window.cmbTone.isVisible() is True + assert window.cmbTone.isVisible() is False assert window.cmbTone.isEnabled() is True assert window.cmbTone.toolTip() == t("compose.tone.tooltip_active") finally: @@ -858,7 +858,7 @@ def test_tone_selector_disabled_for_nonstandard_preset(qapp): _select_workflow(window, WorkflowType.TEXT_IMPROVER) _select_preset(window, "shorten") qapp.processEvents() - assert window.cmbTone.isVisible() is True + assert window.cmbTone.isVisible() is False assert window.cmbTone.isEnabled() is False assert window.cmbTone.toolTip() == t("compose.tone.tooltip_preset_overrides") finally: @@ -875,7 +875,7 @@ def test_tone_selector_disabled_for_custom_preset(qapp): _select_workflow(window, WorkflowType.TEXT_IMPROVER) _select_preset(window, CUSTOM_PRESET_KEY) qapp.processEvents() - assert window.cmbTone.isVisible() is True + assert window.cmbTone.isVisible() is False assert window.cmbTone.isEnabled() is False finally: window.close() @@ -946,7 +946,7 @@ def test_selected_tone_is_passed_to_worker(qapp): @gui_only -def test_custom_action_is_resolved_centrally_by_llm_service(qapp): +def test_custom_action_passes_visible_instruction_to_llm_service(qapp): config = Config() config.compose_custom_preset_text = "FREITEXT-PROMPT" llm = _FakeLLMService() @@ -960,7 +960,7 @@ def test_custom_action_is_resolved_centrally_by_llm_service(qapp): window.btnAction.click() _wait_until(qapp, lambda: not window._busy and window._worker_thread is None and llm.calls) assert llm.calls[-1][2] == "custom" - assert llm.last_custom_prompt is None + assert llm.last_custom_prompt == "FREITEXT-PROMPT" finally: window.close() qapp.processEvents() @@ -1004,6 +1004,58 @@ def test_tone_i18n_keys_present_and_complete(qapp, language): GOAL_VISIBLE_PRESET_KEYS = ("standard", "shorten", "expand", "change_tone", "custom") +@gui_only +def test_instruction_editor_is_available_without_draft(compose_window, qapp): + window, llm, _ = compose_window + assert not window.cmbWorkflow.isVisible() + window.btnOwnPrompt.click() + qapp.processEvents() + assert window.txtOwnPrompt.isVisible() + assert window._selected_preset() == "custom" + assert llm.calls == [] + + +@gui_only +def test_empty_instruction_blocks_request(compose_window): + window, llm, _ = compose_window + window.btnOwnPrompt.click() + window.txtInput.setPlainText("A draft") + window.btnAction.click() + assert llm.calls == [] + assert window.lblStatus.text() == t("compose.custom.empty") + + +@gui_only +def test_edited_instruction_is_used_and_saved_explicitly(compose_window, qapp): + window, llm, _ = compose_window + window.btnOwnPrompt.click() + window.txtOwnPrompt.setPlainText("Write a friendly reply.") + assert window._config.compose_custom_preset_text == "" + window.txtInput.setPlainText("My draft") + window.btnAction.click() + _wait_until(qapp, lambda: not window._busy and window._worker_thread is None and llm.calls) + assert llm.last_custom_prompt == "Write a friendly reply." + window.btnSavePrompt.click() + assert Config().compose_custom_preset_text == "Write a friendly reply." + + +@gui_only +def test_instruction_save_failure_preserves_draft_and_config(compose_window, monkeypatch): + from app.config import ConfigError + window, _, _ = compose_window + window._config.writing_preset = "shorten" + window.btnOwnPrompt.click() + window.txtOwnPrompt.setPlainText("Keep this draft") + def fail(): + raise ConfigError("disk full") + monkeypatch.setattr(window._config, "save", fail) + window.btnSavePrompt.click() + assert window.txtOwnPrompt.toPlainText() == "Keep this draft" + assert window._config.compose_custom_preset_text == "" + assert window._config.writing_preset == "shorten" + assert window.lblStatus.text() == t("compose.custom.save_error") + + def _selectable_preset_values(combo): return tuple( combo.itemData(index) diff --git a/tests/test_features.py b/tests/test_features.py index fb2e232..3629a18 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -672,7 +672,7 @@ def test_abort_export_resets_cleanup_and_ui_state(self): fake._cleanup_active_wav.assert_called_once() assert fake._pending_export_path is None fake._status_label.setText.assert_called_once_with("Export fehlgeschlagen") - fake._status_label.setStyleSheet.assert_called_once_with("color: #f44336;") + fake._status_label.setProperty.assert_called_once_with("status", "error") fake._btn_speak.setText.assert_called_once_with(tts_window.t("tts.button.speak")) fake._btn_pause.setEnabled.assert_called_once_with(False) assert fake._is_paused is False diff --git a/tests/test_i18n.py b/tests/test_i18n.py index bae9be7..12cf280 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -5,7 +5,7 @@ import pytest -from app.i18n import LANGUAGES, DEFAULT_LANGUAGE, TRANSLATIONS, t, get_language, set_language, missing_keys +from app.i18n import LANGUAGES, DEFAULT_LANGUAGE, TRANSLATIONS, t, tq, get_language, set_language, missing_keys PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") @@ -225,21 +225,21 @@ def test_set_language_case_sensitive(self): ( "de", ( - "Standard / Text verbessern", + "Text verbessern", "Kürzen", "Ausformulieren", "Tonfall ändern", - "Eigener Prompt", + "Eigene Anweisung", ), ), ( "en", ( - "Standard / Improve text", + "Improve text", "Shorten", "Expand", "Change tone", - "Custom prompt", + "Custom instruction", ), ), ], @@ -248,3 +248,19 @@ def test_goal_visible_preset_names(language, expected): keys = ("standard", "shorten", "expand", "change_tone", "custom") set_language(language) assert tuple(t(f"preset.{key}.name") for key in keys) == expected + + +@pytest.mark.parametrize( + ("language", "key", "expected"), + [ + ("de", "settings.tab.workflows", "Text && KI"), + ("en", "settings.tab.workflows", "Text && AI"), + ("en", "compose.button.insert_close", "Insert && Close"), + ("de", "compose.button.insert_close", "Einfügen && Schließen"), + ("en", "settings.tab.general", "General"), + ], +) +def test_tq_escapes_ampersands_for_qt_widgets(language, key, expected): + """Qt liest "&" als Mnemonic — tq maskiert es, sonst fehlt es in der UI.""" + set_language(language) + assert tq(key) == expected diff --git a/tests/test_main_window_widget.py b/tests/test_main_window_widget.py new file mode 100644 index 0000000..3bdea94 --- /dev/null +++ b/tests/test_main_window_widget.py @@ -0,0 +1,670 @@ +"""Behavioral checks for the compact neumorphic main widget.""" +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") + + +@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 gui_toggle_recording(self, workflow) -> None: + self.calls.append(("toggle", workflow)) + + def gui_discard(self) -> None: + self.calls.append("discard") + + def set_dictation_mode(self, enabled: bool) -> None: + self.calls.append(("dictation", enabled)) + + def show_compose_window(self) -> None: + self.calls.append("compose") + + def show_custom_prompt(self) -> None: + self.calls.append("prompt") + + def main_window_preset_changed(self, key: str) -> None: + self.calls.append(("preset", key)) + + def show_history_panel(self) -> None: + self.calls.append("history") + + def show_tts_window(self) -> None: + self.calls.append("tts") + + def show_settings_dialog(self) -> None: + self.calls.append("settings") + + +@gui_only +def test_main_widget_keeps_only_core_controls_permanently_visible(qapp): + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + assert window._main_panel.isVisible() + assert not hasattr(window, "_toolbar") + for control in (window._title_label, window._btn_minimize, window._btn_close, + window._btn_toggle, window._status_label, window._timer_label, + window._btn_edit_text, window._btn_history, window._btn_tts, + window._btn_settings, window._btn_options): + assert control.isVisible() + assert window._options_popup.isVisible() is False + assert window._workflow_combo.isVisible() is False + assert window._btn_discard.isVisible() is False + assert window._btn_dictation.isVisible() is False + assert not hasattr(window, "_presentation_header") + + +@gui_only +def test_host_window_is_frameless_and_panel_owns_title_and_window_controls(qapp): + from PyQt6.QtCore import Qt + from app.i18n import t + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + assert window.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + assert window.windowFlags() & Qt.WindowType.FramelessWindowHint + assert "QWidget#mainWidgetWindow { background: transparent; }" in window.styleSheet() + assert window._title_label.text() == t("app.name") + assert window._btn_minimize.toolTip() == t("mainwindow.tooltip.minimize") + assert window._btn_close.toolTip() == t("mainwindow.tooltip.close") + for control in (window._title_label, window._btn_minimize, window._btn_close): + assert control.parentWidget() is window._main_panel + + window.setWindowTitle("Blitztext Test") + assert window._title_label.text() == "Blitztext Test" + + +@gui_only +def test_panel_close_button_hides_to_tray_without_quitting(qapp): + from app.main_window import MainWindow + + controller = _Controller() + window = MainWindow(controller) + window.show() + window._btn_options.click() + qapp.processEvents() + + window._btn_close.click() + qapp.processEvents() + + assert not window.isVisible() + assert not window._options_popup.isVisible() + assert controller.calls == [] + window.show() + qapp.processEvents() + assert window.isVisible() + + +@gui_only +def test_panel_minimize_button_minimizes_the_real_window(qapp, monkeypatch): + from app.main_window import MainWindow + + minimized: list[bool] = [] + monkeypatch.setattr(MainWindow, "showMinimized", lambda self: minimized.append(True)) + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + window._btn_minimize.click() + + assert minimized == [True] + assert window.isVisible() + + +@gui_only +def test_non_interactive_panel_areas_start_native_system_move(qapp): + from PyQt6.QtCore import QPoint, Qt + from PyQt6.QtTest import QTest + from app.main_window import MainWindow + + controller = _Controller() + window = MainWindow(controller) + window.show() + qapp.processEvents() + moves: list[bool] = [] + + class _Handle: + def startSystemMove(self) -> bool: # noqa: N802 + moves.append(True) + return True + + window.windowHandle = lambda: _Handle() + button = window._btn_toggle + center = QPoint(button.width() // 2, button.height() // 2) + + QTest.mousePress(window._title_label, Qt.MouseButton.LeftButton) + QTest.mousePress(window._status_label, Qt.MouseButton.LeftButton) + QTest.mousePress(button, Qt.MouseButton.LeftButton, pos=QPoint(3, center.y())) + assert len(moves) == 3 + assert button._pressed is False + + QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=center) + QTest.mouseClick(window._btn_settings, Qt.MouseButton.LeftButton) + assert len(moves) == 3 + assert [call for call in controller.calls if call != "settings"][0][0] == "toggle" + assert "settings" in controller.calls + + +@gui_only +def test_options_are_shown_only_in_transient_popup(qapp): + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + window._btn_options.click() + qapp.processEvents() + assert window._workflow_combo.isVisible() + assert window._btn_dictation.isVisible() + assert window._btn_discard.isVisible() + + window._options_popup.hide() + assert window._options_popup.isVisible() is False + + +@gui_only +def test_single_panel_owns_microphone_status_and_tools(qapp): + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + for control in (window._btn_toggle, window._status_label, window._btn_edit_text, + window._btn_history, window._btn_tts, window._btn_settings, + window._btn_options): + assert control.parentWidget() is window._main_panel + + +@gui_only +def test_popup_preserves_all_existing_workflows(qapp): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + + assert [ + window._workflow_combo.itemData(index) + for index in range(window._workflow_combo.count()) + ] == [ + WorkflowType.TRANSCRIPTION, + WorkflowType.LOCAL, + WorkflowType.TEXT_IMPROVER, + WorkflowType.DAMPF_ABLASSEN, + WorkflowType.EMOJI_TEXT, + ] + + +@gui_only +def test_toolbar_actions_keep_existing_controller_contract(qapp): + from app.main_window import MainWindow + + controller = _Controller() + window = MainWindow(controller) + window.show() + qapp.processEvents() + + window._btn_edit_text.click() + window._btn_history.click() + window._btn_tts.click() + window._btn_settings.click() + + assert controller.calls == ["compose", "history", "tts", "settings"] + + +@gui_only +def test_dictation_popup_reflects_active_state(qapp): + from app.i18n import t + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + window.set_dictation_checked(True) + + assert window._btn_dictation.isChecked() + assert window._btn_dictation.text() == t("mainwindow.button.dictation_active") + + +@gui_only +def test_recording_state_enables_discard_and_updates_gold_widget(qapp): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + window.update_state("RECORDING", WorkflowType.TRANSCRIPTION, None) + assert window._btn_toggle.mode == "RECORDING" + assert window._btn_toggle._anim.isActive() + assert window._btn_toggle.text() == "Stopp" + assert window._btn_discard.isEnabled() + + window.update_state("TRANSCRIBING", WorkflowType.TRANSCRIPTION, None) + assert window._btn_toggle.mode == "PROCESSING" + assert window._btn_toggle._anim.isActive() + assert window._btn_discard.isEnabled() is False + assert window._timer_label.isVisible() is False + + window.update_state("IDLE", WorkflowType.TRANSCRIPTION, None) + assert window._btn_toggle.mode == "IDLE" + assert not window._btn_toggle._anim.isActive() + + +@pytest.fixture +def paint_errors(monkeypatch): + """Collect exceptions raised inside Qt virtual overrides such as paintEvent. + + Without a custom sys.excepthook PyQt6 calls qFatal() and aborts the whole + process (SIGABRT), which is exactly the production crash this guards. + """ + import sys + + # Store text only: keeping the exception would keep its traceback frame and + # therefore an unfinished QPainter alive beyond its paint device. + errors: list[str] = [] + monkeypatch.setattr( + sys, "excepthook", + lambda exc_type, value, _tb: errors.append(f"{exc_type.__name__}: {value}"), + ) + return errors + + +@gui_only +@pytest.mark.parametrize("state", ["IDLE", "RECORDING", "TRANSCRIBING", "LLM_REWRITING"]) +def test_record_button_paints_every_state_without_exception(qapp, paint_errors, state): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + window.update_state(state, WorkflowType.TRANSCRIPTION, None) + window._btn_toggle._tick() + window._btn_toggle.grab() + window.grab() + + assert paint_errors == [] + + +@gui_only +def test_full_state_cycle_repaints_without_exception(qapp, paint_errors): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + for _cycle in range(3): + for state in ("IDLE", "RECORDING", "TRANSCRIBING", "LLM_REWRITING", "IDLE"): + window.update_state(state, WorkflowType.TRANSCRIPTION, None) + window._btn_toggle._tick() + window.grab() + + assert paint_errors == [] + assert window._btn_toggle.mode == "IDLE" + assert not window._btn_toggle._anim.isActive() + + +def _gold_pixels(button, zone) -> int: + """Count clearly gold pixels in a logical-coordinate zone of the button.""" + image = button.grab().toImage() + ratio = image.width() / button.width() + left, top, right, bottom = zone + count = 0 + for y in range(int(top * ratio), int(bottom * ratio)): + for x in range(int(left * ratio), int(right * ratio)): + color = image.pixelColor(x, y) + if color.red() > 150 and color.green() > 110 and color.red() - color.blue() > 60: + count += 1 + return count + + +def _side_zones(button): + """Left and right areas beside the processing ring, where waves belong.""" + from app.main_window import _RING_DIAMETER, _RING_PEN + + center_x = button.width() / 2 + clearance = _RING_DIAMETER / 2 + _RING_PEN / 2 + 1 + height = button.height() + return ((0, 0, center_x - clearance, height), + (center_x + clearance, 0, button.width(), height)) + + +def _ring_zone(button): + """Thin band above the mic face that only the processing ring paints.""" + from app.main_window import _RING_DIAMETER + + center_x = button.width() / 2 + ring_top = button.height() / 2 - _RING_DIAMETER / 2 + return (center_x - 20, ring_top - 1, center_x + 20, ring_top + 2) + + +@gui_only +def test_recording_waves_are_visible_beside_the_microphone(qapp, paint_errors): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + button = window._btn_toggle + left, right = _side_zones(button) + + assert _gold_pixels(button, left) == 0 + assert _gold_pixels(button, right) == 0 + + window.update_state("RECORDING", WorkflowType.TRANSCRIPTION, None) + for _ in range(5): + button._tick() + assert _gold_pixels(button, left) > 40 + assert _gold_pixels(button, right) > 40 + assert button._anim.isActive() + assert paint_errors == [] + + +@gui_only +def test_recording_waves_move_over_time(qapp): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + button = window._btn_toggle + window.update_state("RECORDING", WorkflowType.TRANSCRIPTION, None) + + first = button.grab().toImage() + for _ in range(4): + button._tick() + assert button.grab().toImage() != first + + +@gui_only +def test_processing_replaces_waves_with_gold_ring_and_idle_clears_both(qapp, paint_errors): + from app.main_window import MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + button = window._btn_toggle + left, right = _side_zones(button) + ring = _ring_zone(button) + idle_ring_pixels = _gold_pixels(button, ring) + + window.update_state("RECORDING", WorkflowType.TRANSCRIPTION, None) + button._tick() + window.update_state("TRANSCRIBING", WorkflowType.TRANSCRIPTION, None) + button._tick() + assert button._anim.isActive() + assert _gold_pixels(button, left) == 0 + assert _gold_pixels(button, right) == 0 + assert _gold_pixels(button, ring) > idle_ring_pixels + 20 + + window.update_state("IDLE", WorkflowType.TRANSCRIPTION, None) + assert not button._anim.isActive() + assert _gold_pixels(button, left) == 0 + assert _gold_pixels(button, right) == 0 + assert _gold_pixels(button, ring) == idle_ring_pixels + assert paint_errors == [] + + +@gui_only +def test_only_the_round_microphone_is_clickable(qapp): + from PyQt6.QtCore import QPoint + from app.main_window import MainWindow + + window = MainWindow(_Controller()) + button = window._btn_toggle + center = QPoint(button.width() // 2, button.height() // 2) + + assert button.hitButton(center) + assert not button.hitButton(QPoint(4, center.y())) + assert not button.hitButton(QPoint(button.width() - 4, center.y())) + + +def _shown_window(qapp): + from app import theme + from app.main_window import MainWindow + + theme.apply_theme(qapp) + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + return window + + +@gui_only +def test_root_layout_holds_exactly_one_surface(qapp): + from PyQt6.QtWidgets import QFrame + + window = _shown_window(qapp) + root = window.layout() + + widgets = [root.itemAt(index).widget() for index in range(root.count())] + assert widgets == [window._main_panel] + frames = [child for child in window.findChildren(QFrame) + if child.parentWidget() is window and not child.isWindow()] + assert frames == [window._main_panel] + # Transient options live in their own popup window, not in the layout. + assert window._options_popup.isWindow() + + +@gui_only +def test_header_microphone_status_and_tools_stack_inside_the_panel(qapp): + window = _shown_window(qapp) + inner = window._main_panel.contentsRect() + title = window._title_label.geometry() + close = window._btn_close.geometry() + record = window._btn_toggle.geometry() + status = window._status_label.geometry() + options = window._btn_options.geometry() + + for rect in (title, close, window._btn_minimize.geometry(), record, status, + window._btn_edit_text.geometry(), options): + assert inner.contains(rect) + assert title.bottom() <= record.top() + 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() + assert window._btn_minimize.geometry().right() < close.left() + assert abs(record.center().x() - inner.center().x()) <= 1 + + +@gui_only +def test_toolbar_buttons_keep_full_size_after_stylesheet_polish(qapp): + from app import theme + + window = _shown_window(qapp) + buttons = (window._btn_edit_text, window._btn_history, window._btn_tts, + window._btn_settings, window._btn_options) + + for button in buttons: + assert button.height() == theme.TOOL_BUTTON_SIZE + assert button.width() >= theme.TOOL_BUTTON_SIZE + for control in (window._btn_minimize, window._btn_close): + assert control.size().width() == theme.WINDOW_BUTTON_SIZE + assert control.size().height() == theme.WINDOW_BUTTON_SIZE + # Outer buttons sit symmetrically inside the panel. + left_gap = buttons[0].geometry().left() + right_gap = window._main_panel.width() - buttons[-1].geometry().right() - 1 + assert abs(left_gap - right_gap) <= 1 + + +@gui_only +def test_window_size_follows_compact_content_with_small_shadow_margins(qapp): + window = _shown_window(qapp) + panel = window._main_panel.geometry() + + assert window.size() == window.layout().sizeHint() + assert 200 <= panel.width() <= 250 + assert 170 <= panel.height() <= 220 + 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() + + +@gui_only +def test_shadows_fade_out_before_the_window_edge(qapp): + window = _shown_window(qapp) + image = window.grab().toImage() + width, height = image.width(), image.height() + + edge_alpha = [image.pixelColor(x, y).alpha() + for x in range(width) for y in (0, height - 1)] + edge_alpha += [image.pixelColor(x, y).alpha() + for y in range(height) for x in (0, width - 1)] + assert max(edge_alpha) <= 3 + + +@gui_only +def test_window_size_stays_stable_across_state_cycles(qapp, paint_errors): + from app.workflows import WorkflowType + + window = _shown_window(qapp) + size = window.size() + panel = window._main_panel.geometry() + for _cycle in range(3): + for state in ("RECORDING", "TRANSCRIBING", "LLM_REWRITING", "IDLE"): + window.update_state(state, WorkflowType.TRANSCRIPTION, None) + window.set_history_count(999) + qapp.processEvents() + assert window.size() == size + assert window._main_panel.geometry() == panel + window.update_state("IDLE", WorkflowType.TRANSCRIPTION, "boom") + qapp.processEvents() + assert window.size() == size + assert paint_errors == [] + + +@gui_only +def test_history_count_fits_inside_its_toolbar_button(qapp): + window = _shown_window(qapp) + window.set_history_count(999) + qapp.processEvents() + + button = window._btn_history + assert button.width() >= button.sizeHint().width() + + +@gui_only +def test_close_hides_window_to_tray_and_it_can_be_reopened(qapp): + window = _shown_window(qapp) + window._btn_options.click() + qapp.processEvents() + + assert window.close() is False + assert not window.isVisible() + assert not window._options_popup.isVisible() + + window.show() + qapp.processEvents() + assert window.isVisible() + + +@gui_only +def test_record_button_keeps_full_custom_paint_area_after_qss_polish(qapp): + from app.main_window import MainWindow + + from app import theme + + window = MainWindow(_Controller()) + window.show() + qapp.processEvents() + + from app.main_window import (_RING_DIAMETER, _RING_PEN, _WAVE_BARS, + _WAVE_CLEARANCE, _WAVE_PITCH, _WAVE_WIDTH) + + assert window._btn_toggle.size().width() == theme.RECORD_BUTTON_WIDTH + assert window._btn_toggle.size().height() == theme.RECORD_BUTTON_HEIGHT + # The waves need room beside the round microphone, the ring above and below. + wave_reach = _WAVE_CLEARANCE + (_WAVE_BARS - 1) * _WAVE_PITCH + _WAVE_WIDTH / 2 + assert theme.RECORD_BUTTON_WIDTH >= 2 * wave_reach + assert theme.RECORD_BUTTON_HEIGHT >= _RING_DIAMETER + _RING_PEN + + +def test_brand_bolt_is_read_from_the_official_logo_asset(): + from app import theme + + capsule, points = theme._brand_bolt_geometry() + + assert theme.BRAND_MARK_SVG.is_file() + assert capsule == (16.0, 6.0, 16.0, 26.0) + assert points == ((26, 11), (20, 20), (24, 20), (22, 27), (28, 17.5), (24, 17.5)) + # The fallback describes exactly the same bolt. + assert tuple(theme._svg_path_points(theme._BRAND_BOLT_FALLBACK)) == points + + +def test_brand_bolt_keeps_logo_proportions_inside_the_capsule(): + from PyQt6.QtCore import QRectF + from app import theme + + capsule = QRectF(-11, -31, 22, 49) + bolt = theme.brand_bolt_path(capsule).boundingRect() + + # Logo: bolt 8 x 16 inside a 16 px wide capsule, centered on it. + assert bolt.width() == pytest.approx(capsule.width() / 2) + assert bolt.height() == pytest.approx(capsule.width()) + assert bolt.center().x() == pytest.approx(capsule.center().x()) + assert bolt.center().y() == pytest.approx(capsule.center().y()) + assert capsule.contains(bolt) + + +@gui_only +@pytest.mark.parametrize("state", ["IDLE", "RECORDING", "TRANSCRIBING", "LLM_REWRITING"]) +def test_brand_bolt_stays_cut_into_the_gold_microphone_in_every_state( + qapp, paint_errors, state, +): + from PyQt6.QtCore import QPointF + from app import theme + from app.main_window import _ART_SCALE, _MIC_CAPSULE, MainWindow + from app.workflows import WorkflowType + + window = MainWindow(_Controller()) + window.show() + window.update_state(state, WorkflowType.TRANSCRIPTION, None) + button = window._btn_toggle + button._tick() + image = button.grab().toImage() + ratio = image.width() / button.width() + + def pixel(point: QPointF): + x = button.width() / 2 + point.x() * _ART_SCALE + y = button.height() / 2 + point.y() * _ART_SCALE + return image.pixelColor(int(x * ratio), int(y * ratio)) + + bolt = theme.brand_bolt_path(_MIC_CAPSULE) + # Middle of the bolt's widest band, between its two horizontal edges. + inner = (QPointF(bolt.elementAt(2).x, bolt.elementAt(2).y) + + QPointF(bolt.elementAt(5).x, bolt.elementAt(5).y)) / 2 + assert bolt.contains(inner) + cut = pixel(inner) + assert cut.lightness() < 90, cut.name() + 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 == [] diff --git a/tests/test_make_screenshots.py b/tests/test_make_screenshots.py index 6263b83..0bcd793 100644 --- a/tests/test_make_screenshots.py +++ b/tests/test_make_screenshots.py @@ -27,7 +27,7 @@ from PIL import Image, ImageDraw, ImageFont # noqa: E402 -from app.i18n import set_language, t # noqa: E402 +from app.i18n import set_language, t, tq # noqa: E402 _MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "_make_screenshots.py" @@ -163,9 +163,9 @@ def test_tab_index_resolves_keys_independent_of_language(lang): # Arrange: reale Tab-Reihenfolge speech, workflows, general in aktueller Sprache set_language(lang) tabs = _FakeTabs([ - t("settings.tab.speech"), - t("settings.tab.workflows"), - t("settings.tab.general"), + tq("settings.tab.speech"), + tq("settings.tab.workflows"), + tq("settings.tab.general"), ]) # Act / Assert: Lookup trifft die korrekten Indizes (Workflows == 1, nicht 2) @@ -177,7 +177,7 @@ def test_tab_index_resolves_keys_independent_of_language(lang): def test_tab_index_raises_for_unknown_tab(): # Arrange set_language("en") - tabs = _FakeTabs([t("settings.tab.general")]) + tabs = _FakeTabs([tq("settings.tab.general")]) # Act / Assert with pytest.raises(ValueError): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 61589b4..ce18428 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -150,7 +150,7 @@ def _fake_save_self(config_dir, preset_key, ui_language="de"): combo_tone=_Combo(text="neutral", data="neutral"), combo_writing_preset=_Combo(text="E-Mail – formell", data=preset_key), edit_compose_custom_preset=_Edit(""), - combo_emoji=_Combo("mittel"), + combo_emoji=_Combo("mittel", "mittel"), edit_dampf_prompt=_Edit(""), _collect_custom_terms=lambda: [], check_autopaste=_Check(True), @@ -299,8 +299,8 @@ def test_refresh_i18n_texts_updates_existing_shell(): set_language("en") BlitztextApp._refresh_i18n_texts(fake) - fake.action_settings.setText.assert_called_once_with("⚙ Settings...") - fake.action_quit.setText.assert_called_once_with("✕ Quit") + fake.action_settings.setText.assert_called_once_with("Settings…") + fake.action_quit.setText.assert_called_once_with("Quit") fake._main_window.setWindowTitle.assert_called_once_with("Blitztext") fake.update_tray_state.assert_called_once() finally: @@ -446,9 +446,16 @@ def test_save_settings_persists_compose_custom_preset(tmp_path): assert reloaded.compose_custom_preset_text == "Freier Compose-Prompt." -def test_refresh_api_key_status_shows_env_name_not_secret(monkeypatch): +@pytest.mark.parametrize( + ("language", "expected_status"), + [("de", "gesetzt"), ("en", "set")], +) +def test_refresh_api_key_status_shows_env_name_not_secret( + monkeypatch, language, expected_status +): secret_value = "dummy-openai-key" monkeypatch.setenv("CUSTOM_OPENAI_KEY", secret_value) + set_language(language) class FakeLineEdit: def text(self): @@ -467,11 +474,16 @@ def setText(self, value): lbl_api_key_status=FakeLabel(), ) - SettingsDialog._refresh_api_key_status(fake) + try: + with patch("app.blitztext_linux.theme.set_status_role") as set_status_role: + SettingsDialog._refresh_api_key_status(fake) + finally: + set_language(DEFAULT_LANGUAGE) assert "CUSTOM_OPENAI_KEY" in fake.lbl_api_key_status.text - assert "gesetzt" in fake.lbl_api_key_status.text + assert expected_status in fake.lbl_api_key_status.text assert secret_value not in fake.lbl_api_key_status.text + set_status_role.assert_called_once_with(fake.lbl_api_key_status, "success") # --- Goal 04: Settings nutzt dieselbe Auswahl und bestehende Prompt-Ablage -- @@ -511,3 +523,23 @@ def test_goal_settings_save_preserves_custom_selection_and_prompt(tmp_path): assert reloaded.writing_preset == "custom" assert reloaded.compose_custom_preset_text == " Nutzer-Prompt exakt. \n" + + +def test_settings_dialog_attaches_help_to_layout_fields_and_editors(tmp_path): + from PyQt6.QtWidgets import QApplication + from app.i18n import t + + qapp = QApplication.instance() or QApplication([]) + config = BlitztextConfig(config_dir=tmp_path / ".config" / "blitztext-linux") + dialog = SettingsDialog(config) + try: + # Base URL is in a QVBoxLayout; ensure the QLineEdit receives help/accessible description + assert dialog.edit_base_url.toolTip() == t("settings.base_url.help") + assert dialog.edit_base_url.accessibleDescription() == t("settings.base_url.help") + + # Counter-check: a field mounted directly as a widget keeps working too. + assert dialog.edit_llm_model.toolTip() == t("settings.llm_model.help") + assert dialog.edit_llm_model.accessibleDescription() == t("settings.llm_model.help") + finally: + dialog.close() + qapp.processEvents() diff --git a/tests/test_smoke_launch.py b/tests/test_smoke_launch.py index c22a38b..5aaae61 100644 --- a/tests/test_smoke_launch.py +++ b/tests/test_smoke_launch.py @@ -65,13 +65,15 @@ def test_app_boots_idles_and_exits_clean(ui_language, tmp_path): win = app._main_window assert win._btn_discard.text() == t("mainwindow.button.discard") assert win._btn_dictation.text() == t("mainwindow.button.dictation") - assert win._btn_history.text() == t("mainwindow.button.history").format(count=0) - assert win._btn_edit_text.text() == t("mainwindow.button.edit_text") + assert win._btn_history.accessibleName() == t("mainwindow.button.history").format(count=0) + # The compact gold trigger is icon-only; its accessible name carries + # the translated action text for screen readers and UI automation. + assert win._btn_edit_text.accessibleName() == t("mainwindow.button.edit_text") assert win._btn_edit_text.toolTip() == t("mainwindow.tooltip.edit_text") assert win._btn_tts.toolTip() == t("mainwindow.tooltip.tts") assert win._btn_settings.toolTip() == t("mainwindow.tooltip.settings") assert win._status_label.text() == t("mainwindow.status.ready") - assert app.action_compose.text() == f"✍ {t('tray.compose')}" + assert app.action_compose.text() == t("tray.compose") assert app.action_dictation.text() == t("tray.dictation_mode") assert app.action_history.text() == t("tray.history") assert app.action_tts.text() == t("tray.tts") @@ -112,16 +114,21 @@ def test_secondary_windows_instantiate(tmp_path): app.show_history_panel() qapp.processEvents() assert app._history_panel is not None + assert app._history_panel.objectName() == "appWindow" + assert app._history_panel._empty_label.isVisible() is True # TTS-Fenster: lazy-init via show_tts_window(). app.show_tts_window() qapp.processEvents() assert app._tts_window is not None + assert app._tts_window.objectName() == "appDialog" + assert app._tts_window._btn_speak.objectName() == "primaryAction" + assert app._tts_window._status_label.property("status") in {"success", "error"} # Settings-Dialog: direkt instanziieren, da show_settings_dialog() exec() aufruft. dlg = SettingsDialog(config) qapp.processEvents() - assert dlg is not None + assert dlg.objectName() == "appDialog" dlg.close() finally: @@ -164,13 +171,13 @@ def test_compose_dialog_lifecycle(tmp_path): set_language("de") w1.retranslate_ui() qapp.processEvents() - assert "verfassen" in w1.windowTitle().lower() + assert "bearbeiten" in w1.windowTitle().lower() # Nach Sprachenwechsel auf EN werden übersetzte Texte gezeigt. set_language("en") w1.retranslate_ui() qapp.processEvents() - assert "Compose" in w1.windowTitle() + assert "Edit text" == w1.windowTitle() finally: if app is not None: diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 7681ec1..2260658 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -672,6 +672,53 @@ def test_stop_wires_routed_worker_result_into_compose(self, gui_app): @gui_only class TestMainWindowControl: + def test_editor_is_created_only_after_user_opens_it(self, gui_app): + win = gui_app._ensure_main_window() + + assert gui_app._compose_window is None + + win._btn_edit_text.click() + + assert gui_app._compose_window is not None + assert gui_app._compose_window.isVisible() is True + + def test_panel_close_button_hides_window_and_keeps_tray_running(self, gui_app): + from PyQt6.QtCore import Qt + + gui_app.app.setQuitOnLastWindowClosed(False) + win = gui_app._ensure_main_window() + win.show() + win._btn_options.click() + gui_app.app.processEvents() + + assert win.windowFlags() & Qt.WindowType.FramelessWindowHint + assert gui_app.tray_icon.isVisible() is True + + win._btn_close.click() + gui_app.app.processEvents() + + assert win.isVisible() is False + assert win._options_popup.isVisible() is False + assert gui_app.tray_icon.isVisible() is True + assert gui_app.app.quitOnLastWindowClosed() is False + + gui_app.show_main_window() + gui_app.app.processEvents() + assert win.isVisible() is True + + def test_show_main_window_restores_minimized_panel(self, gui_app): + win = gui_app._ensure_main_window() + win.show() + win.showMinimized() + gui_app.app.processEvents() + assert win.isMinimized() is True + + gui_app.show_main_window() + gui_app.app.processEvents() + + assert win.isMinimized() is False + assert win.isVisible() is True + def test_gui_toggle_starts_and_stops(self, gui_app): """Maus-Klick startet im IDLE eine Aufnahme und stoppt sie im RECORDING, unabhaengig vom Hotkey-Modus.""" @@ -752,22 +799,23 @@ def test_text_edit_action_is_keyboard_reachable(self, gui_app): assert gui_app._compose_window is not None assert gui_app._compose_window.isVisible() is True - def test_text_edit_action_fits_compact_main_window(self, gui_app): + def test_text_edit_trigger_and_toolbar_fit_neumorphic_widget(self, gui_app): + from app.i18n import t + win = gui_app._ensure_main_window() win.show() gui_app.app.processEvents() + panel = win._main_panel button_rect = win._btn_edit_text.geometry() - history_rect = win._btn_history.geometry() - text_width = win._btn_edit_text.fontMetrics().horizontalAdvance( - win._btn_edit_text.text() - ) - - assert win.width() == 256 - assert button_rect.left() >= win.contentsRect().left() - assert button_rect.right() <= win.contentsRect().right() - assert button_rect.bottom() < history_rect.top() - assert text_width < button_rect.width() + # The window follows its content instead of a fixed oversized client area. + assert win.size() == win.layout().sizeHint() + assert not hasattr(win, "_toolbar") + assert win._btn_edit_text.parentWidget() is panel + assert panel.contentsRect().contains(button_rect) + assert win._btn_toggle.geometry().bottom() < button_rect.top() + assert panel.geometry().right() <= win.contentsRect().right() + assert win._btn_edit_text.accessibleName() == t("mainwindow.button.edit_text") def test_history_reset_timers_are_owned_by_their_widgets(self, gui_app): from PyQt6.QtCore import QCoreApplication, QEvent diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py index a88af31..a66f013 100644 --- a/tests/test_tray_preset_menu.py +++ b/tests/test_tray_preset_menu.py @@ -55,6 +55,21 @@ def _other_key(current: str) -> str: @gui_only class TestPresetMenu: + def test_saved_compose_instruction_rebuilds_shared_service(self, tray_app): + main = tray_app._ensure_main_window() + window = tray_app._ensure_compose_window() + window.edit_custom_prompt() + window.txtOwnPrompt.setPlainText("Keep every fact. Use short sentences.") + window.btnSavePrompt.click() + assert tray_app.llm_service.writing_custom_prompt == "Keep every fact. Use short sentences." + assert window._llm_service is tray_app.llm_service + assert tray_app.config.writing_preset == "custom" + assert tray_app.llm_service.writing_preset == "custom" + assert tray_app.preset_actions["custom"].isChecked() + assert main._preset_combo.currentData() == "custom" + assert BlitztextConfig(config_dir=tray_app.config.config_dir).compose_custom_preset_text == "Keep every fact. Use short sentences." + window.close() + 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) @@ -225,10 +240,11 @@ def exec(self): 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.""" + """Preset-Combo im Options-Popup ist nur bei Blitztext+ sichtbar.""" 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()): @@ -239,6 +255,7 @@ def test_preset_combo_visible_only_for_text_improver(self, tray_app): f"Workflow {wf}: Combo sichtbar={window._preset_combo.isVisible()}, " f"erwartet={expected}" ) + window._options_popup.hide() # --- Goal 04: Tray und Hauptfenster zeigen dieselben Kernaktionen -----------