whisper-key-local 0.5.3__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.
Files changed (43) hide show
  1. whisper_key/assets/version.txt +1 -1
  2. whisper_key/audio_feedback.py +21 -20
  3. whisper_key/clipboard_manager.py +35 -52
  4. whisper_key/config.defaults.yaml +22 -13
  5. whisper_key/config_manager.py +25 -9
  6. whisper_key/console_manager.py +2 -58
  7. whisper_key/hotkey_listener.py +55 -89
  8. whisper_key/instance_manager.py +9 -13
  9. whisper_key/main.py +28 -11
  10. whisper_key/platform/__init__.py +10 -0
  11. whisper_key/platform/macos/__init__.py +1 -0
  12. whisper_key/platform/macos/app.py +27 -0
  13. whisper_key/platform/macos/console.py +13 -0
  14. whisper_key/platform/macos/hotkeys.py +180 -0
  15. whisper_key/platform/macos/icons.py +11 -0
  16. whisper_key/platform/macos/instance_lock.py +31 -0
  17. whisper_key/platform/macos/keyboard.py +97 -0
  18. whisper_key/platform/macos/keycodes.py +17 -0
  19. whisper_key/platform/macos/paths.py +8 -0
  20. whisper_key/platform/macos/permissions.py +66 -0
  21. whisper_key/platform/windows/__init__.py +1 -0
  22. whisper_key/platform/windows/app.py +6 -0
  23. whisper_key/platform/windows/console.py +59 -0
  24. whisper_key/platform/windows/hotkeys.py +30 -0
  25. whisper_key/platform/windows/icons.py +11 -0
  26. whisper_key/platform/windows/instance_lock.py +14 -0
  27. whisper_key/platform/windows/keyboard.py +12 -0
  28. whisper_key/platform/windows/paths.py +8 -0
  29. whisper_key/platform/windows/permissions.py +6 -0
  30. whisper_key/state_manager.py +4 -4
  31. whisper_key/system_tray.py +30 -57
  32. whisper_key/terminal_ui.py +71 -0
  33. whisper_key/utils.py +8 -4
  34. whisper_key_local-0.6.0.dist-info/METADATA +159 -0
  35. whisper_key_local-0.6.0.dist-info/RECORD +47 -0
  36. {whisper_key_local-0.5.3.dist-info → whisper_key_local-0.6.0.dist-info}/WHEEL +1 -1
  37. whisper_key/assets/tray_idle.png +0 -0
  38. whisper_key/assets/tray_processing.png +0 -0
  39. whisper_key/assets/tray_recording.png +0 -0
  40. whisper_key_local-0.5.3.dist-info/METADATA +0 -130
  41. whisper_key_local-0.5.3.dist-info/RECORD +0 -29
  42. {whisper_key_local-0.5.3.dist-info → whisper_key_local-0.6.0.dist-info}/entry_points.txt +0 -0
  43. {whisper_key_local-0.5.3.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,8 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ def get_app_data_path():
5
+ return Path.home() / 'Library' / 'Application Support' / 'whisperkey'
6
+
7
+ def open_file(path):
8
+ subprocess.run(['open', str(path)])
@@ -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,6 @@
1
+ def setup():
2
+ pass
3
+
4
+ def run_event_loop(shutdown_event):
5
+ while not shutdown_event.wait(timeout=0.1):
6
+ pass
@@ -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
@@ -0,0 +1,12 @@
1
+ import pyautogui
2
+
3
+ pyautogui.FAILSAFE = True
4
+
5
+ def set_delay(delay: float):
6
+ pyautogui.PAUSE = delay
7
+
8
+ def send_hotkey(*keys: str):
9
+ pyautogui.hotkey(*keys)
10
+
11
+ def send_key(key: str):
12
+ pyautogui.press(key)
@@ -0,0 +1,8 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ def get_app_data_path():
5
+ return Path(os.getenv('APPDATA')) / 'whisperkey'
6
+
7
+ def open_file(path):
8
+ os.startfile(path)
@@ -0,0 +1,6 @@
1
+ def check_accessibility_permission() -> bool:
2
+ return True
3
+
4
+
5
+ def handle_missing_permission(config_manager) -> bool:
6
+ return True
@@ -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
@@ -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 resolve_asset_path
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 system tray: {e}")
82
- self.available = False
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
- os.startfile(log_path)
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
- os.startfile(config_path)
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.thread = threading.Thread(target=self._run_tray, daemon=True)
330
- self.thread.start()
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
- appdata = os.getenv('APPDATA')
37
- whisperkey_dir = os.path.join(appdata, 'whisperkey')
38
- os.makedirs(whisperkey_dir, exist_ok=True)
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