-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathPython-Vi-ArrowKeys.py
412 lines (333 loc) · 16.1 KB
/
Python-Vi-ArrowKeys.py
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
#!/usr/bin/env python3
# Project Homepage: https://github.com/ThePiGuy/Python-Vi-ArrowKeys
import keyboard as kb
import pystray as tray
from PIL import Image
import py_win_keyboard_layout as kbl
import sys, string, os
# from win32api import GetAsyncKeyState # no idea what this is
dvorakCodes = [-255851511] # obtained from kbl.get_foreground_window_keyboard_layout() while the Dvorak keyboard is active
config = {
"printDebug": True, # deployment: False
"enableSysTray": True, # deployment: True
"enableQuickExit": False, # deployment: False # press 'end' key to exit the program (useful for debug only)
"maps": { # VI Mappings (defined using qwerty positions)
'h': {"action": "left"},
'j': {"action": "down"},
'k': {"action": "up"},
'l': {"action": "right"},
#',' if (getCurKBLayout() in dvorakCodes) else 'w': {"action": "ctrl+right", "method": "press+release"}, # special behaviour: do the press and release all at once
#'n' if (getCurKBLayout() in dvorakCodes) else 'b': {"action": "ctrl+left", "method": "press+release"},
}, # TODO re-enable word jumps
"remaps": { # scan codes/nameL to remap to other characters (primarily number pad)
82: '0',
79: '1',
80: '2',
81: '3',
75: '4',
76: '5',
77: '6',
71: '7',
72: '8',
73: '9',
83: '.',
53: '/',
},
# List of keys to listen for and apply the system to (prevents issues when they're typed before or after a getCurrentTriggerKey())
"hookKeys": list(string.punctuation) + list(string.ascii_lowercase) + ['space', 'end', 'enter', 'backspace'] + list(string.digits),
"listenKeys": ["shift", "right shift", "left shift", "space"] # just listen to the shift keys for use in the main handler (can only be shifts)
}
def getCurKBLayout():
return kbl.get_foreground_window_keyboard_layout()
def convertDvorakKeyToQwertyKeyIfCurrentlyInDvorak(keyLetter:str):
"""
Converts a Dvorak key to the assocated Qwerty key (the letter physically labelled on the keyboard), but only if the keyboard is currently in Dvorak mode.
Otherwise, just returns the input value.
This function does not yet contain a full remapping. It was intended to be used only on the special charaters (home row, plus a few letters), so any values outside of this range will simply result in the input value being returned.
@param qwertyKey a single character to find on the physical keyboard, and return what Dvorak key is there
@return the Qwerty key that the input key is physically labelled with
"""
# consts for remapping; index in each or these strings matches the index in the other string
dvorakInput = "',.pyfgcrlaoeuidhtn;qjkxbmw/"
qwertyOutput = "qwertyuiopasdfghjklzxcvbnm,[" # source: http://wbic16.xedoloh.com/dvorak.html
if len(keyLetter) != 1:
# just pass through cases like "enter" and "backspace"
return keyLetter
if getCurKBLayout() in dvorakCodes:
# we are in Dvorak mode, do conversion
if keyLetter in dvorakInput:
indexInStr = dvorakInput.index(keyLetter)
return qwertyOutput[indexInStr]
else:
return keyLetter
else:
# not in Dvorak, just pass through
return keyLetter
def getCurrentTriggerKey():
return 'e' if (getCurKBLayout() in dvorakCodes) else 'd'
gstate = { # global state of the system
"down": set(), # set of characters currently pressed (set bc there will only ever be a single instance of each)
"shiftsDown": set(), # set of shift keys pressed down (left shift, right shift, shift)
"lastInfo": "", # stores an information string printed to the user, for caching
"lastInfoCount": 0, # comment
"viTriggeredYet": False, # whether VI mode has been triggered while d has been pressed (decides where or not to type a 'd' on 'd UP')
"dSentYet": False, # whether the 'd' character has been send yet (gets reset on 'd DOWN', and sent when 'd' is typed from either 'UP', 'cards', or 'world' section
"wasDUppercase": None, # whether the 'd' character was uppercase or not when pressed
"capslockState": False, # True=capslock on, False=capslock off; updated on every keypress
"icon": None, # system tray icon
"enabled": True, # system tray enabled
"lastKBLayoutCode": getCurKBLayout() # last KB layout code, so we can rebind when it changes
}
config['specials'] = list(config['maps'].keys()) + ['d'] # list of all special characters to remap
# Note that this uses the Qwerty names for all the keys (instead of getCurrentTriggerKey(), for example); conversion is done later on
def listenCallback(event):
"""
Non-supressing listener for certain keys, like the three shift options.
Used to fix issue where all letters after a D are capitals.
"""
nameL = event.name.lower()
## Record which shift was pressed.
downEvent = False
if event.event_type == "up":
if 'shift' in nameL:
gstate['shiftsDown'].discard(nameL) # use discard to avoid error if not in set
downEvent = False
elif event.event_type == "down":
if 'shift' in nameL:
gstate['shiftsDown'].add(nameL)
downEvent = True
else:
printf("Unknown event type: " + event.event_type)
return
## Print Debug Info
printDebugInfo("Listen", event)
def hookCallback(event):
"""
Called for every key down/up event. This is where the remapping magic happens.
Everything after this method is just pretty system tray stuff.
@param event a keyboard.KeyboardEvent object
Samples of event parameter (with event.to_json()):
{"event_type": "down", "scan_code": 30, "name": "a", "time": 1588229823.0407975, "is_keypad": false}
{"event_type": "up", "scan_code": 30, "name": "a", "time": 1588229823.1415234, "is_keypad": false}
Each attribute/key can be accessed directly with dot notation (ex: event.event_type).
"""
nameL = event.name.lower()
scancode = event.scan_code
# SECTION 1: Set hotkey for exiting the program
"""
By pressing the "END" key, the program is exited. Can be disabled in the `config['enableQuickExit']`.
Useful for stopping the program if any bugs occur and the keyboard is blocked.
"""
if (nameL == "end") and config['enableQuickExit']:
sys.exit()
# SECTION 2: Record whether this key was pressed (lower case)
"""
Updates the set() at `gstate['down']` with lowercase names of all keys currently pressed. Also updates the capslock state (Section 2a).
"""
downEvent = False
if event.event_type == "up":
gstate['down'].discard(nameL) # use discard to avoid error if not in set
downEvent = False
elif event.event_type == "down":
gstate['down'].add(nameL)
downEvent = True
else:
printf("Unknown event type: " + event.event_type)
return
# SECTION 2a: Determine capslock state
if nameL in string.ascii_lowercase:
# only update for letters, not numbers nor symbols
if (event.name in string.ascii_lowercase) and (len(gstate['shiftsDown']) > 0):
gstate['capslockState'] = True
elif (event.name in string.ascii_uppercase) and (len(gstate['shiftsDown']) == 0):
gstate['capslockState'] = True
else:
gstate['capslockState'] = False
# SECTION 3: Pass through normal keys (will require keys down check later)
"""
Passes through normal (non-VI) keys. This section is activated when 'd' is not held down, or when the key being received is not a VI key.
* If the key event_type is UP (key received is being released), a release is simply sent to the computer.
* If the key event_type is DOWN (key received is being pressed):
1. We check to see if 'd' is pressed down currently, and see if it has been sent for this time it is pressed.
* If this is the case, as would be when **typing "cards" very quickly,** (known as the 'cards' bug) send a 'd' before the received event
2. Finally, send the received event
"""
if (getCurrentTriggerKey() not in gstate['down']) or (convertDvorakKeyToQwertyKeyIfCurrentlyInDvorak(nameL) not in config['specials']):
# if d is not pressed and this isn't for a d
if downEvent:
# Do 'cards' fix
if (getCurrentTriggerKey() in gstate['down']) and (not gstate['dSentYet']):
kb.press(getCurrentTriggerKey()) # This should be send, maybe (check back later, if it's an issue)
gstate['dSentYet'] = True
# Actually send through the character (by character if on the numpad, otherwise by scancode)
if event.is_keypad and (scancode in config['remaps'].keys()):
kb.press(config['remaps'][scancode]) # always use the actual number character, regardless of numlock. Used because numlock state is weird
else:
kb.press(scancode) # scancode used to avoid issue with 'F' character (to be explicit)
else:
# Actually send through the character (by character if on the numpad, otherwise by scancode)
if event.is_keypad and (scancode in config['remaps'].keys()):
kb.release(config['remaps'][scancode]) # always use the actual number character, regardless of numlock, used because numlock state is weird
else:
kb.release(scancode) # scancode used to avoid issue with 'F' character (to be explicit)
# SECTION 4: Pass through 'd' based on UP event
"""
Normally (neglecting fast consecutive presses), the 'd' key is sent on a key up event.
However, it is not sent if either 1) a VI key was pressed since it was pressed down, or 2) it was already sent because of a fast consecutive press
"""
if (nameL == getCurrentTriggerKey()):
if downEvent:
# alternatively we could reset viTriggeredYet=False here
gstate['dSentYet'] = False # reset to not sent yet
gstate['wasDUppercase'] = (event.name == getCurrentTriggerKey().upper())
else:
if (not gstate['viTriggeredYet']) and (not gstate['dSentYet']):
# "Discord" bug fix
if gstate["wasDUppercase"] and (not gstate['capslockState']):
# Determine what type of shift to press so that the keyup works later
if (len(gstate['shiftsDown']) == 0):
kb.send('shift+d')
else:
kb.press(list(gstate['shiftsDown'])[0])
kb.send(getCurrentTriggerKey())
#kb.press('shift')
#kb.send(getCurrentTriggerKey())
else:
kb.send(getCurrentTriggerKey())
gstate['dSentYet'] = True
gstate['viTriggeredYet'] = False # reset to false
# SECTION 5: Fix "worl/world" bug
"""
Send 'd' after VI key (fixes 'world' bug)
* When you type the word "world" fast, the 'l' and 'd' cause a unique case that must be caught here.
* The `if any([...]) and <d pressed down right now>` statement checks to see if any VI keys are currently pressed, and checks to see if the current event is 'd DOWN'.
"""
if any(
[
thisVIKey in [convertDvorakKeyToQwertyKeyIfCurrentlyInDvorak(i) for i in gstate['down']]
for thisVIKey in config['maps'].keys()
]) and (nameL == getCurrentTriggerKey() and downEvent):
# If any of the VI keys are currently pressed down, and 'd' is being PRESSED
kb.send(getCurrentTriggerKey()) # this might only be a .press, actually; doesn't matter though
#printf("\nDid 'world' bug fix.")
gstate['dSentYet'] = True
# SECTION 6: Perform VI arrow remapping
"""
If the key is a mappable key, and 'd' is currently held down, send the appropriate arrowkey.
"""
if (convertDvorakKeyToQwertyKeyIfCurrentlyInDvorak(nameL) in config['maps'].keys()) and (getCurrentTriggerKey() in gstate['down']):
gstate['viTriggeredYet'] = True # VI triggered, no longer type a 'd' on release
thisSendSetup = config['maps'][convertDvorakKeyToQwertyKeyIfCurrentlyInDvorak(nameL)] # dict with 'action' as the key(s) to press, and 'method' as 'press+release' or blank for normal
thisSendKey = thisSendSetup['action']
if thisSendSetup.get('method') == 'press+release':
if downEvent:
kb.send(thisSendKey)
else: # normal method, do press/release separately as triggered by keyboard
if downEvent:
kb.press(thisSendKey)
else:
kb.release(thisSendKey)
#printf("\nSending: " + thisSendKey)
# Section 6a: Do reset if keyboard layout changed
if nameL in (' ', 'space'): # only do check on space hotkey
if gstate['lastKBLayoutCode'] != getCurKBLayout():
printf("Keyboard layout just changed, rebinding.")
gstate['lastKBLayoutCode'] = getCurKBLayout() # this is likely unnecessary because the program re-runs from scratch
hardResetProgram() # does a full restart of the program to make it work
# Section 7: Print Debug Info
"""
Prints debug info about the current event, and various states. In the future (or as needed), add a printout of `gstate` to the end.
"""
printDebugInfo("Hook", event)
def printDebugInfo(callbackType, event):
# SECTION 7: Print Debug Info
if config['printDebug']:
info = "\nNew {callbackType} Event: type({type})\tname({scancode} = {name})\tkeysDown({keysDown})\tkeypad({keypad})\tcaps({capslockState})".\
format(callbackType=callbackType, type=event.event_type, capslockState=gstate['capslockState'], \
name=event.name, scancode=event.scan_code, keysDown=" | ".join(gstate['down']) + " || " + " | ".join(gstate['shiftsDown']), keypad=event.is_keypad)
if gstate['lastInfo'] != info:
printf(info, end="")
gstate['lastInfoCount'] = 0
elif gstate['lastInfoCount'] < 20: # only print out if it's not already been held for a while
printf(".", end="")
gstate['lastInfoCount'] += 1
gstate['lastInfo'] = info
def startHooks():
"""
Attaches keyboard hooks, starts the program basically.
"""
# Avoid duplicate hooks by removing all hooks first
#stopHooks()
# Hook all keys
# Issues: fails with 'left windows', types a 'd' when shift is pressed, etc.
#kb.hook(hookCallback, True) # supress characters
# Hook only letters (and maybe certain other characters)
for character in config['hookKeys']:
kb.hook_key(character, hookCallback, True) # supress characters
for character in config['listenKeys']:
kb.hook_key(character, listenCallback, False) # don't supress characters
if config['printDebug']:
printf("\nAttached {} hooks.".format(len(config['hookKeys'])))
# wait forever (only useful for when this function is the last thing called, not for system tray)
if not config["enableSysTray"]:
kb.wait()
def stopHooks():
"""
Removes keyboard hooks, stops listening. Pauses the program.
"""
kb.unhook_all() # should do it, but it doesn't (but actually, does appear to do it?)
if config['printDebug']:
printf("\nStopped all hooks/paused the program.")
def traySetup(icon):
"""
Gets called when the system tray icon is created.
This is run in a separate thread, and its completion is not awaited (it can run forever).
@param icon presumably the icon itself
"""
startHooks()
def trayEnabledChanged(icon):
""" Gets called when system tray "Enabled" changes state. This must keep track of its own state. """
gstate['enabled'] = not gstate['enabled'] # toggle it
if gstate['enabled']:
startHooks()
else:
stopHooks()
def hardResetProgram(icon=None):
"""
Gets called when system tray "Restart" is called.
Used because Synergy only allows forwarding of this program's changes if this program is started after Synergy (must be a full start, not just re-Enable).
Source: https://stackoverflow.com/questions/48129942/python-restart-program/48130340
"""
os.execl(sys.executable, os.path.abspath(__file__), *sys.argv)
def traySoftRestartButton(icon=None):
""" Do a soft reset, as requested from the tray. This function doesn't really work. """
stopHooks()
startHooks()
def createSystemTray():
"""
Sends the script to run in the system tray.
This method runs infinitely, until the program is stopped.
"""
image = Image.open("icon-64.png")
menu = tray.Menu(
tray.MenuItem("VI Arrow Keys", lambda: 1, enabled=False), # inactive item with the program's title
tray.MenuItem('Enabled', trayEnabledChanged, checked=lambda item: gstate['enabled']),
tray.MenuItem('Restart (Force)', hardResetProgram),
#tray.MenuItem('Restart (Soft)', traySoftRestartButton),
tray.MenuItem('Quit/Exit', lambda: gstate['icon'].stop()), # just calls icon.stop(), stops the whole program
)
gstate['icon'] = tray.Icon("VI Arrow Keys", image, "VI Arrow Keys", menu) # originally stored in "icon", stored globally though
gstate['icon'].visible = True
gstate['icon'].run(setup=traySetup) # this creates an infinite loops and runs forever until exit here
def run():
# Create the system tray icon
createSystemTray() # never ends
def printf(*args, **kwargs):
""" A print function that flushes the buffer for immediate feedback. """
print(*args, **kwargs, flush=True)
if __name__ == "__main__":
print("VI Arrow Keys Started.")
if config['enableSysTray']:
run()
else:
startHooks()