535 lines
20 KiB
Python
535 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
#
|
|
# Copyright (c) 2024 Stormux
|
|
# Copyright (c) 2010-2012 The Orca Team
|
|
# Copyright (c) 2012 Igalia, S.L.
|
|
# Copyright (c) 2005-2010 Sun Microsystems Inc.
|
|
#
|
|
# This library is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU Lesser General Public
|
|
# License as published by the Free Software Foundation; either
|
|
# version 2.1 of the License, or (at your option) any later version.
|
|
#
|
|
# This library is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
# Lesser General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Lesser General Public
|
|
# License along with this library; if not, write to the
|
|
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
|
# Boston MA 02110-1301 USA.
|
|
#
|
|
# Fork of Orca Screen Reader (GNOME)
|
|
# Original source: https://gitlab.gnome.org/GNOME/orca
|
|
|
|
"""Provides support for defining keybindings and matching them to input
|
|
events."""
|
|
|
|
__id__ = "$Id$"
|
|
__version__ = "$Revision$"
|
|
__date__ = "$Date$"
|
|
__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc."
|
|
__license__ = "LGPL"
|
|
|
|
from gi.repository import Gdk
|
|
|
|
import gi
|
|
gi.require_version('Atspi', '2.0')
|
|
from gi.repository import Atspi
|
|
|
|
import functools
|
|
|
|
from . import debug
|
|
from . import settings
|
|
from . import cthulhu_state
|
|
|
|
from .cthulhu_i18n import _
|
|
|
|
_keysymsCache = {}
|
|
_keycodeCache = {}
|
|
|
|
MODIFIER_CTHULHU = 8
|
|
NO_MODIFIER_MASK = 0
|
|
ALT_MODIFIER_MASK = 1 << Atspi.ModifierType.ALT
|
|
CTRL_MODIFIER_MASK = 1 << Atspi.ModifierType.CONTROL
|
|
CTHULHU_MODIFIER_MASK = 1 << MODIFIER_CTHULHU
|
|
CTHULHU_ALT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
|
|
1 << Atspi.ModifierType.ALT)
|
|
CTHULHU_CTRL_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
|
|
1 << Atspi.ModifierType.CONTROL)
|
|
CTHULHU_CTRL_ALT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
|
|
1 << Atspi.ModifierType.CONTROL |
|
|
1 << Atspi.ModifierType.ALT)
|
|
CTHULHU_SHIFT_MODIFIER_MASK = (1 << MODIFIER_CTHULHU |
|
|
1 << Atspi.ModifierType.SHIFT)
|
|
SHIFT_MODIFIER_MASK = 1 << Atspi.ModifierType.SHIFT
|
|
SHIFT_ALT_MODIFIER_MASK = (1 << Atspi.ModifierType.SHIFT |
|
|
1 << Atspi.ModifierType.ALT)
|
|
CTRL_ALT_MODIFIER_MASK = (1 << Atspi.ModifierType.CONTROL |
|
|
1 << Atspi.ModifierType.ALT)
|
|
COMMAND_MODIFIER_MASK = (1 << Atspi.ModifierType.ALT |
|
|
1 << Atspi.ModifierType.CONTROL |
|
|
1 << Atspi.ModifierType.META2 |
|
|
1 << Atspi.ModifierType.META3)
|
|
NON_LOCKING_MODIFIER_MASK = (1 << Atspi.ModifierType.SHIFT |
|
|
1 << Atspi.ModifierType.ALT |
|
|
1 << Atspi.ModifierType.CONTROL |
|
|
1 << Atspi.ModifierType.META2 |
|
|
1 << Atspi.ModifierType.META3 |
|
|
1 << MODIFIER_CTHULHU)
|
|
defaultModifierMask = NON_LOCKING_MODIFIER_MASK
|
|
|
|
def getKeycode(keysym):
|
|
"""Converts an XKeysym string (e.g., 'KP_Enter') to a keycode that
|
|
should match the event.hw_code for key events.
|
|
|
|
This whole situation is caused by the fact that Solaris chooses
|
|
to give us different keycodes for the same key, and the keypad
|
|
is the primary place where this happens: if NumLock is not on,
|
|
there is no telling the difference between keypad keys and the
|
|
other navigation keys (e.g., arrows, page up/down, etc.). One,
|
|
for example, would expect to get KP_End for the '1' key on the
|
|
keypad if NumLock were not on. Instead, we get 'End' and the
|
|
keycode for it matches the keycode for the other 'End' key. Odd.
|
|
If NumLock is on, we at least get KP_* keys.
|
|
|
|
So...when setting up keybindings, we say we're interested in
|
|
KeySyms, but those keysyms are carefully chosen so as to result
|
|
in a keycode that matches the actual key on the keyboard. This
|
|
is why we use KP_1 instead of KP_End and so on in our keybindings.
|
|
|
|
Arguments:
|
|
- keysym: a string that is a valid representation of an XKeysym.
|
|
|
|
Returns an integer representing a key code that should match the
|
|
event.hw_code for key events.
|
|
"""
|
|
|
|
if not keysym:
|
|
return 0
|
|
|
|
if keysym not in _keycodeCache:
|
|
keymap = Gdk.Keymap.get_default()
|
|
|
|
# Find the numerical value of the keysym
|
|
#
|
|
keyval = Gdk.keyval_from_name(keysym)
|
|
if keyval == 0:
|
|
return 0
|
|
|
|
# Now find the keycodes for the keysym. Since a keysym can
|
|
# be associated with more than one key, we'll shoot for the
|
|
# keysym that's in group 0, regardless of shift level (each
|
|
# entry is of the form [keycode, group, level]).
|
|
#
|
|
_keycodeCache[keysym] = 0
|
|
success, entries = keymap.get_entries_for_keyval(keyval)
|
|
|
|
for entry in entries:
|
|
if entry.group == 0:
|
|
_keycodeCache[keysym] = entry.keycode
|
|
break
|
|
if _keycodeCache[keysym] == 0:
|
|
_keycodeCache[keysym] = entries[0].keycode
|
|
|
|
#print keysym, keyval, entries, _keycodeCache[keysym]
|
|
|
|
return _keycodeCache[keysym]
|
|
|
|
def getModifierNames(mods):
|
|
"""Gets the modifier names of a numeric modifier mask as a human
|
|
consumable string.
|
|
"""
|
|
|
|
text = ""
|
|
if mods & CTHULHU_MODIFIER_MASK:
|
|
if settings.keyboardLayout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP:
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "insert" key when used as the Cthulhu modifier.
|
|
text += _("Insert") + "+"
|
|
else:
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "caps lock" modifier.
|
|
text += _("Caps_Lock") + "+"
|
|
elif mods & (1 << Atspi.ModifierType.SHIFTLOCK):
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "caps lock" modifier.
|
|
#
|
|
text += _("Caps_Lock") + "+"
|
|
#if mods & (1 << Atspi.ModifierType.NUMLOCK):
|
|
# text += _("Num_Lock") + "+"
|
|
if mods & 128:
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "right alt" modifier.
|
|
#
|
|
text += _("Alt_R") + "+"
|
|
if mods & (1 << Atspi.ModifierType.META3):
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "super" modifier.
|
|
#
|
|
text += _("Super") + "+"
|
|
if mods & (1 << Atspi.ModifierType.META2):
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "meta 2" modifier.
|
|
#
|
|
text += _("Meta2") + "+"
|
|
#if mods & (1 << Atspi.ModifierType.META):
|
|
# text += _("Meta") + "+"
|
|
if mods & ALT_MODIFIER_MASK:
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "left alt" modifier.
|
|
#
|
|
text += _("Alt_L") + "+"
|
|
if mods & CTRL_MODIFIER_MASK:
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "control" modifier.
|
|
#
|
|
text += _("Ctrl") + "+"
|
|
if mods & SHIFT_MODIFIER_MASK:
|
|
# Translators: this is presented in a GUI to represent the
|
|
# "shift " modifier.
|
|
#
|
|
text += _("Shift") + "+"
|
|
return text
|
|
|
|
def getClickCountString(count):
|
|
"""Returns a human-consumable string representing the number of
|
|
clicks, such as 'double click' and 'triple click'."""
|
|
|
|
if count == 2:
|
|
# Translators: Cthulhu keybindings support double
|
|
# and triple "clicks" or key presses, similar to
|
|
# using a mouse.
|
|
#
|
|
return _("double click")
|
|
if count == 3:
|
|
# Translators: Cthulhu keybindings support double
|
|
# and triple "clicks" or key presses, similar to
|
|
# using a mouse.
|
|
#
|
|
return _("triple click")
|
|
return ""
|
|
|
|
class KeyBinding:
|
|
"""A single key binding, consisting of a keycode, a modifier mask,
|
|
and the InputEventHandler.
|
|
"""
|
|
|
|
def __init__(self, keysymstring, modifier_mask, modifiers, handler,
|
|
click_count = 1):
|
|
"""Creates a new key binding.
|
|
|
|
Arguments:
|
|
- keysymstring: the keysymstring - this is typically a string
|
|
from /usr/include/X11/keysymdef.h with the preceding 'XK_'
|
|
removed (e.g., XK_KP_Enter becomes the string 'KP_Enter').
|
|
- modifier_mask: bit mask where a set bit tells us what modifiers
|
|
we care about (see Atspi.ModifierType.*)
|
|
- modifiers: the state the modifiers we care about must be in for
|
|
this key binding to match an input event (see also
|
|
Atspi.ModifierType.*)
|
|
- handler: the InputEventHandler for this key binding
|
|
"""
|
|
|
|
self.keysymstring = keysymstring
|
|
self.modifier_mask = modifier_mask
|
|
self.modifiers = modifiers
|
|
self.handler = handler
|
|
self.click_count = click_count
|
|
self.keycode = None
|
|
|
|
def matches(self, keycode, modifiers):
|
|
"""Returns true if this key binding matches the given keycode and
|
|
modifier state.
|
|
"""
|
|
|
|
# We lazily bind the keycode. The primary reason for doing this
|
|
# is so that atspi does not have to be initialized before setting
|
|
# keybindings in the user's preferences file.
|
|
#
|
|
if not self.keycode:
|
|
self.keycode = getKeycode(self.keysymstring)
|
|
|
|
if self.keycode == keycode:
|
|
result = modifiers & self.modifier_mask
|
|
return result == self.modifiers
|
|
else:
|
|
return False
|
|
|
|
def description(self):
|
|
"""Returns the description of this binding's functionality."""
|
|
|
|
try:
|
|
return self.handler.description
|
|
except Exception:
|
|
return ''
|
|
|
|
def asString(self):
|
|
"""Returns a more human-consumable string representing this binding."""
|
|
|
|
mods = getModifierNames(self.modifiers)
|
|
clickCount = getClickCountString(self.click_count)
|
|
keysym = self.keysymstring
|
|
string = f'{mods}{keysym} {clickCount}'
|
|
|
|
return string.strip()
|
|
|
|
def keyDefs(self):
|
|
""" return a list of Atspi key definitions for the given binding.
|
|
This may return more than one binding if the Cthulhu modifier is bound
|
|
to more than one key.
|
|
If AT-SPI is older than 2.40, then this function will not work and
|
|
will return an empty set.
|
|
"""
|
|
ret = []
|
|
if not self.keycode:
|
|
self.keycode = getKeycode(self.keysymstring)
|
|
|
|
if self.modifiers & CTHULHU_MODIFIER_MASK:
|
|
device = cthulhu_state.device
|
|
if device is None:
|
|
return ret
|
|
modList = []
|
|
otherMods = self.modifiers & ~CTHULHU_MODIFIER_MASK
|
|
numLockMod = device.get_modifier(getKeycode("Num_Lock"))
|
|
lockedMods = device.get_locked_modifiers()
|
|
numLockOn = lockedMods & numLockMod
|
|
for key in settings.cthulhuModifierKeys:
|
|
keycode = getKeycode(key)
|
|
if keycode == 0 and key == "Shift_Lock":
|
|
keycode = getKeycode("Caps_Lock")
|
|
mod = device.map_modifier(keycode)
|
|
if key != "KP_Insert" or not numLockOn:
|
|
modList.append(mod | otherMods)
|
|
else:
|
|
modList = [self.modifiers]
|
|
for mod in modList:
|
|
kd = Atspi.KeyDefinition()
|
|
kd.keycode = self.keycode
|
|
kd.modifiers = mod
|
|
ret.append(kd)
|
|
return ret
|
|
|
|
class KeyBindings:
|
|
"""Structure that maintains a set of KeyBinding instances.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.keyBindings = []
|
|
|
|
def __str__(self):
|
|
result = "[\n"
|
|
for keyBinding in self.keyBindings:
|
|
result += " [%x %x %s %d %s]\n" % \
|
|
(keyBinding.modifier_mask,
|
|
keyBinding.modifiers,
|
|
keyBinding.keysymstring,
|
|
keyBinding.click_count,
|
|
keyBinding.handler.description)
|
|
result += "]"
|
|
return result
|
|
|
|
def add(self, keyBinding):
|
|
"""Adds the given KeyBinding instance to this set of keybindings.
|
|
"""
|
|
|
|
if keyBinding.keysymstring and self.hasKeyBinding(keyBinding, "keysNoMask"):
|
|
msg = (
|
|
f"KEYBINDINGS: '{keyBinding.asString()}' "
|
|
f"({keyBinding.description()}) already in keybindings"
|
|
)
|
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
|
|
|
self.keyBindings.append(keyBinding)
|
|
|
|
def remove(self, keyBinding):
|
|
"""Removes the given KeyBinding instance from this set of keybindings.
|
|
"""
|
|
|
|
try:
|
|
i = self.keyBindings.index(keyBinding)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
del self.keyBindings[i]
|
|
|
|
def removeByHandler(self, handler):
|
|
"""Removes the given KeyBinding instance from this set of keybindings.
|
|
"""
|
|
i = len(self.keyBindings)
|
|
while i > 0:
|
|
if self.keyBindings[i - 1].handler == handler:
|
|
del self.keyBindings[i - 1]
|
|
i = i - 1
|
|
|
|
def hasKeyBinding (self, newKeyBinding, typeOfSearch="strict"):
|
|
"""Return True if keyBinding is already in self.keyBindings.
|
|
|
|
The typeOfSearch can be:
|
|
"strict": matches description, modifiers, key, and
|
|
click count
|
|
"description": matches only description.
|
|
"keys": matches the modifiers, key, and modifier mask,
|
|
and click count
|
|
"keysNoMask": matches the modifiers, key, and click count
|
|
"""
|
|
|
|
hasIt = False
|
|
|
|
for keyBinding in self.keyBindings:
|
|
if typeOfSearch == "strict":
|
|
if (keyBinding.handler.description \
|
|
== newKeyBinding.handler.description) \
|
|
and (keyBinding.keysymstring \
|
|
== newKeyBinding.keysymstring) \
|
|
and (keyBinding.modifier_mask \
|
|
== newKeyBinding.modifier_mask) \
|
|
and (keyBinding.modifiers \
|
|
== newKeyBinding.modifiers) \
|
|
and (keyBinding.click_count \
|
|
== newKeyBinding.click_count):
|
|
hasIt = True
|
|
elif typeOfSearch == "description":
|
|
if keyBinding.handler.description \
|
|
== newKeyBinding.handler.description:
|
|
hasIt = True
|
|
elif typeOfSearch == "keys":
|
|
if (keyBinding.keysymstring \
|
|
== newKeyBinding.keysymstring) \
|
|
and (keyBinding.modifier_mask \
|
|
== newKeyBinding.modifier_mask) \
|
|
and (keyBinding.modifiers \
|
|
== newKeyBinding.modifiers) \
|
|
and (keyBinding.click_count \
|
|
== newKeyBinding.click_count):
|
|
hasIt = True
|
|
elif typeOfSearch == "keysNoMask":
|
|
if (keyBinding.keysymstring \
|
|
== newKeyBinding.keysymstring) \
|
|
and (keyBinding.modifiers \
|
|
== newKeyBinding.modifiers) \
|
|
and (keyBinding.click_count \
|
|
== newKeyBinding.click_count):
|
|
hasIt = True
|
|
|
|
return hasIt
|
|
|
|
def getBoundBindings(self, uniqueOnly=False):
|
|
"""Returns the KeyBinding instances which are bound to a keystroke.
|
|
|
|
Arguments:
|
|
- uniqueOnly: Should alternative bindings for the same handler be
|
|
filtered out (default: False)
|
|
"""
|
|
|
|
bound = [kb for kb in self.keyBindings if kb.keysymstring]
|
|
if uniqueOnly:
|
|
handlers = [kb.handler.description for kb in bound]
|
|
bound = [bound[i] for i in map(handlers.index, set(handlers))]
|
|
|
|
bindings = {}
|
|
for kb in bound:
|
|
string = kb.asString()
|
|
match = bindings.get(string)
|
|
if match is not None:
|
|
tokens = ["WARNING: '", string, "' (", kb.description(), ") also matches:", match]
|
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
|
bindings[string] = kb.description()
|
|
|
|
return bound
|
|
|
|
def getBindingsForHandler(self, handler):
|
|
"""Returns the KeyBinding instances associated with handler."""
|
|
|
|
return [kb for kb in self.keyBindings if kb.handler == handler]
|
|
|
|
def _checkMatchingBindings(self, keyboardEvent, result):
|
|
if debug.debugLevel > debug.LEVEL_INFO:
|
|
return
|
|
|
|
# If we don't have multiple matches, we're good.
|
|
if len(result) <= 1:
|
|
return
|
|
|
|
# If we have multiple matches, but they have unique click counts, we're good.
|
|
if len(set(map(lambda x: x.click_count, result))) == len(result):
|
|
return
|
|
|
|
def toString(x):
|
|
return "%s (%ix)" % (x.handler.description, x.click_count)
|
|
|
|
msg = (
|
|
f"KEYBINDINGS: '{keyboardEvent.event_string}' "
|
|
f"matches multiple handlers: {', '.join(map(toString, result))}"
|
|
)
|
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
|
|
|
def getInputHandler(self, keyboardEvent):
|
|
"""Returns the input handler of the key binding that matches the
|
|
given keycode and modifiers, or None if no match exists.
|
|
"""
|
|
|
|
logger.info(f"Looking for handler for key: {keyboardEvent.hw_code} with modifiers {keyboardEvent.modifiers}")
|
|
return binding
|
|
matches = []
|
|
candidates = []
|
|
clickCount = keyboardEvent.getClickCount()
|
|
for keyBinding in self.keyBindings:
|
|
if keyBinding.matches(keyboardEvent.hw_code, keyboardEvent.modifiers):
|
|
if keyBinding.modifier_mask == keyboardEvent.modifiers and \
|
|
keyBinding.click_count == clickCount:
|
|
matches.append(keyBinding)
|
|
# If there's no keysymstring, it's unbound and cannot be
|
|
# a match.
|
|
#
|
|
if keyBinding.keysymstring:
|
|
candidates.append(keyBinding)
|
|
|
|
self._checkMatchingBindings(keyboardEvent, matches)
|
|
if matches:
|
|
return matches[0].handler
|
|
|
|
if keyboardEvent.isKeyPadKeyWithNumlockOn():
|
|
return None
|
|
|
|
# If we're still here, we don't have an exact match. Prefer
|
|
# the one whose click count is closest to, but does not exceed,
|
|
# the actual click count.
|
|
#
|
|
candidates.sort(key=functools.cmp_to_key(lambda x, y: y.click_count - x.click_count))
|
|
self._checkMatchingBindings(keyboardEvent, candidates)
|
|
for candidate in candidates:
|
|
if candidate.click_count <= clickCount:
|
|
return candidate.handler
|
|
|
|
return None
|
|
|
|
def load(self, keymap, handlers):
|
|
""" Takes the keymappings and tries to find a matching named
|
|
function in handlers.
|
|
keymap is a list of lists, each list contains 5 elements
|
|
If addUnbound is set to true, then at the end of loading all the
|
|
keybindings, any remaining functions will be unbound.
|
|
"""
|
|
|
|
# TODO - JD: This won't be needed once the remaining bindings have
|
|
# been removed from the keymap files.
|
|
|
|
for i in keymap:
|
|
keysymstring = i[0]
|
|
modifierMask = i[1]
|
|
modifiers = i[2]
|
|
handler = i[3]
|
|
try:
|
|
clickCount = i[4]
|
|
except Exception:
|
|
clickCount = 1
|
|
|
|
if handler in handlers:
|
|
self.add(KeyBinding(
|
|
keysymstring, modifierMask, modifiers, handlers[handler], clickCount))
|
|
else:
|
|
tokens = ["KEYBINDINGS: Could not find", handler, "handler for keybinding."]
|
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|