PayPerTranscript 0.2.9__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PKG-INFO +2 -1
  2. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/PKG-INFO +2 -1
  3. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/SOURCES.txt +1 -0
  4. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/README.md +1 -0
  5. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/__init__.py +1 -1
  6. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/config.py +28 -8
  7. paypertranscript-0.3.1/paypertranscript/core/context_detector.py +255 -0
  8. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/hotkey.py +42 -7
  9. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/text_inserter.py +23 -8
  10. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/pipeline/transcription.py +107 -4
  11. paypertranscript-0.3.1/paypertranscript/providers/groq_provider.py +273 -0
  12. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/app.py +87 -6
  13. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/overlay.py +43 -1
  14. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/tray.py +15 -9
  15. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/pyproject.toml +1 -1
  16. paypertranscript-0.2.9/paypertranscript/providers/groq_provider.py +0 -193
  17. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/LICENSE +0 -0
  18. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/dependency_links.txt +0 -0
  19. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/entry_points.txt +0 -0
  20. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/requires.txt +0 -0
  21. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/PayPerTranscript.egg-info/top_level.txt +0 -0
  22. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/__main__.py +0 -0
  23. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/app.ico +0 -0
  24. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/app.png +0 -0
  25. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/app_big.png +0 -0
  26. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/arrow_down.svg +0 -0
  27. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/tray.png +0 -0
  28. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/tray_green.png +0 -0
  29. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/icons/tray_orange.png +0 -0
  30. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/sounds/start.wav +0 -0
  31. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/sounds/stop.wav +0 -0
  32. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/assets/styles/dark.qss +0 -0
  33. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/__init__.py +0 -0
  34. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/audio_manager.py +0 -0
  35. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/cost_tracker.py +0 -0
  36. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/logging.py +0 -0
  37. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/paths.py +0 -0
  38. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/recorder.py +0 -0
  39. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/session_logger.py +0 -0
  40. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/updater.py +0 -0
  41. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/core/window_detector.py +0 -0
  42. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/pipeline/__init__.py +0 -0
  43. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/providers/__init__.py +0 -0
  44. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/providers/base.py +0 -0
  45. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/__init__.py +0 -0
  46. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/animated.py +0 -0
  47. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/constants.py +0 -0
  48. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/main_window.py +0 -0
  49. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/__init__.py +0 -0
  50. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/home_page.py +0 -0
  51. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/settings_page.py +0 -0
  52. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/statistics_page.py +0 -0
  53. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/window_mapping_page.py +0 -0
  54. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/pages/word_list_page.py +0 -0
  55. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/setup_wizard.py +0 -0
  56. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/sidebar.py +0 -0
  57. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/paypertranscript/ui/widgets.py +0 -0
  58. {paypertranscript-0.2.9 → paypertranscript-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PayPerTranscript
3
- Version: 0.2.9
3
+ Version: 0.3.1
4
4
  Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
5
5
  Author: PayPerTranscript Contributors
6
6
  License-Expression: MIT
@@ -65,6 +65,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
65
65
  - **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
66
66
  - **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
67
67
  - **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
68
+ - **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
68
69
  - **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
69
70
 
70
71
  ### 📊 Transparenz & Kontrolle
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PayPerTranscript
3
- Version: 0.2.9
3
+ Version: 0.3.1
4
4
  Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
5
5
  Author: PayPerTranscript Contributors
6
6
  License-Expression: MIT
@@ -65,6 +65,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
65
65
  - **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
66
66
  - **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
67
67
  - **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
68
+ - **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
68
69
  - **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
69
70
 
70
71
  ### 📊 Transparenz & Kontrolle
@@ -22,6 +22,7 @@ paypertranscript/assets/styles/dark.qss
22
22
  paypertranscript/core/__init__.py
23
23
  paypertranscript/core/audio_manager.py
24
24
  paypertranscript/core/config.py
25
+ paypertranscript/core/context_detector.py
25
26
  paypertranscript/core/cost_tracker.py
26
27
  paypertranscript/core/hotkey.py
27
28
  paypertranscript/core/logging.py
@@ -36,6 +36,7 @@ Kommerzielle Voice-to-Text Dienste kosten **$12-15/Monat** - egal ob du sie 5 Mi
36
36
  - **Hold-to-Record**: `Ctrl+Win` halten - sprechen - loslassen - fertig
37
37
  - **Blitzschnell**: 30 Sekunden Audio = 0.14 Sekunden Transkription (via Groq Whisper)
38
38
  - **Smart Formatting**: WhatsApp bekommt lockere Texte, Outlook professionelle E-Mails
39
+ - **Kontext-Erkennung**: Markierten Text im aktiven Fenster erkennen - das LLM nutzt ihn für korrekte Schreibweisen und Bezüge
39
40
  - **Wortliste**: Eigene Namen und Fachbegriffe werden immer korrekt geschrieben
40
41
 
41
42
  ### 📊 Transparenz & Kontrolle
@@ -1,3 +1,3 @@
1
1
  """PayPerTranscript - Voice-to-Text mit Pay-per-Use Pricing."""
2
2
 
3
- __version__ = "0.2.9"
3
+ __version__ = "0.3.1"
@@ -49,19 +49,29 @@ DEFAULT_CONFIG: dict[str, Any] = {
49
49
  "casual": {
50
50
  "name": "Persönlich",
51
51
  "prompt": (
52
- "Formatiere den folgenden transkribierten Text als lockere "
53
- "Chat-Nachricht. Alles kleingeschrieben, minimale Interpunktion, "
54
- "Kommas zur Trennung von Gedanken. Kein Punkt am Ende. "
55
- "Gib NUR den formatierten Text aus, keine Erklärungen."
52
+ "Du bist ein Transkriptions-Assistent fuer lockere Chat-Nachrichten. "
53
+ "Deine Aufgabe: Formatiere den transkribierten Text als informelle Nachricht.\n\n"
54
+ "Regeln:\n"
55
+ "- Alles kleingeschrieben\n"
56
+ "- Minimale Interpunktion\n"
57
+ "- Kommas NUR zur Trennung von Saetzen, nicht innerhalb eines Satzes\n"
58
+ "- Kein Punkt am Satzende (Fragezeichen sind erlaubt)\n"
59
+ "- Entferne Fuellwoerter und Wiederholungen\n\n"
60
+ "Gib NUR den formatierten Text aus. "
61
+ "Beantworte keine Fragen, fuege keine Erklaerungen hinzu."
56
62
  ),
57
63
  },
58
64
  "professional": {
59
65
  "name": "Professionell",
60
66
  "prompt": (
61
- "Formatiere den folgenden transkribierten Text als professionelle "
62
- "Nachricht. Korrekte Groß-/Kleinschreibung, saubere Interpunktion, "
63
- "entferne Füllwörter und Wiederholungen. Sachlicher Stil, kurze "
64
- "Absätze. Gib NUR den formatierten Text aus, keine Erklärungen."
67
+ "Du bist ein Transkriptions-Assistent fuer professionelle Kommunikation. "
68
+ "Deine Aufgabe: Formatiere den transkribierten Text als sachliche, professionelle Nachricht.\n\n"
69
+ "Regeln:\n"
70
+ "- Korrekte Gross-/Kleinschreibung und Interpunktion\n"
71
+ "- Entferne Fuellwoerter und Wiederholungen\n"
72
+ "- Sachlicher Stil, kurze Absaetze\n\n"
73
+ "Gib NUR den formatierten Text aus. "
74
+ "Beantworte keine Fragen, fuege keine Erklaerungen hinzu."
65
75
  ),
66
76
  },
67
77
  },
@@ -74,6 +84,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
74
84
  "auto_update": True,
75
85
  "check_interval_hours": 24,
76
86
  },
