diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 6d16b528..00000000 --- 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/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d85bea98 --- /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 412eeda7..86b93167 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 00000000..7d32f0bd --- /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 2e262f54..dc447477 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: @@ -15,14 +32,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 @@ -38,7 +48,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/.gitignore b/.gitignore index 7c3bb75b..a1bc3bc9 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/ @@ -36,4 +40,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 00000000..b6feb58e --- /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/CHANGES.md b/CHANGES.md index 55987ad4..63ce220e 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/MANIFEST.in b/MANIFEST.in index 6691e350..b6057978 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 8767c8aa..7ac62016 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/* @@ -10,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/README.md b/README.md index 2923c87c..6da6afa8 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/directkeys/_darwinmouse.py b/directkeys/_darwinmouse.py deleted file mode 100644 index b112dc07..00000000 --- a/directkeys/_darwinmouse.py +++ /dev/null @@ -1,173 +0,0 @@ -import os -import datetime -import threading -import Quartz -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN - -_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 -} - -class MouseEventListener(object): - def __init__(self, callback, blocking=False): - self.blocking = blocking - self.callback = callback - self.listening = True - - def run(self): - """ 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), - self.handler, - None) - loopsource = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0) - loop = Quartz.CFRunLoopGetCurrent() - Quartz.CFRunLoopAddSource(loop, loopsource, Quartz.kCFRunLoopDefaultMode) - Quartz.CGEventTapEnable(self.tap, True) - - while self.listening: - Quartz.CFRunLoopRunInMode(Quartz.kCFRunLoopDefaultMode, 5, False) - - def handler(self, proxy, e_type, event, refcon): - # TODO Separate event types by button/wheel/move - scan_code = Quartz.CGEventGetIntegerValueField(event, Quartz.kCGKeyboardEventKeycode) - key_name = name_from_scancode(scan_code) - flags = Quartz.CGEventGetFlags(event) - event_type = "" - is_keypad = (flags & Quartz.kCGEventFlagMaskNumericPad) - if e_type == Quartz.kCGEventKeyDown: - event_type = "down" - elif e_type == Quartz.kCGEventKeyUp: - event_type = "up" - - if self.blocking: - return None - - self.callback(KeyboardEvent(event_type, scan_code, name=key_name, is_keypad=is_keypad)) - return event - -# Exports - -def init(): - """ Initializes mouse state """ - pass - -def listen(queue): - """ 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)) - 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 """ - location = get_position() - button_code, button_down, _, _ = _button_mapping[button] - 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: - # Repeated Click - _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"]) - 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 """ - 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: - # Repeated Click - Quartz.CGEventSetIntegerValueField( - 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. """ - location = get_position() - e = Quartz.CGEventCreateMouseEvent( - 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. """ - for b in _button_state: - if _button_state[b]: - e = Quartz.CGEventCreateMouseEvent( - None, - _button_mapping[b][3], # Drag Event - (x, y), - _button_mapping[b][0]) - break - else: - e = Quartz.CGEventCreateMouseEvent( - 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). """ - e = Quartz.CGEventCreate(None) - point = Quartz.CGEventGetLocation(e) - return (point.x, point.y) \ No newline at end of file diff --git a/directkeys/_keyboard_event.py b/directkeys/_keyboard_event.py deleted file mode 100644 index 6da4fbaa..00000000 --- a/directkeys/_keyboard_event.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- 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): - event_type = None - scan_code = None - 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 - self.device = device - self.is_keypad = is_keypad - self.modifiers = modifiers - self.flags = flags - if name: - 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'] - 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) - - 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 - ) - ) diff --git a/directkeys/_mouse_event.py b/directkeys/_mouse_event.py deleted file mode 100644 index 38b89610..00000000 --- a/directkeys/_mouse_event.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from collections import namedtuple - -LEFT = 'left' -RIGHT = 'right' -MIDDLE = 'middle' -WHEEL = 'wheel' -X = 'x' -X2 = 'x2' - -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']) diff --git a/examples/10_second_macro.py b/examples/10_second_macro.py index 4e765dfe..f157d48c 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/customizable_hotkey.py b/examples/customizable_hotkey.py index 8e7e7ff1..eee7f7c9 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 da78ffef..c69fb306 100644 --- a/examples/pressed_keys.py +++ b/examples/pressed_keys.py @@ -2,15 +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() \ 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 d77aa5d9..be2cf95b 100644 --- a/examples/push_to_talk_ubuntu.py +++ b/examples/push_to_talk_ubuntu.py @@ -1,27 +1,31 @@ -#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(): - 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 \ No newline at end of file + directkeys.wait() # wait forever diff --git a/examples/segmented_macro.py b/examples/segmented_macro.py index 1477d9ef..9bb8eb24 100644 --- a/examples/segmented_macro.py +++ b/examples/segmented_macro.py @@ -5,27 +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('../') -import directkeys -import pickle + +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('Press F1 to play segment {}/{}'.format(i+1, len(segments))) - print('Duration: {:.02} seconds'.format(segment[-1].time - segment[0].time)) - 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 = [] @@ -33,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('Saved {} segments to {}'.format(len(segments), filename)) \ No newline at end of file + 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 56b460d5..a481f29d 100644 --- a/examples/simulate_held_down.py +++ b/examples/simulate_held_down.py @@ -1,9 +1,10 @@ -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): - 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 1acfe961..b67c812d 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`. -from directkeys import __main__ \ No newline at end of file diff --git a/examples/write.py b/examples/write.py index ba3a3be0..9b59570f 100644 --- a/examples/write.py +++ b/examples/write.py @@ -2,10 +2,13 @@ Given text files or text from stdin, simulates keyboard events that type the text character-by-character. """ + import sys -sys.path.append('../') -import directkeys + +sys.path.append("../") 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 36fb2a7c..2bfe135f 100644 --- a/make_release.py +++ b/make_release.py @@ -22,60 +22,66 @@ - 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) +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.' +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', 'v{}..HEAD'.format(last_version), '--oneline'], universal_newlines=True) -with open('message.txt', 'w') as message_file: - atexit.register(lambda: os.remove('message.txt')) +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)) - for line in commits.strip().split('\n'): - message_file.write('# {}\n'.format(line)) + 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(["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('# {}\n\n{}\n\n\n{}'.format(directkeys.version, message, 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', 'Update changes for {}'.format(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 = 'https://api.github.com/repos/{}/releases'.format(repo_path) + 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, @@ -85,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 4bf5502d..55c2dc9a 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", @@ -36,9 +35,99 @@ 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" } +# 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"] +# 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" + +[tool.ruff] +target-version = "py39" +line-length = 100 +# 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 = [ + "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/setup.py b/setup.py index 45c4a55b..af4830cd 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ python -m build """ + from setuptools import setup setup() diff --git a/directkeys/__init__.py b/src/directkeys/__init__.py similarity index 84% rename from directkeys/__init__.py rename to src/directkeys/__init__.py index 20111b6a..bf894dcb 100644 --- a/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ directkeys ========== @@ -13,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+ç`). @@ -53,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 @@ -209,53 +230,66 @@ 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 -import re as _re -import itertools as _itertools import collections as _collections -from threading import Thread as _Thread, Lock as _Lock +import itertools as _itertools +import queue as _queue +import re as _re 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 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) + +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: if _UninterruptibleEvent.wait(self, 0.5): 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 +# Load the base dependencies before the backend, to avoid a circular import. # Import the platform specific backend. import platform as _platform -if _platform.system() == 'Windows': + +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': +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: @@ -263,7 +297,8 @@ 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): """ @@ -283,13 +318,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 +337,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. @@ -325,17 +369,20 @@ 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 + _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 +393,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 +436,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 +447,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,12 +472,13 @@ 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: 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]: @@ -444,8 +490,8 @@ def direct_callback(self, event): if self.blocking_hotkeys: if self.filtered_modifiers[scan_code]: - origin = 'modifier' - modifiers_to_update = set([scan_code]) + origin = "modifier" + modifiers_to_update = {scan_code} else: modifiers_to_update = self.active_modifiers if is_modifier(scan_code): @@ -453,15 +499,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 +526,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 +539,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('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 + def parse_hotkey(hotkey): """ Parses a user-provided hotkey into nested tuples representing the @@ -538,11 +593,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 +629,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,9 +661,11 @@ 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 + # Convert _pressed_events into a set with _pressed_events_lock: pressed_scan_codes = set(_pressed_events) for scan_codes in steps[0]: @@ -611,6 +673,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,12 +683,15 @@ 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` each time a key is pressed or released. - + The event passed to the callback is of type `directkeys.KeyboardEvent`, with the following attributes: @@ -644,26 +710,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 +753,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 +798,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 +845,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'). @@ -764,7 +853,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: @@ -778,9 +867,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 +924,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 +949,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: @@ -868,7 +970,6 @@ def catch_misses(event, force_fail=False): release(event.scan_code) del state.suppressed_events[:] - index = 0 set_index(0) return True @@ -886,35 +987,36 @@ 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() 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: 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() @@ -922,20 +1024,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, @@ -945,8 +1054,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 @@ -956,17 +1068,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 @@ -979,6 +1098,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 @@ -996,11 +1116,13 @@ 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))) + 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): """ @@ -1021,18 +1143,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: @@ -1041,7 +1164,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) @@ -1057,6 +1180,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, @@ -1064,13 +1188,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 @@ -1094,13 +1221,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 = 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. - 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): """ @@ -1113,6 +1241,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, @@ -1121,6 +1250,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 @@ -1143,6 +1273,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. @@ -1160,36 +1291,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 @@ -1202,6 +1336,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 @@ -1214,7 +1349,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 @@ -1228,6 +1364,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 @@ -1249,10 +1386,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, @@ -1278,39 +1419,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() - 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 + def remove_word_listener(word_or_handler): """ Removes a previously registered word listener. Accepts either the word used @@ -1319,6 +1461,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 @@ -1333,13 +1476,14 @@ 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 + 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/directkeys/__main__.py b/src/directkeys/__main__.py similarity index 74% rename from directkeys/__main__.py rename to src/directkeys/__main__.py index 4753eeff..66650f84 100644 --- a/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -1,13 +1,16 @@ -# -*- coding: utf-8 -*- -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')) + 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/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py similarity index 90% rename from directkeys/_canonical_names.py rename to src/directkeys/_canonical_names.py index 003fd3b8..4047bb98 100644 --- a/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -1,90 +1,72 @@ -# -*- 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) 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 @@ -947,7 +929,7 @@ "questiondown": "¿", "questiondownsmall": "", "questionsmall": "", - "quotedbl": "\"", + "quotedbl": '"', "quotedblbase": "„", "quotedblleft": "“", "quotedblright": "”", @@ -1207,40 +1189,49 @@ "Zeta": "Ζ", "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) +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): """ 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): - raise ValueError('Can only normalize non-empty string names. Unexpected '+ repr(name)) + if not name or not isinstance(name, str): + 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/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py similarity index 56% rename from directkeys/_darwinkeyboard.py rename to src/directkeys/_darwinkeyboard.py index ef848d7b..4fdfb0df 100644 --- a/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -1,74 +1,74 @@ import ctypes import ctypes.util -import Quartz import time -import os -import threading -from AppKit import NSEvent -from directkeys._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): - 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. - 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()) +import Quartz +from AppKit import NSEvent + +from ._canonical_names import normalize_name +from ._keyboard_event import KeyboardEvent + +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() + } 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 @@ -86,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 @@ -98,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 @@ -112,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 @@ -131,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 @@ -142,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 = u''.join(unichr(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 = u''.join(unichr(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, []) @@ -182,25 +191,25 @@ 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']) - raise ValueError("Unrecognized character: {}".format(character)) + 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: # Invalid vk - raise ValueError("Invalid scan code: {}".format(vk)) + raise ValueError(f"Invalid scan code: {vk}") -class KeyController(object): +class KeyController: def __init__(self): self.key_map = KeyMap() self.current_modifiers = { @@ -211,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 @@ -285,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 @@ -333,19 +342,21 @@ 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(object): + +class KeyEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking self.callback = callback @@ -355,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) @@ -379,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: @@ -393,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: @@ -425,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.CGEventPost(Quartz.kCGSessionEventTap, event) \ No newline at end of file + 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 new file mode 100644 index 00000000..e86e65ff --- /dev/null +++ b/src/directkeys/_darwinmouse.py @@ -0,0 +1,186 @@ +import datetime +import os +import threading + +import Quartz + +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} + + +class MouseEventListener: + def __init__(self, callback, blocking=False): + self.blocking = blocking + self.callback = callback + self.listening = True + + def run(self): + """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), + self.handler, + None, + ) + loopsource = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0) + loop = Quartz.CFRunLoopGetCurrent() + Quartz.CFRunLoopAddSource(loop, loopsource, Quartz.kCFRunLoopDefaultMode) + Quartz.CGEventTapEnable(self.tap, True) + + while self.listening: + Quartz.CFRunLoopRunInMode(Quartz.kCFRunLoopDefaultMode, 5, False) + + def handler(self, proxy, e_type, event, refcon): + # TODO Separate event types by button/wheel/move + scan_code = Quartz.CGEventGetIntegerValueField(event, Quartz.kCGKeyboardEventKeycode) + key_name = name_from_scancode(scan_code) + flags = Quartz.CGEventGetFlags(event) + event_type = "" + is_keypad = flags & Quartz.kCGEventFlagMaskNumericPad + if e_type == Quartz.kCGEventKeyDown: + event_type = "down" + elif e_type == Quartz.kCGEventKeyUp: + event_type = "up" + + if self.blocking: + return None + + self.callback(KeyboardEvent(event_type, scan_code, name=key_name, is_keypad=is_keypad)) + return event + + +# Exports + + +def init(): + """Initializes mouse state""" + pass + + +def listen(queue): + """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) + ) + 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""" + location = get_position() + button_code, button_down, _, _ = _button_mapping[button] + 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 + ): + # Repeated Click + _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"] + ) + 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""" + 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 + ): + # Repeated Click + Quartz.CGEventSetIntegerValueField( + 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.""" + location = get_position() + e = Quartz.CGEventCreateMouseEvent( + 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.""" + for b in _button_state: + if _button_state[b]: + e = Quartz.CGEventCreateMouseEvent( + None, + _button_mapping[b][3], # Drag Event + (x, y), + _button_mapping[b][0], + ) + break + else: + e = Quartz.CGEventCreateMouseEvent( + 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).""" + e = Quartz.CGEventCreate(None) + point = Quartz.CGEventGetLocation(e) + return (point.x, point.y) diff --git a/directkeys/_generic.py b/src/directkeys/_generic.py similarity index 86% rename from directkeys/_generic.py rename to src/directkeys/_generic.py index bac559f7..c2141d0d 100644 --- a/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -1,14 +1,13 @@ -# -*- coding: utf-8 -*- -from threading import Thread, Lock import traceback -import functools +from threading import Lock, Thread try: from queue import Queue except ImportError: from Queue import Queue -class GenericListener(object): + +class GenericListener: lock = Lock() def __init__(self): @@ -22,7 +21,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): @@ -46,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): """ @@ -58,7 +57,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 @@ -68,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 new file mode 100644 index 00000000..00f2d000 --- /dev/null +++ b/src/directkeys/_keyboard_event.py @@ -0,0 +1,69 @@ +import json +from time import time as now + +from ._canonical_names import normalize_name + +KEY_DOWN = "down" +KEY_UP = "up" + + +class KeyboardEvent: + event_type = None + scan_code = None + 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 + self.device = device + self.is_keypad = is_keypad + self.modifiers = modifiers + self.flags = flags + if name: + self.name = normalize_name(name) + + 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("_") + } + 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 + ) + + 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) + ) diff --git a/src/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py new file mode 100644 index 00000000..da9ed5e5 --- /dev/null +++ b/src/directkeys/_mouse_event.py @@ -0,0 +1,19 @@ +from collections import namedtuple + +LEFT = "left" +RIGHT = "right" +MIDDLE = "middle" +WHEEL = "wheel" +X = "x" +X2 = "x2" + +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"]) diff --git a/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py similarity index 75% rename from directkeys/_nixcommon.py rename to src/directkeys/_nixcommon.py index ba3a1d05..ff9d18b6 100644 --- a/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -1,16 +1,16 @@ -# -*- coding: utf-8 -*- -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: 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,14 +20,16 @@ EV_ABS = 0x03 EV_MSC = 0x04 + def make_uinput(): - if not os.path.exists('/dev/uinput'): - raise IOError('No uinput module found.') + if not os.path.exists("/dev/uinput"): + 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') + uinput = open("/dev/uinput", "wb") UI_SET_EVBIT = 0x40045564 fcntl.ioctl(uinput, UI_SET_EVBIT, EV_KEY) @@ -39,16 +41,17 @@ 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(object): + +class EventDevice: def __init__(self, path): self.path = path self._input_file = None @@ -58,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') - except IOError 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)) + 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." + ) exit() def try_close(): @@ -69,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 @@ -96,14 +102,17 @@ 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 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 @@ -115,27 +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 @@ -143,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 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) + + 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 @@ -160,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/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py similarity index 59% rename from directkeys/_nixkeyboard.py rename to src/directkeys/_nixkeyboard.py index 8376468b..6bffce8a 100644 --- a/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -1,113 +1,117 @@ -# -*- coding: utf-8 -*- -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 # 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('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) 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) @@ -115,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() @@ -137,12 +148,14 @@ 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: if event_type == KEY_DOWN: pressed_modifiers.add(name) @@ -150,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) @@ -185,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/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py similarity index 74% rename from directkeys/_nixmouse.py rename to src/directkeys/_nixmouse.py index 24060502..1898bad5 100644 --- a/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -1,21 +1,21 @@ -# -*- coding: utf-8 -*- -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 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() @@ -24,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 @@ -65,17 +76,23 @@ 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() 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 @@ -83,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) @@ -99,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. @@ -117,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: @@ -124,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/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py similarity index 61% rename from directkeys/_winkeyboard.py rename to src/directkeys/_winkeyboard.py index 7f8628cd..ab6b6c31 100644 --- a/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,43 +9,61 @@ - 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 time import traceback -from threading import Lock from collections import defaultdict -import time +from threading import Lock -from directkeys._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 -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". 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) -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 @@ -57,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 @@ -151,7 +176,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] @@ -161,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 @@ -177,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() @@ -345,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 @@ -389,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: @@ -424,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. @@ -434,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. @@ -453,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. @@ -472,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 @@ -517,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 @@ -542,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 @@ -580,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) @@ -590,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 as e: - print('Error in keyboard hook:') + except Exception: + 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) @@ -627,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 @@ -642,6 +703,7 @@ def rebuild_name_tables(): _clear_name_tables() _setup_name_tables() + def listen(callback): prepare_intercept(callback) msg = LPMSG() @@ -649,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('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): if code == 541: # Alt-gr is made of ctrl+alt. Just sending even 541 doesn't do anything. @@ -672,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) @@ -697,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. @@ -722,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 @@ -732,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. @@ -746,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/directkeys/_winmouse.py b/src/directkeys/_winmouse.py similarity index 82% rename from directkeys/_winmouse.py rename to src/directkeys/_winmouse.py index ef16dd42..41b963a8 100644 --- a/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -1,35 +1,61 @@ -# -*- 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 -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 +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 @@ -79,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), @@ -109,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, } @@ -129,6 +148,7 @@ class MSLLHOOKSTRUCT(Structure): init = lambda: None + def listen(queue): def low_level_mouse_handler(nCode, wParam, lParam): struct = lParam.contents @@ -138,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) @@ -161,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/directkeys/mouse.py b/src/directkeys/mouse.py similarity index 77% rename from directkeys/mouse.py rename to src/directkeys/mouse.py index 41bf3152..75f88188 100644 --- a/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,27 +1,46 @@ -# -*- 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) -import time as _time +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 -if _platform.system() == 'Windows': - from. import _winmouse as _os_mouse -elif _platform.system() == 'Linux': - from. import _nixmouse as _os_mouse -elif _platform.system() == 'Darwin': - from. import _darwinmouse as _os_mouse +import time as _time + +if _platform.system() == "Windows": + from . import _winmouse as _os_mouse +elif _platform.system() == "Linux": + from . import _nixmouse as _os_mouse +elif _platform.system() == "Darwin": + 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): def init(self): _os_mouse.init() + def pre_process_event(self, event): if isinstance(event, ButtonEvent): if event.event_type in (UP, DOUBLE): @@ -33,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 @@ -94,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 @@ -113,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)): @@ -124,60 +154,71 @@ 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` 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) 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 @@ -185,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. @@ -199,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 @@ -224,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/manual/altgr_and_stuck_keys.py b/tests/manual/altgr_and_stuck_keys.py index 876bb1a4..e390e554 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 4920a9d3..4b98674b 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) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 39539185..13211dc8 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,100 +11,105 @@ `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 +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, [])], - - '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 @@ -114,7 +118,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) @@ -123,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, u'á \'"', 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) @@ -278,245 +344,328 @@ 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) - def test_write_multiple(self): - directkeys.write('ab', exact=False) - self.do([], d_a+u_a+d_b+u_b) + 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) + 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(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() - 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) + # 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) 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() @@ -526,70 +675,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(self): - directkeys.remove_hotkey(directkeys.add_hotkey('a', trigger, suppress=True)) + + 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): - 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, {}) @@ -597,8 +765,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, {}) @@ -606,9 +775,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, {}) @@ -616,212 +786,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) - def test_add_hotkey_single_step_suppress_with_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, 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, + ) + 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(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) + 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,)], []) 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__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 136a011d..27b0b516 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -1,11 +1,23 @@ -# -*- coding: utf-8 -*- -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 - -class FakeOsMouse(object): +from directkeys._mouse_event import ( + DOUBLE, + DOWN, + LEFT, + MIDDLE, + RIGHT, + UP, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) + + +class FakeOsMouse: def __init__(self): self.append = None self.position = (0, 0) @@ -26,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 @@ -147,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: @@ -158,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) @@ -169,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)) @@ -177,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)])) @@ -196,23 +217,29 @@ 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(): mouse.wait() lock.release() + Thread(target=t).start() self.press() lock.acquire() def test_record_play(self): - from threading import Thread, Lock + 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) @@ -232,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) @@ -241,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) @@ -264,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()