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

+
+ Demo: Hotkey drĂĽcken, sprechen, und Blitztext fĂĽgt den transkribierten Text direkt in die aktive Anwendung ein +
+ 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

+
+ Demo: press the hotkey, speak, and Blitztext pastes the transcribed text into the active application +
+ 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"