87
+ "context": {
88
+ "detect_selection": True,
89
+ "terminal_blocklist": [
90
+ "cmd.exe", "powershell.exe", "pwsh.exe",
91
+ "WindowsTerminal.exe", "mintty.exe", "bash.exe",
92
+ "wsl.exe", "conhost.exe",
93
+ ],
94
+ },
77
95
  }
78
96
 
79
97
  # Schema: Erlaubte Typen pro Pfad für Validierung
@@ -96,6 +114,8 @@ _SCHEMA: dict[str, type | tuple[type, ...]] = {
96
114
  "data.save_transcripts": bool,
97
115
  "updates.auto_update": bool,
98
116
  "updates.check_interval_hours": (int, float),
117
+ "context.detect_selection": bool,
118
+ "context.terminal_blocklist": list,
99
119
  }
100
120
 
101
121
 
@@ -0,0 +1,255 @@
1
+ """Kontext-Erkennung fuer PayPerTranscript.
2
+
3
+ Prueft ob im aktiven Fenster Text markiert ist (via Clipboard-Sentinel + Ctrl+C).
4
+ Der erkannte Text wird dem LLM als Kontext mitgegeben, z.B. fuer Antworten auf E-Mails.
5
+
6
+ Die Erkennung laeuft parallel zum STT-API-Call und fuegt 0ms zusaetzliche Latenz hinzu.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import threading
13
+ import time
14
+ from concurrent.futures import Future
15
+
16
+ import pyautogui
17
+ import pyperclip
18
+
19
+ from paypertranscript.core.config import ConfigManager
20
+ from paypertranscript.core.logging import get_logger
21
+ from paypertranscript.core.window_detector import WindowInfo
22
+
23
+ log = get_logger("core.context_detector")
24
+
25
+ # Sentinel: Null-Bytes koennen nicht in normalem Clipboard-Text vorkommen
26
+ _SENTINEL = "\x00__PPT_SENTINEL__\x00"
27
+
28
+ # Wartezeit nach Ctrl+C bevor Clipboard gelesen wird (ms)
29
+ _CLIPBOARD_WAIT_MS = 80
30
+
31
+ # Timeout fuer Modifier-Release-Wait (ms)
32
+ _MODIFIER_RELEASE_TIMEOUT_MS = 400
33
+
34
+ # Virtual-Key-Codes fuer Modifier-Keys (Win32)
35
+ _VK_MODIFIERS = (
36
+ 0x10, # VK_SHIFT
37
+ 0x11, # VK_CONTROL
38
+ 0x12, # VK_MENU (Alt)
39
+ 0x5B, # VK_LWIN
40
+ 0x5C, # VK_RWIN
41
+ )
42
+
43
+ # Terminal-Prozesse in denen Ctrl+C nicht gesendet werden darf (SIGINT-Gefahr)
44
+ _DEFAULT_TERMINAL_BLOCKLIST = frozenset({
45
+ "cmd.exe", "powershell.exe", "pwsh.exe",
46
+ "windowsterminal.exe", "mintty.exe", "bash.exe",
47
+ "wsl.exe", "conhost.exe", "alacritty.exe",
48
+ "wezterm-gui.exe", "hyper.exe",
49
+ })
50
+
51
+
52
+ def detect_selected_text(
53
+ window: WindowInfo | None,
54
+ config: ConfigManager,
55
+ cancel_event: threading.Event | None = None,
56
+ ) -> str:
57
+ """Prueft ob im aktiven Fenster Text markiert ist und gibt ihn zurueck.
58
+
59
+ Ablauf:
60
+ 1. Feature-Flag und Terminal-Blocklist pruefen
61
+ 2. Clipboard sichern → Sentinel setzen → Ctrl+C → Clipboard lesen → wiederherstellen
62
+ 3. Wenn Clipboard != Sentinel: markierter Text gefunden
63
+
64
+ Gibt in ALLEN Fehler-/Abbruch-Faellen "" zurueck — wirft nie Exceptions.
65
+ Die Pipeline wird dadurch nie blockiert oder gestoert.
66
+
67
+ Args:
68
+ window: Info ueber das aktive Fenster (fuer Blocklist-Check).
69
+ config: ConfigManager-Instanz.
70
+ cancel_event: Optionales Event zum Abbrechen der Erkennung.
71
+
72
+ Returns:
73
+ Markierter Text oder "" wenn nichts markiert / Fehler / deaktiviert.
74
+ """
75
+ t_start = time.perf_counter()
76
+
77
+ try:
78
+ # 1. Feature-Flag pruefen
79
+ if not config.get("context.detect_selection", True):
80
+ log.debug("Context detection disabled by config")
81
+ return ""
82
+
83
+ # 2. Abbruch pruefen
84
+ if cancel_event and cancel_event.is_set():
85
+ log.debug("Context detection cancelled before start")
86
+ return ""
87
+
88
+ # 3. Terminal-Blocklist pruefen
89
+ if window and window.process_name:
90
+ process_lower = window.process_name.lower()
91
+ blocklist = config.get("context.terminal_blocklist", [])
92
+ blocklist_lower = {p.lower() for p in blocklist}
93
+ # Auch Default-Blocklist pruefen
94
+ blocklist_lower.update(p.lower() for p in _DEFAULT_TERMINAL_BLOCKLIST)
95
+
96
+ if process_lower in blocklist_lower:
97
+ log.debug(
98
+ "Context detection skipped: terminal process '%s'",
99
+ window.process_name,
100
+ )
101
+ return ""
102
+
103
+ log.debug(
104
+ "Context detection started for window '%s'",
105
+ window.process_name,
106
+ )
107
+ else:
108
+ log.debug("Context detection started (no window info)")
109
+
110
+ # 4. Clipboard sichern
111
+ try:
112
+ original_clipboard = pyperclip.paste()
113
+ except Exception:
114
+ original_clipboard = ""
115
+ log.debug("Clipboard backed up (%d chars)", len(original_clipboard))
116
+
117
+ # 5. Sentinel auf Clipboard setzen
118
+ try:
119
+ pyperclip.copy(_SENTINEL)
120
+ except Exception as e:
121
+ log.warning("Context detection: clipboard write failed: %s", e)
122
+ return ""
123
+ log.debug("Sentinel placed on clipboard")
124
+
125
+ # 6. Abbruch pruefen
126
+ if cancel_event and cancel_event.is_set():
127
+ _restore_clipboard(original_clipboard)
128
+ log.debug("Context detection cancelled before Ctrl+C")
129
+ return ""
130
+
131
+ # 7. Warten bis Modifier-Keys losgelassen sind (noetig fuer Toggle-Hotkey:
132
+ # User haelt noch Ctrl+Alt → Ctrl+C wuerde als Ctrl+Alt+C ankommen)
133
+ _wait_for_modifiers_released()
134
+
135
+ # 8. Ctrl+C senden
136
+ pyautogui.hotkey("ctrl", "c")
137
+ t_ctrlc = time.perf_counter()
138
+ log.debug("Ctrl+C sent (%.1fms after start)", (t_ctrlc - t_start) * 1000)
139
+
140
+ # 9. Warten bis Clipboard aktualisiert
141
+ time.sleep(_CLIPBOARD_WAIT_MS / 1000)
142
+
143
+ # 10. Clipboard lesen
144
+ try:
145
+ clipboard_content = pyperclip.paste()
146
+ except Exception as e:
147
+ log.warning("Context detection: clipboard read failed: %s", e)
148
+ _restore_clipboard(original_clipboard)
149
+ return ""
150
+
151
+ t_read = time.perf_counter()
152
+ log.debug(
153
+ "Clipboard read after Ctrl+C (%.1fms after start)",
154
+ (t_read - t_start) * 1000,
155
+ )
156
+
157
+ # 11. Auswerten: Hat Ctrl+C den Sentinel ueberschrieben?
158
+ if clipboard_content == _SENTINEL:
159
+ # Sentinel unveraendert → nichts war markiert
160
+ _restore_clipboard(original_clipboard)
161
+ t_end = time.perf_counter()
162
+ log.debug("No text selected (%.1fms total)", (t_end - t_start) * 1000)
163
+ return ""
164
+
165
+ # Text war markiert!
166
+ selected_text = clipboard_content.strip()
167
+
168
+ # 12. Original-Clipboard wiederherstellen
169
+ _restore_clipboard(original_clipboard)
170
+
171
+ t_end = time.perf_counter()
172
+ if selected_text:
173
+ log.info(
174
+ "Selected text detected: %d chars (%.1fms total)",
175
+ len(selected_text),
176
+ (t_end - t_start) * 1000,
177
+ )
178
+ else:
179
+ log.debug("No text selected (empty after strip, %.1fms total)", (t_end - t_start) * 1000)
180
+
181
+ return selected_text
182
+
183
+ except Exception as e:
184
+ t_end = time.perf_counter()
185
+ log.warning(
186
+ "Context detection failed (%.1fms): %s",
187
+ (t_end - t_start) * 1000,
188
+ e,
189
+ )
190
+ return ""
191
+
192
+
193
+ def _wait_for_modifiers_released() -> None:
194
+ """Wartet bis alle Modifier-Keys (Ctrl, Alt, Shift, Win) losgelassen sind.
195
+
196
+ Noetig fuer Toggle-Hotkey: Der User haelt noch Ctrl+Alt wenn die
197
+ Context-Detection startet. Ctrl+C waehrend Ctrl+Alt gehalten wird,
198
+ wuerde als Ctrl+Alt+C interpretiert und Copy nicht ausloesen.
199
+
200
+ Beim Hold-Hotkey sind die Keys bereits losgelassen → returned sofort.
201
+ """
202
+ user32 = ctypes.windll.user32
203
+ deadline = time.perf_counter() + _MODIFIER_RELEASE_TIMEOUT_MS / 1000
204
+
205
+ while time.perf_counter() < deadline:
206
+ if not any(user32.GetAsyncKeyState(vk) & 0x8000 for vk in _VK_MODIFIERS):
207
+ return
208
+ time.sleep(0.01) # 10ms polling
209
+
210
+ # Timeout: Modifier immer noch gehalten — trotzdem weitermachen
211
+ log.debug(
212
+ "Modifier keys still held after %dms timeout",
213
+ _MODIFIER_RELEASE_TIMEOUT_MS,
214
+ )
215
+
216
+
217
+ def _restore_clipboard(content: str) -> None:
218
+ """Stellt den Clipboard-Inhalt wieder her (best-effort)."""
219
+ try:
220
+ pyperclip.copy(content)
221
+ except Exception as e:
222
+ log.warning("Context detection: clipboard restore failed: %s", e)
223
+
224
+
225
+ def detect_selected_text_async(
226
+ window: WindowInfo | None,
227
+ config: ConfigManager,
228
+ cancel_event: threading.Event | None = None,
229
+ ) -> Future[str]:
230
+ """Startet detect_selected_text() in einem daemon-Thread.
231
+
232
+ Args:
233
+ window: Info ueber das aktive Fenster.
234
+ config: ConfigManager-Instanz.
235
+ cancel_event: Optionales Event zum Abbrechen.
236
+
237
+ Returns:
238
+ Future[str] das den markierten Text (oder "") enthaelt.
239
+ """
240
+ future: Future[str] = Future()
241
+
242
+ def _worker() -> None:
243
+ try:
244
+ result = detect_selected_text(window, config, cancel_event)
245
+ future.set_result(result)
246
+ except Exception as e:
247
+ future.set_exception(e)
248
+
249
+ thread = threading.Thread(
250
+ target=_worker,
251
+ daemon=True,
252
+ name="context-detector",
253
+ )
254
+ thread.start()
255
+ return future
@@ -5,6 +5,7 @@ Unterstützt Hold-to-Record und Toggle-Modus.
5
5
  """
