diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 0d5a8fc..435cdd0 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Storm Dragon pkgname=cthulhu -pkgver=2025.12.09 +pkgver=2025.12.27 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 431fe31..63e7234 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2025.12.09', + version: '2025.12.27', meson_version: '>= 1.0.0', ) diff --git a/set-version.sh b/set-version.sh new file mode 100755 index 0000000..c3eb7c9 --- /dev/null +++ b/set-version.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +scriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +newVersion="${1:-}" + +if [[ -z "$newVersion" ]]; then + newVersion="$(date +%Y.%m.%d)" +fi + +if [[ ! "$newVersion" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]]; then + echo "Usage: $(basename "$0") [YYYY.MM.DD]" >&2 + echo "Error: version must match YYYY.MM.DD" >&2 + exit 1 +fi + +cthulhuVersionFile="${scriptDir}/src/cthulhu/cthulhuVersion.py" +mesonFile="${scriptDir}/meson.build" +pkgbuildFile="${scriptDir}/distro-packages/Arch-Linux/PKGBUILD" + +for path in "$cthulhuVersionFile" "$mesonFile" "$pkgbuildFile"; do + if [[ ! -f "$path" ]]; then + echo "Error: Missing file: $path" >&2 + exit 1 + fi +done + +sed -i "s/^version = \".*\"/version = \"${newVersion}\"/" "$cthulhuVersionFile" +sed -i "s/^ version: '.*',/ version: '${newVersion}',/" "$mesonFile" +sed -i "s/^pkgver=.*/pkgver=${newVersion}/" "$pkgbuildFile" +sed -i "s/^pkgrel=.*/pkgrel=1/" "$pkgbuildFile" + +if ! rg -q "^version = \"${newVersion}\"" "$cthulhuVersionFile"; then + echo "Error: Failed to update ${cthulhuVersionFile}" >&2 + exit 1 +fi +if ! rg -q "^ version: '${newVersion}'," "$mesonFile"; then + echo "Error: Failed to update ${mesonFile}" >&2 + exit 1 +fi +if ! rg -q "^pkgver=${newVersion}$" "$pkgbuildFile"; then + echo "Error: Failed to update ${pkgbuildFile}" >&2 + exit 1 +fi +if ! rg -q "^pkgrel=1$" "$pkgbuildFile"; then + echo "Error: Failed to reset pkgrel in ${pkgbuildFile}" >&2 + exit 1 +fi + +echo "Updated version to ${newVersion} in:" \ + "${cthulhuVersionFile}" \ + "${mesonFile}" \ + "${pkgbuildFile}" diff --git a/src/cthulhu/action_presenter.py b/src/cthulhu/action_presenter.py index 63a6c8a..0558519 100644 --- a/src/cthulhu/action_presenter.py +++ b/src/cthulhu/action_presenter.py @@ -43,12 +43,13 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GLib, Gtk from . import cmdnames +from . import dbus_service from . import debug +from . import focus_manager from . import guilabels from . import input_event from . import keybindings from . import messages -from . import cthulhu from . import cthulhu_state from . import script_manager from .ax_object import AXObject @@ -121,16 +122,21 @@ class ActionList(Gtk.Window): def populate_actions(self, actions: list[str]) -> None: """Populates the list with accessible actions.""" - - self._actions = actions + + if isinstance(actions, dict): + items = list(actions.items()) + else: + items = [(action, action) for action in actions] + + self._actions = [name for name, _label in items] # Clear existing items for child in self._listbox.get_children(): self._listbox.remove(child) # Add actions to list - for action in actions: - label = Gtk.Label(label=action) + for _action, label_text in items: + label = Gtk.Label(label=label_text) label.set_xalign(0.0) # Left align label.set_margin_left(10) label.set_margin_right(10) @@ -163,6 +169,11 @@ class ActionPresenter: self._handlers = self.get_handlers(True) # _bindings will be initialized lazily in get_bindings() + msg = "ACTION PRESENTER: Registering D-Bus commands." + debug.printMessage(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("ActionPresenter", self) + def get_handlers(self, refresh: bool = False) -> dict: """Returns a dictionary of input event handlers.""" @@ -226,9 +237,28 @@ class ActionPresenter: script = script_manager.get_manager().get_script(app, self._obj) script_manager.get_manager().set_active_script(script, reason) - # Update Cthulhu state - cthulhu_state.activeWindow = self._window - cthulhu_state.locusOfFocus = self._obj + manager = focus_manager.get_manager() + manager.clear_state(reason) + manager.set_active_window(self._window) + manager.set_locus_of_focus(None, self._obj) + + def _present_message(self, script, full_message, brief_message=None, notify_user=True) -> None: + """Presents a message using the provided script or the active script.""" + + if not notify_user: + return + + if script is not None: + script.presentMessage(full_message, brief_message) + return + + active_script = cthulhu_state.activeScript + if active_script is not None: + active_script.presentMessage(full_message, brief_message) + return + + msg = "ACTION PRESENTER: Unable to present message (no script)." + debug.printMessage(debug.LEVEL_INFO, msg, True) def _clear_gui_and_restore_focus(self) -> None: """Clears the GUI reference and then restores focus.""" @@ -252,7 +282,7 @@ class ActionPresenter: # Use idle_add for asynchronous destruction to allow action to complete GLib.idle_add(self._gui.destroy) - def present_with_time(self, obj, start_time: float) -> bool: + def present_with_time(self, obj, start_time: float, script=None, notify_user=True) -> bool: """Presents accessible actions for the given object with timing.""" try: @@ -264,28 +294,36 @@ class ActionPresenter: return False if obj is None: - debug.printMessage(debug.LEVEL_INFO, "ACTION PRESENTER: obj is None, using locusOfFocus", True) - obj = cthulhu_state.locusOfFocus - - if obj is None: - debug.printMessage(debug.LEVEL_INFO, "ACTION PRESENTER: No object found, presenting NO_ACCESSIBLE_ACTIONS", True) - full_message = messages.NO_ACCESSIBLE_ACTIONS - cthulhu.presentMessage(full_message) - return False + msg = "ACTION PRESENTER: No object found, presenting LOCATION_NOT_FOUND" + debug.printMessage(debug.LEVEL_INFO, msg, True) + full_message = messages.LOCATION_NOT_FOUND_FULL + brief_message = messages.LOCATION_NOT_FOUND_BRIEF + self._present_message(script, full_message, brief_message, notify_user) + return True debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Getting actions for object: {obj}", True) - actions = AXObject.get_action_names(obj) - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Found {len(actions) if actions else 0} actions: {actions}", True) + actions = {} + for i in range(AXObject.get_n_actions(obj)): + name = AXObject.get_action_name(obj, i) + if not name: + continue + localized_name = AXObject.get_action_localized_name(obj, i) + description = AXObject.get_action_description(obj, i) + actions[name] = localized_name or description or name + + debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Found {len(actions)} actions: {actions}", True) if not actions: - debug.printMessage(debug.LEVEL_INFO, "ACTION PRESENTER: No actions found, presenting NO_ACCESSIBLE_ACTIONS", True) - full_message = messages.NO_ACCESSIBLE_ACTIONS - cthulhu.presentMessage(full_message) - return False + msg = "ACTION PRESENTER: No actions found, presenting NO_ACTIONS_FOUND_ON" + debug.printMessage(debug.LEVEL_INFO, msg, True) + name = AXObject.get_name(obj) or AXUtilities.get_localized_role_name(obj) + full_message = messages.NO_ACTIONS_FOUND_ON % name + self._present_message(script, full_message, notify_user=notify_user) + return True debug.printMessage(debug.LEVEL_INFO, "ACTION PRESENTER: Creating GUI", True) self._obj = obj - self._window = cthulhu_state.activeWindow + self._window = focus_manager.get_manager().get_active_window() self._gui = ActionList(self) self._gui.populate_actions(actions) @@ -298,7 +336,13 @@ class ActionPresenter: debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Traceback: {traceback.format_exc()}", True) return False - def show_actions_list(self, script: script.Script = None, input_event: input_event.InputEvent = None) -> bool: + @dbus_service.command + def show_actions_list( + self, + script: script.Script = None, + input_event: input_event.InputEvent = None, + notify_user: bool = True + ) -> bool: """Shows the accessible actions list.""" try: @@ -306,12 +350,19 @@ class ActionPresenter: debug.printMessage(debug.LEVEL_INFO, msg, True) start_time = time.time() - - # Get object from input event if available, otherwise let present_with_time handle it - obj = input_event.get_object() if input_event else None + + obj = None + if input_event is not None: + obj = input_event.get_object() debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Object from input_event: {obj}", True) - - result = self.present_with_time(obj, start_time) + + if obj is None: + manager = focus_manager.get_manager() + _mode, obj = manager.get_active_mode_and_object_of_interest() + if obj is None: + obj = manager.get_locus_of_focus() + + result = self.present_with_time(obj, start_time, script, notify_user) debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: show_actions_list returning: {result}", True) return result except Exception as e: @@ -325,4 +376,4 @@ _presenter = ActionPresenter() def getPresenter() -> ActionPresenter: """Returns the Action Presenter singleton.""" - return _presenter \ No newline at end of file + return _presenter diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index 3250f59..9a87f35 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 = "2025.12.09" +version = "2025.12.27" codeName = "testing" diff --git a/src/cthulhu/label_inference.py b/src/cthulhu/label_inference.py index 4d8a0ed..f0384ea 100644 --- a/src/cthulhu/label_inference.py +++ b/src/cthulhu/label_inference.py @@ -31,11 +31,9 @@ __date__ = "$Date$" __copyright__ = "Copyright (C) 2011-2013 Igalia, S.L." __license__ = "LGPL" -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi - from . import debug +from .ax_component import AXComponent +from .ax_hypertext import AXHypertext from .ax_object import AXObject from .ax_table import AXTable from .ax_text import AXText @@ -80,7 +78,9 @@ class LabelInference: result, objects = self.inferFromTextLeft(obj) debug.printMessage(debug.LEVEL_INFO, f"LABEL INFERENCE: Text Left: '{result}'", True) if not result or self._preferRight(obj): - result, objects = self.inferFromTextRight(obj) or result + rightResult = self.inferFromTextRight(obj) + if rightResult[0] is not None: + result, objects = rightResult debug.printMessage(debug.LEVEL_INFO, f"LABEL INFERENCE: Text Right: '{result}'", True) if not result: result, objects = self.inferFromTable(obj) @@ -153,17 +153,12 @@ class LabelInference: return False def isMatch(x): - return x is not None \ - and not self._script.utilities.isStaticTextLeaf(x) \ - and not AXUtilities.is_link(x) + return AXUtilities.is_web_element(x) and not AXUtilities.is_link(x) - children = [child for child in AXObject.iter_children(obj, isMatch)] + children = list(AXObject.iter_children(obj, isMatch)) if len(children) > 1: return False - if not AXObject.supports_text(obj): - return True - string = AXText.get_all_text(obj).strip() if string.count(self._script.EMBEDDED_OBJECT_CHARACTER) > 1: return False @@ -191,19 +186,7 @@ class LabelInference: if rv is not None: return rv - widgetRoles = [Atspi.Role.CHECK_BOX, - Atspi.Role.RADIO_BUTTON, - Atspi.Role.TOGGLE_BUTTON, - Atspi.Role.COMBO_BOX, - Atspi.Role.LIST, - Atspi.Role.LIST_BOX, - Atspi.Role.MENU, - Atspi.Role.MENU_ITEM, - Atspi.Role.ENTRY, - Atspi.Role.PASSWORD_TEXT, - Atspi.Role.PUSH_BUTTON] - - isWidget = AXObject.get_role(obj) in widgetRoles + isWidget = AXUtilities.is_widget(obj) or AXUtilities.is_menu_related(obj) if not isWidget and AXUtilities.is_editable(obj): isWidget = True @@ -223,30 +206,15 @@ class LabelInference: return rv extents = 0, 0, 0, 0 - text = self._script.utilities.queryNonEmptyText(obj) - if text and not AXUtilities.is_text_input(obj): + if AXObject.supports_text(obj) and not AXUtilities.is_text_input(obj): if endOffset == -1: endOffset = AXText.get_character_count(obj) - - try: - extents = Atspi.Text.get_range_extents( - obj, startOffset, endOffset, Atspi.CoordType.SCREEN) - except Exception: - tokens = ["LABEL INFERENCE: Exception getting character count for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return extents + rect = AXText.get_range_rect(obj, startOffset, endOffset) + extents = rect.x, rect.y, rect.width, rect.height if not (extents[2] and extents[3]): - if not AXObject.supports_component(obj): - tokens = ["LABEL INFERENCE:", obj, "does not support the component interface"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - else: - try: - ext = Atspi.Component.get_extents(obj, Atspi.CoordType.SCREEN) - extents = ext.x, ext.y, ext.width, ext.height - except Exception: - tokens = ["LABEL INFERENCE: Exception getting extents for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + rect = AXComponent.get_rect(obj) + extents = rect.x, rect.y, rect.width, rect.height self._extentsCache[(hash(obj), startOffset, endOffset)] = extents return extents @@ -278,10 +246,12 @@ class LabelInference: key = hash(obj) if self._isWidget(obj): - start, end = self._script.utilities.getHyperlinkRange(obj) + start = AXHypertext.get_link_start_offset(obj) obj = AXObject.get_parent(obj) rv = self._script.utilities.getLineContentsAtOffset(obj, start, True, False) + if rv is None: + rv = [] self._lineCache[key] = rv return rv @@ -408,6 +378,10 @@ class LabelInference: if self._cannotLabel(prevObj): return None, [] + if string.endswith("\n"): + string = string[:-1] + end -= 1 + if string.strip(): x, y, width, height = self._getExtents(prevObj, start, end) objX, objY, objWidth, objHeight = self._getExtents(obj) @@ -528,7 +502,7 @@ class LabelInference: cellLeft = cellRight = cellAbove = cellBelow = None gridrow = AXObject.find_ancestor(cell, self._isRow) - rowindex, colindex = self._script.utilities.coordinatesForCell(cell) + rowindex, colindex = AXTable.get_cell_coordinates(cell, prefer_attribute=False) if colindex > -1: cellLeft = self._getCellFromTable(grid, rowindex, colindex - 1) cellRight = self._getCellFromTable(grid, rowindex, colindex + 1) diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index df97ec8..d1993f6 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -3547,7 +3547,11 @@ class Utilities(script_utilities.Utilities): if rv and not AXObject.get_name(obj) and AXObject.supports_text(obj): string = AXText.get_all_text(obj) - if not string.strip(): + if not string.replace("\ufffc", ""): + tokens = ["WEB:", obj, "is not clickable: its text is just EOCs"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + rv = False + elif not string.strip(): rv = not (AXUtilities.is_static(obj) or AXUtilities.is_link(obj)) self._isClickableElement[hash(obj)] = rv