openadapt-tray 0.0.1__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.
- openadapt_tray/__init__.py +16 -0
- openadapt_tray/__main__.py +10 -0
- openadapt_tray/app.py +336 -0
- openadapt_tray/config.py +124 -0
- openadapt_tray/icons.py +171 -0
- openadapt_tray/ipc.py +350 -0
- openadapt_tray/menu.py +298 -0
- openadapt_tray/notifications.py +410 -0
- openadapt_tray/platform/__init__.py +30 -0
- openadapt_tray/platform/base.py +87 -0
- openadapt_tray/platform/linux.py +226 -0
- openadapt_tray/platform/macos.py +183 -0
- openadapt_tray/platform/windows.py +164 -0
- openadapt_tray/shortcuts.py +148 -0
- openadapt_tray/state.py +100 -0
- openadapt_tray-0.0.1.dist-info/METADATA +259 -0
- openadapt_tray-0.0.1.dist-info/RECORD +19 -0
- openadapt_tray-0.0.1.dist-info/WHEEL +4 -0
- openadapt_tray-0.0.1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""OpenAdapt Tray - System tray application for OpenAdapt."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from openadapt_tray.app import TrayApplication, main
|
|
6
|
+
from openadapt_tray.state import TrayState, AppState, StateManager
|
|
7
|
+
from openadapt_tray.config import TrayConfig
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"TrayApplication",
|
|
11
|
+
"TrayState",
|
|
12
|
+
"AppState",
|
|
13
|
+
"StateManager",
|
|
14
|
+
"TrayConfig",
|
|
15
|
+
"main",
|
|
16
|
+
]
|
openadapt_tray/app.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Main system tray application for OpenAdapt."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import subprocess
|
|
6
|
+
import webbrowser
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import pystray
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from openadapt_tray.state import StateManager, TrayState, AppState
|
|
13
|
+
from openadapt_tray.menu import MenuBuilder
|
|
14
|
+
from openadapt_tray.icons import IconManager
|
|
15
|
+
from openadapt_tray.shortcuts import HotkeyManager
|
|
16
|
+
from openadapt_tray.notifications import NotificationManager
|
|
17
|
+
from openadapt_tray.ipc import IPCClient, IPCMessageType
|
|
18
|
+
from openadapt_tray.config import TrayConfig
|
|
19
|
+
from openadapt_tray.platform import get_platform_handler
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrayApplication:
|
|
23
|
+
"""Main system tray application."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: Optional[TrayConfig] = None):
|
|
26
|
+
"""Initialize the tray application.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
config: Optional configuration. If None, loads from file or defaults.
|
|
30
|
+
"""
|
|
31
|
+
self.config = config or TrayConfig.load()
|
|
32
|
+
self.state = StateManager()
|
|
33
|
+
self.platform = get_platform_handler()
|
|
34
|
+
|
|
35
|
+
# Initialize components
|
|
36
|
+
self.icons = IconManager()
|
|
37
|
+
self.notifications = NotificationManager()
|
|
38
|
+
self.menu_builder = MenuBuilder(self)
|
|
39
|
+
self.hotkeys = HotkeyManager(self.config.hotkeys)
|
|
40
|
+
self.ipc = IPCClient()
|
|
41
|
+
|
|
42
|
+
# Process handle for capture subprocess
|
|
43
|
+
self._capture_process: Optional[subprocess.Popen] = None
|
|
44
|
+
|
|
45
|
+
# Create tray icon
|
|
46
|
+
self.icon = pystray.Icon(
|
|
47
|
+
name="openadapt",
|
|
48
|
+
icon=self.icons.get(TrayState.IDLE),
|
|
49
|
+
title="OpenAdapt",
|
|
50
|
+
menu=self.menu_builder.build(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Set tray icon reference for Windows notifications
|
|
54
|
+
self.notifications.set_tray_icon(self.icon)
|
|
55
|
+
|
|
56
|
+
# Register state change handler
|
|
57
|
+
self.state.add_listener(self._on_state_change)
|
|
58
|
+
|
|
59
|
+
# Register IPC handlers
|
|
60
|
+
self._setup_ipc_handlers()
|
|
61
|
+
|
|
62
|
+
# Register hotkey handlers
|
|
63
|
+
self._setup_hotkeys()
|
|
64
|
+
|
|
65
|
+
def _setup_hotkeys(self) -> None:
|
|
66
|
+
"""Configure global hotkeys."""
|
|
67
|
+
self.hotkeys.register(
|
|
68
|
+
self.config.hotkeys.toggle_recording,
|
|
69
|
+
self._toggle_recording,
|
|
70
|
+
)
|
|
71
|
+
self.hotkeys.register(
|
|
72
|
+
self.config.hotkeys.open_dashboard,
|
|
73
|
+
self._open_dashboard,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Register triple-ctrl for stop recording (legacy compatibility)
|
|
77
|
+
if self.config.stop_on_triple_ctrl:
|
|
78
|
+
self.hotkeys.register(
|
|
79
|
+
"<ctrl>+<ctrl>+<ctrl>",
|
|
80
|
+
self._stop_recording_if_active,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _setup_ipc_handlers(self) -> None:
|
|
84
|
+
"""Configure IPC message handlers."""
|
|
85
|
+
self.ipc.register_handler(
|
|
86
|
+
IPCMessageType.RECORDING_STARTED,
|
|
87
|
+
self._on_ipc_recording_started,
|
|
88
|
+
)
|
|
89
|
+
self.ipc.register_handler(
|
|
90
|
+
IPCMessageType.RECORDING_STOPPED,
|
|
91
|
+
self._on_ipc_recording_stopped,
|
|
92
|
+
)
|
|
93
|
+
self.ipc.register_handler(
|
|
94
|
+
IPCMessageType.RECORDING_ERROR,
|
|
95
|
+
self._on_ipc_recording_error,
|
|
96
|
+
)
|
|
97
|
+
self.ipc.register_handler(
|
|
98
|
+
IPCMessageType.STATUS_UPDATE,
|
|
99
|
+
self._on_ipc_status_update,
|
|
100
|
+
)
|
|
101
|
+
self.ipc.register_handler(
|
|
102
|
+
IPCMessageType.TRAINING_PROGRESS,
|
|
103
|
+
self._on_ipc_training_progress,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _on_state_change(self, state: AppState) -> None:
|
|
107
|
+
"""Handle state changes.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
state: New application state.
|
|
111
|
+
"""
|
|
112
|
+
# Update icon
|
|
113
|
+
self.icon.icon = self.icons.get(state.state)
|
|
114
|
+
|
|
115
|
+
# Update menu
|
|
116
|
+
self.icon.menu = self.menu_builder.build()
|
|
117
|
+
|
|
118
|
+
# Show notification if appropriate
|
|
119
|
+
if self.config.show_notifications:
|
|
120
|
+
self._show_state_notification(state)
|
|
121
|
+
|
|
122
|
+
def _show_state_notification(self, state: AppState) -> None:
|
|
123
|
+
"""Show notification for state transitions.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
state: Current application state.
|
|
127
|
+
"""
|
|
128
|
+
messages = {
|
|
129
|
+
TrayState.RECORDING: (
|
|
130
|
+
"Recording Started",
|
|
131
|
+
f"Capturing: {state.current_capture or 'session'}",
|
|
132
|
+
),
|
|
133
|
+
TrayState.IDLE: ("Recording Stopped", "Capture saved"),
|
|
134
|
+
TrayState.TRAINING: ("Training Started", "Model training in progress"),
|
|
135
|
+
TrayState.ERROR: ("Error", state.error_message or "An error occurred"),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if state.state in messages:
|
|
139
|
+
title, body = messages[state.state]
|
|
140
|
+
self.notifications.show(
|
|
141
|
+
title,
|
|
142
|
+
body,
|
|
143
|
+
duration_ms=self.config.notification_duration_ms,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _toggle_recording(self) -> None:
|
|
147
|
+
"""Toggle recording state."""
|
|
148
|
+
if self.state.current.can_start_recording():
|
|
149
|
+
self.start_recording()
|
|
150
|
+
elif self.state.current.can_stop_recording():
|
|
151
|
+
self.stop_recording()
|
|
152
|
+
|
|
153
|
+
def _stop_recording_if_active(self) -> None:
|
|
154
|
+
"""Stop recording if currently active (for triple-ctrl)."""
|
|
155
|
+
if self.state.current.can_stop_recording():
|
|
156
|
+
self.stop_recording()
|
|
157
|
+
|
|
158
|
+
def start_recording(self, name: Optional[str] = None) -> None:
|
|
159
|
+
"""Start a new capture session.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
name: Optional name for the capture. If None, prompts user.
|
|
163
|
+
"""
|
|
164
|
+
if not self.state.current.can_start_recording():
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Prompt for name if not provided
|
|
168
|
+
if name is None and self.config.use_native_dialogs:
|
|
169
|
+
name = self.platform.prompt_input(
|
|
170
|
+
"New Recording",
|
|
171
|
+
"Enter a name for this capture:",
|
|
172
|
+
)
|
|
173
|
+
if not name:
|
|
174
|
+
return # User cancelled
|
|
175
|
+
|
|
176
|
+
# Use default name if still not set
|
|
177
|
+
if not name:
|
|
178
|
+
from datetime import datetime
|
|
179
|
+
|
|
180
|
+
name = datetime.now().strftime("capture_%Y%m%d_%H%M%S")
|
|
181
|
+
|
|
182
|
+
self.state.transition(TrayState.RECORDING_STARTING, current_capture=name)
|
|
183
|
+
|
|
184
|
+
# Start capture in background thread
|
|
185
|
+
threading.Thread(
|
|
186
|
+
target=self._run_capture,
|
|
187
|
+
args=(name,),
|
|
188
|
+
daemon=True,
|
|
189
|
+
).start()
|
|
190
|
+
|
|
191
|
+
def _run_capture(self, name: str) -> None:
|
|
192
|
+
"""Run capture in background thread.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
name: Capture session name.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
# Start capture via CLI subprocess
|
|
199
|
+
self._capture_process = subprocess.Popen(
|
|
200
|
+
["openadapt", "record", name],
|
|
201
|
+
stdout=subprocess.PIPE,
|
|
202
|
+
stderr=subprocess.PIPE,
|
|
203
|
+
)
|
|
204
|
+
self.state.transition(TrayState.RECORDING, current_capture=name)
|
|
205
|
+
|
|
206
|
+
# Wait for process to complete (when stopped externally)
|
|
207
|
+
self._capture_process.wait()
|
|
208
|
+
|
|
209
|
+
# Check if we're still in recording state (vs explicit stop)
|
|
210
|
+
if self.state.current.state == TrayState.RECORDING:
|
|
211
|
+
self.state.transition(TrayState.IDLE)
|
|
212
|
+
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
self.state.transition(
|
|
215
|
+
TrayState.ERROR,
|
|
216
|
+
error_message="openadapt CLI not found. Please install openadapt.",
|
|
217
|
+
)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
self.state.transition(TrayState.ERROR, error_message=str(e))
|
|
220
|
+
|
|
221
|
+
def stop_recording(self) -> None:
|
|
222
|
+
"""Stop the current capture session."""
|
|
223
|
+
if not self.state.current.can_stop_recording():
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
self.state.transition(TrayState.RECORDING_STOPPING)
|
|
227
|
+
|
|
228
|
+
# Terminate capture process
|
|
229
|
+
if self._capture_process and self._capture_process.poll() is None:
|
|
230
|
+
try:
|
|
231
|
+
# Send SIGTERM for graceful shutdown
|
|
232
|
+
self._capture_process.terminate()
|
|
233
|
+
|
|
234
|
+
# Wait briefly for process to cleanup
|
|
235
|
+
try:
|
|
236
|
+
self._capture_process.wait(timeout=5)
|
|
237
|
+
except subprocess.TimeoutExpired:
|
|
238
|
+
# Force kill if not responding
|
|
239
|
+
self._capture_process.kill()
|
|
240
|
+
except Exception as e:
|
|
241
|
+
print(f"Error stopping capture process: {e}")
|
|
242
|
+
|
|
243
|
+
self._capture_process = None
|
|
244
|
+
self.state.transition(TrayState.IDLE)
|
|
245
|
+
|
|
246
|
+
def _open_dashboard(self) -> None:
|
|
247
|
+
"""Open the web dashboard."""
|
|
248
|
+
webbrowser.open(f"http://localhost:{self.config.dashboard_port}")
|
|
249
|
+
|
|
250
|
+
# IPC event handlers
|
|
251
|
+
|
|
252
|
+
def _on_ipc_recording_started(self, message) -> None:
|
|
253
|
+
"""Handle recording started IPC event."""
|
|
254
|
+
data = message.data or {}
|
|
255
|
+
self.state.transition(
|
|
256
|
+
TrayState.RECORDING,
|
|
257
|
+
current_capture=data.get("name"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _on_ipc_recording_stopped(self, message) -> None:
|
|
261
|
+
"""Handle recording stopped IPC event."""
|
|
262
|
+
self.state.transition(TrayState.IDLE)
|
|
263
|
+
|
|
264
|
+
def _on_ipc_recording_error(self, message) -> None:
|
|
265
|
+
"""Handle recording error IPC event."""
|
|
266
|
+
data = message.data or {}
|
|
267
|
+
self.state.transition(
|
|
268
|
+
TrayState.ERROR,
|
|
269
|
+
error_message=data.get("error", "Recording error"),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _on_ipc_status_update(self, message) -> None:
|
|
273
|
+
"""Handle status update IPC event."""
|
|
274
|
+
# Generic status updates - could update UI or log
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
def _on_ipc_training_progress(self, message) -> None:
|
|
278
|
+
"""Handle training progress IPC event."""
|
|
279
|
+
data = message.data or {}
|
|
280
|
+
progress = data.get("progress", 0)
|
|
281
|
+
self.state.transition(
|
|
282
|
+
TrayState.TRAINING,
|
|
283
|
+
training_progress=progress,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def run(self) -> None:
|
|
287
|
+
"""Run the application."""
|
|
288
|
+
# Start hotkey listener
|
|
289
|
+
self.hotkeys.start()
|
|
290
|
+
|
|
291
|
+
# Platform-specific setup
|
|
292
|
+
self.platform.setup()
|
|
293
|
+
|
|
294
|
+
# Try to connect to IPC server (optional - won't block if unavailable)
|
|
295
|
+
try:
|
|
296
|
+
connected = self.ipc.connect()
|
|
297
|
+
if connected:
|
|
298
|
+
print("IPC connected successfully")
|
|
299
|
+
else:
|
|
300
|
+
print("IPC server not available - running in standalone mode")
|
|
301
|
+
except Exception as e:
|
|
302
|
+
print(f"IPC connection failed: {e} - running in standalone mode")
|
|
303
|
+
|
|
304
|
+
# Run the tray icon (blocks)
|
|
305
|
+
self.icon.run()
|
|
306
|
+
|
|
307
|
+
def quit(self) -> None:
|
|
308
|
+
"""Quit the application."""
|
|
309
|
+
# Stop any active recording
|
|
310
|
+
if self.state.current.is_recording():
|
|
311
|
+
self.stop_recording()
|
|
312
|
+
|
|
313
|
+
# Cleanup components
|
|
314
|
+
self.hotkeys.stop()
|
|
315
|
+
self.ipc.close()
|
|
316
|
+
self.notifications.cleanup()
|
|
317
|
+
self.platform.cleanup()
|
|
318
|
+
|
|
319
|
+
# Stop tray icon
|
|
320
|
+
self.icon.stop()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main() -> None:
|
|
324
|
+
"""Entry point for the tray application."""
|
|
325
|
+
app = TrayApplication()
|
|
326
|
+
try:
|
|
327
|
+
app.run()
|
|
328
|
+
except KeyboardInterrupt:
|
|
329
|
+
app.quit()
|
|
330
|
+
except Exception as e:
|
|
331
|
+
print(f"Fatal error: {e}")
|
|
332
|
+
sys.exit(1)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
main()
|
openadapt_tray/config.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Configuration management for OpenAdapt Tray."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Any, Dict
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from openadapt_tray.shortcuts import HotkeyConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TrayConfig:
|
|
14
|
+
"""Tray application configuration."""
|
|
15
|
+
|
|
16
|
+
# Hotkeys
|
|
17
|
+
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
|
|
18
|
+
|
|
19
|
+
# Paths
|
|
20
|
+
captures_directory: str = "~/openadapt/captures"
|
|
21
|
+
training_output_directory: str = "~/openadapt/training"
|
|
22
|
+
|
|
23
|
+
# Dashboard
|
|
24
|
+
dashboard_port: int = 8080
|
|
25
|
+
auto_launch_dashboard: bool = True
|
|
26
|
+
|
|
27
|
+
# Behavior
|
|
28
|
+
auto_start_on_login: bool = False
|
|
29
|
+
minimize_to_tray: bool = True
|
|
30
|
+
show_notifications: bool = True
|
|
31
|
+
notification_duration_ms: int = 5000
|
|
32
|
+
|
|
33
|
+
# Recording
|
|
34
|
+
default_record_audio: bool = True
|
|
35
|
+
default_transcribe: bool = True
|
|
36
|
+
stop_on_triple_ctrl: bool = True
|
|
37
|
+
|
|
38
|
+
# Appearance
|
|
39
|
+
use_native_dialogs: bool = True
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def config_path(cls) -> Path:
|
|
43
|
+
"""Get configuration file path."""
|
|
44
|
+
# Use XDG_CONFIG_HOME on Linux, ~/Library/Application Support on macOS,
|
|
45
|
+
# %APPDATA% on Windows
|
|
46
|
+
if os.name == "nt":
|
|
47
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
48
|
+
elif os.name == "posix":
|
|
49
|
+
if "darwin" in os.sys.platform:
|
|
50
|
+
base = Path.home() / "Library" / "Application Support"
|
|
51
|
+
else:
|
|
52
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
53
|
+
else:
|
|
54
|
+
base = Path.home() / ".config"
|
|
55
|
+
|
|
56
|
+
return base / "openadapt" / "tray.json"
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def load(cls) -> "TrayConfig":
|
|
60
|
+
"""Load configuration from file."""
|
|
61
|
+
path = cls.config_path()
|
|
62
|
+
if path.exists():
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(path.read_text())
|
|
65
|
+
return cls._from_dict(data)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"Warning: Could not load config: {e}")
|
|
68
|
+
return cls()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _from_dict(cls, data: Dict[str, Any]) -> "TrayConfig":
|
|
72
|
+
"""Create a TrayConfig from a dictionary."""
|
|
73
|
+
hotkeys_data = data.pop("hotkeys", {})
|
|
74
|
+
hotkeys = HotkeyConfig(
|
|
75
|
+
toggle_recording=hotkeys_data.get(
|
|
76
|
+
"toggle_recording", HotkeyConfig.toggle_recording
|
|
77
|
+
),
|
|
78
|
+
open_dashboard=hotkeys_data.get(
|
|
79
|
+
"open_dashboard", HotkeyConfig.open_dashboard
|
|
80
|
+
),
|
|
81
|
+
stop_recording=hotkeys_data.get(
|
|
82
|
+
"stop_recording", HotkeyConfig.stop_recording
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return cls(hotkeys=hotkeys, **data)
|
|
87
|
+
|
|
88
|
+
def save(self) -> None:
|
|
89
|
+
"""Save configuration to file."""
|
|
90
|
+
path = self.config_path()
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
data = self.to_dict()
|
|
94
|
+
path.write_text(json.dumps(data, indent=2))
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
97
|
+
"""Convert configuration to a dictionary."""
|
|
98
|
+
return {
|
|
99
|
+
"hotkeys": {
|
|
100
|
+
"toggle_recording": self.hotkeys.toggle_recording,
|
|
101
|
+
"open_dashboard": self.hotkeys.open_dashboard,
|
|
102
|
+
"stop_recording": self.hotkeys.stop_recording,
|
|
103
|
+
},
|
|
104
|
+
"captures_directory": self.captures_directory,
|
|
105
|
+
"training_output_directory": self.training_output_directory,
|
|
106
|
+
"dashboard_port": self.dashboard_port,
|
|
107
|
+
"auto_launch_dashboard": self.auto_launch_dashboard,
|
|
108
|
+
"auto_start_on_login": self.auto_start_on_login,
|
|
109
|
+
"minimize_to_tray": self.minimize_to_tray,
|
|
110
|
+
"show_notifications": self.show_notifications,
|
|
111
|
+
"notification_duration_ms": self.notification_duration_ms,
|
|
112
|
+
"default_record_audio": self.default_record_audio,
|
|
113
|
+
"default_transcribe": self.default_transcribe,
|
|
114
|
+
"stop_on_triple_ctrl": self.stop_on_triple_ctrl,
|
|
115
|
+
"use_native_dialogs": self.use_native_dialogs,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def get_captures_path(self) -> Path:
|
|
119
|
+
"""Get the expanded captures directory path."""
|
|
120
|
+
return Path(self.captures_directory).expanduser()
|
|
121
|
+
|
|
122
|
+
def get_training_path(self) -> Path:
|
|
123
|
+
"""Get the expanded training output directory path."""
|
|
124
|
+
return Path(self.training_output_directory).expanduser()
|
openadapt_tray/icons.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Icon management for OpenAdapt Tray."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Dict
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from openadapt_tray.state import TrayState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IconManager:
|
|
13
|
+
"""Manages loading and caching of tray icons."""
|
|
14
|
+
|
|
15
|
+
# State to icon filename mapping
|
|
16
|
+
STATE_ICONS: Dict[TrayState, str] = {
|
|
17
|
+
TrayState.IDLE: "idle.png",
|
|
18
|
+
TrayState.RECORDING_STARTING: "recording.png",
|
|
19
|
+
TrayState.RECORDING: "recording.png",
|
|
20
|
+
TrayState.RECORDING_STOPPING: "recording.png",
|
|
21
|
+
TrayState.TRAINING: "training.png",
|
|
22
|
+
TrayState.TRAINING_PAUSED: "training.png",
|
|
23
|
+
TrayState.ERROR: "error.png",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Icon colors for fallback generation
|
|
27
|
+
STATE_COLORS: Dict[TrayState, str] = {
|
|
28
|
+
TrayState.IDLE: "#4A90D9", # Blue
|
|
29
|
+
TrayState.RECORDING_STARTING: "#F5A623", # Yellow/Orange
|
|
30
|
+
TrayState.RECORDING: "#D0021B", # Red
|
|
31
|
+
TrayState.RECORDING_STOPPING: "#F5A623", # Yellow/Orange
|
|
32
|
+
TrayState.TRAINING: "#7B68EE", # Purple
|
|
33
|
+
TrayState.TRAINING_PAUSED: "#9B59B6", # Lighter Purple
|
|
34
|
+
TrayState.ERROR: "#D0021B", # Red
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def __init__(self, assets_dir: Optional[Path] = None):
|
|
38
|
+
"""Initialize the icon manager.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
assets_dir: Path to the assets directory. If None, uses package assets.
|
|
42
|
+
"""
|
|
43
|
+
if assets_dir is None:
|
|
44
|
+
# Find assets directory relative to this module
|
|
45
|
+
module_dir = Path(__file__).parent
|
|
46
|
+
assets_dir = module_dir.parent.parent / "assets" / "icons"
|
|
47
|
+
|
|
48
|
+
self.assets_dir = assets_dir
|
|
49
|
+
self._cache: Dict[str, Image.Image] = {}
|
|
50
|
+
self._retina_scale = self._detect_retina()
|
|
51
|
+
|
|
52
|
+
def _detect_retina(self) -> int:
|
|
53
|
+
"""Detect if we're on a retina/HiDPI display."""
|
|
54
|
+
if sys.platform == "darwin":
|
|
55
|
+
# On macOS, assume retina by default for newer systems
|
|
56
|
+
return 2
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
def get(self, state: TrayState) -> Image.Image:
|
|
60
|
+
"""Get the icon for a given state.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
state: The application state.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
PIL Image for the icon.
|
|
67
|
+
"""
|
|
68
|
+
icon_name = self.STATE_ICONS.get(state, "idle.png")
|
|
69
|
+
|
|
70
|
+
# Check cache first
|
|
71
|
+
cache_key = f"{icon_name}_{self._retina_scale}"
|
|
72
|
+
if cache_key in self._cache:
|
|
73
|
+
return self._cache[cache_key]
|
|
74
|
+
|
|
75
|
+
# Try to load icon file
|
|
76
|
+
icon = self._load_icon(icon_name)
|
|
77
|
+
if icon is None:
|
|
78
|
+
# Generate fallback icon
|
|
79
|
+
icon = self._generate_fallback(state)
|
|
80
|
+
|
|
81
|
+
self._cache[cache_key] = icon
|
|
82
|
+
return icon
|
|
83
|
+
|
|
84
|
+
def _load_icon(self, icon_name: str) -> Optional[Image.Image]:
|
|
85
|
+
"""Try to load an icon from the assets directory.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
icon_name: The icon filename.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
PIL Image if found, None otherwise.
|
|
92
|
+
"""
|
|
93
|
+
if not self.assets_dir.exists():
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Try retina version first on HiDPI displays
|
|
97
|
+
if self._retina_scale > 1:
|
|
98
|
+
retina_name = icon_name.replace(".png", "@2x.png")
|
|
99
|
+
retina_path = self.assets_dir / retina_name
|
|
100
|
+
if retina_path.exists():
|
|
101
|
+
try:
|
|
102
|
+
return Image.open(retina_path)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# Try regular version
|
|
107
|
+
icon_path = self.assets_dir / icon_name
|
|
108
|
+
if icon_path.exists():
|
|
109
|
+
try:
|
|
110
|
+
return Image.open(icon_path)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def _generate_fallback(self, state: TrayState, size: int = 64) -> Image.Image:
|
|
117
|
+
"""Generate a simple colored square icon as fallback.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
state: The application state.
|
|
121
|
+
size: The icon size in pixels.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Generated PIL Image.
|
|
125
|
+
"""
|
|
126
|
+
color = self.STATE_COLORS.get(state, "#4A90D9")
|
|
127
|
+
return self.create_colored_icon(color, size)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def create_colored_icon(color: str, size: int = 64) -> Image.Image:
|
|
131
|
+
"""Create a simple colored square icon.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
color: Hex color string (e.g., "#FF0000").
|
|
135
|
+
size: Icon size in pixels.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
PIL Image with the specified color.
|
|
139
|
+
"""
|
|
140
|
+
# Create RGBA image with rounded corners effect
|
|
141
|
+
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
142
|
+
|
|
143
|
+
# Parse hex color
|
|
144
|
+
if color.startswith("#"):
|
|
145
|
+
color = color[1:]
|
|
146
|
+
r = int(color[0:2], 16)
|
|
147
|
+
g = int(color[2:4], 16)
|
|
148
|
+
b = int(color[4:6], 16)
|
|
149
|
+
|
|
150
|
+
# Draw a filled circle for a cleaner look
|
|
151
|
+
from PIL import ImageDraw
|
|
152
|
+
|
|
153
|
+
draw = ImageDraw.Draw(image)
|
|
154
|
+
|
|
155
|
+
# Draw circle with slight padding
|
|
156
|
+
padding = size // 8
|
|
157
|
+
draw.ellipse(
|
|
158
|
+
[padding, padding, size - padding, size - padding],
|
|
159
|
+
fill=(r, g, b, 255),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return image
|
|
163
|
+
|
|
164
|
+
def clear_cache(self) -> None:
|
|
165
|
+
"""Clear the icon cache."""
|
|
166
|
+
self._cache.clear()
|
|
167
|
+
|
|
168
|
+
def preload_all(self) -> None:
|
|
169
|
+
"""Preload all state icons into cache."""
|
|
170
|
+
for state in TrayState:
|
|
171
|
+
self.get(state)
|