6
6
 
7
7
  import threading
8
+ import time
8
9
  from collections.abc import Callable
9
10
  from typing import Any
10
11
 
@@ -54,9 +55,18 @@ _MODIFIER_GROUPS: dict[str, set[keyboard.Key]] = {
54
55
  "cmd": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
55
56
  }
56
57
 
58
+ # Alle Modifier-Keys (flach) fuer Exakt-Match-Pruefung
59
+ _ALL_MODIFIER_KEYS: set[keyboard.Key] = set()
60
+ for _grp in _MODIFIER_GROUPS.values():
61
+ _ALL_MODIFIER_KEYS |= _grp
62
+
57
63
  # Alt-Keys fuer Menu-Bar-Workaround (Windows aktiviert Menueleiste bei bare Alt-Release)
58
64
  _ALT_KEYS: set[keyboard.Key] = {keyboard.Key.alt_l, keyboard.Key.alt_r}
59
65
 
66
+ # Minimale Zeit zwischen zwei Toggle-Ausloesungen (Sekunden).
67
+ # Verhindert Ghost-Toggles durch synthetische Key-Events (z.B. pyautogui Ctrl+C).
68
+ _TOGGLE_DEBOUNCE_S = 0.5
69
+
60
70
 
61
71
  def _resolve_key(key_str: str) -> keyboard.Key | keyboard.KeyCode:
