-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtyper_controller.py
More file actions
301 lines (268 loc) · 11.2 KB
/
Copy pathtyper_controller.py
File metadata and controls
301 lines (268 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import time
import threading
import sys
import pyperclip
import subprocess
import os
# Conditional imports for cross-platform support
try:
if sys.platform.startswith('linux'):
import evdev
from evdev import ecodes
else:
evdev = None
except ImportError:
evdev = None
try:
from pynput import keyboard
from pynput.keyboard import Key, Controller
except ImportError:
keyboard = None
# Known terminal emulator WM_CLASS values (lowercase)
TERMINAL_WM_CLASSES = {
"gnome-terminal", "gnome-terminal-server", "konsole", "xfce4-terminal",
"lxterminal", "urxvt", "xterm", "rxvt", "alacritty", "kitty", "wezterm",
"foot", "footclient", "st", "st-256color", "termite", "terminator",
"tilix", "guake", "tilda", "yakuake", "cool-retro-term", "hyper",
"iterm2", "windows-terminal", "tabby", "warp", "rio", "ghostty",
}
class TyperController:
def __init__(self, on_press_callback, on_release_callback, hotkey="KEY_RIGHTALT", mode="hold", paste_method="auto"):
self.on_press_callback = on_press_callback
self.on_release_callback = on_release_callback
self.mode = mode
self.is_active = False
self.hotkey_str = hotkey
self.paste_method = paste_method
# Keyboard controller for typing (pynput is good for this everywhere)
self.controller = Controller() if keyboard else None
# State for listener
self.running = False
self.threads = []
# Map keys for different platforms
self._setup_platform_mapping()
def _setup_platform_mapping(self):
if not hasattr(self, 'uinput'):
self.uinput = None
if sys.platform.startswith('linux') and evdev:
# Linux (evdev for reliable global hotkeys on Wayland/X11)
self.trigger_code = getattr(ecodes, self.hotkey_str, ecodes.KEY_RIGHTALT)
self.use_evdev = True
if self.uinput is None:
try:
self.uinput = evdev.UInput()
except Exception as e:
print(f"Could not create UInput: {e}")
else:
# Windows/Mac (pynput)
mapping = {
"KEY_RIGHTALT": [Key.alt_gr, Key.alt_r],
"KEY_LEFTCTRL": [Key.ctrl_l],
"KEY_RIGHTCTRL": [Key.ctrl_r],
"KEY_SPACE": [Key.space],
"KEY_CAPSLOCK": [Key.caps_lock],
"KEY_F10": [Key.f10]
}
self.target_keys = mapping.get(self.hotkey_str, [Key.alt_gr])
self.use_evdev = False
def _is_terminal_focused(self):
if sys.platform != "linux":
return None
wm_class = None
# Try X11 method first
if "DISPLAY" in os.environ:
try:
result = subprocess.run(
["xdotool", "getactivewindow"],
capture_output=True, text=True, timeout=1
)
if result.returncode == 0:
wid = result.stdout.strip()
result = subprocess.run(
["xprop", "-id", wid, "WM_CLASS"],
capture_output=True, text=True, timeout=1
)
if result.returncode == 0 and result.stdout.strip():
# WM_CLASS format: "instance", "class"
parts = result.stdout.strip().split('"')
if len(parts) >= 4:
wm_class = parts[3].lower()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Wayland: try GNOME Shell via D-Bus
if wm_class is None:
try:
result = subprocess.run(
["gdbus", "call", "--session", "--dest", "org.gnome.Shell",
"--object-path", "/org/gnome/Shell",
"--method", "org.gnome.Shell.Introspect.GetWindows"],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0:
# Crude extraction — look for wm_class in the output
import re
matches = re.findall(r'"wm_class"\s*:\s*"([^"]+)"', result.stdout)
if matches:
wm_class = matches[0].lower()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Wayland: try KDE KWin via D-Bus
if wm_class is None:
try:
result = subprocess.run(
["qdbus", "org.kde.KWin", "/KWin", "org.kde.KWin.getActiveWindow"],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0 and result.stdout.strip():
# KWin returns a window ID; use xprop on it if XWayland
wid = result.stdout.strip()
if "DISPLAY" in os.environ:
result = subprocess.run(
["xprop", "-id", wid, "WM_CLASS"],
capture_output=True, text=True, timeout=1
)
if result.returncode == 0:
parts = result.stdout.strip().split('"')
if len(parts) >= 4:
wm_class = parts[3].lower()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
if wm_class is None:
return None
return wm_class in TERMINAL_WM_CLASSES
def update_settings(self, hotkey, mode, paste_method=None):
self.hotkey_str = hotkey
self.mode = mode
self.is_active = False
if paste_method is not None:
self.paste_method = paste_method
self._setup_platform_mapping()
def start_listening(self):
self.running = True
if self.use_evdev:
self._start_evdev_listening()
else:
self._start_pynput_listening()
def stop_listening(self):
self.running = False
if hasattr(self, 'pynput_listener') and self.pynput_listener:
self.pynput_listener.stop()
self.pynput_listener = None
# Evdev threads will exit on self.running = False
self.threads = []
# --- Linux (evdev) Implementation ---
def _start_evdev_listening(self):
kbd_devices = []
try:
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
for device in devices:
if ecodes.EV_KEY in device.capabilities():
kbd_devices.append(device)
except: pass
if not kbd_devices:
print("No keyboard devices found for evdev. Falling back to pynput.")
self.use_evdev = False
self._start_pynput_listening()
return
for device in kbd_devices:
t = threading.Thread(target=self._evdev_loop, args=(device,), daemon=True)
t.start()
self.threads.append(t)
def _evdev_loop(self, device):
try:
for event in device.read_loop():
if not self.running: break
if event.type == ecodes.EV_KEY and event.code == self.trigger_code:
if event.value == 1: # Press
if self.mode == "hold":
if not self.is_active:
self.is_active = True
self.on_press_callback()
else: # toggle
if not self.is_active:
self.is_active = True
self.on_press_callback()
else:
self.is_active = False
self.on_release_callback()
elif event.value == 0: # Release
if self.mode == "hold" and self.is_active:
self.is_active = False
self.on_release_callback()
except: pass
# --- Windows/Mac (pynput) Implementation ---
def _start_pynput_listening(self):
if not keyboard: return
self.pynput_listener = keyboard.Listener(
on_press=self._on_pynput_press,
on_release=self._on_pynput_release
)
self.pynput_listener.start()
def _on_pynput_press(self, key):
if key in self.target_keys:
if self.mode == "hold":
if not self.is_active:
self.is_active = True
self.on_press_callback()
else: # toggle
if not self.is_active:
self.is_active = True
self.on_press_callback()
else:
self.is_active = False
self.on_release_callback()
def _on_pynput_release(self, key):
if key in self.target_keys:
if self.mode == "hold" and self.is_active:
self.is_active = False
self.on_release_callback()
# --- Typing Implementation (Cross-platform bridge) ---
def _key_event(self, code, value):
self.uinput.write(ecodes.EV_KEY, code, value)
self.uinput.syn()
time.sleep(0.03)
def _paste_ctrl_v(self):
if self.use_evdev and hasattr(self, 'uinput') and self.uinput:
self._key_event(ecodes.KEY_LEFTCTRL, 1)
self._key_event(ecodes.KEY_V, 1)
self._key_event(ecodes.KEY_V, 0)
self._key_event(ecodes.KEY_LEFTCTRL, 0)
else:
modifier = Key.cmd if sys.platform == 'darwin' else Key.ctrl
with self.controller.pressed(modifier):
self.controller.press('v')
self.controller.release('v')
def _paste_ctrl_shift_v(self):
if self.use_evdev and hasattr(self, 'uinput') and self.uinput:
self._key_event(ecodes.KEY_LEFTCTRL, 1)
self._key_event(ecodes.KEY_LEFTSHIFT, 1)
self._key_event(ecodes.KEY_V, 1)
self._key_event(ecodes.KEY_V, 0)
self._key_event(ecodes.KEY_LEFTSHIFT, 0)
self._key_event(ecodes.KEY_LEFTCTRL, 0)
else:
modifier = Key.cmd if sys.platform == 'darwin' else Key.ctrl
with self.controller.pressed(modifier):
with self.controller.pressed(Key.shift):
self.controller.press('v')
self.controller.release('v')
def type_text(self, text):
if not text: return
time.sleep(0.2)
try:
pyperclip.copy(text + " ")
method = self.paste_method
if method == "auto":
is_terminal = self._is_terminal_focused()
if is_terminal is None:
method = "ctrl_v"
elif is_terminal:
method = "ctrl_shift_v"
else:
method = "ctrl_v"
if method == "ctrl_shift_v":
self._paste_ctrl_shift_v()
else:
self._paste_ctrl_v()
except Exception as e:
print(f"Typing error: {e}")