From 2ce401faa83d26a6f21925a2c58df46b8b5cc64b Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:26:43 -0400 Subject: [PATCH 01/14] docs: rebrand package docstring and README for the fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup.py publishes `directkeys.__doc__` as the PyPI long_description, and the Makefile regenerates README.md from that same docstring — so the docstring was the single source still advertising the upstream project. It told readers to `pip install keyboard`, pointed every link at boppreh/keyboard, and the README carried the upstream "this project is currently unmaintained" banner, which is the opposite of this fork's premise. - Retitle to directkeys and state up front that this is a maintained fork. - Point install/clone/API links at this repository. - Add a "New in this fork" section and a migration note for `import keyboard`. - Drop the "Python 2 and 3" claim, which contradicts the 3.8+ classifiers. - Rewrite the generated [source] links to this repo and the directkeys package. References to boppreh/mouse and to upstream issues #20/#21/#22 are left alone: they point at a sibling project and at real upstream discussions. Co-Authored-By: Claude Opus 5 --- README.md | 132 ++++++++++++++++++++++------------------- directkeys/__init__.py | 44 ++++++++++---- 2 files changed, 102 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 860e5bd..655d6b7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,10 @@ -**This project is currently unmaintained. It works for many cases, and I wish to pick it up again in the future, but you might encounter some friction and limited features using it.** - ---- - ---- - directkeys -======== +========== Take full control of your keyboard with this small Python library. Hook global events, register hotkeys, simulate key presses and much more. +`directkeys` is an actively maintained fork of [boppreh/keyboard](https://github.com/boppreh/keyboard), which has been dormant since 2021. It keeps the original API so existing code only needs to change the import, and focuses on low-level control of the Windows backend: a configurable AltGr abstraction, raw event flags and recovery from stuck modifier keys. + ## Features - **Global event hook** on all keyboards (captures keys regardless of focus). @@ -16,7 +12,6 @@ Take full control of your keyboard with this small Python library. Hook global e - Works with **Windows** and **Linux** (requires sudo), with experimental **OS X** support (thanks @glitchassassin!). - **Pure Python**, no C modules to be compiled. - **Zero dependencies**. Trivial to install and deploy, just copy the files. -- **Python 2 and 3**. - Complex hotkey support (e.g. `ctrl+shift+m, ctrl+space`) with controllable timeout. - Includes **high level API** (e.g. [record](#directkeys.record) and [play](#directkeys.play), [add_abbreviation](#directkeys.add_abbreviation)). - Maps keys as they actually are in your layout, with **full internationalization support** (e.g. `Ctrl+ç`). @@ -25,19 +20,36 @@ Take full control of your keyboard with this small Python library. Hook global e - Doesn't break accented dead keys (I'm looking at you, pyHook). - Mouse support available via project [mouse](https://github.com/boppreh/mouse) (`pip install mouse`). +### New in this fork + +- **Configurable AltGr abstraction** ([set_alt_gr_abstraction](#directkeys.set_alt_gr_abstraction)): report AltGr as a single event, or expose the raw Windows sequence. +- **Raw event flags**: `KeyboardEvent.flags` carries the low-level hook flags. +- **Stuck key recovery** ([get_stuck_keys](#directkeys.get_stuck_keys) and [force_reset_keyboard](#directkeys.force_reset_keyboard)): detect and release modifiers left pressed by a crashed program. + ## Usage -Install the [PyPI package](https://pypi.python.org/pypi/keyboard/): +Install the [PyPI package](https://pypi.python.org/pypi/directkeys/): pip install directkeys or clone the repository (no installation required, source files are sufficient): - git clone https://github.com/WigoWigo10/directkeys + git clone https://github.com/WigoWigo10/keyboard + +or [download and extract the zip](https://github.com/WigoWigo10/keyboard/archive/master.zip) into your project folder. -or [download and extract the zip](https://github.com/boppreh/keyboard/archive/master.zip) into your project folder. +Then check the [API docs below](https://github.com/WigoWigo10/keyboard#api) to see what features are available. -Then check the [API docs below](https://github.com/boppreh/keyboard#api) to see what features are available. +### Migrating from `keyboard` + +The whole API is unchanged, so in most cases only the import differs: + +```py +# before +import keyboard +# after +import directkeys as keyboard +``` ## Example @@ -75,7 +87,7 @@ Use as standalone module: ```bash # Save JSON events to a file until interrupted: -python -m keyboard > events.txt +python -m directkeys > events.txt cat events.txt # {"event_type": "down", "scan_code": 25, "name": "p", "time": 1622447562.2994788, "is_keypad": false} @@ -83,7 +95,7 @@ cat events.txt # ... # Replay events -python -m keyboard < events.txt +python -m directkeys < events.txt ``` ## Known limitations: @@ -92,9 +104,9 @@ python -m keyboard < events.txt - Media keys on Linux may appear nameless (scan-code only) or not at all. [#20](https://github.com/boppreh/keyboard/issues/20) - Key suppression/blocking only available on Windows. [#22](https://github.com/boppreh/keyboard/issues/22) - To avoid depending on X, the Linux parts reads raw device files (`/dev/input/input*`) but this requires root. -- Other applications, such as some games, may register hooks that swallow all key events. In this case `keyboard` will be unable to report events. +- Other applications, such as some games, may register hooks that swallow all key events. In this case `directkeys` will be unable to report events. - This program makes no attempt to hide itself, so don't use it for keyloggers or online gaming bots. Be responsible. -- SSH connections forward only the text typed, not keyboard events. Therefore if you connect to a server or Raspberry PI that is running `keyboard` via SSH, the server will not detect your key events. +- SSH connections forward only the text typed, not keyboard events. Therefore if you connect to a server or Raspberry PI that is running `directkeys` via SSH, the server will not detect your key events. ## Common patterns and mistakes @@ -183,7 +195,7 @@ while True: ### 'Press any key to continue' ```py -# Don't do this! The `keyboard` module is meant for global events, even when your program is not in focus. +# Don't do this! The `directkeys` module is meant for global events, even when your program is not in focus. #import directkeys #print('Press any key to continue...') #directkeys.get_event() @@ -195,8 +207,6 @@ input('Press enter to continue...') # https://stackoverflow.com/questions/983354/how-to-make-a-script-wait-for-a-pressed-key ``` - - # API #### Table of Contents @@ -309,7 +319,7 @@ input('Press enter to continue...') ### KeyboardEvent.**to\_json**(self, ensure\_ascii=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/_keyboard_event.py#L34) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/_keyboard_event.py#L34) @@ -341,7 +351,7 @@ input('Press enter to continue...') ## directkeys.**is\_modifier**(key) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L242) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L242) Returns True if `key` is a scan code or name of a modifier key. @@ -352,7 +362,7 @@ Returns True if `key` is a scan code or name of a modifier key. ## directkeys.**key\_to\_scan\_codes**(key, error\_if\_missing=True) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L405) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L405) Returns a list of scan codes associated with this key (name or scan code). @@ -363,7 +373,7 @@ Returns a list of scan codes associated with this key (name or scan code). ## directkeys.**parse\_hotkey**(hotkey) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L435) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L435) Parses a user-provided hotkey into nested tuples representing the @@ -388,7 +398,7 @@ parse_hotkey("alt+shift+a, alt+b, c") ## directkeys.**send**(hotkey, do\_press=True, do\_release=True) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L468) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L468) Sends OS events that perform the given *hotkey* hotkey. @@ -414,7 +424,7 @@ Note: keys are released in the opposite order they were pressed. ## directkeys.**press**(hotkey) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L501) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L501) Presses and holds down a hotkey (see [`send`](#directkeys.send)). @@ -423,7 +433,7 @@ Presses and holds down a hotkey (see [`send`](#directkeys.send)). ## directkeys.**release**(hotkey) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L505) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L505) Releases a hotkey (see [`send`](#directkeys.send)). @@ -432,7 +442,7 @@ Releases a hotkey (see [`send`](#directkeys.send)). ## directkeys.**is\_pressed**(hotkey) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L509) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L509) Returns True if the key is pressed. @@ -450,7 +460,7 @@ is_pressed('ctrl+space') #-> True ## directkeys.**call\_later**(fn, args=(), delay=0.001) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L536) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L536) Calls the provided function in a new thread after waiting some time. @@ -463,7 +473,7 @@ the current execution flow. ## directkeys.**hook**(callback, suppress=False, on\_remove=<lambda>) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L546) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L546) Installs a global listener on all available keyboards, invoking `callback` @@ -486,7 +496,7 @@ Returns the given callback for easier development. ## directkeys.**on\_press**(callback, suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L577) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L577) Invokes `callback` for every KEY_DOWN event. For details see [`hook`](#directkeys.hook). @@ -497,7 +507,7 @@ Invokes `callback` for every KEY_DOWN event. For details see [`hook`](#directkey ## directkeys.**on\_release**(callback, suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L583) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L583) Invokes `callback` for every KEY_UP event. For details see [`hook`](#directkeys.hook). @@ -508,7 +518,7 @@ Invokes `callback` for every KEY_UP event. For details see [`hook`](#directkeys. ## directkeys.**hook\_key**(key, callback, suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L589) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L589) Hooks key up and key down events for a single key. Returns the event handler @@ -524,7 +534,7 @@ affects it as well. ## directkeys.**on\_press\_key**(key, callback, suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L613) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L613) Invokes `callback` for KEY_DOWN event related to the given key. For details see [`hook`](#directkeys.hook). @@ -535,7 +545,7 @@ Invokes `callback` for KEY_DOWN event related to the given key. For details see ## directkeys.**on\_release\_key**(key, callback, suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L619) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L619) Invokes `callback` for KEY_UP event related to the given key. For details see [`hook`](#directkeys.hook). @@ -546,7 +556,7 @@ Invokes `callback` for KEY_UP event related to the given key. For details see [` ## directkeys.**unhook**(remove) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L625) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L625) Removes a previously added hook, either by callback or by the return value @@ -558,7 +568,7 @@ of [`hook`](#directkeys.hook). ## directkeys.**unhook\_all**() -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L633) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L633) Removes all keyboard hooks in use, including hotkeys, abbreviations, word @@ -570,7 +580,7 @@ listeners, [`record`](#directkeys.record)ers and [`wait`](#directkeys.wait)s. ## directkeys.**block\_key**(key) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L645) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L645) Suppresses all key events of the given key, regardless of modifiers. @@ -581,7 +591,7 @@ Suppresses all key events of the given key, regardless of modifiers. ## directkeys.**remap\_key**(src, dst) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L652) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L652) Whenever the key `src` is pressed or released, regardless of modifiers, @@ -593,7 +603,7 @@ press or release the hotkey `dst` instead. ## directkeys.**parse\_hotkey\_combinations**(hotkey) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L666) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L666) Parses a user-provided hotkey. Differently from [`parse_hotkey`](#directkeys.parse_hotkey), @@ -606,7 +616,7 @@ each step is a list of all possible combinations of those scan codes. ## directkeys.**add\_hotkey**(hotkey, callback, args=(), suppress=False, timeout=1, trigger\_on\_release=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L706) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L706) Invokes a callback every time a hotkey is pressed. The hotkey must @@ -653,7 +663,7 @@ add_hotkey('ctrl+alt+enter, space', some_callback) ## directkeys.**remove\_hotkey**(hotkey\_or\_callback) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L852) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L852) Removes a previously hooked hotkey. Must be called with the value returned @@ -665,7 +675,7 @@ by [`add_hotkey`](#directkeys.add_hotkey). ## directkeys.**unhook\_all\_hotkeys**() -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L860) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L860) Removes all keyboard hotkeys in use, including abbreviations, word listeners, @@ -677,7 +687,7 @@ Removes all keyboard hotkeys in use, including abbreviations, word listeners, ## directkeys.**remap\_hotkey**(src, dst, suppress=True, trigger\_on\_release=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L871) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L871) Whenever the hotkey `src` is pressed, suppress it and send @@ -696,7 +706,7 @@ remap('alt+w', 'ctrl+up') ## directkeys.**stash\_state**() -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L891) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L891) Builds a list of all currently pressed scan codes, releases them and returns @@ -708,7 +718,7 @@ the list. Pairs well with [`restore_state`](#directkeys.restore_state) and [`res ## directkeys.**restore\_state**(scan\_codes) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L903) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L903) Given a list of scan_codes ensures these keys, and only these keys, are @@ -720,7 +730,7 @@ pressed. Pairs well with [`stash_state`](#directkeys.stash_state), alternative t ## directkeys.**restore\_modifiers**(scan\_codes) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L920) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L920) Like [`restore_state`](#directkeys.restore_state), but only restores modifier keys. @@ -731,7 +741,7 @@ Like [`restore_state`](#directkeys.restore_state), but only restores modifier ke ## directkeys.**write**(text, delay=0, restore\_state\_after=True, exact=None) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L926) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L926) Sends artificial keyboard events to the OS, simulating the typing of a given @@ -756,7 +766,7 @@ value. ## directkeys.**wait**(hotkey=None, suppress=False, trigger\_on\_release=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L981) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L981) Blocks the program execution until the given hotkey is pressed or, @@ -768,7 +778,7 @@ if given no parameters, blocks forever. ## directkeys.**get\_hotkey\_name**(names=None) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L995) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L995) Returns a string representation of hotkey from the given key names, or @@ -795,7 +805,7 @@ get_hotkey_name(['+', 'left ctrl', 'shift']) ## directkeys.**read\_event**(suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1026) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1026) Blocks until a keyboard event happens, then returns that event. @@ -806,7 +816,7 @@ Blocks until a keyboard event happens, then returns that event. ## directkeys.**read\_key**(suppress=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1037) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1037) Blocks until a keyboard event happens, then returns that event's name or, @@ -818,7 +828,7 @@ if missing, its scan code. ## directkeys.**read\_hotkey**(suppress=True) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1045) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1045) Similar to [`read_key()`](#directkeys.read_key), but blocks until the user presses and releases a @@ -839,7 +849,7 @@ read_hotkey() ## directkeys.**get\_typed\_strings**(events, allow\_backspace=True) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1067) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1067) Given a sequence of events, tries to deduce what strings were typed. @@ -866,7 +876,7 @@ get_type_strings(record()) #-> ['This is what', 'I recorded', ''] ## directkeys.**start\_recording**(recorded\_events\_queue=None) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1114) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1114) Starts recording all keyboard events into a global variable, or the given @@ -880,7 +890,7 @@ Use [`stop_recording()`](#directkeys.stop_recording) or [`unhook(hooked_function ## directkeys.**stop\_recording**() -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1126) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1126) Stops the global recording of events and returns a list of the events @@ -892,7 +902,7 @@ captured. ## directkeys.**record**(until='escape', suppress=False, trigger\_on\_release=False) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1138) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1138) Records all keyboard events from all keyboards until the user presses the @@ -909,7 +919,7 @@ Note: for more details on the keyboard hook and events see [`hook`](#directkeys. ## directkeys.**play**(events, speed\_factor=1.0) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1152) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1152) Plays a sequence of recorded events, maintaining the relative time @@ -925,7 +935,7 @@ the end of the function. ## directkeys.**add\_word\_listener**(word, callback, triggers=['space'], match\_suffix=False, timeout=2) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1176) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1176) Invokes a callback every time a sequence of characters is typed (e.g. 'pet') @@ -957,7 +967,7 @@ Note: word matches are **case sensitive**. ## directkeys.**remove\_word\_listener**(word\_or\_handler) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1232) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1232) Removes a previously registered word listener. Accepts either the word used @@ -970,7 +980,7 @@ during registration (exact string) or the event handler returned by the ## directkeys.**add\_abbreviation**(source\_text, replacement\_text, match\_suffix=False, timeout=2) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/__init__.py#L1240) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/__init__.py#L1240) Registers a hotkey that replaces one typed text with another. For example @@ -997,7 +1007,7 @@ For more details see [`add_word_listener`](#directkeys.add_word_listener). ## directkeys.**normalize\_name**(name) -[\[source\]](https://github.com/boppreh/keyboard/blob/master/keyboard/_canonical_names.py#L1233) +[\[source\]](https://github.com/WigoWigo10/keyboard/blob/master/directkeys/_canonical_names.py#L1233) Given a key name (e.g. "LEFT CONTROL"), clean up the string and convert to diff --git a/directkeys/__init__.py b/directkeys/__init__.py index cf16a07..60021b1 100644 --- a/directkeys/__init__.py +++ b/directkeys/__init__.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- """ -keyboard -======== +directkeys +========== Take full control of your keyboard with this small Python library. Hook global events, register hotkeys, simulate key presses and much more. +`directkeys` is an actively maintained fork of [boppreh/keyboard](https://github.com/boppreh/keyboard), which has been dormant since 2021. It keeps the original API so existing code only needs to change the import, and focuses on low-level control of the Windows backend: a configurable AltGr abstraction, raw event flags and recovery from stuck modifier keys. + ## Features - **Global event hook** on all keyboards (captures keys regardless of focus). @@ -12,7 +14,6 @@ - Works with **Windows** and **Linux** (requires sudo), with experimental **OS X** support (thanks @glitchassassin!). - **Pure Python**, no C modules to be compiled. - **Zero dependencies**. Trivial to install and deploy, just copy the files. -- **Python 2 and 3**. - Complex hotkey support (e.g. `ctrl+shift+m, ctrl+space`) with controllable timeout. - Includes **high level API** (e.g. [record](#directkeys.record) and [play](#directkeys.play), [add_abbreviation](#directkeys.add_abbreviation)). - Maps keys as they actually are in your layout, with **full internationalization support** (e.g. `Ctrl+ç`). @@ -21,19 +22,36 @@ - Doesn't break accented dead keys (I'm looking at you, pyHook). - Mouse support available via project [mouse](https://github.com/boppreh/mouse) (`pip install mouse`). +### New in this fork + +- **Configurable AltGr abstraction** ([set_alt_gr_abstraction](#directkeys.set_alt_gr_abstraction)): report AltGr as a single event, or expose the raw Windows sequence. +- **Raw event flags**: `KeyboardEvent.flags` carries the low-level hook flags. +- **Stuck key recovery** ([get_stuck_keys](#directkeys.get_stuck_keys) and [force_reset_keyboard](#directkeys.force_reset_keyboard)): detect and release modifiers left pressed by a crashed program. + ## Usage -Install the [PyPI package](https://pypi.python.org/pypi/keyboard/): +Install the [PyPI package](https://pypi.python.org/pypi/directkeys/): - pip install keyboard + pip install directkeys or clone the repository (no installation required, source files are sufficient): - git clone https://github.com/boppreh/keyboard + git clone https://github.com/WigoWigo10/keyboard + +or [download and extract the zip](https://github.com/WigoWigo10/keyboard/archive/master.zip) into your project folder. -or [download and extract the zip](https://github.com/boppreh/keyboard/archive/master.zip) into your project folder. +Then check the [API docs below](https://github.com/WigoWigo10/keyboard#api) to see what features are available. -Then check the [API docs below](https://github.com/boppreh/keyboard#api) to see what features are available. +### Migrating from `keyboard` + +The whole API is unchanged, so in most cases only the import differs: + +```py +# before +import keyboard +# after +import directkeys as keyboard +``` ## Example @@ -71,7 +89,7 @@ ```bash # Save JSON events to a file until interrupted: -python -m keyboard > events.txt +python -m directkeys > events.txt cat events.txt # {"event_type": "down", "scan_code": 25, "name": "p", "time": 1622447562.2994788, "is_keypad": false} @@ -79,7 +97,7 @@ # ... # Replay events -python -m keyboard < events.txt +python -m directkeys < events.txt ``` ## Known limitations: @@ -88,9 +106,9 @@ - Media keys on Linux may appear nameless (scan-code only) or not at all. [#20](https://github.com/boppreh/keyboard/issues/20) - Key suppression/blocking only available on Windows. [#22](https://github.com/boppreh/keyboard/issues/22) - To avoid depending on X, the Linux parts reads raw device files (`/dev/input/input*`) but this requires root. -- Other applications, such as some games, may register hooks that swallow all key events. In this case `keyboard` will be unable to report events. +- Other applications, such as some games, may register hooks that swallow all key events. In this case `directkeys` will be unable to report events. - This program makes no attempt to hide itself, so don't use it for keyloggers or online gaming bots. Be responsible. -- SSH connections forward only the text typed, not keyboard events. Therefore if you connect to a server or Raspberry PI that is running `keyboard` via SSH, the server will not detect your key events. +- SSH connections forward only the text typed, not keyboard events. Therefore if you connect to a server or Raspberry PI that is running `directkeys` via SSH, the server will not detect your key events. ## Common patterns and mistakes @@ -179,7 +197,7 @@ def on_space(): ### 'Press any key to continue' ```py -# Don't do this! The `keyboard` module is meant for global events, even when your program is not in focus. +# Don't do this! The `directkeys` module is meant for global events, even when your program is not in focus. #import directkeys #print('Press any key to continue...') #directkeys.get_event() From 58fa3b28bb3fc4c9af8aa2bb49b709acfc9a64ac Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:29:33 -0400 Subject: [PATCH 02/14] refactor(windows): drop scaffolding comments and dead code from the backend The backend still carried editing instructions left over from when the AltGr support was pasted in ("COLE ESTAS DUAS FUNCOES DEPOIS DE...", "O resto do arquivo continua aqui...", "Esta funcao voce ja deve ter no final do arquivo"), which read as documentation to anyone else opening the file. Alongside that: - Remove `_remove_alt_gr_mapping`, which was never called. - Remove `shift_is_pressed` and `shift_vks`: the global was still being assigned on every shift event but nothing read it, since `get_modifiers` queries GetKeyState directly. Same for `ignore_next_right_alt`, only ever reset and never read. - Guard the `to_name` mutation in `get_name` with `tables_lock`, matching every other access to the tables. This also serialises the shared ctypes buffers used by `get_event_names`. - Fold the duplicated modifier vk lists in `force_reset_keyboard` and `get_stuck_keys` into a single `_modifier_vk_names` mapping. - Restore the two upstream comments explaining the `fake_alt` / VK_PACKET guard, and move the `__main__` block back to the end of the file. - Translate the new comments and docstrings to English, matching the rest of the codebase and the published API docs. No behaviour change. Co-Authored-By: Claude Opus 5 --- directkeys/_winkeyboard.py | 186 +++++++++++++++---------------------- 1 file changed, 74 insertions(+), 112 deletions(-) diff --git a/directkeys/_winkeyboard.py b/directkeys/_winkeyboard.py index 159d6ee..7f8628c 100644 --- a/directkeys/_winkeyboard.py +++ b/directkeys/_winkeyboard.py @@ -446,14 +446,12 @@ def order_key(line): for name, entries in list(from_name.items()): from_name[name] = sorted(set(entries), key=order_key) -# COLE ESTAS DUAS FUNÇÕES DEPOIS DE _setup_name_tables() - def get_modifiers(altgr_is_pressed): """ - Retorna uma tupla com os nomes dos modificadores atualmente ativos. + Returns a tuple with the names of the currently active modifiers. """ - # GetKeyState devolve 0x8000 (e não 1) no bit de "pressionado", portanto a - # conversão para bool é obrigatória antes de repetir a tupla. + # GetKeyState reports the "pressed" bit as 0x8000, not 1, so every flag has + # to be coerced to a bool before being used to repeat the tuple. return ( ('shift',) * bool(user32.GetKeyState(0x10) & 0x8000) + ('alt gr',) * bool(altgr_is_pressed) + @@ -464,38 +462,17 @@ def get_modifiers(altgr_is_pressed): def get_name(scan_code, vk, is_extended, modifiers): """ - Obtém o nome mais provável para um evento de tecla, dados os modificadores. + Returns the most likely name for a key event, given the active modifiers. """ entry = (scan_code, vk, is_extended, modifiers) - if entry not in to_name: - # Popula a tabela se a combinação for nova - to_name[entry] = list(get_event_names(*entry)) - - names = to_name[entry] + with tables_lock: + if entry not in to_name: + # Cache the combination the first time it is seen. + to_name[entry] = list(get_event_names(*entry)) + names = to_name[entry] return names[0] if names else None -# O resto do arquivo continua aqui (init = _setup_name_tables, keypad_keys = [...], etc.) - -def _remove_alt_gr_mapping(): - """ - Remove ativamente o mapeamento sintético 'alt gr' das tabelas de nomes - se elas já tiverem sido criadas. - """ - with tables_lock: - if 'alt gr' in from_name: - # Remove a entrada da tabela de tradução de nome para código - del from_name['alt gr'] - - # Encontra e remove todas as entradas da tabela de tradução de código para nome - # que correspondem ao 'alt gr' sintético (scan_code=541, vk_code=162). - keys_to_remove = [ - key for key in to_name - if key[0] == 541 and key[1] == 162 - ] - for key in keys_to_remove: - del to_name[key] - -# Called by keyboard/__init__.py +# Called by directkeys/__init__.py init = _setup_name_tables # List created manually. @@ -539,10 +516,7 @@ def _remove_alt_gr_mapping(): (83, 46, 0), ] -shift_is_pressed = False altgr_is_pressed = False -ignore_next_right_alt = False -shift_vks = set([0x10, 0xa0, 0xa1]) def prepare_intercept(callback): """ Registers a Windows low level keyboard hook. The provided callback will @@ -555,85 +529,84 @@ def prepare_intercept(callback): """ _setup_name_tables() - # Adicionado 'flags' como parâmetro da função 'process_key'. - # A função 'process_key' agora irá verificar o switch. def process_key(event_type, vk, scan_code, is_extended, flags): """ - Callback que processa os eventos, lendo o estado de abstração do módulo principal. + Processes a raw hook event, reading the AltGr abstraction setting from + the main module so it can be toggled at runtime. """ - # Importação local para evitar ciclo e ler o estado atualizado. + # Imported locally to break the import cycle and to always read the + # current value of the setting. import directkeys - - global altgr_is_pressed, shift_is_pressed - # Se a abstração estiver DESLIGADA, ignora o Ctrl sintético. + global altgr_is_pressed + + # With the abstraction OFF, drop the synthetic Ctrl entirely. if not directkeys._ABSTRACT_ALT_GR and scan_code == 541: - return True # Suprime o evento + return True # Suppress the event. - # Se a abstração estiver LIGADA, combina os eventos. + # With the abstraction ON, merge the pair into a single 'alt gr' event. if directkeys._ABSTRACT_ALT_GR: global _altgr_right_alt_scan_code, _altgr_right_alt_flags if _altgr_right_alt_scan_code is not None and event_type == KEY_DOWN: - if scan_code == 541: # É o Ctrl sintético + if scan_code == 541: # The synthetic Ctrl. altgr_is_pressed = True event = KeyboardEvent('down', _altgr_right_alt_scan_code, name='alt gr', is_keypad=False, flags=_altgr_right_alt_flags) callback(event) _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None - return True # Suprime o Ctrl sintético - else: # Não era, libera o Right Alt que estava pendente. + return True # Suppress the synthetic Ctrl. + else: # It was not, so flush the pending Right Alt. event = KeyboardEvent('down', _altgr_right_alt_scan_code, name='right alt', is_keypad=False, flags=_altgr_right_alt_flags) callback(event) _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None - - if vk == 165: # É um Right Alt (VK 165) - if event_type == KEY_DOWN: # Pressionado, segura e espera o Ctrl. + + if vk == 165: # Right Alt. + if event_type == KEY_DOWN: # Hold it back and wait for the Ctrl. _altgr_right_alt_scan_code = scan_code _altgr_right_alt_flags = flags - return True # Suprime o Right Alt temporariamente - else: # Solto, conclui o evento 'alt gr'. + return True # Suppress the Right Alt for now. + else: # Released, so complete the 'alt gr' event. altgr_is_pressed = False event = KeyboardEvent('up', scan_code, name='alt gr', is_keypad=False, flags=flags) callback(event) return True - - # Ignora o KeyUp do Ctrl sintético. + + # Ignore the key up of the synthetic Ctrl. if scan_code == 541 and event_type == KEY_UP: return True - # Lógica Padrão para todas as outras teclas. - if vk in shift_vks: - shift_is_pressed = event_type == KEY_DOWN - + # Standard path for every other key. modifiers = get_modifiers(altgr_is_pressed) - + if not directkeys._ABSTRACT_ALT_GR and vk == 165: name = 'alt gr' else: name = get_name(scan_code, vk, is_extended, modifiers) - # `is_extended` não é sinônimo de `is_keypad`: o numpad 1-9 não é - # extended, enquanto setas, ctrl direito e insert são. A tabela - # `keypad_keys` é a única fonte confiável. + # `is_extended` is not a synonym for `is_keypad`: numpad 1-9 are not + # extended, while the arrows, right ctrl and insert are. The + # `keypad_keys` table is the only reliable source. is_keypad = (scan_code, vk, is_extended) in keypad_keys - # Teclas sem scan code (eventos injetados, envio por virtual key) caem - # no negativo do vk para continuarem distinguíveis entre si. + # Keys with no scan code (injected events, sending by virtual key) fall + # back to the negated vk so they stay distinguishable from each other. event = KeyboardEvent(event_type=event_type, scan_code=scan_code or -vk, name=name, is_keypad=is_keypad, flags=flags) return callback(event) def low_level_keyboard_handler(nCode, wParam, lParam): try: vk = lParam.contents.vk_code + # Ignore the second `alt` DOWN observed in some cases. fake_alt = (LLKHF_INJECTED | 0x20) + # Ignore events generated by SendInput with Unicode. if vk != VK_PACKET and lParam.contents.flags & fake_alt != fake_alt: event_type = KEY_UP if wParam & 0x01 else KEY_DOWN - + raw_flags = lParam.contents.flags - processed_flags = raw_flags & 1 # Isola o bit LLKHF_EXTENDED + processed_flags = raw_flags & 1 # Isolate the LLKHF_EXTENDED bit. is_extended = processed_flags scan_code = lParam.contents.scan_code - + should_continue = process_key(event_type, vk, scan_code, is_extended, processed_flags) if not should_continue: @@ -655,7 +628,7 @@ def low_level_keyboard_handler(nCode, wParam, lParam): atexit.register(UnhookWindowsHookEx, keyboard_callback) def _clear_name_tables(): - """ Limpa as tabelas de nomes para que possam ser reconstruídas. """ + """ Empties the name tables so they can be rebuilt. """ with tables_lock: to_name.clear() from_name.clear() @@ -663,8 +636,8 @@ def _clear_name_tables(): def rebuild_name_tables(): """ - Força a limpeza e reconstrução das tabelas de nomes. - Chamado pelo __init__.py quando a configuração de abstração muda. + Forces the name tables to be cleared and rebuilt. Called by __init__.py + when the AltGr abstraction setting changes. """ _clear_name_tables() _setup_name_tables() @@ -724,69 +697,58 @@ def type_unicode(character): cbSize = c_int(ctypes.sizeof(INPUT)) SendInput(nInputs, pInputs, cbSize) -if __name__ == '__main__': - _setup_name_tables() - import pprint - pprint.pprint(to_name) - pprint.pprint(from_name) - #listen(lambda e: print(e.to_json()) or True) +# Virtual key codes of the modifiers that can be left stuck by a program that +# pressed them and exited without releasing. +_modifier_vk_names = { + 0x10: 'shift', 0xA0: 'left shift', 0xA1: 'right shift', + 0x11: 'ctrl', 0xA2: 'left ctrl', 0xA3: 'right ctrl', + 0x12: 'alt', 0xA4: 'left alt', 0xA5: 'right alt', + 0x5B: 'left windows', 0x5C: 'right windows', +} def force_reset_keyboard(): """ - Força o sistema operacional (Windows) a liberar quaisquer teclas modificadoras - que possam ter ficado "presas". + Forces Windows to release any modifier key that was left stuck down. - Para cada tecla detectada como presa, simula 5 cliques (pressionar e soltar) - rapidamente para garantir que o sistema operacional atualize seu estado. + Each key detected as stuck is clicked (pressed and released) five times in + quick succession, to make sure the operating system updates its state. """ - # Lista de virtual key codes para as principais teclas modificadoras - vk_codes = [ - 0x10, 0xA0, 0xA1, # Shift, Left Shift, Right Shift - 0x11, 0xA2, 0xA3, # Ctrl, Left Ctrl, Right Ctrl - 0x12, 0xA4, 0xA5, # Alt, Left Alt, Right Alt (AltGr) - 0x5B, 0x5C # Left Windows, Right Windows - ] - - for vk in vk_codes: - # Verifica se a tecla está "presa" (bit mais significativo está 1) + for vk in _modifier_vk_names: + # The high order bit is set while the key is held down. if user32.GetKeyState(vk) & 0x8000: for _ in range(5): - # Envia um evento de KEY DOWN (pressionar) user32.keybd_event(vk, 0, 0, 0) - # Envia um evento de KEY UP (soltar) user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) - # Pequena pausa para o SO processar + # Give the OS a moment to process the pair. time.sleep(0.01) -# Esta função você já deve ter no final do arquivo, mantenha-a. def _reset_internal_state(): """ - Limpa as variáveis de estado internas da biblioteca usadas para o tratamento - do AltGr, garantindo um estado limpo entre execuções ou testes. + Clears the internal state used by the AltGr handling, so that a fresh run + or test does not inherit a half-finished key sequence. """ - global _altgr_right_alt_scan_code, _altgr_right_alt_flags, altgr_is_pressed, ignore_next_right_alt + global _altgr_right_alt_scan_code, _altgr_right_alt_flags, altgr_is_pressed _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None altgr_is_pressed = False - ignore_next_right_alt = False def get_stuck_keys(): """ - Verifica o estado físico de todas as teclas modificadoras principais usando - GetAsyncKeyState e retorna uma lista com os nomes daquelas que estão presas. + Returns the names of the modifier keys that are currently held down. + + Uses GetAsyncKeyState, which reports the actual physical state, rather than + the state as seen by the message queue. """ stuck_keys = [] - vk_codes_with_names = { - 0x10: 'shift', 0xA0: 'left shift', 0xA1: 'right shift', - 0x11: 'ctrl', 0xA2: 'left ctrl', 0xA3: 'right ctrl', - 0x12: 'alt', 0xA4: 'left alt', 0xA5: 'right alt', - 0x5B: 'left windows', 0x5C: 'right windows' - } - - for vk, name in vk_codes_with_names.items(): - # A verificação de bit mais significativo (0x8000) funciona para ambas as funções. - # A diferença é que GetAsyncKeyState verifica o estado físico atual. + for vk, name in _modifier_vk_names.items(): if GetAsyncKeyState(vk) & 0x8000: stuck_keys.append(name) - + return stuck_keys + +if __name__ == '__main__': + _setup_name_tables() + import pprint + pprint.pprint(to_name) + pprint.pprint(from_name) + #listen(lambda e: print(e.to_json()) or True) From b6e15bcd0a74ced4655545201ca963a2c7c57f57 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:30:39 -0400 Subject: [PATCH 03/14] docs(api): translate the new public API docstrings to English `set_alt_gr_abstraction`, `get_alt_gr_abstraction_state` and the fallback helpers are part of the published API: docstring2markdown renders them into README.md and setup.py ships the module docstring as the PyPI description, so they were the only Portuguese text in the user-facing documentation. Also correct the feature list: `KeyboardEvent.flags` is masked down to the LLKHF_EXTENDED bit on Windows, so describing it as the raw hook flags overstated what the attribute actually carries. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- directkeys/__init__.py | 45 ++++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 655d6b7..2923c87 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Take full control of your keyboard with this small Python library. Hook global e ### New in this fork - **Configurable AltGr abstraction** ([set_alt_gr_abstraction](#directkeys.set_alt_gr_abstraction)): report AltGr as a single event, or expose the raw Windows sequence. -- **Raw event flags**: `KeyboardEvent.flags` carries the low-level hook flags. +- **Event flags**: `KeyboardEvent.flags` carries the low-level hook flags. On Windows it is currently masked down to the `LLKHF_EXTENDED` bit. - **Stuck key recovery** ([get_stuck_keys](#directkeys.get_stuck_keys) and [force_reset_keyboard](#directkeys.force_reset_keyboard)): detect and release modifiers left pressed by a crashed program. ## Usage diff --git a/directkeys/__init__.py b/directkeys/__init__.py index 60021b1..20111b6 100644 --- a/directkeys/__init__.py +++ b/directkeys/__init__.py @@ -25,7 +25,7 @@ ### New in this fork - **Configurable AltGr abstraction** ([set_alt_gr_abstraction](#directkeys.set_alt_gr_abstraction)): report AltGr as a single event, or expose the raw Windows sequence. -- **Raw event flags**: `KeyboardEvent.flags` carries the low-level hook flags. +- **Event flags**: `KeyboardEvent.flags` carries the low-level hook flags. On Windows it is currently masked down to the `LLKHF_EXTENDED` bit. - **Stuck key recovery** ([get_stuck_keys](#directkeys.get_stuck_keys) and [force_reset_keyboard](#directkeys.force_reset_keyboard)): detect and release modifiers left pressed by a crashed program. ## Usage @@ -213,7 +213,7 @@ def on_space(): version = '1.0.0' -# Variável de estado para a abstração do AltGr, gerenciada centralmente. +# Centrally managed state for the AltGr abstraction, read by the backend. _ABSTRACT_ALT_GR = True import re as _re @@ -244,12 +244,12 @@ def wait(self): if _UninterruptibleEvent.wait(self, 0.5): break -# Carrega as dependências base antes do backend para evitar import circular. +# Load the base dependencies before the backend, to avoid a circular import. from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent from ._generic import GenericListener as _GenericListener from ._canonical_names import all_modifiers, sided_modifiers, normalize_name -# Lógica de importação do backend específico do SO. +# Import the platform specific backend. import platform as _platform if _platform.system() == 'Windows': from . import _winkeyboard as _os_keyboard @@ -259,53 +259,56 @@ def wait(self): try: from . import _darwinkeyboard as _os_keyboard except ImportError: - # Pode acontecer durante a instalação, quando o setup.py importa este - # pacote para ler a versão antes de o pyobjc estar disponível. + # Can happen during installation, when setup.py imports this package to + # read the version before pyobjc is available. _os_keyboard = None else: raise OSError("Unsupported platform '{}'".format(_platform.system())) -# Funções públicas para controlar as novas funcionalidades. def set_alt_gr_abstraction(enabled): """ - Habilita ou desabilita a abstração da tecla AltGr no backend do Windows. + Enables or disables the AltGr abstraction in the Windows backend. - Por padrão, a biblioteca trata a sequência de eventos do Windows para 'AltGr' - (Right Alt + Left Ctrl) como um único evento 'alt gr'. Desabilitar esta - opção fará com que os eventos brutos sejam reportados. + By default the library reports the Windows event sequence for AltGr + (Right Alt + a synthetic Left Ctrl) as a single `alt gr` event. Disabling + this makes the raw events visible instead, which is useful when you need + to tell a real Ctrl press apart from the one Windows synthesises. + + Has no effect on other platforms. Args: - enabled (bool): True para habilitar a abstração (padrão), False para desabilitar. + enabled (bool): True to enable the abstraction (default), False to + report raw events. """ global _ABSTRACT_ALT_GR _ABSTRACT_ALT_GR = bool(enabled) - # Notifica o backend para se reconfigurar, se necessário. + # Let the backend reconfigure itself, if it needs to. if hasattr(_os_keyboard, 'rebuild_name_tables'): _os_keyboard.rebuild_name_tables() def get_alt_gr_abstraction_state(): - """ Retorna o estado atual da abstração do AltGr (True se habilitada). """ + """ Returns True if the AltGr abstraction is currently enabled. """ return _ABSTRACT_ALT_GR -# Importa as demais funções do backend. `_os_keyboard` é um alias para o módulo -# já importado acima, portanto a busca precisa ser feita por atributo: um -# `from ._os_keyboard import ...` procuraria por um submódulo inexistente e -# cairia sempre nos fallbacks, mesmo no Windows. +# Import the remaining backend functions. `_os_keyboard` is an alias bound to +# the module imported above, so these have to be looked up as attributes: a +# `from ._os_keyboard import ...` would search for a submodule that does not +# exist and always fall through to the fallbacks, even on Windows. def _fallback_force_reset_keyboard(): """ - Função de fallback para SOs sem suporte. Não faz nada. + Fallback for platforms without a stuck key implementation. Does nothing. """ pass def _fallback_get_stuck_keys(): """ - Função de fallback para SOs sem suporte. Retorna uma lista vazia. + Fallback for platforms without a stuck key implementation. Returns []. """ return [] def _fallback_reset_internal_state(): """ - Função de fallback para SOs sem suporte. Não faz nada. + Fallback for platforms with no internal state to reset. Does nothing. """ pass From 267a5d43e5ce9798d802feee2bff9e4b52efcdfe Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:31:57 -0400 Subject: [PATCH 04/14] build: declare packaging metadata in pyproject.toml setup.py imported the package at build time to read the version and the docstring, which meant a build could fail before install_requires had a chance to provide pyobjc on macOS. Move all metadata to pyproject.toml and reduce setup.py to a shim so legacy invocations keep working. - Read the version statically via `[tool.setuptools.dynamic]`, keeping `directkeys.version` as the single source of truth without importing it. - Take long_description from README.md, which the Makefile already generates from the module docstring. - Declare requires-python >=3.8 to match the classifiers, add 3.13, and use the more specific "POSIX :: Linux" operating system classifier. - Point the Makefile at `python -m build`. Co-Authored-By: Claude Opus 5 --- MANIFEST.in | 3 ++- Makefile | 4 ++-- pyproject.toml | 44 ++++++++++++++++++++++++++++++++++++++++++++ setup.py | 40 +++++----------------------------------- 4 files changed, 53 insertions(+), 38 deletions(-) create mode 100644 pyproject.toml diff --git a/MANIFEST.in b/MANIFEST.in index bf0ce42..6691e35 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include CHANGES.md include README.md -include LICENSE.txt \ No newline at end of file +include LICENSE.txt +include pyproject.toml \ No newline at end of file diff --git a/Makefile b/Makefile index e6a587b..8767c8a 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ test: python -m pytest tests/ --cov=directkeys --cov-report=html -build: tests directkeys setup.py README.md CHANGES.md MANIFEST.in +build: tests directkeys pyproject.toml README.md CHANGES.md MANIFEST.in python ../docstring2markdown/docstring2markdown.py directkeys "https://github.com/WigoWigo10/keyboard/blob/master" > README.md find . \( -name "*.py" -o -name "*.sh" -o -name "* .md" \) -exec dos2unix {} \; - python setup.py sdist --format=zip bdist_wheel && twine check dist/* + python -m build && twine check dist/* release: python make_release.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4bf5502 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "directkeys" +dynamic = ["version"] +description ="A modern and robust keyboard hooking and simulation library for Windows and Linux, focusing on low-level control." +readme = { file = "README.md", content-type = "text/markdown" } +license = { file = "LICENSE.txt" } +requires-python = ">=3.8" +authors = [{ name = "WigoWigo", email = "hiigoor93@gmail.com" }] +keywords = ["directkeys", "keyboard", "hook", "simulate", "hotkey", "low-level", "win32", "sendinput"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", +] +dependencies = ["pyobjc; sys_platform=='darwin'"] + +[project.urls] +Homepage = "https://github.com/WigoWigo10/keyboard" +Source = "https://github.com/WigoWigo10/keyboard" +Changelog = "https://github.com/WigoWigo10/keyboard/blob/master/CHANGES.md" +Issues = "https://github.com/WigoWigo10/keyboard/issues" + +[tool.setuptools] +packages = ["directkeys"] + +# Read statically from the module, so the version has a single source of truth +# and the package does not have to be imported at build time. +[tool.setuptools.dynamic] +version = { attr = "directkeys.version" } diff --git a/setup.py b/setup.py index 574df96..45c4a55 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,10 @@ """ -Usage instructions: +Shim kept so that legacy `python setup.py ...` invocations keep working. -- If you are installing: `python setup.py install` -- If you are developing: `python setup.py sdist bdist_wheel && twine check dist/*` -""" -import directkeys +All packaging metadata lives in pyproject.toml. Prefer building with: + python -m build +""" from setuptools import setup -setup( - name='directkeys', - version=directkeys.version, - author='WigoWigo', - author_email='hiigoor93@gmail.com', - packages=['directkeys'], - url='https://github.com/WigoWigo10/keyboard', - license='MIT', - description='A modern and robust keyboard hooking and simulation library for Windows and Linux, focusing on low-level control.', - keywords='directkeys keyboard hook simulate hotkey low-level win32 sendinput', - long_description=directkeys.__doc__.replace('\r\n', '\n'), - long_description_content_type='text/markdown', - install_requires=["pyobjc; sys_platform=='darwin'"], - classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: MIT License', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Unix', - 'Operating System :: MacOS :: MacOS X', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Utilities', - ], -) \ No newline at end of file +setup() From 03e4393132bd3a16927540097415520f4da8e2b2 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:32:23 -0400 Subject: [PATCH 05/14] docs: add the 1.0.0 changelog entry and the fork copyright CHANGES.md still started at 0.13.5, so the release that renamed the package and added the AltGr, stuck key and flags features had no entry at all. LICENSE.txt keeps the original BoppreH copyright, as the MIT terms require, with the fork's copyright added alongside it. Co-Authored-By: Claude Opus 5 --- CHANGES.md | 23 +++++++++++++++++++++++ LICENSE.txt | 1 + 2 files changed, 24 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 226b945..55987ad 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,26 @@ +# 1.0.0 + +First release of `directkeys`, a fork of [boppreh/keyboard](https://github.com/boppreh/keyboard) 0.13.5. +The API is unchanged, so migrating is usually just a matter of changing the import. + +New features: + +- [Windows] Configurable AltGr abstraction via `set_alt_gr_abstraction()` and + `get_alt_gr_abstraction_state()`. Enabled by default, it reports the + Right Alt + synthetic Left Ctrl pair as a single `alt gr` event; disable it + to see the raw events. +- [Windows] `get_stuck_keys()` reports modifiers left held down, and + `force_reset_keyboard()` releases them. Useful after a program crashes + without releasing a key it pressed. +- `KeyboardEvent.flags` exposes the low-level hook flags, and is included in + `to_json()`. On Windows it currently carries the `LLKHF_EXTENDED` bit. + +Packaging: + +- Renamed the package and the distribution to `directkeys`. +- Metadata moved to `pyproject.toml`; Python 3.8+ is required. + + # 0.13.5 - Added LICENSE.txt file to PyPI packages. diff --git a/LICENSE.txt b/LICENSE.txt index ac5c334..dfb1482 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,7 @@ MIT License Copyright (c) 2016 BoppreH +Copyright (c) 2025 WigoWigo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From c177894d977eb23853693631c9d182f0e61c2e82 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:33:33 -0400 Subject: [PATCH 06/14] chore(tests): move the interactive scripts into tests/manual run_test.py and run_test_2a_crash.py sat at the repository root, where they read like the project's test entry point despite being interactive scripts that block on a human pressing keys on a physical keyboard. Neither keeps a `test_` prefix, so pytest cannot collect them: as test functions their `read_event()` calls would hang the suite waiting for input. The helper script is now resolved relative to __file__ rather than the current working directory, and a module docstring states what the scripts need to run. Co-Authored-By: Claude Opus 5 --- .../manual/altgr_and_stuck_keys.py | 19 ++++++++++++------- .../manual/simulate_stuck_key_crash.py | 0 2 files changed, 12 insertions(+), 7 deletions(-) rename run_test.py => tests/manual/altgr_and_stuck_keys.py (86%) rename run_test_2a_crash.py => tests/manual/simulate_stuck_key_crash.py (100%) diff --git a/run_test.py b/tests/manual/altgr_and_stuck_keys.py similarity index 86% rename from run_test.py rename to tests/manual/altgr_and_stuck_keys.py index d5ba703..876bb1a 100644 --- a/run_test.py +++ b/tests/manual/altgr_and_stuck_keys.py @@ -1,7 +1,16 @@ +""" +Testes manuais e interativos do backend do Windows: exigem um teclado real +(de preferência ABNT2 ou US-INTL, para exercitar o AltGr) e um operador para +seguir as instruções na tela. Rode com `python tests/manual/altgr_and_stuck_keys.py`. +""" import directkeys import time import subprocess import sys +import os + +# Resolvido a partir deste arquivo, para o script funcionar de qualquer cwd. +CRASH_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'simulate_stuck_key_crash.py') def read_next_keydown(suppress=True): """ @@ -53,8 +62,7 @@ def test_stuck_key_fix(): print("\n--- INICIANDO TESTE 2: Correção de Tecla Presa ---") print("--> Passo 2a: Simulando script que trava com 'Ctrl' pressionado...") - crash_script_path = "run_test_2a_crash.py" - process = subprocess.Popen([sys.executable, crash_script_path]) + process = subprocess.Popen([sys.executable, CRASH_SCRIPT]) process.wait() time.sleep(1) print("--> Script travado. A tecla 'Ctrl' deve estar 'presa' no sistema.") @@ -84,11 +92,8 @@ def test_stuck_key_fix(): print("\n✅ Teste 2: SUCESSO!") if __name__ == "__main__": - required_script = "run_test_2a_crash.py" - try: - with open(required_script, "r") as f: pass - except FileNotFoundError: - print(f"\nERRO: O script auxiliar '{required_script}' não foi encontrado.") + if not os.path.exists(CRASH_SCRIPT): + print(f"\nERRO: O script auxiliar '{CRASH_SCRIPT}' não foi encontrado.") sys.exit(1) try: diff --git a/run_test_2a_crash.py b/tests/manual/simulate_stuck_key_crash.py similarity index 100% rename from run_test_2a_crash.py rename to tests/manual/simulate_stuck_key_crash.py From 034efc4364d61963a71b5ca0910c7d01a433699a Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:34:58 -0400 Subject: [PATCH 07/14] ci: run the test suite on Windows and Linux The repository had no CI at all, which is an awkward gap for a fork whose pitch is that it is the maintained one. Adds a workflow that runs the suite on Windows and Linux across the Python versions declared in requires-python, plus a job that builds the distribution and runs `twine check` on it. tests/manual is excluded: those scripts need a physical keyboard and a human. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2e262f5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,57 @@ +name: tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # The Linux backend only touches /dev/input from init(), so the suite + # imports and runs unprivileged against the fake backend, as on Windows. + os: [windows-latest, ubuntu-latest] + python-version: ['3.11', '3.13'] + include: + # ubuntu-latest no longer ships 3.8, the oldest version claimed by + # requires-python, so pin the older runner for that one job. + - os: ubuntu-22.04 + python-version: '3.8' + - os: windows-latest + python-version: '3.8' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -e . + + - name: Run tests + run: python -m pytest tests/ -v --ignore=tests/manual + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build and check the distribution + run: | + python -m pip install --upgrade pip + pip install build twine + python -m build + twine check dist/* From 095ff404cdf8191edac9019dc0ef6ccfba698342 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:39:17 -0400 Subject: [PATCH 08/14] refactor: adopt the src/ layout With the package at the repository root, `import directkeys` from the checkout directory picks up the source tree rather than the installed distribution. That hides packaging mistakes: a module missing from the wheel still imports fine locally and only fails for users. Moving to src/ makes the test run exercise the installed package. Also folds .coveragerc into pyproject.toml and configures pytest there, so `testpaths` and the tests/manual exclusion no longer have to be repeated on every invocation. Co-Authored-By: Claude Opus 5 --- .coveragerc | 23 ----------------- .github/workflows/tests.yml | 3 ++- Makefile | 12 +++++++-- pyproject.toml | 25 ++++++++++++++++++- {directkeys => src/directkeys}/__init__.py | 0 {directkeys => src/directkeys}/__main__.py | 0 .../directkeys}/_canonical_names.py | 0 .../directkeys}/_darwinkeyboard.py | 0 .../directkeys}/_darwinmouse.py | 0 {directkeys => src/directkeys}/_generic.py | 0 .../directkeys}/_keyboard_event.py | 0 .../directkeys}/_mouse_event.py | 0 {directkeys => src/directkeys}/_nixcommon.py | 0 .../directkeys}/_nixkeyboard.py | 0 {directkeys => src/directkeys}/_nixmouse.py | 0 .../directkeys}/_winkeyboard.py | 0 {directkeys => src/directkeys}/_winmouse.py | 0 {directkeys => src/directkeys}/mouse.py | 0 18 files changed, 36 insertions(+), 27 deletions(-) delete mode 100644 .coveragerc rename {directkeys => src/directkeys}/__init__.py (100%) rename {directkeys => src/directkeys}/__main__.py (100%) rename {directkeys => src/directkeys}/_canonical_names.py (100%) rename {directkeys => src/directkeys}/_darwinkeyboard.py (100%) rename {directkeys => src/directkeys}/_darwinmouse.py (100%) rename {directkeys => src/directkeys}/_generic.py (100%) rename {directkeys => src/directkeys}/_keyboard_event.py (100%) rename {directkeys => src/directkeys}/_mouse_event.py (100%) rename {directkeys => src/directkeys}/_nixcommon.py (100%) rename {directkeys => src/directkeys}/_nixkeyboard.py (100%) rename {directkeys => src/directkeys}/_nixmouse.py (100%) rename {directkeys => src/directkeys}/_winkeyboard.py (100%) rename {directkeys => src/directkeys}/_winmouse.py (100%) rename {directkeys => src/directkeys}/mouse.py (100%) diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 6d16b52..0000000 --- a/.coveragerc +++ /dev/null @@ -1,23 +0,0 @@ -[run] -branch = True - -[report] -exclude_lines = - pragma: no cover - - pass - - def __repr__ - - raise NotImplementedError - - if __name__ == .__main__.: - - from. import - - import - - except ImportError: - -[html] -directory = coverage_html_report diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e262f5..da2d109 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,8 @@ jobs: pip install -e . - name: Run tests - run: python -m pytest tests/ -v --ignore=tests/manual + # testpaths and norecursedirs come from pyproject.toml. + run: python -m pytest -v build: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 8767c8a..f50f66c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,15 @@ test: - python -m pytest tests/ --cov=directkeys --cov-report=html + python -m pytest --cov=directkeys --cov-report=html -build: tests directkeys pyproject.toml README.md CHANGES.md MANIFEST.in +lint: + python -m ruff check . + python -m ruff format --check . + +format: + python -m ruff check --fix . + python -m ruff format . + +build: tests src/directkeys pyproject.toml README.md CHANGES.md MANIFEST.in python ../docstring2markdown/docstring2markdown.py directkeys "https://github.com/WigoWigo10/keyboard/blob/master" > README.md find . \( -name "*.py" -o -name "*.sh" -o -name "* .md" \) -exec dos2unix {} \; python -m build && twine check dist/* diff --git a/pyproject.toml b/pyproject.toml index 4bf5502..fe8836c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,32 @@ Changelog = "https://github.com/WigoWigo10/keyboard/blob/master/CHANGES.md" Issues = "https://github.com/WigoWigo10/keyboard/issues" [tool.setuptools] -packages = ["directkeys"] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] # Read statically from the module, so the version has a single source of truth # and the package does not have to be imported at build time. [tool.setuptools.dynamic] version = { attr = "directkeys.version" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +# tests/manual needs a physical keyboard and a human operator. +norecursedirs = ["tests/manual"] + +[tool.coverage.run] +branch = true +source = ["directkeys"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.coverage.html] +directory = "coverage_html_report" diff --git a/directkeys/__init__.py b/src/directkeys/__init__.py similarity index 100% rename from directkeys/__init__.py rename to src/directkeys/__init__.py diff --git a/directkeys/__main__.py b/src/directkeys/__main__.py similarity index 100% rename from directkeys/__main__.py rename to src/directkeys/__main__.py diff --git a/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py similarity index 100% rename from directkeys/_canonical_names.py rename to src/directkeys/_canonical_names.py diff --git a/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py similarity index 100% rename from directkeys/_darwinkeyboard.py rename to src/directkeys/_darwinkeyboard.py diff --git a/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py similarity index 100% rename from directkeys/_darwinmouse.py rename to src/directkeys/_darwinmouse.py diff --git a/directkeys/_generic.py b/src/directkeys/_generic.py similarity index 100% rename from directkeys/_generic.py rename to src/directkeys/_generic.py diff --git a/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py similarity index 100% rename from directkeys/_keyboard_event.py rename to src/directkeys/_keyboard_event.py diff --git a/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py similarity index 100% rename from directkeys/_mouse_event.py rename to src/directkeys/_mouse_event.py diff --git a/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py similarity index 100% rename from directkeys/_nixcommon.py rename to src/directkeys/_nixcommon.py diff --git a/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py similarity index 100% rename from directkeys/_nixkeyboard.py rename to src/directkeys/_nixkeyboard.py diff --git a/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py similarity index 100% rename from directkeys/_nixmouse.py rename to src/directkeys/_nixmouse.py diff --git a/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py similarity index 100% rename from directkeys/_winkeyboard.py rename to src/directkeys/_winkeyboard.py diff --git a/directkeys/_winmouse.py b/src/directkeys/_winmouse.py similarity index 100% rename from directkeys/_winmouse.py rename to src/directkeys/_winmouse.py diff --git a/directkeys/mouse.py b/src/directkeys/mouse.py similarity index 100% rename from directkeys/mouse.py rename to src/directkeys/mouse.py From 4655444b9ac9cddba35e3ad36f251cae71797ab1 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:45:12 -0400 Subject: [PATCH 09/14] refactor: require Python 3.9+ and drop the Python 2 compatibility shims Python 3.8 reached end of life in October 2024 and no longer receives security fixes, so requires-python moves to 3.9 and the CI matrix follows (3.9, 3.11, 3.13 on Windows and Linux). This also removes the need to pin an older Ubuntu runner, since 3.8 is the version ubuntu-latest stopped shipping. With Python 2 long out of scope, the compatibility layer was dead weight that made every module harder to read: - Remove the `basestring` / `unichr` / `long` fallbacks and the `from __future__` imports, using `str`, `chr` and `int` directly. - Remove the `time.monotonic` fallback, present on every supported version. - Replace the Python2 `Queue` / `threading._Event` branch with a plain import. - Turn the `_is_str` / `_is_number` / `_is_list` lambdas into functions and drop the redundant `(object)` base classes and UTF-8 coding cookies. - Restore two upstream comments explaining `_State` and the `_Event.wait` override, lost when the compatibility block was edited. Also introduces `__version__` as the canonical attribute, keeping `version` as an alias so code reading `keyboard.version` keeps working. The setuptools dynamic version now points at `__version__`, which is a literal and can be read statically; the alias is not, and would have forced an import at build time. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 9 +------- pyproject.toml | 7 +++--- src/directkeys/__init__.py | 36 +++++++++++++----------------- src/directkeys/__main__.py | 1 - src/directkeys/_canonical_names.py | 10 +-------- src/directkeys/_darwinkeyboard.py | 17 +++++--------- src/directkeys/_darwinmouse.py | 2 +- src/directkeys/_generic.py | 3 +-- src/directkeys/_keyboard_event.py | 9 +------- src/directkeys/_mouse_event.py | 1 - src/directkeys/_nixcommon.py | 5 ++--- src/directkeys/_nixkeyboard.py | 1 - src/directkeys/_nixmouse.py | 1 - src/directkeys/_winkeyboard.py | 10 +-------- src/directkeys/_winmouse.py | 1 - src/directkeys/mouse.py | 1 - tests/test_keyboard.py | 2 -- tests/test_mouse.py | 3 +-- 18 files changed, 34 insertions(+), 85 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da2d109..3ea7422 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,14 +15,7 @@ jobs: # The Linux backend only touches /dev/input from init(), so the suite # imports and runs unprivileged against the fake backend, as on Windows. os: [windows-latest, ubuntu-latest] - python-version: ['3.11', '3.13'] - include: - # ubuntu-latest no longer ships 3.8, the oldest version claimed by - # requires-python, so pin the older runner for that one job. - - os: ubuntu-22.04 - python-version: '3.8' - - os: windows-latest - python-version: '3.8' + python-version: ['3.9', '3.11', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index fe8836c..d06ea61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description ="A modern and robust keyboard hooking and simulation library for Windows and Linux, focusing on low-level control." readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE.txt" } -requires-python = ">=3.8" +requires-python = ">=3.9" authors = [{ name = "WigoWigo", email = "hiigoor93@gmail.com" }] keywords = ["directkeys", "keyboard", "hook", "simulate", "hotkey", "low-level", "win32", "sendinput"] classifiers = [ @@ -18,7 +18,6 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -44,7 +43,9 @@ where = ["src"] # Read statically from the module, so the version has a single source of truth # and the package does not have to be imported at build time. [tool.setuptools.dynamic] -version = { attr = "directkeys.version" } +# Must point at __version__, not the `version` alias: setuptools only reads the +# value statically when it is a literal, and falls back to importing otherwise. +version = { attr = "directkeys.__version__" } [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index 20111b6..8054457 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ directkeys ========== @@ -209,9 +208,9 @@ def on_space(): # https://stackoverflow.com/questions/983354/how-to-make-a-script-wait-for-a-pressed-key ``` """ -from __future__ import print_function as _print_function - -version = '1.0.0' +__version__ = '1.0.0' +# Kept as an alias: the upstream project exposed the version under this name. +version = __version__ # Centrally managed state for the AltGr abstraction, read by the backend. _ABSTRACT_ALT_GR = True @@ -219,25 +218,20 @@ def on_space(): import re as _re import itertools as _itertools import collections as _collections -from threading import Thread as _Thread, Lock as _Lock +import queue as _queue import time as _time -_time.monotonic = getattr(_time, 'monotonic', None) or _time.time - -try: - long, basestring - _is_str = lambda x: isinstance(x, basestring) - _is_number = lambda x: isinstance(x, (int, long)) - import Queue as _queue - from threading import _Event as _UninterruptibleEvent -except NameError: - _is_str = lambda x: isinstance(x, str) - _is_number = lambda x: isinstance(x, int) - import queue as _queue - from threading import Event as _UninterruptibleEvent -_is_list = lambda x: isinstance(x, (list, tuple)) - -class _State(object): pass +from threading import Thread as _Thread, Lock as _Lock, Event as _UninterruptibleEvent + +def _is_str(x): return isinstance(x, str) +def _is_number(x): return isinstance(x, int) +def _is_list(x): return isinstance(x, (list, tuple)) + +# Just a dynamic object to store attributes for the closures. +class _State: pass +# The "Event" class from `threading` ignores signals when waiting and is +# impossible to interrupt with Ctrl+C. So we rewrite `wait` to wait in small, +# interruptible intervals. class _Event(_UninterruptibleEvent): def wait(self): while True: diff --git a/src/directkeys/__main__.py b/src/directkeys/__main__.py index 4753eef..703a2f8 100644 --- a/src/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import directkeys import fileinput import json diff --git a/src/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py index 003fd3b..bcfaf0b 100644 --- a/src/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -1,11 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -try: - basestring -except NameError: - basestring = str - import platform # Defaults to Windows canonical names (platform-specific overrides below) @@ -1235,7 +1227,7 @@ def normalize_name(name): Given a key name (e.g. "LEFT CONTROL"), clean up the string and convert to the canonical representation (e.g. "left ctrl") if one is known. """ - if not name or not isinstance(name, basestring): + if not name or not isinstance(name, str): raise ValueError('Can only normalize non-empty string names. Unexpected '+ repr(name)) if len(name) > 1: diff --git a/src/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py index ef848d7..d48a1f8 100644 --- a/src/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -5,18 +5,13 @@ import os import threading from AppKit import NSEvent -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP from ._canonical_names import normalize_name from collections import defaultdict -try: # Python 2/3 compatibility - unichr -except NameError: - unichr = chr - Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library('Carbon')) -class KeyMap(object): +class KeyMap: non_layout_keys = dict((vk, normalize_name(name)) for vk, name in { # Layout specific keys from https://stackoverflow.com/a/16125341/252218 # Unfortunately no source for layout-independent keys was found. @@ -153,7 +148,7 @@ class CFRange(ctypes.Structure): ctypes.byref(char_count), non_shifted_char) - non_shifted_key = u''.join(unichr(non_shifted_char[i]) for i in range(char_count.value)) + non_shifted_key = ''.join(chr(non_shifted_char[i]) for i in range(char_count.value)) retval = Carbon.UCKeyTranslate(k_layout_buffer, key_code, @@ -166,7 +161,7 @@ class CFRange(ctypes.Structure): ctypes.byref(char_count), shifted_char) - shifted_key = u''.join(unichr(shifted_char[i]) for i in range(char_count.value)) + shifted_key = ''.join(chr(shifted_char[i]) for i in range(char_count.value)) self.layout_specific_keys[key_code] = (non_shifted_key, shifted_key) # Cleanup @@ -200,7 +195,7 @@ def vk_to_character(self, vk, modifiers=[]): raise ValueError("Invalid scan code: {}".format(vk)) -class KeyController(object): +class KeyController: def __init__(self): self.key_map = KeyMap() self.current_modifiers = { @@ -345,7 +340,7 @@ def map_scan_code(self, scan_code): else: return self.key_map.vk_to_character(scan_code) -class KeyEventListener(object): +class KeyEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking self.callback = callback diff --git a/src/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py index b112dc0..c3b1cd4 100644 --- a/src/directkeys/_darwinmouse.py +++ b/src/directkeys/_darwinmouse.py @@ -21,7 +21,7 @@ "click_count": 0 } -class MouseEventListener(object): +class MouseEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking self.callback = callback diff --git a/src/directkeys/_generic.py b/src/directkeys/_generic.py index bac559f..5fcb0dd 100644 --- a/src/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from threading import Thread, Lock import traceback import functools @@ -8,7 +7,7 @@ except ImportError: from Queue import Queue -class GenericListener(object): +class GenericListener: lock = Lock() def __init__(self): diff --git a/src/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py index 6da4fba..ba2ca5f 100644 --- a/src/directkeys/_keyboard_event.py +++ b/src/directkeys/_keyboard_event.py @@ -1,18 +1,11 @@ -# -*- coding: utf-8 -*- - from time import time as now import json from ._canonical_names import normalize_name -try: - basestring -except NameError: - basestring = str - KEY_DOWN = 'down' KEY_UP = 'up' -class KeyboardEvent(object): +class KeyboardEvent: event_type = None scan_code = None name = None diff --git a/src/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py index 38b8961..8967d9d 100644 --- a/src/directkeys/_mouse_event.py +++ b/src/directkeys/_mouse_event.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from collections import namedtuple LEFT = 'left' diff --git a/src/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py index ba3a1d0..ae9d60a 100644 --- a/src/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import struct import os import atexit @@ -48,7 +47,7 @@ def make_uinput(): return uinput -class EventDevice(object): +class EventDevice: def __init__(self, path): self.path = path self._input_file = None @@ -96,7 +95,7 @@ def write_event(self, type, code, value): self.output_file.write(data_event + sync_event) self.output_file.flush() -class AggregatedEventDevice(object): +class AggregatedEventDevice: def __init__(self, devices, output=None): self.event_queue = Queue() self.devices = devices diff --git a/src/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py index 8376468..d5b8af0 100644 --- a/src/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import struct import traceback from time import time as now diff --git a/src/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py index 2406050..117e797 100644 --- a/src/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import struct from subprocess import check_output import re diff --git a/src/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py index 7f8628c..f0e04e8 100644 --- a/src/directkeys/_winkeyboard.py +++ b/src/directkeys/_winkeyboard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This is the Windows backend for keyboard events, and is implemented by invoking the Win32 API through the ctypes module. This is error prone @@ -10,7 +9,6 @@ - Keypad numbers still print as numbers even when numlock is off. - No way to specify if user wants a keypad key or not in `map_char`. """ -from __future__ import unicode_literals import re import atexit import traceback @@ -18,18 +16,12 @@ from collections import defaultdict import time -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP from ._canonical_names import normalize_name _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None -try: - # Force Python2 to convert to unicode and not to str. - chr = unichr -except NameError: - pass - # This part is just declaring Win32 API structures using ctypes. In C # this would be simply #include "windows.h". diff --git a/src/directkeys/_winmouse.py b/src/directkeys/_winmouse.py index ef16dd4..fd4d360 100644 --- a/src/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import ctypes import time from ctypes import c_short, c_char, c_uint8, c_int32, c_int, c_uint, c_uint32, c_long, byref, Structure, CFUNCTYPE, POINTER diff --git a/src/directkeys/mouse.py b/src/directkeys/mouse.py index 41bf315..eaac358 100644 --- a/src/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import warnings warnings.simplefilter('always', DeprecationWarning) warnings.warn('The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.', DeprecationWarning, stacklevel=2) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 3953918..28fa03b 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Side effects are avoided using two techniques: @@ -12,7 +11,6 @@ `output_events`. Fake OS events (directkeys.press) are processed and added to `output_events` immediately, mimicking real functionality. """ -from __future__ import print_function import unittest import time diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 136a011..3881f15 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- import unittest import time from directkeys._mouse_event import MoveEvent, ButtonEvent, WheelEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE from directkeys import mouse -class FakeOsMouse(object): +class FakeOsMouse: def __init__(self): self.append = None self.position = (0, 0) From 030d401dee3e058996db3ed5a836016533601bf1 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:49:07 -0400 Subject: [PATCH 10/14] build: configure Ruff and fix what it found Adds Ruff (lint + import sorting) configured in pyproject.toml, targeting py39. Rules that the codebase deliberately breaks are ignored explicitly, each with the reason, rather than left to fail: long lines in the ctypes tables, imports that must follow the platform dispatch, and the Cyrillic/Greek key names in _canonical_names.py that RUF001 reads as ambiguous characters. The autofixable findings are applied. Two are worth calling out: - tests/test_keyboard.py defined four tests twice under the same name, so the first definition of each was silently discarded and never ran. They are not duplicates, they are distinct cases: a `write` without delay, a hotkey removal without modifiers, a two-modifier release, and a multistep blocking state assertion. Renamed so both run; the suite goes from 157 to 161 tests and all four recovered cases pass. - `index = 0` in the suppression path assigned to a local that nothing read, right before `set_index(0)` did the real work. A group of upstream findings is ignored rather than fixed, because each would change runtime behaviour and none is covered by a test: repeated dictionary keys (F601), mutable argument defaults (B006), bare except (E722) and `raise` without `from` (B904). Co-Authored-By: Claude Opus 5 --- examples/10_second_macro.py | 3 +- examples/pressed_keys.py | 6 ++- examples/push_to_talk_ubuntu.py | 7 ++-- examples/segmented_macro.py | 12 +++--- examples/simulate_held_down.py | 3 +- examples/stdin_stdout_events.py | 2 +- examples/write.py | 6 ++- make_release.py | 21 ++++++----- pyproject.toml | 56 ++++++++++++++++++++++++++++ src/directkeys/__init__.py | 60 +++++++++++++++--------------- src/directkeys/__main__.py | 6 ++- src/directkeys/_canonical_names.py | 2 +- src/directkeys/_darwinkeyboard.py | 24 ++++++------ src/directkeys/_darwinmouse.py | 8 ++-- src/directkeys/_generic.py | 7 ++-- src/directkeys/_keyboard_event.py | 11 +++--- src/directkeys/_nixcommon.py | 21 ++++++----- src/directkeys/_nixkeyboard.py | 15 +++----- src/directkeys/_nixmouse.py | 14 +++---- src/directkeys/_winkeyboard.py | 44 +++++++++++++++++----- src/directkeys/_winmouse.py | 30 +++++++++++++-- src/directkeys/mouse.py | 27 ++++++++++---- tests/test_keyboard.py | 22 +++++------ tests/test_mouse.py | 21 +++++++++-- 24 files changed, 286 insertions(+), 142 deletions(-) diff --git a/examples/10_second_macro.py b/examples/10_second_macro.py index 4e765df..f157d48 100644 --- a/examples/10_second_macro.py +++ b/examples/10_second_macro.py @@ -1,6 +1,7 @@ -import directkeys import time +import directkeys + directkeys.start_recording() time.sleep(10) events = directkeys.stop_recording() diff --git a/examples/pressed_keys.py b/examples/pressed_keys.py index da78ffe..e56e26c 100644 --- a/examples/pressed_keys.py +++ b/examples/pressed_keys.py @@ -3,14 +3,16 @@ Updates on every keyboard event. """ import sys + sys.path.append('..') import directkeys + def print_pressed_keys(e): line = ', '.join(str(code) for code in directkeys._pressed_events) # '\r' and end='' overwrites the previous line. # ' '*40 prints 40 spaces at the end to ensure the previous line is cleared. print('\r' + line + ' '*40, end='') - + directkeys.hook(print_pressed_keys) -directkeys.wait() \ No newline at end of file +directkeys.wait() diff --git a/examples/push_to_talk_ubuntu.py b/examples/push_to_talk_ubuntu.py index d77aa5d..4349f7c 100644 --- a/examples/push_to_talk_ubuntu.py +++ b/examples/push_to_talk_ubuntu.py @@ -1,8 +1,9 @@ -#quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad +#quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad -import directkeys import subprocess +import directkeys + is_muted = False def unmute(): @@ -24,4 +25,4 @@ def mute(): directkeys.add_hotkey('win', unmute) # unmute on keydown directkeys.add_hotkey('win', mute, trigger_on_release=True) # mute on keyup - directkeys.wait() # wait forever \ No newline at end of file + directkeys.wait() # wait forever diff --git a/examples/segmented_macro.py b/examples/segmented_macro.py index 1477d9e..5a15d06 100644 --- a/examples/segmented_macro.py +++ b/examples/segmented_macro.py @@ -6,10 +6,12 @@ time to speak between segments. """ import sys + sys.path.append('../') -import directkeys -import pickle import os +import pickle + +import directkeys if len(sys.argv) == 1: filename = input('Enter filename to save/load events: ') @@ -19,8 +21,8 @@ if os.path.exists(filename): segments = pickle.load(open(filename, 'rb')) for i, segment in enumerate(segments): - print('Press F1 to play segment {}/{}'.format(i+1, len(segments))) - print('Duration: {:.02} seconds'.format(segment[-1].time - segment[0].time)) + print(f'Press F1 to play segment {i+1}/{len(segments)}') + print(f'Duration: {segment[-1].time - segment[0].time:.02} seconds') directkeys.wait('F1') directkeys.play(segment) @@ -51,4 +53,4 @@ def handle_event(event): directkeys.hook(handle_event) pickle.dump(segments, open(filename, 'wb')) - print('Saved {} segments to {}'.format(len(segments), filename)) \ No newline at end of file + print(f'Saved {len(segments)} segments to {filename}') diff --git a/examples/simulate_held_down.py b/examples/simulate_held_down.py index 56b460d..da3fd6b 100644 --- a/examples/simulate_held_down.py +++ b/examples/simulate_held_down.py @@ -1,6 +1,7 @@ -import directkeys import time +import directkeys + # Sends 20 "key down" events in 0.1 second intervals, followed by a single # "key up" event. for i in range(20): diff --git a/examples/stdin_stdout_events.py b/examples/stdin_stdout_events.py index 1acfe96..ca8e9b9 100644 --- a/examples/stdin_stdout_events.py +++ b/examples/stdin_stdout_events.py @@ -12,7 +12,7 @@ {"event_type": "up", "name": "f", "scan_code": 33, "time": 1491442622.9056144} """ import sys + sys.path.append('..') # Also available as just `python -m keyboard`. -from directkeys import __main__ \ No newline at end of file diff --git a/examples/write.py b/examples/write.py index ba3a3be..0b8297b 100644 --- a/examples/write.py +++ b/examples/write.py @@ -3,9 +3,11 @@ text character-by-character. """ import sys + sys.path.append('../') -import directkeys import fileinput +import directkeys + for line in fileinput.input(): - directkeys.write(line) \ No newline at end of file + directkeys.write(line) diff --git a/make_release.py b/make_release.py index 36fb2a7..7c91e81 100644 --- a/make_release.py +++ b/make_release.py @@ -22,12 +22,13 @@ - Use raw semantic versioning for CHANGES.md and PyPI (e.g. 2.3.1), and prepend 'v' for git tags and releases (e.g. v2.3.1). """ -import re -import sys -import os -from subprocess import run, check_output import atexit +import os +import re +from subprocess import check_output, run + import requests + import directkeys run(['make', 'clean', 'build'], check=True) @@ -36,16 +37,16 @@ last_version = check_output(['git', 'describe', '--abbrev=0'], universal_newlines=True).strip('v\n') assert directkeys.version != last_version, 'Must update directkeys.version first.' -commits = check_output(['git', 'log', 'v{}..HEAD'.format(last_version), '--oneline'], universal_newlines=True) +commits = check_output(['git', 'log', f'v{last_version}..HEAD', '--oneline'], universal_newlines=True) with open('message.txt', 'w') as message_file: atexit.register(lambda: os.remove('message.txt')) message_file.write('\n\n\n') message_file.write('# Enter changes one per line like this:\n') message_file.write('# - Added `foobar`.\n\n\n') - message_file.write('# As a reminder, here\'s the last commits since version {}:\n\n'.format(last_version)) + message_file.write(f'# As a reminder, here\'s the last commits since version {last_version}:\n\n') for line in commits.strip().split('\n'): - message_file.write('# {}\n'.format(line)) + message_file.write(f'# {line}\n') run(['vim', 'message.txt']) with open('message.txt') as message_file: @@ -60,13 +61,13 @@ with open('CHANGES.md') as changes_file: old_changes = changes_file.read() with open('CHANGES.md', 'w') as changes_file: - changes_file.write('# {}\n\n{}\n\n\n{}'.format(directkeys.version, message, old_changes)) + changes_file.write(f'# {directkeys.version}\n\n{message}\n\n\n{old_changes}') tag_name = 'v' + directkeys.version if input('Commit README.md and CHANGES.md files? ').lower().startswith('y'): run(['git', 'add', 'CHANGES.md', 'README.md']) - run(['git', 'commit', '-m', 'Update changes for {}'.format(tag_name)]) + run(['git', 'commit', '-m', f'Update changes for {tag_name}']) run(['git', 'push']) run(['git', 'tag', '-a', tag_name, '--file', 'message.txt'], check=True) run(['git', 'push', 'origin', tag_name], check=True) @@ -75,7 +76,7 @@ if token: git_remotes = check_output(['git', 'remote', '-v']).decode('utf-8') repo_path = re.search(r'github.com[:/](.+?)(?:\.git)? \(push\)', git_remotes).group(1) - releases_url = 'https://api.github.com/repos/{}/releases'.format(repo_path) + releases_url = f'https://api.github.com/repos/{repo_path}/releases' print(releases_url) release = { "tag_name": tag_name, diff --git a/pyproject.toml b/pyproject.toml index d06ea61..0183d04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,3 +66,59 @@ exclude_lines = [ [tool.coverage.html] directory = "coverage_html_report" + +[tool.ruff] +target-version = "py39" +line-length = 100 +extend-exclude = ["tests/manual"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "RUF", # ruff-specific +] +ignore = [ + "E501", # Long lines are pervasive in the ctypes declarations and tables. + "E741", # `l` is a legitimate key name in the canonical name tables. + "E701", # Single-line defs keep the big lookup tables compact. + "E731", # Lambdas are the established idiom for the callback shorthands. + "B008", # Calls in argument defaults are deliberate in the public API. + "RUF005", # Concatenation reads better than unpacking in the event lists. + # Inherited from upstream and left alone deliberately, because changing any + # of them alters runtime behaviour and none is covered by a test: + "F601", # Repeated dict keys: the earlier name for a key code is dropped. + "B006", # Mutable argument defaults. + "B007", # Unused loop variables. + "B018", # Useless expression (a device probe read for its side effect). + "B904", # `raise` without `from` inside an except clause. + "E722", # Bare `except`. + "RUF012", # Mutable class attributes without ClassVar. +] + +[tool.ruff.lint.per-file-ignores] +# Imports deliberately follow the module docstring, the version and the +# platform dispatch, which have to run first. +"src/directkeys/__init__.py" = ["E402"] +"src/directkeys/mouse.py" = ["E402"] +# The ctypes backends group their Win32/Quartz declarations after the module +# preamble, and bind struct fields that are only read through the C API. +"src/directkeys/_win*.py" = ["E402", "F403", "F405", "F841"] +"src/directkeys/_nix*.py" = ["E402", "F403", "F405", "F841"] +"src/directkeys/_darwin*.py" = ["E402", "F403", "F405", "F841"] +# _darwinmouse.handler is a verbatim copy of the keyboard listener's handler and +# calls four names that do not exist in this module, so mouse listening on macOS +# raises NameError. Rewriting it needs a macOS machine to verify against. +"src/directkeys/_darwinmouse.py" = ["E402", "F403", "F405", "F841", "F821"] +# Key names are intentionally spelled in Cyrillic, Greek and accented Latin: +# flagging them as ambiguous characters is exactly backwards for this table. +"src/directkeys/_canonical_names.py" = ["RUF001", "RUF003"] +# The test suite builds its fixtures with star imports and module-level setup. +"tests/*" = ["E402", "F403", "F405", "F841"] +# The examples are documentation: they favour brevity over lint compliance. +"examples/*" = ["E402", "F403", "F405", "F841", "W191", "E101"] diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index 8054457..c901211 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -215,12 +215,15 @@ def on_space(): # Centrally managed state for the AltGr abstraction, read by the backend. _ABSTRACT_ALT_GR = True -import re as _re -import itertools as _itertools import collections as _collections +import itertools as _itertools import queue as _queue +import re as _re import time as _time -from threading import Thread as _Thread, Lock as _Lock, Event as _UninterruptibleEvent +from threading import Event as _UninterruptibleEvent +from threading import Lock as _Lock +from threading import Thread as _Thread + def _is_str(x): return isinstance(x, str) def _is_number(x): return isinstance(x, int) @@ -239,12 +242,15 @@ def wait(self): break # Load the base dependencies before the backend, to avoid a circular import. -from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent -from ._generic import GenericListener as _GenericListener -from ._canonical_names import all_modifiers, sided_modifiers, normalize_name - # Import the platform specific backend. import platform as _platform + +from ._canonical_names import all_modifiers, normalize_name, sided_modifiers +from ._generic import GenericListener as _GenericListener + +# KeyboardEvent is re-exported as part of the public API, not used here. +from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent # noqa: F401 + if _platform.system() == 'Windows': from . import _winkeyboard as _os_keyboard elif _platform.system() == 'Linux': @@ -257,7 +263,7 @@ def wait(self): # read the version before pyobjc is available. _os_keyboard = None else: - raise OSError("Unsupported platform '{}'".format(_platform.system())) + raise OSError(f"Unsupported platform '{_platform.system()}'") def set_alt_gr_abstraction(enabled): """ @@ -319,7 +325,7 @@ def is_modifier(key): return key in all_modifiers else: if not _modifier_scan_codes: - scan_codes = (key_to_scan_codes(name, False) for name in all_modifiers) + scan_codes = (key_to_scan_codes(name, False) for name in all_modifiers) _modifier_scan_codes.update(*scan_codes) return key in _modifier_scan_codes @@ -426,7 +432,7 @@ def direct_callback(self, event): hotkey = tuple(sorted(_pressed_events)) if event_type == KEY_UP: self.active_modifiers.discard(scan_code) - if scan_code in _pressed_events: del _pressed_events[scan_code] + _pressed_events.pop(scan_code, None) # Mappings based on individual keys instead of hotkeys. for key_hook in self.blocking_keys[scan_code]: @@ -439,7 +445,7 @@ def direct_callback(self, event): if self.blocking_hotkeys: if self.filtered_modifiers[scan_code]: origin = 'modifier' - modifiers_to_update = set([scan_code]) + modifiers_to_update = {scan_code} else: modifiers_to_update = self.active_modifiers if is_modifier(scan_code): @@ -500,7 +506,7 @@ def key_to_scan_codes(key, error_if_missing=True): e = exception if not t and error_if_missing: - raise ValueError('Key {} is not mapped to any known key.'.format(repr(key)), e) + raise ValueError(f'Key {key!r} is not mapped to any known key.', e) else: return t @@ -597,7 +603,7 @@ def is_pressed(hotkey): if len(steps) > 1: raise ValueError("Impossible to check if multi-step hotkeys are pressed (`a+b` is ok, `a, b` isn't).") - # Convert _pressed_events into a set + # Convert _pressed_events into a set with _pressed_events_lock: pressed_scan_codes = set(_pressed_events) for scan_codes in steps[0]: @@ -619,7 +625,7 @@ def hook(callback, suppress=False, on_remove=lambda: None): """ Installs a global listener on all available keyboards, invoking `callback` each time a key is pressed or released. - + The event passed to the callback is of type `directkeys.KeyboardEvent`, with the following attributes: @@ -758,7 +764,7 @@ def _add_hotkey_step(handler, combinations, suppress): container = _listener.blocking_hotkeys if suppress else _listener.nonblocking_hotkeys # Register the scan codes of every possible combination of - # modfiier + main key. Modifiers have to be registered in + # modfiier + main key. Modifiers have to be registered in # filtered_modifiers too, so suppression and replaying can work. for scan_codes in combinations: for scan_code in scan_codes: @@ -842,7 +848,7 @@ def remove_(): state.remove_last_step = None state.suppressed_events = [] state.last_update = float('-inf') - + def catch_misses(event, force_fail=False): if ( event.event_type == event_type @@ -862,7 +868,6 @@ def catch_misses(event, force_fail=False): release(event.scan_code) del state.suppressed_events[:] - index = 0 set_index(0) return True @@ -884,7 +889,7 @@ def handler(event): if event.event_type == KEY_UP: remove() set_index(0) - accept = event.event_type == event_type and callback() + accept = event.event_type == event_type and callback() if accept: return catch_misses(event, force_fail=True) else: @@ -994,7 +999,7 @@ def restore_modifiers(scan_codes): """ Like `restore_state`, but only restores modifier keys. """ - restore_state((scan_code for scan_code in scan_codes if is_modifier(scan_code))) + restore_state(scan_code for scan_code in scan_codes if is_modifier(scan_code)) def write(text, delay=0, restore_state_after=True, exact=None): """ @@ -1018,7 +1023,7 @@ def write(text, delay=0, restore_state_after=True, exact=None): exact = _platform.system() == 'Windows' state = stash_state() - + # Window's typing of unicode characters is quite efficient and should be preferred. if exact: for letter in text: @@ -1035,7 +1040,7 @@ def write(text, delay=0, restore_state_after=True, exact=None): except (KeyError, ValueError, StopIteration): _os_keyboard.type_unicode(letter) continue - + for modifier in modifiers: press(modifier) @@ -1088,7 +1093,7 @@ def get_hotkey_name(names=None): names = [e.name for e in _pressed_events.values()] else: names = [normalize_name(name) for name in names] - clean_names = set(e.replace('left ', '').replace('right ', '').replace('+', 'plus') for e in names) + clean_names = {e.replace('left ', '').replace('right ', '').replace('+', 'plus') for e in names} # https://developer.apple.com/macos/human-interface-guidelines/input-and-output/keyboard/ # > List modifier keys in the correct order. If you use more than one modifier key in a # > hotkey, always list them in this order: Control, Option, Shift, Command. @@ -1295,12 +1300,9 @@ def handler(event): hooked = hook(handler) def remove(): hooked() - if word in _word_listeners: - del _word_listeners[word] - if handler in _word_listeners: - del _word_listeners[handler] - if remove in _word_listeners: - del _word_listeners[remove] + _word_listeners.pop(word, None) + _word_listeners.pop(handler, None) + _word_listeners.pop(remove, None) _word_listeners[word] = _word_listeners[handler] = _word_listeners[remove] = remove # TODO: allow multiple word listeners and removing them correctly. return remove @@ -1327,7 +1329,7 @@ def add_abbreviation(source_text, replacement_text, match_suffix=False, timeout= listener for 'pet'. Defaults to false, only whole words are checked. - `timeout` is the maximum number of seconds between typed characters before the current word is discarded. Defaults to 2 seconds. - + For more details see `add_word_listener`. """ replacement = '\b'*(len(source_text)+1) + replacement_text diff --git a/src/directkeys/__main__.py b/src/directkeys/__main__.py index 703a2f8..46aff00 100644 --- a/src/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -1,12 +1,14 @@ -import directkeys import fileinput import json import sys +import directkeys + + def print_event_json(event): print(event.to_json(ensure_ascii=sys.stdout.encoding != 'utf-8')) sys.stdout.flush() directkeys.hook(print_event_json) parse_event_json = lambda line: directkeys.KeyboardEvent(**json.loads(line)) -directkeys.play(parse_event_json(line) for line in fileinput.input()) \ No newline at end of file +directkeys.play(parse_event_json(line) for line in fileinput.input()) diff --git a/src/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py index bcfaf0b..10da204 100644 --- a/src/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -1200,7 +1200,7 @@ "Zsmall": "", } sided_modifiers = {'ctrl', 'alt', 'shift', 'windows'} -all_modifiers = {'alt', 'alt gr', 'ctrl', 'shift', 'windows'} | set('left ' + n for n in sided_modifiers) | set('right ' + n for n in sided_modifiers) +all_modifiers = {'alt', 'alt gr', 'ctrl', 'shift', 'windows'} | {'left ' + n for n in sided_modifiers} | {'right ' + n for n in sided_modifiers} # Platform-specific canonical overrides diff --git a/src/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py index d48a1f8..4d1feb8 100644 --- a/src/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -1,18 +1,18 @@ import ctypes import ctypes.util -import Quartz import time -import os -import threading +from collections import defaultdict + +import Quartz from AppKit import NSEvent -from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP + from ._canonical_names import normalize_name -from collections import defaultdict +from ._keyboard_event import KeyboardEvent Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library('Carbon')) class KeyMap: - non_layout_keys = dict((vk, normalize_name(name)) for vk, name in { + non_layout_keys = {vk: normalize_name(name) for vk, name in { # Layout specific keys from https://stackoverflow.com/a/16125341/252218 # Unfortunately no source for layout-independent keys was found. 0x24: 'return', @@ -62,7 +62,7 @@ class KeyMap: 0x7c: 'right', 0x7d: 'down', 0x7e: 'up', - }.items()) + }.items()} layout_specific_keys = {} def __init__(self): # Virtual key codes are usually the same for any given key, unless you have a different @@ -178,7 +178,7 @@ def character_to_vk(self, character): return (vk, []) elif self.layout_specific_keys[vk][1] == character: return (vk, ['shift']) - raise ValueError("Unrecognized character: {}".format(character)) + raise ValueError(f"Unrecognized character: {character}") def vk_to_character(self, vk, modifiers=[]): """ Returns a character corresponding to the specified scan code (with given @@ -192,7 +192,7 @@ def vk_to_character(self, vk, modifiers=[]): return self.layout_specific_keys[vk][0] else: # Invalid vk - raise ValueError("Invalid scan code: {}".format(vk)) + raise ValueError(f"Invalid scan code: {vk}") class KeyController: @@ -231,7 +231,7 @@ def __init__(self): 'KEYTYPE_ILLUMINATION_DOWN': 22, 'KEYTYPE_ILLUMINATION_TOGGLE': 23 } - + def press(self, key_code): """ Sends a 'down' event for the specified scan code """ if key_code >= 128: @@ -439,7 +439,7 @@ def release(scan_code): key_controller.release(scan_code) def map_name(name): - """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code + """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code and ``modifiers`` is an array of string modifier names (like 'shift') """ yield key_controller.map_char(name) @@ -459,4 +459,4 @@ def type_unicode(character): # Key up event = Quartz.CGEventCreateKeyboardEvent(OUTPUT_SOURCE, 0, False) Quartz.CGEventKeyboardSetUnicodeString(event, len(character.encode('utf-16-le')) // 2, character) - Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) \ No newline at end of file + Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) diff --git a/src/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py index c3b1cd4..3b8afe6 100644 --- a/src/directkeys/_darwinmouse.py +++ b/src/directkeys/_darwinmouse.py @@ -1,8 +1,10 @@ -import os import datetime +import os import threading + import Quartz -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN + +from ._mouse_event import LEFT, MIDDLE, RIGHT _button_mapping = { LEFT: (Quartz.kCGMouseButtonLeft, Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp, Quartz.kCGEventLeftMouseDragged), @@ -170,4 +172,4 @@ def get_position(): """ Returns the mouse's location as a tuple of (x, y). """ e = Quartz.CGEventCreate(None) point = Quartz.CGEventGetLocation(e) - return (point.x, point.y) \ No newline at end of file + return (point.x, point.y) diff --git a/src/directkeys/_generic.py b/src/directkeys/_generic.py index 5fcb0dd..23d9f13 100644 --- a/src/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -1,6 +1,5 @@ -from threading import Thread, Lock import traceback -import functools +from threading import Lock, Thread try: from queue import Queue @@ -21,7 +20,7 @@ def invoke_handlers(self, event): if handler(event): # Stop processing this hotkey. return 1 - except Exception as e: + except Exception: traceback.print_exc() def start_if_necessary(self): @@ -57,7 +56,7 @@ def process(self): if self.pre_process_event(event): self.invoke_handlers(event) self.queue.task_done() - + def add_handler(self, handler): """ Adds a function to receive each event captured, starting the capturing diff --git a/src/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py index ba2ca5f..e4f58f6 100644 --- a/src/directkeys/_keyboard_event.py +++ b/src/directkeys/_keyboard_event.py @@ -1,5 +1,6 @@ -from time import time as now import json +from time import time as now + from ._canonical_names import normalize_name KEY_DOWN = 'down' @@ -27,14 +28,14 @@ def __init__(self, event_type, scan_code, name=None, time=None, device=None, mod self.name = normalize_name(name) def to_json(self, ensure_ascii=False): - attrs = dict( - (attr, getattr(self, attr)) for attr in ['event_type', 'scan_code', 'name', 'time', 'device', 'is_keypad', 'modifiers', 'flags'] + attrs = { + attr: getattr(self, attr) for attr in ['event_type', 'scan_code', 'name', 'time', 'device', 'is_keypad', 'modifiers', 'flags'] if not attr.startswith('_') - ) + } return json.dumps(attrs, ensure_ascii=ensure_ascii) def __repr__(self): - return 'KeyboardEvent({} {})'.format(self.name or 'Unknown {}'.format(self.scan_code), self.event_type) + return 'KeyboardEvent({} {})'.format(self.name or f'Unknown {self.scan_code}', self.event_type) def __eq__(self, other): return ( diff --git a/src/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py index ae9d60a..c6aa4e2 100644 --- a/src/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -1,9 +1,10 @@ -import struct -import os import atexit -from time import time as now -from threading import Thread +import os +import struct from glob import glob +from threading import Thread +from time import time as now + try: from queue import Queue except ImportError: @@ -21,9 +22,10 @@ def make_uinput(): if not os.path.exists('/dev/uinput'): - raise IOError('No uinput module found.') + raise OSError('No uinput module found.') - import fcntl, struct + import fcntl + import struct # Requires uinput driver, but it's usually available. uinput = open("/dev/uinput", 'wb') @@ -58,9 +60,9 @@ def input_file(self): if self._input_file is None: try: self._input_file = open(self.path, 'rb') - except IOError as e: + except OSError as e: if e.strerror == 'Permission denied': - print("# ERROR: Failed to read device '{}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group.".format(self.path)) + print(f"# ERROR: Failed to read device '{self.path}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group.") exit() def try_close(): @@ -116,6 +118,7 @@ def write_event(self, type, code, value): import re from collections import namedtuple + DeviceDescription = namedtuple('DeviceDescription', 'event_file is_mouse is_keyboard') device_pattern = r"""N: Name="([^"]+?)".+?H: Handlers=([^\n]+)""" def list_devices_from_proc(type_name): @@ -145,7 +148,7 @@ def aggregate_devices(type_name): fake_device = EventDevice('uinput Fake Device') fake_device._input_file = uinput fake_device._output_file = uinput - except IOError as e: + except OSError: import warnings warnings.warn('Failed to create a device file using `uinput` module. Sending of events may be limited or unavailable depending on plugged-in devices.', stacklevel=2) fake_device = None diff --git a/src/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py index d5b8af0..7c8eb51 100644 --- a/src/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -1,8 +1,5 @@ -import struct -import traceback -from time import time as now -from collections import namedtuple -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from directkeys._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent + from ._canonical_names import all_modifiers, normalize_name from ._nixcommon import EV_KEY, aggregate_devices @@ -37,16 +34,16 @@ def cleanup_modifier(modifier): return modifier if modifier[:-1] in all_modifiers: return modifier[:-1] - raise ValueError('Unknown modifier {}'.format(modifier)) + raise ValueError(f'Unknown modifier {modifier}') """ Use `dumpkeys --keys-only` to list all scan codes and their names. We then parse the output and built a table. For each scan code and modifiers we have a list of names and vice-versa. """ -from subprocess import check_output, CalledProcessError, PIPE -from collections import defaultdict import re +from collections import defaultdict +from subprocess import CalledProcessError, check_output to_name = defaultdict(list) from_name = defaultdict(list) @@ -141,7 +138,7 @@ def listen(callback): pressed_modifiers_tuple = tuple(sorted(pressed_modifiers)) names = to_name[(scan_code, pressed_modifiers_tuple)] or to_name[(scan_code, ())] or ['unknown'] name = names[0] - + if name in all_modifiers: if event_type == KEY_DOWN: pressed_modifiers.add(name) diff --git a/src/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py index 117e797..dc5453e 100644 --- a/src/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -1,12 +1,10 @@ -import struct -from subprocess import check_output -import re -from ._nixcommon import EV_KEY, EV_REL, EV_MSC, EV_SYN, EV_ABS, aggregate_devices -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN - import ctypes import ctypes.util -from ctypes import c_uint32, c_uint, c_int, byref +import struct +from ctypes import byref, c_int, c_uint, c_uint32 + +from ._mouse_event import DOWN, LEFT, MIDDLE, RIGHT, UP, X2, ButtonEvent, MoveEvent, WheelEvent, X +from ._nixcommon import EV_KEY, EV_MSC, EV_REL, EV_SYN, aggregate_devices display = None window = None @@ -74,7 +72,7 @@ def listen(queue): build_device() while True: - time, type, code, value, device_id = device.read_event() + time, type, code, value, _device_id = device.read_event() if type == EV_SYN or type == EV_MSC: continue diff --git a/src/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py index f0e04e8..eb97e68 100644 --- a/src/directkeys/_winkeyboard.py +++ b/src/directkeys/_winkeyboard.py @@ -9,15 +9,14 @@ - Keypad numbers still print as numbers even when numlock is off. - No way to specify if user wants a keypad key or not in `map_char`. """ -import re import atexit +import time import traceback -from threading import Lock from collections import defaultdict -import time +from threading import Lock -from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP from ._canonical_names import normalize_name +from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None @@ -26,8 +25,33 @@ # this would be simply #include "windows.h". import ctypes -from ctypes import c_short, c_char, c_uint8, c_int32, c_int, c_uint, c_uint32, c_long, Structure, WINFUNCTYPE, POINTER -from ctypes.wintypes import WORD, DWORD, BOOL, HHOOK, MSG, LPWSTR, WCHAR, WPARAM, LPARAM, LONG, HMODULE, LPCWSTR, HINSTANCE, HWND +from ctypes import ( + POINTER, + WINFUNCTYPE, + Structure, + c_int, + c_long, + c_short, + c_uint, + c_uint8, +) +from ctypes.wintypes import ( + BOOL, + DWORD, + HHOOK, + HINSTANCE, + HMODULE, + HWND, + LONG, + LPARAM, + LPCWSTR, + LPWSTR, + MSG, + WCHAR, + WORD, + WPARAM, +) + LPMSG = POINTER(MSG) ULONG_PTR = POINTER(DWORD) @@ -143,7 +167,7 @@ class INPUT(ctypes.Structure): MAPVK_VK_TO_VSC = 0 MAPVK_VSC_TO_VK = 1 MAPVK_VK_TO_VSC_EX = 4 -MAPVK_VSC_TO_VK_EX = 3 +MAPVK_VSC_TO_VK_EX = 3 VkKeyScan = user32.VkKeyScanW VkKeyScan.argtypes = [WCHAR] @@ -603,7 +627,7 @@ def low_level_keyboard_handler(nCode, wParam, lParam): if not should_continue: return -1 - except Exception as e: + except Exception: print('Error in keyboard hook:') traceback.print_exc() @@ -646,9 +670,9 @@ def map_name(name): entries = from_name.get(name) if not entries: - raise ValueError('Key name {} is not mapped to any known key.'.format(repr(name))) + raise ValueError(f'Key name {name!r} is not mapped to any known key.') for i, entry in entries: - scan_code, vk, is_extended, modifiers = entry + scan_code, vk, _is_extended, modifiers = entry yield scan_code or -vk, modifiers def _send_event(code, event_type): diff --git a/src/directkeys/_winmouse.py b/src/directkeys/_winmouse.py index fd4d360..3f2ebf7 100644 --- a/src/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -1,12 +1,36 @@ import ctypes import time -from ctypes import c_short, c_char, c_uint8, c_int32, c_int, c_uint, c_uint32, c_long, byref, Structure, CFUNCTYPE, POINTER -from ctypes.wintypes import DWORD, BOOL, HHOOK, MSG, LPWSTR, WCHAR, WPARAM, LPARAM +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + byref, + c_int, + c_int32, + c_long, +) +from ctypes.wintypes import BOOL, DWORD, HHOOK, LPARAM, MSG, WPARAM + LPMSG = POINTER(MSG) import atexit -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE, WHEEL, HORIZONTAL, VERTICAL +from ._mouse_event import ( + DOUBLE, + DOWN, + HORIZONTAL, + LEFT, + MIDDLE, + RIGHT, + UP, + VERTICAL, + WHEEL, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) #https://github.com/boppreh/mouse/issues/1 #user32 = ctypes.windll.user32 diff --git a/src/directkeys/mouse.py b/src/directkeys/mouse.py index eaac358..291d469 100644 --- a/src/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,21 +1,34 @@ import warnings + warnings.simplefilter('always', DeprecationWarning) warnings.warn('The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.', DeprecationWarning, stacklevel=2) +import platform as _platform import time as _time -import platform as _platform if _platform.system() == 'Windows': - from. import _winmouse as _os_mouse + from . import _winmouse as _os_mouse elif _platform.system() == 'Linux': - from. import _nixmouse as _os_mouse + from . import _nixmouse as _os_mouse elif _platform.system() == 'Darwin': - from. import _darwinmouse as _os_mouse + from . import _darwinmouse as _os_mouse else: - raise OSError("Unsupported platform '{}'".format(_platform.system())) + raise OSError(f"Unsupported platform '{_platform.system()}'") -from ._mouse_event import ButtonEvent, MoveEvent, WheelEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE from ._generic import GenericListener as _GenericListener +from ._mouse_event import ( + DOUBLE, + DOWN, + LEFT, + MIDDLE, + RIGHT, + UP, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) _pressed_events = set() class _MouseListener(_GenericListener): @@ -165,7 +178,7 @@ def hook(callback): each time it is moved, a key status changes or the wheel is spun. A mouse event is passed as argument, with type either `mouse.ButtonEvent`, `mouse.WheelEvent` or `mouse.MoveEvent`. - + Returns the given callback for easier development. """ _listener.add_handler(callback) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 28fa03b..897ccda 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -12,11 +12,11 @@ and added to `output_events` immediately, mimicking real functionality. """ -import unittest import time +import unittest import directkeys -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from directkeys._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent dummy_keys = { 'space': [(0, [])], @@ -112,7 +112,7 @@ def setUp(self): directkeys._logically_pressed_keys.clear() directkeys._hotkeys.clear() directkeys._listener.init() - directkeys._word_listeners = {} + directkeys._word_listeners = {} def do(self, manual_events, expected=None): input_events.extend(manual_events) @@ -128,7 +128,7 @@ def do(self, manual_events, expected=None): directkeys._listener.queue.join() def test_event_json(self): - event = make_event(KEY_DOWN, u'á \'"', 999) + event = make_event(KEY_DOWN, 'á \'"', 999) import json self.assertEqual(event, KeyboardEvent(**json.loads(event.to_json()))) @@ -403,7 +403,7 @@ def test_restore_modifieres(self): def test_write_simple(self): directkeys.write('a', exact=False) self.do([], d_a+u_a) - def test_write_multiple(self): + def test_write_multiple_no_delay(self): directkeys.write('ab', exact=False) self.do([], d_a+u_a+d_b+u_b) def test_write_modifiers(self): @@ -427,8 +427,8 @@ def test_write_unicode_explicit(self): directkeys.write('ab', exact=True) self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='a'), KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='b')]) def test_write_unicode_fallback(self): - directkeys.write(u'áb', exact=False) - self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name=u'á')]+d_b+u_b) + directkeys.write('áb', exact=False) + self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='á')]+d_b+u_b) def test_start_stop_recording(self): directkeys.start_recording() @@ -579,7 +579,7 @@ def test_add_hotkey_single_step_suppress_args_allow(self): def test_add_hotkey_single_step_suppress_single(self): directkeys.add_hotkey('a', trigger, suppress=True) self.do(d_a, triggered_event) - def test_add_hotkey_single_step_suppress_removed(self): + def test_add_hotkey_single_step_suppress_removed_no_modifier(self): directkeys.remove_hotkey(directkeys.add_hotkey('a', trigger, suppress=True)) self.do(d_a, d_a) def test_add_hotkey_single_step_suppress_removed(self): @@ -626,7 +626,7 @@ def test_add_hotkey_single_step_suppress_with_modifiers_fail_unrelated_key(self) def test_add_hotkey_single_step_suppress_with_modifiers_unrelated_key(self): directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) self.do(d_ctrl+d_shift+du_b+d_a, d_shift+d_ctrl+du_b+triggered_event) - def test_add_hotkey_single_step_suppress_with_modifiers_release(self): + def test_add_hotkey_single_step_suppress_with_two_modifiers_release(self): directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) self.do(d_ctrl+d_shift+du_b+d_a+u_ctrl+u_shift, d_shift+d_ctrl+du_b+triggered_event+u_ctrl+u_shift) def test_add_hotkey_single_step_suppress_with_modifiers_out_of_order(self): @@ -733,7 +733,7 @@ def test_parse_hotkey_combinations_fail_empty(self): directkeys.parse_hotkey_combinations('') - def test_add_hotkey_multistep_suppress_incomplete(self): + def test_add_hotkey_multistep_suppress_incomplete_blocking_state(self): directkeys.add_hotkey('a, b', trigger, suppress=True) self.do(du_a, []) self.assertEqual(directkeys._listener.blocking_hotkeys[(1,)], []) @@ -822,4 +822,4 @@ def free(): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 3881f15..3a6df78 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -1,8 +1,21 @@ -import unittest import time +import unittest -from directkeys._mouse_event import MoveEvent, ButtonEvent, WheelEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE from directkeys import mouse +from directkeys._mouse_event import ( + DOUBLE, + DOWN, + LEFT, + MIDDLE, + RIGHT, + UP, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) + class FakeOsMouse: def __init__(self): @@ -195,7 +208,7 @@ def test_ons(self): def test_wait(self): # If this fails it blocks. Unfortunately, but I see no other way of testing. - from threading import Thread, Lock + from threading import Lock, Thread lock = Lock() lock.acquire() def t(): @@ -206,7 +219,7 @@ def t(): lock.acquire() def test_record_play(self): - from threading import Thread, Lock + from threading import Lock, Thread lock = Lock() lock.acquire() def t(): From 983db2df5667332a77af9b4771fc4e74f7e5f4fe Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:54:41 -0400 Subject: [PATCH 11/14] style: apply the Ruff formatter Mechanical reformat of every Python file, in its own commit so the substantive changes elsewhere stay reviewable. Two settings needed care: - `docstring-code-format` is left off. The __init__ docstring is published verbatim as the PyPI description and regenerated into README.md, so the formatter must not rewrite the example code inside it. - Markdown is excluded. Ruff reformats fenced Python blocks in .md files, and since README.md is generated from that same docstring, the formatter and the generator would each keep undoing the other. Verified by comparing the AST of every touched file before and after: 17 are byte-identical, and the remaining 8 differ only in whitespace inside string literals, where Ruff normalises `""" Text. """` to `"""Text."""`. No structural change in any file. README.md is regenerated so it stays in sync with the docstring after that normalisation. Co-Authored-By: Claude Opus 5 --- examples/customizable_hotkey.py | 11 +- examples/pressed_keys.py | 12 +- examples/push_to_talk_ubuntu.py | 31 +- examples/segmented_macro.py | 29 +- examples/simulate_held_down.py | 4 +- examples/stdin_stdout_events.py | 19 +- examples/write.py | 5 +- make_release.py | 85 +-- pyproject.toml | 11 +- setup.py | 1 + src/directkeys/__init__.py | 324 +++++++---- src/directkeys/__main__.py | 4 +- src/directkeys/_canonical_names.py | 193 +++--- src/directkeys/_darwinkeyboard.py | 400 +++++++------ src/directkeys/_darwinmouse.py | 151 ++--- src/directkeys/_generic.py | 5 +- src/directkeys/_keyboard_event.py | 42 +- src/directkeys/_mouse_event.py | 28 +- src/directkeys/_nixcommon.py | 55 +- src/directkeys/_nixkeyboard.py | 131 +++-- src/directkeys/_nixmouse.py | 46 +- src/directkeys/_winkeyboard.py | 554 ++++++++++-------- src/directkeys/_winmouse.py | 52 +- src/directkeys/mouse.py | 76 ++- tests/test_keyboard.py | 903 ++++++++++++++++++----------- tests/test_mouse.py | 50 +- 26 files changed, 1954 insertions(+), 1268 deletions(-) diff --git a/examples/customizable_hotkey.py b/examples/customizable_hotkey.py index 8e7e7ff..eee7f7c 100644 --- a/examples/customizable_hotkey.py +++ b/examples/customizable_hotkey.py @@ -1,12 +1,15 @@ import directkeys -print('Press and release your desired shortcut: ') +print("Press and release your desired shortcut: ") shortcut = directkeys.read_hotkey() -print('Shortcut selected:', shortcut) +print("Shortcut selected:", shortcut) + def on_triggered(): - print("Triggered!") + print("Triggered!") + + directkeys.add_hotkey(shortcut, on_triggered) print("Press ESC to stop.") -directkeys.wait('esc') +directkeys.wait("esc") diff --git a/examples/pressed_keys.py b/examples/pressed_keys.py index e56e26c..c69fb30 100644 --- a/examples/pressed_keys.py +++ b/examples/pressed_keys.py @@ -2,17 +2,19 @@ Prints the scan code of all currently pressed keys. Updates on every keyboard event. """ + import sys -sys.path.append('..') +sys.path.append("..") import directkeys def print_pressed_keys(e): - line = ', '.join(str(code) for code in directkeys._pressed_events) - # '\r' and end='' overwrites the previous line. - # ' '*40 prints 40 spaces at the end to ensure the previous line is cleared. - print('\r' + line + ' '*40, end='') + line = ", ".join(str(code) for code in directkeys._pressed_events) + # '\r' and end='' overwrites the previous line. + # ' '*40 prints 40 spaces at the end to ensure the previous line is cleared. + print("\r" + line + " " * 40, end="") + directkeys.hook(print_pressed_keys) directkeys.wait() diff --git a/examples/push_to_talk_ubuntu.py b/examples/push_to_talk_ubuntu.py index 4349f7c..be2cf95 100644 --- a/examples/push_to_talk_ubuntu.py +++ b/examples/push_to_talk_ubuntu.py @@ -1,4 +1,4 @@ -#quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad +# quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad import subprocess @@ -6,23 +6,26 @@ is_muted = False + def unmute(): - global is_muted - if not is_muted: # if mic is already enabled - return # do nothing - is_muted = False - subprocess.call('amixer set Capture cap', shell=True) # unmute mic + global is_muted + if not is_muted: # if mic is already enabled + return # do nothing + is_muted = False + subprocess.call("amixer set Capture cap", shell=True) # unmute mic + def mute(): - global is_muted - is_muted = True - subprocess.call('amixer set Capture nocap', shell=True) # mute mic + global is_muted + is_muted = True + subprocess.call("amixer set Capture nocap", shell=True) # mute mic + if __name__ == "__main__": - is_muted = True - mute() # mute on startup + is_muted = True + mute() # mute on startup - directkeys.add_hotkey('win', unmute) # unmute on keydown - directkeys.add_hotkey('win', mute, trigger_on_release=True) # mute on keyup + directkeys.add_hotkey("win", unmute) # unmute on keydown + directkeys.add_hotkey("win", mute, trigger_on_release=True) # mute on keyup - directkeys.wait() # wait forever + directkeys.wait() # wait forever diff --git a/examples/segmented_macro.py b/examples/segmented_macro.py index 5a15d06..9bb8eb2 100644 --- a/examples/segmented_macro.py +++ b/examples/segmented_macro.py @@ -5,29 +5,30 @@ It's useful for presentations, to ensure typing accuracy while still giving you time to speak between segments. """ + import sys -sys.path.append('../') +sys.path.append("../") import os import pickle import directkeys if len(sys.argv) == 1: - filename = input('Enter filename to save/load events: ') + filename = input("Enter filename to save/load events: ") else: filename = sys.argv[1] if os.path.exists(filename): - segments = pickle.load(open(filename, 'rb')) + segments = pickle.load(open(filename, "rb")) for i, segment in enumerate(segments): - print(f'Press F1 to play segment {i+1}/{len(segments)}') - print(f'Duration: {segment[-1].time - segment[0].time:.02} seconds') - directkeys.wait('F1') + print(f"Press F1 to play segment {i + 1}/{len(segments)}") + print(f"Duration: {segment[-1].time - segment[0].time:.02} seconds") + directkeys.wait("F1") directkeys.play(segment) else: - print('Press F1 to save this fragment. Press F2 to discard it. Press F3 to stop recording.') + print("Press F1 to save this fragment. Press F2 to discard it. Press F3 to stop recording.") segments = [] segment = [] @@ -35,22 +36,22 @@ def handle_event(event): global segment - if directkeys.matches(event, 'F1'): + if directkeys.matches(event, "F1"): if event.event_type == directkeys.KEY_DOWN: if segment: segments.append(segment) segment = [] - print('Saved', len(segments)) - elif directkeys.matches(event, 'F2'): + print("Saved", len(segments)) + elif directkeys.matches(event, "F2"): if event.event_type == directkeys.KEY_DOWN: segment = [] - print('Discarded') + print("Discarded") else: segment.append(event) directkeys.hook(handle_event) - directkeys.wait('F3') + directkeys.wait("F3") directkeys.hook(handle_event) - pickle.dump(segments, open(filename, 'wb')) - print(f'Saved {len(segments)} segments to {filename}') + pickle.dump(segments, open(filename, "wb")) + print(f"Saved {len(segments)} segments to {filename}") diff --git a/examples/simulate_held_down.py b/examples/simulate_held_down.py index da3fd6b..a481f29 100644 --- a/examples/simulate_held_down.py +++ b/examples/simulate_held_down.py @@ -5,6 +5,6 @@ # Sends 20 "key down" events in 0.1 second intervals, followed by a single # "key up" event. for i in range(20): - directkeys.press('a') + directkeys.press("a") time.sleep(0.1) -directkeys.release('a') +directkeys.release("a") diff --git a/examples/stdin_stdout_events.py b/examples/stdin_stdout_events.py index ca8e9b9..b67c812 100644 --- a/examples/stdin_stdout_events.py +++ b/examples/stdin_stdout_events.py @@ -2,17 +2,18 @@ Prints lines with JSON object for each keyboard event, and reads similar events from stdin to simulate events. Example: - {"event_type": "down", "name": "a", "scan_code": 30, "time": 1491442622.6348252} - {"event_type": "down", "name": "s", "scan_code": 31, "time": 1491442622.664881} - {"event_type": "down", "name": "d", "scan_code": 32, "time": 1491442622.7148278} - {"event_type": "down", "name": "f", "scan_code": 33, "time": 1491442622.7544951} - {"event_type": "up", "name": "a", "scan_code": 30, "time": 1491442622.7748237} - {"event_type": "up", "name": "s", "scan_code": 31, "time": 1491442622.825077} - {"event_type": "up", "name": "d", "scan_code": 32, "time": 1491442622.8644736} - {"event_type": "up", "name": "f", "scan_code": 33, "time": 1491442622.9056144} + {"event_type": "down", "name": "a", "scan_code": 30, "time": 1491442622.6348252} + {"event_type": "down", "name": "s", "scan_code": 31, "time": 1491442622.664881} + {"event_type": "down", "name": "d", "scan_code": 32, "time": 1491442622.7148278} + {"event_type": "down", "name": "f", "scan_code": 33, "time": 1491442622.7544951} + {"event_type": "up", "name": "a", "scan_code": 30, "time": 1491442622.7748237} + {"event_type": "up", "name": "s", "scan_code": 31, "time": 1491442622.825077} + {"event_type": "up", "name": "d", "scan_code": 32, "time": 1491442622.8644736} + {"event_type": "up", "name": "f", "scan_code": 33, "time": 1491442622.9056144} """ + import sys -sys.path.append('..') +sys.path.append("..") # Also available as just `python -m keyboard`. diff --git a/examples/write.py b/examples/write.py index 0b8297b..9b59570 100644 --- a/examples/write.py +++ b/examples/write.py @@ -2,12 +2,13 @@ Given text files or text from stdin, simulates keyboard events that type the text character-by-character. """ + import sys -sys.path.append('../') +sys.path.append("../") import fileinput import directkeys for line in fileinput.input(): - directkeys.write(line) + directkeys.write(line) diff --git a/make_release.py b/make_release.py index 7c91e81..2bfe135 100644 --- a/make_release.py +++ b/make_release.py @@ -22,6 +22,7 @@ - Use raw semantic versioning for CHANGES.md and PyPI (e.g. 2.3.1), and prepend 'v' for git tags and releases (e.g. v2.3.1). """ + import atexit import os import re @@ -31,52 +32,56 @@ import directkeys -run(['make', 'clean', 'build'], check=True) - -assert re.fullmatch(r'\d+\.\d+\.\d+', directkeys.version) -last_version = check_output(['git', 'describe', '--abbrev=0'], universal_newlines=True).strip('v\n') -assert directkeys.version != last_version, 'Must update directkeys.version first.' - -commits = check_output(['git', 'log', f'v{last_version}..HEAD', '--oneline'], universal_newlines=True) -with open('message.txt', 'w') as message_file: - atexit.register(lambda: os.remove('message.txt')) - - message_file.write('\n\n\n') - message_file.write('# Enter changes one per line like this:\n') - message_file.write('# - Added `foobar`.\n\n\n') - message_file.write(f'# As a reminder, here\'s the last commits since version {last_version}:\n\n') - for line in commits.strip().split('\n'): - message_file.write(f'# {line}\n') - -run(['vim', 'message.txt']) -with open('message.txt') as message_file: - lines = [line for line in message_file.readlines() if not line.startswith('#')] -message = ''.join(lines).strip() +run(["make", "clean", "build"], check=True) + +assert re.fullmatch(r"\d+\.\d+\.\d+", directkeys.version) +last_version = check_output(["git", "describe", "--abbrev=0"], universal_newlines=True).strip("v\n") +assert directkeys.version != last_version, "Must update directkeys.version first." + +commits = check_output( + ["git", "log", f"v{last_version}..HEAD", "--oneline"], universal_newlines=True +) +with open("message.txt", "w") as message_file: + atexit.register(lambda: os.remove("message.txt")) + + message_file.write("\n\n\n") + message_file.write("# Enter changes one per line like this:\n") + message_file.write("# - Added `foobar`.\n\n\n") + message_file.write( + f"# As a reminder, here's the last commits since version {last_version}:\n\n" + ) + for line in commits.strip().split("\n"): + message_file.write(f"# {line}\n") + +run(["vim", "message.txt"]) +with open("message.txt") as message_file: + lines = [line for line in message_file.readlines() if not line.startswith("#")] +message = "".join(lines).strip() if not message: - print('Aborting release due to empty message.') + print("Aborting release due to empty message.") exit() -with open('message.txt', 'w') as message_file: +with open("message.txt", "w") as message_file: message_file.write(message) -with open('CHANGES.md') as changes_file: +with open("CHANGES.md") as changes_file: old_changes = changes_file.read() -with open('CHANGES.md', 'w') as changes_file: - changes_file.write(f'# {directkeys.version}\n\n{message}\n\n\n{old_changes}') +with open("CHANGES.md", "w") as changes_file: + changes_file.write(f"# {directkeys.version}\n\n{message}\n\n\n{old_changes}") -tag_name = 'v' + directkeys.version -if input('Commit README.md and CHANGES.md files? ').lower().startswith('y'): - run(['git', 'add', 'CHANGES.md', 'README.md']) - run(['git', 'commit', '-m', f'Update changes for {tag_name}']) - run(['git', 'push']) -run(['git', 'tag', '-a', tag_name, '--file', 'message.txt'], check=True) -run(['git', 'push', 'origin', tag_name], check=True) +tag_name = "v" + directkeys.version +if input("Commit README.md and CHANGES.md files? ").lower().startswith("y"): + run(["git", "add", "CHANGES.md", "README.md"]) + run(["git", "commit", "-m", f"Update changes for {tag_name}"]) + run(["git", "push"]) +run(["git", "tag", "-a", tag_name, "--file", "message.txt"], check=True) +run(["git", "push", "origin", tag_name], check=True) -token = input('To make a release enter your GitHub repo authorization token: ').strip() +token = input("To make a release enter your GitHub repo authorization token: ").strip() if token: - git_remotes = check_output(['git', 'remote', '-v']).decode('utf-8') - repo_path = re.search(r'github.com[:/](.+?)(?:\.git)? \(push\)', git_remotes).group(1) - releases_url = f'https://api.github.com/repos/{repo_path}/releases' + git_remotes = check_output(["git", "remote", "-v"]).decode("utf-8") + repo_path = re.search(r"github.com[:/](.+?)(?:\.git)? \(push\)", git_remotes).group(1) + releases_url = f"https://api.github.com/repos/{repo_path}/releases" print(releases_url) release = { "tag_name": tag_name, @@ -86,7 +91,9 @@ "draft": False, "prerelease": False, } - response = requests.post(releases_url, json=release, headers={'Authorization': 'token ' + token}) + response = requests.post( + releases_url, json=release, headers={"Authorization": "token " + token} + ) print(response.status_code, response.text) -run(['twine', 'upload', 'dist/*'], check=True, shell=True) +run(["twine", "upload", "dist/*"], check=True, shell=True) diff --git a/pyproject.toml b/pyproject.toml index 0183d04..55c2dc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,16 @@ directory = "coverage_html_report" [tool.ruff] target-version = "py39" line-length = 100 -extend-exclude = ["tests/manual"] +# README.md is generated from the __init__ docstring by the `build` target, so +# letting Ruff reformat its code blocks would put the two permanently out of +# sync, each rewriting the other on the next run. +extend-exclude = ["tests/manual", "*.md"] + +[tool.ruff.format] +# Deliberately left off: the __init__ docstring is published verbatim as the +# PyPI description and regenerated into README.md, so the formatter must not +# rewrite the example code inside it. +docstring-code-format = false [tool.ruff.lint] select = [ diff --git a/setup.py b/setup.py index 45c4a55..af4830c 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ python -m build """ + from setuptools import setup setup() diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index c901211..4ccf81c 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -208,7 +208,8 @@ def on_space(): # https://stackoverflow.com/questions/983354/how-to-make-a-script-wait-for-a-pressed-key ``` """ -__version__ = '1.0.0' + +__version__ = "1.0.0" # Kept as an alias: the upstream project exposed the version under this name. version = __version__ @@ -225,12 +226,22 @@ def on_space(): from threading import Thread as _Thread -def _is_str(x): return isinstance(x, str) -def _is_number(x): return isinstance(x, int) -def _is_list(x): return isinstance(x, (list, tuple)) +def _is_str(x): + return isinstance(x, str) + + +def _is_number(x): + return isinstance(x, int) + + +def _is_list(x): + return isinstance(x, (list, tuple)) + # Just a dynamic object to store attributes for the closures. -class _State: pass +class _State: + pass + # The "Event" class from `threading` ignores signals when waiting and is # impossible to interrupt with Ctrl+C. So we rewrite `wait` to wait in small, @@ -241,6 +252,7 @@ def wait(self): if _UninterruptibleEvent.wait(self, 0.5): break + # Load the base dependencies before the backend, to avoid a circular import. # Import the platform specific backend. import platform as _platform @@ -251,11 +263,11 @@ def wait(self): # KeyboardEvent is re-exported as part of the public API, not used here. from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent # noqa: F401 -if _platform.system() == 'Windows': +if _platform.system() == "Windows": from . import _winkeyboard as _os_keyboard -elif _platform.system() == 'Linux': +elif _platform.system() == "Linux": from . import _nixkeyboard as _os_keyboard -elif _platform.system() == 'Darwin': +elif _platform.system() == "Darwin": try: from . import _darwinkeyboard as _os_keyboard except ImportError: @@ -265,6 +277,7 @@ def wait(self): else: raise OSError(f"Unsupported platform '{_platform.system()}'") + def set_alt_gr_abstraction(enabled): """ Enables or disables the AltGr abstraction in the Windows backend. @@ -283,13 +296,15 @@ def set_alt_gr_abstraction(enabled): global _ABSTRACT_ALT_GR _ABSTRACT_ALT_GR = bool(enabled) # Let the backend reconfigure itself, if it needs to. - if hasattr(_os_keyboard, 'rebuild_name_tables'): + if hasattr(_os_keyboard, "rebuild_name_tables"): _os_keyboard.rebuild_name_tables() + def get_alt_gr_abstraction_state(): - """ Returns True if the AltGr abstraction is currently enabled. """ + """Returns True if the AltGr abstraction is currently enabled.""" return _ABSTRACT_ALT_GR + # Import the remaining backend functions. `_os_keyboard` is an alias bound to # the module imported above, so these have to be looked up as attributes: a # `from ._os_keyboard import ...` would search for a submodule that does not @@ -300,23 +315,30 @@ def _fallback_force_reset_keyboard(): """ pass + def _fallback_get_stuck_keys(): """ Fallback for platforms without a stuck key implementation. Returns []. """ return [] + def _fallback_reset_internal_state(): """ Fallback for platforms with no internal state to reset. Does nothing. """ pass -force_reset_keyboard = getattr(_os_keyboard, 'force_reset_keyboard', _fallback_force_reset_keyboard) -get_stuck_keys = getattr(_os_keyboard, 'get_stuck_keys', _fallback_get_stuck_keys) -reset_internal_state = getattr(_os_keyboard, '_reset_internal_state', _fallback_reset_internal_state) + +force_reset_keyboard = getattr(_os_keyboard, "force_reset_keyboard", _fallback_force_reset_keyboard) +get_stuck_keys = getattr(_os_keyboard, "get_stuck_keys", _fallback_get_stuck_keys) +reset_internal_state = getattr( + _os_keyboard, "_reset_internal_state", _fallback_reset_internal_state +) _modifier_scan_codes = set() + + def is_modifier(key): """ Returns True if `key` is a scan code or name of a modifier key. @@ -329,13 +351,16 @@ def is_modifier(key): _modifier_scan_codes.update(*scan_codes) return key in _modifier_scan_codes + _pressed_events_lock = _Lock() _pressed_events = {} _physically_pressed_keys = _pressed_events _logically_pressed_keys = {} + + class _KeyboardListener(_GenericListener): transition_table = { - #Current state of the modifier, per `modifier_states`. + # Current state of the modifier, per `modifier_states`. #| #| Type of event that triggered this modifier update. #| | @@ -346,35 +371,33 @@ class _KeyboardListener(_GenericListener): #| | | => | Accept the event? #| | | | | #| | | | | Next state. - #v v v v v v - ('free', KEY_UP, 'modifier'): (False, True, 'free'), - ('free', KEY_DOWN, 'modifier'): (False, False, 'pending'), - ('pending', KEY_UP, 'modifier'): (True, True, 'free'), - ('pending', KEY_DOWN, 'modifier'): (False, True, 'allowed'), - ('suppressed', KEY_UP, 'modifier'): (False, False, 'free'), - ('suppressed', KEY_DOWN, 'modifier'): (False, False, 'suppressed'), - ('allowed', KEY_UP, 'modifier'): (False, True, 'free'), - ('allowed', KEY_DOWN, 'modifier'): (False, True, 'allowed'), - - ('free', KEY_UP, 'hotkey'): (False, None, 'free'), - ('free', KEY_DOWN, 'hotkey'): (False, None, 'free'), - ('pending', KEY_UP, 'hotkey'): (False, None, 'suppressed'), - ('pending', KEY_DOWN, 'hotkey'): (False, None, 'suppressed'), - ('suppressed', KEY_UP, 'hotkey'): (False, None, 'suppressed'), - ('suppressed', KEY_DOWN, 'hotkey'): (False, None, 'suppressed'), - ('allowed', KEY_UP, 'hotkey'): (False, None, 'allowed'), - ('allowed', KEY_DOWN, 'hotkey'): (False, None, 'allowed'), - - ('free', KEY_UP, 'other'): (False, True, 'free'), - ('free', KEY_DOWN, 'other'): (False, True, 'free'), - ('pending', KEY_UP, 'other'): (True, True, 'allowed'), - ('pending', KEY_DOWN, 'other'): (True, True, 'allowed'), + # v v v v v v + ("free", KEY_UP, "modifier"): (False, True, "free"), + ("free", KEY_DOWN, "modifier"): (False, False, "pending"), + ("pending", KEY_UP, "modifier"): (True, True, "free"), + ("pending", KEY_DOWN, "modifier"): (False, True, "allowed"), + ("suppressed", KEY_UP, "modifier"): (False, False, "free"), + ("suppressed", KEY_DOWN, "modifier"): (False, False, "suppressed"), + ("allowed", KEY_UP, "modifier"): (False, True, "free"), + ("allowed", KEY_DOWN, "modifier"): (False, True, "allowed"), + ("free", KEY_UP, "hotkey"): (False, None, "free"), + ("free", KEY_DOWN, "hotkey"): (False, None, "free"), + ("pending", KEY_UP, "hotkey"): (False, None, "suppressed"), + ("pending", KEY_DOWN, "hotkey"): (False, None, "suppressed"), + ("suppressed", KEY_UP, "hotkey"): (False, None, "suppressed"), + ("suppressed", KEY_DOWN, "hotkey"): (False, None, "suppressed"), + ("allowed", KEY_UP, "hotkey"): (False, None, "allowed"), + ("allowed", KEY_DOWN, "hotkey"): (False, None, "allowed"), + ("free", KEY_UP, "other"): (False, True, "free"), + ("free", KEY_DOWN, "other"): (False, True, "free"), + ("pending", KEY_UP, "other"): (True, True, "allowed"), + ("pending", KEY_DOWN, "other"): (True, True, "allowed"), # Necessary when hotkeys are removed after beign triggered, such as # TestKeyboard.test_add_hotkey_multistep_suppress_modifier. - ('suppressed', KEY_UP, 'other'): (False, False, 'allowed'), - ('suppressed', KEY_DOWN, 'other'): (True, True, 'allowed'), - ('allowed', KEY_UP, 'other'): (False, True, 'allowed'), - ('allowed', KEY_DOWN, 'other'): (False, True, 'allowed'), + ("suppressed", KEY_UP, "other"): (False, False, "allowed"), + ("suppressed", KEY_DOWN, "other"): (True, True, "allowed"), + ("allowed", KEY_UP, "other"): (False, True, "allowed"), + ("allowed", KEY_DOWN, "other"): (False, True, "allowed"), } def init(self): @@ -391,7 +414,7 @@ def init(self): # Supporting hotkey suppression is harder than it looks. See # https://github.com/boppreh/keyboard/issues/22 - self.modifier_states = {} # "alt" -> "allowed" + self.modifier_states = {} # "alt" -> "allowed" def pre_process_event(self, event): for key_hook in self.nonblocking_keys[event.scan_code]: @@ -402,7 +425,7 @@ def pre_process_event(self, event): for callback in self.nonblocking_hotkeys[hotkey]: callback(event) - return event.scan_code or (event.name and event.name != 'unknown') + return event.scan_code or (event.name and event.name != "unknown") def direct_callback(self, event): """ @@ -427,7 +450,8 @@ def direct_callback(self, event): # Update tables of currently pressed keys and modifiers. with _pressed_events_lock: if event_type == KEY_DOWN: - if is_modifier(scan_code): self.active_modifiers.add(scan_code) + if is_modifier(scan_code): + self.active_modifiers.add(scan_code) _pressed_events[scan_code] = event hotkey = tuple(sorted(_pressed_events)) if event_type == KEY_UP: @@ -444,7 +468,7 @@ def direct_callback(self, event): if self.blocking_hotkeys: if self.filtered_modifiers[scan_code]: - origin = 'modifier' + origin = "modifier" modifiers_to_update = {scan_code} else: modifiers_to_update = self.active_modifiers @@ -453,15 +477,17 @@ def direct_callback(self, event): callback_results = [callback(event) for callback in self.blocking_hotkeys[hotkey]] if callback_results: accept = all(callback_results) - origin = 'hotkey' + origin = "hotkey" else: - origin = 'other' + origin = "other" for key in sorted(modifiers_to_update): - transition_tuple = (self.modifier_states.get(key, 'free'), event_type, origin) + transition_tuple = (self.modifier_states.get(key, "free"), event_type, origin) should_press, new_accept, new_state = self.transition_table[transition_tuple] - if should_press: press(key) - if new_accept is not None: accept = new_accept + if should_press: + press(key) + if new_accept is not None: + accept = new_accept self.modifier_states[key] = new_state if accept: @@ -478,8 +504,10 @@ def direct_callback(self, event): def listen(self): _os_keyboard.listen(self.direct_callback) + _listener = _KeyboardListener() + def key_to_scan_codes(key, error_if_missing=True): """ Returns a list of scan codes associated with this key (name or scan code). @@ -489,27 +517,32 @@ def key_to_scan_codes(key, error_if_missing=True): elif _is_list(key): return sum((key_to_scan_codes(i) for i in key), ()) elif not _is_str(key): - raise ValueError('Unexpected key type ' + str(type(key)) + ', value (' + repr(key) + ')') + raise ValueError("Unexpected key type " + str(type(key)) + ", value (" + repr(key) + ")") normalized = normalize_name(key) if normalized in sided_modifiers: - left_scan_codes = key_to_scan_codes('left ' + normalized, False) - right_scan_codes = key_to_scan_codes('right ' + normalized, False) + left_scan_codes = key_to_scan_codes("left " + normalized, False) + right_scan_codes = key_to_scan_codes("right " + normalized, False) return left_scan_codes + tuple(c for c in right_scan_codes if c not in left_scan_codes) try: # Put items in ordered dict to remove duplicates. - t = tuple(_collections.OrderedDict((scan_code, True) for scan_code, modifier in _os_keyboard.map_name(normalized))) + t = tuple( + _collections.OrderedDict( + (scan_code, True) for scan_code, modifier in _os_keyboard.map_name(normalized) + ) + ) e = None except (KeyError, ValueError) as exception: t = () e = exception if not t and error_if_missing: - raise ValueError(f'Key {key!r} is not mapped to any known key.', e) + raise ValueError(f"Key {key!r} is not mapped to any known key.", e) else: return t + def parse_hotkey(hotkey): """ Parses a user-provided hotkey into nested tuples representing the @@ -538,11 +571,12 @@ def parse_hotkey(hotkey): return hotkey steps = [] - for step in _re.split(r',\s?', hotkey): - keys = _re.split(r'\s?\+\s?', step) + for step in _re.split(r",\s?", hotkey): + keys = _re.split(r"\s?\+\s?", step) steps.append(tuple(key_to_scan_codes(key) for key in keys)) return tuple(steps) + def send(hotkey, do_press=True, do_release=True): """ Sends OS events that perform the given *hotkey* hotkey. @@ -573,17 +607,21 @@ def send(hotkey, do_press=True, do_release=True): _listener.is_replaying = False + # Alias. press_and_release = send + def press(hotkey): - """ Presses and holds down a hotkey (see `send`). """ + """Presses and holds down a hotkey (see `send`).""" send(hotkey, True, False) + def release(hotkey): - """ Releases a hotkey (see `send`). """ + """Releases a hotkey (see `send`).""" send(hotkey, False, True) + def is_pressed(hotkey): """ Returns True if the key is pressed. @@ -601,7 +639,9 @@ def is_pressed(hotkey): steps = parse_hotkey(hotkey) if len(steps) > 1: - raise ValueError("Impossible to check if multi-step hotkeys are pressed (`a+b` is ok, `a, b` isn't).") + raise ValueError( + "Impossible to check if multi-step hotkeys are pressed (`a+b` is ok, `a, b` isn't)." + ) # Convert _pressed_events into a set with _pressed_events_lock: @@ -611,6 +651,7 @@ def is_pressed(hotkey): return False return True + def call_later(fn, args=(), delay=0.001): """ Calls the provided function in a new thread after waiting some time. @@ -620,7 +661,10 @@ def call_later(fn, args=(), delay=0.001): thread = _Thread(target=lambda: (_time.sleep(delay), fn(*args))) thread.start() + _hooks = {} + + def hook(callback, suppress=False, on_remove=lambda: None): """ Installs a global listener on all available keyboards, invoking `callback` @@ -644,26 +688,31 @@ def hook(callback, suppress=False, on_remove=lambda: None): append, remove = _listener.add_handler, _listener.remove_handler append(callback) + def remove_(): _hooks.pop(callback, None) _hooks.pop(remove_, None) remove(callback) on_remove() + _hooks[callback] = _hooks[remove_] = remove_ return remove_ + def on_press(callback, suppress=False): """ Invokes `callback` for every KEY_DOWN event. For details see `hook`. """ return hook(lambda e: e.event_type == KEY_UP or callback(e), suppress=suppress) + def on_release(callback, suppress=False): """ Invokes `callback` for every KEY_UP event. For details see `hook`. """ return hook(lambda e: e.event_type == KEY_DOWN or callback(e), suppress=suppress) + def hook_key(key, callback, suppress=False): """ Hooks key up and key down events for a single key. Returns the event handler @@ -682,32 +731,39 @@ def hook_key(key, callback, suppress=False): def remove_(): _hooks.pop(callback, None) _hooks.pop(key, None) - _hooks.pop(remove_ ,None) + _hooks.pop(remove_, None) for scan_code in scan_codes: store[scan_code].remove(callback) + _hooks[callback] = _hooks[key] = _hooks[remove_] = remove_ return remove_ + def on_press_key(key, callback, suppress=False): """ Invokes `callback` for KEY_DOWN event related to the given key. For details see `hook`. """ return hook_key(key, lambda e: e.event_type == KEY_UP or callback(e), suppress=suppress) + def on_release_key(key, callback, suppress=False): """ Invokes `callback` for KEY_UP event related to the given key. For details see `hook`. """ return hook_key(key, lambda e: e.event_type == KEY_DOWN or callback(e), suppress=suppress) + def unhook(remove): """ Removes a previously added hook, either by callback or by the return value of `hook`. """ _hooks[remove]() + + unhook_key = unhook + def unhook_all(): """ Removes all keyboard hooks in use, including hotkeys, abbreviations, word @@ -720,33 +776,43 @@ def unhook_all(): del _listener.handlers[:] unhook_all_hotkeys() + def block_key(key): """ Suppresses all key events of the given key, regardless of modifiers. """ return hook_key(key, lambda e: False, suppress=True) + + unblock_key = unhook_key + def remap_key(src, dst): """ Whenever the key `src` is pressed or released, regardless of modifiers, press or release the hotkey `dst` instead. """ + def handler(event): if event.event_type == KEY_DOWN: press(dst) else: release(dst) return False + return hook_key(src, handler, suppress=True) + + unremap_key = unhook_key + def parse_hotkey_combinations(hotkey): """ Parses a user-provided hotkey. Differently from `parse_hotkey`, instead of each step being a list of the different scan codes for each key, each step is a list of all possible combinations of those scan codes. """ + def combine_step(step): # A single step may be composed of many keys, and each key can have # multiple scan codes. To speed up hotkey matching and avoid introducing @@ -757,6 +823,7 @@ def combine_step(step): return tuple(tuple(combine_step(step)) for step in parse_hotkey(hotkey)) + def _add_hotkey_step(handler, combinations, suppress): """ Hooks a single-step hotkey (e.g. 'shift+a'). @@ -778,9 +845,13 @@ def remove(): if is_modifier(scan_code): _listener.filtered_modifiers[scan_code] -= 1 container[scan_codes].remove(handler) + return remove + _hotkeys = {} + + def add_hotkey(hotkey, callback, args=(), suppress=False, timeout=1, trigger_on_release=False): """ Invokes a callback every time a hotkey is pressed. The hotkey must @@ -831,13 +902,22 @@ def add_hotkey(hotkey, callback, args=(), suppress=False, timeout=1, trigger_on_ # and any mistake will make that key "sticky". Therefore just let all # KEY_UP events go through as long as that's not what we are listening # for. - handler = lambda e: (event_type == KEY_DOWN and e.event_type == KEY_UP and e.scan_code in _logically_pressed_keys) or (event_type == e.event_type and callback()) + handler = lambda e: ( + ( + event_type == KEY_DOWN + and e.event_type == KEY_UP + and e.scan_code in _logically_pressed_keys + ) + or (event_type == e.event_type and callback()) + ) remove_step = _add_hotkey_step(handler, steps[0], suppress) + def remove_(): remove_step() _hotkeys.pop(hotkey, None) _hotkeys.pop(remove_, None) _hotkeys.pop(callback, None) + # TODO: allow multiple callbacks for each hotkey without overwriting the # remover. _hotkeys[hotkey] = _hotkeys[remove_] = _hotkeys[callback] = remove_ @@ -847,18 +927,18 @@ def remove_(): state.remove_catch_misses = lambda: None state.remove_last_step = None state.suppressed_events = [] - state.last_update = float('-inf') + state.last_update = float("-inf") def catch_misses(event, force_fail=False): if ( + ( event.event_type == event_type and state.index and event.scan_code not in allowed_keys_by_step[state.index] - ) or ( - timeout - and _time.monotonic() - state.last_update >= timeout - ) or force_fail: # Weird formatting to ensure short-circuit. - + ) + or (timeout and _time.monotonic() - state.last_update >= timeout) + or force_fail + ): # Weird formatting to ensure short-circuit. state.remove_last_step() for event in state.suppressed_events: @@ -885,6 +965,7 @@ def set_index(new_index): state.remove_catch_misses = hook(catch_misses, suppress=True) if new_index == len(steps) - 1: + def handler(event): if event.event_type == KEY_UP: remove() @@ -895,25 +976,25 @@ def handler(event): else: state.suppressed_events[:] = [event] return False + remove = _add_hotkey_step(handler, steps[state.index], suppress) else: # Fix value of next_index. - def handler(event, new_index=state.index+1): + def handler(event, new_index=state.index + 1): if event.event_type == KEY_UP: remove() set_index(new_index) state.suppressed_events.append(event) return False + remove = _add_hotkey_step(handler, steps[state.index], suppress) state.remove_last_step = remove state.last_update = _time.monotonic() return False + set_index(0) - allowed_keys_by_step = [ - set().union(*step) - for step in steps - ] + allowed_keys_by_step = [set().union(*step) for step in steps] def remove_(): state.remove_catch_misses() @@ -921,20 +1002,27 @@ def remove_(): _hotkeys.pop(hotkey, None) _hotkeys.pop(remove_, None) _hotkeys.pop(callback, None) + # TODO: allow multiple callbacks for each hotkey without overwriting the # remover. _hotkeys[hotkey] = _hotkeys[remove_] = _hotkeys[callback] = remove_ return remove_ + + register_hotkey = add_hotkey + def remove_hotkey(hotkey_or_callback): """ Removes a previously hooked hotkey. Must be called with the value returned by `add_hotkey`. """ _hotkeys[hotkey_or_callback]() + + unregister_hotkey = clear_hotkey = remove_hotkey + def unhook_all_hotkeys(): """ Removes all keyboard hotkeys in use, including abbreviations, word listeners, @@ -944,8 +1032,11 @@ def unhook_all_hotkeys(): # are removed together. _listener.blocking_hotkeys.clear() _listener.nonblocking_hotkeys.clear() + + unregister_all_hotkeys = remove_all_hotkeys = clear_all_hotkeys = unhook_all_hotkeys + def remap_hotkey(src, dst, suppress=True, trigger_on_release=False): """ Whenever the hotkey `src` is pressed, suppress it and send @@ -955,17 +1046,24 @@ def remap_hotkey(src, dst, suppress=True, trigger_on_release=False): remap('alt+w', 'ctrl+up') """ + def handler(): - active_modifiers = sorted(modifier for modifier, state in _listener.modifier_states.items() if state == 'allowed') + active_modifiers = sorted( + modifier for modifier, state in _listener.modifier_states.items() if state == "allowed" + ) for modifier in active_modifiers: release(modifier) send(dst) for modifier in reversed(active_modifiers): press(modifier) return False + return add_hotkey(src, handler, suppress=suppress, trigger_on_release=trigger_on_release) + + unremap_hotkey = remove_hotkey + def stash_state(): """ Builds a list of all currently pressed scan codes, releases them and returns @@ -978,6 +1076,7 @@ def stash_state(): _os_keyboard.release(scan_code) return state + def restore_state(scan_codes): """ Given a list of scan_codes ensures these keys, and only these keys, are @@ -995,12 +1094,14 @@ def restore_state(scan_codes): _listener.is_replaying = False + def restore_modifiers(scan_codes): """ Like `restore_state`, but only restores modifier keys. """ restore_state(scan_code for scan_code in scan_codes if is_modifier(scan_code)) + def write(text, delay=0, restore_state_after=True, exact=None): """ Sends artificial keyboard events to the OS, simulating the typing of a given @@ -1020,18 +1121,19 @@ def write(text, delay=0, restore_state_after=True, exact=None): value. """ if exact is None: - exact = _platform.system() == 'Windows' + exact = _platform.system() == "Windows" state = stash_state() # Window's typing of unicode characters is quite efficient and should be preferred. if exact: for letter in text: - if letter in '\n\b': + if letter in "\n\b": send(letter) else: _os_keyboard.type_unicode(letter) - if delay: _time.sleep(delay) + if delay: + _time.sleep(delay) else: for letter in text: try: @@ -1056,6 +1158,7 @@ def write(text, delay=0, restore_state_after=True, exact=None): if restore_state_after: restore_modifiers(state) + def wait(hotkey=None, suppress=False, trigger_on_release=False): """ Blocks the program execution until the given hotkey is pressed or, @@ -1063,13 +1166,16 @@ def wait(hotkey=None, suppress=False, trigger_on_release=False): """ if hotkey: lock = _Event() - remove = add_hotkey(hotkey, lambda: lock.set(), suppress=suppress, trigger_on_release=trigger_on_release) + remove = add_hotkey( + hotkey, lambda: lock.set(), suppress=suppress, trigger_on_release=trigger_on_release + ) lock.wait() remove_hotkey(remove) else: while True: _time.sleep(1e6) + def get_hotkey_name(names=None): """ Returns a string representation of hotkey from the given key names, or @@ -1093,13 +1199,14 @@ def get_hotkey_name(names=None): names = [e.name for e in _pressed_events.values()] else: names = [normalize_name(name) for name in names] - clean_names = {e.replace('left ', '').replace('right ', '').replace('+', 'plus') for e in names} + clean_names = {e.replace("left ", "").replace("right ", "").replace("+", "plus") for e in names} # https://developer.apple.com/macos/human-interface-guidelines/input-and-output/keyboard/ # > List modifier keys in the correct order. If you use more than one modifier key in a # > hotkey, always list them in this order: Control, Option, Shift, Command. - modifiers = ['ctrl', 'alt', 'shift', 'windows'] + modifiers = ["ctrl", "alt", "shift", "windows"] sorting_key = lambda k: (modifiers.index(k) if k in modifiers else 5, str(k)) - return '+'.join(sorted(clean_names, key=sorting_key)) + return "+".join(sorted(clean_names, key=sorting_key)) + def read_event(suppress=False): """ @@ -1112,6 +1219,7 @@ def read_event(suppress=False): unhook(hooked) return event + def read_key(suppress=False): """ Blocks until a keyboard event happens, then returns that event's name or, @@ -1120,6 +1228,7 @@ def read_key(suppress=False): event = read_event(suppress) return event.name or event.scan_code + def read_hotkey(suppress=True): """ Similar to `read_key()`, but blocks until the user presses and releases a @@ -1142,6 +1251,7 @@ def read_hotkey(suppress=True): names = [e.name for e in _pressed_events.values()] + [event.name] return get_hotkey_name(names) + def get_typed_strings(events, allow_backspace=True): """ Given a sequence of events, tries to deduce what strings were typed. @@ -1159,36 +1269,39 @@ def get_typed_strings(events, allow_backspace=True): get_type_strings(record()) #-> ['This is what', 'I recorded', ''] """ - backspace_name = 'delete' if _platform.system() == 'Darwin' else 'backspace' + backspace_name = "delete" if _platform.system() == "Darwin" else "backspace" shift_pressed = False capslock_pressed = False - string = '' + string = "" for event in events: name = event.name # Space is the only key that we _parse_hotkey to the spelled out name # because of legibility. Now we have to undo that. - if event.name == 'space': - name = ' ' + if event.name == "space": + name = " " - if 'shift' in event.name: - shift_pressed = event.event_type == 'down' - elif event.name == 'caps lock' and event.event_type == 'down': + if "shift" in event.name: + shift_pressed = event.event_type == "down" + elif event.name == "caps lock" and event.event_type == "down": capslock_pressed = not capslock_pressed - elif allow_backspace and event.name == backspace_name and event.event_type == 'down': + elif allow_backspace and event.name == backspace_name and event.event_type == "down": string = string[:-1] - elif event.event_type == 'down': + elif event.event_type == "down": if len(name) == 1: if shift_pressed ^ capslock_pressed: name = name.upper() string = string + name else: yield string - string = '' + string = "" yield string + _recording = None + + def start_recording(recorded_events_queue=None): """ Starts recording all keyboard events into a global variable, or the given @@ -1201,6 +1314,7 @@ def start_recording(recorded_events_queue=None): _recording = (recorded_events_queue, hook(recorded_events_queue.put)) return _recording + def stop_recording(): """ Stops the global recording of events and returns a list of the events @@ -1213,7 +1327,8 @@ def stop_recording(): unhook(hooked) return list(recorded_events_queue.queue) -def record(until='escape', suppress=False, trigger_on_release=False): + +def record(until="escape", suppress=False, trigger_on_release=False): """ Records all keyboard events from all keyboards until the user presses the given hotkey. Then returns the list of events recorded, of type @@ -1227,6 +1342,7 @@ def record(until='escape', suppress=False, trigger_on_release=False): wait(until, suppress=suppress, trigger_on_release=trigger_on_release) return stop_recording() + def play(events, speed_factor=1.0): """ Plays a sequence of recorded events, maintaining the relative time @@ -1248,10 +1364,14 @@ def play(events, speed_factor=1.0): press(key) if event.event_type == KEY_DOWN else release(key) restore_modifiers(state) + + replay = play _word_listeners = {} -def add_word_listener(word, callback, triggers=['space'], match_suffix=False, timeout=2): + + +def add_word_listener(word, callback, triggers=["space"], match_suffix=False, timeout=2): """ Invokes a callback every time a sequence of characters is typed (e.g. 'pet') and followed by a trigger key (e.g. space). Modifiers (e.g. alt, ctrl, @@ -1277,36 +1397,40 @@ def add_word_listener(word, callback, triggers=['space'], match_suffix=False, ti Note: word matches are **case sensitive**. """ state = _State() - state.current = '' + state.current = "" state.time = -1 def handler(event): name = event.name - if event.event_type == KEY_UP or name in all_modifiers: return + if event.event_type == KEY_UP or name in all_modifiers: + return if timeout and event.time - state.time > timeout: - state.current = '' + state.current = "" state.time = event.time matched = state.current == word or (match_suffix and state.current.endswith(word)) if name in triggers and matched: callback() - state.current = '' + state.current = "" elif len(name) > 1: - state.current = '' + state.current = "" else: state.current += name hooked = hook(handler) + def remove(): hooked() _word_listeners.pop(word, None) _word_listeners.pop(handler, None) _word_listeners.pop(remove, None) + _word_listeners[word] = _word_listeners[handler] = _word_listeners[remove] = remove # TODO: allow multiple word listeners and removing them correctly. return remove + def remove_word_listener(word_or_handler): """ Removes a previously registered word listener. Accepts either the word used @@ -1315,6 +1439,7 @@ def remove_word_listener(word_or_handler): """ _word_listeners[word_or_handler]() + def add_abbreviation(source_text, replacement_text, match_suffix=False, timeout=2): """ Registers a hotkey that replaces one typed text with another. For example @@ -1332,10 +1457,11 @@ def add_abbreviation(source_text, replacement_text, match_suffix=False, timeout= For more details see `add_word_listener`. """ - replacement = '\b'*(len(source_text)+1) + replacement_text + replacement = "\b" * (len(source_text) + 1) + replacement_text callback = lambda: write(replacement) return add_word_listener(source_text, callback, match_suffix=match_suffix, timeout=timeout) + # Aliases. register_word_listener = add_word_listener register_abbreviation = add_abbreviation diff --git a/src/directkeys/__main__.py b/src/directkeys/__main__.py index 46aff00..66650f8 100644 --- a/src/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -6,8 +6,10 @@ def print_event_json(event): - print(event.to_json(ensure_ascii=sys.stdout.encoding != 'utf-8')) + print(event.to_json(ensure_ascii=sys.stdout.encoding != "utf-8")) sys.stdout.flush() + + directkeys.hook(print_event_json) parse_event_json = lambda line: directkeys.KeyboardEvent(**json.loads(line)) diff --git a/src/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py index 10da204..4047bb9 100644 --- a/src/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -2,81 +2,71 @@ # Defaults to Windows canonical names (platform-specific overrides below) canonical_names = { - 'escape': 'esc', - 'return': 'enter', - 'del': 'delete', - 'control': 'ctrl', - - 'left arrow': 'left', - 'up arrow': 'up', - 'down arrow': 'down', - 'right arrow': 'right', - - ' ': 'space', # Prefer to spell out keys that would be hard to read. - '\x1b': 'esc', - '\x08': 'backspace', - '\n': 'enter', - '\t': 'tab', - '\r': 'enter', - - 'scrlk': 'scroll lock', - 'prtscn': 'print screen', - 'prnt scrn': 'print screen', - 'snapshot': 'print screen', - 'ins': 'insert', - 'pause break': 'pause', - 'ctrll lock': 'caps lock', - 'capslock': 'caps lock', - 'number lock': 'num lock', - 'numlock': 'num lock', - 'space bar': 'space', - 'spacebar': 'space', - 'linefeed': 'enter', - 'win': 'windows', - + "escape": "esc", + "return": "enter", + "del": "delete", + "control": "ctrl", + "left arrow": "left", + "up arrow": "up", + "down arrow": "down", + "right arrow": "right", + " ": "space", # Prefer to spell out keys that would be hard to read. + "\x1b": "esc", + "\x08": "backspace", + "\n": "enter", + "\t": "tab", + "\r": "enter", + "scrlk": "scroll lock", + "prtscn": "print screen", + "prnt scrn": "print screen", + "snapshot": "print screen", + "ins": "insert", + "pause break": "pause", + "ctrll lock": "caps lock", + "capslock": "caps lock", + "number lock": "num lock", + "numlock": "num lock", + "space bar": "space", + "spacebar": "space", + "linefeed": "enter", + "win": "windows", # Mac keys - 'command': 'windows', - 'cmd': 'windows', - 'control': 'ctrl', - 'option': 'alt', - - 'app': 'menu', - 'apps': 'menu', - 'application': 'menu', - 'applications': 'menu', - - 'pagedown': 'page down', - 'pageup': 'page up', - 'pgdown': 'page down', - 'pgup': 'page up', - - 'play/pause': 'play/pause media', - - 'num multiply': '*', - 'num divide': '/', - 'num add': '+', - 'num plus': '+', - 'num minus': '-', - 'num sub': '-', - 'num enter': 'enter', - 'num 0': '0', - 'num 1': '1', - 'num 2': '2', - 'num 3': '3', - 'num 4': '4', - 'num 5': '5', - 'num 6': '6', - 'num 7': '7', - 'num 8': '8', - 'num 9': '9', - - 'left win': 'left windows', - 'right win': 'right windows', - 'left control': 'left ctrl', - 'right control': 'right ctrl', - 'left menu': 'left alt', # Windows... - 'altgr': 'alt gr', - + "command": "windows", + "cmd": "windows", + "control": "ctrl", + "option": "alt", + "app": "menu", + "apps": "menu", + "application": "menu", + "applications": "menu", + "pagedown": "page down", + "pageup": "page up", + "pgdown": "page down", + "pgup": "page up", + "play/pause": "play/pause media", + "num multiply": "*", + "num divide": "/", + "num add": "+", + "num plus": "+", + "num minus": "-", + "num sub": "-", + "num enter": "enter", + "num 0": "0", + "num 1": "1", + "num 2": "2", + "num 3": "3", + "num 4": "4", + "num 5": "5", + "num 6": "6", + "num 7": "7", + "num 8": "8", + "num 9": "9", + "left win": "left windows", + "right win": "right windows", + "left control": "left ctrl", + "right control": "right ctrl", + "left menu": "left alt", # Windows... + "altgr": "alt gr", # https://www.x.org/releases/X11R7.6/doc/libX11/Compose/en_US.UTF-8.html # https://svn.apache.org/repos/asf/xmlgraphics/commons/tags/commons-1_0/src/java/org/apache/xmlgraphics/fonts/Glyphs.java # Note this list has plenty of uppercase letters that are not being used @@ -939,7 +929,7 @@ "questiondown": "¿", "questiondownsmall": "", "questionsmall": "", - "quotedbl": "\"", + "quotedbl": '"', "quotedblbase": "„", "quotedblleft": "“", "quotedblright": "”", @@ -1199,28 +1189,37 @@ "Zeta": "Ζ", "Zsmall": "", } -sided_modifiers = {'ctrl', 'alt', 'shift', 'windows'} -all_modifiers = {'alt', 'alt gr', 'ctrl', 'shift', 'windows'} | {'left ' + n for n in sided_modifiers} | {'right ' + n for n in sided_modifiers} +sided_modifiers = {"ctrl", "alt", "shift", "windows"} +all_modifiers = ( + {"alt", "alt gr", "ctrl", "shift", "windows"} + | {"left " + n for n in sided_modifiers} + | {"right " + n for n in sided_modifiers} +) # Platform-specific canonical overrides -if platform.system() == 'Darwin': - canonical_names.update({ - "command": "command", - "windows": "command", - "cmd": "command", - "win": "command", - "backspace": "delete", - 'alt gr': 'alt' # Issue #117 - }) - all_modifiers = {'alt', 'ctrl', 'shift', 'windows'} -if platform.system() == 'Linux': - canonical_names.update({ - "select": "end", - "find": "home", - 'next': 'page down', - 'prior': 'page up', - }) +if platform.system() == "Darwin": + canonical_names.update( + { + "command": "command", + "windows": "command", + "cmd": "command", + "win": "command", + "backspace": "delete", + "alt gr": "alt", # Issue #117 + } + ) + all_modifiers = {"alt", "ctrl", "shift", "windows"} +if platform.system() == "Linux": + canonical_names.update( + { + "select": "end", + "find": "home", + "next": "page down", + "prior": "page up", + } + ) + def normalize_name(name): """ @@ -1228,11 +1227,11 @@ def normalize_name(name): the canonical representation (e.g. "left ctrl") if one is known. """ if not name or not isinstance(name, str): - raise ValueError('Can only normalize non-empty string names. Unexpected '+ repr(name)) + raise ValueError("Can only normalize non-empty string names. Unexpected " + repr(name)) if len(name) > 1: name = name.lower() - if name != '_' and '_' in name: - name = name.replace('_', ' ') + if name != "_" and "_" in name: + name = name.replace("_", " ") return canonical_names.get(name, name) diff --git a/src/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py index 4d1feb8..4fdfb0d 100644 --- a/src/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -9,61 +9,66 @@ from ._canonical_names import normalize_name from ._keyboard_event import KeyboardEvent -Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library('Carbon')) +Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library("Carbon")) + class KeyMap: - non_layout_keys = {vk: normalize_name(name) for vk, name in { - # Layout specific keys from https://stackoverflow.com/a/16125341/252218 - # Unfortunately no source for layout-independent keys was found. - 0x24: 'return', - 0x30: 'tab', - 0x31: 'space', - 0x33: 'delete', - 0x35: 'escape', - 0x37: 'command', - 0x38: 'shift', - 0x39: 'capslock', - 0x3a: 'option', - 0x3b: 'control', - 0x3c: 'right shift', - 0x3d: 'right option', - 0x3e: 'right control', - 0x3f: 'function', - 0x40: 'f17', - 0x48: 'volume up', - 0x49: 'volume down', - 0x4a: 'mute', - 0x4f: 'f18', - 0x50: 'f19', - 0x5a: 'f20', - 0x60: 'f5', - 0x61: 'f6', - 0x62: 'f7', - 0x63: 'f3', - 0x64: 'f8', - 0x65: 'f9', - 0x67: 'f11', - 0x69: 'f13', - 0x6a: 'f16', - 0x6b: 'f14', - 0x6d: 'f10', - 0x6f: 'f12', - 0x71: 'f15', - 0x72: 'help', - 0x73: 'home', - 0x74: 'page up', - 0x75: 'forward delete', - 0x76: 'f4', - 0x77: 'end', - 0x78: 'f2', - 0x79: 'page down', - 0x7a: 'f1', - 0x7b: 'left', - 0x7c: 'right', - 0x7d: 'down', - 0x7e: 'up', - }.items()} + non_layout_keys = { + vk: normalize_name(name) + for vk, name in { + # Layout specific keys from https://stackoverflow.com/a/16125341/252218 + # Unfortunately no source for layout-independent keys was found. + 0x24: "return", + 0x30: "tab", + 0x31: "space", + 0x33: "delete", + 0x35: "escape", + 0x37: "command", + 0x38: "shift", + 0x39: "capslock", + 0x3A: "option", + 0x3B: "control", + 0x3C: "right shift", + 0x3D: "right option", + 0x3E: "right control", + 0x3F: "function", + 0x40: "f17", + 0x48: "volume up", + 0x49: "volume down", + 0x4A: "mute", + 0x4F: "f18", + 0x50: "f19", + 0x5A: "f20", + 0x60: "f5", + 0x61: "f6", + 0x62: "f7", + 0x63: "f3", + 0x64: "f8", + 0x65: "f9", + 0x67: "f11", + 0x69: "f13", + 0x6A: "f16", + 0x6B: "f14", + 0x6D: "f10", + 0x6F: "f12", + 0x71: "f15", + 0x72: "help", + 0x73: "home", + 0x74: "page up", + 0x75: "forward delete", + 0x76: "f4", + 0x77: "end", + 0x78: "f2", + 0x79: "page down", + 0x7A: "f1", + 0x7B: "left", + 0x7C: "right", + 0x7D: "down", + 0x7E: "up", + }.items() + } layout_specific_keys = {} + def __init__(self): # Virtual key codes are usually the same for any given key, unless you have a different # keyboard layout. The only way I've found to determine the layout relies on (supposedly @@ -81,10 +86,11 @@ def __init__(self): UniChar4 = UniChar * 4 class CFRange(ctypes.Structure): - _fields_ = [('loc', CFIndex), - ('len', CFIndex)] + _fields_ = [("loc", CFIndex), ("len", CFIndex)] - kTISPropertyUnicodeKeyLayoutData = ctypes.c_void_p.in_dll(Carbon, 'kTISPropertyUnicodeKeyLayoutData') + kTISPropertyUnicodeKeyLayoutData = ctypes.c_void_p.in_dll( + Carbon, "kTISPropertyUnicodeKeyLayoutData" + ) shiftKey = 0x0200 alphaKey = 0x0400 optionKey = 0x0800 @@ -93,7 +99,7 @@ class CFRange(ctypes.Structure): kUCKeyTranslateNoDeadKeysBit = 0 # Set up function calls: - Carbon.CFDataGetBytes.argtypes = [CFDataRef] #, CFRange, UInt8 + Carbon.CFDataGetBytes.argtypes = [CFDataRef] # , CFRange, UInt8 Carbon.CFDataGetBytes.restype = None Carbon.CFDataGetLength.argtypes = [CFDataRef] Carbon.CFDataGetLength.restype = CFIndex @@ -107,16 +113,18 @@ class CFRange(ctypes.Structure): Carbon.TISCopyCurrentASCIICapableKeyboardLayoutInputSource.restype = ctypes.c_void_p Carbon.TISGetInputSourceProperty.argtypes = [ctypes.c_void_p, ctypes.c_void_p] Carbon.TISGetInputSourceProperty.restype = ctypes.c_void_p - Carbon.UCKeyTranslate.argtypes = [ctypes.c_void_p, - ctypes.c_uint16, - ctypes.c_uint16, - ctypes.c_uint32, - ctypes.c_uint32, - OptionBits, # keyTranslateOptions - ctypes.POINTER(ctypes.c_uint32), # deadKeyState - UniCharCount, # maxStringLength - ctypes.POINTER(UniCharCount), # actualStringLength - UniChar4] + Carbon.UCKeyTranslate.argtypes = [ + ctypes.c_void_p, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint32, + ctypes.c_uint32, + OptionBits, # keyTranslateOptions + ctypes.POINTER(ctypes.c_uint32), # deadKeyState + UniCharCount, # maxStringLength + ctypes.POINTER(UniCharCount), # actualStringLength + UniChar4, + ] Carbon.UCKeyTranslate.restype = ctypes.c_uint32 # Get keyboard layout @@ -126,7 +134,9 @@ class CFRange(ctypes.Structure): klis = Carbon.TISCopyCurrentASCIICapableKeyboardLayoutInputSource() k_layout = Carbon.TISGetInputSourceProperty(klis, kTISPropertyUnicodeKeyLayoutData) k_layout_size = Carbon.CFDataGetLength(k_layout) - k_layout_buffer = ctypes.create_string_buffer(k_layout_size) # TODO - Verify this works instead of initializing with empty string + k_layout_buffer = ctypes.create_string_buffer( + k_layout_size + ) # TODO - Verify this works instead of initializing with empty string Carbon.CFDataGetBytes(k_layout, CFRange(0, k_layout_size), ctypes.byref(k_layout_buffer)) # Generate character representations of key codes @@ -137,39 +147,43 @@ class CFRange(ctypes.Structure): keys_down = ctypes.c_uint32() char_count = UniCharCount() - retval = Carbon.UCKeyTranslate(k_layout_buffer, - key_code, - kUCKeyActionDisplay, - 0, # No modifier - Carbon.LMGetKbdType(), - kUCKeyTranslateNoDeadKeysBit, - ctypes.byref(keys_down), - 4, - ctypes.byref(char_count), - non_shifted_char) - - non_shifted_key = ''.join(chr(non_shifted_char[i]) for i in range(char_count.value)) - - retval = Carbon.UCKeyTranslate(k_layout_buffer, - key_code, - kUCKeyActionDisplay, - shiftKey >> 8, # Shift - Carbon.LMGetKbdType(), - kUCKeyTranslateNoDeadKeysBit, - ctypes.byref(keys_down), - 4, - ctypes.byref(char_count), - shifted_char) - - shifted_key = ''.join(chr(shifted_char[i]) for i in range(char_count.value)) + retval = Carbon.UCKeyTranslate( + k_layout_buffer, + key_code, + kUCKeyActionDisplay, + 0, # No modifier + Carbon.LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + ctypes.byref(keys_down), + 4, + ctypes.byref(char_count), + non_shifted_char, + ) + + non_shifted_key = "".join(chr(non_shifted_char[i]) for i in range(char_count.value)) + + retval = Carbon.UCKeyTranslate( + k_layout_buffer, + key_code, + kUCKeyActionDisplay, + shiftKey >> 8, # Shift + Carbon.LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + ctypes.byref(keys_down), + 4, + ctypes.byref(char_count), + shifted_char, + ) + + shifted_key = "".join(chr(shifted_char[i]) for i in range(char_count.value)) self.layout_specific_keys[key_code] = (non_shifted_key, shifted_key) # Cleanup Carbon.CFRelease(klis) def character_to_vk(self, character): - """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code - and ``modifiers`` is an array of string modifier names (like 'shift') """ + """Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code + and ``modifiers`` is an array of string modifier names (like 'shift')""" for vk in self.non_layout_keys: if self.non_layout_keys[vk] == character.lower(): return (vk, []) @@ -177,17 +191,17 @@ def character_to_vk(self, character): if self.layout_specific_keys[vk][0] == character: return (vk, []) elif self.layout_specific_keys[vk][1] == character: - return (vk, ['shift']) + return (vk, ["shift"]) raise ValueError(f"Unrecognized character: {character}") def vk_to_character(self, vk, modifiers=[]): - """ Returns a character corresponding to the specified scan code (with given - modifiers applied) """ + """Returns a character corresponding to the specified scan code (with given + modifiers applied)""" if vk in self.non_layout_keys: # Not a character return self.non_layout_keys[vk] elif vk in self.layout_specific_keys: - if 'shift' in modifiers: + if "shift" in modifiers: return self.layout_specific_keys[vk][1] return self.layout_specific_keys[vk][0] else: @@ -206,60 +220,60 @@ def __init__(self): "cmd": False, } self.media_keys = { - 'KEYTYPE_SOUND_UP': 0, - 'KEYTYPE_SOUND_DOWN': 1, - 'KEYTYPE_BRIGHTNESS_UP': 2, - 'KEYTYPE_BRIGHTNESS_DOWN': 3, - 'KEYTYPE_CAPS_LOCK': 4, - 'KEYTYPE_HELP': 5, - 'POWER_KEY': 6, - 'KEYTYPE_MUTE': 7, - 'UP_ARROW_KEY': 8, - 'DOWN_ARROW_KEY': 9, - 'KEYTYPE_NUM_LOCK': 10, - 'KEYTYPE_CONTRAST_UP': 11, - 'KEYTYPE_CONTRAST_DOWN': 12, - 'KEYTYPE_LAUNCH_PANEL': 13, - 'KEYTYPE_EJECT': 14, - 'KEYTYPE_VIDMIRROR': 15, - 'KEYTYPE_PLAY': 16, - 'KEYTYPE_NEXT': 17, - 'KEYTYPE_PREVIOUS': 18, - 'KEYTYPE_FAST': 19, - 'KEYTYPE_REWIND': 20, - 'KEYTYPE_ILLUMINATION_UP': 21, - 'KEYTYPE_ILLUMINATION_DOWN': 22, - 'KEYTYPE_ILLUMINATION_TOGGLE': 23 + "KEYTYPE_SOUND_UP": 0, + "KEYTYPE_SOUND_DOWN": 1, + "KEYTYPE_BRIGHTNESS_UP": 2, + "KEYTYPE_BRIGHTNESS_DOWN": 3, + "KEYTYPE_CAPS_LOCK": 4, + "KEYTYPE_HELP": 5, + "POWER_KEY": 6, + "KEYTYPE_MUTE": 7, + "UP_ARROW_KEY": 8, + "DOWN_ARROW_KEY": 9, + "KEYTYPE_NUM_LOCK": 10, + "KEYTYPE_CONTRAST_UP": 11, + "KEYTYPE_CONTRAST_DOWN": 12, + "KEYTYPE_LAUNCH_PANEL": 13, + "KEYTYPE_EJECT": 14, + "KEYTYPE_VIDMIRROR": 15, + "KEYTYPE_PLAY": 16, + "KEYTYPE_NEXT": 17, + "KEYTYPE_PREVIOUS": 18, + "KEYTYPE_FAST": 19, + "KEYTYPE_REWIND": 20, + "KEYTYPE_ILLUMINATION_UP": 21, + "KEYTYPE_ILLUMINATION_DOWN": 22, + "KEYTYPE_ILLUMINATION_TOGGLE": 23, } def press(self, key_code): - """ Sends a 'down' event for the specified scan code """ + """Sends a 'down' event for the specified scan code""" if key_code >= 128: # Media key ev = NSEvent.otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2_( - 14, # type - (0, 0), # location - 0xa00, # flags - 0, # timestamp - 0, # window - 0, # ctx - 8, # subtype - ((key_code-128) << 16) | (0xa << 8), # data1 - -1 # data2 + 14, # type + (0, 0), # location + 0xA00, # flags + 0, # timestamp + 0, # window + 0, # ctx + 8, # subtype + ((key_code - 128) << 16) | (0xA << 8), # data1 + -1, # data2 ) Quartz.CGEventPost(0, ev.CGEvent()) else: # Regular key # Update modifiers if necessary - if key_code == 0x37: # cmd + if key_code == 0x37: # cmd self.current_modifiers["cmd"] = True - elif key_code == 0x38 or key_code == 0x3C: # shift or right shift + elif key_code == 0x38 or key_code == 0x3C: # shift or right shift self.current_modifiers["shift"] = True - elif key_code == 0x39: # caps lock + elif key_code == 0x39: # caps lock self.current_modifiers["caps"] = True - elif key_code == 0x3A: # alt + elif key_code == 0x3A: # alt self.current_modifiers["alt"] = True - elif key_code == 0x3B: # ctrl + elif key_code == 0x3B: # ctrl self.current_modifiers["ctrl"] = True # Apply modifiers if necessary @@ -280,33 +294,33 @@ def press(self, key_code): time.sleep(0.01) def release(self, key_code): - """ Sends an 'up' event for the specified scan code """ + """Sends an 'up' event for the specified scan code""" if key_code >= 128: # Media key ev = NSEvent.otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2_( - 14, # type - (0, 0), # location - 0xb00, # flags - 0, # timestamp - 0, # window - 0, # ctx - 8, # subtype - ((key_code-128) << 16) | (0xb << 8), # data1 - -1 # data2 + 14, # type + (0, 0), # location + 0xB00, # flags + 0, # timestamp + 0, # window + 0, # ctx + 8, # subtype + ((key_code - 128) << 16) | (0xB << 8), # data1 + -1, # data2 ) Quartz.CGEventPost(0, ev.CGEvent()) else: # Regular key # Update modifiers if necessary - if key_code == 0x37: # cmd + if key_code == 0x37: # cmd self.current_modifiers["cmd"] = False - elif key_code == 0x38 or key_code == 0x3C: # shift or right shift + elif key_code == 0x38 or key_code == 0x3C: # shift or right shift self.current_modifiers["shift"] = False - elif key_code == 0x39: # caps lock + elif key_code == 0x39: # caps lock self.current_modifiers["caps"] = False - elif key_code == 0x3A: # alt + elif key_code == 0x3A: # alt self.current_modifiers["alt"] = False - elif key_code == 0x3B: # ctrl + elif key_code == 0x3B: # ctrl self.current_modifiers["ctrl"] = False # Apply modifiers if necessary @@ -328,18 +342,20 @@ def release(self, key_code): def map_char(self, character): if character in self.media_keys: - return (128+self.media_keys[character],[]) + return (128 + self.media_keys[character], []) else: return self.key_map.character_to_vk(character) + def map_scan_code(self, scan_code): if scan_code >= 128: - character = [k for k, v in enumerate(self.media_keys) if v == scan_code-128] + character = [k for k, v in enumerate(self.media_keys) if v == scan_code - 128] if len(character): return character[0] return None else: return self.key_map.vk_to_character(scan_code) + class KeyEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking @@ -350,17 +366,18 @@ def __init__(self, callback, blocking=False): self.pressed_modifiers = set() def run(self): - """ Creates a listener and loops while waiting for an event. Intended to run as - a background thread. """ + """Creates a listener and loops while waiting for an event. Intended to run as + a background thread.""" self.tap = Quartz.CGEventTapCreate( Quartz.kCGSessionEventTap, Quartz.kCGHeadInsertEventTap, Quartz.kCGEventTapOptionDefault, - Quartz.CGEventMaskBit(Quartz.kCGEventKeyDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventKeyUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventFlagsChanged), + Quartz.CGEventMaskBit(Quartz.kCGEventKeyDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventKeyUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventFlagsChanged), self.handler, - None) + None, + ) loopsource = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0) loop = Quartz.CFRunLoopGetCurrent() Quartz.CFRunLoopAddSource(loop, loopsource, Quartz.kCFRunLoopDefaultMode) @@ -374,7 +391,7 @@ def handler(self, proxy, e_type, event, refcon): key_name = name_from_scancode(scan_code) flags = Quartz.CGEventGetFlags(event) event_type = "" - is_keypad = (flags & Quartz.kCGEventFlagMaskNumericPad) + is_keypad = flags & Quartz.kCGEventFlagMaskNumericPad if e_type == Quartz.kCGEventKeyDown: event_type = "down" elif e_type == Quartz.kCGEventKeyUp: @@ -388,29 +405,34 @@ def handler(self, proxy, e_type, event, refcon): # scan codes for each modifier key for bitmask, key_name_suffixes in ( - (Quartz.kCGEventFlagMaskShift, ("shift", )), - (Quartz.kCGEventFlagMaskAlphaShift, ("caps lock", )), - (Quartz.kCGEventFlagMaskControl, ("ctrl",)), - (Quartz.kCGEventFlagMaskCommand, ("command", "windows")), - (Quartz.kCGEventFlagMaskAlternate, ("option", "alt")), + (Quartz.kCGEventFlagMaskShift, ("shift",)), + (Quartz.kCGEventFlagMaskAlphaShift, ("caps lock",)), + (Quartz.kCGEventFlagMaskControl, ("ctrl",)), + (Quartz.kCGEventFlagMaskCommand, ("command", "windows")), + (Quartz.kCGEventFlagMaskAlternate, ("option", "alt")), ): ends_with_suffix = any(key_name.endswith(suffix) for suffix in key_name_suffixes) if ends_with_suffix: event_found = True - key_name_suffix = key_name_suffixes[0] # it doesn't matter here if we clobber suffixes from the same modifier like option/alt + key_name_suffix = key_name_suffixes[ + 0 + ] # it doesn't matter here if we clobber suffixes from the same modifier like option/alt if not (flags & bitmask): event_type = "up" - self.modifier_scancodes[key_name_suffix] = [] # just to be sure... - for suffix in key_name_suffixes: self.pressed_modifiers.discard(suffix) + self.modifier_scancodes[key_name_suffix] = [] # just to be sure... + for suffix in key_name_suffixes: + self.pressed_modifiers.discard(suffix) else: if scan_code in self.modifier_scancodes[key_name_suffix]: event_type = "up" self.modifier_scancodes[key_name_suffix].remove(scan_code) - for suffix in key_name_suffixes: self.pressed_modifiers.discard(suffix) + for suffix in key_name_suffixes: + self.pressed_modifiers.discard(suffix) else: event_type = "down" self.modifier_scancodes[key_name_suffix].append(scan_code) - for suffix in key_name_suffixes: self.pressed_modifiers.add(suffix) + for suffix in key_name_suffixes: + self.pressed_modifiers.add(suffix) if event_found: break if not event_found: @@ -420,43 +442,63 @@ def handler(self, proxy, e_type, event, refcon): return None pressed_modifiers_tuple = tuple(sorted(self.pressed_modifiers)) - self.callback(KeyboardEvent(event_type, scan_code, name=key_name, is_keypad=is_keypad, modifiers=pressed_modifiers_tuple)) + self.callback( + KeyboardEvent( + event_type, + scan_code, + name=key_name, + is_keypad=is_keypad, + modifiers=pressed_modifiers_tuple, + ) + ) return event + key_controller = KeyController() """ Exported functions below """ + def init(): key_controller = KeyController() + def press(scan_code): - """ Sends a 'down' event for the specified scan code """ + """Sends a 'down' event for the specified scan code""" key_controller.press(scan_code) + def release(scan_code): - """ Sends an 'up' event for the specified scan code """ + """Sends an 'up' event for the specified scan code""" key_controller.release(scan_code) + def map_name(name): - """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code - and ``modifiers`` is an array of string modifier names (like 'shift') """ + """Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code + and ``modifiers`` is an array of string modifier names (like 'shift')""" yield key_controller.map_char(name) + def name_from_scancode(scan_code): - """ Returns the name or character associated with the specified key code """ + """Returns the name or character associated with the specified key code""" return key_controller.map_scan_code(scan_code) + def listen(callback): KeyEventListener(callback).run() + def type_unicode(character): OUTPUT_SOURCE = Quartz.CGEventSourceCreate(Quartz.kCGEventSourceStateHIDSystemState) # Key down event = Quartz.CGEventCreateKeyboardEvent(OUTPUT_SOURCE, 0, True) - Quartz.CGEventKeyboardSetUnicodeString(event, len(character.encode('utf-16-le')) // 2, character) + Quartz.CGEventKeyboardSetUnicodeString( + event, len(character.encode("utf-16-le")) // 2, character + ) Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) # Key up event = Quartz.CGEventCreateKeyboardEvent(OUTPUT_SOURCE, 0, False) - Quartz.CGEventKeyboardSetUnicodeString(event, len(character.encode('utf-16-le')) // 2, character) + Quartz.CGEventKeyboardSetUnicodeString( + event, len(character.encode("utf-16-le")) // 2, character + ) Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) diff --git a/src/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py index 3b8afe6..e86e65f 100644 --- a/src/directkeys/_darwinmouse.py +++ b/src/directkeys/_darwinmouse.py @@ -7,21 +7,28 @@ from ._mouse_event import LEFT, MIDDLE, RIGHT _button_mapping = { - LEFT: (Quartz.kCGMouseButtonLeft, Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp, Quartz.kCGEventLeftMouseDragged), - RIGHT: (Quartz.kCGMouseButtonRight, Quartz.kCGEventRightMouseDown, Quartz.kCGEventRightMouseUp, Quartz.kCGEventRightMouseDragged), - MIDDLE: (Quartz.kCGMouseButtonCenter, Quartz.kCGEventOtherMouseDown, Quartz.kCGEventOtherMouseUp, Quartz.kCGEventOtherMouseDragged) -} -_button_state = { - LEFT: False, - RIGHT: False, - MIDDLE: False -} -_last_click = { - "time": None, - "button": None, - "position": None, - "click_count": 0 + LEFT: ( + Quartz.kCGMouseButtonLeft, + Quartz.kCGEventLeftMouseDown, + Quartz.kCGEventLeftMouseUp, + Quartz.kCGEventLeftMouseDragged, + ), + RIGHT: ( + Quartz.kCGMouseButtonRight, + Quartz.kCGEventRightMouseDown, + Quartz.kCGEventRightMouseUp, + Quartz.kCGEventRightMouseDragged, + ), + MIDDLE: ( + Quartz.kCGMouseButtonCenter, + Quartz.kCGEventOtherMouseDown, + Quartz.kCGEventOtherMouseUp, + Quartz.kCGEventOtherMouseDragged, + ), } +_button_state = {LEFT: False, RIGHT: False, MIDDLE: False} +_last_click = {"time": None, "button": None, "position": None, "click_count": 0} + class MouseEventListener: def __init__(self, callback, blocking=False): @@ -30,22 +37,23 @@ def __init__(self, callback, blocking=False): self.listening = True def run(self): - """ Creates a listener and loops while waiting for an event. Intended to run as - a background thread. """ + """Creates a listener and loops while waiting for an event. Intended to run as + a background thread.""" self.tap = Quartz.CGEventTapCreate( Quartz.kCGSessionEventTap, Quartz.kCGHeadInsertEventTap, Quartz.kCGEventTapOptionDefault, - Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) | - Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel), + Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) + | Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel), self.handler, - None) + None, + ) loopsource = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0) loop = Quartz.CFRunLoopGetCurrent() Quartz.CFRunLoopAddSource(loop, loopsource, Quartz.kCFRunLoopDefaultMode) @@ -60,7 +68,7 @@ def handler(self, proxy, e_type, event, refcon): key_name = name_from_scancode(scan_code) flags = Quartz.CGEventGetFlags(event) event_type = "" - is_keypad = (flags & Quartz.kCGEventFlagMaskNumericPad) + is_keypad = flags & Quartz.kCGEventFlagMaskNumericPad if e_type == Quartz.kCGEventKeyDown: event_type = "down" elif e_type == Quartz.kCGEventKeyUp: @@ -72,104 +80,107 @@ def handler(self, proxy, e_type, event, refcon): self.callback(KeyboardEvent(event_type, scan_code, name=key_name, is_keypad=is_keypad)) return event + # Exports + def init(): - """ Initializes mouse state """ + """Initializes mouse state""" pass + def listen(queue): - """ Appends events to the queue (ButtonEvent, WheelEvent, and MoveEvent). """ + """Appends events to the queue (ButtonEvent, WheelEvent, and MoveEvent).""" if not os.geteuid() == 0: raise OSError("Error 13 - Must be run as administrator") - listener = MouseEventListener(lambda e: queue.put(e) or is_allowed(e.name, e.event_type == KEY_UP)) + listener = MouseEventListener( + lambda e: queue.put(e) or is_allowed(e.name, e.event_type == KEY_UP) + ) t = threading.Thread(target=listener.run, args=()) t.daemon = True t.start() + def press(button=LEFT): - """ Sends a down event for the specified button, using the provided constants """ + """Sends a down event for the specified button, using the provided constants""" location = get_position() button_code, button_down, _, _ = _button_mapping[button] - e = Quartz.CGEventCreateMouseEvent( - None, - button_down, - location, - button_code) + e = Quartz.CGEventCreateMouseEvent(None, button_down, location, button_code) # Check if this is a double-click (same location within the last 300ms) - if _last_click["time"] is not None and datetime.datetime.now() - _last_click["time"] < datetime.timedelta(seconds=0.3) and _last_click["button"] == button and _last_click["position"] == location: + if ( + _last_click["time"] is not None + and datetime.datetime.now() - _last_click["time"] < datetime.timedelta(seconds=0.3) + and _last_click["button"] == button + and _last_click["position"] == location + ): # Repeated Click - _last_click["click_count"] = min(3, _last_click["click_count"]+1) + _last_click["click_count"] = min(3, _last_click["click_count"] + 1) else: # Not a double-click - Reset last click _last_click["click_count"] = 1 Quartz.CGEventSetIntegerValueField( - e, - Quartz.kCGMouseEventClickState, - _last_click["click_count"]) + e, Quartz.kCGMouseEventClickState, _last_click["click_count"] + ) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) _button_state[button] = True _last_click["time"] = datetime.datetime.now() _last_click["button"] = button _last_click["position"] = location + def release(button=LEFT): - """ Sends an up event for the specified button, using the provided constants """ + """Sends an up event for the specified button, using the provided constants""" location = get_position() button_code, _, button_up, _ = _button_mapping[button] - e = Quartz.CGEventCreateMouseEvent( - None, - button_up, - location, - button_code) - - if _last_click["time"] is not None and _last_click["time"] > datetime.datetime.now() - datetime.timedelta(microseconds=300000) and _last_click["button"] == button and _last_click["position"] == location: + e = Quartz.CGEventCreateMouseEvent(None, button_up, location, button_code) + + if ( + _last_click["time"] is not None + and _last_click["time"] > datetime.datetime.now() - datetime.timedelta(microseconds=300000) + and _last_click["button"] == button + and _last_click["position"] == location + ): # Repeated Click Quartz.CGEventSetIntegerValueField( - e, - Quartz.kCGMouseEventClickState, - _last_click["click_count"]) + e, Quartz.kCGMouseEventClickState, _last_click["click_count"] + ) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) _button_state[button] = False + def wheel(delta=1): - """ Sends a wheel event for the provided number of clicks. May be negative to reverse - direction. """ + """Sends a wheel event for the provided number of clicks. May be negative to reverse + direction.""" location = get_position() e = Quartz.CGEventCreateMouseEvent( - None, - Quartz.kCGEventScrollWheel, - location, - Quartz.kCGMouseButtonLeft) - e2 = Quartz.CGEventCreateScrollWheelEvent( - None, - Quartz.kCGScrollEventUnitLine, - 1, - delta) + None, Quartz.kCGEventScrollWheel, location, Quartz.kCGMouseButtonLeft + ) + e2 = Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, delta) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e2) + def move_to(x, y): - """ Sets the mouse's location to the specified coordinates. """ + """Sets the mouse's location to the specified coordinates.""" for b in _button_state: if _button_state[b]: e = Quartz.CGEventCreateMouseEvent( None, - _button_mapping[b][3], # Drag Event + _button_mapping[b][3], # Drag Event (x, y), - _button_mapping[b][0]) + _button_mapping[b][0], + ) break else: e = Quartz.CGEventCreateMouseEvent( - None, - Quartz.kCGEventMouseMoved, - (x, y), - Quartz.kCGMouseButtonLeft) + None, Quartz.kCGEventMouseMoved, (x, y), Quartz.kCGMouseButtonLeft + ) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) + def get_position(): - """ Returns the mouse's location as a tuple of (x, y). """ + """Returns the mouse's location as a tuple of (x, y).""" e = Quartz.CGEventCreate(None) point = Quartz.CGEventGetLocation(e) return (point.x, point.y) diff --git a/src/directkeys/_generic.py b/src/directkeys/_generic.py index 23d9f13..c2141d0 100644 --- a/src/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -6,6 +6,7 @@ except ImportError: from Queue import Queue + class GenericListener: lock = Lock() @@ -44,7 +45,7 @@ def start_if_necessary(self): self.lock.release() def pre_process_event(self, event): - raise NotImplementedError('This method should be implemented in the child class.') + raise NotImplementedError("This method should be implemented in the child class.") def process(self): """ @@ -66,6 +67,6 @@ def add_handler(self, handler): self.handlers.append(handler) def remove_handler(self, handler): - """ Removes a previously added event handler. """ + """Removes a previously added event handler.""" while handler in self.handlers: self.handlers.remove(handler) diff --git a/src/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py index e4f58f6..00f2d00 100644 --- a/src/directkeys/_keyboard_event.py +++ b/src/directkeys/_keyboard_event.py @@ -3,8 +3,9 @@ from ._canonical_names import normalize_name -KEY_DOWN = 'down' -KEY_UP = 'up' +KEY_DOWN = "down" +KEY_UP = "up" + class KeyboardEvent: event_type = None @@ -16,7 +17,17 @@ class KeyboardEvent: is_keypad = None flags = None - def __init__(self, event_type, scan_code, name=None, time=None, device=None, modifiers=None, is_keypad=None, flags=None): + def __init__( + self, + event_type, + scan_code, + name=None, + time=None, + device=None, + modifiers=None, + is_keypad=None, + flags=None, + ): self.event_type = event_type self.scan_code = scan_code self.time = now() if time is None else time @@ -29,21 +40,30 @@ def __init__(self, event_type, scan_code, name=None, time=None, device=None, mod def to_json(self, ensure_ascii=False): attrs = { - attr: getattr(self, attr) for attr in ['event_type', 'scan_code', 'name', 'time', 'device', 'is_keypad', 'modifiers', 'flags'] - if not attr.startswith('_') + attr: getattr(self, attr) + for attr in [ + "event_type", + "scan_code", + "name", + "time", + "device", + "is_keypad", + "modifiers", + "flags", + ] + if not attr.startswith("_") } return json.dumps(attrs, ensure_ascii=ensure_ascii) def __repr__(self): - return 'KeyboardEvent({} {})'.format(self.name or f'Unknown {self.scan_code}', self.event_type) + return "KeyboardEvent({} {})".format( + self.name or f"Unknown {self.scan_code}", self.event_type + ) def __eq__(self, other): return ( isinstance(other, KeyboardEvent) and self.event_type == other.event_type - and ( - not self.scan_code or not other.scan_code or self.scan_code == other.scan_code - ) and ( - not self.name or not other.name or self.name == other.name - ) + and (not self.scan_code or not other.scan_code or self.scan_code == other.scan_code) + and (not self.name or not other.name or self.name == other.name) ) diff --git a/src/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py index 8967d9d..da9ed5e 100644 --- a/src/directkeys/_mouse_event.py +++ b/src/directkeys/_mouse_event.py @@ -1,19 +1,19 @@ from collections import namedtuple -LEFT = 'left' -RIGHT = 'right' -MIDDLE = 'middle' -WHEEL = 'wheel' -X = 'x' -X2 = 'x2' +LEFT = "left" +RIGHT = "right" +MIDDLE = "middle" +WHEEL = "wheel" +X = "x" +X2 = "x2" -UP = 'up' -DOWN = 'down' -DOUBLE = 'double' -VERTICAL = 'vertical' -HORIZONTAL = 'horizontal' +UP = "up" +DOWN = "down" +DOUBLE = "double" +VERTICAL = "vertical" +HORIZONTAL = "horizontal" -ButtonEvent = namedtuple('ButtonEvent', ['event_type', 'button', 'time']) -WheelEvent = namedtuple('WheelEvent', ['delta', 'time']) -MoveEvent = namedtuple('MoveEvent', ['x', 'y', 'time']) +ButtonEvent = namedtuple("ButtonEvent", ["event_type", "button", "time"]) +WheelEvent = namedtuple("WheelEvent", ["delta", "time"]) +MoveEvent = namedtuple("MoveEvent", ["x", "y", "time"]) diff --git a/src/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py index c6aa4e2..ff9d18b 100644 --- a/src/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -10,7 +10,7 @@ except ImportError: from Queue import Queue -event_bin_format = 'llHHI' +event_bin_format = "llHHI" # Taken from include/linux/input.h # https://www.kernel.org/doc/Documentation/input/event-codes.txt @@ -20,15 +20,16 @@ EV_ABS = 0x03 EV_MSC = 0x04 + def make_uinput(): - if not os.path.exists('/dev/uinput'): - raise OSError('No uinput module found.') + if not os.path.exists("/dev/uinput"): + raise OSError("No uinput module found.") import fcntl import struct # Requires uinput driver, but it's usually available. - uinput = open("/dev/uinput", 'wb') + uinput = open("/dev/uinput", "wb") UI_SET_EVBIT = 0x40045564 fcntl.ioctl(uinput, UI_SET_EVBIT, EV_KEY) @@ -40,15 +41,16 @@ def make_uinput(): uinput_user_dev = "80sHHHHi64i64i64i64i" axis = [0] * 64 * 4 uinput.write(struct.pack(uinput_user_dev, b"Virtual Keyboard", BUS_USB, 1, 1, 1, 0, *axis)) - uinput.flush() # Without this you may get Errno 22: Invalid argument. + uinput.flush() # Without this you may get Errno 22: Invalid argument. UI_DEV_CREATE = 0x5501 fcntl.ioctl(uinput, UI_DEV_CREATE) UI_DEV_DESTROY = 0x5502 - #fcntl.ioctl(uinput, UI_DEV_DESTROY) + # fcntl.ioctl(uinput, UI_DEV_DESTROY) return uinput + class EventDevice: def __init__(self, path): self.path = path @@ -59,10 +61,12 @@ def __init__(self, path): def input_file(self): if self._input_file is None: try: - self._input_file = open(self.path, 'rb') + self._input_file = open(self.path, "rb") except OSError as e: - if e.strerror == 'Permission denied': - print(f"# ERROR: Failed to read device '{self.path}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group.") + if e.strerror == "Permission denied": + print( + f"# ERROR: Failed to read device '{self.path}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group." + ) exit() def try_close(): @@ -70,13 +74,14 @@ def try_close(): self._input_file.close except: pass + atexit.register(try_close) return self._input_file @property def output_file(self): if self._output_file is None: - self._output_file = open(self.path, 'wb') + self._output_file = open(self.path, "wb") atexit.register(self._output_file.close) return self._output_file @@ -97,14 +102,17 @@ def write_event(self, type, code, value): self.output_file.write(data_event + sync_event) self.output_file.flush() + class AggregatedEventDevice: def __init__(self, devices, output=None): self.event_queue = Queue() self.devices = devices self.output = output or self.devices[0] + def start_reading(device): while True: self.event_queue.put(device.read_event()) + for device in self.devices: thread = Thread(target=start_reading, args=[device]) thread.daemon = True @@ -116,28 +124,35 @@ def read_event(self): def write_event(self, type, code, value): self.output.write_event(type, code, value) + import re from collections import namedtuple -DeviceDescription = namedtuple('DeviceDescription', 'event_file is_mouse is_keyboard') +DeviceDescription = namedtuple("DeviceDescription", "event_file is_mouse is_keyboard") device_pattern = r"""N: Name="([^"]+?)".+?H: Handlers=([^\n]+)""" + + def list_devices_from_proc(type_name): try: - with open('/proc/bus/input/devices') as f: + with open("/proc/bus/input/devices") as f: description = f.read() except FileNotFoundError: return devices = {} for name, handlers in re.findall(device_pattern, description, re.DOTALL): - path = '/dev/input/event' + re.search(r'event(\d+)', handlers).group(1) + path = "/dev/input/event" + re.search(r"event(\d+)", handlers).group(1) if type_name in handlers: yield EventDevice(path) + def list_devices_from_by_id(name_suffix, by_id=True): - for path in glob('/dev/input/{}/*-event-{}'.format('by-id' if by_id else 'by-path', name_suffix)): + for path in glob( + "/dev/input/{}/*-event-{}".format("by-id" if by_id else "by-path", name_suffix) + ): yield EventDevice(path) + def aggregate_devices(type_name): # Some systems have multiple keyboards with different range of allowed keys # on each one, like a notebook with a "keyboard" device exclusive for the @@ -145,12 +160,16 @@ def aggregate_devices(type_name): # send events, we create a fake device and send all events through there. try: uinput = make_uinput() - fake_device = EventDevice('uinput Fake Device') + fake_device = EventDevice("uinput Fake Device") fake_device._input_file = uinput fake_device._output_file = uinput except OSError: import warnings - warnings.warn('Failed to create a device file using `uinput` module. Sending of events may be limited or unavailable depending on plugged-in devices.', stacklevel=2) + + warnings.warn( + "Failed to create a device file using `uinput` module. Sending of events may be limited or unavailable depending on plugged-in devices.", + stacklevel=2, + ) fake_device = None # We don't aggregate devices from different sources to avoid @@ -162,7 +181,9 @@ def aggregate_devices(type_name): # breaks on mouse for virtualbox # was getting /dev/input/by-id/usb-VirtualBox_USB_Tablet-event-mouse - devices_from_by_id = list(list_devices_from_by_id(type_name)) or list(list_devices_from_by_id(type_name, by_id=False)) + devices_from_by_id = list(list_devices_from_by_id(type_name)) or list( + list_devices_from_by_id(type_name, by_id=False) + ) if devices_from_by_id: return AggregatedEventDevice(devices_from_by_id, output=fake_device) diff --git a/src/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py index 7c8eb51..6bffce8 100644 --- a/src/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -6,35 +6,37 @@ # TODO: start by reading current keyboard state, as to not missing any already pressed keys. # See: http://stackoverflow.com/questions/3649874/how-to-get-keyboard-state-in-linux + def cleanup_key(name): - """ Formats a dumpkeys format to our standard. """ - name = name.lstrip('+') - is_keypad = name.startswith('KP_') - for mod in ('Meta_', 'Control_', 'dead_', 'KP_'): + """Formats a dumpkeys format to our standard.""" + name = name.lstrip("+") + is_keypad = name.startswith("KP_") + for mod in ("Meta_", "Control_", "dead_", "KP_"): if name.startswith(mod): - name = name[len(mod):] + name = name[len(mod) :] # Dumpkeys is weird like that. - if name == 'Remove': - name = 'Delete' - elif name == 'Delete': - name = 'Backspace' - - if name.endswith('_r'): - name = 'right ' + name[:-2] - if name.endswith('_l'): - name = 'left ' + name[:-2] + if name == "Remove": + name = "Delete" + elif name == "Delete": + name = "Backspace" + if name.endswith("_r"): + name = "right " + name[:-2] + if name.endswith("_l"): + name = "left " + name[:-2] return normalize_name(name), is_keypad + def cleanup_modifier(modifier): modifier = normalize_name(modifier) if modifier in all_modifiers: return modifier if modifier[:-1] in all_modifiers: return modifier[:-1] - raise ValueError(f'Unknown modifier {modifier}') + raise ValueError(f"Unknown modifier {modifier}") + """ Use `dumpkeys --keys-only` to list all scan codes and their names. We @@ -49,61 +51,67 @@ def cleanup_modifier(modifier): from_name = defaultdict(list) keypad_scan_codes = set() + def register_key(key_and_modifiers, name): if name not in to_name[key_and_modifiers]: to_name[key_and_modifiers].append(name) if key_and_modifiers not in from_name[name]: from_name[name].append(key_and_modifiers) + def build_tables(): - if to_name and from_name: return + if to_name and from_name: + return modifiers_bits = { - 'shift': 1, - 'alt gr': 2, - 'ctrl': 4, - 'alt': 8, + "shift": 1, + "alt gr": 2, + "ctrl": 4, + "alt": 8, } - keycode_template = r'^keycode\s+(\d+)\s+=(.*?)$' + keycode_template = r"^keycode\s+(\d+)\s+=(.*?)$" try: - dump = check_output(['dumpkeys', '--keys-only'], universal_newlines=True) + dump = check_output(["dumpkeys", "--keys-only"], universal_newlines=True) except CalledProcessError as e: if e.returncode == 1: - raise ValueError('Failed to run dumpkeys to get key names. Check if your user is part of the "tty" group, and if not, add it with "sudo usermod -a -G tty USER".') + raise ValueError( + 'Failed to run dumpkeys to get key names. Check if your user is part of the "tty" group, and if not, add it with "sudo usermod -a -G tty USER".' + ) else: raise - for str_scan_code, str_names in re.findall(keycode_template, dump, re.MULTILINE): scan_code = int(str_scan_code) for i, str_name in enumerate(str_names.strip().split()): - modifiers = tuple(sorted(modifier for modifier, bit in modifiers_bits.items() if i & bit)) + modifiers = tuple( + sorted(modifier for modifier, bit in modifiers_bits.items() if i & bit) + ) name, is_keypad = cleanup_key(str_name) register_key((scan_code, modifiers), name) if is_keypad: keypad_scan_codes.add(scan_code) - register_key((scan_code, modifiers), 'keypad ' + name) + register_key((scan_code, modifiers), "keypad " + name) # dumpkeys consistently misreports the Windows key, sometimes # skipping it completely or reporting as 'alt. 125 = left win, # 126 = right win. - if (125, ()) not in to_name or to_name[(125, ())] == ['alt']: + if (125, ()) not in to_name or to_name[(125, ())] == ["alt"]: to_name[(125, ())].clear() - if (125, ()) in from_name['alt']: - from_name['alt'].remove((125, ())) - register_key((125, ()), 'windows') - if (126, ()) not in to_name or to_name[(126, ())] == ['alt']: + if (125, ()) in from_name["alt"]: + from_name["alt"].remove((125, ())) + register_key((125, ()), "windows") + if (126, ()) not in to_name or to_name[(126, ())] == ["alt"]: to_name[(126, ())].clear() - if (126, ()) in from_name['alt']: - from_name['alt'].remove((126, ())) - register_key((126, ()), 'windows') + if (126, ()) in from_name["alt"]: + from_name["alt"].remove((126, ())) + register_key((126, ()), "windows") # The menu key is usually skipped altogether, so we also add it manually. if (127, ()) not in to_name: - register_key((127, ()), 'menu') + register_key((127, ()), "menu") - synonyms_template = r'^(\S+)\s+for (.+)$' - dump = check_output(['dumpkeys', '--long-info'], universal_newlines=True) + synonyms_template = r"^(\S+)\s+for (.+)$" + dump = check_output(["dumpkeys", "--long-info"], universal_newlines=True) for synonym_str, original_str in re.findall(synonyms_template, dump, re.MULTILINE): synonym, _ = cleanup_key(synonym_str) original, _ = cleanup_key(original_str) @@ -111,18 +119,25 @@ def build_tables(): from_name[original].extend(from_name[synonym]) from_name[synonym].extend(from_name[original]) + device = None + + def build_device(): global device - if device: return - device = aggregate_devices('kbd') + if device: + return + device = aggregate_devices("kbd") + def init(): build_device() build_tables() + pressed_modifiers = set() + def listen(callback): build_device() build_tables() @@ -133,10 +148,12 @@ def listen(callback): continue scan_code = code - event_type = KEY_DOWN if value else KEY_UP # 0 = UP, 1 = DOWN, 2 = HOLD + event_type = KEY_DOWN if value else KEY_UP # 0 = UP, 1 = DOWN, 2 = HOLD pressed_modifiers_tuple = tuple(sorted(pressed_modifiers)) - names = to_name[(scan_code, pressed_modifiers_tuple)] or to_name[(scan_code, ())] or ['unknown'] + names = ( + to_name[(scan_code, pressed_modifiers_tuple)] or to_name[(scan_code, ())] or ["unknown"] + ) name = names[0] if name in all_modifiers: @@ -146,33 +163,48 @@ def listen(callback): pressed_modifiers.discard(name) is_keypad = scan_code in keypad_scan_codes - callback(KeyboardEvent(event_type=event_type, scan_code=scan_code, name=name, time=time, device=device_id, is_keypad=is_keypad, modifiers=pressed_modifiers_tuple)) + callback( + KeyboardEvent( + event_type=event_type, + scan_code=scan_code, + name=name, + time=time, + device=device_id, + is_keypad=is_keypad, + modifiers=pressed_modifiers_tuple, + ) + ) + def write_event(scan_code, is_down): build_device() device.write_event(EV_KEY, scan_code, int(is_down)) + def map_name(name): build_tables() for entry in from_name[name]: yield entry - parts = name.split(' ', 1) - if len(parts) > 1 and parts[0] in ('left', 'right'): + parts = name.split(" ", 1) + if len(parts) > 1 and parts[0] in ("left", "right"): for entry in from_name[parts[1]]: yield entry + def press(scan_code): write_event(scan_code, True) + def release(scan_code): write_event(scan_code, False) + def type_unicode(character): codepoint = ord(character) - hexadecimal = hex(codepoint)[len('0x'):] + hexadecimal = hex(codepoint)[len("0x") :] - for key in ['ctrl', 'shift', 'u']: + for key in ["ctrl", "shift", "u"]: scan_code, _ = next(map_name(key)) press(scan_code) @@ -181,11 +213,14 @@ def type_unicode(character): press(scan_code) release(scan_code) - for key in ['ctrl', 'shift', 'u']: + for key in ["ctrl", "shift", "u"]: scan_code, _ = next(map_name(key)) release(scan_code) -if __name__ == '__main__': + +if __name__ == "__main__": + def p(e): print(e) + listen(p) diff --git a/src/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py index dc5453e..1898bad 100644 --- a/src/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -9,10 +9,13 @@ display = None window = None x11 = None + + def build_display(): global display, window, x11 - if display and window and x11: return - x11 = ctypes.cdll.LoadLibrary(ctypes.util.find_library('X11')) + if display and window and x11: + return + x11 = ctypes.cdll.LoadLibrary(ctypes.util.find_library("X11")) # Required because we will have multiple threads calling x11, # such as the listener thread and then main using "move_to". x11.XInitThreads() @@ -21,21 +24,32 @@ def build_display(): # http://stackoverflow.com/questions/35137007/get-mouse-position-on-linux-pure-python window = x11.XDefaultRootWindow(display) + def get_position(): build_display() root_id, child_id = c_uint32(), c_uint32() root_x, root_y, win_x, win_y = c_int(), c_int(), c_int(), c_int() mask = c_uint() - ret = x11.XQueryPointer(display, c_uint32(window), byref(root_id), byref(child_id), - byref(root_x), byref(root_y), - byref(win_x), byref(win_y), byref(mask)) + ret = x11.XQueryPointer( + display, + c_uint32(window), + byref(root_id), + byref(child_id), + byref(root_x), + byref(root_y), + byref(win_x), + byref(win_y), + byref(mask), + ) return root_x.value, root_y.value + def move_to(x, y): build_display() x11.XWarpPointer(display, None, window, 0, 0, 0, 0, x, y) x11.XFlush(display) + REL_X = 0x00 REL_Y = 0x01 REL_Z = 0x02 @@ -62,12 +76,18 @@ def move_to(x, y): code_by_button = {button: code for code, button in button_by_code.items()} device = None + + def build_device(): global device - if device: return - device = aggregate_devices('mouse') + if device: + return + device = aggregate_devices("mouse") + + init = build_device + def listen(queue): build_device() @@ -80,9 +100,9 @@ def listen(queue): arg = None if type == EV_KEY: - event = ButtonEvent(DOWN if value else UP, button_by_code.get(code, '?'), time) + event = ButtonEvent(DOWN if value else UP, button_by_code.get(code, "?"), time) elif type == EV_REL: - value, = struct.unpack('i', struct.pack('I', value)) + (value,) = struct.unpack("i", struct.pack("I", value)) if code == REL_WHEEL: event = WheelEvent(value, time) @@ -96,14 +116,17 @@ def listen(queue): queue.put(event) + def press(button=LEFT): build_device() device.write_event(EV_KEY, code_by_button[button], 0x01) + def release(button=LEFT): build_device() device.write_event(EV_KEY, code_by_button[button], 0x00) + def move_relative(x, y): build_device() # Note relative events are not in terms of pixels, but millimeters. @@ -114,6 +137,7 @@ def move_relative(x, y): device.write_event(EV_REL, REL_X, x) device.write_event(EV_REL, REL_Y, y) + def wheel(delta=1): build_device() if delta < 0: @@ -121,6 +145,6 @@ def wheel(delta=1): device.write_event(EV_REL, REL_WHEEL, delta) -if __name__ == '__main__': - #listen(print) +if __name__ == "__main__": + # listen(print) move_to(100, 200) diff --git a/src/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py index eb97e68..ab6b6c3 100644 --- a/src/directkeys/_winkeyboard.py +++ b/src/directkeys/_winkeyboard.py @@ -9,6 +9,7 @@ - Keypad numbers still print as numbers even when numlock is off. - No way to specify if user wants a keypad key or not in `map_char`. """ + import atexit import time import traceback @@ -55,14 +56,14 @@ LPMSG = POINTER(MSG) ULONG_PTR = POINTER(DWORD) -kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) GetModuleHandleW = kernel32.GetModuleHandleW GetModuleHandleW.restype = HMODULE GetModuleHandleW.argtypes = [LPCWSTR] -#https://github.com/boppreh/mouse/issues/1 -#user32 = ctypes.windll.user32 -user32 = ctypes.WinDLL('user32', use_last_error = True) +# https://github.com/boppreh/mouse/issues/1 +# user32 = ctypes.windll.user32 +user32 = ctypes.WinDLL("user32", use_last_error=True) VK_PACKET = 0xE7 @@ -73,51 +74,59 @@ KEYEVENTF_KEYUP = 0x02 KEYEVENTF_UNICODE = 0x04 + class KBDLLHOOKSTRUCT(Structure): - _fields_ = [("vk_code", DWORD), - ("scan_code", DWORD), - ("flags", DWORD), - ("time", c_int), - ("dwExtraInfo", ULONG_PTR)] + _fields_ = [ + ("vk_code", DWORD), + ("scan_code", DWORD), + ("flags", DWORD), + ("time", c_int), + ("dwExtraInfo", ULONG_PTR), + ] + # Included for completeness. class MOUSEINPUT(ctypes.Structure): - _fields_ = (('dx', LONG), - ('dy', LONG), - ('mouseData', DWORD), - ('dwFlags', DWORD), - ('time', DWORD), - ('dwExtraInfo', ULONG_PTR)) + _fields_ = ( + ("dx", LONG), + ("dy", LONG), + ("mouseData", DWORD), + ("dwFlags", DWORD), + ("time", DWORD), + ("dwExtraInfo", ULONG_PTR), + ) + class KEYBDINPUT(ctypes.Structure): - _fields_ = (('wVk', WORD), - ('wScan', WORD), - ('dwFlags', DWORD), - ('time', DWORD), - ('dwExtraInfo', ULONG_PTR)) + _fields_ = ( + ("wVk", WORD), + ("wScan", WORD), + ("dwFlags", DWORD), + ("time", DWORD), + ("dwExtraInfo", ULONG_PTR), + ) + class HARDWAREINPUT(ctypes.Structure): - _fields_ = (('uMsg', DWORD), - ('wParamL', WORD), - ('wParamH', WORD)) + _fields_ = (("uMsg", DWORD), ("wParamL", WORD), ("wParamH", WORD)) + class _INPUTunion(ctypes.Union): - _fields_ = (('mi', MOUSEINPUT), - ('ki', KEYBDINPUT), - ('hi', HARDWAREINPUT)) + _fields_ = (("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)) + class INPUT(ctypes.Structure): - _fields_ = (('type', DWORD), - ('union', _INPUTunion)) + _fields_ = (("type", DWORD), ("union", _INPUTunion)) + LowLevelKeyboardProc = WINFUNCTYPE(c_int, WPARAM, LPARAM, POINTER(KBDLLHOOKSTRUCT)) SetWindowsHookEx = user32.SetWindowsHookExW -SetWindowsHookEx.argtypes = [c_int, LowLevelKeyboardProc, HINSTANCE , DWORD] +SetWindowsHookEx.argtypes = [c_int, LowLevelKeyboardProc, HINSTANCE, DWORD] SetWindowsHookEx.restype = HHOOK CallNextHookEx = user32.CallNextHookEx -#CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(KBDLLHOOKSTRUCT)] +# CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(KBDLLHOOKSTRUCT)] CallNextHookEx.restype = c_int UnhookWindowsHookEx = user32.UnhookWindowsHookEx @@ -177,7 +186,7 @@ class INPUT(ctypes.Structure): WM_KEYDOWN = 0x0100 WM_KEYUP = 0x0101 -WM_SYSKEYDOWN = 0x104 # Used for ALT key +WM_SYSKEYDOWN = 0x104 # Used for ALT key WM_SYSKEYUP = 0x105 @@ -193,165 +202,165 @@ class INPUT(ctypes.Structure): # List taken from the official documentation, but stripped of the OEM-specific keys. # Keys are virtual key codes, values are pairs (name, is_keypad). official_virtual_keys = { - 0x03: ('control-break processing', False), - 0x08: ('backspace', False), - 0x09: ('tab', False), - 0x0c: ('clear', False), - 0x0d: ('enter', False), - 0x10: ('shift', False), - 0x11: ('ctrl', False), - 0x12: ('alt', False), - 0x13: ('pause', False), - 0x14: ('caps lock', False), - 0x15: ('ime kana mode', False), - 0x15: ('ime hanguel mode', False), - 0x15: ('ime hangul mode', False), - 0x17: ('ime junja mode', False), - 0x18: ('ime final mode', False), - 0x19: ('ime hanja mode', False), - 0x19: ('ime kanji mode', False), - 0x1b: ('esc', False), - 0x1c: ('ime convert', False), - 0x1d: ('ime nonconvert', False), - 0x1e: ('ime accept', False), - 0x1f: ('ime mode change request', False), - 0x20: ('spacebar', False), - 0x21: ('page up', False), - 0x22: ('page down', False), - 0x23: ('end', False), - 0x24: ('home', False), - 0x25: ('left', False), - 0x26: ('up', False), - 0x27: ('right', False), - 0x28: ('down', False), - 0x29: ('select', False), - 0x2a: ('print', False), - 0x2b: ('execute', False), - 0x2c: ('print screen', False), - 0x2d: ('insert', False), - 0x2e: ('delete', False), - 0x2f: ('help', False), - 0x30: ('0', False), - 0x31: ('1', False), - 0x32: ('2', False), - 0x33: ('3', False), - 0x34: ('4', False), - 0x35: ('5', False), - 0x36: ('6', False), - 0x37: ('7', False), - 0x38: ('8', False), - 0x39: ('9', False), - 0x41: ('a', False), - 0x42: ('b', False), - 0x43: ('c', False), - 0x44: ('d', False), - 0x45: ('e', False), - 0x46: ('f', False), - 0x47: ('g', False), - 0x48: ('h', False), - 0x49: ('i', False), - 0x4a: ('j', False), - 0x4b: ('k', False), - 0x4c: ('l', False), - 0x4d: ('m', False), - 0x4e: ('n', False), - 0x4f: ('o', False), - 0x50: ('p', False), - 0x51: ('q', False), - 0x52: ('r', False), - 0x53: ('s', False), - 0x54: ('t', False), - 0x55: ('u', False), - 0x56: ('v', False), - 0x57: ('w', False), - 0x58: ('x', False), - 0x59: ('y', False), - 0x5a: ('z', False), - 0x5b: ('left windows', False), - 0x5c: ('right windows', False), - 0x5d: ('applications', False), - 0x5f: ('sleep', False), - 0x60: ('0', True), - 0x61: ('1', True), - 0x62: ('2', True), - 0x63: ('3', True), - 0x64: ('4', True), - 0x65: ('5', True), - 0x66: ('6', True), - 0x67: ('7', True), - 0x68: ('8', True), - 0x69: ('9', True), - 0x6a: ('*', True), - 0x6b: ('+', True), - 0x6c: ('separator', True), - 0x6d: ('-', True), - 0x6e: ('decimal', True), - 0x6f: ('/', True), - 0x70: ('f1', False), - 0x71: ('f2', False), - 0x72: ('f3', False), - 0x73: ('f4', False), - 0x74: ('f5', False), - 0x75: ('f6', False), - 0x76: ('f7', False), - 0x77: ('f8', False), - 0x78: ('f9', False), - 0x79: ('f10', False), - 0x7a: ('f11', False), - 0x7b: ('f12', False), - 0x7c: ('f13', False), - 0x7d: ('f14', False), - 0x7e: ('f15', False), - 0x7f: ('f16', False), - 0x80: ('f17', False), - 0x81: ('f18', False), - 0x82: ('f19', False), - 0x83: ('f20', False), - 0x84: ('f21', False), - 0x85: ('f22', False), - 0x86: ('f23', False), - 0x87: ('f24', False), - 0x90: ('num lock', False), - 0x91: ('scroll lock', False), - 0xa0: ('left shift', False), - 0xa1: ('right shift', False), - 0xa2: ('left ctrl', False), - 0xa3: ('right ctrl', False), - 0xa4: ('left menu', False), - 0xa5: ('right menu', False), - 0xa6: ('browser back', False), - 0xa7: ('browser forward', False), - 0xa8: ('browser refresh', False), - 0xa9: ('browser stop', False), - 0xaa: ('browser search key', False), - 0xab: ('browser favorites', False), - 0xac: ('browser start and home', False), - 0xad: ('volume mute', False), - 0xae: ('volume down', False), - 0xaf: ('volume up', False), - 0xb0: ('next track', False), - 0xb1: ('previous track', False), - 0xb2: ('stop media', False), - 0xb3: ('play/pause media', False), - 0xb4: ('start mail', False), - 0xb5: ('select media', False), - 0xb6: ('start application 1', False), - 0xb7: ('start application 2', False), - 0xbb: ('+', False), - 0xbc: (',', False), - 0xbd: ('-', False), - 0xbe: ('.', False), - #0xbe:('/', False), # Used for miscellaneous characters; it can vary by directkeys. For the US standard keyboard, the '/?. - 0xe5: ('ime process', False), - 0xf6: ('attn', False), - 0xf7: ('crsel', False), - 0xf8: ('exsel', False), - 0xf9: ('erase eof', False), - 0xfa: ('play', False), - 0xfb: ('zoom', False), - 0xfc: ('reserved ', False), - 0xfd: ('pa1', False), - 0xfe: ('clear', False), + 0x03: ("control-break processing", False), + 0x08: ("backspace", False), + 0x09: ("tab", False), + 0x0C: ("clear", False), + 0x0D: ("enter", False), + 0x10: ("shift", False), + 0x11: ("ctrl", False), + 0x12: ("alt", False), + 0x13: ("pause", False), + 0x14: ("caps lock", False), + 0x15: ("ime kana mode", False), + 0x15: ("ime hanguel mode", False), + 0x15: ("ime hangul mode", False), + 0x17: ("ime junja mode", False), + 0x18: ("ime final mode", False), + 0x19: ("ime hanja mode", False), + 0x19: ("ime kanji mode", False), + 0x1B: ("esc", False), + 0x1C: ("ime convert", False), + 0x1D: ("ime nonconvert", False), + 0x1E: ("ime accept", False), + 0x1F: ("ime mode change request", False), + 0x20: ("spacebar", False), + 0x21: ("page up", False), + 0x22: ("page down", False), + 0x23: ("end", False), + 0x24: ("home", False), + 0x25: ("left", False), + 0x26: ("up", False), + 0x27: ("right", False), + 0x28: ("down", False), + 0x29: ("select", False), + 0x2A: ("print", False), + 0x2B: ("execute", False), + 0x2C: ("print screen", False), + 0x2D: ("insert", False), + 0x2E: ("delete", False), + 0x2F: ("help", False), + 0x30: ("0", False), + 0x31: ("1", False), + 0x32: ("2", False), + 0x33: ("3", False), + 0x34: ("4", False), + 0x35: ("5", False), + 0x36: ("6", False), + 0x37: ("7", False), + 0x38: ("8", False), + 0x39: ("9", False), + 0x41: ("a", False), + 0x42: ("b", False), + 0x43: ("c", False), + 0x44: ("d", False), + 0x45: ("e", False), + 0x46: ("f", False), + 0x47: ("g", False), + 0x48: ("h", False), + 0x49: ("i", False), + 0x4A: ("j", False), + 0x4B: ("k", False), + 0x4C: ("l", False), + 0x4D: ("m", False), + 0x4E: ("n", False), + 0x4F: ("o", False), + 0x50: ("p", False), + 0x51: ("q", False), + 0x52: ("r", False), + 0x53: ("s", False), + 0x54: ("t", False), + 0x55: ("u", False), + 0x56: ("v", False), + 0x57: ("w", False), + 0x58: ("x", False), + 0x59: ("y", False), + 0x5A: ("z", False), + 0x5B: ("left windows", False), + 0x5C: ("right windows", False), + 0x5D: ("applications", False), + 0x5F: ("sleep", False), + 0x60: ("0", True), + 0x61: ("1", True), + 0x62: ("2", True), + 0x63: ("3", True), + 0x64: ("4", True), + 0x65: ("5", True), + 0x66: ("6", True), + 0x67: ("7", True), + 0x68: ("8", True), + 0x69: ("9", True), + 0x6A: ("*", True), + 0x6B: ("+", True), + 0x6C: ("separator", True), + 0x6D: ("-", True), + 0x6E: ("decimal", True), + 0x6F: ("/", True), + 0x70: ("f1", False), + 0x71: ("f2", False), + 0x72: ("f3", False), + 0x73: ("f4", False), + 0x74: ("f5", False), + 0x75: ("f6", False), + 0x76: ("f7", False), + 0x77: ("f8", False), + 0x78: ("f9", False), + 0x79: ("f10", False), + 0x7A: ("f11", False), + 0x7B: ("f12", False), + 0x7C: ("f13", False), + 0x7D: ("f14", False), + 0x7E: ("f15", False), + 0x7F: ("f16", False), + 0x80: ("f17", False), + 0x81: ("f18", False), + 0x82: ("f19", False), + 0x83: ("f20", False), + 0x84: ("f21", False), + 0x85: ("f22", False), + 0x86: ("f23", False), + 0x87: ("f24", False), + 0x90: ("num lock", False), + 0x91: ("scroll lock", False), + 0xA0: ("left shift", False), + 0xA1: ("right shift", False), + 0xA2: ("left ctrl", False), + 0xA3: ("right ctrl", False), + 0xA4: ("left menu", False), + 0xA5: ("right menu", False), + 0xA6: ("browser back", False), + 0xA7: ("browser forward", False), + 0xA8: ("browser refresh", False), + 0xA9: ("browser stop", False), + 0xAA: ("browser search key", False), + 0xAB: ("browser favorites", False), + 0xAC: ("browser start and home", False), + 0xAD: ("volume mute", False), + 0xAE: ("volume down", False), + 0xAF: ("volume up", False), + 0xB0: ("next track", False), + 0xB1: ("previous track", False), + 0xB2: ("stop media", False), + 0xB3: ("play/pause media", False), + 0xB4: ("start mail", False), + 0xB5: ("select media", False), + 0xB6: ("start application 1", False), + 0xB7: ("start application 2", False), + 0xBB: ("+", False), + 0xBC: (",", False), + 0xBD: ("-", False), + 0xBE: (".", False), + # 0xbe:('/', False), # Used for miscellaneous characters; it can vary by directkeys. For the US standard keyboard, the '/?. + 0xE5: ("ime process", False), + 0xF6: ("attn", False), + 0xF7: ("crsel", False), + 0xF8: ("exsel", False), + 0xF9: ("erase eof", False), + 0xFA: ("play", False), + 0xFB: ("zoom", False), + 0xFC: ("reserved ", False), + 0xFD: ("pa1", False), + 0xFE: ("clear", False), } tables_lock = Lock() @@ -361,30 +370,32 @@ class INPUT(ctypes.Structure): distinct_modifiers = [ (), - ('shift',), - ('alt gr',), - ('num lock',), - ('shift', 'num lock'), - ('caps lock',), - ('shift', 'caps lock'), - ('alt gr', 'num lock'), + ("shift",), + ("alt gr",), + ("num lock",), + ("shift", "num lock"), + ("caps lock",), + ("shift", "caps lock"), + ("alt gr", "num lock"), ] name_buffer = ctypes.create_unicode_buffer(32) unicode_buffer = ctypes.create_unicode_buffer(32) keyboard_state = keyboard_state_type() + + def get_event_names(scan_code, vk, is_extended, modifiers): is_keypad = (scan_code, vk, is_extended) in keypad_keys is_official = vk in official_virtual_keys if is_keypad and is_official: yield official_virtual_keys[vk][0] - keyboard_state[0x10] = 0x80 * ('shift' in modifiers) - keyboard_state[0x11] = 0x80 * ('alt gr' in modifiers) - keyboard_state[0x12] = 0x80 * ('alt gr' in modifiers) - keyboard_state[0x14] = 0x01 * ('caps lock' in modifiers) - keyboard_state[0x90] = 0x01 * ('num lock' in modifiers) - keyboard_state[0x91] = 0x01 * ('scroll lock' in modifiers) + keyboard_state[0x10] = 0x80 * ("shift" in modifiers) + keyboard_state[0x11] = 0x80 * ("alt gr" in modifiers) + keyboard_state[0x12] = 0x80 * ("alt gr" in modifiers) + keyboard_state[0x14] = 0x01 * ("caps lock" in modifiers) + keyboard_state[0x90] = 0x01 * ("num lock" in modifiers) + keyboard_state[0x91] = 0x01 * ("scroll lock" in modifiers) unicode_ret = ToUnicode(vk, scan_code, keyboard_state, unicode_buffer, len(unicode_buffer), 0) if unicode_ret and unicode_buffer.value: yield unicode_buffer.value @@ -405,18 +416,22 @@ def get_event_names(scan_code, vk, is_extended, modifiers): if not is_keypad and is_official: yield official_virtual_keys[vk][0] + def _setup_name_tables(): """ Ensures the scan code/virtual key code/name translation tables are filled. """ with tables_lock: - if to_name: return + if to_name: + return # Go through every possible scan code, and map them to virtual key codes. # Then vice-versa. - all_scan_codes = [(sc, user32.MapVirtualKeyExW(sc, MAPVK_VSC_TO_VK_EX, 0)) for sc in range(0x100)] - all_vks = [(user32.MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, 0), vk) for vk in range(0x100)] + all_scan_codes = [ + (sc, user32.MapVirtualKeyExW(sc, MAPVK_VSC_TO_VK_EX, 0)) for sc in range(0x100) + ] + all_vks = [(user32.MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, 0), vk) for vk in range(0x100)] for scan_code, vk in all_scan_codes + all_vks: # `to_name` and `from_name` entries will be a tuple (scan_code, vk, extended, shift_state). if (scan_code, vk, 0, 0, 0) in to_name: @@ -440,7 +455,6 @@ def _setup_name_tables(): for i, name in enumerate(map(normalize_name, names + lowercase_names)): from_name[name].append((i, entry)) - # TODO: single quotes on US INTL is returning the dead key (?), and therefore # not typing properly. @@ -450,18 +464,21 @@ def _setup_name_tables(): # Windows is consistent in its inconsistency. for extended in [0, 1]: for modifiers in distinct_modifiers: - to_name[(541, 162, extended, modifiers)] = ['alt gr'] - from_name['alt gr'].append((1, (541, 162, extended, modifiers))) + to_name[(541, 162, extended, modifiers)] = ["alt gr"] + from_name["alt gr"].append((1, (541, 162, extended, modifiers))) modifiers_preference = defaultdict(lambda: 10) - modifiers_preference.update({(): 0, ('shift',): 1, ('alt gr',): 2, ('ctrl',): 3, ('alt',): 4}) + modifiers_preference.update({(): 0, ("shift",): 1, ("alt gr",): 2, ("ctrl",): 3, ("alt",): 4}) + def order_key(line): i, entry = line scan_code, vk, extended, modifiers = entry return modifiers_preference[modifiers], i, extended, vk, scan_code + for name, entries in list(from_name.items()): from_name[name] = sorted(set(entries), key=order_key) + def get_modifiers(altgr_is_pressed): """ Returns a tuple with the names of the currently active modifiers. @@ -469,13 +486,14 @@ def get_modifiers(altgr_is_pressed): # GetKeyState reports the "pressed" bit as 0x8000, not 1, so every flag has # to be coerced to a bool before being used to repeat the tuple. return ( - ('shift',) * bool(user32.GetKeyState(0x10) & 0x8000) + - ('alt gr',) * bool(altgr_is_pressed) + - ('num lock',) * bool(user32.GetKeyState(0x90) & 1) + - ('caps lock',) * bool(user32.GetKeyState(0x14) & 1) + - ('scroll lock',) * bool(user32.GetKeyState(0x91) & 1) + ("shift",) * bool(user32.GetKeyState(0x10) & 0x8000) + + ("alt gr",) * bool(altgr_is_pressed) + + ("num lock",) * bool(user32.GetKeyState(0x90) & 1) + + ("caps lock",) * bool(user32.GetKeyState(0x14) & 1) + + ("scroll lock",) * bool(user32.GetKeyState(0x91) & 1) ) + def get_name(scan_code, vk, is_extended, modifiers): """ Returns the most likely name for a key event, given the active modifiers. @@ -488,6 +506,7 @@ def get_name(scan_code, vk, is_extended, modifiers): names = to_name[entry] return names[0] if names else None + # Called by directkeys/__init__.py init = _setup_name_tables @@ -533,6 +552,8 @@ def get_name(scan_code, vk, is_extended, modifiers): ] altgr_is_pressed = False + + def prepare_intercept(callback): """ Registers a Windows low level keyboard hook. The provided callback will @@ -558,33 +579,47 @@ def process_key(event_type, vk, scan_code, is_extended, flags): # With the abstraction OFF, drop the synthetic Ctrl entirely. if not directkeys._ABSTRACT_ALT_GR and scan_code == 541: - return True # Suppress the event. + return True # Suppress the event. # With the abstraction ON, merge the pair into a single 'alt gr' event. if directkeys._ABSTRACT_ALT_GR: global _altgr_right_alt_scan_code, _altgr_right_alt_flags if _altgr_right_alt_scan_code is not None and event_type == KEY_DOWN: - if scan_code == 541: # The synthetic Ctrl. + if scan_code == 541: # The synthetic Ctrl. altgr_is_pressed = True - event = KeyboardEvent('down', _altgr_right_alt_scan_code, name='alt gr', is_keypad=False, flags=_altgr_right_alt_flags) + event = KeyboardEvent( + "down", + _altgr_right_alt_scan_code, + name="alt gr", + is_keypad=False, + flags=_altgr_right_alt_flags, + ) callback(event) _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None - return True # Suppress the synthetic Ctrl. - else: # It was not, so flush the pending Right Alt. - event = KeyboardEvent('down', _altgr_right_alt_scan_code, name='right alt', is_keypad=False, flags=_altgr_right_alt_flags) + return True # Suppress the synthetic Ctrl. + else: # It was not, so flush the pending Right Alt. + event = KeyboardEvent( + "down", + _altgr_right_alt_scan_code, + name="right alt", + is_keypad=False, + flags=_altgr_right_alt_flags, + ) callback(event) _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None - if vk == 165: # Right Alt. - if event_type == KEY_DOWN: # Hold it back and wait for the Ctrl. + if vk == 165: # Right Alt. + if event_type == KEY_DOWN: # Hold it back and wait for the Ctrl. _altgr_right_alt_scan_code = scan_code _altgr_right_alt_flags = flags - return True # Suppress the Right Alt for now. - else: # Released, so complete the 'alt gr' event. + return True # Suppress the Right Alt for now. + else: # Released, so complete the 'alt gr' event. altgr_is_pressed = False - event = KeyboardEvent('up', scan_code, name='alt gr', is_keypad=False, flags=flags) + event = KeyboardEvent( + "up", scan_code, name="alt gr", is_keypad=False, flags=flags + ) callback(event) return True @@ -596,7 +631,7 @@ def process_key(event_type, vk, scan_code, is_extended, flags): modifiers = get_modifiers(altgr_is_pressed) if not directkeys._ABSTRACT_ALT_GR and vk == 165: - name = 'alt gr' + name = "alt gr" else: name = get_name(scan_code, vk, is_extended, modifiers) @@ -606,36 +641,44 @@ def process_key(event_type, vk, scan_code, is_extended, flags): is_keypad = (scan_code, vk, is_extended) in keypad_keys # Keys with no scan code (injected events, sending by virtual key) fall # back to the negated vk so they stay distinguishable from each other. - event = KeyboardEvent(event_type=event_type, scan_code=scan_code or -vk, name=name, is_keypad=is_keypad, flags=flags) + event = KeyboardEvent( + event_type=event_type, + scan_code=scan_code or -vk, + name=name, + is_keypad=is_keypad, + flags=flags, + ) return callback(event) def low_level_keyboard_handler(nCode, wParam, lParam): try: vk = lParam.contents.vk_code # Ignore the second `alt` DOWN observed in some cases. - fake_alt = (LLKHF_INJECTED | 0x20) + fake_alt = LLKHF_INJECTED | 0x20 # Ignore events generated by SendInput with Unicode. if vk != VK_PACKET and lParam.contents.flags & fake_alt != fake_alt: event_type = KEY_UP if wParam & 0x01 else KEY_DOWN raw_flags = lParam.contents.flags - processed_flags = raw_flags & 1 # Isolate the LLKHF_EXTENDED bit. + processed_flags = raw_flags & 1 # Isolate the LLKHF_EXTENDED bit. is_extended = processed_flags scan_code = lParam.contents.scan_code - should_continue = process_key(event_type, vk, scan_code, is_extended, processed_flags) + should_continue = process_key( + event_type, vk, scan_code, is_extended, processed_flags + ) if not should_continue: return -1 except Exception: - print('Error in keyboard hook:') + print("Error in keyboard hook:") traceback.print_exc() return CallNextHookEx(None, nCode, wParam, lParam) WH_KEYBOARD_LL = c_int(13) keyboard_callback = LowLevelKeyboardProc(low_level_keyboard_handler) - handle = GetModuleHandleW(None) + handle = GetModuleHandleW(None) thread_id = DWORD(0) keyboard_hook = SetWindowsHookEx(WH_KEYBOARD_LL, keyboard_callback, handle, thread_id) @@ -643,13 +686,15 @@ def low_level_keyboard_handler(nCode, wParam, lParam): # try/finally block doesn't seem to work here. atexit.register(UnhookWindowsHookEx, keyboard_callback) + def _clear_name_tables(): - """ Empties the name tables so they can be rebuilt. """ + """Empties the name tables so they can be rebuilt.""" with tables_lock: to_name.clear() from_name.clear() scan_code_to_vk.clear() + def rebuild_name_tables(): """ Forces the name tables to be cleared and rebuilt. Called by __init__.py @@ -658,6 +703,7 @@ def rebuild_name_tables(): _clear_name_tables() _setup_name_tables() + def listen(callback): prepare_intercept(callback) msg = LPMSG() @@ -665,16 +711,18 @@ def listen(callback): TranslateMessage(msg) DispatchMessage(msg) + def map_name(name): _setup_name_tables() entries = from_name.get(name) if not entries: - raise ValueError(f'Key name {name!r} is not mapped to any known key.') + raise ValueError(f"Key name {name!r} is not mapped to any known key.") for i, entry in entries: scan_code, vk, _is_extended, modifiers = entry yield scan_code or -vk, modifiers + def _send_event(code, event_type): if code == 541: # Alt-gr is made of ctrl+alt. Just sending even 541 doesn't do anything. @@ -688,23 +736,28 @@ def _send_event(code, event_type): # and the value actually contains the Virtual key code. user32.keybd_event(-code, 0, event_type, 0) + def press(code): _send_event(code, 0) + def release(code): _send_event(code, 2) + def type_unicode(character): # This code and related structures are based on # http://stackoverflow.com/a/11910555/252218 - surrogates = bytearray(character.encode('utf-16le')) + surrogates = bytearray(character.encode("utf-16le")) presses = [] releases = [] for i in range(0, len(surrogates), 2): - higher, lower = surrogates[i:i+2] + higher, lower = surrogates[i : i + 2] structure = KEYBDINPUT(0, (lower << 8) + higher, KEYEVENTF_UNICODE, 0, None) presses.append(INPUT(INPUT_KEYBOARD, _INPUTunion(ki=structure))) - structure = KEYBDINPUT(0, (lower << 8) + higher, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, 0, None) + structure = KEYBDINPUT( + 0, (lower << 8) + higher, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, 0, None + ) releases.append(INPUT(INPUT_KEYBOARD, _INPUTunion(ki=structure))) inputs = presses + releases nInputs = len(inputs) @@ -713,15 +766,24 @@ def type_unicode(character): cbSize = c_int(ctypes.sizeof(INPUT)) SendInput(nInputs, pInputs, cbSize) + # Virtual key codes of the modifiers that can be left stuck by a program that # pressed them and exited without releasing. _modifier_vk_names = { - 0x10: 'shift', 0xA0: 'left shift', 0xA1: 'right shift', - 0x11: 'ctrl', 0xA2: 'left ctrl', 0xA3: 'right ctrl', - 0x12: 'alt', 0xA4: 'left alt', 0xA5: 'right alt', - 0x5B: 'left windows', 0x5C: 'right windows', + 0x10: "shift", + 0xA0: "left shift", + 0xA1: "right shift", + 0x11: "ctrl", + 0xA2: "left ctrl", + 0xA3: "right ctrl", + 0x12: "alt", + 0xA4: "left alt", + 0xA5: "right alt", + 0x5B: "left windows", + 0x5C: "right windows", } + def force_reset_keyboard(): """ Forces Windows to release any modifier key that was left stuck down. @@ -738,6 +800,7 @@ def force_reset_keyboard(): # Give the OS a moment to process the pair. time.sleep(0.01) + def _reset_internal_state(): """ Clears the internal state used by the AltGr handling, so that a fresh run @@ -748,6 +811,7 @@ def _reset_internal_state(): _altgr_right_alt_flags = None altgr_is_pressed = False + def get_stuck_keys(): """ Returns the names of the modifier keys that are currently held down. @@ -762,9 +826,11 @@ def get_stuck_keys(): return stuck_keys -if __name__ == '__main__': + +if __name__ == "__main__": _setup_name_tables() import pprint + pprint.pprint(to_name) pprint.pprint(from_name) - #listen(lambda e: print(e.to_json()) or True) + # listen(lambda e: print(e.to_json()) or True) diff --git a/src/directkeys/_winmouse.py b/src/directkeys/_winmouse.py index 3f2ebf7..41b963a 100644 --- a/src/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -32,27 +32,30 @@ X, ) -#https://github.com/boppreh/mouse/issues/1 -#user32 = ctypes.windll.user32 -user32 = ctypes.WinDLL('user32', use_last_error = True) +# https://github.com/boppreh/mouse/issues/1 +# user32 = ctypes.windll.user32 +user32 = ctypes.WinDLL("user32", use_last_error=True) + class MSLLHOOKSTRUCT(Structure): - _fields_ = [("x", c_long), - ("y", c_long), - ('data', c_int32), - ('reserved', c_int32), - ("flags", DWORD), - ("time", c_int), - ] + _fields_ = [ + ("x", c_long), + ("y", c_long), + ("data", c_int32), + ("reserved", c_int32), + ("flags", DWORD), + ("time", c_int), + ] + LowLevelMouseProc = CFUNCTYPE(c_int, WPARAM, LPARAM, POINTER(MSLLHOOKSTRUCT)) SetWindowsHookEx = user32.SetWindowsHookExA -#SetWindowsHookEx.argtypes = [c_int, LowLevelMouseProc, c_int, c_int] +# SetWindowsHookEx.argtypes = [c_int, LowLevelMouseProc, c_int, c_int] SetWindowsHookEx.restype = HHOOK CallNextHookEx = user32.CallNextHookEx -#CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(MSLLHOOKSTRUCT)] +# CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(MSLLHOOKSTRUCT)] CallNextHookEx.restype = c_int UnhookWindowsHookEx = user32.UnhookWindowsHookEx @@ -102,15 +105,12 @@ class MSLLHOOKSTRUCT(Structure): WM_LBUTTONDOWN: (DOWN, LEFT), WM_LBUTTONUP: (UP, LEFT), WM_LBUTTONDBLCLK: (DOUBLE, LEFT), - WM_RBUTTONDOWN: (DOWN, RIGHT), WM_RBUTTONUP: (UP, RIGHT), WM_RBUTTONDBLCLK: (DOUBLE, RIGHT), - WM_MBUTTONDOWN: (DOWN, MIDDLE), WM_MBUTTONUP: (UP, MIDDLE), WM_MBUTTONDBLCLK: (DOUBLE, MIDDLE), - WM_XBUTTONDOWN: (DOWN, X), WM_XBUTTONUP: (UP, X), WM_XBUTTONDBLCLK: (DOUBLE, X), @@ -132,16 +132,12 @@ class MSLLHOOKSTRUCT(Structure): simulated_mouse_codes = { (WHEEL, HORIZONTAL): MOUSEEVENTF_HWHEEL, (WHEEL, VERTICAL): MOUSEEVENTF_WHEEL, - (DOWN, LEFT): MOUSEEVENTF_LEFTDOWN, (UP, LEFT): MOUSEEVENTF_LEFTUP, - (DOWN, RIGHT): MOUSEEVENTF_RIGHTDOWN, (UP, RIGHT): MOUSEEVENTF_RIGHTUP, - (DOWN, MIDDLE): MOUSEEVENTF_MIDDLEDOWN, (UP, MIDDLE): MOUSEEVENTF_MIDDLEUP, - (DOWN, X): MOUSEEVENTF_XDOWN, (UP, X): MOUSEEVENTF_XUP, } @@ -152,6 +148,7 @@ class MSLLHOOKSTRUCT(Structure): init = lambda: None + def listen(queue): def low_level_mouse_handler(nCode, wParam, lParam): struct = lParam.contents @@ -161,9 +158,9 @@ def low_level_mouse_handler(nCode, wParam, lParam): if wParam == WM_MOUSEMOVE: event = MoveEvent(struct.x, struct.y, t) elif wParam == WM_MOUSEWHEEL: - event = WheelEvent(struct.data / (WHEEL_DELTA * (2<<15)), t) + event = WheelEvent(struct.data / (WHEEL_DELTA * (2 << 15)), t) elif wParam in buttons_by_wm_code: - type, button = buttons_by_wm_code.get(wParam, ('?', '?')) + type, button = buttons_by_wm_code.get(wParam, ("?", "?")) if wParam >= WM_XBUTTONDOWN: button = {0x10000: X, 0x20000: X2}[struct.data] event = ButtonEvent(type, button, t) @@ -184,41 +181,52 @@ def low_level_mouse_handler(nCode, wParam, lParam): TranslateMessage(msg) DispatchMessage(msg) + def _translate_button(button): if button == X or button == X2: return X, {X: 0x10000, X2: 0x20000}[button] else: return button, 0 + def press(button=LEFT): button, data = _translate_button(button) code = simulated_mouse_codes[(DOWN, button)] user32.mouse_event(code, 0, 0, data, 0) + def release(button=LEFT): button, data = _translate_button(button) code = simulated_mouse_codes[(UP, button)] user32.mouse_event(code, 0, 0, data, 0) + def wheel(delta=1): code = simulated_mouse_codes[(WHEEL, VERTICAL)] user32.mouse_event(code, 0, 0, int(delta * WHEEL_DELTA), 0) + def move_to(x, y): user32.SetCursorPos(int(x), int(y)) + def move_relative(x, y): user32.mouse_event(MOUSEEVENTF_MOVE, int(x), int(y), 0, 0) + class POINT(Structure): _fields_ = [("x", c_long), ("y", c_long)] + def get_position(): point = POINT() user32.GetCursorPos(byref(point)) return (point.x, point.y) -if __name__ == '__main__': + +if __name__ == "__main__": + def p(e): print(e) + listen(p) diff --git a/src/directkeys/mouse.py b/src/directkeys/mouse.py index 291d469..75f8818 100644 --- a/src/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,16 +1,20 @@ import warnings -warnings.simplefilter('always', DeprecationWarning) -warnings.warn('The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.', DeprecationWarning, stacklevel=2) +warnings.simplefilter("always", DeprecationWarning) +warnings.warn( + "The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.", + DeprecationWarning, + stacklevel=2, +) import platform as _platform import time as _time -if _platform.system() == 'Windows': +if _platform.system() == "Windows": from . import _winmouse as _os_mouse -elif _platform.system() == 'Linux': +elif _platform.system() == "Linux": from . import _nixmouse as _os_mouse -elif _platform.system() == 'Darwin': +elif _platform.system() == "Darwin": from . import _darwinmouse as _os_mouse else: raise OSError(f"Unsupported platform '{_platform.system()}'") @@ -31,9 +35,12 @@ ) _pressed_events = set() + + class _MouseListener(_GenericListener): def init(self): _os_mouse.init() + def pre_process_event(self, event): if isinstance(event, ButtonEvent): if event.event_type in (UP, DOUBLE): @@ -45,39 +52,48 @@ def pre_process_event(self, event): def listen(self): _os_mouse.listen(self.queue) + _listener = _MouseListener() + def is_pressed(button=LEFT): - """ Returns True if the given button is currently pressed. """ + """Returns True if the given button is currently pressed.""" _listener.start_if_necessary() return button in _pressed_events + def press(button=LEFT): - """ Presses the given button (but doesn't release). """ + """Presses the given button (but doesn't release).""" _os_mouse.press(button) + def release(button=LEFT): - """ Releases the given button. """ + """Releases the given button.""" _os_mouse.release(button) + def click(button=LEFT): - """ Sends a click with the given button. """ + """Sends a click with the given button.""" _os_mouse.press(button) _os_mouse.release(button) + def double_click(button=LEFT): - """ Sends a double click with the given button. """ + """Sends a double click with the given button.""" click(button) click(button) + def right_click(): - """ Sends a right click with the given button. """ + """Sends a right click with the given button.""" click(RIGHT) + def wheel(delta=1): - """ Scrolls the wheel `delta` clicks. Sign indicates direction. """ + """Scrolls the wheel `delta` clicks. Sign indicates direction.""" _os_mouse.wheel(delta) + def move(x, y, absolute=True, duration=0): """ Moves the mouse. If `absolute`, to position (x, y), otherwise move relative @@ -106,12 +122,13 @@ def move(x, y, absolute=True, duration=0): # 120 movements per second. # Round and keep float to ensure float division in Python 2 steps = max(1.0, float(int(duration * 120.0))) - for i in range(int(steps)+1): - move(start_x + dx*i/steps, start_y + dy*i/steps) - _time.sleep(duration/steps) + for i in range(int(steps) + 1): + move(start_x + dx * i / steps, start_y + dy * i / steps) + _time.sleep(duration / steps) else: _os_mouse.move_to(x, y) + def drag(start_x, start_y, end_x, end_y, absolute=True, duration=0): """ Holds the left mouse button, moving from start to end position, then @@ -125,8 +142,9 @@ def drag(start_x, start_y, end_x, end_y, absolute=True, duration=0): move(end_x, end_y, absolute, duration) release() + def on_button(callback, args=(), buttons=(LEFT, MIDDLE, RIGHT, X, X2), types=(UP, DOWN, DOUBLE)): - """ Invokes `callback` with `args` when the specified event happens. """ + """Invokes `callback` with `args` when the specified event happens.""" if not isinstance(buttons, (tuple, list)): buttons = (buttons,) if not isinstance(types, (tuple, list)): @@ -136,42 +154,51 @@ def handler(event): if isinstance(event, ButtonEvent): if event.event_type in types and event.button in buttons: callback(*args) + _listener.add_handler(handler) return handler + def on_click(callback, args=()): - """ Invokes `callback` with `args` when the left button is clicked. """ + """Invokes `callback` with `args` when the left button is clicked.""" return on_button(callback, args, [LEFT], [UP]) + def on_double_click(callback, args=()): """ Invokes `callback` with `args` when the left button is double clicked. """ return on_button(callback, args, [LEFT], [DOUBLE]) + def on_right_click(callback, args=()): - """ Invokes `callback` with `args` when the right button is clicked. """ + """Invokes `callback` with `args` when the right button is clicked.""" return on_button(callback, args, [RIGHT], [UP]) + def on_middle_click(callback, args=()): - """ Invokes `callback` with `args` when the middle button is clicked. """ + """Invokes `callback` with `args` when the middle button is clicked.""" return on_button(callback, args, [MIDDLE], [UP]) + def wait(button=LEFT, target_types=(UP, DOWN, DOUBLE)): """ Blocks program execution until the given button performs an event. """ from threading import Lock + lock = Lock() lock.acquire() handler = on_button(lock.release, (), [button], target_types) lock.acquire() _listener.remove_handler(handler) + def get_position(): - """ Returns the (x, y) mouse position. """ + """Returns the (x, y) mouse position.""" return _os_mouse.get_position() + def hook(callback): """ Installs a global listener on all available mouses, invoking `callback` @@ -184,12 +211,14 @@ def hook(callback): _listener.add_handler(callback) return callback + def unhook(callback): """ Removes a previously installed hook. """ _listener.remove_handler(callback) + def unhook_all(): """ Removes all hooks registered by this application. Note this may include @@ -197,6 +226,7 @@ def unhook_all(): """ del _listener.handlers[:] + def record(button=RIGHT, target_types=(DOWN,)): """ Records all mouse events until the user presses the given button. @@ -211,6 +241,7 @@ def record(button=RIGHT, target_types=(DOWN,)): unhook(recorded.append) return recorded + def play(events, speed_factor=1.0, include_clicks=True, include_moves=True, include_wheel=True): """ Plays a sequence of recorded events, maintaining the relative time @@ -236,9 +267,10 @@ def play(events, speed_factor=1.0, include_clicks=True, include_moves=True, incl elif isinstance(event, WheelEvent) and include_wheel: _os_mouse.wheel(event.delta) + replay = play hold = press -if __name__ == '__main__': - print('Recording... Double click to stop and replay.') +if __name__ == "__main__": + print("Recording... Double click to stop and replay.") play(record()) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 897ccda..ccc4e9a 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -19,91 +19,97 @@ from directkeys._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent dummy_keys = { - 'space': [(0, [])], - - 'a': [(1, [])], - 'b': [(2, [])], - 'c': [(3, [])], - 'A': [(1, ['shift']), (-1, [])], - 'B': [(2, ['shift']), (-2, [])], - 'C': [(3, ['shift']), (-3, [])], - - 'alt': [(4, [])], - 'left alt': [(4, [])], - - 'left shift': [(5, [])], - 'right shift': [(6, [])], - - 'left ctrl': [(7, [])], - - 'backspace': [(8, [])], - 'caps lock': [(9, [])], - - '+': [(10, [])], - ',': [(11, [])], - '_': [(12, [])], - - 'none': [], - 'duplicated': [(20, []), (20, [])], + "space": [(0, [])], + "a": [(1, [])], + "b": [(2, [])], + "c": [(3, [])], + "A": [(1, ["shift"]), (-1, [])], + "B": [(2, ["shift"]), (-2, [])], + "C": [(3, ["shift"]), (-3, [])], + "alt": [(4, [])], + "left alt": [(4, [])], + "left shift": [(5, [])], + "right shift": [(6, [])], + "left ctrl": [(7, [])], + "backspace": [(8, [])], + "caps lock": [(9, [])], + "+": [(10, [])], + ",": [(11, [])], + "_": [(12, [])], + "none": [], + "duplicated": [(20, []), (20, [])], } + def make_event(event_type, name, scan_code=None, time=0): - return KeyboardEvent(event_type=event_type, scan_code=scan_code or dummy_keys[name][0][0], name=name, time=time) + return KeyboardEvent( + event_type=event_type, scan_code=scan_code or dummy_keys[name][0][0], name=name, time=time + ) + # Used when manually pumping events. input_events = [] output_events = [] + def send_instant_event(event): if directkeys._listener.direct_callback(event): output_events.append(event) + # Mock out side effects. directkeys._os_keyboard.init = lambda: None directkeys._os_keyboard.listen = lambda callback: None directkeys._os_keyboard.map_name = dummy_keys.__getitem__ -directkeys._os_keyboard.press = lambda scan_code: send_instant_event(make_event(KEY_DOWN, None, scan_code)) -directkeys._os_keyboard.release = lambda scan_code: send_instant_event(make_event(KEY_UP, None, scan_code)) -directkeys._os_keyboard.type_unicode = lambda char: output_events.append(KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name=char)) +directkeys._os_keyboard.press = lambda scan_code: send_instant_event( + make_event(KEY_DOWN, None, scan_code) +) +directkeys._os_keyboard.release = lambda scan_code: send_instant_event( + make_event(KEY_UP, None, scan_code) +) +directkeys._os_keyboard.type_unicode = lambda char: output_events.append( + KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name=char) +) # Shortcuts for defining test inputs and expected outputs. # Usage: d_shift + d_a + u_a + u_shift -d_a = [make_event(KEY_DOWN, 'a')] -u_a = [make_event(KEY_UP, 'a')] -du_a = d_a+u_a -d_b = [make_event(KEY_DOWN, 'b')] -u_b = [make_event(KEY_UP, 'b')] -du_b = d_b+u_b -d_c = [make_event(KEY_DOWN, 'c')] -u_c = [make_event(KEY_UP, 'c')] -du_c = d_c+u_c -d_ctrl = [make_event(KEY_DOWN, 'left ctrl')] -u_ctrl = [make_event(KEY_UP, 'left ctrl')] -du_ctrl = d_ctrl+u_ctrl -d_shift = [make_event(KEY_DOWN, 'left shift')] -u_shift = [make_event(KEY_UP, 'left shift')] -du_shift = d_shift+u_shift -d_alt = [make_event(KEY_DOWN, 'alt')] -u_alt = [make_event(KEY_UP, 'alt')] -du_alt = d_alt+u_alt -du_backspace = [make_event(KEY_DOWN, 'backspace'), make_event(KEY_UP, 'backspace')] -du_capslock = [make_event(KEY_DOWN, 'caps lock'), make_event(KEY_UP, 'caps lock')] -d_space = [make_event(KEY_DOWN, 'space')] -u_space = [make_event(KEY_UP, 'space')] -du_space = [make_event(KEY_DOWN, 'space'), make_event(KEY_UP, 'space')] +d_a = [make_event(KEY_DOWN, "a")] +u_a = [make_event(KEY_UP, "a")] +du_a = d_a + u_a +d_b = [make_event(KEY_DOWN, "b")] +u_b = [make_event(KEY_UP, "b")] +du_b = d_b + u_b +d_c = [make_event(KEY_DOWN, "c")] +u_c = [make_event(KEY_UP, "c")] +du_c = d_c + u_c +d_ctrl = [make_event(KEY_DOWN, "left ctrl")] +u_ctrl = [make_event(KEY_UP, "left ctrl")] +du_ctrl = d_ctrl + u_ctrl +d_shift = [make_event(KEY_DOWN, "left shift")] +u_shift = [make_event(KEY_UP, "left shift")] +du_shift = d_shift + u_shift +d_alt = [make_event(KEY_DOWN, "alt")] +u_alt = [make_event(KEY_UP, "alt")] +du_alt = d_alt + u_alt +du_backspace = [make_event(KEY_DOWN, "backspace"), make_event(KEY_UP, "backspace")] +du_capslock = [make_event(KEY_DOWN, "caps lock"), make_event(KEY_UP, "caps lock")] +d_space = [make_event(KEY_DOWN, "space")] +u_space = [make_event(KEY_UP, "space")] +du_space = [make_event(KEY_DOWN, "space"), make_event(KEY_UP, "space")] trigger = lambda e=None: directkeys.press(999) triggered_event = [KeyboardEvent(KEY_DOWN, scan_code=999)] + class TestKeyboard(unittest.TestCase): def tearDown(self): directkeys.unhook_all() - #self.assertEquals(directkeys._hooks, {}) - #self.assertEquals(directkeys._hotkeys, {}) + # self.assertEquals(directkeys._hooks, {}) + # self.assertEquals(directkeys._hotkeys, {}) def setUp(self): - #directkeys._hooks.clear() - #directkeys._hotkeys.clear() + # directkeys._hooks.clear() + # directkeys._hotkeys.clear() del input_events[:] del output_events[:] directkeys._recording = None @@ -121,154 +127,216 @@ def do(self, manual_events, expected=None): if directkeys._listener.direct_callback(event): output_events.append(event) if expected is not None: - to_names = lambda es: '+'.join(('d' if e.event_type == KEY_DOWN else 'u') + '_' + str(e.scan_code) for e in es) + to_names = lambda es: "+".join( + ("d" if e.event_type == KEY_DOWN else "u") + "_" + str(e.scan_code) for e in es + ) self.assertEqual(to_names(output_events), to_names(expected)) del output_events[:] directkeys._listener.queue.join() def test_event_json(self): - event = make_event(KEY_DOWN, 'á \'"', 999) + event = make_event(KEY_DOWN, "á '\"", 999) import json + self.assertEqual(event, KeyboardEvent(**json.loads(event.to_json()))) def test_is_modifier_name(self): for name in directkeys.all_modifiers: self.assertTrue(directkeys.is_modifier(name)) + def test_is_modifier_scan_code(self): for i in range(10): self.assertEqual(directkeys.is_modifier(i), i in [4, 5, 6, 7]) def test_key_to_scan_codes_brute(self): for name, entries in dummy_keys.items(): - if name in ['none', 'duplicated']: continue + if name in ["none", "duplicated"]: + continue expected = tuple(scan_code for scan_code, modifiers in entries) self.assertEqual(directkeys.key_to_scan_codes(name), expected) + def test_key_to_scan_code_from_scan_code(self): for i in range(10): self.assertEqual(directkeys.key_to_scan_codes(i), (i,)) + def test_key_to_scan_code_from_letter(self): - self.assertEqual(directkeys.key_to_scan_codes('a'), (1,)) - self.assertEqual(directkeys.key_to_scan_codes('A'), (1,-1)) + self.assertEqual(directkeys.key_to_scan_codes("a"), (1,)) + self.assertEqual(directkeys.key_to_scan_codes("A"), (1, -1)) + def test_key_to_scan_code_from_normalized(self): - self.assertEqual(directkeys.key_to_scan_codes('shift'), (5,6)) - self.assertEqual(directkeys.key_to_scan_codes('SHIFT'), (5,6)) - self.assertEqual(directkeys.key_to_scan_codes('ctrl'), directkeys.key_to_scan_codes('CONTROL')) + self.assertEqual(directkeys.key_to_scan_codes("shift"), (5, 6)) + self.assertEqual(directkeys.key_to_scan_codes("SHIFT"), (5, 6)) + self.assertEqual( + directkeys.key_to_scan_codes("ctrl"), directkeys.key_to_scan_codes("CONTROL") + ) + def test_key_to_scan_code_from_sided_modifier(self): - self.assertEqual(directkeys.key_to_scan_codes('left shift'), (5,)) - self.assertEqual(directkeys.key_to_scan_codes('right shift'), (6,)) + self.assertEqual(directkeys.key_to_scan_codes("left shift"), (5,)) + self.assertEqual(directkeys.key_to_scan_codes("right shift"), (6,)) + def test_key_to_scan_code_underscores(self): - self.assertEqual(directkeys.key_to_scan_codes('_'), (12,)) - self.assertEqual(directkeys.key_to_scan_codes('right_shift'), (6,)) + self.assertEqual(directkeys.key_to_scan_codes("_"), (12,)) + self.assertEqual(directkeys.key_to_scan_codes("right_shift"), (6,)) + def test_key_to_scan_code_error_none(self): with self.assertRaises(ValueError): directkeys.key_to_scan_codes(None) + def test_key_to_scan_code_error_empty(self): with self.assertRaises(ValueError): - directkeys.key_to_scan_codes('') + directkeys.key_to_scan_codes("") + def test_key_to_scan_code_error_other(self): with self.assertRaises(ValueError): directkeys.key_to_scan_codes({}) + def test_key_to_scan_code_list(self): - self.assertEqual(directkeys.key_to_scan_codes([10, 5, 'a']), (10, 5, 1)) + self.assertEqual(directkeys.key_to_scan_codes([10, 5, "a"]), (10, 5, 1)) + def test_key_to_scan_code_empty(self): with self.assertRaises(ValueError): - directkeys.key_to_scan_codes('none') + directkeys.key_to_scan_codes("none") + def test_key_to_scan_code_duplicated(self): - self.assertEqual(directkeys.key_to_scan_codes('duplicated'), (20,)) + self.assertEqual(directkeys.key_to_scan_codes("duplicated"), (20,)) def test_parse_hotkey_simple(self): - self.assertEqual(directkeys.parse_hotkey('a'), (((1,),),)) - self.assertEqual(directkeys.parse_hotkey('A'), (((1,-1),),)) + self.assertEqual(directkeys.parse_hotkey("a"), (((1,),),)) + self.assertEqual(directkeys.parse_hotkey("A"), (((1, -1),),)) + def test_parse_hotkey_separators(self): - self.assertEqual(directkeys.parse_hotkey('+'), directkeys.parse_hotkey('plus')) - self.assertEqual(directkeys.parse_hotkey(','), directkeys.parse_hotkey('comma')) + self.assertEqual(directkeys.parse_hotkey("+"), directkeys.parse_hotkey("plus")) + self.assertEqual(directkeys.parse_hotkey(","), directkeys.parse_hotkey("comma")) + def test_parse_hotkey_keys(self): - self.assertEqual(directkeys.parse_hotkey('left shift + a'), (((5,), (1,),),)) - self.assertEqual(directkeys.parse_hotkey('left shift+a'), (((5,), (1,),),)) + self.assertEqual( + directkeys.parse_hotkey("left shift + a"), + ( + ( + (5,), + (1,), + ), + ), + ) + self.assertEqual( + directkeys.parse_hotkey("left shift+a"), + ( + ( + (5,), + (1,), + ), + ), + ) + def test_parse_hotkey_simple_steps(self): - self.assertEqual(directkeys.parse_hotkey('a,b'), (((1,),),((2,),))) - self.assertEqual(directkeys.parse_hotkey('a, b'), (((1,),),((2,),))) + self.assertEqual(directkeys.parse_hotkey("a,b"), (((1,),), ((2,),))) + self.assertEqual(directkeys.parse_hotkey("a, b"), (((1,),), ((2,),))) + def test_parse_hotkey_steps(self): - self.assertEqual(directkeys.parse_hotkey('a+b, b+c'), (((1,),(2,)),((2,),(3,)))) + self.assertEqual(directkeys.parse_hotkey("a+b, b+c"), (((1,), (2,)), ((2,), (3,)))) + def test_parse_hotkey_example(self): - alt_codes = directkeys.key_to_scan_codes('alt') - shift_codes = directkeys.key_to_scan_codes('shift') - a_codes = directkeys.key_to_scan_codes('a') - b_codes = directkeys.key_to_scan_codes('b') - c_codes = directkeys.key_to_scan_codes('c') - self.assertEqual(directkeys.parse_hotkey("alt+shift+a, alt+b, c"), ((alt_codes, shift_codes, a_codes), (alt_codes, b_codes), (c_codes,))) + alt_codes = directkeys.key_to_scan_codes("alt") + shift_codes = directkeys.key_to_scan_codes("shift") + a_codes = directkeys.key_to_scan_codes("a") + b_codes = directkeys.key_to_scan_codes("b") + c_codes = directkeys.key_to_scan_codes("c") + self.assertEqual( + directkeys.parse_hotkey("alt+shift+a, alt+b, c"), + ((alt_codes, shift_codes, a_codes), (alt_codes, b_codes), (c_codes,)), + ) + def test_parse_hotkey_list_scan_codes(self): self.assertEqual(directkeys.parse_hotkey([1, 2, 3]), (((1,), (2,), (3,)),)) + def test_parse_hotkey_deep_list_scan_codes(self): - result = directkeys.parse_hotkey('a') + result = directkeys.parse_hotkey("a") self.assertEqual(directkeys.parse_hotkey(result), (((1,),),)) + def test_parse_hotkey_list_names(self): - self.assertEqual(directkeys.parse_hotkey(['a', 'b', 'c']), (((1,), (2,), (3,)),)) + self.assertEqual(directkeys.parse_hotkey(["a", "b", "c"]), (((1,), (2,), (3,)),)) def test_is_pressed_none(self): - self.assertFalse(directkeys.is_pressed('a')) + self.assertFalse(directkeys.is_pressed("a")) + def test_is_pressed_true(self): self.do(d_a) - self.assertTrue(directkeys.is_pressed('a')) + self.assertTrue(directkeys.is_pressed("a")) + def test_is_pressed_true_scan_code_true(self): self.do(d_a) self.assertTrue(directkeys.is_pressed(1)) + def test_is_pressed_true_scan_code_false(self): self.do(d_a) self.assertFalse(directkeys.is_pressed(2)) + def test_is_pressed_true_scan_code_invalid(self): self.do(d_a) self.assertFalse(directkeys.is_pressed(-1)) + def test_is_pressed_false(self): - self.do(d_a+u_a+d_b) - self.assertFalse(directkeys.is_pressed('a')) - self.assertTrue(directkeys.is_pressed('b')) + self.do(d_a + u_a + d_b) + self.assertFalse(directkeys.is_pressed("a")) + self.assertTrue(directkeys.is_pressed("b")) + def test_is_pressed_hotkey_true(self): - self.do(d_shift+d_a) - self.assertTrue(directkeys.is_pressed('shift+a')) + self.do(d_shift + d_a) + self.assertTrue(directkeys.is_pressed("shift+a")) + def test_is_pressed_hotkey_false(self): - self.do(d_shift+d_a+u_a) - self.assertFalse(directkeys.is_pressed('shift+a')) + self.do(d_shift + d_a + u_a) + self.assertFalse(directkeys.is_pressed("shift+a")) + def test_is_pressed_multi_step_fail(self): - self.do(u_a+d_a) + self.do(u_a + d_a) with self.assertRaises(ValueError): - directkeys.is_pressed('a, b') + directkeys.is_pressed("a, b") def test_send_single_press_release(self): - directkeys.send('a', do_press=True, do_release=True) - self.do([], d_a+u_a) + directkeys.send("a", do_press=True, do_release=True) + self.do([], d_a + u_a) + def test_send_single_press(self): - directkeys.send('a', do_press=True, do_release=False) + directkeys.send("a", do_press=True, do_release=False) self.do([], d_a) + def test_send_single_release(self): - directkeys.send('a', do_press=False, do_release=True) + directkeys.send("a", do_press=False, do_release=True) self.do([], u_a) + def test_send_single_none(self): - directkeys.send('a', do_press=False, do_release=False) + directkeys.send("a", do_press=False, do_release=False) self.do([], []) + def test_press(self): - directkeys.press('a') + directkeys.press("a") self.do([], d_a) + def test_release(self): - directkeys.release('a') + directkeys.release("a") self.do([], u_a) + def test_press_and_release(self): - directkeys.press_and_release('a') - self.do([], d_a+u_a) + directkeys.press_and_release("a") + self.do([], d_a + u_a) def test_send_modifier_press_release(self): - directkeys.send('ctrl+a', do_press=True, do_release=True) - self.do([], d_ctrl+d_a+u_a+u_ctrl) + directkeys.send("ctrl+a", do_press=True, do_release=True) + self.do([], d_ctrl + d_a + u_a + u_ctrl) + def test_send_modifiers_release(self): - directkeys.send('ctrl+shift+a', do_press=False, do_release=True) - self.do([], u_a+u_shift+u_ctrl) + directkeys.send("ctrl+shift+a", do_press=False, do_release=True) + self.do([], u_a + u_shift + u_ctrl) def test_call_later(self): triggered = [] + def fn(arg1, arg2): assert arg1 == 1 and arg2 == 2 triggered.append(True) + directkeys.call_later(fn, (1, 2), 0.01) self.assertFalse(triggered) time.sleep(0.05) @@ -276,245 +344,325 @@ def fn(arg1, arg2): def test_hook_nonblocking(self): self.i = 0 + def count(e): - self.assertEqual(e.name, 'a') + self.assertEqual(e.name, "a") self.i += 1 + hook = directkeys.hook(count, suppress=False) - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 2) directkeys.unhook(hook) - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 2) directkeys.hook(count, suppress=False) - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 4) directkeys.unhook_all() - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 4) + def test_hook_blocking(self): self.i = 0 + def count(e): - self.assertIn(e.name, ['a', 'b']) + self.assertIn(e.name, ["a", "b"]) self.i += 1 - return e.name == 'b' + return e.name == "b" + hook = directkeys.hook(count, suppress=True) - self.do(d_a+d_b, d_b) + self.do(d_a + d_b, d_b) self.assertEqual(self.i, 2) directkeys.unhook(hook) - self.do(d_a+d_b, d_a+d_b) + self.do(d_a + d_b, d_a + d_b) self.assertEqual(self.i, 2) directkeys.hook(count, suppress=True) - self.do(d_a+d_b, d_b) + self.do(d_a + d_b, d_b) self.assertEqual(self.i, 4) directkeys.unhook_all() - self.do(d_a+d_b, d_a+d_b) + self.do(d_a + d_b, d_a + d_b) self.assertEqual(self.i, 4) + def test_on_press_nonblocking(self): - directkeys.on_press(lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_DOWN)) - self.do(d_a+u_a) + directkeys.on_press( + lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_DOWN) + ) + self.do(d_a + u_a) + def test_on_press_blocking(self): directkeys.on_press(lambda e: e.scan_code == 1, suppress=True) - self.do([make_event(KEY_DOWN, 'A', -1)] + d_a, d_a) + self.do([make_event(KEY_DOWN, "A", -1)] + d_a, d_a) + def test_on_release(self): - directkeys.on_release(lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_UP)) - self.do(d_a+u_a) + directkeys.on_release( + lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_UP) + ) + self.do(d_a + u_a) def test_hook_key_invalid(self): with self.assertRaises(ValueError): - directkeys.hook_key('invalid', lambda e: None) + directkeys.hook_key("invalid", lambda e: None) + def test_hook_key_nonblocking(self): self.i = 0 + def count(event): self.i += 1 - hook = directkeys.hook_key('A', count) + + hook = directkeys.hook_key("A", count) self.do(d_a) self.assertEqual(self.i, 1) - self.do(u_a+d_b) + self.do(u_a + d_b) self.assertEqual(self.i, 2) - self.do([make_event(KEY_DOWN, 'A', -1)]) + self.do([make_event(KEY_DOWN, "A", -1)]) self.assertEqual(self.i, 3) directkeys.unhook_key(hook) self.do(d_a) self.assertEqual(self.i, 3) + def test_hook_key_blocking(self): self.i = 0 + def count(event): self.i += 1 return event.scan_code == 1 - hook = directkeys.hook_key('A', count, suppress=True) + + hook = directkeys.hook_key("A", count, suppress=True) self.do(d_a, d_a) self.assertEqual(self.i, 1) - self.do(u_a+d_b, u_a+d_b) + self.do(u_a + d_b, u_a + d_b) self.assertEqual(self.i, 2) - self.do([make_event(KEY_DOWN, 'A', -1)], []) + self.do([make_event(KEY_DOWN, "A", -1)], []) self.assertEqual(self.i, 3) directkeys.unhook_key(hook) - self.do([make_event(KEY_DOWN, 'A', -1)], [make_event(KEY_DOWN, 'A', -1)]) + self.do([make_event(KEY_DOWN, "A", -1)], [make_event(KEY_DOWN, "A", -1)]) self.assertEqual(self.i, 3) + def test_on_press_key_nonblocking(self): - directkeys.on_press_key('A', lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_DOWN)) - self.do(d_a+u_a+d_b+u_b) + directkeys.on_press_key( + "A", + lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_DOWN), + ) + self.do(d_a + u_a + d_b + u_b) + def test_on_press_key_blocking(self): - directkeys.on_press_key('A', lambda e: e.scan_code == 1, suppress=True) - self.do([make_event(KEY_DOWN, 'A', -1)] + d_a, d_a) + directkeys.on_press_key("A", lambda e: e.scan_code == 1, suppress=True) + self.do([make_event(KEY_DOWN, "A", -1)] + d_a, d_a) + def test_on_release_key(self): - directkeys.on_release_key('a', lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_UP)) - self.do(d_a+u_a) + directkeys.on_release_key( + "a", lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_UP) + ) + self.do(d_a + u_a) def test_block_key(self): - blocked = directkeys.block_key('a') - self.do(d_a+d_b, d_b) - self.do([make_event(KEY_DOWN, 'A', -1)], [make_event(KEY_DOWN, 'A', -1)]) + blocked = directkeys.block_key("a") + self.do(d_a + d_b, d_b) + self.do([make_event(KEY_DOWN, "A", -1)], [make_event(KEY_DOWN, "A", -1)]) directkeys.unblock_key(blocked) - self.do(d_a+d_b, d_a+d_b) + self.do(d_a + d_b, d_a + d_b) + def test_block_key_ambiguous(self): - directkeys.block_key('A') - self.do(d_a+d_b, d_b) - self.do([make_event(KEY_DOWN, 'A', -1)], []) + directkeys.block_key("A") + self.do(d_a + d_b, d_b) + self.do([make_event(KEY_DOWN, "A", -1)], []) def test_remap_key_simple(self): - mapped = directkeys.remap_key('a', 'b') - self.do(d_a+d_c+u_a, d_b+d_c+u_b) + mapped = directkeys.remap_key("a", "b") + self.do(d_a + d_c + u_a, d_b + d_c + u_b) directkeys.unremap_key(mapped) - self.do(d_a+d_c+u_a, d_a+d_c+u_a) + self.do(d_a + d_c + u_a, d_a + d_c + u_a) + def test_remap_key_ambiguous(self): - directkeys.remap_key('A', 'b') - self.do(d_a+d_b, d_b+d_b) - self.do([make_event(KEY_DOWN, 'A', -1)], d_b) + directkeys.remap_key("A", "b") + self.do(d_a + d_b, d_b + d_b) + self.do([make_event(KEY_DOWN, "A", -1)], d_b) + def test_remap_key_multiple(self): - mapped = directkeys.remap_key('a', 'shift+b') - self.do(d_a+d_c+u_a, d_shift+d_b+d_c+u_b+u_shift) + mapped = directkeys.remap_key("a", "shift+b") + self.do(d_a + d_c + u_a, d_shift + d_b + d_c + u_b + u_shift) directkeys.unremap_key(mapped) - self.do(d_a+d_c+u_a, d_a+d_c+u_a) + self.do(d_a + d_c + u_a, d_a + d_c + u_a) def test_stash_state(self): - self.do(d_a+d_shift) + self.do(d_a + d_shift) self.assertEqual(sorted(directkeys.stash_state()), [1, 5]) - self.do([], u_a+u_shift) + self.do([], u_a + u_shift) + def test_restore_state(self): self.do(d_b) directkeys.restore_state([1, 5]) - self.do([], u_b+d_a+d_shift) + self.do([], u_b + d_a + d_shift) + def test_restore_modifieres(self): self.do(d_b) directkeys.restore_modifiers([1, 5]) - self.do([], u_b+d_shift) + self.do([], u_b + d_shift) def test_write_simple(self): - directkeys.write('a', exact=False) - self.do([], d_a+u_a) + directkeys.write("a", exact=False) + self.do([], d_a + u_a) + def test_write_multiple_no_delay(self): - directkeys.write('ab', exact=False) - self.do([], d_a+u_a+d_b+u_b) + directkeys.write("ab", exact=False) + self.do([], d_a + u_a + d_b + u_b) + def test_write_modifiers(self): - directkeys.write('Ab', exact=False) - self.do([], d_shift+d_a+u_a+u_shift+d_b+u_b) + directkeys.write("Ab", exact=False) + self.do([], d_shift + d_a + u_a + u_shift + d_b + u_b) + # restore_state_after has been removed after the introduction of `restore_modifiers`. - #def test_write_stash_not_restore(self): + # def test_write_stash_not_restore(self): # self.do(d_shift) # directkeys.write('a', restore_state_after=False, exact=False) # self.do([], u_shift+d_a+u_a) def test_write_stash_restore(self): self.do(d_shift) - directkeys.write('a', exact=False) - self.do([], u_shift+d_a+u_a+d_shift) + directkeys.write("a", exact=False) + self.do([], u_shift + d_a + u_a + d_shift) + def test_write_multiple(self): last_time = time.time() - directkeys.write('ab', delay=0.01, exact=False) - self.do([], d_a+u_a+d_b+u_b) + directkeys.write("ab", delay=0.01, exact=False) + self.do([], d_a + u_a + d_b + u_b) self.assertGreater(time.time() - last_time, 0.015) + def test_write_unicode_explicit(self): - directkeys.write('ab', exact=True) - self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='a'), KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='b')]) + directkeys.write("ab", exact=True) + self.do( + [], + [ + KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name="a"), + KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name="b"), + ], + ) + def test_write_unicode_fallback(self): - directkeys.write('áb', exact=False) - self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='á')]+d_b+u_b) + directkeys.write("áb", exact=False) + self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name="á")] + d_b + u_b) def test_start_stop_recording(self): directkeys.start_recording() - self.do(d_a+u_a) - self.assertEqual(directkeys.stop_recording(), d_a+u_a) + self.do(d_a + u_a) + self.assertEqual(directkeys.stop_recording(), d_a + u_a) + def test_stop_recording_error(self): with self.assertRaises(ValueError): directkeys.stop_recording() def test_record(self): queue = directkeys._queue.Queue() + def process(): - queue.put(directkeys.record('space', suppress=True)) + queue.put(directkeys.record("space", suppress=True)) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() # 0.01s sleep failed once already. Better solutions? time.sleep(0.01) - self.do(du_a+du_b+du_space, du_a+du_b) - self.assertEqual(queue.get(timeout=0.5), du_a+du_b+du_space) + self.do(du_a + du_b + du_space, du_a + du_b) + self.assertEqual(queue.get(timeout=0.5), du_a + du_b + du_space) def test_play_nodelay(self): - directkeys.play(d_a+u_a, 0) - self.do([], d_a+u_a) + directkeys.play(d_a + u_a, 0) + self.do([], d_a + u_a) + def test_play_stash(self): self.do(d_ctrl) - directkeys.play(d_a+u_a, 0) - self.do([], u_ctrl+d_a+u_a+d_ctrl) + directkeys.play(d_a + u_a, 0) + self.do([], u_ctrl + d_a + u_a + d_ctrl) + def test_play_delay(self): last_time = time.time() - events = [make_event(KEY_DOWN, 'a', 1, 100), make_event(KEY_UP, 'a', 1, 100.01)] + events = [make_event(KEY_DOWN, "a", 1, 100), make_event(KEY_UP, "a", 1, 100.01)] directkeys.play(events, 1) - self.do([], d_a+u_a) + self.do([], d_a + u_a) self.assertGreater(time.time() - last_time, 0.005) def test_get_typed_strings_simple(self): - events = du_a+du_b+du_backspace+d_shift+du_a+u_shift+du_space+du_ctrl+du_a - self.assertEqual(list(directkeys.get_typed_strings(events)), ['aA ', 'a']) + events = du_a + du_b + du_backspace + d_shift + du_a + u_shift + du_space + du_ctrl + du_a + self.assertEqual(list(directkeys.get_typed_strings(events)), ["aA ", "a"]) + def test_get_typed_strings_backspace(self): - events = du_a+du_b+du_backspace - self.assertEqual(list(directkeys.get_typed_strings(events)), ['a']) - events = du_backspace+du_a+du_b - self.assertEqual(list(directkeys.get_typed_strings(events)), ['ab']) + events = du_a + du_b + du_backspace + self.assertEqual(list(directkeys.get_typed_strings(events)), ["a"]) + events = du_backspace + du_a + du_b + self.assertEqual(list(directkeys.get_typed_strings(events)), ["ab"]) + def test_get_typed_strings_shift(self): - events = d_shift+du_a+du_b+u_shift+du_space+du_ctrl+du_a - self.assertEqual(list(directkeys.get_typed_strings(events)), ['AB ', 'a']) + events = d_shift + du_a + du_b + u_shift + du_space + du_ctrl + du_a + self.assertEqual(list(directkeys.get_typed_strings(events)), ["AB ", "a"]) + def test_get_typed_strings_all(self): - events = du_a+du_b+du_backspace+d_shift+du_a+du_capslock+du_b+u_shift+du_space+du_ctrl+du_a - self.assertEqual(list(directkeys.get_typed_strings(events)), ['aAb ', 'A']) + events = ( + du_a + + du_b + + du_backspace + + d_shift + + du_a + + du_capslock + + du_b + + u_shift + + du_space + + du_ctrl + + du_a + ) + self.assertEqual(list(directkeys.get_typed_strings(events)), ["aAb ", "A"]) def test_get_hotkey_name_simple(self): - self.assertEqual(directkeys.get_hotkey_name(['a']), 'a') + self.assertEqual(directkeys.get_hotkey_name(["a"]), "a") + def test_get_hotkey_name_modifiers(self): - self.assertEqual(directkeys.get_hotkey_name(['a', 'shift', 'ctrl']), 'ctrl+shift+a') + self.assertEqual(directkeys.get_hotkey_name(["a", "shift", "ctrl"]), "ctrl+shift+a") + def test_get_hotkey_name_normalize(self): - self.assertEqual(directkeys.get_hotkey_name(['SHIFT', 'left ctrl']), 'ctrl+shift') + self.assertEqual(directkeys.get_hotkey_name(["SHIFT", "left ctrl"]), "ctrl+shift") + def test_get_hotkey_name_plus(self): - self.assertEqual(directkeys.get_hotkey_name(['+']), 'plus') + self.assertEqual(directkeys.get_hotkey_name(["+"]), "plus") + def test_get_hotkey_name_duplicated(self): - self.assertEqual(directkeys.get_hotkey_name(['+', 'plus']), 'plus') + self.assertEqual(directkeys.get_hotkey_name(["+", "plus"]), "plus") + def test_get_hotkey_name_full(self): - self.assertEqual(directkeys.get_hotkey_name(['+', 'left ctrl', 'shift', 'WIN', 'right alt']), 'ctrl+alt+shift+windows+plus') + self.assertEqual( + directkeys.get_hotkey_name(["+", "left ctrl", "shift", "WIN", "right alt"]), + "ctrl+alt+shift+windows+plus", + ) + def test_get_hotkey_name_multiple(self): - self.assertEqual(directkeys.get_hotkey_name(['ctrl', 'b', '!', 'a']), 'ctrl+!+a+b') + self.assertEqual(directkeys.get_hotkey_name(["ctrl", "b", "!", "a"]), "ctrl+!+a+b") + def test_get_hotkey_name_from_pressed(self): - self.do(du_c+d_ctrl+d_a+d_b) - self.assertEqual(directkeys.get_hotkey_name(), 'ctrl+a+b') + self.do(du_c + d_ctrl + d_a + d_b) + self.assertEqual(directkeys.get_hotkey_name(), "ctrl+a+b") def test_read_hotkey(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.read_hotkey()) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() time.sleep(0.01) - self.do(d_ctrl+d_a+d_b+u_ctrl) - self.assertEqual(queue.get(timeout=0.5), 'ctrl+a+b') + self.do(d_ctrl + d_a + d_b + u_ctrl) + self.assertEqual(queue.get(timeout=0.5), "ctrl+a+b") def test_read_event(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.read_event(suppress=True)) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() @@ -524,70 +672,89 @@ def process(): def test_read_key(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.read_key(suppress=True)) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() time.sleep(0.01) self.do(d_a, []) - self.assertEqual(queue.get(timeout=0.5), 'a') + self.assertEqual(queue.get(timeout=0.5), "a") def test_wait_infinite(self): self.triggered = False + def process(): directkeys.wait() self.triggered = True + from threading import Thread + t = Thread(target=process) - t.daemon = True # Yep, we are letting this thread loose. + t.daemon = True # Yep, we are letting this thread loose. t.start() time.sleep(0.01) self.assertFalse(self.triggered) def test_wait_until_success(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.wait(queue.get(timeout=0.5), suppress=True) or True) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() - queue.put('a') + queue.put("a") time.sleep(0.01) self.do(d_a, []) self.assertTrue(queue.get(timeout=0.5)) + def test_wait_until_fail(self): def process(): - directkeys.wait('a', suppress=True) + directkeys.wait("a", suppress=True) self.fail() + from threading import Thread + t = Thread(target=process) - t.daemon = True # Yep, we are letting this thread loose. + t.daemon = True # Yep, we are letting this thread loose. t.start() time.sleep(0.01) self.do(d_b) def test_add_hotkey_single_step_suppress_allow(self): - directkeys.add_hotkey('a', lambda: trigger() or True, suppress=True) - self.do(d_a, triggered_event+d_a) + directkeys.add_hotkey("a", lambda: trigger() or True, suppress=True) + self.do(d_a, triggered_event + d_a) + def test_add_hotkey_single_step_suppress_args_allow(self): arg = object() - directkeys.add_hotkey('a', lambda a: self.assertIs(a, arg) or trigger() or True, args=(arg,), suppress=True) - self.do(d_a, triggered_event+d_a) + directkeys.add_hotkey( + "a", lambda a: self.assertIs(a, arg) or trigger() or True, args=(arg,), suppress=True + ) + self.do(d_a, triggered_event + d_a) + def test_add_hotkey_single_step_suppress_single(self): - directkeys.add_hotkey('a', trigger, suppress=True) + directkeys.add_hotkey("a", trigger, suppress=True) self.do(d_a, triggered_event) + def test_add_hotkey_single_step_suppress_removed_no_modifier(self): - directkeys.remove_hotkey(directkeys.add_hotkey('a', trigger, suppress=True)) + directkeys.remove_hotkey(directkeys.add_hotkey("a", trigger, suppress=True)) self.do(d_a, d_a) + def test_add_hotkey_single_step_suppress_removed(self): - directkeys.remove_hotkey(directkeys.add_hotkey('ctrl+a', trigger, suppress=True)) - self.do(d_ctrl+d_a, d_ctrl+d_a) - self.assertEqual(directkeys._listener.filtered_modifiers[dummy_keys['left ctrl'][0][0]], 0) + directkeys.remove_hotkey(directkeys.add_hotkey("ctrl+a", trigger, suppress=True)) + self.do(d_ctrl + d_a, d_ctrl + d_a) + self.assertEqual(directkeys._listener.filtered_modifiers[dummy_keys["left ctrl"][0][0]], 0) + def test_remove_hotkey_internal(self): - remove = directkeys.add_hotkey('shift+a', trigger, suppress=True) + remove = directkeys.add_hotkey("shift+a", trigger, suppress=True) self.assertTrue(all(directkeys._listener.blocking_hotkeys.values())) self.assertTrue(all(directkeys._listener.filtered_modifiers.values())) self.assertNotEqual(directkeys._hotkeys, {}) @@ -595,8 +762,9 @@ def test_remove_hotkey_internal(self): self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertTrue(not any(directkeys._listener.blocking_hotkeys.values())) self.assertEqual(directkeys._hotkeys, {}) + def test_remove_hotkey_internal_multistep_start(self): - remove = directkeys.add_hotkey('shift+a, b', trigger, suppress=True) + remove = directkeys.add_hotkey("shift+a, b", trigger, suppress=True) self.assertTrue(all(directkeys._listener.blocking_hotkeys.values())) self.assertTrue(all(directkeys._listener.filtered_modifiers.values())) self.assertNotEqual(directkeys._hotkeys, {}) @@ -604,9 +772,10 @@ def test_remove_hotkey_internal_multistep_start(self): self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertTrue(not any(directkeys._listener.blocking_hotkeys.values())) self.assertEqual(directkeys._hotkeys, {}) + def test_remove_hotkey_internal_multistep_end(self): - remove = directkeys.add_hotkey('shift+a, b', trigger, suppress=True) - self.do(d_shift+du_a+u_shift) + remove = directkeys.add_hotkey("shift+a, b", trigger, suppress=True) + self.do(d_shift + du_a + u_shift) self.assertTrue(any(directkeys._listener.blocking_hotkeys.values())) self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertNotEqual(directkeys._hotkeys, {}) @@ -614,212 +783,298 @@ def test_remove_hotkey_internal_multistep_end(self): self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertTrue(not any(directkeys._listener.blocking_hotkeys.values())) self.assertEqual(directkeys._hotkeys, {}) + def test_add_hotkey_single_step_suppress_with_modifiers(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+d_a, triggered_event) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + d_a, triggered_event) + def test_add_hotkey_single_step_suppress_with_modifiers_fail_unrelated_modifier(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+u_shift+d_a, d_shift+u_shift+d_ctrl+d_a) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + u_shift + d_a, d_shift + u_shift + d_ctrl + d_a) + def test_add_hotkey_single_step_suppress_with_modifiers_fail_unrelated_key(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_b, d_shift+d_ctrl+du_b) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + du_b, d_shift + d_ctrl + du_b) + def test_add_hotkey_single_step_suppress_with_modifiers_unrelated_key(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_b+d_a, d_shift+d_ctrl+du_b+triggered_event) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + du_b + d_a, d_shift + d_ctrl + du_b + triggered_event) + def test_add_hotkey_single_step_suppress_with_two_modifiers_release(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_b+d_a+u_ctrl+u_shift, d_shift+d_ctrl+du_b+triggered_event+u_ctrl+u_shift) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do( + d_ctrl + d_shift + du_b + d_a + u_ctrl + u_shift, + d_shift + d_ctrl + du_b + triggered_event + u_ctrl + u_shift, + ) + def test_add_hotkey_single_step_suppress_with_modifiers_out_of_order(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_shift+d_ctrl+d_a, triggered_event) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_shift + d_ctrl + d_a, triggered_event) + def test_add_hotkey_single_step_suppress_with_modifiers_repeated(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True) - self.do(d_ctrl+du_a+du_b+du_a, triggered_event+d_ctrl+du_b+triggered_event) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True) + self.do(d_ctrl + du_a + du_b + du_a, triggered_event + d_ctrl + du_b + triggered_event) + def test_add_hotkey_single_step_suppress_with_modifiers_release(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True, trigger_on_release=True) - self.do(d_ctrl+du_a+du_b+du_a, triggered_event+d_ctrl+du_b+triggered_event) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True, trigger_on_release=True) + self.do(d_ctrl + du_a + du_b + du_a, triggered_event + d_ctrl + du_b + triggered_event) + def test_add_hotkey_single_step_suppress_with_modifier_superset_release(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True, trigger_on_release=True) - self.do(d_ctrl+d_shift+du_a+u_shift+u_ctrl, d_ctrl+d_shift+du_a+u_shift+u_ctrl) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True, trigger_on_release=True) + self.do( + d_ctrl + d_shift + du_a + u_shift + u_ctrl, d_ctrl + d_shift + du_a + u_shift + u_ctrl + ) + def test_add_hotkey_single_step_suppress_with_modifier_superset(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_a+u_shift+u_ctrl, d_ctrl+d_shift+du_a+u_shift+u_ctrl) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True) + self.do( + d_ctrl + d_shift + du_a + u_shift + u_ctrl, d_ctrl + d_shift + du_a + u_shift + u_ctrl + ) + def test_add_hotkey_single_step_timeout(self): - directkeys.add_hotkey('a', trigger, timeout=1, suppress=True) + directkeys.add_hotkey("a", trigger, timeout=1, suppress=True) self.do(du_a, triggered_event) + def test_add_hotkey_multi_step_first_timeout(self): - directkeys.add_hotkey('a, b', trigger, timeout=0.01, suppress=True) + directkeys.add_hotkey("a, b", trigger, timeout=0.01, suppress=True) time.sleep(0.03) - self.do(du_a+du_b, triggered_event) + self.do(du_a + du_b, triggered_event) + def test_add_hotkey_multi_step_last_timeout(self): - directkeys.add_hotkey('a, b', trigger, timeout=0.01, suppress=True) + directkeys.add_hotkey("a, b", trigger, timeout=0.01, suppress=True) self.do(du_a, []) time.sleep(0.05) - self.do(du_b, du_a+du_b) + self.do(du_b, du_a + du_b) + def test_add_hotkey_multi_step_success_timeout(self): - directkeys.add_hotkey('a, b', trigger, timeout=0.05, suppress=True) + directkeys.add_hotkey("a, b", trigger, timeout=0.05, suppress=True) self.do(du_a, []) time.sleep(0.01) self.do(du_b, triggered_event) + def test_add_hotkey_multi_step_suffix_timeout(self): - directkeys.add_hotkey('a, b, a', trigger, timeout=0.01, suppress=True) - self.do(du_a+du_b, []) + directkeys.add_hotkey("a, b, a", trigger, timeout=0.01, suppress=True) + self.do(du_a + du_b, []) time.sleep(0.05) - self.do(du_a, du_a+du_b) - self.do(du_b+du_a, triggered_event) + self.do(du_a, du_a + du_b) + self.do(du_b + du_a, triggered_event) + def test_add_hotkey_multi_step_allow(self): - directkeys.add_hotkey('a, b', lambda: trigger() or True, suppress=True) - self.do(du_a+du_b, triggered_event+du_a+du_b) + directkeys.add_hotkey("a, b", lambda: trigger() or True, suppress=True) + self.do(du_a + du_b, triggered_event + du_a + du_b) def test_add_hotkey_single_step_nonsuppress(self): queue = directkeys._queue.Queue() - directkeys.add_hotkey('ctrl+shift+a+b', lambda: queue.put(True), suppress=False) - self.do(d_shift+d_ctrl+d_a+d_b) + directkeys.add_hotkey("ctrl+shift+a+b", lambda: queue.put(True), suppress=False) + self.do(d_shift + d_ctrl + d_a + d_b) self.assertTrue(queue.get(timeout=0.5)) + def test_add_hotkey_single_step_nonsuppress_repeated(self): queue = directkeys._queue.Queue() - directkeys.add_hotkey('ctrl+shift+a+b', lambda: queue.put(True), suppress=False) - self.do(d_shift+d_ctrl+d_a+d_b) - self.do(d_shift+d_ctrl+d_a+d_b) + directkeys.add_hotkey("ctrl+shift+a+b", lambda: queue.put(True), suppress=False) + self.do(d_shift + d_ctrl + d_a + d_b) + self.do(d_shift + d_ctrl + d_a + d_b) self.assertTrue(queue.get(timeout=0.5)) self.assertTrue(queue.get(timeout=0.5)) + def test_add_hotkey_single_step_nosuppress_with_modifiers_out_of_order(self): queue = directkeys._queue.Queue() - directkeys.add_hotkey('ctrl+shift+a', lambda: queue.put(True), suppress=False) - self.do(d_shift+d_ctrl+d_a) + directkeys.add_hotkey("ctrl+shift+a", lambda: queue.put(True), suppress=False) + self.do(d_shift + d_ctrl + d_a) self.assertTrue(queue.get(timeout=0.5)) + def test_add_hotkey_single_step_suppress_regression_1(self): - directkeys.add_hotkey('a', trigger, suppress=True) - self.do(d_c+d_a+u_c+u_a, d_c+d_a+u_c+u_a) + directkeys.add_hotkey("a", trigger, suppress=True) + self.do(d_c + d_a + u_c + u_a, d_c + d_a + u_c + u_a) def test_remap_hotkey_single(self): - directkeys.remap_hotkey('a', 'b') - self.do(d_a+u_a, d_b+u_b) + directkeys.remap_hotkey("a", "b") + self.do(d_a + u_a, d_b + u_b) + def test_remap_hotkey_complex_dst(self): - directkeys.remap_hotkey('a', 'ctrl+b, c') - self.do(d_a+u_a, d_ctrl+du_b+u_ctrl+du_c) + directkeys.remap_hotkey("a", "ctrl+b, c") + self.do(d_a + u_a, d_ctrl + du_b + u_ctrl + du_c) + def test_remap_hotkey_modifiers(self): - directkeys.remap_hotkey('ctrl+shift+a', 'b') - self.do(d_ctrl+d_shift+d_a+u_a, du_b) + directkeys.remap_hotkey("ctrl+shift+a", "b") + self.do(d_ctrl + d_shift + d_a + u_a, du_b) + def test_remap_hotkey_modifiers_repeat(self): - directkeys.remap_hotkey('ctrl+shift+a', 'b') - self.do(d_ctrl+d_shift+du_a+du_a, du_b+du_b) + directkeys.remap_hotkey("ctrl+shift+a", "b") + self.do(d_ctrl + d_shift + du_a + du_a, du_b + du_b) + def test_remap_hotkey_modifiers_state(self): - directkeys.remap_hotkey('ctrl+shift+a', 'b') - self.do(d_ctrl+d_shift+du_c+du_a+du_a, d_shift+d_ctrl+du_c+u_shift+u_ctrl+du_b+d_ctrl+d_shift+u_shift+u_ctrl+du_b+d_ctrl+d_shift) + directkeys.remap_hotkey("ctrl+shift+a", "b") + self.do( + d_ctrl + d_shift + du_c + du_a + du_a, + d_shift + + d_ctrl + + du_c + + u_shift + + u_ctrl + + du_b + + d_ctrl + + d_shift + + u_shift + + u_ctrl + + du_b + + d_ctrl + + d_shift, + ) + def test_remap_hotkey_release_incomplete(self): - directkeys.remap_hotkey('a', 'b', trigger_on_release=True) + directkeys.remap_hotkey("a", "b", trigger_on_release=True) self.do(d_a, []) + def test_remap_hotkey_release_complete(self): - directkeys.remap_hotkey('a', 'b', trigger_on_release=True) + directkeys.remap_hotkey("a", "b", trigger_on_release=True) self.do(du_a, du_b) def test_parse_hotkey_combinations_scan_code(self): self.assertEqual(directkeys.parse_hotkey_combinations(30), (((30,),),)) + def test_parse_hotkey_combinations_single(self): - self.assertEqual(directkeys.parse_hotkey_combinations('a'), (((1,),),)) + self.assertEqual(directkeys.parse_hotkey_combinations("a"), (((1,),),)) + def test_parse_hotkey_combinations_single_modifier(self): - self.assertEqual(directkeys.parse_hotkey_combinations('shift+a'), (((1, 5), (1, 6)),)) + self.assertEqual(directkeys.parse_hotkey_combinations("shift+a"), (((1, 5), (1, 6)),)) + def test_parse_hotkey_combinations_single_modifiers(self): - self.assertEqual(directkeys.parse_hotkey_combinations('shift+ctrl+a'), (((1, 5, 7), (1, 6, 7)),)) + self.assertEqual( + directkeys.parse_hotkey_combinations("shift+ctrl+a"), (((1, 5, 7), (1, 6, 7)),) + ) + def test_parse_hotkey_combinations_multi(self): - self.assertEqual(directkeys.parse_hotkey_combinations('a, b'), (((1,),), ((2,),))) + self.assertEqual(directkeys.parse_hotkey_combinations("a, b"), (((1,),), ((2,),))) + def test_parse_hotkey_combinations_multi_modifier(self): - self.assertEqual(directkeys.parse_hotkey_combinations('shift+a, b'), (((1, 5), (1, 6)), ((2,),))) + self.assertEqual( + directkeys.parse_hotkey_combinations("shift+a, b"), (((1, 5), (1, 6)), ((2,),)) + ) + def test_parse_hotkey_combinations_list_list(self): - self.assertEqual(directkeys.parse_hotkey_combinations(directkeys.parse_hotkey_combinations('a, b')), directkeys.parse_hotkey_combinations('a, b')) + self.assertEqual( + directkeys.parse_hotkey_combinations(directkeys.parse_hotkey_combinations("a, b")), + directkeys.parse_hotkey_combinations("a, b"), + ) + def test_parse_hotkey_combinations_fail_empty(self): with self.assertRaises(ValueError): - directkeys.parse_hotkey_combinations('') - + directkeys.parse_hotkey_combinations("") def test_add_hotkey_multistep_suppress_incomplete_blocking_state(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) + directkeys.add_hotkey("a, b", trigger, suppress=True) self.do(du_a, []) self.assertEqual(directkeys._listener.blocking_hotkeys[(1,)], []) self.assertEqual(len(directkeys._listener.blocking_hotkeys[(2,)]), 1) + def test_add_hotkey_multistep_suppress_incomplete(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(du_a+du_b, triggered_event) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(du_a + du_b, triggered_event) + def test_add_hotkey_multistep_suppress_modifier(self): - directkeys.add_hotkey('shift+a, b', trigger, suppress=True) - self.do(d_shift+du_a+u_shift+du_b, triggered_event) + directkeys.add_hotkey("shift+a, b", trigger, suppress=True) + self.do(d_shift + du_a + u_shift + du_b, triggered_event) + def test_add_hotkey_multistep_suppress_fail(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(du_a+du_c, du_a+du_c) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(du_a + du_c, du_a + du_c) + def test_add_hotkey_multistep_suppress_three_steps(self): - directkeys.add_hotkey('a, b, c', trigger, suppress=True) - self.do(du_a+du_b+du_c, triggered_event) + directkeys.add_hotkey("a, b, c", trigger, suppress=True) + self.do(du_a + du_b + du_c, triggered_event) + def test_add_hotkey_multistep_suppress_repeated_prefix(self): - directkeys.add_hotkey('a, a, c', trigger, suppress=True, trigger_on_release=True) - self.do(du_a+du_a+du_c, triggered_event) + directkeys.add_hotkey("a, a, c", trigger, suppress=True, trigger_on_release=True) + self.do(du_a + du_a + du_c, triggered_event) + def test_add_hotkey_multistep_suppress_repeated_key(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(du_a+du_a+du_b, du_a+triggered_event) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(du_a + du_a + du_b, du_a + triggered_event) self.assertEqual(directkeys._listener.blocking_hotkeys[(2,)], []) self.assertEqual(len(directkeys._listener.blocking_hotkeys[(1,)]), 1) + def test_add_hotkey_multi_step_suppress_regression_1(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(d_c+d_a+u_c+u_a+du_c, d_c+d_a+u_c+u_a+du_c) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(d_c + d_a + u_c + u_a + du_c, d_c + d_a + u_c + u_a + du_c) + def test_add_hotkey_multi_step_suppress_replays(self): - directkeys.add_hotkey('a, b, c', trigger, suppress=True) - self.do(du_a+du_b+du_a+du_b+du_space, du_a+du_b+du_a+du_b+du_space) + directkeys.add_hotkey("a, b, c", trigger, suppress=True) + self.do(du_a + du_b + du_a + du_b + du_space, du_a + du_b + du_a + du_b + du_space) def test_add_word_listener_success(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - self.do(du_a+du_b+du_c+du_space) + + directkeys.add_word_listener("abc", free) + self.do(du_a + du_b + du_c + du_space) self.assertTrue(queue.get(timeout=0.5)) + def test_add_word_listener_no_trigger_fail(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - self.do(du_a+du_b+du_c) + + directkeys.add_word_listener("abc", free) + self.do(du_a + du_b + du_c) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) + def test_add_word_listener_timeout_fail(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free, timeout=1) - self.do(du_a+du_b+du_c+[make_event(KEY_DOWN, name='space', time=2)]) + + directkeys.add_word_listener("abc", free, timeout=1) + self.do(du_a + du_b + du_c + [make_event(KEY_DOWN, name="space", time=2)]) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) + def test_duplicated_word_listener(self): - directkeys.add_word_listener('abc', trigger) - directkeys.add_word_listener('abc', trigger) + directkeys.add_word_listener("abc", trigger) + directkeys.add_word_listener("abc", trigger) + def test_add_word_listener_remove(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - directkeys.remove_word_listener('abc') - self.do(du_a+du_b+du_c+du_space) + + directkeys.add_word_listener("abc", free) + directkeys.remove_word_listener("abc") + self.do(du_a + du_b + du_c + du_space) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) + def test_add_word_listener_suffix_success(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free, match_suffix=True) - self.do(du_a+du_a+du_b+du_c+du_space) + + directkeys.add_word_listener("abc", free, match_suffix=True) + self.do(du_a + du_a + du_b + du_c + du_space) self.assertTrue(queue.get(timeout=0.5)) + def test_add_word_listener_suffix_fail(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - self.do(du_a+du_a+du_b+du_c) + + directkeys.add_word_listener("abc", free) + self.do(du_a + du_a + du_b + du_c) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) - #def test_add_abbreviation(self): + # def test_add_abbreviation(self): # directkeys.add_abbreviation('abc', 'aaa') # self.do(du_a+du_b+du_c+du_space, []) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 3a6df78..27b0b51 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -38,19 +38,20 @@ def get_position(self): return self.position def move_to(self, x, y): - self.append(('move', (x, y))) + self.append(("move", (x, y))) self.position = (x, y) def wheel(self, delta): - self.append(('wheel', delta)) + self.append(("wheel", delta)) def move_relative(self, x, y): self.position = (self.position[0] + x, self.position[1] + y) + class TestMouse(unittest.TestCase): @staticmethod def setUpClass(): - mouse._os_mouse= FakeOsMouse() + mouse._os_mouse = FakeOsMouse() mouse._listener.start_if_necessary() assert mouse._os_mouse.listening @@ -159,8 +160,10 @@ def test_move(self): def triggers(self, fn, events, **kwargs): self.triggered = False + def callback(): self.triggered = True + handler = fn(callback, **kwargs) for event_type, arg in events: @@ -170,7 +173,7 @@ def callback(): self.release(arg) elif event_type == DOUBLE: self.double_click(arg) - elif event_type == 'WHEEL': + elif event_type == "WHEEL": self.wheel() mouse._listener.remove_handler(handler) @@ -181,7 +184,7 @@ def test_on_button(self): self.assertTrue(self.triggers(mouse.on_button, [(DOWN, RIGHT)])) self.assertTrue(self.triggers(mouse.on_button, [(DOWN, X)])) - self.assertFalse(self.triggers(mouse.on_button, [('WHEEL', '')])) + self.assertFalse(self.triggers(mouse.on_button, [("WHEEL", "")])) self.assertFalse(self.triggers(mouse.on_button, [(DOWN, X)], buttons=MIDDLE)) self.assertTrue(self.triggers(mouse.on_button, [(DOWN, MIDDLE)], buttons=MIDDLE)) @@ -189,9 +192,15 @@ def test_on_button(self): self.assertFalse(self.triggers(mouse.on_button, [(DOWN, MIDDLE)], buttons=MIDDLE, types=UP)) self.assertTrue(self.triggers(mouse.on_button, [(UP, MIDDLE)], buttons=MIDDLE, types=UP)) - self.assertTrue(self.triggers(mouse.on_button, [(UP, MIDDLE)], buttons=[MIDDLE, LEFT], types=[UP, DOWN])) - self.assertTrue(self.triggers(mouse.on_button, [(DOWN, LEFT)], buttons=[MIDDLE, LEFT], types=[UP, DOWN])) - self.assertFalse(self.triggers(mouse.on_button, [(UP, X)], buttons=[MIDDLE, LEFT], types=[UP, DOWN])) + self.assertTrue( + self.triggers(mouse.on_button, [(UP, MIDDLE)], buttons=[MIDDLE, LEFT], types=[UP, DOWN]) + ) + self.assertTrue( + self.triggers(mouse.on_button, [(DOWN, LEFT)], buttons=[MIDDLE, LEFT], types=[UP, DOWN]) + ) + self.assertFalse( + self.triggers(mouse.on_button, [(UP, X)], buttons=[MIDDLE, LEFT], types=[UP, DOWN]) + ) def test_ons(self): self.assertTrue(self.triggers(mouse.on_click, [(UP, LEFT)])) @@ -209,22 +218,28 @@ def test_ons(self): def test_wait(self): # If this fails it blocks. Unfortunately, but I see no other way of testing. from threading import Lock, Thread + lock = Lock() lock.acquire() + def t(): mouse.wait() lock.release() + Thread(target=t).start() self.press() lock.acquire() def test_record_play(self): from threading import Lock, Thread + lock = Lock() lock.acquire() + def t(): self.recorded = mouse.record(RIGHT) lock.release() + Thread(target=t).start() self.click() self.wheel(5) @@ -244,8 +259,8 @@ def t(): self.assertEqual(len(events), 5) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('wheel', 5)) - self.assertEqual(events[3], ('move', (100, 50))) + self.assertEqual(events[2], ("wheel", 5)) + self.assertEqual(events[3], ("move", (100, 50))) self.assertEqual(events[4], (DOWN, RIGHT)) mouse.play(self.recorded) @@ -253,22 +268,22 @@ def t(): self.assertEqual(len(events), 5) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('wheel', 5)) - self.assertEqual(events[3], ('move', (100, 50))) + self.assertEqual(events[2], ("wheel", 5)) + self.assertEqual(events[3], ("move", (100, 50))) self.assertEqual(events[4], (DOWN, RIGHT)) mouse.play(self.recorded, include_clicks=False) events = self.flush_events() self.assertEqual(len(events), 2) - self.assertEqual(events[0], ('wheel', 5)) - self.assertEqual(events[1], ('move', (100, 50))) + self.assertEqual(events[0], ("wheel", 5)) + self.assertEqual(events[1], ("move", (100, 50))) mouse.play(self.recorded, include_moves=False) events = self.flush_events() self.assertEqual(len(events), 4) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('wheel', 5)) + self.assertEqual(events[2], ("wheel", 5)) self.assertEqual(events[3], (DOWN, RIGHT)) mouse.play(self.recorded, include_wheel=False) @@ -276,8 +291,9 @@ def t(): self.assertEqual(len(events), 4) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('move', (100, 50))) + self.assertEqual(events[2], ("move", (100, 50))) self.assertEqual(events[3], (DOWN, RIGHT)) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() From fb626d69a02261625f64ef77c7ba0373c0935444 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:57:15 -0400 Subject: [PATCH 12/14] build: add pre-commit, editorconfig and Dependabot Rounds out the tooling so the checks that CI runs can also run locally before a commit, and so contributors get consistent whitespace regardless of editor. - .pre-commit-config.yaml runs Ruff (check + format) plus the standard whitespace and syntax hooks. README.md is excluded from the whitespace hooks because it is generated from the module docstring. - The `mixed-line-ending` hook is deliberately left out: `.gitattributes` already normalises endings at the git level, and forcing LF in the working tree would fight the CRLF checkout on Windows in a loop. - .editorconfig mirrors the same rules for editors. - Dependabot keeps the Actions and pip pins current, monthly. - A `lint` job runs Ruff in CI. .gitattributes drops the Visual Studio and msysgit boilerplate inherited from the original template, keeps the `text=auto` normalisation, and marks README.md as generated so it stops inflating pull request diffs. Verified by running `pre-commit run --all-files` twice: the second run is clean, so the hooks are idempotent. Co-Authored-By: Claude Opus 5 --- .editorconfig | 19 +++++++++++++++ .gitattributes | 31 +++++++++--------------- .github/dependabot.yml | 15 ++++++++++++ .github/workflows/tests.yml | 17 +++++++++++++ .gitignore | 2 +- .pre-commit-config.yaml | 20 +++++++++++++++ MANIFEST.in | 2 +- Makefile | 2 +- tests/manual/altgr_and_stuck_keys.py | 16 ++++++------ tests/manual/simulate_stuck_key_crash.py | 2 +- 10 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .pre-commit-config.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d85bea9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,toml,json}] +indent_size = 2 + +[*.md] +# Trailing whitespace is significant in Markdown, and README.md is generated. +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes index 412eeda..86b9316 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,22 +1,15 @@ -# Auto detect text files and perform LF normalization +# Normalise line endings in the repository, checking out the platform native +# ones. This is what keeps the tree consistent between Windows and Linux. * text=auto -# Custom for Visual Studio -*.cs diff=csharp -*.sln merge=union -*.csproj merge=union -*.vbproj merge=union -*.fsproj merge=union -*.dbproj merge=union +*.py text diff=python +*.md text +*.toml text +*.yml text +*.yaml text +*.txt text +Makefile text -# Standard to msysgit -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain +# Generated from the directkeys module docstring by the `build` target, so it +# should not count towards language statistics or clutter pull request diffs. +README.md linguist-generated=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7d32f0b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + prefix: "ci" + + - package-ecosystem: pip + directory: / + schedule: + interval: monthly + commit-message: + prefix: "build" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ea7422..dc44747 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,23 @@ on: workflow_dispatch: jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - uses: astral-sh/ruff-action@v3 + + - name: Lint + run: ruff check --output-format=github . + + - name: Check formatting + run: ruff format --check . + test: runs-on: ${{ matrix.os }} strategy: diff --git a/.gitignore b/.gitignore index 7c3bb75..6c46613 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ Desktop.ini *.bak *.tmp *.swp -*~.nib \ No newline at end of file +*~.nib diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b6feb58 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.1 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + # README.md is generated from the __init__ docstring; leave it alone. + exclude: ^README\.md$ + - id: end-of-file-fixer + exclude: ^README\.md$ + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files diff --git a/MANIFEST.in b/MANIFEST.in index 6691e35..b605797 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include CHANGES.md include README.md include LICENSE.txt -include pyproject.toml \ No newline at end of file +include pyproject.toml diff --git a/Makefile b/Makefile index f50f66c..7ac6201 100644 --- a/Makefile +++ b/Makefile @@ -18,4 +18,4 @@ release: python make_release.py clean: - rm -rfv dist build coverage_html_report directkeys.egg-info \ No newline at end of file + rm -rfv dist build coverage_html_report directkeys.egg-info diff --git a/tests/manual/altgr_and_stuck_keys.py b/tests/manual/altgr_and_stuck_keys.py index 876bb1a..e390e55 100644 --- a/tests/manual/altgr_and_stuck_keys.py +++ b/tests/manual/altgr_and_stuck_keys.py @@ -33,11 +33,11 @@ def test_raw_event_capture(): print("-" * 50) directkeys.set_alt_gr_abstraction(False) - + print("1. Pressione e solte 'AltGr'...") alt_gr_event = read_next_keydown() print(f" -> Recebido: Tecla='{alt_gr_event.name}', Scan={alt_gr_event.scan_code:#04x}, Flags={alt_gr_event.flags}") - + print("\n2. Pressione e solte a tecla '/'...") slash_event = read_next_keydown() print(f" -> Recebido: Tecla='{slash_event.name}', Scan={slash_event.scan_code:#04x}, Flags={slash_event.flags}") @@ -52,7 +52,7 @@ def test_raw_event_capture(): assert esc_event.name == 'esc' print("✅ Teste 1: SUCESSO!") - + directkeys.set_alt_gr_abstraction(True) def test_stuck_key_fix(): @@ -66,20 +66,20 @@ def test_stuck_key_fix(): process.wait() time.sleep(1) print("--> Script travado. A tecla 'Ctrl' deve estar 'presa' no sistema.") - + # Verificação inicial (opcional, mas bom para confirmar o problema) stuck_before = directkeys.get_stuck_keys() if 'ctrl' in stuck_before or 'left ctrl' in stuck_before: print(f" [CONFIRMADO] Teclas presas detectadas: {stuck_before}") else: print(f" [AVISO] Não foi possível detectar a tecla 'Ctrl' como presa. O teste continua.") - + input("--> Pressione Enter para executar a correção...") print("\n--> Passo 2b: Executando a função de correção 'force_reset_keyboard()'...") directkeys.force_reset_keyboard() print("--> Função executada.") - + # Verificação de ressalva print("--> Verificando se ainda há teclas presas...") stuck_after = directkeys.get_stuck_keys() @@ -88,7 +88,7 @@ def test_stuck_key_fix(): assert False, f"A função force_reset_keyboard não limpou as seguintes teclas: {stuck_after}" else: print(" [SUCESSO] Nenhuma tecla modificadora presa foi detectada.") - + print("\n✅ Teste 2: SUCESSO!") if __name__ == "__main__": @@ -105,7 +105,7 @@ def test_stuck_key_fix(): directkeys.force_reset_keyboard() directkeys.reset_internal_state() directkeys.unhook_all() - + # Verificação final de ressalva final_stuck_keys = directkeys.get_stuck_keys() if final_stuck_keys: diff --git a/tests/manual/simulate_stuck_key_crash.py b/tests/manual/simulate_stuck_key_crash.py index 4920a9d..4b98674 100644 --- a/tests/manual/simulate_stuck_key_crash.py +++ b/tests/manual/simulate_stuck_key_crash.py @@ -12,4 +12,4 @@ print("!!! SCRIPT ENCERRADO ABRUPTAMENTE !!!") # O sys.exit() simula um crash. O bloco 'finally' não será executado. -sys.exit(0) \ No newline at end of file +sys.exit(0) From b2fd2d1cdd8f88ed8f1bd394da71927378b51a6f Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Sat, 1 Aug 2026 18:33:09 -0400 Subject: [PATCH 13/14] docs: document the 3.9 requirement and the development setup Records the modernisation in CHANGES.md, including the Python 3.9 requirement, the src/ layout and the tooling, alongside the fixes made earlier on the fork. The module docstring gains a Development section covering the editable install, pre-commit and the two Ruff checks CI runs, and states the supported Python version where the removed "Python 2 and 3" line used to be. README.md is regenerated from it. Also widens the margin in test_record. The test races a background thread that has to register both the recording hook and the suppressing hotkey before the events are fed in, and upstream already noted the 0.01s sleep had lost that race once; it lost it again here. There is no public barrier to synchronise on, so this is a larger margin rather than a real fix, and the comment says so. Ten consecutive full runs pass afterwards. Co-Authored-By: Claude Opus 5 --- CHANGES.md | 26 ++++++++++++++++++++++++-- README.md | 24 +++++++++++++++++++++++- src/directkeys/__init__.py | 24 +++++++++++++++++++++++- tests/test_keyboard.py | 7 +++++-- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 55987ad..63ce220 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,10 +15,32 @@ New features: - `KeyboardEvent.flags` exposes the low-level hook flags, and is included in `to_json()`. On Windows it currently carries the `LLKHF_EXTENDED` bit. -Packaging: +Also exposes `__version__` as the canonical version attribute. The upstream +`version` name is kept as an alias, so nothing breaks. + +Packaging and project layout: - Renamed the package and the distribution to `directkeys`. -- Metadata moved to `pyproject.toml`; Python 3.8+ is required. +- **Python 3.9+ is now required.** 3.8 reached end of life in October 2024. + All the Python 2 compatibility shims are gone with it. +- Metadata moved to `pyproject.toml`; `setup.py` is now only a shim. +- The package moved to a `src/` layout, so the test run exercises the + installed distribution rather than the source tree. +- Ruff (lint and format), pre-commit and an EditorConfig are configured, and + CI runs the suite plus a lint and a build check on Windows and Linux. + +Fixes carried over from the initial fork work: + +- `force_reset_keyboard()`, `get_stuck_keys()` and `reset_internal_state()` + were never loaded from the Windows backend and always resolved to no-op + stubs. +- `get_modifiers()` built a 32772-element tuple on every keystroke whenever + shift was held, because `GetKeyState` reports the pressed bit as `0x8000`. +- `set_alt_gr_abstraction()` never rebuilt the name tables. +- Key events reported `is_keypad` as the extended-key flag, which is close to + the inverse of the intended value, and dropped the `scan_code or -vk` + fallback for keys with no scan code. +- Importing the package on macOS no longer fails when pyobjc is missing. # 0.13.5 diff --git a/README.md b/README.md index 2923c87..6da6afa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Take full control of your keyboard with this small Python library. Hook global e - **Listen** and **send** keyboard events. - Works with **Windows** and **Linux** (requires sudo), with experimental **OS X** support (thanks @glitchassassin!). - **Pure Python**, no C modules to be compiled. -- **Zero dependencies**. Trivial to install and deploy, just copy the files. +- **Zero dependencies** on Windows and Linux. Trivial to install and deploy. +- **Python 3.9+**. - Complex hotkey support (e.g. `ctrl+shift+m, ctrl+space`) with controllable timeout. - Includes **high level API** (e.g. [record](#directkeys.record) and [play](#directkeys.play), [add_abbreviation](#directkeys.add_abbreviation)). - Maps keys as they actually are in your layout, with **full internationalization support** (e.g. `Ctrl+ç`). @@ -51,6 +52,27 @@ import keyboard import directkeys as keyboard ``` +## Development + +```bash +git clone https://github.com/WigoWigo10/keyboard +cd keyboard +pip install -e . +pip install pytest pre-commit ruff +pre-commit install +``` + +Run the checks the way CI does: + +```bash +pytest # the automated suite +ruff check . # lint +ruff format --check . +``` + +`tests/manual/` holds interactive scripts that drive a real keyboard, so they +are excluded from the automated run and have to be started by hand. + ## Example diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index 4ccf81c..bf894dc 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -12,7 +12,8 @@ - **Listen** and **send** keyboard events. - Works with **Windows** and **Linux** (requires sudo), with experimental **OS X** support (thanks @glitchassassin!). - **Pure Python**, no C modules to be compiled. -- **Zero dependencies**. Trivial to install and deploy, just copy the files. +- **Zero dependencies** on Windows and Linux. Trivial to install and deploy. +- **Python 3.9+**. - Complex hotkey support (e.g. `ctrl+shift+m, ctrl+space`) with controllable timeout. - Includes **high level API** (e.g. [record](#directkeys.record) and [play](#directkeys.play), [add_abbreviation](#directkeys.add_abbreviation)). - Maps keys as they actually are in your layout, with **full internationalization support** (e.g. `Ctrl+ç`). @@ -52,6 +53,27 @@ import directkeys as keyboard ``` +## Development + +```bash +git clone https://github.com/WigoWigo10/keyboard +cd keyboard +pip install -e . +pip install pytest pre-commit ruff +pre-commit install +``` + +Run the checks the way CI does: + +```bash +pytest # the automated suite +ruff check . # lint +ruff format --check . +``` + +`tests/manual/` holds interactive scripts that drive a real keyboard, so they +are excluded from the automated run and have to be started by hand. + ## Example diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index ccc4e9a..13211dc 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -561,8 +561,11 @@ def process(): t = Thread(target=process) t.daemon = True t.start() - # 0.01s sleep failed once already. Better solutions? - time.sleep(0.01) + # Racy by construction: the thread has to register both the recording + # hook and the suppressing hotkey for `space` before the events below + # are fed in. There is no public barrier to wait on, so this is a + # margin rather than a fix -- 0.01s was observed to lose the race. + time.sleep(0.2) self.do(du_a + du_b + du_space, du_a + du_b) self.assertEqual(queue.get(timeout=0.5), du_a + du_b + du_space) From 79f2dc9961df3400248b06c1c661d1c958d04c8c Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Sat, 1 Aug 2026 18:34:49 -0400 Subject: [PATCH 14/14] chore: ignore the Ruff and mypy caches Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6c46613..a1bc3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ eggs/ htmlcov/ coverage_html_report/ +# Tooling caches +.ruff_cache/ +.mypy_cache/ + # Editor and OS specific files .vscode/ .idea/