From 30a40f69746c0c33c8d14fe74fb912816b4b054b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 12 Jan 2026 11:54:42 -0500 Subject: [PATCH 1/2] Merged dbus implementation for plugins. --- distro-packages/Arch-Linux/PKGBUILD | 2 +- meson.build | 2 +- src/cthulhu/cthulhuVersion.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 2f40a3f..b4ee574 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Storm Dragon pkgname=cthulhu -pkgver=2026.01.10 +pkgver=2026.01.12 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" diff --git a/meson.build b/meson.build index 3be58d2..cbf9a99 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2026.01.10-master', + version: '2026.01.12-master', meson_version: '>= 1.0.0', ) diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index d2c567a..aa31ed5 100644 --- a/src/cthulhu/cthulhuVersion.py +++ b/src/cthulhu/cthulhuVersion.py @@ -23,5 +23,5 @@ # Forked from Orca screen reader. # Cthulhu project: https://git.stormux.org/storm/cthulhu -version = "2026.01.10" +version = "2026.01.12" codeName = "master" From 51ef3de6729a59a23ccdef6280a1d40c5d46c0eb Mon Sep 17 00:00:00 2001 From: Harley Richardson Date: Fri, 13 Feb 2026 10:14:04 +0000 Subject: [PATCH 2/2] Port keyboard monitoring API from Orca, and fix flat review bug in GTK apps --- src/cthulhu/input_event.py | 11 +- src/cthulhu/input_event_manager.py | 54 ++--- src/cthulhu/keybindings.py | 315 +++++++++++++++-------------- src/cthulhu/script_utilities.py | 2 +- 4 files changed, 188 insertions(+), 194 deletions(-) diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index 476aa52..20cd536 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -1139,7 +1139,7 @@ class RemoteControllerEvent(InputEvent): 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 (e.g., key bindings and braille bindings) will be handled by an instance of an InputEventHandler. @@ -1159,6 +1159,15 @@ class InputEventHandler: self.function = function self.description = description self.learnModeEnabled = learnModeEnabled + self._enabled = enabled + + def is_enabled(self): + """Returns True if this handler is enabled.""" + return self._enabled + + def set_enabled(self, enabled): + """Sets enabled state of this handler.""" + self._enabled = enabled def __eq__(self, other): """Compares one input handler to another.""" diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 21ebb7f..9959329 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -74,7 +74,10 @@ class InputEventManager: msg = "INPUT EVENT MANAGER: Starting key watcher." 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) def stop_key_watcher(self) -> None: @@ -100,43 +103,30 @@ class InputEventManager: msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}" 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]: """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: tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] grab_ids = [] - key_definitions = self._get_key_definitions(binding) - 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: + for kd in binding.key_definitions(): 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: continue grab_ids.append(grab_id) 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 def remove_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> None: @@ -147,30 +137,18 @@ class InputEventManager: debug.print_tokens(debug.LEVEL_INFO, tokens, True) return - grab_ids = None - if hasattr(binding, "get_grab_ids"): - grab_ids = binding.get_grab_ids() - elif hasattr(binding, "_grab_ids"): - grab_ids = list(binding._grab_ids) - + grab_ids = binding.get_grab_ids() if not grab_ids: tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) 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: self._device.remove_key_grab(grab_id) removed = self._grabbed_bindings.pop(grab_id, None) if removed is None: msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}" 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: """Removes a grab by id.""" @@ -182,10 +160,8 @@ class InputEventManager: self._device.remove_key_grab(grab_id) 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) - if not binding._grab_ids: - delattr(binding, "_grab_ids") if binding: tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding] debug.print_tokens(debug.LEVEL_INFO, tokens, True) diff --git a/src/cthulhu/keybindings.py b/src/cthulhu/keybindings.py index dcc0367..d16f380 100644 --- a/src/cthulhu/keybindings.py +++ b/src/cthulhu/keybindings.py @@ -26,6 +26,8 @@ """Provides support for defining keybindings and matching them to input events.""" +from __future__ import annotations + __id__ = "$Id$" __version__ = "$Revision$" __date__ = "$Date$" @@ -39,6 +41,7 @@ from gi.repository import Gdk from gi.repository import Atspi import functools +from typing import TYPE_CHECKING from . import debug from . import settings @@ -46,6 +49,9 @@ from . import cthulhu_state from .cthulhu_i18n import _ +if TYPE_CHECKING: + from .input_event import KeyboardEvent, InputEventHandler + _keysymsCache = {} _keycodeCache = {} @@ -80,63 +86,35 @@ NON_LOCKING_MODIFIER_MASK = (1 << Atspi.ModifierType.SHIFT | 1 << MODIFIER_CTHULHU) defaultModifierMask = NON_LOCKING_MODIFIER_MASK -def getKeycode(keysym): - """Converts an XKeysym string (e.g., 'KP_Enter') to a keycode that - should match the event.hw_code for key events. - - This whole situation is caused by the fact that Solaris chooses - to give us different keycodes for the same key, and the keypad - is the primary place where this happens: if NumLock is not on, - there is no telling the difference between keypad keys and the - other navigation keys (e.g., arrows, page up/down, etc.). One, - for example, would expect to get KP_End for the '1' key on the - keypad if NumLock were not on. Instead, we get 'End' and the - keycode for it matches the keycode for the other 'End' key. Odd. - If NumLock is on, we at least get KP_* keys. - - So...when setting up keybindings, we say we're interested in - KeySyms, but those keysyms are carefully chosen so as to result - in a keycode that matches the actual key on the keyboard. This - is why we use KP_1 instead of KP_End and so on in our keybindings. - - Arguments: - - keysym: a string that is a valid representation of an XKeysym. - - Returns an integer representing a key code that should match the - event.hw_code for key events. - """ +CAN_USE_KEYSYMS = Atspi.get_version() >= (2, 55, 0) +def get_keycodes(keysym): + """Converts an XKeysym string to a (keyval, keycode) tuple.""" if not keysym: - return 0 + return (0, 0) if keysym not in _keycodeCache: keymap = Gdk.Keymap.get_default() - - # Find the numerical value of the keysym - # keyval = Gdk.keyval_from_name(keysym) if keyval == 0: - return 0 + return (0, 0) - # Now find the keycodes for the keysym. Since a keysym can - # be associated with more than one key, we'll shoot for the - # keysym that's in group 0, regardless of shift level (each - # entry is of the form [keycode, group, level]). - # - _keycodeCache[keysym] = 0 - success, entries = keymap.get_entries_for_keyval(keyval) + _keycodeCache[keysym] = (keyval, 0) + _success, entries = keymap.get_entries_for_keyval(keyval) for entry in entries: if entry.group == 0: - _keycodeCache[keysym] = entry.keycode + _keycodeCache[keysym] = (keyval, entry.keycode) break - if _keycodeCache[keysym] == 0: - _keycodeCache[keysym] = entries[0].keycode - - #print keysym, keyval, entries, _keycodeCache[keysym] + if _keycodeCache[keysym] == (keyval, 0): + _keycodeCache[keysym] = (keyval, entries[0].keycode) return _keycodeCache[keysym] +def getKeycode(keysym): + """Converts an XKeysym string to a keycode. Legacy wrapper.""" + return get_keycodes(keysym)[1] + def getModifierNames(mods): """Gets the modifier names of a numeric modifier mask as a human consumable string. @@ -211,13 +189,43 @@ def get_click_countString(count): return _("triple click") 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: """A single key binding, consisting of a keycode, a modifier mask, and the InputEventHandler. """ def __init__(self, keysymstring, modifier_mask, modifiers, handler, - click_count = 1): + click_count = 1, enabled=True): """Creates a new key binding. Arguments: @@ -237,42 +245,22 @@ class KeyBinding: self.modifiers = modifiers self.handler = handler 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): - """Returns true if this key binding matches the given keycode and - modifier state. - """ + def matches(self, keyval, keycode, modifiers): + """Returns true if this key binding matches the given keyval/keycode and modifier state.""" - # We lazily bind the keycode. The primary reason for doing this - # is so that atspi does not have to be initialized before setting - # keybindings in the user's preferences file. - # if not self.keycode: - self.keycode = getKeycode(self.keysymstring) + self.keyval, self.keycode = get_keycodes(self.keysymstring) - # Debug logging for DisplayVersion plugin specifically - 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: + if self.keycode == keycode or self.keyval == keyval: result = modifiers & self.modifier_mask return result == self.modifiers - else: - return False + + return False def description(self): """Returns the description of this binding's functionality.""" @@ -292,42 +280,53 @@ class KeyBinding: return string.strip() - def keyDefs(self): - """ return a list of Atspi key definitions for the given binding. - This may return more than one binding if the Cthulhu modifier is bound - to more than one key. - If AT-SPI is older than 2.40, then this function will not work and - will return an empty set. - """ + def is_bound(self): + """Returns True if this KeyBinding is bound to a key.""" + return bool(self.keysymstring) + + def is_enabled(self): + """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 = [] if not self.keycode: - self.keycode = getKeycode(self.keysymstring) - - if self.modifiers & CTHULHU_MODIFIER_MASK: - device = cthulhu_state.device - if device is None: - return ret - modList = [] - otherMods = self.modifiers & ~CTHULHU_MODIFIER_MASK - numLockMod = device.get_modifier(getKeycode("Num_Lock")) - lockedMods = device.get_locked_modifiers() - numLockOn = lockedMods & numLockMod - for key in settings.cthulhuModifierKeys: - keycode = getKeycode(key) - if keycode == 0 and key == "Shift_Lock": - keycode = getKeycode("Caps_Lock") - mod = device.map_modifier(keycode) - if key != "KP_Insert" or not numLockOn: - modList.append(mod | otherMods) - else: - modList = [self.modifiers] - for mod in modList: - kd = Atspi.KeyDefinition() - kd.keycode = self.keycode - kd.modifiers = mod - ret.append(kd) + self.keyval, self.keycode = get_keycodes(self.keysymstring) + ret.extend(create_key_definitions(self.keycode, self.keyval, self.modifiers)) + if CAN_USE_KEYSYMS and self.modifiers & SHIFT_MODIFIER_MASK: + upper_keyval = Gdk.keyval_to_upper(self.keyval) + if upper_keyval != self.keyval: + ret.extend(create_key_definitions(self.keycode, upper_keyval, self.modifiers)) return ret + def keyDefs(self): + """Legacy wrapper. Use key_definitions() instead.""" + return self.key_definitions() + class KeyBindings: """Structure that maintains a set of KeyBinding instances. """ @@ -347,7 +346,7 @@ class KeyBindings: result += "]" return result - def add(self, keyBinding): + def add(self, keyBinding, include_grabs=False): """Adds the given KeyBinding instance to this set of keybindings. """ @@ -359,17 +358,29 @@ class KeyBindings: debug.printMessage(debug.LEVEL_INFO, msg, True) 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. """ + 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: - i = self.keyBindings.index(keyBinding) - except Exception: + self.keyBindings.remove(keyBinding) + except ValueError: pass - else: - del self.keyBindings[i] def removeByHandler(self, handler): """Removes the given KeyBinding instance from this set of keybindings. @@ -380,6 +391,38 @@ class KeyBindings: del self.keyBindings[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"): """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] + 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): if debug.debugLevel > debug.LEVEL_INFO: return @@ -487,39 +537,11 @@ class KeyBindings: 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 = [] candidates = [] clickCount = keyboardEvent.get_click_count() for keyBinding in self.keyBindings: - if keyBinding.matches(keyboardEvent.hw_code, keyboardEvent.modifiers): - if event_str.lower() == 'v': - logger.info(f"MATCH found! keysym={keyBinding.keysymstring}, desc={keyBinding.handler.description}") + if keyBinding.matches(keyboardEvent.id, keyboardEvent.hw_code, keyboardEvent.modifiers): if (keyboardEvent.modifiers & keyBinding.modifier_mask) == keyBinding.modifiers and \ keyBinding.click_count == clickCount: matches.append(keyBinding) @@ -529,17 +551,8 @@ class KeyBindings: if keyBinding.keysymstring: 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) if matches: - if event_str.lower() == 'v': - logger.info(f"Returning exact match handler: {matches[0].handler.description}") return matches[0].handler if keyboardEvent.isKeyPadKeyWithNumlockOn(): @@ -553,12 +566,8 @@ class KeyBindings: self._checkMatchingBindings(keyboardEvent, candidates) for candidate in candidates: if candidate.click_count <= clickCount: - if event_str.lower() == 'v': - logger.info(f"Returning candidate handler: {candidate.handler.description}") return candidate.handler - if event_str.lower() == 'v': - logger.info("No handler found!") return None def load(self, keymap, handlers): diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 05453d0..9de4b8b 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -4455,7 +4455,7 @@ class Utilities: tokens = ["SCRIPT UTILITIES: ", obj, f"has {nRows} rows"] 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) row, col = self.coordinatesForCell(cell) startIndex = max(0, row)