plover 5.0.0.dev3__py3-none-any.whl → 5.1.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.
- plover/__init__.py +1 -1
- plover/dictionary/loading_manager.py +22 -5
- plover/engine.py +11 -11
- plover/exception.py +0 -12
- plover/gui_qt/about_dialog_ui.py +1 -1
- plover/gui_qt/add_translation_dialog_ui.py +1 -1
- plover/gui_qt/add_translation_widget_ui.py +1 -1
- plover/gui_qt/config_file_widget_ui.py +1 -1
- plover/gui_qt/config_keyboard_widget_ui.py +1 -1
- plover/gui_qt/config_plover_hid_widget_ui.py +110 -0
- plover/gui_qt/config_serial_widget_ui.py +1 -1
- plover/gui_qt/config_window_ui.py +1 -1
- plover/gui_qt/console_widget_ui.py +1 -1
- plover/gui_qt/dictionaries_widget.py +9 -0
- plover/gui_qt/dictionaries_widget_ui.py +1 -1
- plover/gui_qt/dictionary_editor_ui.py +1 -1
- plover/gui_qt/engine.py +6 -1
- plover/gui_qt/lookup_dialog_ui.py +1 -1
- plover/gui_qt/machine_options.py +53 -1
- plover/gui_qt/main_window_ui.py +1 -1
- plover/gui_qt/paper_tape_ui.py +1 -1
- plover/gui_qt/plugins_manager_ui.py +1 -1
- plover/gui_qt/resources_rc.py +29 -29
- plover/gui_qt/run_dialog_ui.py +1 -1
- plover/gui_qt/suggestions_dialog_ui.py +1 -1
- plover/machine/plover_hid.py +312 -0
- plover/machine/procat.py +1 -1
- plover/oslayer/linux/keyboardcontrol_uinput.py +137 -37
- plover/oslayer/osx/keyboardlayout.py +4 -1
- plover/oslayer/windows/log.py +28 -6
- plover/system/english_stenotype.py +53 -0
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/METADATA +2 -1
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/RECORD +43 -40
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/entry_points.txt +5 -3
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/licenses/LICENSE.txt +4 -5
- plover_build_utils/deps.sh +2 -0
- plover_build_utils/download.py +5 -2
- plover_build_utils/functions.sh +80 -17
- /plover/machine/{geminipr.py → gemini_pr.py} +0 -0
- /plover/machine/{txbolt.py → tx_bolt.py} +0 -0
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/WHEEL +0 -0
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/top_level.txt +0 -0
- {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/zip-safe +0 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread-based monitoring of a Plover HID stenotype machine.
|
|
3
|
+
|
|
4
|
+
This implementation is based on the plover-machine-hid plugin created by dnaq (MIT license):
|
|
5
|
+
https://github.com/dnaq/plover-machine-hid
|
|
6
|
+
|
|
7
|
+
Plover HID is a simple HID-based protocol that sends the current state
|
|
8
|
+
of the steno machine every time that state changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from plover.machine.base import ThreadedStenotypeBase
|
|
12
|
+
from plover.misc import boolean
|
|
13
|
+
from plover import log
|
|
14
|
+
|
|
15
|
+
import hid
|
|
16
|
+
import time
|
|
17
|
+
import platform
|
|
18
|
+
import ctypes
|
|
19
|
+
import threading
|
|
20
|
+
from queue import Queue, Empty
|
|
21
|
+
from typing import Dict
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _darwin_disable_exclusive_open():
|
|
26
|
+
lib = getattr(hid, "hidapi", None)
|
|
27
|
+
if lib is not None:
|
|
28
|
+
func = getattr(lib, "hid_darwin_set_open_exclusive")
|
|
29
|
+
func.argtypes = (ctypes.c_int,)
|
|
30
|
+
func.restype = None
|
|
31
|
+
func(0)
|
|
32
|
+
else:
|
|
33
|
+
log.warning(
|
|
34
|
+
"could not call hid_darwin_set_open_exclusive; HID may open exclusively"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Invoke once at import time (before any devices are opened)
|
|
39
|
+
if platform.system() == "Darwin":
|
|
40
|
+
_darwin_disable_exclusive_open()
|
|
41
|
+
|
|
42
|
+
USAGE_PAGE: int = 0xFF50
|
|
43
|
+
USAGE: int = 0x4C56
|
|
44
|
+
|
|
45
|
+
N_LEVERS: int = 64
|
|
46
|
+
|
|
47
|
+
# A simple report contains the report id 1 and one bit
|
|
48
|
+
# for each of the 64 buttons in the report.
|
|
49
|
+
SIMPLE_REPORT_TYPE: int = 0x01
|
|
50
|
+
SIMPLE_REPORT_LEN: int = N_LEVERS // 8
|
|
51
|
+
|
|
52
|
+
# fmt: off
|
|
53
|
+
STENO_KEY_CHART = (
|
|
54
|
+
"S1-", "T-", "K-", "P-", "W-", "H-", "R-", "A-",
|
|
55
|
+
"O-", "*1", "-E", "-U", "-F", "-R", "-P", "-B",
|
|
56
|
+
"-L", "-G", "-T", "-S", "-D", "-Z", "#1", "S2-",
|
|
57
|
+
"*2", "*3", "*4", "#2", "#3", "#4", "#5", "#6",
|
|
58
|
+
"#7", "#8", "#9", "#A", "#B", "#C", "X1", "X2",
|
|
59
|
+
"X3", "X4", "X5", "X6", "X7", "X8", "X9", "X10",
|
|
60
|
+
"X11", "X12", "X13", "X14", "X15", "X16", "X17", "X18",
|
|
61
|
+
"X19", "X20", "X21", "X22", "X23", "X24", "X25", "X26",
|
|
62
|
+
)
|
|
63
|
+
# fmt: on
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InvalidReport(Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class HidDeviceRecord:
|
|
72
|
+
device: hid.Device
|
|
73
|
+
thread: threading.Thread
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PloverHid(ThreadedStenotypeBase):
|
|
77
|
+
# fmt: off
|
|
78
|
+
KEYS_LAYOUT: str = '''
|
|
79
|
+
#1 #2 #3 #4 #5 #6 #7 #8 #9 #A #B #C
|
|
80
|
+
S1- T- P- H- *1 *3 -F -P -L -T -D
|
|
81
|
+
S2- K- W- R- *2 *4 -R -B -G -S -Z
|
|
82
|
+
A- O- -E -U
|
|
83
|
+
X1 X2 X3 X4 X5 X6 X7 X8 X9 X10
|
|
84
|
+
X11 X12 X13 X14 X15 X16 X17 X18 X19 X20
|
|
85
|
+
X21 X22 X23 X24 X25 X26
|
|
86
|
+
'''
|
|
87
|
+
# fmt: on
|
|
88
|
+
|
|
89
|
+
def __init__(self, params):
|
|
90
|
+
super().__init__()
|
|
91
|
+
self._params = params
|
|
92
|
+
self._devices: Dict[str, HidDeviceRecord] = {}
|
|
93
|
+
self._report_queue: Queue[bytes] = Queue()
|
|
94
|
+
self._lock: threading.Lock = threading.Lock()
|
|
95
|
+
self._device_watcher: threading.Thread | None = None
|
|
96
|
+
|
|
97
|
+
def _add_device(self, path):
|
|
98
|
+
"""Open a HID device at `path`, start its reader, and mark ready if first."""
|
|
99
|
+
try:
|
|
100
|
+
device = hid.Device(path=path)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
log.debug(f"open failed for {path!r}: {e}")
|
|
103
|
+
return False
|
|
104
|
+
was_empty = len(self._devices) == 0
|
|
105
|
+
thread = threading.Thread(
|
|
106
|
+
target=self._read_from_device_loop, args=(path, device), daemon=True
|
|
107
|
+
)
|
|
108
|
+
self._devices[path] = HidDeviceRecord(device=device, thread=thread)
|
|
109
|
+
thread.start()
|
|
110
|
+
if was_empty:
|
|
111
|
+
# We were previously in a disconnected/error state; now we're ready.
|
|
112
|
+
self._ready()
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def _remove_device(self, path):
|
|
116
|
+
"""Close and forget a HID device by path; if none remain, mark disconnected."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
entry = self._devices.pop(path, None)
|
|
119
|
+
if not entry:
|
|
120
|
+
return
|
|
121
|
+
device = entry.device
|
|
122
|
+
thread = entry.thread
|
|
123
|
+
# Closing the device will unblock any pending read in the reader thread.
|
|
124
|
+
try:
|
|
125
|
+
device.close()
|
|
126
|
+
except Exception:
|
|
127
|
+
log.debug("failed to close HID device")
|
|
128
|
+
pass
|
|
129
|
+
# Join the reader if we're not currently in that same thread.
|
|
130
|
+
try:
|
|
131
|
+
if thread is not None and thread is not threading.current_thread():
|
|
132
|
+
thread.join(timeout=0.2)
|
|
133
|
+
except Exception:
|
|
134
|
+
log.debug("failed kill device read thread")
|
|
135
|
+
pass
|
|
136
|
+
# If nothing left and we're not shutting down, show Disconnected in the UI
|
|
137
|
+
if not self._devices and not self.finished.is_set():
|
|
138
|
+
self._error()
|
|
139
|
+
|
|
140
|
+
def _scan_device_loop(self):
|
|
141
|
+
"""Scan for new matching HID devices and start readers for them."""
|
|
142
|
+
scan_ms = self._params["device_scan_interval_ms"]
|
|
143
|
+
while not self.finished.is_set():
|
|
144
|
+
try:
|
|
145
|
+
paths = [
|
|
146
|
+
d["path"]
|
|
147
|
+
for d in hid.enumerate()
|
|
148
|
+
if d.get("usage_page") == USAGE_PAGE and d.get("usage") == USAGE
|
|
149
|
+
]
|
|
150
|
+
except Exception as e:
|
|
151
|
+
log.debug(f"device scan enumerate failed: {e}")
|
|
152
|
+
paths = []
|
|
153
|
+
|
|
154
|
+
with self._lock:
|
|
155
|
+
for path in paths:
|
|
156
|
+
if path in self._devices:
|
|
157
|
+
continue
|
|
158
|
+
# Call outside lock to avoid holding it during open/start
|
|
159
|
+
for path in paths:
|
|
160
|
+
if path in self._devices:
|
|
161
|
+
continue
|
|
162
|
+
if self._add_device(path):
|
|
163
|
+
log.debug("device scan: opened new HID device")
|
|
164
|
+
|
|
165
|
+
# sleep but wake early if stopping
|
|
166
|
+
if self.finished.wait(scan_ms / 1000.0):
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
def _read_from_device_loop(self, path: str, device: hid.Device) -> None:
|
|
170
|
+
"""Per-device reader thread: blocking read, push reports to the report queue."""
|
|
171
|
+
slice_ms = self._params["repeat_interval_ms"]
|
|
172
|
+
while not self.finished.is_set():
|
|
173
|
+
try:
|
|
174
|
+
report = device.read(65536, slice_ms)
|
|
175
|
+
except Exception:
|
|
176
|
+
log.debug("read error: device unplugged?")
|
|
177
|
+
break
|
|
178
|
+
if report:
|
|
179
|
+
try:
|
|
180
|
+
self._report_queue.put_nowait(report)
|
|
181
|
+
except Exception:
|
|
182
|
+
log.debug("failed to put report in queue")
|
|
183
|
+
pass
|
|
184
|
+
self._remove_device(path)
|
|
185
|
+
|
|
186
|
+
def _parse(self, report):
|
|
187
|
+
# The first byte is the report id, and due to idiosyncrasies
|
|
188
|
+
# in how HID-apis work on different operating system we can't
|
|
189
|
+
# map the report id to the contents in a good way, so we force
|
|
190
|
+
# compliant devices to always use a report id of 0x50 ('P').
|
|
191
|
+
if len(report) > SIMPLE_REPORT_LEN and report[0] == 0x50:
|
|
192
|
+
return int.from_bytes(report[1 : SIMPLE_REPORT_LEN + 1], "big")
|
|
193
|
+
else:
|
|
194
|
+
raise InvalidReport()
|
|
195
|
+
|
|
196
|
+
def _send(self, key_state):
|
|
197
|
+
steno_actions = self.keymap.keys_to_actions(
|
|
198
|
+
[key for i, key in enumerate(STENO_KEY_CHART) if key_state >> (63 - i) & 1]
|
|
199
|
+
)
|
|
200
|
+
if steno_actions:
|
|
201
|
+
self._notify(steno_actions)
|
|
202
|
+
|
|
203
|
+
def run(self):
|
|
204
|
+
key_state = 0
|
|
205
|
+
current = 0
|
|
206
|
+
last_sent = 0
|
|
207
|
+
press_started = time.time()
|
|
208
|
+
sent_first_up = False
|
|
209
|
+
while not self.finished.wait(0):
|
|
210
|
+
interval_ms = self._params["repeat_interval_ms"]
|
|
211
|
+
try:
|
|
212
|
+
report = self._report_queue.get(timeout=interval_ms / 1000.0)
|
|
213
|
+
except Empty:
|
|
214
|
+
# No report in this slice; handle repeats
|
|
215
|
+
if (
|
|
216
|
+
self._params["double_tap_repeat"]
|
|
217
|
+
and 0 != current == last_sent
|
|
218
|
+
and time.time() - press_started
|
|
219
|
+
> self._params["repeat_delay_ms"] / 1e3
|
|
220
|
+
):
|
|
221
|
+
self._send(current)
|
|
222
|
+
# Avoid sending an extra chord when the repeated chord is released.
|
|
223
|
+
sent_first_up = True
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
current = self._parse(report)
|
|
228
|
+
except InvalidReport:
|
|
229
|
+
log.error("invalid report")
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
press_started = time.time()
|
|
233
|
+
if self._params["first_up_chord_send"]:
|
|
234
|
+
if key_state & ~current and not sent_first_up:
|
|
235
|
+
# A finger went up: send a first-up chord and remember it.
|
|
236
|
+
self._send(key_state)
|
|
237
|
+
last_sent = key_state
|
|
238
|
+
sent_first_up = True
|
|
239
|
+
if current & ~key_state:
|
|
240
|
+
# A finger went down: get ready to send a new first-up chord.
|
|
241
|
+
sent_first_up = False
|
|
242
|
+
key_state = current
|
|
243
|
+
else:
|
|
244
|
+
key_state |= current
|
|
245
|
+
if current == 0:
|
|
246
|
+
# All fingers are up: send the "total" chord and reset it.
|
|
247
|
+
self._send(key_state)
|
|
248
|
+
last_sent = key_state
|
|
249
|
+
key_state = 0
|
|
250
|
+
|
|
251
|
+
def start_capture(self):
|
|
252
|
+
self.finished.clear()
|
|
253
|
+
self._initializing()
|
|
254
|
+
# Enumerate all hid devices on the machine and if we find one with our
|
|
255
|
+
# usage page and usage we try to connect to it.
|
|
256
|
+
try:
|
|
257
|
+
devices = [
|
|
258
|
+
device["path"]
|
|
259
|
+
for device in hid.enumerate()
|
|
260
|
+
if device["usage_page"] == USAGE_PAGE and device["usage"] == USAGE
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
if not devices:
|
|
264
|
+
log.info("no HID device found; watching for devices…")
|
|
265
|
+
# setting state to error to display Disconnected in the main window
|
|
266
|
+
self._error()
|
|
267
|
+
|
|
268
|
+
for path in devices:
|
|
269
|
+
self._add_device(path)
|
|
270
|
+
# Start device watcher
|
|
271
|
+
self._device_watcher = threading.Thread(
|
|
272
|
+
target=self._scan_device_loop, daemon=True
|
|
273
|
+
)
|
|
274
|
+
self._device_watcher.start()
|
|
275
|
+
except Exception as e:
|
|
276
|
+
self._error()
|
|
277
|
+
log.error(f"error during start of capture: {e}")
|
|
278
|
+
return
|
|
279
|
+
self.start()
|
|
280
|
+
|
|
281
|
+
def stop_capture(self):
|
|
282
|
+
super().stop_capture()
|
|
283
|
+
# Stop device watcher
|
|
284
|
+
t_watch = getattr(self, "_device_watcher", None)
|
|
285
|
+
if t_watch is not None:
|
|
286
|
+
try:
|
|
287
|
+
t_watch.join(timeout=0.3)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
self._device_watcher = None
|
|
291
|
+
# Remove all devices via common teardown
|
|
292
|
+
for path in list(self._devices.keys()):
|
|
293
|
+
try:
|
|
294
|
+
self._remove_device(path)
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
# Drain the report queue best-effort
|
|
298
|
+
try:
|
|
299
|
+
while True:
|
|
300
|
+
self._report_queue.get_nowait()
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def get_option_info(cls):
|
|
306
|
+
return {
|
|
307
|
+
"first_up_chord_send": (False, boolean),
|
|
308
|
+
"double_tap_repeat": (False, boolean),
|
|
309
|
+
"repeat_delay_ms": (200, int),
|
|
310
|
+
"repeat_interval_ms": (30, int),
|
|
311
|
+
"device_scan_interval_ms": (1000, int),
|
|
312
|
+
}
|
plover/machine/procat.py
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
from evdev import
|
|
1
|
+
from evdev import (
|
|
2
|
+
UInput,
|
|
3
|
+
ecodes as e,
|
|
4
|
+
util,
|
|
5
|
+
InputDevice,
|
|
6
|
+
list_devices,
|
|
7
|
+
InputEvent,
|
|
8
|
+
KeyEvent,
|
|
9
|
+
)
|
|
2
10
|
import threading
|
|
3
|
-
|
|
11
|
+
import os
|
|
12
|
+
import selectors
|
|
13
|
+
|
|
4
14
|
from psutil import process_iter
|
|
5
15
|
|
|
6
16
|
from plover.output.keyboard import GenericKeyboardEmulation
|
|
@@ -320,6 +330,17 @@ KEYCODE_TO_KEY = dict(
|
|
|
320
330
|
zip(LAYOUTS[DEFAULT_LAYOUT].values(), LAYOUTS[DEFAULT_LAYOUT].keys())
|
|
321
331
|
)
|
|
322
332
|
|
|
333
|
+
MODIFIER_KEY_CODES: set[int] = {
|
|
334
|
+
e.KEY_LEFTSHIFT,
|
|
335
|
+
e.KEY_RIGHTSHIFT,
|
|
336
|
+
e.KEY_LEFTCTRL,
|
|
337
|
+
e.KEY_RIGHTCTRL,
|
|
338
|
+
e.KEY_LEFTALT,
|
|
339
|
+
e.KEY_RIGHTALT,
|
|
340
|
+
e.KEY_LEFTMETA,
|
|
341
|
+
e.KEY_RIGHTMETA,
|
|
342
|
+
}
|
|
343
|
+
|
|
323
344
|
|
|
324
345
|
class KeyboardEmulation(GenericKeyboardEmulation):
|
|
325
346
|
def __init__(self):
|
|
@@ -412,21 +433,30 @@ class KeyboardEmulation(GenericKeyboardEmulation):
|
|
|
412
433
|
|
|
413
434
|
|
|
414
435
|
class KeyboardCapture(Capture):
|
|
436
|
+
_selector: selectors.DefaultSelector
|
|
437
|
+
_device_thread: threading.Thread | None
|
|
438
|
+
# Pipes to signal `_run` thread to stop
|
|
439
|
+
_device_thread_read_pipe: int | None
|
|
440
|
+
_device_thread_write_pipe: int | None
|
|
441
|
+
|
|
415
442
|
def __init__(self):
|
|
416
443
|
super().__init__()
|
|
417
|
-
# This is based on the example from the python-evdev documentation, using the first of the three alternative methods: https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select
|
|
418
444
|
self._devices = self._get_devices()
|
|
419
|
-
|
|
420
|
-
self.
|
|
445
|
+
|
|
446
|
+
self._selector = selectors.DefaultSelector()
|
|
447
|
+
self._device_thread = None
|
|
448
|
+
self._device_thread_read_pipe = None
|
|
449
|
+
self._device_thread_write_pipe = None
|
|
450
|
+
|
|
421
451
|
self._res = util.find_ecodes_by_regex(r"KEY_.*")
|
|
422
452
|
self._ui = UInput(self._res)
|
|
423
|
-
self._suppressed_keys =
|
|
453
|
+
self._suppressed_keys = set()
|
|
424
454
|
# The keycodes from evdev, e.g. e.KEY_A refers to the *physical* a, which corresponds with the qwerty layout.
|
|
425
455
|
|
|
426
456
|
def _get_devices(self):
|
|
427
457
|
input_devices = [InputDevice(path) for path in list_devices()]
|
|
428
458
|
keyboard_devices = [dev for dev in input_devices if self._filter_devices(dev)]
|
|
429
|
-
return
|
|
459
|
+
return keyboard_devices
|
|
430
460
|
|
|
431
461
|
def _filter_devices(self, device):
|
|
432
462
|
"""
|
|
@@ -451,7 +481,7 @@ class KeyboardCapture(Capture):
|
|
|
451
481
|
There is likely a race condition here between checking active keys and
|
|
452
482
|
actually grabbing the device, but it appears to work fine.
|
|
453
483
|
"""
|
|
454
|
-
for device in self._devices
|
|
484
|
+
for device in self._devices:
|
|
455
485
|
if len(device.active_keys()) > 0:
|
|
456
486
|
for _ in device.read_loop():
|
|
457
487
|
if len(device.active_keys()) == 0:
|
|
@@ -461,26 +491,50 @@ class KeyboardCapture(Capture):
|
|
|
461
491
|
|
|
462
492
|
def _ungrab_devices(self):
|
|
463
493
|
"""Ungrab all devices. Handles all exceptions when ungrabbing."""
|
|
464
|
-
for device in self._devices
|
|
494
|
+
for device in self._devices:
|
|
465
495
|
try:
|
|
466
496
|
device.ungrab()
|
|
467
497
|
except:
|
|
468
498
|
log.debug("failed to ungrab device", exc_info=True)
|
|
469
499
|
|
|
470
500
|
def start(self):
|
|
501
|
+
# Exception handling note: cancel() will eventually be called when the
|
|
502
|
+
# machine reconnect button is pressed or when the machine is changed.
|
|
503
|
+
# Therefore, cancel() does not need to be called in the except block.
|
|
471
504
|
try:
|
|
472
505
|
self._grab_devices()
|
|
473
|
-
|
|
506
|
+
self._device_thread_read_pipe, self._device_thread_write_pipe = os.pipe()
|
|
507
|
+
self._selector.register(self._device_thread_read_pipe, selectors.EVENT_READ)
|
|
508
|
+
for device in self._devices:
|
|
509
|
+
self._selector.register(device, selectors.EVENT_READ)
|
|
510
|
+
|
|
511
|
+
self._device_thread = threading.Thread(target=self._run)
|
|
512
|
+
self._device_thread.start()
|
|
513
|
+
except Exception:
|
|
474
514
|
self._ungrab_devices()
|
|
515
|
+
self._ui.close()
|
|
475
516
|
raise
|
|
476
|
-
self._running = True
|
|
477
|
-
self._thread = threading.Thread(target=self._run)
|
|
478
|
-
self._thread.start()
|
|
479
517
|
|
|
480
518
|
def cancel(self):
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
self.
|
|
519
|
+
if (
|
|
520
|
+
self._device_thread_read_pipe is None
|
|
521
|
+
or self._device_thread_write_pipe is None
|
|
522
|
+
):
|
|
523
|
+
# The only way for these pipes to be None is if pipe creation in start() failed
|
|
524
|
+
# In that case, no other code after pipe creation would have run
|
|
525
|
+
# and no cleanup is required
|
|
526
|
+
return
|
|
527
|
+
try:
|
|
528
|
+
# Write some arbitrary data to the pipe to signal the _run thread to stop
|
|
529
|
+
os.write(self._device_thread_write_pipe, b"a")
|
|
530
|
+
if self._device_thread is not None:
|
|
531
|
+
self._device_thread.join()
|
|
532
|
+
self._selector.close()
|
|
533
|
+
except Exception:
|
|
534
|
+
log.debug("error stopping KeyboardCapture", exc_info=True)
|
|
535
|
+
finally:
|
|
536
|
+
os.close(self._device_thread_read_pipe)
|
|
537
|
+
os.close(self._device_thread_write_pipe)
|
|
484
538
|
|
|
485
539
|
def suppress(self, suppressed_keys=()):
|
|
486
540
|
"""
|
|
@@ -488,31 +542,77 @@ class KeyboardCapture(Capture):
|
|
|
488
542
|
are passed through to a UInput device and emulated, while keys in this list get sent to plover.
|
|
489
543
|
It does add a little bit of delay, but that is not noticeable.
|
|
490
544
|
"""
|
|
491
|
-
self._suppressed_keys = suppressed_keys
|
|
545
|
+
self._suppressed_keys = set(suppressed_keys)
|
|
492
546
|
|
|
493
547
|
def _run(self):
|
|
548
|
+
keys_pressed_with_modifier: set[int] = set()
|
|
549
|
+
down_modifier_keys: set[int] = set()
|
|
550
|
+
|
|
551
|
+
def _process_key_event(event: InputEvent) -> tuple[str | None, bool]:
|
|
552
|
+
"""
|
|
553
|
+
Processes an InputEvent to determine which key Plover should receive
|
|
554
|
+
and whether the event should be suppressed.
|
|
555
|
+
Considers pressed modifiers and Plover's suppressed keys.
|
|
556
|
+
Returns a tuple of (key_to_send_to_plover, suppress)
|
|
557
|
+
"""
|
|
558
|
+
if not self._suppressed_keys:
|
|
559
|
+
# No keys are suppressed
|
|
560
|
+
# Always send to Plover so that it can handle global shortcuts like PLOVER_TOGGLE (PHROLG)
|
|
561
|
+
return KEYCODE_TO_KEY.get(event.code, None), False
|
|
562
|
+
if event.code in MODIFIER_KEY_CODES:
|
|
563
|
+
# Can't use if-else because there is a third case: key_hold
|
|
564
|
+
if event.value == KeyEvent.key_down:
|
|
565
|
+
down_modifier_keys.add(event.code)
|
|
566
|
+
elif event.value == KeyEvent.key_up:
|
|
567
|
+
down_modifier_keys.discard(event.code)
|
|
568
|
+
return None, False
|
|
569
|
+
key = KEYCODE_TO_KEY.get(event.code, None)
|
|
570
|
+
if key is None:
|
|
571
|
+
# Key is unhandled. Passthrough
|
|
572
|
+
return None, False
|
|
573
|
+
if event.value == KeyEvent.key_down and down_modifier_keys:
|
|
574
|
+
keys_pressed_with_modifier.add(event.code)
|
|
575
|
+
return None, False
|
|
576
|
+
if (
|
|
577
|
+
event.value == KeyEvent.key_up
|
|
578
|
+
and event.code in keys_pressed_with_modifier
|
|
579
|
+
):
|
|
580
|
+
# Must pass through key up event if key was pressed with modifier
|
|
581
|
+
# or else it will stay pressed down and start repeating.
|
|
582
|
+
# Must release even if modifier key was released first
|
|
583
|
+
keys_pressed_with_modifier.discard(event.code)
|
|
584
|
+
return None, False
|
|
585
|
+
suppress = key in self._suppressed_keys
|
|
586
|
+
return key, suppress
|
|
587
|
+
|
|
494
588
|
try:
|
|
495
|
-
while
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
for fd in r:
|
|
504
|
-
for event in self._devices[fd].read():
|
|
589
|
+
while True:
|
|
590
|
+
for key, events in self._selector.select():
|
|
591
|
+
if key.fd == self._device_thread_read_pipe:
|
|
592
|
+
# Stop this thread
|
|
593
|
+
return
|
|
594
|
+
assert isinstance(key.fileobj, InputDevice)
|
|
595
|
+
device: InputDevice = key.fileobj
|
|
596
|
+
for event in device.read():
|
|
505
597
|
if event.type == e.EV_KEY:
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
598
|
+
key_to_send_to_plover, suppress = _process_key_event(event)
|
|
599
|
+
if key_to_send_to_plover is not None:
|
|
600
|
+
# Always send keys to Plover when no keys suppressed.
|
|
601
|
+
# This is required for global shortcuts like
|
|
602
|
+
# Plover toggle (PHROLG) when Plover is disabled.
|
|
603
|
+
# Note: Must explicitly check key_up or key_down
|
|
604
|
+
# because there is a third case: key_hold
|
|
605
|
+
if event.value == KeyEvent.key_down:
|
|
606
|
+
self.key_down(key_to_send_to_plover)
|
|
607
|
+
elif event.value == KeyEvent.key_up:
|
|
608
|
+
self.key_up(key_to_send_to_plover)
|
|
609
|
+
if suppress:
|
|
610
|
+
# Skip rest of loop to prevent event from
|
|
611
|
+
# being passed through
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
# Passthrough event
|
|
615
|
+
self._ui.write_event(event)
|
|
516
616
|
except:
|
|
517
617
|
log.error("keyboard capture error", exc_info=True)
|
|
518
618
|
finally:
|
|
@@ -135,7 +135,10 @@ class KeyboardLayout:
|
|
|
135
135
|
class LayoutWatchingCallback(AppKit.NSObject):
|
|
136
136
|
def layoutChanged_(self, event):
|
|
137
137
|
log.info("Mac keyboard layout changed, updating")
|
|
138
|
-
|
|
138
|
+
try:
|
|
139
|
+
layout._update_layout()
|
|
140
|
+
except:
|
|
141
|
+
log.warning("error during layout update, ignoring")
|
|
139
142
|
|
|
140
143
|
center = Foundation.NSDistributedNotificationCenter.defaultCenter()
|
|
141
144
|
watcher_callback = LayoutWatchingCallback.new()
|
plover/oslayer/windows/log.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from plyer import notification
|
|
2
2
|
import logging
|
|
3
|
-
import sys
|
|
4
3
|
import os
|
|
5
4
|
|
|
6
5
|
from plover import log, __name__ as __software_name__
|
|
@@ -9,6 +8,25 @@ from plover.oslayer.config import ASSETS_DIR
|
|
|
9
8
|
APPNAME = __software_name__.capitalize()
|
|
10
9
|
APPICON = os.path.join(ASSETS_DIR, "plover.ico")
|
|
11
10
|
|
|
11
|
+
# Windows NOTIFYICONDATAW limits
|
|
12
|
+
_MAX_TITLE = 60 # leave some margin (spec is 64)
|
|
13
|
+
_MAX_BODY = 250 # leave some margin (spec is 256)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _flatten(s: str) -> str:
|
|
17
|
+
# Remove newlines/tabs that can expand length; collapse whitespace.
|
|
18
|
+
return " ".join(s.replace("\r", " ").replace("\n", " ").replace("\t", " ").split())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _truncate(s: str, limit: int) -> str:
|
|
22
|
+
s = _flatten(s)
|
|
23
|
+
if len(s) <= limit:
|
|
24
|
+
return s
|
|
25
|
+
# keep room for ellipsis
|
|
26
|
+
if limit <= 1:
|
|
27
|
+
return s[:limit]
|
|
28
|
+
return s[: limit - 1] + "…"
|
|
29
|
+
|
|
12
30
|
|
|
13
31
|
class NotificationHandler(logging.Handler):
|
|
14
32
|
def __init__(self):
|
|
@@ -21,18 +39,22 @@ class NotificationHandler(logging.Handler):
|
|
|
21
39
|
def handle(self, record):
|
|
22
40
|
level = record.levelno
|
|
23
41
|
message = self.format(record)
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
|
|
43
|
+
title = _truncate(APPNAME, _MAX_TITLE)
|
|
44
|
+
body = _truncate(message, _MAX_BODY)
|
|
45
|
+
|
|
46
|
+
# Reasonable timeouts
|
|
26
47
|
if level <= log.INFO:
|
|
27
48
|
timeout = 10
|
|
28
49
|
elif level <= log.WARNING:
|
|
29
50
|
timeout = 15
|
|
30
51
|
else:
|
|
31
52
|
timeout = 60
|
|
53
|
+
|
|
32
54
|
notification.notify(
|
|
33
|
-
app_name=
|
|
55
|
+
app_name=title,
|
|
34
56
|
app_icon=APPICON,
|
|
35
|
-
title=
|
|
36
|
-
message=
|
|
57
|
+
title=title,
|
|
58
|
+
message=body,
|
|
37
59
|
timeout=timeout,
|
|
38
60
|
)
|