55from queue import Empty as EmptyException , Queue
66from threading import Thread
77from typing import Optional
8+ import sys
89
910from charachorder import CharaChorder , SerialException
10- from pynput import keyboard as kbd , mouse
11+ import vinput
1112
1213from .backends import Backend , SQLiteBackend
1314from .Definitions import ActionType , BanlistAttr , BanlistEntry , CaseSensitivity , ChordMetadata , ChordMetadataAttr , \
1617
1718class 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
0 commit comments