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.
Files changed (43) hide show
  1. plover/__init__.py +1 -1
  2. plover/dictionary/loading_manager.py +22 -5
  3. plover/engine.py +11 -11
  4. plover/exception.py +0 -12
  5. plover/gui_qt/about_dialog_ui.py +1 -1
  6. plover/gui_qt/add_translation_dialog_ui.py +1 -1
  7. plover/gui_qt/add_translation_widget_ui.py +1 -1
  8. plover/gui_qt/config_file_widget_ui.py +1 -1
  9. plover/gui_qt/config_keyboard_widget_ui.py +1 -1
  10. plover/gui_qt/config_plover_hid_widget_ui.py +110 -0
  11. plover/gui_qt/config_serial_widget_ui.py +1 -1
  12. plover/gui_qt/config_window_ui.py +1 -1
  13. plover/gui_qt/console_widget_ui.py +1 -1
  14. plover/gui_qt/dictionaries_widget.py +9 -0
  15. plover/gui_qt/dictionaries_widget_ui.py +1 -1
  16. plover/gui_qt/dictionary_editor_ui.py +1 -1
  17. plover/gui_qt/engine.py +6 -1
  18. plover/gui_qt/lookup_dialog_ui.py +1 -1
  19. plover/gui_qt/machine_options.py +53 -1
  20. plover/gui_qt/main_window_ui.py +1 -1
  21. plover/gui_qt/paper_tape_ui.py +1 -1
  22. plover/gui_qt/plugins_manager_ui.py +1 -1
  23. plover/gui_qt/resources_rc.py +29 -29
  24. plover/gui_qt/run_dialog_ui.py +1 -1
  25. plover/gui_qt/suggestions_dialog_ui.py +1 -1
  26. plover/machine/plover_hid.py +312 -0
  27. plover/machine/procat.py +1 -1
  28. plover/oslayer/linux/keyboardcontrol_uinput.py +137 -37
  29. plover/oslayer/osx/keyboardlayout.py +4 -1
  30. plover/oslayer/windows/log.py +28 -6
  31. plover/system/english_stenotype.py +53 -0
  32. {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/METADATA +2 -1
  33. {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/RECORD +43 -40
  34. {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/entry_points.txt +5 -3
  35. {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/licenses/LICENSE.txt +4 -5
  36. plover_build_utils/deps.sh +2 -0
  37. plover_build_utils/download.py +5 -2
  38. plover_build_utils/functions.sh +80 -17
  39. /plover/machine/{geminipr.py → gemini_pr.py} +0 -0
  40. /plover/machine/{txbolt.py → tx_bolt.py} +0 -0
  41. {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/WHEEL +0 -0
  42. {plover-5.0.0.dev3.dist-info → plover-5.1.0.dist-info}/top_level.txt +0 -0
  43. {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
@@ -22,7 +22,7 @@ STENO_KEY_CHART = (None, "#", "S-", "T-", "K-", "P-", "W-", "H-",
22
22
  BYTES_PER_STROKE = 4
23
23
 
24
24
 
25
- class ProCAT(SerialStenotypeBase):
25
+ class ProCat(SerialStenotypeBase):
26
26
  """Interface for ProCAT machines."""
27
27
 
28
28
  KEYS_LAYOUT = """
@@ -1,6 +1,16 @@
1
- from evdev import UInput, ecodes as e, util, InputDevice, list_devices
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
- from select import select
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
- self._running = False
420
- self._thread = None
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 {dev.fd: dev for dev in keyboard_devices}
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.values():
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.values():
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
- except Exception as e:
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
- self._running = False
482
- if self._thread is not None:
483
- self._thread.join()
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 self._running:
496
- """
497
- The select() call blocks the loop until it gets an input, which meant that the keyboard
498
- had to be pressed once after executing `cancel()`. Now, there is a 1 second delay instead
499
- FIXME: maybe use one of the other options to avoid the timeout
500
- https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select
501
- """
502
- r, _, _ = select(self._devices, [], [], 1)
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
- if event.code in KEYCODE_TO_KEY:
507
- key_name = KEYCODE_TO_KEY[event.code]
508
- if key_name in self._suppressed_keys:
509
- pressed = event.value == 1
510
- (self.key_down if pressed else self.key_up)(
511
- key_name
512
- )
513
- continue # Go to the next iteration, skipping the below code:
514
- self._ui.write(e.EV_KEY, event.code, event.value)
515
- self._ui.syn()
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
- layout._update_layout()
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()
@@ -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
- if message.endswith("\n"):
25
- message = message[:-1]
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=APPNAME,
55
+ app_name=title,
34
56
  app_icon=APPICON,
35
- title=APPNAME,
36
- message=message,
57
+ title=title,
58
+ message=body,
37
59
  timeout=timeout,
38
60
  )