whisper-key-local 0.5.2__py3-none-any.whl → 0.6.0__py3-none-any.whl
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.
- whisper_key/assets/version.txt +1 -1
- whisper_key/audio_feedback.py +21 -20
- whisper_key/audio_recorder.py +2 -9
- whisper_key/clipboard_manager.py +35 -52
- whisper_key/config.defaults.yaml +22 -13
- whisper_key/config_manager.py +25 -9
- whisper_key/console_manager.py +2 -58
- whisper_key/hotkey_listener.py +55 -89
- whisper_key/instance_manager.py +9 -13
- whisper_key/main.py +28 -11
- whisper_key/platform/__init__.py +10 -0
- whisper_key/platform/macos/__init__.py +1 -0
- whisper_key/platform/macos/app.py +27 -0
- whisper_key/platform/macos/console.py +13 -0
- whisper_key/platform/macos/hotkeys.py +180 -0
- whisper_key/platform/macos/icons.py +11 -0
- whisper_key/platform/macos/instance_lock.py +31 -0
- whisper_key/platform/macos/keyboard.py +97 -0
- whisper_key/platform/macos/keycodes.py +17 -0
- whisper_key/platform/macos/paths.py +8 -0
- whisper_key/platform/macos/permissions.py +66 -0
- whisper_key/platform/windows/__init__.py +1 -0
- whisper_key/platform/windows/app.py +6 -0
- whisper_key/platform/windows/console.py +59 -0
- whisper_key/platform/windows/hotkeys.py +30 -0
- whisper_key/platform/windows/icons.py +11 -0
- whisper_key/platform/windows/instance_lock.py +14 -0
- whisper_key/platform/windows/keyboard.py +12 -0
- whisper_key/platform/windows/paths.py +8 -0
- whisper_key/platform/windows/permissions.py +6 -0
- whisper_key/state_manager.py +4 -4
- whisper_key/system_tray.py +30 -57
- whisper_key/terminal_ui.py +71 -0
- whisper_key/utils.py +8 -4
- whisper_key_local-0.6.0.dist-info/METADATA +159 -0
- whisper_key_local-0.6.0.dist-info/RECORD +47 -0
- {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/WHEEL +1 -1
- whisper_key/assets/tray_idle.png +0 -0
- whisper_key/assets/tray_processing.png +0 -0
- whisper_key/assets/tray_recording.png +0 -0
- whisper_key_local-0.5.2.dist-info/METADATA +0 -130
- whisper_key_local-0.5.2.dist-info/RECORD +0 -29
- {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/entry_points.txt +0 -0
- {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from .keycodes import KEY_CODES
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
_delay = 0.0
|
|
9
|
+
MODIFIER_FLAGS = {}
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from Quartz import (
|
|
13
|
+
CGEventCreateKeyboardEvent,
|
|
14
|
+
CGEventPost,
|
|
15
|
+
CGEventSetFlags,
|
|
16
|
+
kCGHIDEventTap,
|
|
17
|
+
kCGEventFlagMaskCommand,
|
|
18
|
+
kCGEventFlagMaskControl,
|
|
19
|
+
kCGEventFlagMaskShift,
|
|
20
|
+
kCGEventFlagMaskAlternate,
|
|
21
|
+
)
|
|
22
|
+
_quartz_available = True
|
|
23
|
+
MODIFIER_FLAGS = {
|
|
24
|
+
'cmd': kCGEventFlagMaskCommand,
|
|
25
|
+
'command': kCGEventFlagMaskCommand,
|
|
26
|
+
'ctrl': kCGEventFlagMaskControl,
|
|
27
|
+
'control': kCGEventFlagMaskControl,
|
|
28
|
+
'shift': kCGEventFlagMaskShift,
|
|
29
|
+
'option': kCGEventFlagMaskAlternate,
|
|
30
|
+
'alt': kCGEventFlagMaskAlternate,
|
|
31
|
+
}
|
|
32
|
+
except ImportError:
|
|
33
|
+
_quartz_available = False
|
|
34
|
+
logger.warning("Quartz not available - keyboard simulation disabled")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_delay(delay: float):
|
|
38
|
+
global _delay
|
|
39
|
+
_delay = delay
|
|
40
|
+
logger.debug(f"Keyboard delay set to {delay}s")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def send_key(key: str):
|
|
44
|
+
if not _quartz_available:
|
|
45
|
+
logger.warning("Cannot send key - Quartz not available")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
key_lower = key.lower()
|
|
49
|
+
key_code = KEY_CODES.get(key_lower)
|
|
50
|
+
if key_code is None:
|
|
51
|
+
logger.error(f"Unknown key: {key}")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
logger.debug(f"Sending key: {key} (code: {hex(key_code)})")
|
|
55
|
+
|
|
56
|
+
event = CGEventCreateKeyboardEvent(None, key_code, True)
|
|
57
|
+
CGEventSetFlags(event, 0)
|
|
58
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
59
|
+
|
|
60
|
+
if _delay > 0:
|
|
61
|
+
time.sleep(_delay)
|
|
62
|
+
|
|
63
|
+
event = CGEventCreateKeyboardEvent(None, key_code, False)
|
|
64
|
+
CGEventSetFlags(event, 0)
|
|
65
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def send_hotkey(*keys: str):
|
|
69
|
+
if not _quartz_available:
|
|
70
|
+
logger.warning("Cannot send hotkey - Quartz not available")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
modifiers = [k for k in keys if k.lower() in MODIFIER_FLAGS]
|
|
74
|
+
regular_keys = [k for k in keys if k.lower() not in MODIFIER_FLAGS]
|
|
75
|
+
|
|
76
|
+
flags = 0
|
|
77
|
+
for mod in modifiers:
|
|
78
|
+
flags |= MODIFIER_FLAGS[mod.lower()]
|
|
79
|
+
|
|
80
|
+
logger.debug(f"Sending hotkey: {'+'.join(keys)} (modifiers: {modifiers}, keys: {regular_keys})")
|
|
81
|
+
|
|
82
|
+
for key in regular_keys:
|
|
83
|
+
key_code = KEY_CODES.get(key.lower())
|
|
84
|
+
if key_code is None:
|
|
85
|
+
logger.error(f"Unknown key in hotkey: {key}")
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
event = CGEventCreateKeyboardEvent(None, key_code, True)
|
|
89
|
+
CGEventSetFlags(event, flags)
|
|
90
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
91
|
+
|
|
92
|
+
if _delay > 0:
|
|
93
|
+
time.sleep(_delay)
|
|
94
|
+
|
|
95
|
+
event = CGEventCreateKeyboardEvent(None, key_code, False)
|
|
96
|
+
CGEventSetFlags(event, flags)
|
|
97
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
KEY_CODES = {
|
|
2
|
+
'a': 0, 'b': 11, 'c': 8, 'd': 2, 'e': 14, 'f': 3, 'g': 5, 'h': 4,
|
|
3
|
+
'i': 34, 'j': 38, 'k': 40, 'l': 37, 'm': 46, 'n': 45, 'o': 31, 'p': 35,
|
|
4
|
+
'q': 12, 'r': 15, 's': 1, 't': 17, 'u': 32, 'v': 9, 'w': 13, 'x': 7,
|
|
5
|
+
'y': 16, 'z': 6,
|
|
6
|
+
'0': 29, '1': 18, '2': 19, '3': 20, '4': 21,
|
|
7
|
+
'5': 23, '6': 22, '7': 26, '8': 28, '9': 25,
|
|
8
|
+
'space': 49,
|
|
9
|
+
'enter': 36, 'return': 36,
|
|
10
|
+
'tab': 48,
|
|
11
|
+
'delete': 51, 'backspace': 51,
|
|
12
|
+
'escape': 53, 'esc': 53,
|
|
13
|
+
'f1': 122, 'f2': 120, 'f3': 99, 'f4': 118, 'f5': 96, 'f6': 97,
|
|
14
|
+
'f7': 98, 'f8': 100, 'f9': 101, 'f10': 109, 'f11': 103, 'f12': 111,
|
|
15
|
+
'.': 47, ',': 43, '/': 44, ';': 41, "'": 39, '[': 33, ']': 30,
|
|
16
|
+
'-': 27, '=': 24, '`': 50, '\\': 42,
|
|
17
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from ApplicationServices import AXIsProcessTrusted, AXIsProcessTrustedWithOptions
|
|
10
|
+
_appservices_available = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
_appservices_available = False
|
|
13
|
+
logger.warning("ApplicationServices not available - permission checks disabled")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_terminal_app_name() -> str:
|
|
17
|
+
term_program = os.environ.get('TERM_PROGRAM', '')
|
|
18
|
+
if 'iTerm' in term_program:
|
|
19
|
+
return 'iTerm'
|
|
20
|
+
elif term_program == 'Apple_Terminal':
|
|
21
|
+
return 'Terminal'
|
|
22
|
+
elif term_program:
|
|
23
|
+
return term_program.replace('.app', '')
|
|
24
|
+
return 'your terminal app'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_accessibility_permission() -> bool:
|
|
28
|
+
if not _appservices_available:
|
|
29
|
+
return True
|
|
30
|
+
return AXIsProcessTrusted()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def request_accessibility_permission():
|
|
34
|
+
if not _appservices_available:
|
|
35
|
+
return
|
|
36
|
+
options = {'AXTrustedCheckOptionPrompt': True}
|
|
37
|
+
AXIsProcessTrustedWithOptions(options)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle_missing_permission(config_manager) -> bool:
|
|
41
|
+
from ...terminal_ui import prompt_choice
|
|
42
|
+
|
|
43
|
+
app_name = _get_terminal_app_name()
|
|
44
|
+
|
|
45
|
+
title = "Auto-paste requires permission to simulate [Cmd+V] keypress..."
|
|
46
|
+
options = [
|
|
47
|
+
(f"Grant accessibility permission to {app_name}", "Transcribe directly to cursor, with option to auto-send"),
|
|
48
|
+
("Disable auto-paste", "Transcribe to clipboard, then manually paste"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
choice = prompt_choice(title, options)
|
|
52
|
+
|
|
53
|
+
if choice == 1:
|
|
54
|
+
config_manager.update_user_setting('clipboard', 'auto_paste', True)
|
|
55
|
+
request_accessibility_permission()
|
|
56
|
+
print()
|
|
57
|
+
print("Please restart Whisper Key after permission is granted")
|
|
58
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
59
|
+
return False
|
|
60
|
+
elif choice == 2:
|
|
61
|
+
config_manager.update_user_setting('clipboard', 'auto_paste', False)
|
|
62
|
+
print()
|
|
63
|
+
return True
|
|
64
|
+
else:
|
|
65
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
66
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import instance_lock, console, keyboard, hotkeys, paths, permissions
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import win32console
|
|
2
|
+
import win32gui
|
|
3
|
+
import win32con
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
class ConsoleManager:
|
|
9
|
+
def __init__(self, config: dict, is_executable_mode: bool = False):
|
|
10
|
+
self.logger = logging.getLogger(__name__)
|
|
11
|
+
self.config = config
|
|
12
|
+
self.is_executable_mode = is_executable_mode
|
|
13
|
+
self.console_handle = None
|
|
14
|
+
self._lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
self.console_handle = self._get_console_window()
|
|
18
|
+
except Exception as e:
|
|
19
|
+
self.logger.error(f"Failed to initialize console manager: {e}")
|
|
20
|
+
|
|
21
|
+
self._hide_on_startup()
|
|
22
|
+
|
|
23
|
+
def _get_console_window(self):
|
|
24
|
+
try:
|
|
25
|
+
handle = win32console.GetConsoleWindow()
|
|
26
|
+
if handle:
|
|
27
|
+
self.logger.debug(f"Console window handle: {handle}")
|
|
28
|
+
return handle
|
|
29
|
+
else:
|
|
30
|
+
self.logger.warning("GetConsoleWindow returned null handle")
|
|
31
|
+
return None
|
|
32
|
+
except Exception as e:
|
|
33
|
+
self.logger.error(f"Failed to get console window: {e}")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def _hide_on_startup(self):
|
|
37
|
+
if self.config.get('start_hidden', False) and self.is_executable_mode:
|
|
38
|
+
time.sleep(2)
|
|
39
|
+
self.hide_console()
|
|
40
|
+
|
|
41
|
+
def show_console(self):
|
|
42
|
+
with self._lock:
|
|
43
|
+
try:
|
|
44
|
+
win32gui.ShowWindow(self.console_handle, win32con.SW_HIDE)
|
|
45
|
+
win32gui.ShowWindow(self.console_handle, win32con.SW_RESTORE)
|
|
46
|
+
win32gui.SetForegroundWindow(self.console_handle)
|
|
47
|
+
return True
|
|
48
|
+
except Exception as e:
|
|
49
|
+
self.logger.error(f"Failed to show/focus console: {e}")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def hide_console(self):
|
|
53
|
+
with self._lock:
|
|
54
|
+
try:
|
|
55
|
+
win32gui.ShowWindow(self.console_handle, win32con.SW_HIDE)
|
|
56
|
+
return True
|
|
57
|
+
except Exception as e:
|
|
58
|
+
self.logger.error(f"Failed to hide console: {e}")
|
|
59
|
+
return False
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from global_hotkeys import register_hotkeys, start_checking_hotkeys, stop_checking_hotkeys
|
|
2
|
+
|
|
3
|
+
# global-hotkeys library expects: 'control + window + shift' format
|
|
4
|
+
KEY_MAP = {
|
|
5
|
+
'ctrl': 'control',
|
|
6
|
+
'win': 'window',
|
|
7
|
+
'windows': 'window',
|
|
8
|
+
'cmd': 'window',
|
|
9
|
+
'super': 'window',
|
|
10
|
+
'esc': 'escape',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
def _normalize_hotkey(hotkey_str: str) -> str:
|
|
14
|
+
keys = hotkey_str.lower().split('+')
|
|
15
|
+
converted = [KEY_MAP.get(k.strip(), k.strip()) for k in keys]
|
|
16
|
+
return ' + '.join(converted)
|
|
17
|
+
|
|
18
|
+
def register(bindings: list):
|
|
19
|
+
normalized = []
|
|
20
|
+
for binding in bindings:
|
|
21
|
+
hotkey_str = binding[0]
|
|
22
|
+
normalized_binding = [_normalize_hotkey(hotkey_str)] + binding[1:]
|
|
23
|
+
normalized.append(normalized_binding)
|
|
24
|
+
register_hotkeys(normalized)
|
|
25
|
+
|
|
26
|
+
def start():
|
|
27
|
+
start_checking_hotkeys()
|
|
28
|
+
|
|
29
|
+
def stop():
|
|
30
|
+
stop_checking_hotkeys()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from PIL import Image
|
|
3
|
+
|
|
4
|
+
ASSETS_DIR = Path(__file__).parent / "assets"
|
|
5
|
+
|
|
6
|
+
def get_tray_icons() -> dict:
|
|
7
|
+
return {
|
|
8
|
+
"idle": Image.open(ASSETS_DIR / "tray_idle.png"),
|
|
9
|
+
"recording": Image.open(ASSETS_DIR / "tray_recording.png"),
|
|
10
|
+
"processing": Image.open(ASSETS_DIR / "tray_processing.png"),
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import win32api
|
|
2
|
+
import win32event
|
|
3
|
+
|
|
4
|
+
def acquire_lock(app_name: str):
|
|
5
|
+
mutex_name = f"{app_name}_SingleInstance"
|
|
6
|
+
mutex_handle = win32event.CreateMutex(None, True, mutex_name)
|
|
7
|
+
|
|
8
|
+
if win32api.GetLastError() == 183: # ERROR_ALREADY_EXISTS
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
return mutex_handle
|
|
12
|
+
|
|
13
|
+
def release_lock(handle):
|
|
14
|
+
pass # Mutex is released automatically when process exits
|
whisper_key/state_manager.py
CHANGED
|
@@ -127,13 +127,13 @@ class StateManager:
|
|
|
127
127
|
|
|
128
128
|
duration = self.audio_recorder.get_audio_duration(audio_data)
|
|
129
129
|
print(f"🎤 Recorded {duration:.1f} seconds! Transcribing...")
|
|
130
|
-
|
|
130
|
+
|
|
131
|
+
self.system_tray.update_state("processing")
|
|
132
|
+
|
|
131
133
|
transcribed_text = self.whisper_engine.transcribe_audio(audio_data)
|
|
132
|
-
|
|
134
|
+
|
|
133
135
|
if not transcribed_text:
|
|
134
136
|
return
|
|
135
|
-
|
|
136
|
-
self.system_tray.update_state("processing")
|
|
137
137
|
|
|
138
138
|
success = self.clipboard_manager.deliver_transcription(
|
|
139
139
|
transcribed_text, use_auto_enter
|
whisper_key/system_tray.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import threading
|
|
3
2
|
import os
|
|
4
3
|
import signal
|
|
5
4
|
from typing import Optional, TYPE_CHECKING
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
|
-
from .utils import
|
|
7
|
+
from .utils import open_file
|
|
8
|
+
from .platform import permissions, icons
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
11
|
import pystray
|
|
@@ -36,7 +36,6 @@ class SystemTray:
|
|
|
36
36
|
self.icon = None # pystray object, holds menu, state, etc.
|
|
37
37
|
self.is_running = False
|
|
38
38
|
self.current_state = "idle"
|
|
39
|
-
self.thread = None
|
|
40
39
|
self.available = True
|
|
41
40
|
|
|
42
41
|
if self._check_tray_availability():
|
|
@@ -46,7 +45,7 @@ class SystemTray:
|
|
|
46
45
|
if not self.tray_config['enabled']:
|
|
47
46
|
self.logger.warning(" ✗ System tray disabled in configuration")
|
|
48
47
|
self.available = False
|
|
49
|
-
|
|
48
|
+
|
|
50
49
|
elif not TRAY_AVAILABLE:
|
|
51
50
|
self.logger.warning(" ✗ System tray not available - pystray or Pillow not installed")
|
|
52
51
|
self.available = False
|
|
@@ -55,31 +54,14 @@ class SystemTray:
|
|
|
55
54
|
|
|
56
55
|
def _load_icons_to_cache(self):
|
|
57
56
|
try:
|
|
58
|
-
self.icons =
|
|
59
|
-
|
|
60
|
-
icon_files = {
|
|
61
|
-
"idle": "assets/tray_idle.png",
|
|
62
|
-
"recording": "assets/tray_recording.png",
|
|
63
|
-
"processing": "assets/tray_processing.png"
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
for state, asset_path in icon_files.items():
|
|
67
|
-
icon_path = Path(resolve_asset_path(asset_path))
|
|
68
|
-
|
|
69
|
-
try:
|
|
70
|
-
if icon_path.exists():
|
|
71
|
-
self.icons[state] = Image.open(str(icon_path))
|
|
72
|
-
else:
|
|
73
|
-
self.icons[state] = self._create_fallback_icon(state)
|
|
74
|
-
self.logger.warning(f"Icon file not found, using fallback: {icon_path}")
|
|
75
|
-
|
|
76
|
-
except Exception as e:
|
|
77
|
-
self.logger.error(f"Failed to load icon {icon_path}: {e}")
|
|
78
|
-
self.icons[state] = self._create_fallback_icon(state)
|
|
79
|
-
|
|
57
|
+
self.icons = icons.get_tray_icons()
|
|
80
58
|
except Exception as e:
|
|
81
|
-
self.logger.error(f"Failed to load
|
|
82
|
-
self.
|
|
59
|
+
self.logger.error(f"Failed to load tray icons: {e}")
|
|
60
|
+
self.icons = {
|
|
61
|
+
"idle": self._create_fallback_icon("idle"),
|
|
62
|
+
"recording": self._create_fallback_icon("recording"),
|
|
63
|
+
"processing": self._create_fallback_icon("processing"),
|
|
64
|
+
}
|
|
83
65
|
|
|
84
66
|
def _create_fallback_icon(self, state: str) -> Image.Image:
|
|
85
67
|
colors = {
|
|
@@ -235,7 +217,7 @@ class SystemTray:
|
|
|
235
217
|
try:
|
|
236
218
|
print("⚙️ Opening log file...")
|
|
237
219
|
log_path = self.config_manager.get_log_file_path()
|
|
238
|
-
|
|
220
|
+
open_file(log_path)
|
|
239
221
|
except Exception as e:
|
|
240
222
|
self.logger.error(f"Failed to open log file: {e}")
|
|
241
223
|
|
|
@@ -243,11 +225,17 @@ class SystemTray:
|
|
|
243
225
|
try:
|
|
244
226
|
print("⚙️ Opening settings...")
|
|
245
227
|
config_path = self.config_manager.user_settings_path
|
|
246
|
-
|
|
228
|
+
open_file(config_path)
|
|
247
229
|
except Exception as e:
|
|
248
230
|
self.logger.error(f"Failed to open config file: {e}")
|
|
249
231
|
|
|
250
|
-
def _set_transcription_mode(self, auto_paste: bool):
|
|
232
|
+
def _set_transcription_mode(self, auto_paste: bool):
|
|
233
|
+
if auto_paste:
|
|
234
|
+
if not permissions.check_accessibility_permission():
|
|
235
|
+
if not permissions.handle_missing_permission(self.config_manager):
|
|
236
|
+
return
|
|
237
|
+
auto_paste = False
|
|
238
|
+
|
|
251
239
|
self.state_manager.update_transcription_mode(auto_paste)
|
|
252
240
|
self.icon.menu = self._create_menu()
|
|
253
241
|
|
|
@@ -307,58 +295,43 @@ class SystemTray:
|
|
|
307
295
|
except Exception as e:
|
|
308
296
|
self.logger.error(f"Failed to refresh tray menu: {e}")
|
|
309
297
|
|
|
310
|
-
def start(self):
|
|
298
|
+
def start(self):
|
|
311
299
|
if not self.available:
|
|
312
300
|
return False
|
|
313
|
-
|
|
301
|
+
|
|
314
302
|
if self.is_running:
|
|
315
303
|
self.logger.warning("System tray is already running")
|
|
316
304
|
return True
|
|
317
|
-
|
|
305
|
+
|
|
318
306
|
try:
|
|
319
|
-
idle_icon = self.icons.get("idle")
|
|
307
|
+
idle_icon = self.icons.get("idle")
|
|
320
308
|
menu = self._create_menu()
|
|
321
|
-
|
|
309
|
+
|
|
322
310
|
self.icon = pystray.Icon(
|
|
323
311
|
name="whisper-key",
|
|
324
312
|
icon=idle_icon,
|
|
325
313
|
title="Whisper Key",
|
|
326
314
|
menu=menu
|
|
327
315
|
)
|
|
328
|
-
|
|
329
|
-
self.
|
|
330
|
-
|
|
331
|
-
|
|
316
|
+
|
|
317
|
+
self.icon.run_detached()
|
|
318
|
+
|
|
332
319
|
self.is_running = True
|
|
333
320
|
print(" ✓ System tray icon is running...")
|
|
334
321
|
|
|
335
322
|
return True
|
|
336
|
-
|
|
323
|
+
|
|
337
324
|
except Exception as e:
|
|
338
325
|
self.logger.error(f"Failed to start system tray: {e}")
|
|
339
326
|
return False
|
|
340
327
|
|
|
341
|
-
def _run_tray(self):
|
|
342
|
-
try:
|
|
343
|
-
self.icon.run() # pystray provided loop method
|
|
344
|
-
except Exception as e:
|
|
345
|
-
self.logger.error(f"System tray thread error: {e}")
|
|
346
|
-
finally:
|
|
347
|
-
self.is_running = False
|
|
348
|
-
self.logger.debug("Tray icon thread ended")
|
|
349
|
-
|
|
350
328
|
def stop(self):
|
|
351
329
|
if not self.is_running:
|
|
352
330
|
return
|
|
353
|
-
|
|
331
|
+
|
|
354
332
|
try:
|
|
355
333
|
self.icon.stop()
|
|
356
|
-
|
|
357
|
-
# Wait for thread to finish to avoid deadlock
|
|
358
|
-
if self.thread and self.thread.is_alive() and self.thread != threading.current_thread():
|
|
359
|
-
self.thread.join(timeout=2.0)
|
|
360
|
-
|
|
361
334
|
self.is_running = False
|
|
362
|
-
|
|
335
|
+
|
|
363
336
|
except Exception as e:
|
|
364
337
|
self.logger.error(f"Error stopping system tray: {e}")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
CYAN = "\x1b[36m"
|
|
4
|
+
BOLD_CYAN = "\x1b[1;36m"
|
|
5
|
+
DIM = "\x1b[2m"
|
|
6
|
+
RESET = "\x1b[0m"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def getch():
|
|
10
|
+
try:
|
|
11
|
+
import tty
|
|
12
|
+
import termios
|
|
13
|
+
fd = sys.stdin.fileno()
|
|
14
|
+
old_settings = termios.tcgetattr(fd)
|
|
15
|
+
try:
|
|
16
|
+
tty.setraw(fd)
|
|
17
|
+
ch = sys.stdin.read(1)
|
|
18
|
+
finally:
|
|
19
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
20
|
+
return ch
|
|
21
|
+
except Exception:
|
|
22
|
+
line = input()
|
|
23
|
+
return line[0] if line else '\n'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prompt_choice(title: str, options: list[tuple[str, str]]) -> int:
|
|
27
|
+
all_texts = [title]
|
|
28
|
+
for main_text, desc in options:
|
|
29
|
+
all_texts.append(main_text)
|
|
30
|
+
if desc:
|
|
31
|
+
all_texts.append(" " + desc)
|
|
32
|
+
|
|
33
|
+
width = max(len(t) for t in all_texts) + 2
|
|
34
|
+
|
|
35
|
+
def pad(text):
|
|
36
|
+
return text + ' ' * (width - len(text))
|
|
37
|
+
|
|
38
|
+
def line(text=""):
|
|
39
|
+
return f"{CYAN} │{RESET} {pad(text)} {CYAN}│{RESET}"
|
|
40
|
+
|
|
41
|
+
def line_dim(text):
|
|
42
|
+
return f"{CYAN} │{RESET} {DIM}{pad(text)}{RESET} {CYAN}│{RESET}"
|
|
43
|
+
|
|
44
|
+
print()
|
|
45
|
+
print(f"{CYAN} ┌{'─' * (width + 2)}┐{RESET}")
|
|
46
|
+
print(f"{CYAN} │{RESET} {BOLD_CYAN}{pad(title)}{RESET} {CYAN}│{RESET}")
|
|
47
|
+
|
|
48
|
+
for i, (main_text, desc) in enumerate(options, 1):
|
|
49
|
+
print(line())
|
|
50
|
+
print(line(f"[{i}] {main_text}"))
|
|
51
|
+
if desc:
|
|
52
|
+
print(line_dim(" " + desc))
|
|
53
|
+
|
|
54
|
+
print(f"{CYAN} └{'─' * (width + 2)}┘{RESET}")
|
|
55
|
+
print()
|
|
56
|
+
print(" Press a number to choose: ", end="", flush=True)
|
|
57
|
+
|
|
58
|
+
valid_choices = {str(i): i for i in range(1, len(options) + 1)}
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
ch = getch()
|
|
63
|
+
if ch in valid_choices:
|
|
64
|
+
print(ch)
|
|
65
|
+
return valid_choices[ch]
|
|
66
|
+
if ch in ('\x03', '\x04'):
|
|
67
|
+
print()
|
|
68
|
+
return -1
|
|
69
|
+
except (KeyboardInterrupt, EOFError):
|
|
70
|
+
print()
|
|
71
|
+
return -1
|
whisper_key/utils.py
CHANGED
|
@@ -33,10 +33,14 @@ def is_installed_package():
|
|
|
33
33
|
return 'site-packages' in __file__
|
|
34
34
|
|
|
35
35
|
def get_user_app_data_path():
|
|
36
|
-
|
|
37
|
-
whisperkey_dir =
|
|
38
|
-
|
|
39
|
-
return whisperkey_dir
|
|
36
|
+
from .platform import paths
|
|
37
|
+
whisperkey_dir = paths.get_app_data_path()
|
|
38
|
+
whisperkey_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return str(whisperkey_dir)
|
|
40
|
+
|
|
41
|
+
def open_file(path):
|
|
42
|
+
from .platform import paths
|
|
43
|
+
paths.open_file(path)
|
|
40
44
|
|
|
41
45
|
def resolve_asset_path(relative_path: str) -> str:
|
|
42
46
|
|