12 Commits

Author SHA1 Message Date
destructatron 8312a842c1 Fix duplicate role name when aria-roledescription is present
When an element has aria-roledescription (e.g. Discord messages use
'Message'), _generateRoleName was adding both the roledescription AND
the standard localized role name (e.g. 'article'). This resulted in
the role being announced twice.

The aria-roledescription attribute is intended to replace the standard
role name, not supplement it. Return early after adding the
roledescription to match Orca's behavior.
2026-02-17 19:21:36 +00:00
destructatron 07138197cb Fix web content editable label, line boundary, and punctuation bugs
Bug 1: Content editable entry label not announced on caret-nav focus
- When navigating into a content editable via caret navigation (e.g. down
  arrow from message list to message entry in Discord), the label and role
  were suppressed because the code treated entering the field the same as
  navigating within it.
- Fixed locus_of_focus_changed in web/script.py to detect when focus enters
  a content editable from outside and generate full object speech.
- Fixed _generateLabelOrName in web/speech_generator.py to preserve the
  label when the prior object was outside the content editable.

Bug 2: Adjacent button included in line contents on first line
- When reading line contents inside a content editable, the layout-mode
  expansion could walk outside the content editable boundary and include
  adjacent UI elements (e.g. a 'More options' button) that happened to
  share the same visual line.
- Fixed _getLineContentsAtOffset in web/script_utilities.py to stop
  expanding at content editable boundaries in both directions.

Bug 3: Punctuation stripped from live regions and AT-SPI announcements
- presentMessage uses resetStyles=True by default, which sets punctuation
  to NONE for system voice messages. This is correct for generated text
  but wrong for user content in live regions and AT-SPI announcements.
- Fixed liveregions.py pumpMessages and default.py onAnnouncement to pass
  resetStyles=False, preserving the user's punctuation settings.
2026-02-17 19:07:41 +00:00
Storm Dragon 4add36f5ca latest changed merged, seems to be reasonably stable. 2026-02-17 08:01:47 -05:00
Storm Dragon 40e63150a6 Read a list of applications that should always be started in sleep mode from sleep.toml. 2026-02-17 07:57:14 -05:00
Storm Dragon 4dba0ec0cd Fixed a bug with sleep mode, was not suspending review keys for sleeping applications. 2026-02-16 14:55:56 -05:00
Storm Dragon e6f780c38b New code tested and seems pretty stable so merged and bumped version. 2026-02-15 12:10:36 -05:00
Storm Dragon 0f7f73a6a0 Fixed flat review bug affecting some sites in chrome. 2026-02-13 13:23:14 -05:00
Storm Dragon aa71d02036 Merge remote-tracking branch 'origin/keyboard-monitoring-api' into testing 2026-02-13 11:19:40 -05:00
destructatron 51ef3de672 Port keyboard monitoring API from Orca, and fix flat review bug in GTK apps 2026-02-13 10:14:04 +00:00
Storm Dragon c6049ef5f3 Preparing for release. 2026-01-26 13:07:33 -05:00
Storm Dragon 78ef51d01f Latest code, hopefully stable. 2026-01-19 05:24:48 -05:00
Storm Dragon 30a40f6974 Merged dbus implementation for plugins. 2026-01-12 11:54:42 -05:00
17 changed files with 437 additions and 244 deletions
+1 -1
View File
@@ -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
View File
@@ -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',
) )
+2 -2
View File
@@ -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"
+44 -1
View File
@@ -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."""
+15 -39
View File
@@ -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
View File
@@ -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):
+3 -1
View File
@@ -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)
+10
View File
@@ -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
+1 -1
View File
@@ -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)
+3 -1
View File
@@ -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."""
+10 -29
View File
@@ -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."""
+15 -3
View File
@@ -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]
+10 -1
View File
@@ -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')
+19
View File
@@ -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,
+112 -5
View File
@@ -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
+11 -6
View File
@@ -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 ""