62
72
  """Löst einen Config-String in ein pynput-Key-Objekt auf."""
@@ -136,6 +146,7 @@ class HotkeyListener:
136
146
  self._pressed_keys: set[keyboard.Key | keyboard.KeyCode] = set()
137
147
  self._hold_active = False
138
148
  self._toggle_combo_held = False
149
+ self._toggle_last_fired: float = 0.0
139
150
  self._listener: keyboard.Listener | None = None
140
151
  self._lock = threading.Lock()
141
152
  self._kb_controller: keyboard.Controller | None = None
@@ -149,18 +160,33 @@ class HotkeyListener:
149
160
  target_keys: list[keyboard.Key | keyboard.KeyCode],
150
161
  modifier_groups: list[set[keyboard.Key]],
151
162
  ) -> bool:
152
- """Prüft ob eine Tastenkombination aktuell gedrückt ist."""
163
+ """Prüft ob eine Tastenkombination aktuell gedrückt ist.
164
+
165
+ Exaktes Modifier-Matching: es muessen genau die konfigurierten Modifier
166
+ gedrueckt sein, keine zusaetzlichen. Damit wird verhindert, dass z.B.
167
+ Ctrl+Win auch durch Ctrl+Shift+Alt+F9 ausgeloest wird.
168
+ """
153
169
  if not target_keys:
