Skip to content

Commit fdfaa2b

Browse files
slendidevRaymo111
andauthored
Switch from pynput to libvinput (#134)
* Switch from pynput to libvinput Signed-off-by: Slendi <slendi@socopon.com> * Change modifier CLI args * Fix: Update timing after adding key to word Edge case where a non-allowed char key (e.g. number) typed fast enough to be considered a chord is added as the first character of a word, and timing is not initialized. When a non-chord key (e.g. modifier key) is then pressed (to end the word), the word is logged but there is no timing associated with it, which would cause a crash. * Fix: device chords format --------- Signed-off-by: Slendi <slendi@socopon.com> Signed-off-by: Raymond Li <hi@raymond.li> Co-authored-by: Raymond Li <hi@raymond.li>
1 parent efa88a7 commit fdfaa2b

File tree

7 files changed

+127
-86
lines changed

7 files changed

+127
-86
lines changed

.github/workflows/CI.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
pip install build twine
7171
pip install -r requirements.txt
7272
pip install -r test-requirements.txt
73-
sudo apt-get install xvfb
73+
sudo apt-get install xvfb libx11-dev libxcb-xtest0 libxdo3
7474
- name: Lint with flake8
7575
run: |
7676
flake8 --count --show-source --statistics --max-line-length=120 \

dist.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,7 @@ def run_command(command: str):
7777

7878
if not (args.no_build or args.ui_only):
7979
# Pyinstaller command
80-
build_cmd = "pyinstaller --onefile --name nexus nexus/__main__.py --icon ui/images/icon.ico"
81-
if os_name == "notwin": # Add hidden imports for Linux
82-
build_cmd += " --hidden-import pynput.keyboard._xorg --hidden-import pynput.mouse._xorg"
80+
build_cmd = "pyinstaller --onefile --name nexus nexus/__main__.py --icon ui/images/icon.ico --collect-all vinput"
8381

8482
if os_name == "win":
8583
print("Building windowed executable...")

nexus/Freqlog/Definitions.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
from enum import Enum
55
from typing import Any, Self
66

7-
from pynput.keyboard import Key
8-
97
from nexus import __author__
8+
import vinput
109

1110

1211
class Defaults:
@@ -15,9 +14,10 @@ class Defaults:
1514
{chr(i) for i in range(ord('a'), ord('z') + 1)} | {chr(i) for i in range(ord('A'), ord('Z') + 1)}
1615
DEFAULT_ALLOWED_CHARS: set = \
1716
DEFAULT_ALLOWED_FIRST_CHARS | {"'", "-", "_", "/", "~"} # | {chr(i) for i in range(ord('0'), ord('9') + 1)}
17+
DEFAULT_MODIFIERS: set = [
18+
'left_control', 'left_alt', 'left_meta', 'left_super', 'left_hyper',
19+
'right_control', 'right_alt', 'right_meta', 'right_super', 'right_hyper']
1820
# TODO: uncomment above line when first char detection is implemented
19-
DEFAULT_MODIFIER_KEYS: set = {Key.ctrl, Key.ctrl_l, Key.ctrl_r, Key.alt, Key.alt_l, Key.alt_r, Key.alt_gr, Key.cmd,
20-
Key.cmd_l, Key.cmd_r}
2121
DEFAULT_NEW_WORD_THRESHOLD: float = 5 # seconds after which character input is considered a new word
2222
DEFAULT_CHORD_CHAR_THRESHOLD: int = 5 # milliseconds between characters in a chord to be considered a chord
2323
DEFAULT_DB_FILE: str = "nexus_freqlog_db.sqlite3"
@@ -39,6 +39,11 @@ class Defaults:
3939
else: # Fallback (unknown platform)
4040
DEFAULT_DB_PATH = DEFAULT_DB_FILE
4141

42+
# The names of all available modifiers in libvinput.
43+
MODIFIER_NAMES = [
44+
a for a in dir(vinput.KeyboardModifiers)
45+
if not a.startswith('_') and type(getattr(vinput.KeyboardModifiers, a)).__name__ == "CField"]
46+
4247
# Create directory if it doesn't exist
4348
os.makedirs(os.path.dirname(DEFAULT_DB_PATH), exist_ok=True)
4449

nexus/Freqlog/Freqlog.py

Lines changed: 101 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from queue import Empty as EmptyException, Queue
66
from threading import Thread
77
from typing import Optional
8+
import sys
89

910
from charachorder import CharaChorder, SerialException
10-
from pynput import keyboard as kbd, mouse
11+
import vinput
1112

1213
from .backends import Backend, SQLiteBackend
1314
from .Definitions import ActionType, BanlistAttr, BanlistEntry, CaseSensitivity, ChordMetadata, ChordMetadataAttr, \
@@ -16,18 +17,20 @@
1617

1718
class Freqlog:
1819

19-
def _on_press(self, key: kbd.Key | kbd.KeyCode) -> None:
20-
"""Store PRESS, key and current time in queue"""
21-
self.q.put((ActionType.PRESS, key, datetime.now()))
22-
23-
def _on_release(self, key: kbd.Key | kbd.KeyCode) -> None:
24-
""""Store RELEASE, key and current time in queue"""
25-
if key in self.modifier_keys:
26-
self.q.put((ActionType.RELEASE, key, datetime.now()))
20+
def _on_key(self, key: vinput.KeyboardEvent) -> None:
21+
kind = ActionType.PRESS
22+
if not key.pressed:
23+
kind = ActionType.RELEASE
24+
if key.keychar == '' or key.keychar == '\0':
25+
return
26+
self.q.put((kind, key.keychar.lower(), key.modifiers, datetime.now()))
2727

28-
def _on_click(self, _x, _y, button: mouse.Button, _pressed) -> None:
28+
def _on_mouse_button(self, button: vinput.MouseButtonEvent) -> None:
2929
"""Store PRESS, key and current time in queue"""
30-
self.q.put((ActionType.PRESS, button, datetime.now()))
30+
self.q.put((ActionType.PRESS, button, None, datetime.now()))
31+
32+
def _on_mouse_move(self, move: vinput.MouseMoveEvent) -> None:
33+
pass
3134

3235
def _log_word(self, word: str, start_time: datetime, end_time: datetime) -> None:
3336
"""
@@ -59,14 +62,20 @@ def _log_chord(self, chord: str, start_time: datetime, end_time: datetime) -> No
5962
logging.info(f"Banned chord, {end_time}")
6063
logging.debug(f"(Banned chord was '{chord}')")
6164

65+
# This checks if the event is either a valid key or if it should be treated as mouse input/modifier key press.
66+
@staticmethod
67+
def _is_key(x: str | vinput.MouseButtonEvent) -> bool:
68+
if isinstance(x, vinput.MouseButtonEvent):
69+
return False
70+
return x != '' and x != '\0'
71+
6272
def _process_queue(self):
6373
word: str = "" # word to be logged, reset on criteria below
6474
word_start_time: datetime | None = None
6575
word_end_time: datetime | None = None
6676
chars_since_last_bs: int = 0
6777
avg_char_time_after_last_bs: timedelta | None = None
6878
last_key_was_disallowed: bool = False
69-
active_modifier_keys: set = set()
7079

7180
def _get_timed_interruptable(q, timeout):
7281
# Based on https://stackoverflow.com/a/37016663/9206488
@@ -116,30 +125,49 @@ def _log_and_reset_word(min_length: int = 2) -> None:
116125
avg_char_time_after_last_bs = None
117126
last_key_was_disallowed = False
118127

128+
def _update_timing():
129+
"""Must be called after adding a key to word and before self.q.task_done()"""
130+
nonlocal word_start_time, word_end_time, last_key_was_disallowed, chars_since_last_bs, \
131+
avg_char_time_after_last_bs
132+
if not word_start_time:
133+
word_start_time = time_pressed
134+
elif chars_since_last_bs > 1 and avg_char_time_after_last_bs:
135+
# Should only get here if chars_since_last_bs > 2
136+
avg_char_time_after_last_bs = (avg_char_time_after_last_bs * (chars_since_last_bs - 1) +
137+
(time_pressed - word_end_time)) / chars_since_last_bs
138+
elif chars_since_last_bs > 1:
139+
avg_char_time_after_last_bs = time_pressed - word_end_time
140+
word_end_time = time_pressed
141+
119142
while self.is_logging:
120143
try:
121144
action: ActionType
122-
key: kbd.Key | kbd.KeyCode | mouse.Button
145+
key: str | vinput.MouseButtonEvent
146+
modifiers: vinput.KeyboardModifiers | None
123147
time_pressed: datetime
124148

125149
# Blocking here makes the while-True non-blocking
126-
action, key, time_pressed = _get_timed_interruptable(self.q, self.new_word_threshold)
150+
action, key, modifiers, time_pressed = _get_timed_interruptable(self.q, self.new_word_threshold)
151+
152+
if isinstance(key, bytes):
153+
key = key.decode('utf-8')
127154

128155
# Debug keystrokes
129-
if isinstance(key, kbd.Key) or isinstance(key, kbd.KeyCode):
156+
if self._is_key(key):
130157
logging.debug(f"{action}: {key} - {time_pressed}")
131-
logging.debug(f"word: '{word}', active_modifier_keys: {active_modifier_keys}")
158+
logging.debug(f"word: '{word}', modifiers: {modifiers}")
132159

133-
# Update modifier keys
134-
if action == ActionType.PRESS and key in self.modifier_keys:
135-
active_modifier_keys.add(key)
136-
elif action == ActionType.RELEASE:
137-
active_modifier_keys.discard(key)
160+
if action == ActionType.RELEASE:
161+
continue
138162

139163
# On backspace, remove last char from word if word is not empty
140-
if key == kbd.Key.backspace and word:
141-
if active_modifier_keys.intersection({kbd.Key.ctrl, kbd.Key.ctrl_l, kbd.Key.ctrl_r,
142-
kbd.Key.cmd, kbd.Key.cmd_l, kbd.Key.cmd_r}):
164+
if key == '\b' and word:
165+
if sys.platform == 'darwin':
166+
word_del_cond = modifiers.left_alt or modifiers.right_alt
167+
else:
168+
word_del_cond = modifiers.left_control or modifiers.right_control
169+
170+
if word_del_cond:
143171
# Remove last word from word
144172
# FIXME: make this work - rn _log_and_reset_word() is called immediately upon ctrl/cmd keydown
145173
# TODO: make this configurable (i.e. for vim, etc)
@@ -160,61 +188,57 @@ def _log_and_reset_word(min_length: int = 2) -> None:
160188
continue
161189

162190
# Handle whitespace/disallowed keys
163-
if ((isinstance(key, kbd.Key) and key in {kbd.Key.space, kbd.Key.tab, kbd.Key.enter}) or
164-
(isinstance(key, kbd.KeyCode) and (not key.char or key.char not in self.allowed_chars))):
191+
if self._is_key(key) and (key in " \t\n\r" or key not in self.allowed_chars):
165192
# If key is whitespace/disallowed and timing is more than chord_char_threshold, log and reset word
166193
if (word and avg_char_time_after_last_bs and
167194
avg_char_time_after_last_bs > timedelta(milliseconds=self.chord_char_threshold)):
168195
logging.debug(f"Whitespace/disallowed, log+reset: {word}")
169196
_log_and_reset_word()
170197
else: # Add key to chord
171-
match key:
172-
case kbd.Key.space:
173-
word += " "
174-
case kbd.Key.tab:
175-
word += "\t"
176-
case kbd.Key.enter:
177-
word += "\n"
178-
case _:
179-
if isinstance(key, kbd.KeyCode) and key.char:
180-
word += key.char
198+
if self._is_key(key):
199+
word += key
200+
_update_timing()
181201
last_key_was_disallowed = True
182202
self.q.task_done()
183203
continue
184204

185205
# On non-chord key, log and reset word if it exists
186206
# Non-chord key = key in modifier keys or non-key
187207
# FIXME: support modifier keys in chords
188-
if key in self.modifier_keys or not (isinstance(key, kbd.Key) or isinstance(key, kbd.KeyCode)):
208+
if not self._is_key(key):
189209
logging.debug(f"Non-chord key: {key}")
190210
if word:
191211
_log_and_reset_word()
192212
self.q.task_done()
193213
continue
194214

195-
# Add new char to word and update word timing if no modifier keys are pressed
196-
if isinstance(key, kbd.KeyCode) and not active_modifier_keys and key.char:
215+
banned_modifier_active = False
216+
for attr in Defaults.MODIFIER_NAMES:
217+
# Skip non-enabled modifiers
218+
if not getattr(self.modifier_keys, attr):
219+
continue
220+
221+
if getattr(modifiers, attr):
222+
banned_modifier_active = True
223+
break
224+
225+
# Add new char to word and update word timing if no banned modifier keys are pressed
226+
if not banned_modifier_active:
197227
# I think this is for chords that end in space
198228
# If last key was disallowed and timing of this key is more than chord_char_threshold, log+reset
199229
if (last_key_was_disallowed and word and word_end_time and
200230
(time_pressed - word_end_time) > timedelta(milliseconds=self.chord_char_threshold)):
201231
logging.debug(f"Disallowed and timing, log+reset: {word}")
202232
_log_and_reset_word()
203-
word += key.char
233+
word += key
204234
chars_since_last_bs += 1
205-
206-
# TODO: code below potentially needs to be copied to edge cases above
207-
if not word_start_time:
208-
word_start_time = time_pressed
209-
elif chars_since_last_bs > 1 and avg_char_time_after_last_bs:
210-
# Should only get here if chars_since_last_bs > 2
211-
avg_char_time_after_last_bs = (avg_char_time_after_last_bs * (chars_since_last_bs - 1) +
212-
(time_pressed - word_end_time)) / chars_since_last_bs
213-
elif chars_since_last_bs > 1:
214-
avg_char_time_after_last_bs = time_pressed - word_end_time
215-
word_end_time = time_pressed
235+
_update_timing()
216236
self.q.task_done()
237+
continue
217238

239+
# Should never get here
240+
logging.error(f"Uncaught key: {key}")
241+
self.q.task_done()
218242
except EmptyException: # Queue is empty
219243
# If word is older than NEW_WORD_THRESHOLD seconds, log and reset word
220244
if word:
@@ -237,7 +261,7 @@ def _get_chords(self):
237261
self.chords = []
238262
started_logging = False # prevent early short-circuit
239263
for chord, phrase in self.device.get_chordmaps():
240-
self.chords.append(str(phrase).strip())
264+
self.chords.append(''.join(phrase).strip())
241265
if not self.is_logging: # Short circuit if logging is stopped
242266
if started_logging:
243267
logging.info("Stopped getting chords from device")
@@ -298,6 +322,7 @@ def __init__(self, backend_path: str, password_callback: callable, loggable: boo
298322
logging.error(e)
299323

300324
self.is_logging: bool = False # Used in self._get_chords, needs to be initialized here
325+
self.loggable = loggable
301326
if loggable:
302327
logging.info(f"Logging set to freqlog db at {backend_path}")
303328

@@ -306,21 +331,23 @@ def __init__(self, backend_path: str, password_callback: callable, loggable: boo
306331

307332
self.backend: Backend = SQLiteBackend(backend_path, password_callback, upgrade_callback)
308333
self.q: Queue = Queue()
309-
self.listener: kbd.Listener | None = None
310-
self.mouse_listener: mouse.Listener | None = None
311-
if loggable:
312-
self.listener = kbd.Listener(on_press=self._on_press, on_release=self._on_release, name="Keyboard Listener")
313-
self.mouse_listener = mouse.Listener(on_click=self._on_click, name="Mouse Listener")
334+
self.listener: vinput.EventListener | None = None
335+
self.listener_thread = Thread(target=lambda: self._log_start())
314336
self.new_word_threshold: float = Defaults.DEFAULT_NEW_WORD_THRESHOLD
315337
self.chord_char_threshold: int = Defaults.DEFAULT_CHORD_CHAR_THRESHOLD
316338
self.allowed_chars: set = Defaults.DEFAULT_ALLOWED_CHARS
317339
self.allowed_first_chars: set = Defaults.DEFAULT_ALLOWED_FIRST_CHARS
318-
self.modifier_keys: set = Defaults.DEFAULT_MODIFIER_KEYS
340+
self.modifier_keys: vinput.KeyboardModifiers = vinput.KeyboardModifiers()
341+
for attr in Defaults.DEFAULT_MODIFIERS:
342+
setattr(self.modifier_keys, attr, True)
319343
self.killed: bool = False
320344

321345
def start_logging(self, new_word_threshold: float | None = None, chord_char_threshold: int | None = None,
322346
allowed_chars: set | str | None = None, allowed_first_chars: set | str | None = None,
323-
modifier_keys: set = None) -> None:
347+
modifier_keys: vinput.KeyboardModifiers | None = None) -> None:
348+
if not self.loggable:
349+
return
350+
324351
if isinstance(allowed_chars, set):
325352
self.allowed_chars = allowed_chars
326353
elif isinstance(allowed_chars, str):
@@ -342,21 +369,31 @@ def start_logging(self, new_word_threshold: float | None = None, chord_char_thre
342369
f"allowed_chars={self.allowed_chars}, "
343370
f"allowed_first_chars={self.allowed_first_chars}, "
344371
f"modifier_keys={self.modifier_keys}")
345-
self.listener.start()
346-
self.mouse_listener.start()
372+
373+
self.listener_thread.start()
347374
self.is_logging = True
348375
logging.warning("Started freqlogging")
349376
self._process_queue()
350377

378+
def _log_start(self):
379+
self.listener = vinput.EventListener(True, True, True)
380+
381+
try:
382+
self.listener.start(
383+
lambda x: self._on_key(x),
384+
lambda x: self._on_mouse_button(x),
385+
lambda x: self._on_mouse_move(x))
386+
except vinput.VInputException as e:
387+
logging.error("Failed to start listeners: " + str(e))
388+
351389
def stop_logging(self) -> None: # FIXME: find out why this runs twice on one Ctrl-C (does it still?)
352390
if self.killed: # TODO: Forcibly kill if already killed once
353391
exit(1) # This doesn't work rn
354392
self.killed = True
355393
logging.warning("Stopping freqlog")
356394
if self.listener:
357-
self.listener.stop()
358-
if self.mouse_listener:
359-
self.mouse_listener.stop()
395+
del self.listener
396+
self.listener = None
360397
self.is_logging = False
361398
logging.info("Stopped listeners")
362399

nexus/__main__.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sys
66

77
from getpass import getpass
8-
from pynput import keyboard
8+
import vinput
99

1010
from nexus import __doc__, __version__
1111
from nexus.Freqlog import Freqlog
@@ -78,13 +78,10 @@ def main():
7878
parser_start.add_argument("--allowed-first-chars",
7979
default=Defaults.DEFAULT_ALLOWED_FIRST_CHARS,
8080
help="Chars to be considered as the first char in words")
81-
parser_start.add_argument("--add-modifier-key", action="append", default=[],
82-
help="Add a modifier key to the default set",
83-
choices=sorted(key.name for key in set(keyboard.Key) - Defaults.DEFAULT_MODIFIER_KEYS))
84-
parser_start.add_argument("--remove-modifier-key", action="append", default=[],
85-
help="Remove a modifier key from the default set",
86-
choices=sorted(key.name for key in Defaults.DEFAULT_MODIFIER_KEYS))
87-
81+
parser_start.add_argument("--modifier-keys", default=Defaults.DEFAULT_MODIFIERS,
82+
help="Specify which modifier keys to use",
83+
choices=Defaults.MODIFIER_NAMES,
84+
nargs='+')
8885
# Num words
8986
subparsers.add_parser("numwords", help="Get number of words in freqlog",
9087
parents=[log_arg, path_arg, case_arg, upgrade_arg])
@@ -346,10 +343,14 @@ def _prompt_for_password(new: bool, desc: str = "") -> str:
346343
except Exception as e:
347344
logging.error(e)
348345
sys.exit(4)
346+
mods = vinput.KeyboardModifiers()
347+
logging.debug('Activated modifier keys:')
348+
for mod in args.modifier_keys:
349+
logging.debug(' - ' + str(mod))
350+
setattr(mods, mod, True)
349351
signal.signal(signal.SIGINT, lambda _: freqlog.stop_logging())
350352
freqlog.start_logging(args.new_word_threshold, args.chord_char_threshold, args.allowed_chars,
351-
args.allowed_first_chars, Defaults.DEFAULT_MODIFIER_KEYS -
352-
set(args.remove_modifier_key) | set(args.add_modifier_key))
353+
args.allowed_first_chars, mods)
353354
case "checkword": # Check if word is banned
354355
for word in args.word:
355356
if freqlog.check_banned(word):

0 commit comments

Comments
 (0)