diff --git a/README.de.md b/README.de.md
index 57781d0..043f17e 100644
--- a/README.de.md
+++ b/README.de.md
@@ -18,6 +18,12 @@
đź”— Webseite: timintech.github.io/blitztextweb
+
+

+
+
Alt gedrückt halten (Standard-Hotkey), sprechen, loslassen — das Transkript landet direkt in der aktiven Anwendung.
+
+
> [!IMPORTANT]
> **Eigenständiger Linux-Port:** Dieses Repository enthält ausschließlich den Linux-Port von Blitztext – eine eigenständige Python 3/PyQt6-Implementierung optimiert für **Kubuntu/Ubuntu unter KDE Plasma mit Wayland**. Für die originale macOS-Version besuche bitte das [offizielle Haupt-Repository](https://github.com/cmagnussen/blitztext-app).
@@ -47,7 +53,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast
| Workflow | Hotkey | LLM? | Beschreibung |
| :--- | :--- | :---: | :--- |
-| **Blitztext** | Meta + H | ❌ | Standard: Nimmt auf, transkribiert und fügt den Text ein. |
+| **Blitztext** | Alt (halten) | ❌ | Standard: Nimmt auf, solange die Taste gehalten wird, transkribiert und fügt den Text ein. 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. |
diff --git a/README.md b/README.md
index 703e7fe..fdaad4f 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,12 @@
đź”— Website: timintech.github.io/blitztextweb
+
+

+
+
Hold Alt (default hotkey), speak, release — the transcript lands directly in the active application.
+
+
> [!IMPORTANT]
> **Standalone Linux port:** This repository contains exclusively the Linux port of Blitztext – a standalone Python 3/PyQt6 implementation optimized for **Kubuntu/Ubuntu running KDE Plasma with Wayland**. For the original macOS version, please visit the [official main repository](https://github.com/cmagnussen/blitztext-app).
@@ -47,7 +53,7 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have
| Workflow | Hotkey | LLM? | Description |
| :--- | :--- | :---: | :--- |
-| **Blitztext** | Meta + H | ❌ | Default: records, transcribes, and pastes the text. |
+| **Blitztext** | Alt (hold) | ❌ | Default: records while the key is held, transcribes, and pastes the text. 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. |
diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py
index 4f24c43..0d790ce 100644
--- a/app/blitztext_linux.py
+++ b/app/blitztext_linux.py
@@ -33,7 +33,7 @@
from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS
from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index
-from app.hotkey_service import HotkeyWorker
+from app.hotkey_service import HotkeyWorker, hotkey_display_name
from app.audio_recorder import AudioRecorder, AudioRecorderError
from app.transcribe import transcribe, TranscribeError
from app.paste_service import PasteService, PasteServiceError
@@ -705,7 +705,9 @@ def setup_tray(self) -> None:
self.menu.addSeparator()
# Actions fĂĽr die fĂĽnf Workflows
- self.action_transcription = QAction(f"{t('workflow.transcription.name')}\tMeta+H", self)
+ self.action_transcription = QAction(
+ f"{t('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)
@@ -816,7 +818,9 @@ def _refresh_i18n_texts(self) -> None:
if hasattr(self, "action_compose"):
self.action_compose.setText(f"✍ {t('tray.compose')}")
if hasattr(self, "action_transcription"):
- self.action_transcription.setText(f"{t('workflow.transcription.name')}\tMeta+H")
+ self.action_transcription.setText(
+ f"{t('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")
if hasattr(self, "action_improver"):
diff --git a/app/hotkey_service.py b/app/hotkey_service.py
index a83ec4b..8ac47f5 100644
--- a/app/hotkey_service.py
+++ b/app/hotkey_service.py
@@ -21,6 +21,29 @@
DEVICE_REFRESH_SECONDS = 5.0
logger = logging.getLogger("blitztext.hotkey")
+# Anzeigenamen fĂĽr die konfigurierbare Aufnahmetaste (config.transcription_hotkey).
+# Deckt alle VALID_HOTKEY_KEYS aus app/config.py ab; unbekannte Namen fallen
+# auf den evdev-Namen ohne "KEY_"-Präfix zurück.
+_HOTKEY_DISPLAY_NAMES = {
+ "KEY_LEFTALT": "Alt",
+ "KEY_RIGHTALT": "AltGr",
+ "KEY_LEFTCTRL": "Ctrl",
+ "KEY_RIGHTCTRL": "Ctrl",
+ "KEY_F13": "F13",
+ "KEY_F14": "F14",
+ "KEY_F15": "F15",
+ "KEY_F16": "F16",
+ "KEY_SCROLLLOCK": "Scroll Lock",
+ "KEY_PAUSE": "Pause",
+ "KEY_INSERT": "Insert",
+ "KEY_CAPSLOCK": "Caps Lock",
+}
+
+
+def hotkey_display_name(key_name: str) -> str:
+ """Menschenlesbarer Name der Aufnahmetaste fĂĽr MenĂĽ- und UI-Labels."""
+ return _HOTKEY_DISPLAY_NAMES.get(key_name, key_name.removeprefix("KEY_").capitalize())
+
# Hotkey-Definitionen: WorkflowType -> (modifier_set, trigger_key_name)
_HOTKEY_MAP = [
# (workflow, trigger_key, required_modifiers)
diff --git a/app/workflows.py b/app/workflows.py
index e192e01..8d79587 100644
--- a/app/workflows.py
+++ b/app/workflows.py
@@ -12,7 +12,10 @@ class WorkflowType(str, Enum):
WORKFLOW_META: Dict[WorkflowType, Dict[str, Any]] = {
WorkflowType.TRANSCRIPTION: {
"display_name": "🎙 Blitztext",
- "hotkey": "Meta+H",
+ # Standard-Aufnahmetaste (config.transcription_hotkey: KEY_LEFTALT);
+ # der tatsächliche Wert ist konfigurierbar, UI-Labels leiten ihn zur
+ # Laufzeit ĂĽber hotkey_service.hotkey_display_name() ab.
+ "hotkey": "Alt",
"needs_llm": False,
"description": "Standard Sprache zu Text (Online oder Lokal)",
},
diff --git a/docs/screenshots/linux/Banner-de.png b/docs/screenshots/linux/Banner-de.png
index 8506a2e..d1097be 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 72097fb..6fb249e 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
index 51674f9..6fb249e 100644
Binary files a/docs/screenshots/linux/Banner.png and b/docs/screenshots/linux/Banner.png differ
diff --git a/docs/screenshots/linux/compose-de.png b/docs/screenshots/linux/compose-de.png
index b8b08ee..d913727 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 d7ccaa5..077e839 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/demo-de.gif b/docs/screenshots/linux/demo-de.gif
new file mode 100644
index 0000000..be2477d
Binary files /dev/null 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
new file mode 100644
index 0000000..6edfd02
Binary files /dev/null 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 673b661..149fce4 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 c879f45..3c6e8ce 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-window-de.png b/docs/screenshots/linux/main-window-de.png
index acb8504..ec28428 100644
Binary files a/docs/screenshots/linux/main-window-de.png and b/docs/screenshots/linux/main-window-de.png differ
diff --git a/docs/screenshots/linux/main-window-en.png b/docs/screenshots/linux/main-window-en.png
index 403ae42..539bf3d 100644
Binary files a/docs/screenshots/linux/main-window-en.png and b/docs/screenshots/linux/main-window-en.png differ
diff --git a/docs/screenshots/linux/main-window-recording-de.png b/docs/screenshots/linux/main-window-recording-de.png
index 6d2e96f..3b728b8 100644
Binary files a/docs/screenshots/linux/main-window-recording-de.png and b/docs/screenshots/linux/main-window-recording-de.png differ
diff --git a/docs/screenshots/linux/main-window-recording-en.png b/docs/screenshots/linux/main-window-recording-en.png
index 7d58a58..6a7a92e 100644
Binary files a/docs/screenshots/linux/main-window-recording-en.png and b/docs/screenshots/linux/main-window-recording-en.png differ
diff --git a/docs/screenshots/linux/settings-ai-workflows-de.png b/docs/screenshots/linux/settings-ai-workflows-de.png
index e8c0d1e..897b2f4 100644
Binary files a/docs/screenshots/linux/settings-ai-workflows-de.png and b/docs/screenshots/linux/settings-ai-workflows-de.png differ
diff --git a/docs/screenshots/linux/settings-ai-workflows-en.png b/docs/screenshots/linux/settings-ai-workflows-en.png
index cca4a19..4b880ff 100644
Binary files a/docs/screenshots/linux/settings-ai-workflows-en.png and b/docs/screenshots/linux/settings-ai-workflows-en.png differ
diff --git a/docs/screenshots/linux/settings-general-de.png b/docs/screenshots/linux/settings-general-de.png
index ae362b2..aa2cc07 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 bf04760..94e65b4 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 4d2b468..d656408 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 feef9a2..6600c21 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/tray-menu-de.png b/docs/screenshots/linux/tray-menu-de.png
index e98ed19..e49a28a 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 0a26a7f..31f0b58 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 2c04cfe..041f400 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 dbd15fb..9f4052f 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
new file mode 100644
index 0000000..a03bd09
--- /dev/null
+++ b/scripts/_make_demo_gif.py
@@ -0,0 +1,383 @@
+"""Generate the animated README demo GIF for Blitztext Linux.
+
+The GIF shows the real product: the actual PyQt6 main window is rendered
+offscreen and grabbed in its true states (ready → recording → transcribing →
+ready), exactly like ``scripts/_make_screenshots.py`` does for the static
+README screenshots. Next to it, a plain target text field receives the
+transcript. The real tray icons from ``docs/screenshots/linux`` mirror the
+workflow states. No invented controls are drawn.
+
+The displayed hotkey matches the actual default configuration: the standard
+Blitztext workflow (``WorkflowType.TRANSCRIPTION``) is triggered by holding
+the left Alt key (``transcription_hotkey: KEY_LEFTALT``, ``hotkey_mode:
+hold`` — see ``app/config.py`` and ``_HOTKEY_MAP`` in
+``app/hotkey_service.py``).
+
+Usage:
+ PYTHONPATH=. QT_QPA_PLATFORM=offscreen .venv/bin/python scripts/_make_demo_gif.py [out_dir]
+
+Default output directory:
+ docs/screenshots/linux (writes demo-en.gif and demo-de.gif)
+"""
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+from functools import lru_cache
+from pathlib import Path
+from types import SimpleNamespace
+
+from PIL import Image, ImageDraw, ImageFont
+
+SCREENSHOT_DIR = Path("docs/screenshots/linux")
+CANVAS_SIZE = (960, 540)
+BACKGROUND_TOP = (7, 17, 31)
+BACKGROUND_BOTTOM = (2, 6, 13)
+ACCENT = "#2db2ff"
+FIELD_BG = (18, 28, 44, 255)
+FIELD_BORDER = (50, 127, 194, 160)
+PANEL_BG = (9, 15, 26, 255)
+TEXT_PRIMARY = "#eef6ff"
+TEXT_SECONDARY = "#8ea6c1"
+IDLE_GREEN = "#4ade80"
+
+APP_WINDOW_SCALE = 1.5
+APP_WINDOW_POS = (64, 84)
+FIELD_BOX = (500, 108, 900, 420)
+TYPE_CHUNK = 3
+TYPE_FRAME_MS = 45
+
+LANG_COPY = {
+ "en": {
+ "gif": "demo-en.gif",
+ "field_caption": "Active application",
+ "hint": "Hold Alt and speak",
+ "hotkey": "Alt",
+ "hotkey_sub": "hold",
+ "spoken": "“Hi Anna, I’ll send you the updated rollout plan right after our meeting.”",
+ "typed": "Hi Anna, I’ll send you the updated rollout plan right after our meeting.",
+ "badge": "✓ Pasted — transcribed 100% locally",
+ },
+ "de": {
+ "gif": "demo-de.gif",
+ "field_caption": "Aktive Anwendung",
+ "hint": "Alt gedrĂĽckt halten und sprechen",
+ "hotkey": "Alt",
+ "hotkey_sub": "gedrĂĽckt halten",
+ "spoken": "„Hallo Anna, ich schicke dir den aktualisierten Rollout-Plan direkt nach unserem Meeting.“",
+ "typed": "Hallo Anna, ich schicke dir den aktualisierten Rollout-Plan direkt nach unserem Meeting.",
+ "badge": "✓ Eingefügt — 100 % lokal transkribiert",
+ },
+}
+
+
+def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
+ candidates = [
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
+ ]
+ for path in candidates:
+ if Path(path).is_file():
+ return ImageFont.truetype(path, size=size)
+ return ImageFont.load_default()
+
+
+FONT_BODY = _font(19)
+FONT_CAPTION = _font(15, bold=True)
+FONT_HINT = _font(22, bold=True)
+FONT_CHIP = _font(30, bold=True)
+FONT_CHIP_SUB = _font(15)
+FONT_SUBTITLE = _font(18)
+FONT_BADGE = _font(17, bold=True)
+FONT_CLOCK = _font(15)
+
+
+def _typewriter_chunks(text: str, chunk: int = TYPE_CHUNK) -> list[str]:
+ """Progressive prefixes of ``text`` for the typing animation.
+
+ The last element is always the full text; an empty input yields no frames.
+ """
+ if not text:
+ return []
+ if chunk < 1:
+ raise ValueError(f"chunk must be >= 1, got {chunk}")
+ prefixes = [text[:end] for end in range(chunk, len(text), chunk)]
+ prefixes.append(text)
+ return prefixes
+
+
+def _wrap_lines(draw: ImageDraw.ImageDraw, text: str, font, width: int) -> list[str]:
+ """Word-wrap ``text`` so every rendered line fits into ``width`` pixels."""
+ lines: list[str] = []
+ current = ""
+ for word in text.split():
+ trial = f"{current} {word}".strip()
+ if draw.textlength(trial, font=font) <= width or not current:
+ current = trial
+ else:
+ lines.append(current)
+ current = word
+ if current:
+ lines.append(current)
+ return lines
+
+
+def _load_tray_icon(name: str, size: int = 30) -> Image.Image:
+ path = SCREENSHOT_DIR / name
+ if not path.is_file():
+ raise FileNotFoundError(f"Tray icon missing for demo GIF: {path}")
+ icon = Image.open(path).convert("RGBA")
+ icon.thumbnail((size, size), Image.Resampling.LANCZOS)
+ return icon
+
+
+def _scale_app_window(image: Image.Image, scale: float = APP_WINDOW_SCALE) -> Image.Image:
+ size = (round(image.width * scale), round(image.height * scale))
+ return image.resize(size, Image.Resampling.LANCZOS)
+
+
+@lru_cache(maxsize=1)
+def _background() -> Image.Image:
+ bg = Image.new("RGBA", CANVAS_SIZE)
+ draw = ImageDraw.Draw(bg)
+ for y in range(CANVAS_SIZE[1]):
+ ratio = y / max(1, CANVAS_SIZE[1] - 1)
+ color = tuple(
+ int(BACKGROUND_TOP[i] * (1 - ratio) + BACKGROUND_BOTTOM[i] * ratio)
+ for i in range(3)
+ )
+ draw.line((0, y, CANVAS_SIZE[0], y), fill=color + (255,))
+ return bg
+
+
+def _draw_target_field(draw: ImageDraw.ImageDraw, copy: dict, typed: str, cursor_on: bool) -> None:
+ x0, y0, x1, y1 = FIELD_BOX
+ draw.text((x0, y0 - 26), copy["field_caption"], font=FONT_CAPTION, fill=TEXT_SECONDARY)
+ draw.rounded_rectangle(FIELD_BOX, radius=10, fill=FIELD_BG, outline=FIELD_BORDER, width=2)
+ lines = _wrap_lines(draw, typed, FONT_BODY, x1 - x0 - 48) if typed else []
+ x, y = x0 + 24, y0 + 22
+ line_height = FONT_BODY.size + 9
+ for line in lines:
+ draw.text((x, y), line, font=FONT_BODY, fill=TEXT_PRIMARY)
+ y += line_height
+ if cursor_on:
+ cursor_x = x + (draw.textlength(lines[-1], font=FONT_BODY) + 4 if lines else 0)
+ cursor_y = y - line_height if lines else y
+ draw.line((cursor_x, cursor_y + 2, cursor_x, cursor_y + FONT_BODY.size + 4), fill=ACCENT, width=2)
+
+
+def _draw_panel(canvas: Image.Image, draw: ImageDraw.ImageDraw, tray_icon: Image.Image) -> None:
+ draw.rectangle((0, 496, CANVAS_SIZE[0], CANVAS_SIZE[1]), fill=PANEL_BG)
+ draw.line((0, 496, CANVAS_SIZE[0], 496), fill=(50, 127, 194, 120), width=1)
+ icon_x = CANVAS_SIZE[0] - 108
+ icon_y = 496 + (44 - tray_icon.height) // 2
+ canvas.alpha_composite(tray_icon, (icon_x, icon_y))
+ draw.text((CANVAS_SIZE[0] - 62, 510), "14:32", font=FONT_CLOCK, fill=TEXT_SECONDARY)
+
+
+def _draw_hint(draw: ImageDraw.ImageDraw, text: str) -> None:
+ width = draw.textlength(text, font=FONT_HINT)
+ x0, y0, x1, _ = FIELD_BOX
+ cx = (x0 + x1) / 2
+ x = cx - width / 2
+ draw.rounded_rectangle((x - 22, y0 + 108, x + width + 22, y0 + 162), radius=14, fill=(10, 22, 39, 235), outline=FIELD_BORDER, width=2)
+ draw.text((x, y0 + 122), text, font=FONT_HINT, fill=TEXT_PRIMARY)
+
+
+def _draw_hotkey_chip(draw: ImageDraw.ImageDraw, copy: dict) -> None:
+ x0, y0, x1, _ = FIELD_BOX
+ cx = (x0 + x1) / 2
+ key_w = draw.textlength(copy["hotkey"], font=FONT_CHIP)
+ sub_w = draw.textlength(copy["hotkey_sub"], font=FONT_CHIP_SUB)
+ box_w = max(key_w, sub_w) + 64
+ draw.rounded_rectangle((cx - box_w / 2, y0 + 96, cx + box_w / 2, y0 + 186), radius=16, fill=(13, 60, 105, 245), outline=ACCENT, width=3)
+ draw.text((cx - key_w / 2, y0 + 112), copy["hotkey"], font=FONT_CHIP, fill=TEXT_PRIMARY)
+ draw.text((cx - sub_w / 2, y0 + 154), copy["hotkey_sub"], font=FONT_CHIP_SUB, fill=TEXT_SECONDARY)
+
+
+def _draw_subtitle(draw: ImageDraw.ImageDraw, text: str) -> None:
+ lines = _wrap_lines(draw, text, FONT_SUBTITLE, 780)
+ y = 444
+ for line in lines:
+ draw.text((CANVAS_SIZE[0] / 2, y), line, font=FONT_SUBTITLE, fill=TEXT_SECONDARY, anchor="ma")
+ y += FONT_SUBTITLE.size + 6
+
+
+def _draw_badge(draw: ImageDraw.ImageDraw, text: str) -> None:
+ width = draw.textlength(text, font=FONT_BADGE)
+ x0, y0, x1, y1 = FIELD_BOX
+ cx = (x0 + x1) / 2
+ x = cx - width / 2
+ draw.rounded_rectangle((x - 20, y1 + 14, x + width + 20, y1 + 50), radius=12, fill=(9, 48, 30, 240), outline=(74, 222, 128, 220), width=2)
+ draw.text((x, y1 + 22), text, font=FONT_BADGE, fill=IDLE_GREEN)
+
+
+def _scene(
+ copy: dict,
+ icons: dict[str, Image.Image],
+ app_states: dict[str, Image.Image],
+ *,
+ app_state: str = "idle",
+ tray: str = "idle",
+ typed: str = "",
+ cursor_on: bool = True,
+ hint: bool = False,
+ hotkey: bool = False,
+ subtitle: bool = False,
+ badge: bool = False,
+) -> Image.Image:
+ canvas = _background().copy()
+ canvas.alpha_composite(app_states[app_state], APP_WINDOW_POS)
+ draw = ImageDraw.Draw(canvas)
+ _draw_target_field(draw, copy, typed, cursor_on)
+ _draw_panel(canvas, draw, icons[tray])
+ if hint:
+ _draw_hint(draw, copy["hint"])
+ if hotkey:
+ _draw_hotkey_chip(draw, copy)
+ if subtitle:
+ _draw_subtitle(draw, copy["spoken"])
+ if badge:
+ _draw_badge(draw, copy["badge"])
+ return canvas
+
+
+def _build_frames(
+ lang: str,
+ icons: dict[str, Image.Image],
+ app_states: dict[str, Image.Image],
+) -> list[tuple[Image.Image, int]]:
+ """All GIF frames with per-frame durations (ms) for one language."""
+ copy = LANG_COPY[lang]
+ frames: list[tuple[Image.Image, int]] = []
+
+ def scene(**kwargs) -> Image.Image:
+ return _scene(copy, icons, app_states, **kwargs)
+
+ # 1. Ready: real IDLE window, blinking cursor in the target field, hint
+ for cursor_on, duration in ((True, 650), (False, 420), (True, 650)):
+ frames.append((scene(hint=True, cursor_on=cursor_on), duration))
+
+ # 2. Hotkey held (real default: hold left Alt, see module docstring)
+ frames.append((scene(hotkey=True), 950))
+
+ # 3. Recording: real RECORDING window, timer counting up, spoken sentence
+ for state in ("recording-1", "recording-2", "recording-3"):
+ frames.append((scene(app_state=state, tray="recording", subtitle=True, cursor_on=False), 700))
+
+ # 4. Transcribing: real TRANSCRIBING window
+ for duration in (500, 500, 400):
+ frames.append((scene(app_state="transcribing", tray="processing", cursor_on=False), duration))
+
+ # 5. Typing: transcript lands in the target field, window back to ready
+ for prefix in _typewriter_chunks(copy["typed"]):
+ frames.append((scene(typed=prefix), TYPE_FRAME_MS))
+
+ # 6. Final hold with badge
+ frames.append((scene(typed=copy["typed"]), 700))
+ frames.append((scene(typed=copy["typed"], badge=True), 3200))
+ return frames
+
+
+def _save_gif(frames: list[tuple[Image.Image, int]], path: Path) -> None:
+ if not frames:
+ raise ValueError("Cannot write GIF without frames")
+ images = [frame.convert("RGB").quantize(colors=256, dither=Image.Dither.NONE) for frame, _ in frames]
+ durations = [duration for _, duration in frames]
+ images[0].save(
+ str(path),
+ save_all=True,
+ append_images=images[1:],
+ duration=durations,
+ loop=0,
+ optimize=True,
+ )
+
+
+def _grab_app_states(lang: str) -> dict[str, Image.Image]:
+ """Grab the real main window offscreen in its true workflow states.
+
+ Mirrors the approach of ``scripts/_make_screenshots.py``: the actual
+ ``MainWindow`` widget is instantiated with a no-op controller, driven
+ through ``update_state`` and grabbed per state. The default workflow
+ (Blitztext / TRANSCRIPTION) stays selected, matching the Alt hotkey shown
+ in the demo.
+ """
+ from PyQt6.QtWidgets import QApplication
+
+ from app import theme
+ from app.i18n import set_language
+ from app.main_window import MainWindow
+
+ app = QApplication.instance()
+ assert app is not None, "QApplication must exist before grabbing app states"
+
+ # Wie im echten App-Start (blitztext_linux.main): Breeze-Dark-Glass-Theme.
+ theme.apply_theme(app)
+ set_language(lang)
+ controller = SimpleNamespace(
+ gui_toggle_recording=lambda *a, **k: None,
+ gui_discard=lambda *a, **k: None,
+ set_dictation_mode=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,
+ )
+ window = MainWindow(controller)
+ window.show()
+
+ def grab(state: str, timer_text: str | None = None) -> Image.Image:
+ window.update_state(state, None, None)
+ if timer_text is not None:
+ window._timer_label.setText(timer_text)
+ for _ in range(10):
+ app.processEvents()
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
+ tmp = Path(handle.name)
+ try:
+ window.grab().save(str(tmp))
+ image = Image.open(tmp).convert("RGBA")
+ image.load()
+ finally:
+ tmp.unlink(missing_ok=True)
+ return _scale_app_window(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"),
+ }
+ window.close()
+ return states
+
+
+def main() -> int:
+ from PyQt6.QtWidgets import QApplication
+
+ out_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else SCREENSHOT_DIR.resolve()
+ out_dir.mkdir(parents=True, exist_ok=True)
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ qt_app = QApplication(sys.argv) # noqa: F841 — Referenz hält die QApplication am Leben
+ icons = {
+ "idle": _load_tray_icon("tray-idle.png"),
+ "recording": _load_tray_icon("tray-recording.png"),
+ "processing": _load_tray_icon("tray-processing.png"),
+ }
+ for lang in ("en", "de"):
+ copy = LANG_COPY[lang]
+ app_states = _grab_app_states(lang)
+ frames = _build_frames(lang, icons, app_states)
+ out_path = out_dir / copy["gif"]
+ _save_gif(frames, out_path)
+ size_kb = out_path.stat().st_size / 1024
+ print(f" âś“ {out_path.name} ({len(frames)} frames, {size_kb:.0f} KiB)")
+ print("Done.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/_make_screenshots.py b/scripts/_make_screenshots.py
index b83c395..49e10bf 100644
--- a/scripts/_make_screenshots.py
+++ b/scripts/_make_screenshots.py
@@ -22,6 +22,7 @@
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication
+from app import theme
from app.blitztext_linux import BlitztextApp, Config, SettingsDialog
from app.compose_window import ComposeWindow
from app.config import BlitztextConfig
@@ -428,6 +429,9 @@ 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,
+ # 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)
diff --git a/tests/test_hotkey_modes.py b/tests/test_hotkey_modes.py
index 645b263..b5de9ac 100644
--- a/tests/test_hotkey_modes.py
+++ b/tests/test_hotkey_modes.py
@@ -484,3 +484,24 @@ def test_invalid_mode_raises(self, callbacks):
on_start=callbacks["start"],
on_stop=callbacks["stop"],
)
+
+
+class TestHotkeyDisplayName:
+ """Anzeigenamen fĂĽr die konfigurierbare Aufnahmetaste (Tray-MenĂĽ-Label)."""
+
+ def test_default_left_alt_maps_to_alt(self):
+ from app.hotkey_service import hotkey_display_name
+ assert hotkey_display_name("KEY_LEFTALT") == "Alt"
+
+ def test_all_valid_hotkeys_have_explicit_display_names(self):
+ # Jede unter Einstellungen wählbare Taste hat ein gepflegtes Label
+ # (kein automatischer "Key_..."-Fallback).
+ from app.config import VALID_HOTKEY_KEYS
+ from app.hotkey_service import _HOTKEY_DISPLAY_NAMES, hotkey_display_name
+ for key in VALID_HOTKEY_KEYS:
+ assert key in _HOTKEY_DISPLAY_NAMES, f"Missing display name for {key}"
+ assert hotkey_display_name(key)
+
+ def test_unknown_key_falls_back_to_stripped_name(self):
+ from app.hotkey_service import hotkey_display_name
+ assert hotkey_display_name("KEY_KPENTER") == "Kpenter"
diff --git a/tests/test_make_demo_gif.py b/tests/test_make_demo_gif.py
new file mode 100644
index 0000000..585d4af
--- /dev/null
+++ b/tests/test_make_demo_gif.py
@@ -0,0 +1,192 @@
+"""Unit-Tests fĂĽr die reinen Hilfsfunktionen aus ``scripts/_make_demo_gif.py``.
+
+Das Skript ist ein manuell ausgefĂĽhrter Asset-Generator (README-Demo-GIF)
+und kein Teil der ausgelieferten App. Getestet werden die reinen,
+deterministischen Funktionen (kein Qt, nur Pillow):
+
+* ``_typewriter_chunks`` – progressive Präfixe für die Tipp-Animation
+* ``_wrap_lines`` – Wortumbruch-Logik
+* ``_load_tray_icon`` – Laden + Einpassen der Tray-Icons (inkl. Fehlerpfad)
+* ``_scale_app_window`` – Skalierung der echten Fenster-Grabs
+* ``_build_frames`` / ``_save_gif`` – Frame-Aufbau und GIF-Ausgabe
+
+Die echten Fenster-Zustände (``_grab_app_states``) benötigen eine
+``QApplication`` und werden hier durch einfache Platzhalter-Bilder ersetzt.
+
+``scripts/`` ist kein Package; das Modul wird daher per ``importlib`` ĂĽber den
+Dateipfad geladen. Ein reiner Import erzeugt keine ``QApplication``, sodass die
+Tests ungated laufen können.
+"""
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+# Pillow ist eine reine Dev-/Tooling-Abhängigkeit (siehe requirements-dev.txt).
+pytest.importorskip("PIL")
+
+from PIL import Image, ImageDraw # noqa: E402
+
+_MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "_make_demo_gif.py"
+
+
+def _load_module():
+ spec = importlib.util.spec_from_file_location("_make_demo_gif_under_test", _MODULE_PATH)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+demo = _load_module()
+
+
+# --------------------------------------------------------------------------- #
+# _typewriter_chunks
+# --------------------------------------------------------------------------- #
+def test_typewriter_chunks_are_progressive_and_end_with_full_text():
+ # Arrange
+ text = "Hallo Anna, bis gleich."
+
+ # Act
+ chunks = demo._typewriter_chunks(text, chunk=3)
+
+ # Assert: jeder Frame verlängert den vorherigen, letzter Frame = Volltext
+ assert chunks[-1] == text
+ for earlier, later in zip(chunks, chunks[1:], strict=False):
+ assert later.startswith(earlier)
+ assert len(later) > len(earlier)
+
+
+def test_typewriter_chunks_empty_text_yields_no_frames():
+ # Act / Assert
+ assert demo._typewriter_chunks("") == []
+
+
+def test_typewriter_chunks_short_text_yields_single_full_frame():
+ # Arrange: Text kürzer als die Chunk-Größe
+ chunks = demo._typewriter_chunks("Hi", chunk=10)
+
+ # Assert
+ assert chunks == ["Hi"]
+
+
+def test_typewriter_chunks_rejects_invalid_chunk_size():
+ # Act / Assert
+ with pytest.raises(ValueError):
+ demo._typewriter_chunks("Hallo", chunk=0)
+
+
+# --------------------------------------------------------------------------- #
+# _wrap_lines
+# --------------------------------------------------------------------------- #
+def test_wrap_lines_wraps_long_text_and_preserves_all_words():
+ # Arrange
+ image = Image.new("RGBA", (200, 200))
+ draw = ImageDraw.Draw(image)
+ font = demo._font(15)
+ text = "the quick brown fox jumps over the lazy dog repeatedly today"
+
+ # Act
+ lines = demo._wrap_lines(draw, text, font, width=80)
+
+ # Assert: mehrzeilig, kein Wortverlust
+ assert len(lines) >= 2
+ assert " ".join(lines) == text
+
+
+def test_wrap_lines_empty_text_returns_no_lines():
+ # Arrange
+ image = Image.new("RGBA", (100, 100))
+ draw = ImageDraw.Draw(image)
+
+ # Act / Assert
+ assert demo._wrap_lines(draw, "", demo._font(15), width=80) == []
+
+
+# --------------------------------------------------------------------------- #
+# _load_tray_icon
+# --------------------------------------------------------------------------- #
+def test_load_tray_icon_fits_icon_into_requested_size():
+ # Act: echtes Repo-Asset (128x128) auf 30px einpassen
+ icon = demo._load_tray_icon("tray-idle.png", size=30)
+
+ # Assert
+ assert max(icon.size) <= 30
+ assert icon.mode == "RGBA"
+
+
+def test_load_tray_icon_raises_filenotfound_for_missing_asset():
+ # Act / Assert
+ with pytest.raises(FileNotFoundError):
+ demo._load_tray_icon("does-not-exist.png")
+
+
+# --------------------------------------------------------------------------- #
+# _scale_app_window
+# --------------------------------------------------------------------------- #
+def test_scale_app_window_scales_by_configured_factor():
+ # Arrange: 100x80 wächst mit Faktor 1.5 auf 150x120
+ source = Image.new("RGBA", (100, 80), (10, 20, 30, 255))
+
+ # Act
+ scaled = demo._scale_app_window(source, scale=1.5)
+
+ # Assert
+ assert scaled.size == (150, 120)
+
+
+# --------------------------------------------------------------------------- #
+# _build_frames / _save_gif
+# --------------------------------------------------------------------------- #
+def _demo_icons() -> dict:
+ icon = Image.new("RGBA", (30, 30), (0, 255, 0, 255))
+ return {"idle": icon, "recording": icon, "processing": icon}
+
+
+def _demo_app_states() -> dict:
+ """Platzhalter fĂĽr die echten MainWindow-Grabs (Qt-frei)."""
+ window = Image.new("RGBA", (384, 389), (40, 44, 48, 255))
+ return {
+ "idle": window,
+ "recording-1": window,
+ "recording-2": window,
+ "recording-3": window,
+ "transcribing": window,
+ }
+
+
+@pytest.mark.parametrize("lang", ["en", "de"])
+def test_build_frames_produces_uniform_frames_with_positive_durations(lang):
+ # Act
+ frames = demo._build_frames(lang, _demo_icons(), _demo_app_states())
+
+ # Assert: genug Frames für alle Szenen, einheitliche Größe, gültige Dauern
+ assert len(frames) > 20
+ for image, duration in frames:
+ assert image.size == demo.CANVAS_SIZE
+ assert duration > 0
+
+
+def test_save_gif_writes_animated_gif(tmp_path):
+ # Arrange: zwei minimale Frames
+ frames = [
+ (Image.new("RGBA", (40, 40), (255, 0, 0, 255)), 100),
+ (Image.new("RGBA", (40, 40), (0, 0, 255, 255)), 100),
+ ]
+ out = tmp_path / "demo.gif"
+
+ # Act
+ demo._save_gif(frames, out)
+
+ # Assert: animiertes GIF mit beiden Frames
+ with Image.open(out) as gif:
+ assert gif.format == "GIF"
+ assert gif.n_frames == 2
+
+
+def test_save_gif_rejects_empty_frame_list(tmp_path):
+ # Act / Assert
+ with pytest.raises(ValueError):
+ demo._save_gif([], tmp_path / "empty.gif")
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index dfd3d47..479966d 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -28,7 +28,9 @@ def test_hotkeys_defined(self):
assert "hotkey" in WORKFLOW_META[wf], f"Missing hotkey for {wf}"
def test_transcription_hotkey(self):
- assert WORKFLOW_META[WorkflowType.TRANSCRIPTION]["hotkey"] == "Meta+H"
+ # Standard-Aufnahmetaste (KEY_LEFTALT, Modus "hold") — nicht Meta+H;
+ # UI-Labels leiten den konfigurierten Wert via hotkey_display_name ab.
+ assert WORKFLOW_META[WorkflowType.TRANSCRIPTION]["hotkey"] == "Alt"
def test_local_hotkey(self):
assert WORKFLOW_META[WorkflowType.LOCAL]["hotkey"] == "Meta+Shift+H"