154
170
  return False
155
171
 
172
+ # Sammle welche Modifier-Gruppen zum Hotkey gehoeren
173
+ required_modifier_keys: set[keyboard.Key] = set()
174
+
156
175
  for i, target_key in enumerate(target_keys):
157
176
  # Für Modifier: prüfe ob *irgendein* Key aus der Gruppe gedrückt ist
158
177
  if i < len(modifier_groups) and modifier_groups[i]:
159
178
  if not (modifier_groups[i] & self._pressed_keys):
160
179
  return False
180
+ required_modifier_keys |= modifier_groups[i]
161
181
  else:
162
182
  if target_key not in self._pressed_keys:
163
183
  return False
184
+
185
+ # Pruefe ob Extra-Modifier gedrueckt sind, die nicht zum Hotkey gehoeren
186
+ extra_modifiers = (self._pressed_keys & _ALL_MODIFIER_KEYS) - required_modifier_keys
187
+ if extra_modifiers:
188
+ return False
189
+
164
190
  return True
165
191
 
166
192
  def _combo_uses_alt(self, target_keys: list[keyboard.Key | keyboard.KeyCode]) -> bool:
@@ -192,12 +218,20 @@ class HotkeyListener:
192
218
  if self._on_hold_start:
193
219
  threading.Thread(target=self._on_hold_start, daemon=True).start()
