Files
cthulhu/src/cthulhu/input_event.py

1233 lines
44 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.
#
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
"""Provides support for handling input events."""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
"Copyright (c) 2011-2016 Igalia, S.L."
__license__ = "LGPL"
import gi
gi.require_version("Atspi", "2.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Atspi
import math
import time
from typing import Optional, Any
from gi.repository import Gdk
from gi.repository import GLib
from . import debug
from . import keybindings
from . import keynames
from . import messages
from . import cthulhu
from . import cthulhu_modifier_manager
from . import cthulhu_state
from . import script_manager
from . import settings
from .ax_object import AXObject
from .ax_utilities import AXUtilities
KEYBOARD_EVENT: str = "keyboard"
BRAILLE_EVENT: str = "braille"
MOUSE_BUTTON_EVENT: str = "mouse:button"
REMOTE_CONTROLLER_EVENT: str = "remote controller"
class InputEvent:
def __init__(self, eventType: str) -> None:
"""Creates a new KEYBOARD_EVENT, BRAILLE_EVENT, or MOUSE_BUTTON_EVENT."""
self.type: str = eventType
self.time: float = time.time()
self._clickCount: int = 0
def get_click_count(self) -> int:
"""Return the count of the number of clicks a user has made."""
return self._clickCount
def set_click_count(self, count: Optional[int] = None) -> None:
"""Updates the count of the number of clicks a user has made."""
if count is None:
return
self._clickCount = count
class KeyboardEvent(InputEvent):
stickyKeys: bool = False
duplicateCount: int = 0
cthulhuModifierPressed: bool = False
# Whether last press of the Cthulhu modifier was alone
lastCthulhuModifierAlone: bool = False
lastCthulhuModifierAloneTime: Optional[float] = None
# Whether the current press of the Cthulhu modifier is alone
currentCthulhuModifierAlone: bool = False
currentCthulhuModifierAloneTime: Optional[float] = None
# When the second cthulhu press happened
secondCthulhuModifierTime: Optional[float] = None
# Sticky modifiers state, to be applied to the next keyboard event
cthulhuStickyModifiers: int = 0
TYPE_UNKNOWN = "unknown"
TYPE_PRINTABLE = "printable"
TYPE_MODIFIER = "modifier"
TYPE_LOCKING = "locking"
TYPE_FUNCTION = "function"
TYPE_ACTION = "action"
TYPE_NAVIGATION = "navigation"
TYPE_DIACRITICAL = "diacritical"
TYPE_ALPHABETIC = "alphabetic"
TYPE_NUMERIC = "numeric"
TYPE_PUNCTUATION = "punctuation"
TYPE_SPACE = "space"
GDK_PUNCTUATION_KEYS = [Gdk.KEY_acute,
Gdk.KEY_ampersand,
Gdk.KEY_apostrophe,
Gdk.KEY_asciicircum,
Gdk.KEY_asciitilde,
Gdk.KEY_asterisk,
Gdk.KEY_at,
Gdk.KEY_backslash,
Gdk.KEY_bar,
Gdk.KEY_braceleft,
Gdk.KEY_braceright,
Gdk.KEY_bracketleft,
Gdk.KEY_bracketright,
Gdk.KEY_brokenbar,
Gdk.KEY_cedilla,
Gdk.KEY_cent,
Gdk.KEY_colon,
Gdk.KEY_comma,
Gdk.KEY_copyright,
Gdk.KEY_currency,
Gdk.KEY_degree,
Gdk.KEY_diaeresis,
Gdk.KEY_dollar,
Gdk.KEY_EuroSign,
Gdk.KEY_equal,
Gdk.KEY_exclam,
Gdk.KEY_exclamdown,
Gdk.KEY_grave,
Gdk.KEY_greater,
Gdk.KEY_guillemotleft,
Gdk.KEY_guillemotright,
Gdk.KEY_hyphen,
Gdk.KEY_less,
Gdk.KEY_macron,
Gdk.KEY_minus,
Gdk.KEY_notsign,
Gdk.KEY_numbersign,
Gdk.KEY_paragraph,
Gdk.KEY_parenleft,
Gdk.KEY_parenright,
Gdk.KEY_percent,
Gdk.KEY_period,
Gdk.KEY_periodcentered,
Gdk.KEY_plus,
Gdk.KEY_plusminus,
Gdk.KEY_question,
Gdk.KEY_questiondown,
Gdk.KEY_quotedbl,
Gdk.KEY_quoteleft,
Gdk.KEY_quoteright,
Gdk.KEY_registered,
Gdk.KEY_section,
Gdk.KEY_semicolon,
Gdk.KEY_slash,
Gdk.KEY_sterling,
Gdk.KEY_underscore,
Gdk.KEY_yen]
GDK_ACCENTED_LETTER_KEYS = [Gdk.KEY_Aacute,
Gdk.KEY_aacute,
Gdk.KEY_Acircumflex,
Gdk.KEY_acircumflex,
Gdk.KEY_Adiaeresis,
Gdk.KEY_adiaeresis,
Gdk.KEY_Agrave,
Gdk.KEY_agrave,
Gdk.KEY_Aring,
Gdk.KEY_aring,
Gdk.KEY_Atilde,
Gdk.KEY_atilde,
Gdk.KEY_Ccedilla,
Gdk.KEY_ccedilla,
Gdk.KEY_Eacute,
Gdk.KEY_eacute,
Gdk.KEY_Ecircumflex,
Gdk.KEY_ecircumflex,
Gdk.KEY_Ediaeresis,
Gdk.KEY_ediaeresis,
Gdk.KEY_Egrave,
Gdk.KEY_egrave,
Gdk.KEY_Iacute,
Gdk.KEY_iacute,
Gdk.KEY_Icircumflex,
Gdk.KEY_icircumflex,
Gdk.KEY_Idiaeresis,
Gdk.KEY_idiaeresis,
Gdk.KEY_Igrave,
Gdk.KEY_igrave,
Gdk.KEY_Ntilde,
Gdk.KEY_ntilde,
Gdk.KEY_Oacute,
Gdk.KEY_oacute,
Gdk.KEY_Ocircumflex,
Gdk.KEY_ocircumflex,
Gdk.KEY_Odiaeresis,
Gdk.KEY_odiaeresis,
Gdk.KEY_Ograve,
Gdk.KEY_ograve,
Gdk.KEY_Ooblique,
Gdk.KEY_ooblique,
Gdk.KEY_Otilde,
Gdk.KEY_otilde,
Gdk.KEY_Uacute,
Gdk.KEY_uacute,
Gdk.KEY_Ucircumflex,
Gdk.KEY_ucircumflex,
Gdk.KEY_Udiaeresis,
Gdk.KEY_udiaeresis,
Gdk.KEY_Ugrave,
Gdk.KEY_ugrave,
Gdk.KEY_Yacute,
Gdk.KEY_yacute]
def __init__(self, pressed, keycode, keysym, modifiers, text):
"""Creates a new InputEvent of type KEYBOARD_EVENT.
Arguments:
- pressed: True if key is pressed, False if released
- keycode: hardware keycode
- keysym: keysym value
- modifiers: modifier mask
- text: text representation of the key
"""
super().__init__(KEYBOARD_EVENT)
self.id = keysym
self.type = Atspi.EventType.KEY_PRESSED_EVENT if pressed else Atspi.EventType.KEY_RELEASED_EVENT
self.hw_code = keycode
self.modifiers = modifiers & Gdk.ModifierType.MODIFIER_MASK
if modifiers & (1 << Atspi.ModifierType.NUMLOCK):
self.modifiers |= (1 << Atspi.ModifierType.NUMLOCK)
self.event_string = text
self.keyval_name = Gdk.keyval_name(keysym)
if self.event_string == "":
self.event_string = self.keyval_name
self.timestamp = time.time() * 1000 # Convert to milliseconds
self.is_duplicate = False # Will be set by InputEventManager
self._script = None
self._app = None
self._window = None
self._obj = None
self._handler = None
self._consumer = None
self._should_consume = None
self._consume_reason = None
self._did_consume = None
self._result_reason = None
self._bypassCthulhu = None
self._is_kp_with_numlock = False
# Some implementors don't populate this field at all. More often than not,
# the event_string and the keyval_name coincide for input events.
if not self.event_string:
self.event_string = self.keyval_name
# Some implementors do populate the field, but with the keyname rather than
# the printable character. This messes us up with punctuation and other symbols.
if len(self.event_string) > 1 \
and (self.id in KeyboardEvent.GDK_PUNCTUATION_KEYS or \
self.id in KeyboardEvent.GDK_ACCENTED_LETTER_KEYS):
self.event_string = chr(self.id)
# Some implementors don't include numlock in the modifiers. Unfortunately,
# trying to heuristically hack around this just by looking at the event
# is not reliable. Ditto regarding asking Gdk for the numlock state.
if self.keyval_name.startswith("KP"):
if modifiers & (1 << Atspi.ModifierType.NUMLOCK):
self._is_kp_with_numlock = True
self.keyType = None
self.shouldEcho = False
# InputEventManager will call _finalize_initialization after setting
# script/object/window to ensure shouldConsume uses correct context.
def _finalize_initialization(self):
"""Finalize initialization after object creation.
This is separated to allow InputEventManager to set additional properties first."""
if self.is_duplicate:
KeyboardEvent.duplicateCount += 1
else:
KeyboardEvent.duplicateCount = 0
_isPressed = self.type == Atspi.EventType.KEY_PRESSED_EVENT
role = AXObject.get_role(self._obj) if self._obj else None
_mayEcho = _isPressed or role == Atspi.Role.TERMINAL
if KeyboardEvent.stickyKeys and not self.isCthulhuModifier() \
and not KeyboardEvent.lastCthulhuModifierAlone:
doubleEvent = self._getDoubleClickCandidate()
if doubleEvent and \
doubleEvent.modifiers & keybindings.CTHULHU_MODIFIER_MASK:
# this is the second event of a double-click, and sticky Cthulhu
# affected the first, so copy over the modifiers to the second
KeyboardEvent.cthulhuStickyModifiers = doubleEvent.modifiers
if not self.isCthulhuModifier():
if KeyboardEvent.cthulhuModifierPressed:
KeyboardEvent.currentCthulhuModifierAlone = False
KeyboardEvent.currentCthulhuModifierAloneTime = None
else:
KeyboardEvent.lastCthulhuModifierAlone = False
KeyboardEvent.lastCthulhuModifierAloneTime = None
if self.isNavigationKey():
self.keyType = KeyboardEvent.TYPE_NAVIGATION
self.shouldEcho = _mayEcho and settings.enableNavigationKeys
elif self.isActionKey():
self.keyType = KeyboardEvent.TYPE_ACTION
self.shouldEcho = _mayEcho and settings.enableActionKeys
elif self.is_modifier_key():
self.keyType = KeyboardEvent.TYPE_MODIFIER
self.shouldEcho = _mayEcho and settings.enableModifierKeys
if self.isCthulhuModifier() and not self.is_duplicate:
now = time.time()
if KeyboardEvent.lastCthulhuModifierAlone:
if _isPressed:
KeyboardEvent.secondCthulhuModifierTime = now
if (KeyboardEvent.secondCthulhuModifierTime is not None and
KeyboardEvent.lastCthulhuModifierAloneTime is not None and
KeyboardEvent.secondCthulhuModifierTime <
KeyboardEvent.lastCthulhuModifierAloneTime + 0.5):
# double-cthulhu, let the real action happen
if self.event_string in ["Caps_Lock", "Shift_Lock"]:
cthulhu_modifier_manager.getManager().toggleModifier(self)
else:
self._bypassCthulhu = True
if not _isPressed:
KeyboardEvent.lastCthulhuModifierAlone = False
KeyboardEvent.lastCthulhuModifierAloneTime = False
else:
KeyboardEvent.cthulhuModifierPressed = _isPressed
if _isPressed:
KeyboardEvent.currentCthulhuModifierAlone = True
KeyboardEvent.currentCthulhuModifierAloneTime = now
else:
KeyboardEvent.lastCthulhuModifierAlone = \
KeyboardEvent.currentCthulhuModifierAlone
KeyboardEvent.lastCthulhuModifierAloneTime = \
KeyboardEvent.currentCthulhuModifierAloneTime
elif self.isFunctionKey():
self.keyType = KeyboardEvent.TYPE_FUNCTION
self.shouldEcho = _mayEcho and settings.enableFunctionKeys
elif self.isDiacriticalKey():
self.keyType = KeyboardEvent.TYPE_DIACRITICAL
self.shouldEcho = _mayEcho and settings.enableDiacriticalKeys
elif self.isLockingKey():
self.keyType = KeyboardEvent.TYPE_LOCKING
self.shouldEcho = settings.presentLockingKeys
if self.shouldEcho is None:
self.shouldEcho = not settings.onlySpeakDisplayedText
self.shouldEcho = self.shouldEcho and _isPressed
elif self.isAlphabeticKey():
self.keyType = KeyboardEvent.TYPE_ALPHABETIC
self.shouldEcho = _mayEcho \
and (settings.enableAlphabeticKeys or settings.enableEchoByCharacter)
elif self.isNumericKey():
self.keyType = KeyboardEvent.TYPE_NUMERIC
self.shouldEcho = _mayEcho \
and (settings.enableNumericKeys or settings.enableEchoByCharacter)
elif self.isPunctuationKey():
self.keyType = KeyboardEvent.TYPE_PUNCTUATION
self.shouldEcho = _mayEcho \
and (settings.enablePunctuationKeys or settings.enableEchoByCharacter)
elif self.isSpace():
self.keyType = KeyboardEvent.TYPE_SPACE
self.shouldEcho = _mayEcho \
and (settings.enableSpace or settings.enableEchoByCharacter)
else:
self.keyType = KeyboardEvent.TYPE_UNKNOWN
self.shouldEcho = False
if not self.isLockingKey():
self.shouldEcho = self.shouldEcho and settings.enableKeyEcho
if not self.is_modifier_key():
self.set_click_count()
if cthulhu_state.bypassNextCommand and _isPressed:
KeyboardEvent.cthulhuModifierPressed = False
if KeyboardEvent.cthulhuModifierPressed:
self.modifiers |= keybindings.CTHULHU_MODIFIER_MASK
if KeyboardEvent.stickyKeys:
# apply all recorded sticky modifiers
self.modifiers |= KeyboardEvent.cthulhuStickyModifiers
if self.is_modifier_key():
# add this modifier to the sticky ones
KeyboardEvent.cthulhuStickyModifiers |= self.modifiers
else:
# Non-modifier key, so clear the sticky modifiers. If the user
# actually double-presses that key, the modifiers of this event
# will be copied over to the second event, see earlier in this
# function.
KeyboardEvent.cthulhuStickyModifiers = 0
self._should_consume, self._consume_reason = self.shouldConsume()
def _getDoubleClickCandidate(self):
lastEvent = cthulhu_state.lastNonModifierKeyEvent
if isinstance(lastEvent, KeyboardEvent) \
and lastEvent.event_string == self.event_string \
and self.time - lastEvent.time <= settings.doubleClickTimeout:
return lastEvent
return None
def set_click_count(self, count=None):
"""Updates the count of the number of clicks a user has made.
If count is provided, sets the click count to that value.
Otherwise, calculates the click count based on event timing."""
if count is not None:
self._clickCount = count
return
doubleEvent = self._getDoubleClickCandidate()
if not doubleEvent:
self._clickCount = 1
return
self._clickCount = doubleEvent.get_click_count()
if self.is_duplicate:
return
if self.type == Atspi.EventType.KEY_RELEASED_EVENT:
return
if self._clickCount < 3:
self._clickCount += 1
return
self._clickCount = 1
def __eq__(self, other):
if not other:
return False
if self.type == other.type and self.hw_code == other.hw_code:
return self.timestamp == other.timestamp
return False
def __str__(self):
if self._shouldObscure():
keyid = hw_code = modifiers = event_string = keyval_name = key_type = "*"
else:
keyid = self.id
hw_code = self.hw_code
modifiers = self.modifiers
event_string = self.event_string
keyval_name = self.keyval_name
key_type = self.keyType
return (f"KEYBOARD_EVENT: type={self.type.value_name.upper()}\n") \
+ f" id={keyid}\n" \
+ f" hw_code={hw_code}\n" \
+ f" modifiers={modifiers}\n" \
+ f" event_string=({event_string})\n" \
+ f" keyval_name=({keyval_name})\n" \
+ (" timestamp=%d\n" % self.timestamp) \
+ f" time={time.time():f}\n" \
+ f" keyType={key_type}\n" \
+ f" clickCount={self._clickCount}\n" \
+ f" shouldEcho={self.shouldEcho}\n"
def _shouldObscure(self):
if not AXUtilities.is_password_text(self._obj):
return False
if not self.is_printable_key():
return False
if self.modifiers & keybindings.CTRL_MODIFIER_MASK \
or self.modifiers & keybindings.ALT_MODIFIER_MASK \
or self.modifiers & keybindings.CTHULHU_MODIFIER_MASK:
return False
return True
def _isReleaseForLastNonModifierKeyEvent(self):
last = cthulhu_state.lastNonModifierKeyEvent
if not last:
return False
if not last.is_pressed_key() or self.is_pressed_key():
return False
if self.id == last.id and self.hw_code == last.hw_code:
return self.modifiers == last.modifiers
return False
def isReleaseFor(self, other):
"""Return True if this is the release event for other."""
if not other:
return False
if not other.is_pressed_key() or self.is_pressed_key():
return False
return self.id == other.id \
and self.hw_code == other.hw_code \
and self.modifiers == other.modifiers \
and self.event_string == other.event_string \
and self.keyval_name == other.keyval_name \
and self.keyType == other.keyType \
and self._clickCount == other._clickCount
def isNavigationKey(self):
"""Return True if this is a navigation key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_NAVIGATION
return self.event_string in \
["Left", "Right", "Up", "Down", "Home", "End"]
def isActionKey(self):
"""Return True if this is an action key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_ACTION
return self.event_string in \
["Return", "Escape", "Tab", "BackSpace", "Delete",
"Page_Up", "Page_Down"]
def isAlphabeticKey(self):
"""Return True if this is an alphabetic key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_ALPHABETIC
if not len(self.event_string) == 1:
return False
return self.event_string.isalpha()
def isDiacriticalKey(self):
"""Return True if this is a non-spacing diacritical key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_DIACRITICAL
return self.event_string.startswith("dead_")
def isFunctionKey(self):
"""Return True if this is a function key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_FUNCTION
return self.event_string in \
["F1", "F2", "F3", "F4", "F5", "F6",
"F7", "F8", "F9", "F10", "F11", "F12"]
def isLockingKey(self):
"""Return True if this is a locking key."""
if self.keyType:
return self.keyType in KeyboardEvent.TYPE_LOCKING
lockingKeys = ["Caps_Lock", "Shift_Lock", "Num_Lock", "Scroll_Lock"]
if self.event_string not in lockingKeys:
return False
if not cthulhu_state.bypassNextCommand and not self._bypassCthulhu:
return self.event_string not in settings.cthulhuModifierKeys
return True
def is_modifier_key(self):
"""Return True if this is a modifier key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_MODIFIER
if self.isCthulhuModifier():
return True
return self.event_string in \
['Alt_L', 'Alt_R', 'Control_L', 'Control_R',
'Shift_L', 'Shift_R', 'Meta_L', 'Meta_R',
'ISO_Level3_Shift']
def isNumericKey(self):
"""Return True if this is a numeric key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_NUMERIC
if not len(self.event_string) == 1:
return False
return self.event_string.isnumeric()
def isCthulhuModifier(self, checkBypassMode=True):
"""Return True if this is the Cthulhu modifier key."""
if checkBypassMode and cthulhu_state.bypassNextCommand:
return False
if self.event_string in settings.cthulhuModifierKeys:
return True
if self.keyval_name == "KP_0" \
and "KP_Insert" in settings.cthulhuModifierKeys \
and self.modifiers & keybindings.SHIFT_MODIFIER_MASK:
return True
return False
def isCthulhuModified(self):
"""Return True if this key is Cthulhu modified."""
if cthulhu_state.bypassNextCommand:
return False
return self.modifiers & keybindings.CTHULHU_MODIFIER_MASK
def isKeyPadKeyWithNumlockOn(self):
"""Return True if this is a key pad key with numlock on."""
return self._is_kp_with_numlock
def is_printable_key(self):
"""Return True if this is a printable key."""
if self.event_string in ["space", " "]:
return True
if not len(self.event_string) == 1:
return False
return self.event_string.isprintable()
def is_pressed_key(self):
"""Returns True if the key is pressed"""
return self.type == Atspi.EventType.KEY_PRESSED_EVENT
def isPunctuationKey(self):
"""Return True if this is a punctuation key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_PUNCTUATION
if not len(self.event_string) == 1:
return False
if self.isAlphabeticKey() or self.isNumericKey():
return False
return self.event_string.isprintable() and not self.event_string.isspace()
def isSpace(self):
"""Return True if this is the space key."""
if self.keyType:
return self.keyType == KeyboardEvent.TYPE_SPACE
return self.event_string in ["space", " "]
def isFromApplication(self, app):
"""Return True if this is associated with the specified app."""
return self._app == app
def isCharacterEchoable(self):
"""Returns True if the script will echo this event as part of
character echo. We do this to not double-echo a given printable
character."""
if not self.is_printable_key():
return False
script = cthulhu_state.activeScript
return script and script.utilities.willEchoCharacter(self)
def getLockingState(self):
"""Returns True if the event locked a locking key, False if the
event unlocked a locking key, and None if we do not know or this
is not a locking key."""
if not self.isLockingKey():
return None
if self.event_string == "Caps_Lock":
mod = Atspi.ModifierType.SHIFTLOCK
elif self.event_string == "Shift_Lock":
mod = Atspi.ModifierType.SHIFT
elif self.event_string == "Num_Lock":
mod = Atspi.ModifierType.NUMLOCK
else:
return None
return not self.modifiers & (1 << mod)
def getLockingStateString(self):
"""Returns the string which reflects the locking state we wish to
include when presenting a locking key."""
locked = self.getLockingState()
if locked is None:
return ''
if not locked:
return messages.LOCKING_KEY_STATE_OFF
return messages.LOCKING_KEY_STATE_ON
def getKeyName(self):
"""Returns the string to be used for presenting the key to the user."""
return keynames.getKeyName(self.event_string)
def get_object(self):
"""Returns the object believed to be associated with this key event."""
return self._obj
def set_object(self, obj):
"""Sets the object believed to be associated with this key event."""
self._obj = obj
def get_window(self):
"""Returns the window associated with this key event."""
return self._window
def set_window(self, window):
"""Sets the window associated with this key event."""
self._window = window
def get_script(self):
"""Returns the script associated with this key event."""
return self._script
def set_script(self, script):
"""Sets the script associated with this key event."""
self._script = script
if script:
self._app = script.app
def get_click_count(self):
"""Returns the click count for this event."""
return self._clickCount
def as_single_line_string(self):
"""Returns a single-line string representation of this event."""
return f"KeyboardEvent({self.keyval_name}, pressed={self.is_pressed_key()}, modifiers={self.modifiers})"
def getHandler(self):
"""Returns the handler associated with this key event."""
return self._handler
def _resolveHandler(self):
"""Resolve handler for this event, returning True if a global handler was used."""
if not self._handler and self._script:
self._handler = self._script.keyBindings.getInputHandler(self)
if not self._handler:
globalHandler = self._getGlobalHandler()
if globalHandler:
self._handler = globalHandler
return True
return False
def shouldConsume(self):
"""Returns True if this event should be consumed."""
# Debug logging to understand handler matching
debug.print_log(
debug.LEVEL_INFO,
"INPUT EVENT",
f"shouldConsume: key='{self.event_string}' hw_code={self.hw_code} modifiers={self.modifiers}",
timestamp=True,
)
if not self.timestamp:
return False, 'No timestamp'
globalHandlerUsed = False
if not self._script:
globalHandler = self._getGlobalHandler()
if globalHandler:
self._handler = globalHandler
self._script = script_manager.get_manager().get_default_script()
globalHandlerUsed = True
else:
debug.print_log(debug.LEVEL_INFO, "INPUT EVENT",
"shouldConsume: No active script",
reason="no-active-script", timestamp=True)
return False, 'No active script when received'
debug.print_log(debug.LEVEL_INFO, "INPUT EVENT",
f"shouldConsume: Active script={self._script.__class__.__name__}",
timestamp=True)
if self.is_duplicate:
return False, 'Is duplicate'
if cthulhu_state.capturingKeys:
return False, 'Capturing keys'
if cthulhu_state.bypassNextCommand:
return False, 'Bypass next command'
globalHandlerUsed = globalHandlerUsed or self._resolveHandler()
if self._handler:
debug.print_log(debug.LEVEL_INFO, "INPUT EVENT",
f"shouldConsume: Handler found: {self._handler.description}",
timestamp=True)
else:
debug.print_log(debug.LEVEL_INFO, "INPUT EVENT",
"shouldConsume: No handler found",
reason="no-handler", timestamp=True)
if self._isSleepModeActive():
if self._isSleepModeToggleHandler():
return True, 'Sleep mode toggle command'
return False, 'Sleep mode active'
self._script.updateKeyboardEventState(self, self._handler)
scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler)
if globalHandlerUsed:
scriptConsumes = True
debug.print_log(debug.LEVEL_INFO, "INPUT EVENT",
f"shouldConsume: scriptConsumes={scriptConsumes}",
timestamp=True)
if self._isReleaseForLastNonModifierKeyEvent():
return scriptConsumes, 'Is release for last non-modifier keyevent'
if self._script.learnModePresenter.is_active():
self._consumer = self._script.learnModePresenter.handle_event
return True, 'In Learn Mode'
if self.is_modifier_key():
if not self.isCthulhuModifier():
return False, 'Non-Cthulhu modifier not in Learn Mode'
return True, 'Cthulhu modifier'
if not self._handler:
return False, 'No handler'
return scriptConsumes, 'Script indication'
def _getGlobalHandler(self):
try:
plugin_manager = cthulhu.cthulhuApp.getPluginSystemManager()
except Exception:
return None
if not plugin_manager:
return None
global_bindings = plugin_manager.get_global_keybindings()
if not global_bindings:
return None
return global_bindings.getInputHandler(self)
def _isSleepModeActive(self):
"""Returns True if the script for this event is in sleep mode."""
if not self._script:
return False
if "scripts.sleepmode" in self._script.__module__:
return True
app = getattr(self._script, "app", None)
if app is None:
return False
try:
from . import sleep_mode_manager
manager = sleep_mode_manager.getManager()
return bool(manager and manager.isActiveForApp(app))
except Exception:
return False
def _isSleepModeToggleHandler(self):
"""Returns True if the resolved handler toggles sleep mode."""
if not self._handler or not self._handler.function:
return False
functionName = getattr(self._handler.function, "__name__", "")
return "toggleSleepMode" in functionName
def didConsume(self):
"""Returns True if this event was consumed."""
if self._did_consume is not None:
return self._did_consume
return False
def isHandledBy(self, method):
if not self._handler:
return False
return method.__func__ == self._handler.function
def _should_interrupt_presentation_on_press(self):
if not settings.gameMode:
return True
if cthulhu_state.bypassNextCommand and not self.is_modifier_key():
return False
if self._handler or self._consumer:
return True
if self.isCthulhuModifier():
return True
return False
def _present(self, inputEvent=None):
if self.is_pressed_key():
if self._should_interrupt_presentation_on_press():
self._script.presentationInterrupt()
if self._script.learnModePresenter.is_active():
return False
return self._script.presentKeyboardEvent(self)
def process(self):
"""Processes this input event."""
startTime = time.time()
if not self._shouldObscure():
data = "'%s' (%d)" % (self.event_string, self.hw_code)
else:
data = "(obscured)"
if self.is_duplicate:
data = '%s DUPLICATE EVENT #%i' % (data, KeyboardEvent.duplicateCount)
msg = f'\nvvvvv PROCESS {self.type.value_name.upper()}: {data} vvvvv'
debug.printMessage(debug.LEVEL_INFO, msg, False)
tokens = ["HOST_APP:", self._app]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
tokens = ["WINDOW:", self._window]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
tokens = ["LOCATION:", self._obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
tokens = ["CONSUME:", self._should_consume, self._consume_reason]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
self._did_consume, self._result_reason = self._process()
if self._should_consume != self._did_consume:
tokens = ["CONSUMED:", self._did_consume, self._result_reason]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if debug.LEVEL_INFO >= debug.debugLevel and cthulhu_state.activeScript:
attributes = cthulhu_state.activeScript.getTransferableAttributes()
for key, value in attributes.items():
msg = f"INPUT EVENT: {key}: {value}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
msg = f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
msg = f"^^^^^ PROCESS {self.type.value_name.upper()}: {data} ^^^^^\n"
debug.printMessage(debug.LEVEL_INFO, msg, False)
return self._did_consume
def _process(self):
"""Processes this input event."""
if self._bypassCthulhu:
if (self.event_string == "Caps_Lock" \
or self.event_string == "Shift_Lock") \
and self.type == Atspi.EventType.KEY_PRESSED_EVENT:
self.keyType = KeyboardEvent.TYPE_LOCKING
self._present()
return False, 'Bypassed cthulhu modifier'
cthulhu_state.lastInputEvent = self
if not self.is_modifier_key():
cthulhu_state.lastNonModifierKeyEvent = self
if not self._script:
return False, 'No active script'
if self.is_duplicate:
return False, 'Is duplicate'
self._present()
if not self.is_pressed_key():
return self._should_consume, 'Consumed based on handler'
if cthulhu_state.capturingKeys:
return False, 'Capturing keys'
if self.isCthulhuModifier():
return True, 'Cthulhu modifier'
if cthulhu_state.bypassNextCommand:
if not self.is_modifier_key():
cthulhu_state.bypassNextCommand = False
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Bypass next command disabled")
self._script.addKeyGrabs()
return False, 'Bypass next command'
if not self._should_consume:
return False, 'Should not consume'
if not (self._consumer or self._handler):
return False, 'No consumer or handler'
if self._consumer or self._handler.function:
GLib.timeout_add(1, self._consume)
return True, 'Will be consumed'
return False, 'Unaddressed case'
def _consume(self):
startTime = time.time()
data = "'%s' (%d)" % (self.event_string, self.hw_code)
msg = f'vvvvv CONSUME {self.type.value_name.upper()}: {data} vvvvv'
debug.printMessage(debug.LEVEL_INFO, msg, False)
if self._consumer:
msg = f'INFO: Consumer is {self._consumer.__name__}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._consumer(self)
elif self._handler.function:
msg = f'INFO: Handler is {self._handler.description}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._handler.function(self._script, self)
else:
msg = 'INFO: No handler or consumer'
debug.printMessage(debug.LEVEL_INFO, msg, True)
msg = f'TOTAL PROCESSING TIME: {time.time() - startTime:.4f}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
msg = f'^^^^^ CONSUME {self.type.value_name.upper()}: {data} ^^^^^'
debug.printMessage(debug.LEVEL_INFO, msg, False)
return False
class BrailleEvent(InputEvent):
def __init__(self, event):
"""Creates a new InputEvent of type BRAILLE_EVENT.
Arguments:
- event: the integer BrlTTY command for this event.
"""
super().__init__(BRAILLE_EVENT)
self.event = event
class MouseButtonEvent(InputEvent):
try:
display = Gdk.Display.get_default()
seat = Gdk.Display.get_default_seat(display)
_pointer = seat.get_pointer()
except Exception:
_pointer = None
def __init__(self, event):
"""Creates a new InputEvent of type MOUSE_BUTTON_EVENT."""
super().__init__(MOUSE_BUTTON_EVENT)
self.x = event.detail1
self.y = event.detail2
self.pressed = event.type.endswith('p')
self.button = event.type[len("mouse:button:"):-1]
self._script = cthulhu_state.activeScript
self.window = cthulhu_state.activeWindow
self.obj = None
if self.pressed:
self._validateCoordinates()
if not self._script:
return
if not self._script.utilities.canBeActiveWindow(self.window):
self.window = self._script.utilities.activeWindow()
if not self.window:
return
self.obj = self._script.utilities.descendantAtPoint(
self.window, self.x, self.y, event.any_data)
def _validateCoordinates(self):
if not self._pointer:
return
screen, x, y = self._pointer.get_position()
if math.sqrt((self.x - x)**2 + (self.y - y)**2) < 25:
return
msg = (
f"WARNING: Event coordinates ({self.x}, {self.y}) may be bogus. "
f"Updating to ({x}, {y})"
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
self.x, self.y = x, y
def set_click_count(self, count=None):
"""Updates the count of the number of clicks a user has made."""
if count is not None:
self._clickCount = count
return
if not self.pressed:
return
lastInputEvent = cthulhu_state.lastInputEvent
if not isinstance(lastInputEvent, MouseButtonEvent):
self._clickCount = 1
return
if self.time - lastInputEvent.time < settings.doubleClickTimeout \
and lastInputEvent.button == self.button:
if self._clickCount < 2:
self._clickCount += 1
return
self._clickCount = 1
class RemoteControllerEvent(InputEvent):
"""A simple input event whose main purpose is identification of the origin."""
def __init__(self):
super().__init__(REMOTE_CONTROLLER_EVENT)
class InputEventHandler:
def __init__(self, function, description, learnModeEnabled=True, enabled=True):
"""Creates a new InputEventHandler instance. All bindings
(e.g., key bindings and braille bindings) will be handled
by an instance of an InputEventHandler.
Arguments:
- function: the function to call with an InputEvent instance as its
sole argument. The function is expected to return True
if it consumes the event; otherwise it should return
False
- description: a localized string describing what this InputEvent
does
- learnModeEnabled: if True, the description will be spoken and
brailled if learn mode is enabled. If False,
the function will be called no matter what.
"""
self.function = function
self.description = description
self.learnModeEnabled = learnModeEnabled
self._enabled = enabled
def is_enabled(self):
"""Returns True if this handler is enabled."""
return self._enabled
def set_enabled(self, enabled):
"""Sets enabled state of this handler."""
self._enabled = enabled
def __eq__(self, other):
"""Compares one input handler to another."""
if not other:
return False
return (self.function == other.function)
def processInputEvent(self, script, inputEvent):
"""Processes an input event.
This function is expected to return True if it consumes the
event; otherwise it is expected to return False.
Arguments:
- script: the script (if any) associated with this event
- inputEvent: the input event to pass to the function bound
to this InputEventHandler instance.
"""
consumed = False
try:
consumed = self.function(script, inputEvent)
except Exception:
debug.printException(debug.LEVEL_SEVERE)
return consumed