diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8acb0da3..b4451a18 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,11 +15,27 @@ Important misc changes - Update action versions in build.yaml to latest. - Update Qt/GTK "Run" button in interface to run on F5 - Update two links in the **README.rst** file. +- Adds GNOME Window Extension for interacting with Windows on x11/wayland Features --------- Create a GUI-free headless entrypoint to autokey, which can be run without GUI libraries and controlled purely via scripting API +Allows the distinction between left and right modifier keys for ``[Key.CONTROL, Key.ALT, Key.SUPER, Key.SHIFT, Key.HYPER, Key.META]``. + +At this time you cannot "mix and match", IE if you have a ``Key.CONTROL`` and ``Key.ALT`` as the hotkeys it will check for; +``Key.LEFTCONTROL, Key.LEFTALT`` +and +``Key.RIGHTCONTROL, Key.RIGHTALT`` + +But not for; +``Key.LEFTCONTROL, Key.RIGHTALT`` +``Key.RIGHTCONTROL, Key.RIGHTALT`` + +This is considered a breaking change, prior it would, in effect, check for all of those scenarios. + +Currently the left/right modifiers GUI option is only accessible via the GTK interface, but they should be respected if you manually update your config files. + Bug fixes --------- @@ -88,7 +104,7 @@ Scripting API - Deprecated: Confusingly named engine.create_abbreviation() and engine.create_hotkey() are deprecated and will be removed in the future. Use engine.create_phrase() with appropriate arguments instead. - Extended: engine.create_phrase() now supports multiple new optional arguments, allowing to fully configure the created phrase. It can set everything the GUI can do. -- New: Scripts can use engine.get_triggered_abbreviation() to read which abbreviation triggered it’s execution. +- New: Scripts can use engine.get_triggered_abbreviation() to read which abbreviation triggered it's execution. The function returns a tuple containing the abbreviation and the trigger character (the character that 'completed' or 'confirmed' the abbreviation. Both tuple elements are None if the script was not triggered by an abbreviation. The trigger character is None if the script was configured to 'trigger immediately'. The function always returns a tuple, so direct tuple unpacking like abbreviation, trigger = engine.get_triggered_abbreviation() will always work. - Allow creation of 'temporary' hotkeys and whole folders (which do not persist between sessions). - Allow overriding existing hotkeys when creating phrases with hotkeys. @@ -97,7 +113,7 @@ Scripting API **keyboard API object** - keyboard.send_keys() got a new optional parameter send_mode, allowing to specify how the given text is sent. It basically offers the same pasting options as are available to AutoKey Phrases. -- keyboard.send_keys() now raises a TypeError instead of a generic AssertionError, if parameters don’t match the expected types. +- keyboard.send_keys() now raises a TypeError instead of a generic AssertionError, if parameters don't match the expected types. **New clipboard API method** - Change the default phrase send mode to `ctrl+v` (paste using clipboard) rather than sending keys one at a time. @@ -115,12 +131,12 @@ Scripting API Command line interface -++++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^^^ - Added a --version command line switch. It prints the current AutoKey version on the standard output and then exits. Graphical user interfaces -+++++++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^^^^^^ - (GTK) Warn user about missing required and optional programs on startup. - (GTK) UI will now update when changes are detected to watched files. @@ -129,7 +145,7 @@ Graphical user interfaces - Add setting to change GtkSourceView theme, (defaults to classic). Other -+++++ +^^^^^ - Add `wait_for_keyevent` scripting function. - Rewrote script error logging system, with a neat Script Error Dialog to go with it. @@ -149,20 +165,20 @@ Bug fixes - Both QT and GTK versions will reload hotkeys after a keymap change event. - Fix locking issue - Expose Alt_GR as a hotkey modifier on GTK. -- (GTK) Fixed GUI lock-up, if multiple script error notifications are posted in quick succession. The notifications are now rate-limited and won’t post more than one notification per second. Fixes issue #383 +- (GTK) Fixed GUI lock-up, if multiple script error notifications are posted in quick succession. The notifications are now rate-limited and won't post more than one notification per second. Fixes issue #383 Scripting API -+++++++++++++ +^^^^^^^^^^^^^ - Fixed API call `system.exec_command()` crashing, if output capturing is active, but the executed command has empty output. Fixes issue #379 Packaging -+++++++++ +^^^^^^^^^ - Fixed AutoKey PNG icon size. Now, the icon size is 96x96 pixels, fixing Lintian warnings on Debian. Fixes issue #369 Other changes ---------- +------------- - Add CI for testing - Update pip installation requirements @@ -277,7 +293,7 @@ Bug fixes - Force AutoKey to exit, if the X server connection closes, most probably at logout or session end. Fixes issue #198 Qt tray icon fixes and improvements -+++++++++++++++++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Added »View script error« entry to the Tray icon context menu, like in the GTK GUI. Part of issue #158 - Tray icon turns red, when scripts raise an error, like in the GTK GUI. Part of issue #158 @@ -384,28 +400,28 @@ Resurrected, re-written and cleaned up the `autokey-qt` Qt GUI. `autokey-qt` is dependent on currently supported libraries. Added improvements -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ - The main window now keeps its complete state when closed and re-opened (excluding complete application restarts). This includes the currently selected item(s) in the tree view on the left of the main window, selected text and cursor position in the editor on the right if currently editing a script or phrase. - The entries in the popup menu, that is shown when a hotkey assigned to a folder is pressed, now show icons based on their type (folder, phrase or script). This also works when items are configured to be shown in the system tray icon context menu. - The *A* autokey application icons are now always displayed correctly, both in the main window and the system tray icon. -- Various menu actions now have system dependent keyboard shortcuts, that should adjust to the expected default of the user’s current platform/desktop environment. +- Various menu actions now have system dependent keyboard shortcuts, that should adjust to the expected default of the user's current platform/desktop environment. - Added icons and descriptive tooltip texts to various buttons. - The `enable monitoring` checkboxes (both in the `Settings` menu and the tray icon context menu) now properly react to pressing the global hotkey for this action and thus stay in sync. (Even if the hotkey is used while the menu is shown.) Regressions -+++++++++++ +^^^^^^^^^^^ - Customizing the main window toolbar entries and keyboard shortcuts to trigger various UI actions is no longer possible. This feature was provided by the KDE4 libraries and is currently dropped. - The previous, KDE4-based About dialogue is replaced with a very minimalistic one. - The settings dialogue heavily used the KDE4 functionalities. During the port to Qt5, the dialogue lost some visual style, but all core functionality is kept. Runtime dependencies -++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^ - Removed dependencies on deprecated and unmaintained PyQt4 and PyKDE4 libraries. - Removed dependency on `dbus.mainloop.qt`, instead use the DBus support built into Qt5. - Now depend on PyQt5, the Qt5 SVG module and the Qt5 QScintilla2 module. Build-time dependencies -+++++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^^^^ Optionally depend on `pyrcc5` command line tool to compile Qt resources into a Python module. Qt UI files are no longer compiled using `pykdeuic4`, Removed the old compiler wrapper script in commit 6eeeb92f_. @@ -413,11 +429,11 @@ Qt UI files are no longer compiled using `pykdeuic4`, Removed the old compiler w .. _6eeeb92f: https://github.com/autokey/autokey/commit/6eeeb92f14c694979c1367d51350c1e6509329b1 Known bugs -++++++++++ +^^^^^^^^^^ The system tray icon is shown, but non-functional, after enabling it in the settings dialogue. AutoKey Qt has to be restarted for the tray icon to start working. This should have no impact on the normal daily use. Changed features -++++++++++++++++ +^^^^^^^^^^^^^^^^ The `hide tray icon` entry in the tray icon context menu now hides the icon for the current session only. The entry does not permanently disable the tray icon any more without any confirmation. Now, the only way to permanently disable the tray icon is through using the appropriate setting in the settings dialogue. Fixed the broken `Clipboard` and `Mouse selection` phrase paste modes @@ -431,20 +447,20 @@ Scripting API Changes --------------------- Additions -+++++++++ +^^^^^^^^^ - Added a colour picker dialogue to the GTK dialog class, because the used `zenity` now supports it. - The picked colour is returned as three integers using the ColourData NamedTuple, providing both index based access and attribute access, using the channel names (`r`, `g`, `b`). Additionally, ColourData provides some conversion methods. Breaking changes -++++++++++++++++ +^^^^^^^^^^^^^^^^ - See Pull request `#148`_. The `dialog` classes for user input in scripts now return typed NamedTuple tuples instead of plain tuples. This change is safe as long as users do not perform needlessly restrictive type checks in their scripts (e.g. `if type(returned_data) == type(tuple()): ...`). User scripts doing so will break. - The KDialog based colour picker now also returns a ColourData instance instead of a HTML style hex string, thus making this portable between both GTK and Qt GUIs. AutoKey users previously using the old KDE GUI and using the colour picker dialogue have to port their scripts. A simple fix is using the `html_code` property of the returned ColourData instance. .. _`#148`: https://github.com/autokey/autokey/pull/148 Fixes -+++++ +^^^^^ - Re-introduce the newline trimming for system.exec_command() function. During the porting to Python 3, the newline trimming was removed, causing users various issues with unexpected newline characters at end of output. Now properly remove the _last_ newline at end of command output. (See issues `#75`_, `#92`_, `#145`_) - Applied various code style improvements to the scripting module. @@ -537,18 +553,18 @@ Internal changes: Changed the data structure of the input stack. Version 0.93.0 <2014-02-27 Thu> =============================== -Added functions “acknowledge_gnome_notification” and “move_to_pat”, more details `here`_. +Added functions "acknowledge_gnome_notification" and "move_to_pat", more details `here`_. .. _here: https://github.com/autokey/autokey/blob/master/new_features.rst Version 0.92.0 <2014-02-21 Fri> =============================== -Added an interactive shell launcher, “autokey-shell”. “autokey-shell” allows you to run some AutoKey functions interactively. Read `this`_ for more details. +Added an interactive shell launcher, "autokey-shell". "autokey-shell" allows you to run some AutoKey functions interactively. Read `this`_ for more details. Version 0.91.0 <2014-02-14 Fri> =============================== -Added a new function “click_on_pat” for use in user scripts. See `this`_ for more details. +Added a new function "click_on_pat" for use in user scripts. See `this`_ for more details. .. _this: https://github.com/autokey/autokey/blob/master/new_features.rst @@ -562,21 +578,21 @@ Python 3 related changes Python 3 is less tolerant of circular imports so some files were split into several files. Those pieces of the original have their file names prefixed with the original's. Bug fixes -+++++++++ +^^^^^^^^^ Eliminate possible deadlock. Changed .. code-block:: python - p = subprocess.Popen([…], stdout=subprocess.PIPE) + p = subprocess.Popen([...], stdout=subprocess.PIPE) retCode = p.wait() output = p.stdout.read()[:-1] # Drop trailing newline to -.. code:: python +.. code-block:: python - p = subprocess.Popen([…], stdout=subprocess.PIPE) + p = subprocess.Popen([...], stdout=subprocess.PIPE) output = p.communicate()[0].decode()[:-1] # Drop trailing newline retCode = p.returncode @@ -584,7 +600,7 @@ The former may cause a deadlock, for more information, see `Python docs`_. This .. _Python docs: http://docs.python.org/3/library/subprocess.html#subprocess.Popen.wait -For a “gi.repository.Notify.Notification” object, test if method “attach_to_status_icon” exists before calling. After this fix, errors in user scripts will trigger a notification. +For a "gi.repository.Notify.Notification" object, test if method "attach_to_status_icon" exists before calling. After this fix, errors in user scripts will trigger a notification. Respect XDG standard. Details `here`__. @@ -595,13 +611,13 @@ Corrected a typo in manpage of autokey-run. For the GTK GUI, after script error is viewed, tray icon is reverted back to original. Other changes -+++++++++++++ -In setup.py, the “/usr/” prefix to the directory names in the data_files argument were removed to allow for non-root install. +^^^^^^^^^^^^^ +In setup.py, the "/usr/" prefix to the directory names in the data_files argument were removed to allow for non-root install. -Removed the “WINDOWID” environment variable so that zenity is not tied to the window from which it was launched. +Removed the "WINDOWID" environment variable so that zenity is not tied to the window from which it was launched. -Modified the launcher and other files to allow for editable installs (“pip install -e”). +Modified the launcher and other files to allow for editable installs ("pip install -e"). -Added an “about” dialog for the Python 3 port. +Added an "about" dialog for the Python 3 port. Changed hyperlink for bug reports. diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index b0b3dfc9..1fbd4cfc 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -7,6 +7,7 @@ Please add a line to CHANGELOG.rst when creating PRs Please make sure tests pass before you submit PRs. To ensure this happens automatically, I recommend adding the following lines to the file `.git/hooks/pre-push`: .. code:: sh + remote="$1" url="$2" diff --git a/lib/autokey/UI_common_functions.py b/lib/autokey/UI_common_functions.py index 401a5533..97681df9 100644 --- a/lib/autokey/UI_common_functions.py +++ b/lib/autokey/UI_common_functions.py @@ -6,6 +6,7 @@ import subprocess import sys import time +import os from . import common import autokey.model.helpers @@ -24,13 +25,52 @@ qt_modules = ['PyQt5', 'PyQt5.QtGui', 'PyQt5.QtWidgets', 'PyQt5.QtCore', 'PyQt5.Qsci'] -common_programs = ['wmctrl', 'ps', 'xrandr'] +# wmctrl, xrandr are x11 specific programs. +x11_programs = ['wmctrl', 'xrandr'] +common_programs = ['ps'] # Checking some of these appears to be redundant as some are provided by the same packages on my system but # better safe than sorry. -optional_programs = ['visgrep', 'import', 'png2pat', 'xte', 'xmousepos'] +x11_optional_programs = ['xte', 'xmousepos'] +optional_programs = ['visgrep', 'import', 'png2pat'] gtk_programs = ['zenity'] qt_programs = ['kdialog'] +def checkGnomeAutokeyExtension(): + bus_name = "org.gnome.Shell" + object_path = "/org/gnome/Shell/Extensions/AutoKey" + interface_name = "org.gnome.Shell.Extensions.AutoKey" + check_dbus_object_exists(bus_name, object_path, interface_name) + pass + + +def check_dbus_object_exists(bus_name, object_path, interface_name): + #keep dbus import here + import dbus + try: + # Connect to the D-Bus session bus + bus = dbus.SessionBus() + + # Get a reference to the service and object + obj = bus.get_object(bus_name, object_path) + + # Get a reference to the desired interface + interface = dbus.Interface(obj, interface_name) + + # Call a method on the object (e.g., 'Get') and check if it returns a valid result + interface.List() # Replace 'property_name' with an actual property name + + # If the method call was successful, the object exists + return True + + except dbus.exceptions.DBusException as e: + # Handle the exception and return False if the object does not exist + if e.get_dbus_name() == 'org.freedesktop.DBus.Error.UnknownObject': + return False + else: + # If the exception is not related to the unknown object, re-raise it + raise + + def checkModuleImports(modules): missing_modules = [] for module in modules: @@ -57,6 +97,9 @@ def checkProgramImports(programs, optional=False): return missing_programs def checkOptionalPrograms(): + if os.environ.get("XDG_SESSION_TYPE") == "x11": + checkProgramImports(x11_optional_programs, optional=True) + if common.USED_UI_TYPE == "QT": checkProgramImports(optional_programs, optional=True) elif common.USED_UI_TYPE == "GTK": @@ -74,6 +117,10 @@ def getErrorMessage(item_type, missing_items): def checkRequirements(): errorMessage = "" + + if os.environ.get("XDG_SESSION_TYPE") == "x11": + missing_programs = checkProgramImports(x11_programs) + if common.USED_UI_TYPE == "QT": missing_programs = checkProgramImports(common_programs+qt_programs) missing_modules = checkModuleImports(common_modules+qt_modules) diff --git a/lib/autokey/autokey_app.py b/lib/autokey/autokey_app.py index 06dd6bd5..a1ef7b9b 100644 --- a/lib/autokey/autokey_app.py +++ b/lib/autokey/autokey_app.py @@ -24,7 +24,9 @@ import dbus.mainloop.glib import signal import subprocess +import hashlib from typing import NamedTuple, Iterable +import re import autokey.model.script from autokey import common @@ -112,8 +114,61 @@ def __initialise(self): self.__add_user_code_dir_to_path() self.__create_DBus_service() self.__register_ctrlc_handler() + + # process command line commands here? + try: + self.usage_statistics() + except Exception as e: + logger.error(f"Usage statistics failure: {e}") + logger.info("Autokey application services ready") + def usage_statistics(self): + def get_digest(value): + return hashlib.md5(str(value).encode()).hexdigest()[0:8] + + logger.info("----- AutoKey Usage Statistics -----") + for item in self.configManager.allItems: + if type(item) is autokey.model.phrase.Phrase: + # logger.info(item.description, item.usageCount, item.phrase) + logger.info(f"Phrase: {get_digest(item.description)}, Usage Count: {item.usageCount} {self.getMacroUsage(item.phrase)}") + elif type(item) is autokey.model.script.Script: + logger.info(f"Script: {get_digest(item.description)}, Usage Count: {item.usageCount} {self.getAPIUsage(item.code)}") + + for item in self.configManager.allFolders: + + logger.info(f"Folder: {get_digest(item.title)}, Usage Count: {item.usageCount}") + + logger.info("----- AutoKey Usage Statistics -----") + + def getAPIUsage(self, code): + api_modules = ["engine","keyboard","mouse","highlevel","store","dialog","clipboard","system","window"] + + reg = re.compile("("+"|".join(api_modules)+")\.(\w*)\(") + + results = re.findall(reg, code) + + # Create an empty dictionary to store the counts + count_dict = {} + + # Loop through the list and count each item + for item in results: + if item in count_dict: + count_dict[item] += 1 + else: + count_dict[item] = 1 + + return count_dict + + def getMacroUsage(self, phrase): + macros = ["cursor", "script", "system", "date", "file", "clipboard"] + + reg = re.compile("<("+"|".join(macros)+")") + + results = re.findall(reg, phrase) + return results + + def __create_DBus_service(self): logger.info("Creating DBus service") dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) @@ -279,7 +334,7 @@ def autokey_shutdown(self): Shut down the entire application. """ logger.debug("Shutting down service and file monitor...") + self.monitor.stop() self.service.shutdown() self.dbusService.unregister() - self.monitor.stop() logger.debug("Finished shutting down service and file monitor...") diff --git a/lib/autokey/configmanager/configmanager.py b/lib/autokey/configmanager/configmanager.py index c51881d5..aa161b8e 100644 --- a/lib/autokey/configmanager/configmanager.py +++ b/lib/autokey/configmanager/configmanager.py @@ -37,7 +37,7 @@ RECENT_ENTRIES_FOLDER, IS_FIRST_RUN, SERVICE_RUNNING, MENU_TAKES_FOCUS, SHOW_TRAY_ICON, SORT_BY_USAGE_COUNT, \ PROMPT_TO_SAVE, ENABLE_QT4_WORKAROUND, UNDO_USING_BACKSPACE, WINDOW_DEFAULT_SIZE, HPANE_POSITION, COLUMN_WIDTHS, \ SHOW_TOOLBAR, NOTIFICATION_ICON, WORKAROUND_APP_REGEX, TRIGGER_BY_INITIAL, SCRIPT_GLOBALS, INTERFACE_TYPE, \ - DISABLED_MODIFIERS, GTK_THEME, GTK_TREE_VIEW_EXPANDED_ROWS, PATH_LAST_OPEN + DISABLED_MODIFIERS, GTK_THEME, GTK_TREE_VIEW_EXPANDED_ROWS, PATH_LAST_OPEN, KEYBOARD, MOUSE, DEVICES import autokey.configmanager.version_upgrading as version_upgrade import autokey.configmanager.predefined_user_files from autokey.iomediator.constants import X_RECORD_INTERFACE @@ -89,6 +89,10 @@ def _try_persist_settings(config_manager): logger.exception(msg) raise Exception(msg) +def save_files(config_manager): + logger.info("Persisting files") + for item in config_manager.allItems: + item.persist() def save_config(config_manager): logger.info("Persisting configuration") @@ -215,7 +219,10 @@ class ConfigManager: SCRIPT_GLOBALS: {}, GTK_THEME: "classic", GTK_TREE_VIEW_EXPANDED_ROWS: [], - PATH_LAST_OPEN: "0" + PATH_LAST_OPEN: "0", + KEYBOARD: None, + MOUSE: None, + DEVICES: [], } def __init__(self, app): @@ -597,7 +604,7 @@ def config_altered(self, persistGlobal): Called when some element of configuration has been altered, to update the lists of phrases/folders. - @param persistGlobal: save the global configuration at the end of the process + :param persistGlobal: save the global configuration at the end of the process """ logger.info("Configuration changed - rebuilding in-memory structures") @@ -698,9 +705,9 @@ def check_abbreviation_unique(self, abbreviation, filterPattern, targetItem): """ Checks that the given abbreviation is not already in use. - @param abbreviation: the abbreviation to check - @param filterPattern: The filter pattern associated with the abbreviation - @param targetItem: the phrase for which the abbreviation to be used + :param abbreviation: the abbreviation to check + :param filterPattern: The filter pattern associated with the abbreviation + :param targetItem: the phrase for which the abbreviation to be used """ for item in itertools.chain(self.allFolders, self.allItems): if ConfigManager.item_has_abbreviation(item, abbreviation) and \ @@ -750,10 +757,10 @@ def check_hotkey_unique(self, modifiers, hotKey, newFilterPattern, targetItem): Checks that the given hotkey is not already in use. Also checks the special hotkeys configured from the advanced settings dialog. - @param modifiers: modifiers for the hotkey - @param hotKey: the hotkey to check - @param newFilterPattern: - @param targetItem: the phrase for which the hotKey to be used + :param modifiers: modifiers for the hotkey + :param hotKey: the hotkey to check + :param newFilterPattern: + :param targetItem: the phrase for which the hotKey to be used """ item = self.get_item_with_hotkey(modifiers, hotKey, newFilterPattern) if item: @@ -767,9 +774,9 @@ def get_item_with_hotkey(self, modifiers, hotKey, newFilterPattern=None): special hotkeys configured from the advanced settings dialog. Checks folders first, then phrases, then special hotkeys. - @param modifiers: modifiers for the hotkey - @param hotKey: the hotkey to check - @param newFilterPattern: + :param modifiers: modifiers for the hotkey + :param hotKey: the hotkey to check + :param newFilterPattern: """ for item in self.globalHotkeys: if item.enabled and ConfigManager.item_has_same_hotkey(item, diff --git a/lib/autokey/configmanager/configmanager_constants.py b/lib/autokey/configmanager/configmanager_constants.py index 315c482a..7838df33 100644 --- a/lib/autokey/configmanager/configmanager_constants.py +++ b/lib/autokey/configmanager/configmanager_constants.py @@ -51,3 +51,6 @@ GTK_THEME = "gtkTheme" GTK_TREE_VIEW_EXPANDED_ROWS = "gtkExpandedRows" PATH_LAST_OPEN = "pathLastOpen" +KEYBOARD= "keyboard" +MOUSE = "mouse" +DEVICES = "devices" \ No newline at end of file diff --git a/lib/autokey/gnome_interface.py b/lib/autokey/gnome_interface.py new file mode 100644 index 00000000..2499700f --- /dev/null +++ b/lib/autokey/gnome_interface.py @@ -0,0 +1,108 @@ + +import dbus +import json +from dbus.mainloop.glib import DBusGMainLoop + + +from autokey.sys_interface.abstract_interface import AbstractSysInterface, AbstractMouseInterface, AbstractWindowInterface, WindowInfo + +logger = __import__("autokey.logger").logger.get_logger(__name__) + +class DBusInterface: + def __init__(self): + mainloop= DBusGMainLoop() + session_bus = dbus.SessionBus(mainloop=mainloop) + shell_obj = session_bus.get_object('org.gnome.Shell', '/org/gnome/Shell/Extensions/AutoKey') + self.dbus_interface = dbus.Interface(shell_obj, 'org.gnome.Shell.Extensions.AutoKey') + + version = self.dbus_interface.CheckVersion() + logger.debug("AutoKey Gnome Extension version: %s" % version) + if version == "0.1": + pass + else: + raise Exception("Incompatible version of AutoKey Gnome Extension") + + + +class GnomeMouseReadInterface(DBusInterface): + def __init__(self): + super().__init__() + + def mouse_location(self): + [x, y] = self.dbus_interface.GetMouseLocation() + return [int(x), int(y)] + +class GnomeExtensionWindowInterface(DBusInterface, AbstractWindowInterface): + def __init__(self): + super().__init__() + + def get_screen_size(self): + x,y = self.dbus_interface.ScreenSize() + return [int(x), int(y)] + + def get_window_info(self, window=None, traverse: bool=True) -> WindowInfo: + """ + Returns a WindowInfo object containing the class and title. + """ + window = self._active_window() + return WindowInfo(wm_class=window['wm_class'], wm_title=window['wm_title']) + + def get_window_class(self, window=None, traverse=True) -> str: + """ + Returns the window class of the currently focused window. + """ + return self._active_window()['wm_class'] + + + def get_window_title(self, window=None, traverse=True) -> str: + """ + Returns the active window title + """ + return self._active_window()['wm_title'] + + def _active_window(self): + #TODO probably can be done more efficiently with an additional dbus method in the gnome extension + window_list = self._dbus_window_list() + for window in window_list: + if window['focus']: + return window + # TODO seeing this a lot when I use a script to call `gnome-screenshot -a`, suspect it's just related to that focus behaves differently when that app runs? + logger.error("Unable to determine the active window") + return None + + def _dbus_window_list(self): + #TODO consider how/if error handling can be implemented + try: + return json.loads(self.dbus_interface.List()) + except dbus.exceptions.DBusException as e: + self.__init__() #reconnect to dbus + return json.loads(self.dbus_interface.List()) + + def _dbus_close_window(self, window_id): + #TODO consider how/if error handling can be implemented + try: + self.dbus_interface.Close(window_id) + except dbus.exceptions.DBusException as e: + self.__init__() + self.dbus_interface.Close(window_id) + + def _dbus_activate_window(self, window_id): + try: + self.dbus_interface.Activate(window_id) + except dbus.exceptions.DBusException as e: + self.__init__() + self.dbus_interface.Activate(window_id) + + def _dbus_move_window(self, window_id, x, y): + try: + self.dbus_interface.Move(window_id, x, y) + except dbus.exceptions.DBusException as e: + self.__init__() + self.dbus_interface.Move(window_id, x, y) + + def _dbus_resize_window(self, window_id, width, height): + try: + self.dbus_interface.Resize(window_id, width, height) + except dbus.exceptions.DBusException as e: + self.__init__() + self.dbus_interface.Resize(window_id, width, height) \ No newline at end of file diff --git a/lib/autokey/gtkapp.py b/lib/autokey/gtkapp.py index 7379bcb1..8a4605f7 100644 --- a/lib/autokey/gtkapp.py +++ b/lib/autokey/gtkapp.py @@ -23,6 +23,7 @@ import os.path import time import threading +import subprocess import gettext @@ -141,7 +142,7 @@ def notify_error(self, error: autokey.model.script.ScriptErrorRecord): """ Show an error notification popup. - @param error: The error that occurred in a Script + :param error: The error that occurred in a Script """ message = "The script '{}' encountered an error".format(error.script_name) self.notifier.notify_error(message) @@ -177,13 +178,13 @@ def show_error_dialog(self, message, details=None, dialog_type=Gtk.MessageType.E """ Convenience method for showing an error dialog. - @param dialog_type: One of Gtk.MessageType.ERROR, Gtk.MessageType.WARNING , Gtk.MessageType.INFO, Gtk.MessageType.OTHER, Gtk.MessageType.QUESTION + :param dialog_type: One of Gtk.MessageType.ERROR, Gtk.MessageType.WARNING , Gtk.MessageType.INFO, Gtk.MessageType.OTHER, Gtk.MessageType.QUESTION defaults to Gtk.MessageType.ERROR """ - # TODO check if gtk threads entered. - # Gdk.threads_enter() - # display error - # Gdk.threads_leave() + # TODO does this cause issues with other places the error dialog is shown? + # without this threads_enter/threads_leave it would fail to show dialog/create + # app indicator when error is thrown from uinput interfaced + Gdk.threads_enter() logger.debug("Displaying "+dialog_type.value_name+" Dialog") dlg = Gtk.MessageDialog(type=dialog_type, buttons=Gtk.ButtonsType.OK, message_format=message) @@ -191,6 +192,33 @@ def show_error_dialog(self, message, details=None, dialog_type=Gtk.MessageType.E dlg.format_secondary_text(details) dlg.run() dlg.destroy() + Gdk.threads_leave() + + def show_error_dialog_with_link(self, message, details=None, link_data=None, dialog_type=Gtk.MessageType.ERROR): + Gdk.threads_enter() + # logger.debug("Displaying ) + label = Gtk.Label() + label.set_markup(f'{link_data}') + # label.set_tooltip_text("Click to open file") + # label.set_cursor(Gdk.Cursor.new(Gdk.CursorType.HAND1)) + # label.connect("activate-link", open_file_link, link_data) + + dialog = Gtk.MessageDialog(type=dialog_type, buttons=Gtk.ButtonsType.NONE, message_format=message) + open_button = dialog.add_button("Open", Gtk.ResponseType.YES) + + dialog.get_message_area().add(label) + + dialog.show_all() + + response = dialog.run() + + if response == Gtk.ResponseType.YES: + logger.info(f"Attempting to open {link_data}") + subprocess.Popen(['xdg-open', link_data]) + + dialog.destroy() + Gdk.threads_leave() + def show_script_error(self, parent): """ diff --git a/lib/autokey/gtkui/data/hotkeysettings.xml b/lib/autokey/gtkui/data/hotkeysettings.xml index fa460962..b589dfbb 100644 --- a/lib/autokey/gtkui/data/hotkeysettings.xml +++ b/lib/autokey/gtkui/data/hotkeysettings.xml @@ -1,35 +1,35 @@ + - + - False - 8 + False + 8 Set Hotkey False True - center-on-parent - dialog + center-on-parent + dialog True - False + False vertical 5 True - False - end + False + end gtk-cancel - False + False True - True - True - False - True + True + True + True @@ -41,12 +41,11 @@ gtk-ok - False + False True - True - True - False - True + True + True + True @@ -59,30 +58,28 @@ False True - end + end 0 - + True - False - 5 + False + 5 + vertical 10 - + True - False + False 5 True - - Control - False + True - True - False - False + False + Universal: False @@ -91,13 +88,114 @@ - - Alt - False + + <ctrl> True - True - False - False + True + + + + False + False + 1 + + + + + <alt> + True + True + + + + False + False + 2 + + + + + <shift> + True + True + + + + False + False + 3 + + + + + <super> + True + True + + + + False + False + 4 + + + + + <meta> + True + True + + + + False + False + 5 + + + + + <hyper> + True + True + + + + False + False + 6 + + + + + True + True + 0 + + + + + True + False + 5 + True + + + True + False + + + False + True + 0 + + + + + Right Control + False + True + True + False False @@ -106,13 +204,12 @@ - - Alt GR - False + + Right Alt + False True - True - False - False + True + False False @@ -121,13 +218,12 @@ - - Shift - False + + Right Shift + False True - True - False - False + True + False False @@ -136,13 +232,12 @@ - - Super - False + + Right Super + False True - True - False - False + True + False False @@ -151,13 +246,12 @@ - - Hyper - False + + Right Meta + False True - True - False - False + True + False False @@ -166,13 +260,12 @@ - - Meta - False + + Right Hyper + False True - True - False - False + True + False False @@ -184,18 +277,151 @@ True True - 0 + 1 - + True - False + False + 5 + True + + + True + False + + + False + True + 0 + + + + + Left Control + False + True + True + False + + + False + True + 1 + + + + + Left Alt + False + True + True + False + + + False + True + 2 + + + + + Left Shift + False + True + True + False + + + False + True + 3 + + + + + Left Super + False + True + True + False + + + False + True + 4 + + + + + Left Meta + False + True + True + False + + + False + True + 5 + + + + + Left Hyper + False + True + True + False + + + False + True + 6 + + + + + True + True + 2 + + + + + True + False + + + Alt GR + False + True + True + False + + + False + True + 6 + + + + + True + True + 3 + + + + + True + False True - False - 0 + False + start Key: %s @@ -207,24 +433,23 @@ Press to Set - False + False True - True - False - False + True + False False True - 1 + 2 True True - 1 + 4 diff --git a/lib/autokey/gtkui/data/settingsdialog.xml b/lib/autokey/gtkui/data/settingsdialog.xml index 91eec601..fbe1313f 100644 --- a/lib/autokey/gtkui/data/settingsdialog.xml +++ b/lib/autokey/gtkui/data/settingsdialog.xml @@ -1,36 +1,33 @@ - + - False - 8 + False + 8 AutoKey - Preferences True - center-on-parent - dialog - - - + center-on-parent + dialog True - False + False vertical 2 True - False - end + False + end gtk-cancel - False + False True - True - True - True + True + True + True @@ -42,13 +39,13 @@ gtk-ok - False + False True - True - True - True - True - True + True + True + True + True + True @@ -61,52 +58,55 @@ False True - end + end 0 True - True + True True - False + False 0 0 - 10 - 5 - 5 - 5 + 10 + 5 + 5 + 5 - + True - False + False + vertical 5 True - False - 0 - none + False + 0 + none True - False - 12 + False + 12 - + True - False + False + vertical Automatically start AutoKey at login - False + False True - True - False - True + True + False + start + True True @@ -117,12 +117,12 @@ Automatically save changes without confirmation - False + False True - True - False - False - True + True + False + start + True True @@ -133,12 +133,12 @@ Show a notification icon - False + False True - True - False - False - True + True + False + start + True @@ -150,11 +150,12 @@ Disable handling of the Capslock key - False + False True - True - False - True + True + False + start + True True @@ -165,11 +166,11 @@ True - False + False True - False + False Notification icon style (requires restart): @@ -189,11 +190,11 @@ True - False + False True - False + False GTKSource Theme (requires restart): @@ -217,9 +218,9 @@ True - False + False <b>Application</b> - True + True @@ -232,26 +233,28 @@ True - False - 0 - none + False + 0 + none True - False - 12 + False + 12 - + True - False + False + vertical Allow keyboard navigation of popup menu - False + False True - True - False - True + True + False + start + True True @@ -262,11 +265,12 @@ Sort menu items with most frequently used first - False + False True - True - False - True + True + False + start + True True @@ -277,11 +281,12 @@ Trigger menu item by first initial - False + False True - True - False - True + True + False + start + True True @@ -296,9 +301,9 @@ True - False + False <b>Popup Menu</b> - True + True @@ -311,22 +316,23 @@ True - False - 0 - none + False + 0 + none True - False - 12 + False + 12 Enable undo by pressing backspace - False + False True - True - False - True + True + False + start + True @@ -334,9 +340,9 @@ True - False + False <b>Expansions</b> - True + True @@ -346,6 +352,138 @@ 2 + + + True + False + 0 + none + + + True + False + 12 + + + True + False + vertical + + + True + False + + + True + False + Keyboard: + + + False + True + 0 + + + + + True + True + + + True + True + end + 1 + + + + + True + False + + + False + True + 10 + 2 + + + + + False + True + 0 + + + + + True + False + + + True + False + Mouse: + + + False + True + 0 + + + + + True + True + + + True + True + end + 1 + + + + + True + False + + + False + True + 10 + 2 + + + + + False + True + 1 + + + + + + + + + True + False + <b>UInput Devices</b> + True + + + + + False + True + 3 + + + + + @@ -353,52 +491,52 @@ True - False + False General - False + False True - False + False 0 0 - 10 - 5 - 5 - 5 + 10 + 5 + 5 + 5 - + True - False + False + vertical 10 True - False - 0 - none + False + 0 + none True - False - 5 - 5 - 12 + False + 5 + 5 + 12 True - False + False 5 True - False + False Hotkey: - 0 False @@ -409,9 +547,8 @@ True - False + False $hotkey - 0 True @@ -422,11 +559,11 @@ Set - False - 80 + False + 80 True - True - True + True + True @@ -438,12 +575,12 @@ gtk-clear - False - 80 + False + 80 True - True - True - True + True + True + True @@ -459,9 +596,9 @@ True - False + False <b>Toggle monitoring using a hotkey</b> - True + True @@ -474,27 +611,26 @@ True - False - 0 - none + False + 0 + none True - False - 5 - 5 - 12 + False + 5 + 5 + 12 True - False + False 5 True - False + False Hotkey: - 0 False @@ -505,9 +641,8 @@ True - False + False $hotkey - 0 True @@ -518,11 +653,11 @@ Set - False - 80 + False + 80 True - True - True + True + True @@ -534,12 +669,12 @@ gtk-clear - False - 80 + False + 80 True - True - True - True + True + True + True @@ -555,9 +690,9 @@ True - False + False <b>Show configuration window using a hotkey</b> - True + True @@ -577,45 +712,45 @@ True - False + False Special Hotkeys 1 - False + False True - False - 10 - 5 - 5 - 5 + False + 10 + 5 + 5 + 5 True - False - 0 - none + False + 0 + none True - False - 12 + False + 12 - + True - False + False + vertical True - False + False 5 Any Python modules placed in this folder will be available for import by scripts. - 0 False @@ -626,7 +761,7 @@ import by scripts. True - False + False select-folder Select A Folder @@ -643,9 +778,9 @@ import by scripts. True - False + False <b>User Module Folder</b> - True + True @@ -658,12 +793,12 @@ import by scripts. True - False + False Script Engine 2 - False + False diff --git a/lib/autokey/gtkui/dialogs.py b/lib/autokey/gtkui/dialogs.py index 74366edc..184d23aa 100644 --- a/lib/autokey/gtkui/dialogs.py +++ b/lib/autokey/gtkui/dialogs.py @@ -26,6 +26,7 @@ import autokey.model.triggermode import autokey.model.constants import autokey.model.phrase +from autokey.model.key import MAPPED_UNIVERSAL_MODIFIERS from autokey.model.triggermode import TriggerMode import autokey.iomediator.keygrabber import autokey.iomediator.windowgrabber @@ -433,23 +434,60 @@ def __init__(self, parent, configManager, closure): self.closure = closure self.key = None - self.controlButton = builder.get_object("controlButton") - self.altButton = builder.get_object("altButton") + self.universalControl = builder.get_object("universalControl") + self.universalAlt = builder.get_object("universalAlt") + self.universalShift = builder.get_object("universalShift") + self.universalSuper = builder.get_object("universalSuper") + self.universalHyper = builder.get_object("universalHyper") + self.universalMeta = builder.get_object("universalMeta") + + self.rcontrolButton = builder.get_object("rcontrolButton") + self.raltButton = builder.get_object("raltButton") + self.rshiftButton = builder.get_object("rshiftButton") + self.rsuperButton = builder.get_object("rsuperButton") + self.rhyperButton = builder.get_object("rhyperButton") + self.rmetaButton = builder.get_object("rmetaButton") + + self.lcontrolButton = builder.get_object("lcontrolButton") + self.laltButton = builder.get_object("laltButton") + self.lshiftButton = builder.get_object("lshiftButton") + self.lsuperButton = builder.get_object("lsuperButton") + self.lhyperButton = builder.get_object("lhyperButton") + self.lmetaButton = builder.get_object("lmetaButton") + self.altgrButton = builder.get_object("altgrButton") - self.shiftButton = builder.get_object("shiftButton") - self.superButton = builder.get_object("superButton") - self.hyperButton = builder.get_object("hyperButton") - self.metaButton = builder.get_object("metaButton") self.setButton = builder.get_object("setButton") self.keyLabel = builder.get_object("keyLabel") self.MODIFIER_BUTTONS = { - self.controlButton: Key.CONTROL, - self.altButton: Key.ALT, + self.lcontrolButton: Key.LEFTCONTROL, + self.rcontrolButton: Key.RIGHTCONTROL, + + self.laltButton: Key.LEFTALT, + self.raltButton: Key.RIGHTALT, + self.altgrButton: Key.ALT_GR, - self.shiftButton: Key.SHIFT, - self.superButton: Key.SUPER, - self.hyperButton: Key.HYPER, - self.metaButton: Key.META, + + self.lshiftButton: Key.LEFTSHIFT, + self.rshiftButton: Key.RIGHTSHIFT, + + self.lsuperButton: Key.LEFTSUPER, + self.rsuperButton: Key.RIGHTSUPER, + + self.lhyperButton: Key.LEFTHYPER, + self.rhyperButton: Key.RIGHTHYPER, + + self.lmetaButton: Key.LEFTMETA, + self.rmetaButton: Key.RIGHTMETA, + + } + + self.MODIFIER_SWITCHES ={ + self.universalControl: Key.CONTROL, + self.universalAlt: Key.ALT, + self.universalShift: Key.SHIFT, + self.universalSuper: Key.SUPER, + self.universalHyper: Key.HYPER, + self.universalMeta: Key.META, } DialogBase.__init__(self) @@ -471,6 +509,9 @@ def activate_modifier_buttons(self, modifiers): for button, key in self.MODIFIER_BUTTONS.items(): button.set_active(key in modifiers) + for button, key in self.MODIFIER_SWITCHES.items(): + button.set_active(key in modifiers) + def save(self, item): UI_common.save_hotkey_settings_dialog(self, item) @@ -478,6 +519,9 @@ def reset(self): for button in self.MODIFIER_BUTTONS: button.set_active(False) + for button in self.MODIFIER_SWITCHES: + button.set_active(False) + self._setKeyLabel(_("(None)")) self.key = None self.setButton.set_sensitive(True) @@ -506,6 +550,12 @@ def get_active_modifiers(self): for button, key in self.MODIFIER_BUTTONS.items(): if button.get_active(): modifiers.append(key) + for button, key in self.MODIFIER_SWITCHES.items(): + if button.get_active(): + for item in MAPPED_UNIVERSAL_MODIFIERS[key]: # remove the left/right modifier versions if the switch is active + if item in modifiers: + modifiers.remove(item) + modifiers.append(key) modifiers.sort() return modifiers @@ -531,6 +581,28 @@ def on_setButton_pressed(self, widget, data=None): self.grabber = autokey.iomediator.keygrabber.KeyGrabber(self) self.grabber.start() + def on_switch_activate(self, widget, data=None): + # print(widget, widget.get_name()) + name = widget.get_name() + if name == "": + self.lcontrolButton.set_active(False) + self.rcontrolButton.set_active(False) + elif name == "": + self.laltButton.set_active(False) + self.raltButton.set_active(False) + elif name == "": + self.lshiftButton.set_active(False) + self.rshiftButton.set_active(False) + elif name == "": + self.lsuperButton.set_active(False) + self.rsuperButton.set_active(False) + elif name == "": + self.lmetaButton.set_active(False) + self.rmetaButton.set_active(False) + elif name == "": + self.lhyperButton.set_active(False) + self.rhyperButton.set_active(False) + class GlobalHotkeyDialog(HotkeySettingsDialog): diff --git a/lib/autokey/gtkui/settingsdialog.py b/lib/autokey/gtkui/settingsdialog.py index 72039450..435f2c84 100644 --- a/lib/autokey/gtkui/settingsdialog.py +++ b/lib/autokey/gtkui/settingsdialog.py @@ -102,7 +102,13 @@ def __init__(self, parent, configManager): self.configKeyLabel = builder.get_object("configKeyLabel") self.clearConfigButton = builder.get_object("clearConfigButton") self.monitorKeyLabel = builder.get_object("monitorKeyLabel") - self.clearMonitorButton = builder.get_object("clearMonitorButton") + self.clearMonitorButton = builder.get_object("clearMonitorButton") + + # UInput Settings + self.uinput_keyboard = builder.get_object("uinput_keyboard") + self.uinput_mouse = builder.get_object("uinput_mouse") + self.uinput_keyboard.set_text(cm.ConfigManager.SETTINGS[cm_constants.KEYBOARD]) + self.uinput_mouse.set_text(cm.ConfigManager.SETTINGS[cm_constants.MOUSE]) self.useConfigHotkey = self.__loadHotkey(configManager.configHotkey, self.configKeyLabel, self.showConfigDlg, self.clearConfigButton) @@ -134,6 +140,10 @@ def on_save(self, widget, data=None): cm.ConfigManager.SETTINGS[cm_constants.TRIGGER_BY_INITIAL] = self.triggerItemByInitial.get_active() cm.ConfigManager.SETTINGS[cm_constants.UNDO_USING_BACKSPACE] = self.enableUndoCheckbox.get_active() cm.ConfigManager.SETTINGS[cm_constants.NOTIFICATION_ICON] = ICON_NAME_MAP[self.iconStyleCombo.get_active_text()] + + # UInput Settings + cm.ConfigManager.SETTINGS[cm_constants.KEYBOARD] = self.uinput_keyboard.get_text() + cm.ConfigManager.SETTINGS[cm_constants.MOUSE] = self.uinput_mouse.get_text() self._save_disable_capslock_setting() self.configManager.userCodeDir = self.userModuleChooserButton.get_current_folder() sys.path.append(self.configManager.userCodeDir) diff --git a/lib/autokey/headless_app.py b/lib/autokey/headless_app.py index 27679dc9..d8cac539 100644 --- a/lib/autokey/headless_app.py +++ b/lib/autokey/headless_app.py @@ -74,7 +74,7 @@ def shutdown(self): # """ # Show an error notification popup. - # @param error: The error that occurred in a Script + # :param error: The error that occurred in a Script # """ # message = "The script '{}' encountered an error".format(error.script_name) # self.notifier.notify_error(message) diff --git a/lib/autokey/interface.py b/lib/autokey/interface.py index 15d2985b..f5ee3de4 100644 --- a/lib/autokey/interface.py +++ b/lib/autokey/interface.py @@ -20,7 +20,7 @@ A better name might be "keyboard_interface.py". """ -__all__ = ["XRecordInterface", "AtSpiInterface", "WindowInfo"] +#__all__ = ["XRecordInterface", "AtSpiInterface"] import logging import typing @@ -29,18 +29,20 @@ import queue import subprocess import time +import copy + import autokey.model.phrase if typing.TYPE_CHECKING: from autokey.iomediator.iomediator import IoMediator import autokey.configmanager.configmanager_constants as cm_constants -from autokey.sys_interface.abstract_interface import AbstractSysInterface, AbstractMouseInterface, AbstractWindowInterface +from autokey.sys_interface.abstract_interface import AbstractSysInterface, AbstractMouseInterface, AbstractWindowInterface, WindowInfo, queue_method # Imported to enable threading in Xlib. See module description. Not an unused import statement. import Xlib.threaded as xlib_threaded -# Delete again, as the reference is not needed anymore after the import side-effect has done it’s work. +# Delete again, as the reference is not needed anymore after the import side-effect has done it's work. # This (hopefully) also prevents automatic code cleanup software from deleting an "unused" import and re-introduce # issues. del xlib_threaded @@ -104,15 +106,125 @@ def str_or_bytes_to_bytes(x: typing.Union[str, bytes, memoryview]) -> bytes: raise RuntimeError("x must be str or bytes or memoryview object, type(x)={}, repr(x)={}".format(type(x), repr(x))) -# This tuple is used to return requested window properties. -WindowInfo = typing.NamedTuple("WindowInfo", [("wm_title", str), ("wm_class", str)]) -class XInterfaceBase(threading.Thread, AbstractMouseInterface, AbstractWindowInterface): +class XWindowInterface(AbstractWindowInterface): + """ + Window Read interface for x11/xorg. + + Extends :class:`.AbstractWindowInterface` + """ + + def __init__(self): + self.localDisplay = display.Display() + # Window name atoms + self.__NameAtom = self.localDisplay.intern_atom("_NET_WM_NAME", True) + self.__VisibleNameAtom = self.localDisplay.intern_atom("_NET_WM_VISIBLE_NAME", True) + + + def get_window_info(self, window=None, traverse: bool=True) -> WindowInfo: + try: + if window is None: + window = self.localDisplay.get_input_focus().focus + return self._get_window_info(window, traverse) + except error.BadWindow: + logger.warning("Got BadWindow error while requesting window information.") + return self._create_window_info(window, "", "") + + def get_window_title(self, window=None, traverse=True) -> str: + return self.get_window_info(window, traverse).wm_title + + def get_window_class(self, window=None, traverse=True) -> str: + return self.get_window_info(window, traverse).wm_class + + def _get_window_info(self, window, traverse: bool, wm_title: str=None, wm_class: str=None) -> WindowInfo: + new_wm_title = self._try_get_window_title(window) + new_wm_class = self._try_get_window_class(window) + + if not wm_title and new_wm_title: # Found title, update known information + wm_title = new_wm_title + if not wm_class and new_wm_class: # Found class, update known information + wm_class = new_wm_class + + if traverse: + # Recursive operation on the parent window + if wm_title and wm_class: # Both known, abort walking the tree and return the data. + return self._create_window_info(window, wm_title, wm_class) + else: # At least one property is still not known. So walk the window tree up. + parent = window.query_tree().parent + # Stop traversal, if the parent is not a window. When querying the parent, at some point, an integer + # is returned. Then just stop following the tree. + if isinstance(parent, int): + # At this point, wm_title or wm_class may still be None. The recursive call with traverse=False + # will replace any None with an empty string. See below. + return self._get_window_info(window, False, wm_title, wm_class) + else: + return self._get_window_info(parent, traverse, wm_title, wm_class) + + else: + # No recursion, so fill unknown values with empty strings. + if wm_title is None: + wm_title = "" + if wm_class is None: + wm_class = "" + return self._create_window_info(window, wm_title, wm_class) + + def _create_window_info(self, window, wm_title: str, wm_class: str): + """ + Creates a WindowInfo object from the window title and WM_CLASS. + Also checks for the Java XFocusProxyWindow workaround and applies it if needed: + + Workaround for Java applications: Java AWT uses a XFocusProxyWindow class, so to get usable information, + the parent window needs to be queried. Credits: https://github.com/mooz/xkeysnail/pull/32 + https://github.com/JetBrains/jdk8u_jdk/blob/master/src/solaris/classes/sun/awt/X11/XFocusProxyWindow.java#L35 + """ + if "FocusProxy" in wm_class: + parent = window.query_tree().parent + # Discard both the already known wm_class and window title, because both are known to be wrong. + return self._get_window_info(parent, False) + else: + return WindowInfo(wm_title=wm_title, wm_class=wm_class) + + def _try_get_window_title(self, window) -> typing.Optional[str]: + atom = self._try_read_property(window, self.__VisibleNameAtom) + if atom is None: + atom = self._try_read_property(window, self.__NameAtom) + if atom: + value = atom.value # type: typing.Union[str, bytes] + # based on python3-xlib version, atom.value may be a bytes object, then decoding is necessary. + return value.decode("utf-8") if isinstance(value, bytes) else value + else: + return None + + @staticmethod + def _try_read_property(window, property_name: str): + """ + Try to read the given property of the given window. + Returns the atom, if successful, None otherwise. + """ + try: + return window.get_property(property_name, 0, 0, 255) + except error.BadAtom: + return None + + @staticmethod + def _try_get_window_class(window) -> typing.Optional[str]: + wm_class = window.get_wm_class() + if wm_class: + return "{}.{}".format(wm_class[0], wm_class[1]) + else: + return None + + +class XInterfaceBase(threading.Thread, AbstractMouseInterface): """ Encapsulates the common functionality for the two X interface classes. + + Extends :class:`.AbstractMouseInterface` """ + queue = queue.Queue() + def __init__(self, mediator, app): threading.Thread.__init__(self) self.setDaemon(True) @@ -125,7 +237,6 @@ def __init__(self, mediator, app): # Event loop self.eventThread = threading.Thread(target=self.__eventLoop) - self.queue = queue.Queue() # Event listener self.listenerThread = threading.Thread(target=self.__flush_events_loop) @@ -135,10 +246,6 @@ def __init__(self, mediator, app): # Set initial lock state self.__set_lock_keys_state() - # Window name atoms - self.__NameAtom = self.localDisplay.intern_atom("_NET_WM_NAME", True) - self.__VisibleNameAtom = self.localDisplay.intern_atom("_NET_WM_VISIBLE_NAME", True) - #move detection of key map changes to X event thread in order to have QT and GTK detection # if not common.USING_QT: # self.keyMap = Gdk.Keymap.get_default() @@ -149,8 +256,13 @@ def __init__(self, mediator, app): self.eventThread.start() self.listenerThread.start() + @queue_method(queue) def flush(self): - self.__enqueue(self.__flush) + """ + Flush the X interface. + """ + self.localDisplay.flush() + self.lastChars = [] def on_keys_changed(self, data=None): """ @@ -160,34 +272,56 @@ def on_keys_changed(self, data=None): logger.debug("Recorded keymap change event") self.__ignoreRemap = True time.sleep(0.2) - self.__enqueue(self.__ungrab_all_hotkeys) - self.__enqueue(self.__delayedInitMappings) + self.__ungrab_all_hotkeys() + self.__delayedInitMappings() else: logger.debug("Ignored keymap change event") + @queue_method(queue) def press_key(self, keyName): - self.__enqueue(self.__pressKey, keyName) + """ + Press passed keyName. + + :param keyName: + """ + self.__sendKeyPressEvent(self.__lookupKeyCode(keyName), 0) + @queue_method(queue) def release_key(self, keyName): - self.__enqueue(self.__releaseKey, keyName) + self.__sendKeyReleaseEvent(self.__lookupKeyCode(keyName), 0) + @queue_method(queue) def handle_keypress(self, keyCode): - self.__enqueue(self.__handleKeyPress, keyCode) + focus = self.localDisplay.get_input_focus().focus + + modifier = self.__decodeModifier(keyCode) + if modifier is not None: + self.mediator.handle_modifier_down(modifier) + else: + window_info = self.mediator.windowInterface.get_window_info(focus) + self.mediator.handle_keypress(keyCode, window_info) + @queue_method(queue) def handle_keyrelease(self, keyCode): - self.__enqueue(self.__handleKeyrelease, keyCode) + modifier = self.__decodeModifier(keyCode) + if modifier is not None: + self.mediator.handle_modifier_up(modifier) + @queue_method(queue) def grab_keyboard(self): - self.__enqueue(self.__grab_keyboard) + focus = self.localDisplay.get_input_focus().focus + focus.grab_keyboard(True, X.GrabModeAsync, X.GrabModeAsync, X.CurrentTime) + self.localDisplay.flush() + @queue_method(queue) def ungrab_keyboard(self): - self.__enqueue(self.__ungrabKeyboard) + self.localDisplay.ungrab_keyboard(X.CurrentTime) + self.localDisplay.flush() def grab_hotkey(self, item): self.__enqueue_hotkey_grab_ungrab(item, grab=True) def ungrab_hotkey(self, item): - import copy newItem = copy.copy(item) self.__enqueue_hotkey_grab_ungrab(newItem, grab=False) @@ -211,43 +345,112 @@ def lookup_string(self, keyCode, shifted, numlock, altGrid): except ValueError: return "" % keyCode + @queue_method(queue) def send_string(self, string): - # Asynchronous send string. - self.__enqueue(self.__sendString, string) + """ + Send a string of printable characters. + """ + logger.debug("Sending string: %r", string) + # Determine if workaround is needed + if not cm.ConfigManager.SETTINGS[cm_constants.ENABLE_QT4_WORKAROUND]: + self.__checkWorkaroundNeeded() + # First find out if any chars need remapping + remapNeeded = self.__chars_need_remapping(string) + # Now we know chars need remapping, do it + self.__remap_characters(remapNeeded, string) + + focus = self.localDisplay.get_input_focus().focus + + for char in string: + try: + self.__send_keycode_for_char(char, focus) + except Exception as e: + logger.exception("Error sending char %r: %s", char, str(e)) + + self.__ignoreRemap = False + + @queue_method(queue) def send_key(self, keyName): """ - Send a specific non-printing key, eg Up, Left, etc - """ - self.__enqueue(self.__sendKey, keyName) + Send a specific non-printing key, eg , , etc + :param keyName: Name of the key IE , + """ + logger.debug("Send special key: [%r]", keyName) + self.__sendKeyCode(self.__lookupKeyCode(keyName)) + @queue_method(queue) def send_modified_key(self, keyName, modifiers): """ Send a modified key (e.g. when emulating a hotkey) """ - self.__enqueue(self.__sendModifiedKey, keyName, modifiers) + logger.debug("Send modified key: modifiers: %s key: %s", modifiers, keyName) + try: + keyCode = self.__lookupKeyCode(keyName) + self.__send_keycode_with_modifiers_pressed(keyCode, + modifiers) + except Exception as e: + logger.warning("Error sending modified key %r %r: %s", modifiers, keyName, str(e)) + @queue_method(queue) def fake_keypress(self, keyName): - self.__enqueue(self.__fakeKeypress, keyName) + keyCode = self.__lookupKeyCode(keyName) + xtest.fake_input(self.rootWindow, X.KeyPress, keyCode) + self.localDisplay.sync() + xtest.fake_input(self.rootWindow, X.KeyRelease, keyCode) + self.localDisplay.sync() + @queue_method(queue) def fake_keydown(self, keyName): - self.__enqueue(self.__fakeKeydown, keyName) + keyCode = self.__lookupKeyCode(keyName) + xtest.fake_input(self.rootWindow, X.KeyPress, keyCode) + self.localDisplay.sync() + @queue_method(queue) def fake_keyup(self, keyName): - self.__enqueue(self.__fakeKeyup, keyName) + keyCode = self.__lookupKeyCode(keyName) + xtest.fake_input(self.rootWindow, X.KeyRelease, keyCode) + self.localDisplay.sync() + @queue_method(queue) def send_mouse_click(self, xCoord, yCoord, button, relative): - self.__enqueue(self.__sendMouseClick, xCoord, yCoord, button, relative) + # Get current pointer position so we can return it there + pos = self.rootWindow.query_pointer() + if relative: + focus = self.localDisplay.get_input_focus().focus + focus.warp_pointer(xCoord, yCoord) + xtest.fake_input(focus, X.ButtonPress, button, x=xCoord, y=yCoord) + xtest.fake_input(focus, X.ButtonRelease, button, x=xCoord, y=yCoord) + else: + self.rootWindow.warp_pointer(xCoord, yCoord) + xtest.fake_input(self.rootWindow, X.ButtonPress, button, x=xCoord, y=yCoord) + xtest.fake_input(self.rootWindow, X.ButtonRelease, button, x=xCoord, y=yCoord) + + self.rootWindow.warp_pointer(pos.root_x, pos.root_y) + + self.__flush() + + @queue_method(queue) def mouse_press(self, xCoord, yCoord, button): - self.__enqueue(self.__mousePress, xCoord, yCoord, button) + focus = self.localDisplay.get_input_focus().focus + xtest.fake_input(focus, X.ButtonPress, button, x=xCoord, y=yCoord) + self.__flush() + @queue_method(queue) def mouse_release(self, xCoord, yCoord, button): - self.__enqueue(self.__mouseRelease, xCoord, yCoord, button) + focus = self.localDisplay.get_input_focus().focus + xtest.fake_input(focus, X.ButtonRelease, button, x=xCoord, y=yCoord) + self.__flush() def mouse_location(self): + """Returns the location of the Mouse as (x,y) + + :return: Tuple of the Mouse Location(x,y) + :rtype: tuple + """ pos = self.rootWindow.query_pointer() return (pos.root_x, pos.root_y) @@ -260,35 +463,63 @@ def relative_mouse_location(self, window=None): def scroll_down(self, number): for i in range(0, number): - self.__enqueue(self.__scroll, Button.SCROLL_DOWN) + self.__scroll(Button.SCROLL_DOWN) def scroll_up(self, number): for i in range(0, number): - self.__enqueue(self.__scroll, Button.SCROLL_UP) + self.__scroll(Button.SCROLL_UP) + @queue_method(queue) def move_cursor(self, xCoord, yCoord, relative=False, relative_self=False): - self.__enqueue(self.__moveCursor, xCoord, yCoord, relative, relative_self) + if relative: + focus = self.localDisplay.get_input_focus().focus + focus.warp_pointer(xCoord, yCoord) + self.__flush() + return + + if relative_self: + pos = self.rootWindow.query_pointer() + xCoord += pos.root_x + yCoord += pos.root_y + self.rootWindow.warp_pointer(xCoord,yCoord) + self.__flush() + + @queue_method(queue) def send_mouse_click_relative(self, xoff, yoff, button): - self.__enqueue(self.__sendMouseClickRelative, xoff, yoff, button) + # Get current pointer position + pos = self.rootWindow.query_pointer() - def handle_mouseclick(self, button, x, y): - self.__enqueue(self.__handleMouseclick, button, x, y) + xCoord = pos.root_x + xoff + yCoord = pos.root_y + yoff - def get_window_info(self, window=None, traverse: bool=True) -> WindowInfo: - try: - if window is None: - window = self.localDisplay.get_input_focus().focus - return self._get_window_info(window, traverse) - except error.BadWindow: - logger.warning("Got BadWindow error while requesting window information.") - return self._create_window_info(window, "", "") + self.rootWindow.warp_pointer(xCoord, yCoord) + xtest.fake_input(self.rootWindow, X.ButtonPress, button, x=xCoord, y=yCoord) + xtest.fake_input(self.rootWindow, X.ButtonRelease, button, x=xCoord, y=yCoord) - def get_window_title(self, window=None, traverse=True) -> str: - return self.get_window_info(window, traverse).wm_title + self.rootWindow.warp_pointer(pos.root_x, pos.root_y) + + self.__flush() + + @queue_method(queue) + def handle_mouseclick(self, button, x, y): + # Sleep a bit to timing issues. A mouse click might change the active application. + # If so, the switch happens asynchronously somewhere during the execution of the first two queries below, + # causing the queried window title (and maybe the window class or even none of those) to be invalid. + time.sleep(0.005) # TODO: may need some tweaking + window_info = self.mediator.windowInterface.get_window_info() + + if x is None and y is None: + ret = self.localDisplay.get_input_focus().focus.query_pointer() + self.mediator.handle_mouse_click(ret.root_x, ret.root_y, ret.win_x, ret.win_y, button, window_info) + else: + focus = self.localDisplay.get_input_focus().focus + try: + rel = focus.translate_coords(self.rootWindow, x, y) + self.mediator.handle_mouse_click(x, y, rel.x, rel.y, button, window_info) + except: + self.mediator.handle_mouse_click(x, y, 0, 0, button, window_info) - def get_window_class(self, window=None, traverse=True) -> str: - return self.get_window_info(window, traverse).wm_class def cancel(self): logger.debug("XInterfaceBase: Try to exit event thread.") @@ -325,6 +556,7 @@ def __eventLoop(self): def __enqueue(self, method: typing.Callable, *args): self.queue.put_nowait((method, args)) + @queue_method(queue) def __delayedInitMappings(self): self.__initMappings() self.__ignoreRemap = False @@ -337,7 +569,7 @@ def __initMappings(self): self.__build_usable_offsets() self.__build_modifier_mask_mapping() - self.__grab_all_hotkeys() + self.__grab_ungrab_all_hotkeys(grab=True) self.localDisplay.flush() self.__availableKeycodes = self.__get_unused_keycodes() @@ -359,7 +591,7 @@ def __build_modifier_mask_mapping(self): self.modMasks = {} mapping = self.localDisplay.get_modifier_mapping() - for keySym, ak in XK_TO_AK_MAP.items(): + for ak, keySym in AK_TO_XK_MAP.items(): if ak in MODIFIERS: keyCodeList = self.localDisplay.keysym_to_keycodes(keySym) found = False @@ -441,25 +673,20 @@ def __grab_ungrab_all_hotkeys(self, grab=True): else: self.__recursive_ungrab(item) if grab: - self.__enqueue(self.__recurseTree, self.rootWindow, hotkeys) + self.recurseTree(self.rootWindow, hotkeys) else: - self.__recurseTreeUngrab(self.rootWindow, hotkeys) - - def __grab_all_hotkeys(self): - """ - Run during startup to grab global and specific hotkeys in all open windows - """ - self.__grab_ungrab_all_hotkeys(grab=True) + self.__recurse_tree_grab_ungrab(self.rootWindow, hotkeys, grab=False) def __enqueue_grab(self, item): self.__enqueue(self.__grabHotkey, item.hotKey, item.modifiers, self.rootWindow) if self.__needsMutterWorkaround(item): self.__enqueue(self.__grabRecurse, item, self.rootWindow, False) - def __recurseTree(self, parent, hotkeys): + @queue_method(queue) + def recurseTree(self, parent, hotkeys): self.__recurse_tree_grab_ungrab(parent, hotkeys, grab=True) - + @queue_method(queue) def __ungrab_all_hotkeys(self): """ Ungrab all hotkeys in preparation for keymap change @@ -482,7 +709,7 @@ def __recurse_tree_grab_ungrab(self, parent, hotkeys, grab=True): for window in children: try: - window_info = self.get_window_info(window, False) + window_info = self.mediator.windowInterface.get_window_info(window, False) if window_info.wm_title or window_info.wm_class: for item in hotkeys: @@ -499,10 +726,7 @@ def __recurse_tree_grab_ungrab(self, parent, hotkeys, grab=True): ungrab = "" if grab else "un" logger.exception("{}grab on window failed".format(ungrab)) - - def __recurseTreeUngrab(self, parent, hotkeys): - self.__recurse_tree_grab_ungrab(parent, hotkeys, grab=False) - + @queue_method(queue) def __grabHotkeysForWindow(self, window): """ Grab all hotkeys relevant to the window @@ -511,7 +735,7 @@ def __grabHotkeysForWindow(self, window): """ c = self.app.configManager hotkeys = c.hotKeys + c.hotKeyFolders - window_info = self.get_window_info(window) + window_info = self.mediator.windowInterface.get_window_info(window) for item in hotkeys: if item.get_applicable_regex() is not None and item._should_trigger_window_title(window_info): self.__enqueue(self.__grabHotkey, item.hotKey, item.modifiers, window) @@ -589,7 +813,7 @@ def __grab_ungrab_recurse(self, item, parent, checkWinInfo=True, grab=True): shouldTrigger = False if checkWinInfo: - window_info = self.get_window_info(window, False) + window_info = self.mediator.windowInterface.get_window_info(window, False) shouldTrigger = item._should_trigger_window_title(window_info) if shouldTrigger or not checkWinInfo: @@ -613,16 +837,6 @@ def __ungrabHotkey(self, key, modifiers, window): """ self.__grab_ungrab_hotkey(key, modifiers, window, grab=False) - - def __grab_keyboard(self): - focus = self.localDisplay.get_input_focus().focus - focus.grab_keyboard(True, X.GrabModeAsync, X.GrabModeAsync, X.CurrentTime) - self.localDisplay.flush() - - def __ungrabKeyboard(self): - self.localDisplay.ungrab_keyboard(X.CurrentTime) - self.localDisplay.flush() - def __findUsableKeycode(self, codeList): for code, offset in codeList: if offset in self.__usableOffsets: @@ -683,30 +897,6 @@ def __get_usable_char_keycode_and_offset(self, char): keyCode, offset = self.__findUsableKeycode(keyCodeList) return keyCode, offset - def __sendString(self, string): - """ - Send a string of printable characters. - """ - logger.debug("Sending string: %r", string) - # Determine if workaround is needed - if not cm.ConfigManager.SETTINGS[cm_constants.ENABLE_QT4_WORKAROUND]: - self.__checkWorkaroundNeeded() - - # First find out if any chars need remapping - remapNeeded = self.__chars_need_remapping(string) - # Now we know chars need remapping, do it - self.__remap_characters(remapNeeded, string) - - focus = self.localDisplay.get_input_focus().focus - - for char in string: - try: - self.__send_keycode_for_char(char, focus) - except Exception as e: - logger.exception("Error sending char %r: %s", char, str(e)) - - self.__ignoreRemap = False - def __send_keycode_for_char(self, char, focus): # Offset encodes the modifiers needed to convert the base key press # into the desired result symbol. @@ -744,7 +934,7 @@ def __sendByTypingAsUnicodePoint(self, char, focus): ukeyCode, uOffset = self.__findUsableKeycode(ukeyCodeList) self.__send_keycode_with_modifiers_pressed(ukeyCode, [Key.CONTROL, Key.SHIFT], focus) self.__send_char_as_hex_string(char) - self.__pressKey(Key.ENTER) + self.__sendKeyPressEvent(self.__lookupKeyCode(Key.ENTER), 0) def __send_char_as_hex_string(self, char): char_as_hex_string = '{:X}'.format(ord(char)) @@ -758,72 +948,15 @@ def __send_keycode_with_modifiers_pressed(self, keyCode, modifier_keys, focus=No mask = 0 for modkey in modifier_keys: mask |= self.modMasks[modkey] - for modkey in modifier_keys: self.__pressKey(modkey) + for modkey in modifier_keys: self.__sendKeyPressEvent(self.__lookupKeyCode(modkey), 0) if focus: self.__sendKeyCode(keyCode, mask, focus) else: self.__sendKeyCode(keyCode, mask) - for modkey in modifier_keys: self.__releaseKey(modkey) + for modkey in modifier_keys: self.__sendKeyReleaseEvent(self.__lookupKeyCode(modkey), 0) - def __sendKey(self, keyName): - logger.debug("Send special key: [%r]", keyName) - self.__sendKeyCode(self.__lookupKeyCode(keyName)) - - def __fakeKeypress(self, keyName): - keyCode = self.__lookupKeyCode(keyName) - xtest.fake_input(self.rootWindow, X.KeyPress, keyCode) - self.localDisplay.sync() - xtest.fake_input(self.rootWindow, X.KeyRelease, keyCode) - self.localDisplay.sync() - - def __fakeKeydown(self, keyName): - keyCode = self.__lookupKeyCode(keyName) - xtest.fake_input(self.rootWindow, X.KeyPress, keyCode) - self.localDisplay.sync() - - def __fakeKeyup(self, keyName): - keyCode = self.__lookupKeyCode(keyName) - xtest.fake_input(self.rootWindow, X.KeyRelease, keyCode) - self.localDisplay.sync() - - def __sendModifiedKey(self, keyName, modifiers): - logger.debug("Send modified key: modifiers: %s key: %s", modifiers, keyName) - try: - keyCode = self.__lookupKeyCode(keyName) - self.__send_keycode_with_modifiers_pressed(keyCode, - modifiers) - except Exception as e: - logger.warning("Error sending modified key %r %r: %s", modifiers, keyName, str(e)) - - def __sendMouseClick(self, xCoord, yCoord, button, relative): - # Get current pointer position so we can return it there - pos = self.rootWindow.query_pointer() - - if relative: - focus = self.localDisplay.get_input_focus().focus - focus.warp_pointer(xCoord, yCoord) - xtest.fake_input(focus, X.ButtonPress, button, x=xCoord, y=yCoord) - xtest.fake_input(focus, X.ButtonRelease, button, x=xCoord, y=yCoord) - else: - self.rootWindow.warp_pointer(xCoord, yCoord) - xtest.fake_input(self.rootWindow, X.ButtonPress, button, x=xCoord, y=yCoord) - xtest.fake_input(self.rootWindow, X.ButtonRelease, button, x=xCoord, y=yCoord) - - self.rootWindow.warp_pointer(pos.root_x, pos.root_y) - - self.__flush() - - def __mousePress(self, xCoord, yCoord, button): - focus = self.localDisplay.get_input_focus().focus - xtest.fake_input(focus, X.ButtonPress, button, x=xCoord, y=yCoord) - self.__flush() - - def __mouseRelease(self, xCoord, yCoord, button): - focus = self.localDisplay.get_input_focus().focus - xtest.fake_input(focus, X.ButtonRelease, button, x=xCoord, y=yCoord) - self.__flush() - + @queue_method(queue) def __scroll(self, button): focus = self.localDisplay.get_input_focus().focus x,y = self.mouse_location() @@ -831,45 +964,10 @@ def __scroll(self, button): xtest.fake_input(self=focus, event_type=X.ButtonRelease, detail=button, x=x, y=y) self.__flush() - def __moveCursor(self, xCoord, yCoord, relative=False, relative_self=False): - if relative: - focus = self.localDisplay.get_input_focus().focus - focus.warp_pointer(xCoord, yCoord) - self.__flush() - return - - if relative_self: - pos = self.rootWindow.query_pointer() - xCoord += pos.root_x - yCoord += pos.root_y - - self.rootWindow.warp_pointer(xCoord,yCoord) - self.__flush() - - def __sendMouseClickRelative(self, xoff, yoff, button): - # Get current pointer position - pos = self.rootWindow.query_pointer() - - xCoord = pos.root_x + xoff - yCoord = pos.root_y + yoff - - self.rootWindow.warp_pointer(xCoord, yCoord) - xtest.fake_input(self.rootWindow, X.ButtonPress, button, x=xCoord, y=yCoord) - xtest.fake_input(self.rootWindow, X.ButtonRelease, button, x=xCoord, y=yCoord) - - self.rootWindow.warp_pointer(pos.root_x, pos.root_y) - - self.__flush() - def __flush(self): self.localDisplay.flush() self.lastChars = [] - def __pressKey(self, keyName): - self.__sendKeyPressEvent(self.__lookupKeyCode(keyName), 0) - - def __releaseKey(self, keyName): - self.__sendKeyReleaseEvent(self.__lookupKeyCode(keyName), 0) def __flush_events_loop(self): logger.debug("__flushEvents: Entering event loop.") @@ -913,40 +1011,7 @@ def __flush_events(self): for window in createdWindows: if window not in destroyedWindows: - self.__enqueue(self.__grabHotkeysForWindow, window) - - def __handleKeyPress(self, keyCode): - focus = self.localDisplay.get_input_focus().focus - - modifier = self.__decodeModifier(keyCode) - if modifier is not None: - self.mediator.handle_modifier_down(modifier) - else: - window_info = self.get_window_info(focus) - self.mediator.handle_keypress(keyCode, window_info) - - def __handleKeyrelease(self, keyCode): - modifier = self.__decodeModifier(keyCode) - if modifier is not None: - self.mediator.handle_modifier_up(modifier) - - def __handleMouseclick(self, button, x, y): - # Sleep a bit to timing issues. A mouse click might change the active application. - # If so, the switch happens asynchronously somewhere during the execution of the first two queries below, - # causing the queried window title (and maybe the window class or even none of those) to be invalid. - time.sleep(0.005) # TODO: may need some tweaking - window_info = self.get_window_info() - - if x is None and y is None: - ret = self.localDisplay.get_input_focus().focus.query_pointer() - self.mediator.handle_mouse_click(ret.root_x, ret.root_y, ret.win_x, ret.win_y, button, window_info) - else: - focus = self.localDisplay.get_input_focus().focus - try: - rel = focus.translate_coords(self.rootWindow, x, y) - self.mediator.handle_mouse_click(x, y, rel.x, rel.y, button, window_info) - except: - self.mediator.handle_mouse_click(x, y, 0, 0, button, window_info) + self.__grabHotkeysForWindow(window) def __decodeModifier(self, keyCode): """ @@ -967,7 +1032,7 @@ def __sendKeyCode(self, keyCode, modifiers=0, theWindow=None): def __checkWorkaroundNeeded(self): focus = self.localDisplay.get_input_focus().focus - window_info = self.get_window_info(focus) + window_info = self.mediator.windowInterface.get_window_info(focus) w = self.app.configManager.workAroundApps if w.match(window_info.wm_title) or w.match(window_info.wm_class): self.__enableQT4Workaround = True @@ -1033,84 +1098,6 @@ def __lookupKeyCode(self, char: str) -> int: logger.error("Unknown key name: %s", char) raise - def _get_window_info(self, window, traverse: bool, wm_title: str=None, wm_class: str=None) -> WindowInfo: - new_wm_title = self._try_get_window_title(window) - new_wm_class = self._try_get_window_class(window) - - if not wm_title and new_wm_title: # Found title, update known information - wm_title = new_wm_title - if not wm_class and new_wm_class: # Found class, update known information - wm_class = new_wm_class - - if traverse: - # Recursive operation on the parent window - if wm_title and wm_class: # Both known, abort walking the tree and return the data. - return self._create_window_info(window, wm_title, wm_class) - else: # At least one property is still not known. So walk the window tree up. - parent = window.query_tree().parent - # Stop traversal, if the parent is not a window. When querying the parent, at some point, an integer - # is returned. Then just stop following the tree. - if isinstance(parent, int): - # At this point, wm_title or wm_class may still be None. The recursive call with traverse=False - # will replace any None with an empty string. See below. - return self._get_window_info(window, False, wm_title, wm_class) - else: - return self._get_window_info(parent, traverse, wm_title, wm_class) - - else: - # No recursion, so fill unknown values with empty strings. - if wm_title is None: - wm_title = "" - if wm_class is None: - wm_class = "" - return self._create_window_info(window, wm_title, wm_class) - - def _create_window_info(self, window, wm_title: str, wm_class: str): - """ - Creates a WindowInfo object from the window title and WM_CLASS. - Also checks for the Java XFocusProxyWindow workaround and applies it if needed: - - Workaround for Java applications: Java AWT uses a XFocusProxyWindow class, so to get usable information, - the parent window needs to be queried. Credits: https://github.com/mooz/xkeysnail/pull/32 - https://github.com/JetBrains/jdk8u_jdk/blob/master/src/solaris/classes/sun/awt/X11/XFocusProxyWindow.java#L35 - """ - if "FocusProxy" in wm_class: - parent = window.query_tree().parent - # Discard both the already known wm_class and window title, because both are known to be wrong. - return self._get_window_info(parent, False) - else: - return WindowInfo(wm_title=wm_title, wm_class=wm_class) - - def _try_get_window_title(self, window) -> typing.Optional[str]: - atom = self._try_read_property(window, self.__VisibleNameAtom) - if atom is None: - atom = self._try_read_property(window, self.__NameAtom) - if atom: - value = atom.value # type: typing.Union[str, bytes] - # based on python3-xlib version, atom.value may be a bytes object, then decoding is necessary. - return value.decode("utf-8") if isinstance(value, bytes) else value - else: - return None - - @staticmethod - def _try_read_property(window, property_name: str): - """ - Try to read the given property of the given window. - Returns the atom, if successful, None otherwise. - """ - try: - return window.get_property(property_name, 0, 0, 255) - except error.BadAtom: - return None - - @staticmethod - def _try_get_window_class(window) -> typing.Optional[str]: - wm_class = window.get_wm_class() - if wm_class: - return "{}.{}".format(wm_class[0], wm_class[1]) - else: - return None - class XRecordInterface(XInterfaceBase, AbstractSysInterface): @@ -1210,96 +1197,123 @@ def __pumpEvents(self): XK.load_keysym_group('xkb') XK_TO_AK_MAP = { - XK.XK_Shift_L: Key.SHIFT, - XK.XK_Shift_R: Key.SHIFT, - XK.XK_Caps_Lock: Key.CAPSLOCK, - XK.XK_Control_L: Key.CONTROL, - XK.XK_Control_R: Key.CONTROL, - XK.XK_Alt_L: Key.ALT, - XK.XK_Alt_R: Key.ALT, - XK.XK_ISO_Level3_Shift: Key.ALT_GR, - XK.XK_Super_L: Key.SUPER, - XK.XK_Super_R: Key.SUPER, - XK.XK_Hyper_L: Key.HYPER, - XK.XK_Hyper_R: Key.HYPER, - XK.XK_Meta_L: Key.META, - XK.XK_Meta_R: Key.META, - XK.XK_Num_Lock: Key.NUMLOCK, - #SPACE: Key.SPACE, - XK.XK_Tab: Key.TAB, - XK.XK_Left: Key.LEFT, - XK.XK_Right: Key.RIGHT, - XK.XK_Up: Key.UP, - XK.XK_Down: Key.DOWN, - XK.XK_Return: Key.ENTER, - XK.XK_BackSpace: Key.BACKSPACE, - XK.XK_Scroll_Lock: Key.SCROLL_LOCK, - XK.XK_Print: Key.PRINT_SCREEN, - XK.XK_Pause: Key.PAUSE, - XK.XK_Menu: Key.MENU, - XK.XK_F1: Key.F1, - XK.XK_F2: Key.F2, - XK.XK_F3: Key.F3, - XK.XK_F4: Key.F4, - XK.XK_F5: Key.F5, - XK.XK_F6: Key.F6, - XK.XK_F7: Key.F7, - XK.XK_F8: Key.F8, - XK.XK_F9: Key.F9, - XK.XK_F10: Key.F10, - XK.XK_F11: Key.F11, - XK.XK_F12: Key.F12, - XK.XK_F13: Key.F13, - XK.XK_F14: Key.F14, - XK.XK_F15: Key.F15, - XK.XK_F16: Key.F16, - XK.XK_F17: Key.F17, - XK.XK_F18: Key.F18, - XK.XK_F19: Key.F19, - XK.XK_F20: Key.F20, - XK.XK_F21: Key.F21, - XK.XK_F22: Key.F22, - XK.XK_F23: Key.F23, - XK.XK_F24: Key.F24, - XK.XK_F25: Key.F25, - XK.XK_F26: Key.F26, - XK.XK_F27: Key.F27, - XK.XK_F28: Key.F28, - XK.XK_F29: Key.F29, - XK.XK_F30: Key.F30, - XK.XK_F31: Key.F31, - XK.XK_F32: Key.F32, - XK.XK_F33: Key.F33, - XK.XK_F34: Key.F34, - XK.XK_F35: Key.F35, - XK.XK_Escape: Key.ESCAPE, - XK.XK_Insert: Key.INSERT, - XK.XK_Delete: Key.DELETE, - XK.XK_Home: Key.HOME, - XK.XK_End: Key.END, - XK.XK_Page_Up: Key.PAGE_UP, - XK.XK_Page_Down: Key.PAGE_DOWN, - XK.XK_KP_Insert: Key.NP_INSERT, - XK.XK_KP_Delete: Key.NP_DELETE, - XK.XK_KP_End: Key.NP_END, - XK.XK_KP_Down: Key.NP_DOWN, - XK.XK_KP_Page_Down: Key.NP_PAGE_DOWN, - XK.XK_KP_Left: Key.NP_LEFT, - XK.XK_KP_Begin: Key.NP_5, - XK.XK_KP_Right: Key.NP_RIGHT, - XK.XK_KP_Home: Key.NP_HOME, - XK.XK_KP_Up: Key.NP_UP, - XK.XK_KP_Page_Up: Key.NP_PAGE_UP, - XK.XK_KP_Divide: Key.NP_DIVIDE, - XK.XK_KP_Multiply: Key.NP_MULTIPLY, - XK.XK_KP_Add: Key.NP_ADD, - XK.XK_KP_Subtract: Key.NP_SUBTRACT, - XK.XK_KP_Enter: Key.ENTER, - XK.XK_space: ' ' - } + + # XK.XK_Shift_L: Key.SHIFT, + XK.XK_Shift_L: Key.LEFTSHIFT, + XK.XK_Shift_R: Key.RIGHTSHIFT, + + # XK.XK_Control_L: Key.CONTROL, + XK.XK_Control_L: Key.LEFTCONTROL, + XK.XK_Control_R: Key.RIGHTCONTROL, + + # XK.XK_Alt_L: Key.ALT, + XK.XK_Alt_L: Key.LEFTALT, + XK.XK_Alt_R: Key.RIGHTALT, + + # XK.XK_Super_L: Key.SUPER, + XK.XK_Super_L: Key.LEFTSUPER, + XK.XK_Super_R: Key.RIGHTSUPER, + + # XK.XK_Hyper_L: Key.HYPER, + XK.XK_Hyper_L: Key.LEFTHYPER, + XK.XK_Hyper_R: Key.RIGHTHYPER, + + # XK.XK_Meta_L: Key.META, + XK.XK_Meta_L: Key.LEFTMETA, + XK.XK_Meta_R: Key.RIGHTMETA, + + XK.XK_Caps_Lock: Key.CAPSLOCK, + XK.XK_Num_Lock: Key.NUMLOCK, + + #SPACE: Key.SPACE, + XK.XK_Tab: Key.TAB, + + XK.XK_Left: Key.LEFT, + XK.XK_Right: Key.RIGHT, + XK.XK_Up: Key.UP, + XK.XK_Down: Key.DOWN, + + XK.XK_Return: Key.ENTER, + XK.XK_BackSpace: Key.BACKSPACE, + XK.XK_Scroll_Lock: Key.SCROLL_LOCK, + XK.XK_Print: Key.PRINT_SCREEN, + XK.XK_Pause: Key.PAUSE, + XK.XK_Menu: Key.MENU, + + XK.XK_F1: Key.F1, + XK.XK_F2: Key.F2, + XK.XK_F3: Key.F3, + XK.XK_F4: Key.F4, + XK.XK_F5: Key.F5, + XK.XK_F6: Key.F6, + XK.XK_F7: Key.F7, + XK.XK_F8: Key.F8, + XK.XK_F9: Key.F9, + XK.XK_F10: Key.F10, + XK.XK_F11: Key.F11, + XK.XK_F12: Key.F12, + XK.XK_F13: Key.F13, + XK.XK_F14: Key.F14, + XK.XK_F15: Key.F15, + XK.XK_F16: Key.F16, + XK.XK_F17: Key.F17, + XK.XK_F18: Key.F18, + XK.XK_F19: Key.F19, + XK.XK_F20: Key.F20, + XK.XK_F21: Key.F21, + XK.XK_F22: Key.F22, + XK.XK_F23: Key.F23, + XK.XK_F24: Key.F24, + XK.XK_F25: Key.F25, + XK.XK_F26: Key.F26, + XK.XK_F27: Key.F27, + XK.XK_F28: Key.F28, + XK.XK_F29: Key.F29, + XK.XK_F30: Key.F30, + XK.XK_F31: Key.F31, + XK.XK_F32: Key.F32, + XK.XK_F33: Key.F33, + XK.XK_F34: Key.F34, + XK.XK_F35: Key.F35, + + XK.XK_Escape: Key.ESCAPE, + + XK.XK_Insert: Key.INSERT, + XK.XK_Delete: Key.DELETE, + XK.XK_Home: Key.HOME, + XK.XK_End: Key.END, + XK.XK_Page_Up: Key.PAGE_UP, + XK.XK_Page_Down: Key.PAGE_DOWN, + + XK.XK_KP_Insert: Key.NP_INSERT, + XK.XK_KP_Delete: Key.NP_DELETE, + XK.XK_KP_End: Key.NP_END, + XK.XK_KP_Down: Key.NP_DOWN, + XK.XK_KP_Page_Down: Key.NP_PAGE_DOWN, + XK.XK_KP_Left: Key.NP_LEFT, + XK.XK_KP_Begin: Key.NP_5, + XK.XK_KP_Right: Key.NP_RIGHT, + XK.XK_KP_Home: Key.NP_HOME, + XK.XK_KP_Up: Key.NP_UP, + XK.XK_KP_Page_Up: Key.NP_PAGE_UP, + XK.XK_KP_Divide: Key.NP_DIVIDE, + XK.XK_KP_Multiply: Key.NP_MULTIPLY, + XK.XK_KP_Add: Key.NP_ADD, + XK.XK_KP_Subtract: Key.NP_SUBTRACT, + XK.XK_KP_Enter: Key.ENTER, + XK.XK_space: ' ', + XK.XK_ISO_Level3_Shift: Key.ALT_GR, + } AK_TO_XK_MAP = dict((v,k) for k, v in XK_TO_AK_MAP.items()) +AK_TO_XK_MAP[Key.SHIFT] = XK.XK_Shift_L +AK_TO_XK_MAP[Key.CONTROL] = XK.XK_Control_L +AK_TO_XK_MAP[Key.ALT] = XK.XK_Alt_L +AK_TO_XK_MAP[Key.SUPER] = XK.XK_Super_L +AK_TO_XK_MAP[Key.HYPER] = XK.XK_Hyper_L +AK_TO_XK_MAP[Key.META] = XK.XK_Meta_L + XK_TO_AK_NUMLOCKED = { XK.XK_KP_Insert: "0", XK.XK_KP_Delete: ".", diff --git a/lib/autokey/iomediator/iomediator.py b/lib/autokey/iomediator/iomediator.py index 725ef0da..2ad7f3cd 100644 --- a/lib/autokey/iomediator/iomediator.py +++ b/lib/autokey/iomediator/iomediator.py @@ -16,12 +16,13 @@ import threading import time import queue +import os import autokey from autokey import common from autokey.configmanager.configmanager import ConfigManager from autokey.configmanager.configmanager_constants import INTERFACE_TYPE -from autokey.interface import XRecordInterface, AtSpiInterface +from autokey.gnome_interface import GnomeExtensionWindowInterface from autokey.sys_interface.clipboard import Clipboard from autokey.model.phrase import SendMode @@ -55,21 +56,34 @@ def __init__(self, service): self.app = service.app # Modifier tracking - self.modifiers = { - Key.CONTROL: False, - Key.ALT: False, - Key.ALT_GR: False, - Key.SHIFT: False, - Key.SUPER: False, - Key.HYPER: False, - Key.META: False, - Key.CAPSLOCK: False, - Key.NUMLOCK: False - } + self.modifiers = {} + for key in MODIFIERS: + self.modifiers[key]=False + + # self.interfaceType="uinput" + session_type = os.environ.get("XDG_SESSION_TYPE") + if session_type == "wayland": + self.interfaceType = "uinput" + elif session_type == "x11": + pass + elif session_type is None: + pass - if self.interfaceType == X_RECORD_INTERFACE: + if self.interfaceType == "uinput": + self.windowInterface = GnomeExtensionWindowInterface() + else: + from autokey.interface import XWindowInterface + self.windowInterface = XWindowInterface() + + + if self.interfaceType == "uinput": + from autokey.uinput_interface import UInputInterface + self.interface = UInputInterface(self, self.app) + elif self.interfaceType == X_RECORD_INTERFACE: + from autokey.interface import XRecordInterface self.interface = XRecordInterface(self, self.app) else: + from autokey.interface import AtSpiInterface self.interface = AtSpiInterface(self, self.app) self.clipboard = Clipboard() @@ -111,7 +125,9 @@ def set_modifier_state(self, modifier, state): def handle_modifier_down(self, modifier): """ - Updates the state of the given modifier key to 'pressed' + Updates the state of the given modifier key to 'pressed'. + + :param modifier: Should be AutoKey Key value """ logger.debug("%s pressed", modifier) if modifier in (Key.CAPSLOCK, Key.NUMLOCK): @@ -152,6 +168,7 @@ def run(self): # We make a copy here because the wait_for... functions modify the listeners, # and we want this processing cycle to complete before changing what happens + logger.debug("Raw Key: {} | Modifiers: {} | Key: {} | Window Info: {}".format(raw_key, modifiers, key, window_info)) for target in self.listeners.copy(): target.handle_keypress(raw_key, modifiers, key, window_info) @@ -184,6 +201,7 @@ def send_string(self, string: str): @staticmethod def _send_string(string, interface): modifiers = [] + logger.debug("Sending string sections: %s", KEY_SPLIT_RE.split(string)) for section in KEY_SPLIT_RE.split(string): if len(section) > 0: if Key.is_key(section[:-1]) and section[-1] == '+' and section[:-1] in MODIFIERS: @@ -237,7 +255,7 @@ def remove_string(self, string): backspaces += 1 else: backspaces += len(section) - + logger.debug("Sending backspaces: %d", backspaces) self.send_backspace(backspaces) def send_key(self, key_name): @@ -249,6 +267,7 @@ def press_key(self, key_name): self.interface.fake_keydown(key_name) def release_key(self, key_name): + logger.debug("Release key: %s", key_name) key_name = key_name.replace('\n', "") self.interface.fake_keyup(key_name) diff --git a/lib/autokey/macro.py b/lib/autokey/macro.py index 62cf7af5..11e7ddda 100644 --- a/lib/autokey/macro.py +++ b/lib/autokey/macro.py @@ -5,6 +5,8 @@ from autokey.model.key import Key, KEY_SPLIT_RE from autokey import common +import autokey.scripting + if common.USED_UI_TYPE == "QT": from PyQt5.QtWidgets import QAction @@ -83,6 +85,7 @@ def __init__(self, engine): self.macros.append(FileContentsMacro()) self.macros.append(CursorMacro()) self.macros.append(SystemMacro(engine)) + self.macros.append(ClipboardMacro()) def get_menu(self, callback, menu=None): if common.USED_UI_TYPE == "QT": @@ -180,6 +183,9 @@ def do_process(self, sections, i): class CursorMacro(AbstractMacro): + """ + C{} - Positions the text cursor at the indicated text position. There may only be one macro in a snippet. + """ ID = "cursor" TITLE = _("Position cursor") @@ -196,6 +202,28 @@ def do_process(self, sections, i): class ScriptMacro(AbstractMacro): + """ + C{