194
220
 
195
- # Toggle-Hotkey prüfen
196
- if self._toggle_keys and self._check_combo(self._toggle_keys, self._toggle_modifier_groups):
197
- self._toggle_combo_held = True
198
- log.debug("Toggle-Hotkey gedrückt")
199
- if self._on_toggle:
200
- threading.Thread(target=self._on_toggle, daemon=True).start()
221
+ # Toggle-Hotkey prüfen (Guard + Debounce gegen synthetische Key-Events)
222
+ if (self._toggle_keys
223
+ and not self._toggle_combo_held
224
+ and self._check_combo(self._toggle_keys, self._toggle_modifier_groups)):
225
+ now = time.monotonic()
226
+ if (now - self._toggle_last_fired) >= _TOGGLE_DEBOUNCE_S:
227
+ self._toggle_combo_held = True
228
+ self._toggle_last_fired = now
229
+ log.debug("Toggle-Hotkey gedrückt")
230
+ if self._on_toggle:
231
+ threading.Thread(target=self._on_toggle, daemon=True).start()
232
+ else:
233
+ self._toggle_combo_held = True
234
+ log.debug("Toggle-Hotkey ignoriert (Debounce)")
201
235
 
202
236
  def _on_release(self, key: keyboard.Key | keyboard.KeyCode) -> None:
