Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8312a842c1 | |||
| 07138197cb | |||
| 4add36f5ca | |||
| 40e63150a6 | |||
| 4dba0ec0cd | |||
| e6f780c38b | |||
| 0f7f73a6a0 | |||
| aa71d02036 | |||
| 51ef3de672 | |||
| c6049ef5f3 | |||
| 78ef51d01f | |||
| 30a40f6974 |
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||||
|
|
||||||
pkgname=cthulhu
|
pkgname=cthulhu
|
||||||
pkgver=2026.01.19
|
pkgver=2026.02.17
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
||||||
url="https://git.stormux.org/storm/cthulhu"
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
project('cthulhu',
|
project('cthulhu',
|
||||||
version: '2026.01.19-testing',
|
version: '2026.02.17-master',
|
||||||
meson_version: '>= 1.0.0',
|
meson_version: '>= 1.0.0',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
# Forked from Orca screen reader.
|
# Forked from Orca screen reader.
|
||||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||||
|
|
||||||
version = "2026.01.19"
|
version = "2026.02.17"
|
||||||
codeName = "testing"
|
codeName = "master"
|
||||||
|
|||||||
@@ -844,6 +844,11 @@ class KeyboardEvent(InputEvent):
|
|||||||
"shouldConsume: No handler found",
|
"shouldConsume: No handler found",
|
||||||
reason="no-handler", timestamp=True)
|
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)
|
self._script.updateKeyboardEventState(self, self._handler)
|
||||||
scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler)
|
scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler)
|
||||||
if globalHandlerUsed:
|
if globalHandlerUsed:
|
||||||
@@ -881,6 +886,35 @@ class KeyboardEvent(InputEvent):
|
|||||||
return None
|
return None
|
||||||
return global_bindings.getInputHandler(self)
|
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):
|
def didConsume(self):
|
||||||
"""Returns True if this event was consumed."""
|
"""Returns True if this event was consumed."""
|
||||||
|
|
||||||
@@ -1139,7 +1173,7 @@ class RemoteControllerEvent(InputEvent):
|
|||||||
|
|
||||||
class InputEventHandler:
|
class InputEventHandler:
|
||||||
|
|
||||||
def __init__(self, function, description, learnModeEnabled=True):
|
def __init__(self, function, description, learnModeEnabled=True, enabled=True):
|
||||||
"""Creates a new InputEventHandler instance. All bindings
|
"""Creates a new InputEventHandler instance. All bindings
|
||||||
(e.g., key bindings and braille bindings) will be handled
|
(e.g., key bindings and braille bindings) will be handled
|
||||||
by an instance of an InputEventHandler.
|
by an instance of an InputEventHandler.
|
||||||
@@ -1159,6 +1193,15 @@ class InputEventHandler:
|
|||||||
self.function = function
|
self.function = function
|
||||||
self.description = description
|
self.description = description
|
||||||
self.learnModeEnabled = learnModeEnabled
|
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):
|
def __eq__(self, other):
|
||||||
"""Compares one input handler to another."""
|
"""Compares one input handler to another."""
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ class InputEventManager:
|
|||||||
|
|
||||||
msg = "INPUT EVENT MANAGER: Starting key watcher."
|
msg = "INPUT EVENT MANAGER: Starting key watcher."
|
||||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
self._device = Atspi.Device.new_full("org.stormux.Cthulhu")
|
if Atspi.get_version() >= (2, 55, 90):
|
||||||
|
self._device = Atspi.Device.new_full("org.stormux.Cthulhu")
|
||||||
|
else:
|
||||||
|
self._device = Atspi.Device.new()
|
||||||
self._device.add_key_watcher(self.process_keyboard_event)
|
self._device.add_key_watcher(self.process_keyboard_event)
|
||||||
|
|
||||||
def stop_key_watcher(self) -> None:
|
def stop_key_watcher(self) -> None:
|
||||||
@@ -100,43 +103,30 @@ class InputEventManager:
|
|||||||
msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}"
|
msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}"
|
||||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
def _get_key_definitions(self, binding: keybindings.KeyBinding) -> List[Atspi.KeyDefinition]:
|
|
||||||
if hasattr(binding, "key_definitions"):
|
|
||||||
return list(binding.key_definitions())
|
|
||||||
if hasattr(binding, "keyDefs"):
|
|
||||||
return list(binding.keyDefs())
|
|
||||||
return []
|
|
||||||
|
|
||||||
def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> List[int]:
|
def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> List[int]:
|
||||||
"""Adds grabs for binding if it is enabled, returns grab IDs."""
|
"""Adds grabs for binding if it is enabled, returns grab IDs."""
|
||||||
|
|
||||||
|
if not (binding.is_enabled() and binding.is_bound()):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if binding.has_grabs():
|
||||||
|
tokens = ["INPUT EVENT MANAGER:", binding, "already has grabs."]
|
||||||
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return []
|
||||||
|
|
||||||
if self._device is None:
|
if self._device is None:
|
||||||
tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding]
|
tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding]
|
||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
grab_ids = []
|
grab_ids = []
|
||||||
key_definitions = self._get_key_definitions(binding)
|
for kd in binding.key_definitions():
|
||||||
if not key_definitions:
|
|
||||||
tokens = ["INPUT EVENT MANAGER: No key definitions for", binding]
|
|
||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
for kd in key_definitions:
|
|
||||||
grab_id = self._device.add_key_grab(kd, None)
|
grab_id = self._device.add_key_grab(kd, None)
|
||||||
# When we have double/triple-click bindings, the single-click binding will be
|
|
||||||
# registered first, and subsequent attempts to register what is externally the
|
|
||||||
# same grab will fail. If we only have a double/triple-click, it succeeds.
|
|
||||||
# A grab id of 0 indicates failure.
|
|
||||||
if grab_id == 0:
|
if grab_id == 0:
|
||||||
continue
|
continue
|
||||||
grab_ids.append(grab_id)
|
grab_ids.append(grab_id)
|
||||||
self._grabbed_bindings[grab_id] = binding
|
self._grabbed_bindings[grab_id] = binding
|
||||||
|
|
||||||
if grab_ids:
|
|
||||||
tokens = ["INPUT EVENT MANAGER: Added grabs", grab_ids, "for", binding]
|
|
||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
|
||||||
|
|
||||||
return grab_ids
|
return grab_ids
|
||||||
|
|
||||||
def remove_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> None:
|
def remove_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> None:
|
||||||
@@ -147,30 +137,18 @@ class InputEventManager:
|
|||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
return
|
return
|
||||||
|
|
||||||
grab_ids = None
|
grab_ids = binding.get_grab_ids()
|
||||||
if hasattr(binding, "get_grab_ids"):
|
|
||||||
grab_ids = binding.get_grab_ids()
|
|
||||||
elif hasattr(binding, "_grab_ids"):
|
|
||||||
grab_ids = list(binding._grab_ids)
|
|
||||||
|
|
||||||
if not grab_ids:
|
if not grab_ids:
|
||||||
tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."]
|
tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."]
|
||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
return
|
return
|
||||||
|
|
||||||
tokens = ["INPUT EVENT MANAGER: Removing grabs", grab_ids, "for", binding]
|
|
||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
|
||||||
|
|
||||||
for grab_id in grab_ids:
|
for grab_id in grab_ids:
|
||||||
self._device.remove_key_grab(grab_id)
|
self._device.remove_key_grab(grab_id)
|
||||||
removed = self._grabbed_bindings.pop(grab_id, None)
|
removed = self._grabbed_bindings.pop(grab_id, None)
|
||||||
if removed is None:
|
if removed is None:
|
||||||
msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}"
|
msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}"
|
||||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
if hasattr(binding, "_grab_ids") and grab_id in binding._grab_ids:
|
|
||||||
binding._grab_ids.remove(grab_id)
|
|
||||||
if hasattr(binding, "_grab_ids") and not binding._grab_ids:
|
|
||||||
delattr(binding, "_grab_ids")
|
|
||||||
|
|
||||||
def remove_grab_by_id(self, grab_id: int) -> None:
|
def remove_grab_by_id(self, grab_id: int) -> None:
|
||||||
"""Removes a grab by id."""
|
"""Removes a grab by id."""
|
||||||
@@ -182,10 +160,8 @@ class InputEventManager:
|
|||||||
|
|
||||||
self._device.remove_key_grab(grab_id)
|
self._device.remove_key_grab(grab_id)
|
||||||
binding = self._grabbed_bindings.pop(grab_id, None)
|
binding = self._grabbed_bindings.pop(grab_id, None)
|
||||||
if binding and hasattr(binding, "_grab_ids") and grab_id in binding._grab_ids:
|
if binding and grab_id in binding._grab_ids:
|
||||||
binding._grab_ids.remove(grab_id)
|
binding._grab_ids.remove(grab_id)
|
||||||
if not binding._grab_ids:
|
|
||||||
delattr(binding, "_grab_ids")
|
|
||||||
if binding:
|
if binding:
|
||||||
tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding]
|
tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding]
|
||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
|||||||
+162
-153
@@ -26,6 +26,8 @@
|
|||||||
"""Provides support for defining keybindings and matching them to input
|
"""Provides support for defining keybindings and matching them to input
|
||||||
events."""
|
events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
__id__ = "$Id$"
|
__id__ = "$Id$"
|
||||||
__version__ = "$Revision$"
|
__version__ = "$Revision$"
|
||||||
__date__ = "$Date$"
|
__date__ = "$Date$"
|
||||||
@@ -39,6 +41,7 @@ from gi.repository import Gdk
|
|||||||
from gi.repository import Atspi
|
from gi.repository import Atspi
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from . import debug
|
from . import debug
|
||||||
from . import settings
|
from . import settings
|
||||||
@@ -46,6 +49,9 @@ from . import cthulhu_state
|
|||||||
|
|
||||||
from .cthulhu_i18n import _
|
from .cthulhu_i18n import _
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .input_event import KeyboardEvent, InputEventHandler
|
||||||
|
|
||||||
_keysymsCache = {}
|
_keysymsCache = {}
|
||||||
_keycodeCache = {}
|
_keycodeCache = {}
|
||||||
|
|
||||||
@@ -80,63 +86,35 @@ NON_LOCKING_MODIFIER_MASK = (1 << Atspi.ModifierType.SHIFT |
|
|||||||
1 << MODIFIER_CTHULHU)
|
1 << MODIFIER_CTHULHU)
|
||||||
defaultModifierMask = NON_LOCKING_MODIFIER_MASK
|
defaultModifierMask = NON_LOCKING_MODIFIER_MASK
|
||||||
|
|
||||||
def getKeycode(keysym):
|
CAN_USE_KEYSYMS = Atspi.get_version() >= (2, 55, 0)
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
def get_keycodes(keysym):
|
||||||
|
"""Converts an XKeysym string to a (keyval, keycode) tuple."""
|
||||||
if not keysym:
|
if not keysym:
|
||||||
return 0
|
return (0, 0)
|
||||||
|
|
||||||
if keysym not in _keycodeCache:
|
if keysym not in _keycodeCache:
|
||||||
keymap = Gdk.Keymap.get_default()
|
keymap = Gdk.Keymap.get_default()
|
||||||
|
|
||||||
# Find the numerical value of the keysym
|
|
||||||
#
|
|
||||||
keyval = Gdk.keyval_from_name(keysym)
|
keyval = Gdk.keyval_from_name(keysym)
|
||||||
if keyval == 0:
|
if keyval == 0:
|
||||||
return 0
|
return (0, 0)
|
||||||
|
|
||||||
# Now find the keycodes for the keysym. Since a keysym can
|
_keycodeCache[keysym] = (keyval, 0)
|
||||||
# be associated with more than one key, we'll shoot for the
|
_success, entries = keymap.get_entries_for_keyval(keyval)
|
||||||
# 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:
|
for entry in entries:
|
||||||
if entry.group == 0:
|
if entry.group == 0:
|
||||||
_keycodeCache[keysym] = entry.keycode
|
_keycodeCache[keysym] = (keyval, entry.keycode)
|
||||||
break
|
break
|
||||||
if _keycodeCache[keysym] == 0:
|
if _keycodeCache[keysym] == (keyval, 0):
|
||||||
_keycodeCache[keysym] = entries[0].keycode
|
_keycodeCache[keysym] = (keyval, entries[0].keycode)
|
||||||
|
|
||||||
#print keysym, keyval, entries, _keycodeCache[keysym]
|
|
||||||
|
|
||||||
return _keycodeCache[keysym]
|
return _keycodeCache[keysym]
|
||||||
|
|
||||||
|
def getKeycode(keysym):
|
||||||
|
"""Converts an XKeysym string to a keycode. Legacy wrapper."""
|
||||||
|
return get_keycodes(keysym)[1]
|
||||||
|
|
||||||
def getModifierNames(mods):
|
def getModifierNames(mods):
|
||||||
"""Gets the modifier names of a numeric modifier mask as a human
|
"""Gets the modifier names of a numeric modifier mask as a human
|
||||||
consumable string.
|
consumable string.
|
||||||
@@ -211,13 +189,43 @@ def get_click_countString(count):
|
|||||||
return _("triple click")
|
return _("triple click")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def create_key_definitions(keycode, keyval, modifiers):
|
||||||
|
"""Returns a list of Atspi key definitions for the given keycode, keyval, and modifiers."""
|
||||||
|
ret = []
|
||||||
|
if modifiers & CTHULHU_MODIFIER_MASK:
|
||||||
|
modifier_list = []
|
||||||
|
other_modifiers = modifiers & ~CTHULHU_MODIFIER_MASK
|
||||||
|
from . import input_event_manager
|
||||||
|
manager = input_event_manager.get_manager()
|
||||||
|
for key in settings.cthulhuModifierKeys:
|
||||||
|
mod_keyval, mod_keycode = get_keycodes(key)
|
||||||
|
if mod_keycode == 0 and key == "Shift_Lock":
|
||||||
|
mod_keyval, mod_keycode = get_keycodes("Caps_Lock")
|
||||||
|
if CAN_USE_KEYSYMS:
|
||||||
|
mod = manager.map_keysym_to_modifier(mod_keyval)
|
||||||
|
else:
|
||||||
|
mod = manager.map_keycode_to_modifier(mod_keycode)
|
||||||
|
if mod:
|
||||||
|
modifier_list.append(mod | other_modifiers)
|
||||||
|
else:
|
||||||
|
modifier_list = [modifiers]
|
||||||
|
for mod in modifier_list:
|
||||||
|
kd = Atspi.KeyDefinition()
|
||||||
|
if CAN_USE_KEYSYMS:
|
||||||
|
kd.keysym = keyval
|
||||||
|
else:
|
||||||
|
kd.keycode = keycode
|
||||||
|
kd.modifiers = mod
|
||||||
|
ret.append(kd)
|
||||||
|
return ret
|
||||||
|
|
||||||
class KeyBinding:
|
class KeyBinding:
|
||||||
"""A single key binding, consisting of a keycode, a modifier mask,
|
"""A single key binding, consisting of a keycode, a modifier mask,
|
||||||
and the InputEventHandler.
|
and the InputEventHandler.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, keysymstring, modifier_mask, modifiers, handler,
|
def __init__(self, keysymstring, modifier_mask, modifiers, handler,
|
||||||
click_count = 1):
|
click_count = 1, enabled=True):
|
||||||
"""Creates a new key binding.
|
"""Creates a new key binding.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
@@ -237,42 +245,22 @@ class KeyBinding:
|
|||||||
self.modifiers = modifiers
|
self.modifiers = modifiers
|
||||||
self.handler = handler
|
self.handler = handler
|
||||||
self.click_count = click_count
|
self.click_count = click_count
|
||||||
self.keycode = None
|
self.keycode = 0
|
||||||
|
self.keyval = 0
|
||||||
|
self._enabled = enabled
|
||||||
|
self._grab_ids = []
|
||||||
|
|
||||||
def matches(self, keycode, modifiers):
|
def matches(self, keyval, keycode, modifiers):
|
||||||
"""Returns true if this key binding matches the given keycode and
|
"""Returns true if this key binding matches the given keyval/keycode and modifier state."""
|
||||||
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:
|
if not self.keycode:
|
||||||
self.keycode = getKeycode(self.keysymstring)
|
self.keyval, self.keycode = get_keycodes(self.keysymstring)
|
||||||
|
|
||||||
# Debug logging for DisplayVersion plugin specifically
|
if self.keycode == keycode or self.keyval == keyval:
|
||||||
if self.keysymstring == 'v' and self.modifiers == 257:
|
|
||||||
with open('/tmp/displayversion_matches.log', 'a') as f:
|
|
||||||
f.write(f"=== DisplayVersion matches() debug ===\n")
|
|
||||||
f.write(f"Self keycode: {self.keycode}\n")
|
|
||||||
f.write(f"Self keysymstring: {self.keysymstring}\n")
|
|
||||||
f.write(f"Self modifiers: {self.modifiers}\n")
|
|
||||||
f.write(f"Self modifier_mask: {self.modifier_mask}\n")
|
|
||||||
f.write(f"Input keycode: {keycode}\n")
|
|
||||||
f.write(f"Input modifiers: {modifiers}\n")
|
|
||||||
f.write(f"Keycode match: {self.keycode == keycode}\n")
|
|
||||||
if self.keycode == keycode:
|
|
||||||
result = modifiers & self.modifier_mask
|
|
||||||
f.write(f"Modifier calculation: {modifiers} & {self.modifier_mask} = {result}\n")
|
|
||||||
f.write(f"Modifier match: {result == self.modifiers}\n")
|
|
||||||
f.write(f"Overall match: {self.keycode == keycode and (modifiers & self.modifier_mask) == self.modifiers}\n")
|
|
||||||
|
|
||||||
if self.keycode == keycode:
|
|
||||||
result = modifiers & self.modifier_mask
|
result = modifiers & self.modifier_mask
|
||||||
return result == self.modifiers
|
return result == self.modifiers
|
||||||
else:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def description(self):
|
def description(self):
|
||||||
"""Returns the description of this binding's functionality."""
|
"""Returns the description of this binding's functionality."""
|
||||||
@@ -292,42 +280,53 @@ class KeyBinding:
|
|||||||
|
|
||||||
return string.strip()
|
return string.strip()
|
||||||
|
|
||||||
def keyDefs(self):
|
def is_bound(self):
|
||||||
""" return a list of Atspi key definitions for the given binding.
|
"""Returns True if this KeyBinding is bound to a key."""
|
||||||
This may return more than one binding if the Cthulhu modifier is bound
|
return bool(self.keysymstring)
|
||||||
to more than one key.
|
|
||||||
If AT-SPI is older than 2.40, then this function will not work and
|
def is_enabled(self):
|
||||||
will return an empty set.
|
"""Returns True if this KeyBinding is enabled."""
|
||||||
"""
|
return self._enabled
|
||||||
|
|
||||||
|
def set_enabled(self, enabled):
|
||||||
|
"""Set this KeyBinding's enabled state."""
|
||||||
|
self._enabled = enabled
|
||||||
|
|
||||||
|
def get_grab_ids(self):
|
||||||
|
"""Returns the grab IDs for this KeyBinding."""
|
||||||
|
return self._grab_ids
|
||||||
|
|
||||||
|
def has_grabs(self):
|
||||||
|
"""Returns True if there are existing grabs associated with this KeyBinding."""
|
||||||
|
return bool(self._grab_ids)
|
||||||
|
|
||||||
|
def add_grabs(self):
|
||||||
|
"""Adds key grabs for this KeyBinding."""
|
||||||
|
from . import input_event_manager
|
||||||
|
self._grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(self)
|
||||||
|
|
||||||
|
def remove_grabs(self):
|
||||||
|
"""Removes key grabs for this KeyBinding."""
|
||||||
|
from . import input_event_manager
|
||||||
|
input_event_manager.get_manager().remove_grabs_for_keybinding(self)
|
||||||
|
self._grab_ids = []
|
||||||
|
|
||||||
|
def key_definitions(self):
|
||||||
|
"""Return a list of Atspi key definitions for the given binding."""
|
||||||
ret = []
|
ret = []
|
||||||
if not self.keycode:
|
if not self.keycode:
|
||||||
self.keycode = getKeycode(self.keysymstring)
|
self.keyval, self.keycode = get_keycodes(self.keysymstring)
|
||||||
|
ret.extend(create_key_definitions(self.keycode, self.keyval, self.modifiers))
|
||||||
if self.modifiers & CTHULHU_MODIFIER_MASK:
|
if CAN_USE_KEYSYMS and self.modifiers & SHIFT_MODIFIER_MASK:
|
||||||
device = cthulhu_state.device
|
upper_keyval = Gdk.keyval_to_upper(self.keyval)
|
||||||
if device is None:
|
if upper_keyval != self.keyval:
|
||||||
return ret
|
ret.extend(create_key_definitions(self.keycode, upper_keyval, self.modifiers))
|
||||||
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
|
return ret
|
||||||
|
|
||||||
|
def keyDefs(self):
|
||||||
|
"""Legacy wrapper. Use key_definitions() instead."""
|
||||||
|
return self.key_definitions()
|
||||||
|
|
||||||
class KeyBindings:
|
class KeyBindings:
|
||||||
"""Structure that maintains a set of KeyBinding instances.
|
"""Structure that maintains a set of KeyBinding instances.
|
||||||
"""
|
"""
|
||||||
@@ -347,7 +346,7 @@ class KeyBindings:
|
|||||||
result += "]"
|
result += "]"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def add(self, keyBinding):
|
def add(self, keyBinding, include_grabs=False):
|
||||||
"""Adds the given KeyBinding instance to this set of keybindings.
|
"""Adds the given KeyBinding instance to this set of keybindings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -359,17 +358,29 @@ class KeyBindings:
|
|||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
self.keyBindings.append(keyBinding)
|
self.keyBindings.append(keyBinding)
|
||||||
|
if include_grabs:
|
||||||
|
keyBinding.add_grabs()
|
||||||
|
|
||||||
def remove(self, keyBinding):
|
def remove(self, keyBinding, include_grabs=False):
|
||||||
"""Removes the given KeyBinding instance from this set of keybindings.
|
"""Removes the given KeyBinding instance from this set of keybindings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if keyBinding not in self.keyBindings:
|
||||||
|
candidates = self.getBindingsForHandler(keyBinding.handler)
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
for candidate in self.getBindingsForHandler(keyBinding.handler):
|
||||||
|
self.remove(candidate, include_grabs)
|
||||||
|
return
|
||||||
|
|
||||||
|
if keyBinding.has_grabs():
|
||||||
|
if include_grabs:
|
||||||
|
keyBinding.remove_grabs()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
i = self.keyBindings.index(keyBinding)
|
self.keyBindings.remove(keyBinding)
|
||||||
except Exception:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
else:
|
|
||||||
del self.keyBindings[i]
|
|
||||||
|
|
||||||
def removeByHandler(self, handler):
|
def removeByHandler(self, handler):
|
||||||
"""Removes the given KeyBinding instance from this set of keybindings.
|
"""Removes the given KeyBinding instance from this set of keybindings.
|
||||||
@@ -380,6 +391,38 @@ class KeyBindings:
|
|||||||
del self.keyBindings[i - 1]
|
del self.keyBindings[i - 1]
|
||||||
i = i - 1
|
i = i - 1
|
||||||
|
|
||||||
|
def add_key_grabs(self, reason=""):
|
||||||
|
"""Adds grabs for all enabled bindings in this set of keybindings."""
|
||||||
|
msg = "KEYBINDINGS: Adding key grabs"
|
||||||
|
if reason:
|
||||||
|
msg += f": {reason}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for binding in self.keyBindings:
|
||||||
|
if binding.is_enabled() and not binding.has_grabs():
|
||||||
|
count += 1
|
||||||
|
binding.add_grabs()
|
||||||
|
|
||||||
|
msg = f"KEYBINDINGS: {count} key grabs added (total bindings: {len(self.keyBindings)})."
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
|
def remove_key_grabs(self, reason=""):
|
||||||
|
"""Removes all grabs for this set of keybindings."""
|
||||||
|
msg = "KEYBINDINGS: Removing key grabs"
|
||||||
|
if reason:
|
||||||
|
msg += f": {reason}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for binding in self.keyBindings:
|
||||||
|
if binding.has_grabs():
|
||||||
|
count += 1
|
||||||
|
binding.remove_grabs()
|
||||||
|
|
||||||
|
msg = f"KEYBINDINGS: {count} key grabs removed (total bindings: {len(self.keyBindings)})."
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
def hasKeyBinding (self, newKeyBinding, typeOfSearch="strict"):
|
def hasKeyBinding (self, newKeyBinding, typeOfSearch="strict"):
|
||||||
"""Return True if keyBinding is already in self.keyBindings.
|
"""Return True if keyBinding is already in self.keyBindings.
|
||||||
|
|
||||||
@@ -461,6 +504,13 @@ class KeyBindings:
|
|||||||
|
|
||||||
return [kb for kb in self.keyBindings if kb.handler == handler]
|
return [kb for kb in self.keyBindings if kb.handler == handler]
|
||||||
|
|
||||||
|
def has_enabled_handler(self, handler):
|
||||||
|
"""Returns True if the handler is found in this set of keybindings and is enabled."""
|
||||||
|
for binding in self.keyBindings:
|
||||||
|
if binding.handler == handler and binding.is_enabled():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _checkMatchingBindings(self, keyboardEvent, result):
|
def _checkMatchingBindings(self, keyboardEvent, result):
|
||||||
if debug.debugLevel > debug.LEVEL_INFO:
|
if debug.debugLevel > debug.LEVEL_INFO:
|
||||||
return
|
return
|
||||||
@@ -487,39 +537,11 @@ class KeyBindings:
|
|||||||
given keycode and modifiers, or None if no match exists.
|
given keycode and modifiers, or None if no match exists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Check if this might be the DisplayVersion key combination
|
|
||||||
event_str = keyboardEvent.event_string if hasattr(keyboardEvent, 'event_string') else 'unknown'
|
|
||||||
if event_str.lower() == 'v':
|
|
||||||
logger.info(f"=== KeyBindings.getInputHandler: Looking for handler ===")
|
|
||||||
logger.info(f"Event string: {event_str}")
|
|
||||||
logger.info(f"Hardware code: {keyboardEvent.hw_code}")
|
|
||||||
logger.info(f"Modifiers: {keyboardEvent.modifiers}")
|
|
||||||
logger.info(f"Total keybindings to check: {len(self.keyBindings)}")
|
|
||||||
|
|
||||||
with open('/tmp/keybinding_lookup.log', 'a') as f:
|
|
||||||
f.write(f"=== Looking for 'v' key handler ===\n")
|
|
||||||
f.write(f"Event string: {event_str}\n")
|
|
||||||
f.write(f"Hardware code: {keyboardEvent.hw_code}\n")
|
|
||||||
f.write(f"Modifiers: {keyboardEvent.modifiers}\n")
|
|
||||||
f.write(f"Total keybindings: {len(self.keyBindings)}\n")
|
|
||||||
|
|
||||||
# Log all keybindings for comparison
|
|
||||||
for i, kb in enumerate(self.keyBindings):
|
|
||||||
if 'v' in kb.keysymstring.lower() or 'version' in kb.handler.description.lower():
|
|
||||||
logger.info(f"Binding {i}: keysym={kb.keysymstring}, modifiers={kb.modifiers}, mask={kb.modifier_mask}, desc={kb.handler.description}")
|
|
||||||
with open('/tmp/keybinding_lookup.log', 'a') as f:
|
|
||||||
f.write(f"Found V-related binding {i}: keysym={kb.keysymstring}, modifiers={kb.modifiers}, mask={kb.modifier_mask}, desc={kb.handler.description}\n")
|
|
||||||
|
|
||||||
matches = []
|
matches = []
|
||||||
candidates = []
|
candidates = []
|
||||||
clickCount = keyboardEvent.get_click_count()
|
clickCount = keyboardEvent.get_click_count()
|
||||||
for keyBinding in self.keyBindings:
|
for keyBinding in self.keyBindings:
|
||||||
if keyBinding.matches(keyboardEvent.hw_code, keyboardEvent.modifiers):
|
if keyBinding.matches(keyboardEvent.id, keyboardEvent.hw_code, keyboardEvent.modifiers):
|
||||||
if event_str.lower() == 'v':
|
|
||||||
logger.info(f"MATCH found! keysym={keyBinding.keysymstring}, desc={keyBinding.handler.description}")
|
|
||||||
if (keyboardEvent.modifiers & keyBinding.modifier_mask) == keyBinding.modifiers and \
|
if (keyboardEvent.modifiers & keyBinding.modifier_mask) == keyBinding.modifiers and \
|
||||||
keyBinding.click_count == clickCount:
|
keyBinding.click_count == clickCount:
|
||||||
matches.append(keyBinding)
|
matches.append(keyBinding)
|
||||||
@@ -529,17 +551,8 @@ class KeyBindings:
|
|||||||
if keyBinding.keysymstring:
|
if keyBinding.keysymstring:
|
||||||
candidates.append(keyBinding)
|
candidates.append(keyBinding)
|
||||||
|
|
||||||
if event_str.lower() == 'v':
|
|
||||||
logger.info(f"Exact matches: {len(matches)}")
|
|
||||||
logger.info(f"Candidates: {len(candidates)}")
|
|
||||||
with open('/tmp/keybinding_lookup.log', 'a') as f:
|
|
||||||
f.write(f"Exact matches: {len(matches)}\n")
|
|
||||||
f.write(f"Candidates: {len(candidates)}\n")
|
|
||||||
|
|
||||||
self._checkMatchingBindings(keyboardEvent, matches)
|
self._checkMatchingBindings(keyboardEvent, matches)
|
||||||
if matches:
|
if matches:
|
||||||
if event_str.lower() == 'v':
|
|
||||||
logger.info(f"Returning exact match handler: {matches[0].handler.description}")
|
|
||||||
return matches[0].handler
|
return matches[0].handler
|
||||||
|
|
||||||
if keyboardEvent.isKeyPadKeyWithNumlockOn():
|
if keyboardEvent.isKeyPadKeyWithNumlockOn():
|
||||||
@@ -553,12 +566,8 @@ class KeyBindings:
|
|||||||
self._checkMatchingBindings(keyboardEvent, candidates)
|
self._checkMatchingBindings(keyboardEvent, candidates)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate.click_count <= clickCount:
|
if candidate.click_count <= clickCount:
|
||||||
if event_str.lower() == 'v':
|
|
||||||
logger.info(f"Returning candidate handler: {candidate.handler.description}")
|
|
||||||
return candidate.handler
|
return candidate.handler
|
||||||
|
|
||||||
if event_str.lower() == 'v':
|
|
||||||
logger.info("No handler found!")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load(self, keymap, handlers):
|
def load(self, keymap, handlers):
|
||||||
|
|||||||
@@ -276,7 +276,9 @@ class LiveRegionManager:
|
|||||||
utts = message['labels'] + message['content']
|
utts = message['labels'] + message['content']
|
||||||
|
|
||||||
if self.monitoring:
|
if self.monitoring:
|
||||||
self._script.presentMessage(utts)
|
# Live region content is user-generated text, not system messages.
|
||||||
|
# Use resetStyles=False to preserve the user's punctuation settings.
|
||||||
|
self._script.presentMessage(utts, resetStyles=False)
|
||||||
else:
|
else:
|
||||||
msg = "INFO: Not presenting message because monitoring is off"
|
msg = "INFO: Not presenting message because monitoring is off"
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|||||||
@@ -282,6 +282,16 @@ class ScriptManager:
|
|||||||
Returns an instance of a Script.
|
Returns an instance of a Script.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if app:
|
||||||
|
try:
|
||||||
|
from . import sleep_mode_manager
|
||||||
|
sleepModeManager = sleep_mode_manager.getManager()
|
||||||
|
sleepModeManager.refreshAutoSleepConfig()
|
||||||
|
if sleepModeManager and sleepModeManager.isActiveForApp(app):
|
||||||
|
return self.get_or_create_sleep_mode_script(app)
|
||||||
|
except Exception as error:
|
||||||
|
_log_tokens(["Could not check sleep mode for", app, ":", error], "sleep-mode-check-failed")
|
||||||
|
|
||||||
customScript = None
|
customScript = None
|
||||||
appScript = None
|
appScript = None
|
||||||
toolkitScript = None
|
toolkitScript = None
|
||||||
|
|||||||
@@ -4455,7 +4455,7 @@ class Utilities:
|
|||||||
tokens = ["SCRIPT UTILITIES: ", obj, f"has {nRows} rows"]
|
tokens = ["SCRIPT UTILITIES: ", obj, f"has {nRows} rows"]
|
||||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
|
||||||
x, y, width, height = boundingbox
|
x, y, width, height = boundingbox.x, boundingbox.y, boundingbox.width, boundingbox.height
|
||||||
cell = self.descendantAtPoint(obj, x, y + 1)
|
cell = self.descendantAtPoint(obj, x, y + 1)
|
||||||
row, col = self.coordinatesForCell(cell)
|
row, col = self.coordinatesForCell(cell)
|
||||||
startIndex = max(0, row)
|
startIndex = max(0, row)
|
||||||
|
|||||||
@@ -1473,7 +1473,9 @@ class Script(script.Script):
|
|||||||
"""Callback for object:announcement events."""
|
"""Callback for object:announcement events."""
|
||||||
|
|
||||||
if isinstance(event.any_data, str):
|
if isinstance(event.any_data, str):
|
||||||
self.presentMessage(event.any_data)
|
# AT-SPI announcements contain application content, not system messages.
|
||||||
|
# Use resetStyles=False to preserve the user's punctuation settings.
|
||||||
|
self.presentMessage(event.any_data, resetStyles=False)
|
||||||
|
|
||||||
def onNameChanged(self, event):
|
def onNameChanged(self, event):
|
||||||
"""Callback for object:property-change:accessible-name events."""
|
"""Callback for object:property-change:accessible-name events."""
|
||||||
|
|||||||
@@ -75,9 +75,6 @@ class Script(default.Script):
|
|||||||
"""Called when this script is deactivated."""
|
"""Called when this script is deactivated."""
|
||||||
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True)
|
debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True)
|
||||||
|
|
||||||
# Restore key grabs
|
|
||||||
self.addKeyGrabs()
|
|
||||||
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.")
|
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.")
|
||||||
|
|
||||||
super().deactivate()
|
super().deactivate()
|
||||||
@@ -86,18 +83,23 @@ class Script(default.Script):
|
|||||||
"""Remove key grabs except for sleep mode toggle."""
|
"""Remove key grabs except for sleep mode toggle."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First remove all grabs inherited from default activation,
|
||||||
|
# including modifier grabs.
|
||||||
|
super().removeKeyGrabs()
|
||||||
|
|
||||||
self.grab_ids = []
|
self.grab_ids = []
|
||||||
for keyBinding in self.keyBindings:
|
for keyBinding in self.keyBindings.keyBindings:
|
||||||
if hasattr(keyBinding, 'handler') and hasattr(keyBinding.handler, 'function'):
|
if hasattr(keyBinding, 'handler') and hasattr(keyBinding.handler, 'function'):
|
||||||
if hasattr(keyBinding.handler.function, '__name__'):
|
if hasattr(keyBinding.handler.function, '__name__'):
|
||||||
if 'toggleSleepMode' in keyBinding.handler.function.__name__:
|
if 'toggleSleepMode' in keyBinding.handler.function.__name__:
|
||||||
# Keep sleep mode toggle
|
# Keep sleep mode toggle
|
||||||
try:
|
try:
|
||||||
import cthulhu
|
import cthulhu
|
||||||
grab_id = cthulhu.addKeyGrab(keyBinding)
|
grabIds = cthulhu.addKeyGrab(keyBinding)
|
||||||
if grab_id:
|
if grabIds:
|
||||||
self.grab_ids.append(grab_id)
|
for grabId in grabIds:
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grab_id}", True)
|
self.grab_ids.append(grabId)
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grabId}", True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True)
|
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True)
|
||||||
else:
|
else:
|
||||||
@@ -106,27 +108,6 @@ class Script(default.Script):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in removeKeyGrabs: {e}", True)
|
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in removeKeyGrabs: {e}", True)
|
||||||
|
|
||||||
def addKeyGrabs(self):
|
|
||||||
"""Add back all key grabs."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Remove our limited grabs first
|
|
||||||
if hasattr(self, 'grab_ids'):
|
|
||||||
import cthulhu
|
|
||||||
for grab_id in self.grab_ids:
|
|
||||||
try:
|
|
||||||
cthulhu.removeKeyGrab(grab_id)
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Removed key grab: {grab_id}", True)
|
|
||||||
except Exception as e:
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error removing key grab {grab_id}: {e}", True)
|
|
||||||
self.grab_ids = []
|
|
||||||
|
|
||||||
# Let the parent class restore all grabs
|
|
||||||
super().addKeyGrabs()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in addKeyGrabs: {e}", True)
|
|
||||||
|
|
||||||
# Block common event handlers as an additional layer of protection
|
# Block common event handlers as an additional layer of protection
|
||||||
def onCaretMoved(self, event):
|
def onCaretMoved(self, event):
|
||||||
"""Block caret movement events."""
|
"""Block caret movement events."""
|
||||||
|
|||||||
@@ -1648,9 +1648,21 @@ class Script(default.Script):
|
|||||||
elif self.utilities.isContentEditableWithEmbeddedObjects(newFocus) \
|
elif self.utilities.isContentEditableWithEmbeddedObjects(newFocus) \
|
||||||
and (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \
|
and (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \
|
||||||
and not (AXUtilities.is_table_cell(newFocus) and AXObject.get_name(newFocus)):
|
and not (AXUtilities.is_table_cell(newFocus) and AXObject.get_name(newFocus)):
|
||||||
tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
|
# Check if we're entering the content editable from outside (e.g. down arrow
|
||||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
# from a message list into a message entry). In that case, generate full object
|
||||||
contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
|
# speech (with label and role) rather than just line contents.
|
||||||
|
enteredFromOutside = oldFocus is not None \
|
||||||
|
and oldFocus != newFocus \
|
||||||
|
and not AXObject.find_ancestor(oldFocus, lambda x: x == newFocus)
|
||||||
|
if enteredFromOutside:
|
||||||
|
tokens = ["WEB: New focus", newFocus,
|
||||||
|
"content editable entered from outside. Generating speech."]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
args['priorObj'] = oldFocus
|
||||||
|
else:
|
||||||
|
tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
|
||||||
elif self.utilities.isAnchor(newFocus):
|
elif self.utilities.isAnchor(newFocus):
|
||||||
tokens = ["WEB: New focus", newFocus, "is anchor. Generating line."]
|
tokens = ["WEB: New focus", newFocus, "is anchor. Generating line."]
|
||||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
|||||||
@@ -1834,6 +1834,16 @@ class Utilities(script_utilities.Utilities):
|
|||||||
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
|
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
|
||||||
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
|
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
|
||||||
|
|
||||||
|
# If we're inside a content editable, don't expand line contents beyond
|
||||||
|
# its boundaries (e.g. don't include a "More options" button adjacent to
|
||||||
|
# a message entry just because it's on the same visual line).
|
||||||
|
contentEditableBoundary = None
|
||||||
|
if self.isContentEditableWithEmbeddedObjects(obj):
|
||||||
|
contentEditableBoundary = obj
|
||||||
|
else:
|
||||||
|
contentEditableBoundary = AXObject.find_ancestor(
|
||||||
|
obj, self.isContentEditableWithEmbeddedObjects)
|
||||||
|
|
||||||
# Check for things on the same line to the left of this object.
|
# Check for things on the same line to the left of this object.
|
||||||
prevStartTime = time.time()
|
prevStartTime = time.time()
|
||||||
while prevObj and self.getDocumentForObject(prevObj) == document:
|
while prevObj and self.getDocumentForObject(prevObj) == document:
|
||||||
@@ -1848,6 +1858,10 @@ class Utilities(script_utilities.Utilities):
|
|||||||
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
|
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if contentEditableBoundary and prevObj != contentEditableBoundary \
|
||||||
|
and not AXObject.find_ancestor(prevObj, lambda x: x == contentEditableBoundary):
|
||||||
|
break
|
||||||
|
|
||||||
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
|
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
|
||||||
onLeft = list(filter(_include, onLeft))
|
onLeft = list(filter(_include, onLeft))
|
||||||
if not onLeft:
|
if not onLeft:
|
||||||
@@ -1878,6 +1892,10 @@ class Utilities(script_utilities.Utilities):
|
|||||||
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
|
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if contentEditableBoundary and nextObj != contentEditableBoundary \
|
||||||
|
and not AXObject.find_ancestor(nextObj, lambda x: x == contentEditableBoundary):
|
||||||
|
break
|
||||||
|
|
||||||
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
|
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
|
||||||
if onRight and self._contentIsSubsetOf(objects[0], onRight[-1]):
|
if onRight and self._contentIsSubsetOf(objects[0], onRight[-1]):
|
||||||
onRight = onRight[0:-1]
|
onRight = onRight[0:-1]
|
||||||
|
|||||||
@@ -361,7 +361,13 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
|
|||||||
if self._script.utilities.isContentEditableWithEmbeddedObjects(obj) \
|
if self._script.utilities.isContentEditableWithEmbeddedObjects(obj) \
|
||||||
or self._script.utilities.isDocument(obj):
|
or self._script.utilities.isDocument(obj):
|
||||||
if input_event_manager.get_manager().last_event_was_caret_navigation():
|
if input_event_manager.get_manager().last_event_was_caret_navigation():
|
||||||
return []
|
# Still generate the label if we just entered this object from outside
|
||||||
|
# (e.g. down arrow from message list into message entry in Discord).
|
||||||
|
enteredFromOutside = priorObj is not None \
|
||||||
|
and priorObj != obj \
|
||||||
|
and not AXObject.find_ancestor(priorObj, lambda x: x == obj)
|
||||||
|
if not enteredFromOutside:
|
||||||
|
return []
|
||||||
|
|
||||||
if AXUtilities.is_page_tab(priorObj) and AXObject.get_name(priorObj) == objName:
|
if AXUtilities.is_page_tab(priorObj) and AXObject.get_name(priorObj) == objName:
|
||||||
return []
|
return []
|
||||||
@@ -554,6 +560,9 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
|
|||||||
if roledescription:
|
if roledescription:
|
||||||
result = [roledescription]
|
result = [roledescription]
|
||||||
result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args))
|
result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args))
|
||||||
|
# aria-roledescription replaces the standard role name, so return
|
||||||
|
# early to avoid announcing both (e.g. "Message" + "article").
|
||||||
|
return result
|
||||||
|
|
||||||
role = args.get('role', AXObject.get_role(obj))
|
role = args.get('role', AXObject.get_role(obj))
|
||||||
roleSoundPresentation = cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation')
|
roleSoundPresentation = cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation')
|
||||||
|
|||||||
@@ -214,6 +214,25 @@ class SettingsManager(object):
|
|||||||
if not os.path.exists(userCustomFile):
|
if not os.path.exists(userCustomFile):
|
||||||
os.close(os.open(userCustomFile, os.O_CREAT, 0o700))
|
os.close(os.open(userCustomFile, os.O_CREAT, 0o700))
|
||||||
|
|
||||||
|
sleepConfigFile = os.path.join(cthulhuDir, "sleep.toml")
|
||||||
|
if not os.path.exists(sleepConfigFile):
|
||||||
|
sleepTemplate = (
|
||||||
|
"# Cthulhu auto-sleep apps\n"
|
||||||
|
"#\n"
|
||||||
|
"# List current app names with:\n"
|
||||||
|
"# cthulhu --list-apps\n"
|
||||||
|
"# Use the middle app-name column from that output.\n"
|
||||||
|
"#\n"
|
||||||
|
"# Add app names to auto-enable sleep mode:\n"
|
||||||
|
"# apps = [\"qemu\"]\n"
|
||||||
|
"#\n"
|
||||||
|
"# Or use a section:\n"
|
||||||
|
"# [sleep]\n"
|
||||||
|
"# apps = [\"qemu\"]\n"
|
||||||
|
)
|
||||||
|
with open(sleepConfigFile, "w", encoding="utf-8") as configFile:
|
||||||
|
configFile.write(sleepTemplate)
|
||||||
|
|
||||||
if self.isFirstStart() and self._backend:
|
if self.isFirstStart() and self._backend:
|
||||||
self._backend.saveDefaultSettings(self.defaultGeneral,
|
self._backend.saveDefaultSettings(self.defaultGeneral,
|
||||||
self.defaultPronunciations,
|
self.defaultPronunciations,
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ __copyright__ = "Copyright (c) 2024 Stormux"
|
|||||||
__license__ = "LGPL"
|
__license__ = "LGPL"
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import os
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
|
from tomlkit import parse
|
||||||
import cthulhu.braille as braille
|
import cthulhu.braille as braille
|
||||||
import cthulhu.cmdnames as cmdnames
|
import cthulhu.cmdnames as cmdnames
|
||||||
import cthulhu.debug as debug
|
import cthulhu.debug as debug
|
||||||
@@ -47,7 +49,12 @@ class SleepModeManager:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._handlers = self.getHandlers(True)
|
self._handlers = self.getHandlers(True)
|
||||||
self._bindings = keybindings.KeyBindings()
|
self._bindings = keybindings.KeyBindings()
|
||||||
self._apps = []
|
self._apps = set()
|
||||||
|
self._disabledAutoSleepApps = set()
|
||||||
|
self._autoSleepAppNames = set()
|
||||||
|
self._autoSleepPath = self._getAutoSleepPath()
|
||||||
|
self._autoSleepConfigMTime = None
|
||||||
|
self._loadAutoSleepConfig()
|
||||||
self._lastToggleTime = 0
|
self._lastToggleTime = 0
|
||||||
self._toggleDebounceDelay = 0.1 # 100ms debounce (reduced for better responsiveness)
|
self._toggleDebounceDelay = 0.1 # 100ms debounce (reduced for better responsiveness)
|
||||||
|
|
||||||
@@ -76,12 +83,106 @@ class SleepModeManager:
|
|||||||
def isActiveForApp(self, app):
|
def isActiveForApp(self, app):
|
||||||
"""Returns True if sleep mode is active for app."""
|
"""Returns True if sleep mode is active for app."""
|
||||||
|
|
||||||
result = bool(app and hash(app) in self._apps)
|
if not app:
|
||||||
|
return False
|
||||||
|
|
||||||
|
appHash = hash(app)
|
||||||
|
result = appHash in self._apps
|
||||||
|
if not result and self._isAutoSleepConfiguredForApp(app):
|
||||||
|
result = appHash not in self._disabledAutoSleepApps
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
tokens = ["SLEEP MODE MANAGER: Is active for", app]
|
tokens = ["SLEEP MODE MANAGER: Is active for", app]
|
||||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _getAutoSleepPath(self):
|
||||||
|
prefsDir = os.path.join(GLib.get_user_data_dir(), "cthulhu")
|
||||||
|
try:
|
||||||
|
from . import cthulhu
|
||||||
|
app = cthulhu.cthulhuApp
|
||||||
|
if app and app.settingsManager:
|
||||||
|
configuredDir = app.settingsManager.getPrefsDir()
|
||||||
|
if configuredDir:
|
||||||
|
prefsDir = configuredDir
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return os.path.join(prefsDir, "sleep.toml")
|
||||||
|
|
||||||
|
def _refreshAutoSleepPath(self):
|
||||||
|
latestPath = self._getAutoSleepPath()
|
||||||
|
if latestPath != self._autoSleepPath:
|
||||||
|
self._autoSleepPath = latestPath
|
||||||
|
self._autoSleepConfigMTime = None
|
||||||
|
self._loadAutoSleepConfig()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
latestMTime = os.path.getmtime(self._autoSleepPath)
|
||||||
|
except OSError:
|
||||||
|
latestMTime = None
|
||||||
|
|
||||||
|
if latestMTime != self._autoSleepConfigMTime:
|
||||||
|
self._loadAutoSleepConfig()
|
||||||
|
|
||||||
|
def refreshAutoSleepConfig(self):
|
||||||
|
"""Refresh auto-sleep config if prefs directory has changed."""
|
||||||
|
|
||||||
|
self._refreshAutoSleepPath()
|
||||||
|
|
||||||
|
def _loadAutoSleepConfig(self):
|
||||||
|
self._autoSleepAppNames = set()
|
||||||
|
self._autoSleepConfigMTime = None
|
||||||
|
|
||||||
|
if not os.path.isfile(self._autoSleepPath):
|
||||||
|
msg = f"SLEEP MODE MANAGER: No sleep config at {self._autoSleepPath}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._autoSleepConfigMTime = os.path.getmtime(self._autoSleepPath)
|
||||||
|
except OSError:
|
||||||
|
self._autoSleepConfigMTime = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self._autoSleepPath, "r", encoding="utf-8") as configFile:
|
||||||
|
config = parse(configFile.read() or "")
|
||||||
|
except Exception as error:
|
||||||
|
tokens = ["SLEEP MODE MANAGER: Failed to parse", self._autoSleepPath, ":", error]
|
||||||
|
debug.printTokens(debug.LEVEL_WARNING, tokens, True)
|
||||||
|
return
|
||||||
|
|
||||||
|
appNames = []
|
||||||
|
topLevelApps = config.get("apps", [])
|
||||||
|
if isinstance(topLevelApps, list):
|
||||||
|
appNames.extend(topLevelApps)
|
||||||
|
|
||||||
|
sleepSection = config.get("sleep", {})
|
||||||
|
if isinstance(sleepSection, dict):
|
||||||
|
sectionApps = sleepSection.get("apps", [])
|
||||||
|
if isinstance(sectionApps, list):
|
||||||
|
appNames.extend(sectionApps)
|
||||||
|
|
||||||
|
for appName in appNames:
|
||||||
|
if not isinstance(appName, str):
|
||||||
|
continue
|
||||||
|
normalizedName = appName.strip().lower()
|
||||||
|
if normalizedName:
|
||||||
|
self._autoSleepAppNames.add(normalizedName)
|
||||||
|
|
||||||
|
msg = f"SLEEP MODE MANAGER: Loaded {len(self._autoSleepAppNames)} auto-sleep apps from {self._autoSleepPath}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
|
def _isAutoSleepConfiguredForApp(self, app):
|
||||||
|
if not app:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._autoSleepAppNames:
|
||||||
|
return False
|
||||||
|
|
||||||
|
appName = (AXObject.get_name(app) or "").strip().lower()
|
||||||
|
return bool(appName and appName in self._autoSleepAppNames)
|
||||||
|
|
||||||
def _setupHandlers(self):
|
def _setupHandlers(self):
|
||||||
"""Sets up and returns the sleep-mode-manager input event handlers."""
|
"""Sets up and returns the sleep-mode-manager input event handlers."""
|
||||||
|
|
||||||
@@ -132,12 +233,16 @@ class SleepModeManager:
|
|||||||
if not (script and script.app):
|
if not (script and script.app):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
from . import cthulhu_state
|
self.refreshAutoSleepConfig()
|
||||||
|
|
||||||
scriptManager = script_manager.get_manager()
|
scriptManager = script_manager.get_manager()
|
||||||
|
|
||||||
if self.isActiveForApp(script.app):
|
if self.isActiveForApp(script.app):
|
||||||
# Turning OFF sleep mode
|
# Turning OFF sleep mode
|
||||||
self._apps.remove(hash(script.app))
|
appHash = hash(script.app)
|
||||||
|
self._apps.discard(appHash)
|
||||||
|
if self._isAutoSleepConfiguredForApp(script.app):
|
||||||
|
self._disabledAutoSleepApps.add(appHash)
|
||||||
newScript = scriptManager.get_script(script.app)
|
newScript = scriptManager.get_script(script.app)
|
||||||
if notifyUser:
|
if notifyUser:
|
||||||
newScript.presentMessage(
|
newScript.presentMessage(
|
||||||
@@ -177,7 +282,9 @@ class SleepModeManager:
|
|||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Active script set successfully", True)
|
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Active script set successfully", True)
|
||||||
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Adding app to sleep list", True)
|
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Adding app to sleep list", True)
|
||||||
self._apps.append(hash(script.app))
|
appHash = hash(script.app)
|
||||||
|
self._disabledAutoSleepApps.discard(appHash)
|
||||||
|
self._apps.add(appHash)
|
||||||
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Enabled for {AXObject.get_name(script.app)} (delayed)", True)
|
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Enabled for {AXObject.get_name(script.app)} (delayed)", True)
|
||||||
# Reset debounce timer after successful toggle
|
# Reset debounce timer after successful toggle
|
||||||
self._lastToggleTime = 0
|
self._lastToggleTime = 0
|
||||||
|
|||||||
@@ -748,15 +748,20 @@ class SpeechGenerator(generator.Generator):
|
|||||||
method for scripts to call.
|
method for scripts to call.
|
||||||
"""
|
"""
|
||||||
generated = self._generateRoleName(obj, **args)
|
generated = self._generateRoleName(obj, **args)
|
||||||
if generated:
|
return self._getFirstString(generated)
|
||||||
return generated[0]
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def getName(self, obj, **args):
|
def getName(self, obj, **args):
|
||||||
generated = self._generateName(obj, **args)
|
generated = self._generateName(obj, **args)
|
||||||
if generated:
|
return self._getFirstString(generated)
|
||||||
return generated[0]
|
|
||||||
|
def _getFirstString(self, generated):
|
||||||
|
for item in generated or []:
|
||||||
|
if isinstance(item, str):
|
||||||
|
return item
|
||||||
|
if isinstance(item, list):
|
||||||
|
nestedString = self._getFirstString(item)
|
||||||
|
if nestedString:
|
||||||
|
return nestedString
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user