203
237
  """Callback für Key-Release-Events."""
@@ -281,6 +315,7 @@ class HotkeyListener:
281
315
  # State zurücksetzen
282
316
  self._hold_active = False
283
317
  self._toggle_combo_held = False
318
+ self._toggle_last_fired = 0.0
284
319
  self._pressed_keys.clear()
285
320
 
286
321
  @property
@@ -19,6 +19,27 @@ log = get_logger("core.text_inserter")
19
19
  pyautogui.FAILSAFE = False
20
20
  pyautogui.PAUSE = 0
21
21
 
22
+ # Clipboard-Wiederherstellung: Retry-Konfiguration
23
+ _CLIPBOARD_RESTORE_RETRIES = 3
24
+ _CLIPBOARD_RESTORE_DELAY = 0.05 # 50ms zwischen Versuchen
25
+
26
+
27
+ def _restore_clipboard(content: str) -> None:
28
+ """Stellt die Zwischenablage wieder her mit Retry-Logik.
29
+
30
+ Andere Apps (Clipboard-Manager, Password-Manager) koennen die
31
+ Zwischenablage kurzzeitig sperren. Daher mehrere Versuche.
32
+ """
33
+ for attempt in range(1, _CLIPBOARD_RESTORE_RETRIES + 1):
34
+ try:
35
+ pyperclip.copy(content)
36
+ return
37
+ except Exception:
38
+ if attempt < _CLIPBOARD_RESTORE_RETRIES:
39
+ time.sleep(_CLIPBOARD_RESTORE_DELAY)
40
+ else:
41
+ log.warning("Zwischenablage konnte nicht wiederhergestellt werden (nach %d Versuchen)", _CLIPBOARD_RESTORE_RETRIES)
42
+
22
43
 
23
44
  def insert_text(text: str) -> None:
24
45
  """Fügt Text an der aktuellen Cursor-Position ein.
@@ -62,10 +83,7 @@ def insert_text(text: str) -> None:
62
83
 
63
84
  finally:
64
85
  # 5. Alte Zwischenablage wiederherstellen
65
- try:
66
- pyperclip.copy(old_clipboard)
67
- except Exception:
68
- log.debug("Zwischenablage konnte nicht wiederhergestellt werden")
86
+ _restore_clipboard(old_clipboard)
69
87
 
70
88
 
71
89
  # Intervall (Sekunden) zwischen Chunk-Pastes bei Streaming-Typing
@@ -125,7 +143,4 @@ def insert_text_streaming(chunks: Iterator[str]) -> None:
125
143
  log.error("Auch Fallback-Paste fehlgeschlagen")
126
144
 
127
145
  finally:
128
- try:
129
- pyperclip.copy(old_clipboard)
130
- except Exception:
131
- log.debug("Zwischenablage konnte nicht wiederhergestellt werden")
146
+ _restore_clipboard(old_clipboard)