Moving closer to tagged release.

This commit is contained in:
Storm Dragon
2025-12-30 05:30:50 -05:00
62 changed files with 1585 additions and 760 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ subdir('PluginName')
- Community: IRC #stormux on irc.stormux.org
### Key Dependencies
- Python 3.10+, pygobject-3.0, pluggy, gtk+-3.0
- Python 3.9+, pygobject-3.0, pluggy, gtk+-3.0
- AT-SPI2, ATK for accessibility
- Optional: BrlTTY/BrlAPI (braille), Speech Dispatcher, liblouis, GStreamer
+1 -1
View File
@@ -49,7 +49,7 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit.
### Core Requirements
* **Python 3.10+** - Python platform
* **Python 3.9+** - Python platform
* **pygobject-3.0** - Python bindings for the GObject library
* **gtk+-3.0** - GTK+ toolkit (minimal usage for AT-SPI integration)
* **AT-SPI2** - Assistive Technology Service Provider Interface
+22 -35
View File
@@ -6,85 +6,72 @@
set -e # Exit on error
# Colors for output (only if stdout is a terminal)
if [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
else
RED=''
GREEN=''
YELLOW=''
BLUE=''
NC=''
fi
echo -e "${BLUE}Cthulhu Meson Local Build Script${NC}"
echo "Cthulhu Meson Local Build Script"
echo "Building and installing Cthulhu to ~/.local"
echo "================================================"
# Check if we're in the right directory
if [[ ! -f "meson.build" ]] || [[ ! -f "src/cthulhu/cthulhu.py" ]]; then
echo -e "${RED}Error: This script must be run from the Cthulhu source directory${NC}"
echo "Error: This script must be run from the Cthulhu source directory"
exit 1
fi
# Check for required dependencies
echo -e "${YELLOW}Checking dependencies...${NC}"
echo "Checking dependencies..."
for cmd in meson ninja python3; do
if ! command -v "$cmd" &> /dev/null; then
echo -e "${RED}Error: $cmd is not installed${NC}"
echo "Error: $cmd is not installed"
echo "Please install: meson ninja python"
exit 1
fi
done
# Check for optional dependencies
missing_optional=()
for module in gi speech; do
if ! python3 -c "import $module" 2>/dev/null; then
missing_optional+=("python-$module")
fi
done
missingOptional=()
if ! python3 -c "import gi" 2>/dev/null; then
missingOptional+=("python-gi")
fi
if [[ ${#missing_optional[@]} -gt 0 ]]; then
echo -e "${YELLOW}Warning: Optional dependencies missing: ${missing_optional[*]}${NC}"
if [[ ${#missingOptional[@]} -gt 0 ]]; then
echo "Warning: Optional dependencies missing: ${missingOptional[*]}"
echo "Cthulhu may not function properly without these."
fi
# Clean any cached bytecode
echo "Removing __pycache__ directories..."
find . -type d -name "__pycache__" -prune -exec rm -rf {} +
# Clean any existing build directory
if [[ -d "_build" ]]; then
echo -e "${YELLOW}Removing existing build directory...${NC}"
echo "Removing existing build directory..."
rm -rf _build
fi
# Setup Meson build
echo -e "${YELLOW}Setting up Meson build...${NC}"
echo "Setting up Meson build..."
meson setup _build --prefix="$HOME/.local" --buildtype=debugoptimized
# Build
echo -e "${YELLOW}Building Cthulhu...${NC}"
echo "Building Cthulhu..."
meson compile -C _build
# Install
echo -e "${YELLOW}Installing Cthulhu to ~/.local...${NC}"
echo "Installing Cthulhu to ~/.local..."
meson install -C _build
# Update desktop database and icon cache
if command -v update-desktop-database &> /dev/null; then
echo -e "${YELLOW}Updating desktop database...${NC}"
echo "Updating desktop database..."
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
fi
if command -v gtk-update-icon-cache &> /dev/null; then
echo -e "${YELLOW}Updating icon cache...${NC}"
echo "Updating icon cache..."
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
fi
echo
echo -e "${GREEN}Build completed successfully!${NC}"
echo "Build completed successfully!"
echo
echo "To run Cthulhu:"
echo " ~/.local/bin/cthulhu"
@@ -93,4 +80,4 @@ echo "To run Cthulhu setup:"
echo " ~/.local/bin/cthulhu -s"
echo
echo "Build artifacts are in: _build/"
echo -e "${BLUE}To clean build artifacts, run: ${YELLOW}rm -rf _build${NC}"
echo "To clean build artifacts, run: rm -rf _build"
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu
pkgver=2025.12.28
pkgver=2025.12.30
pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu"
+6 -2
View File
@@ -1,12 +1,16 @@
project('cthulhu',
version: '2025.12.28-master',
<<<<<<< HEAD
version: '2025.12.30-master',
=======
version: '2025.12.30-master',
>>>>>>> testing
meson_version: '>= 1.0.0',
)
python = import('python')
i18n = import('i18n')
python_minimum_version = '3.10'
python_minimum_version = '3.9'
python3 = python.find_installation('python3', required: true)
if not python3.language_version().version_compare(f'>= @python_minimum_version@')
error(f'Python @python_minimum_version@ or newer is required.')
+1 -1
View File
@@ -7,7 +7,7 @@ name = "cthulhu"
dynamic = ["version"]
description = "Fork of the Orca screen reader"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.9"
license = { text = "LGPL-2.1-or-later" }
dependencies = [
"pygobject>=3.18",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -34,7 +34,7 @@ __copyright__ = "Copyright (c) 2023 Igalia, S.L."
__license__ = "LGPL"
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
import gi
@@ -103,7 +103,7 @@ class ActionList(Gtk.Window):
GLib.idle_add(self._presenter._clear_gui_and_restore_focus)
def populate_actions(self, actions: dict[str, str] | list[str]) -> None:
def populate_actions(self, actions: Union[dict[str, str], list[str]]) -> None:
"""Populates the list with accessible actions."""
if isinstance(actions, dict):
+7 -6
View File
@@ -29,6 +29,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2023 Igalia, S.L."
__license__ = "LGPL"
from typing import Optional
import time
import gi
@@ -48,15 +49,15 @@ class AXCollection:
# pylint: disable=R0913,R0914
@staticmethod
def create_match_rule(
states: list[str] | None = None,
states: Optional[list[str]] = None,
state_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL,
attributes: list[str] | None = None,
attributes: Optional[list[str]] = None,
attribute_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL,
roles: list[str] | None = None,
roles: Optional[list[str]] = None,
role_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL,
interfaces: list[str] | None = None,
interfaces: Optional[list[str]] = None,
interface_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL,
invert: bool = False) -> Atspi.MatchRule | None:
invert: bool = False) -> Optional[Atspi.MatchRule]:
"""Creates a match rule based on the supplied criteria."""
if states is None:
@@ -136,7 +137,7 @@ class AXCollection:
obj: Atspi.Accessible,
rule: Atspi.MatchRule,
order: Atspi.CollectionSortOrder = Atspi.CollectionSortOrder.CANONICAL
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the first object matching the specified rule."""
if not AXObject.supports_collection(obj):
+5 -4
View File
@@ -34,6 +34,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL"
from typing import Optional
import functools
import gi
@@ -285,7 +286,7 @@ class AXComponent:
@staticmethod
def _find_descendant_at_point(
obj: Atspi.Accessible, x: int, y: int
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Checks each child to see if it has a descendant at the specified point."""
for child in AXObject.iter_children(obj):
@@ -297,7 +298,7 @@ class AXComponent:
return None
@staticmethod
def _get_object_at_point(obj: Atspi.Accessible, x: int, y: int) -> Atspi.Accessible | None:
def _get_object_at_point(obj: Atspi.Accessible, x: int, y: int) -> Optional[Atspi.Accessible]:
"""Returns the child (or descendant?) of obj at the specified point."""
if not AXObject.supports_component(obj):
@@ -317,7 +318,7 @@ class AXComponent:
@staticmethod
def _get_descendant_at_point(
obj: Atspi.Accessible, x: int, y: int
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the deepest descendant of obj at the specified point."""
child = AXComponent._get_object_at_point(obj, x, y)
@@ -338,7 +339,7 @@ class AXComponent:
@staticmethod
def get_descendant_at_point(
obj: Atspi.Accessible, x: int, y: int
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the deepest descendant of obj at the specified point."""
result = AXComponent._get_descendant_at_point(obj, x, y)
+24 -23
View File
@@ -34,6 +34,7 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
"Copyright (c) 2018-2023 Igalia, S.L."
__license__ = "LGPL"
from typing import Optional
import gi
gi.require_version("Atspi", "2.0")
gi.require_version("Gtk", "3.0")
@@ -60,8 +61,8 @@ class AXEventSynthesizer:
@staticmethod
def _is_scrolled_off_screen(
obj: Atspi.Accessible,
offset: int | None = None,
ancestor: Atspi.Accessible | None = None
offset: Optional[int] = None,
ancestor: Optional[Atspi.Accessible] = None
) -> bool:
"""Returns true if obj, or the caret offset therein, is scrolled off-screen."""
@@ -119,7 +120,7 @@ class AXEventSynthesizer:
return True
@staticmethod
def _mouse_event_on_character(obj: Atspi.Accessible, offset: int | None, event: str) -> bool:
def _mouse_event_on_character(obj: Atspi.Accessible, offset: Optional[int], event: str) -> bool:
"""Performs the specified mouse event on the current character in obj."""
if offset is None:
@@ -164,7 +165,7 @@ class AXEventSynthesizer:
return AXEventSynthesizer._generate_mouse_event(obj, relative_x, relative_y, event)
@staticmethod
def route_to_character(obj: Atspi.Accessible, offset: int | None = None) -> bool:
def route_to_character(obj: Atspi.Accessible, offset: Optional[int] = None) -> bool:
"""Routes the pointer to the current character in obj."""
tokens = [f"AXEventSynthesizer: Attempting to route to offset {offset} in", obj]
@@ -181,7 +182,7 @@ class AXEventSynthesizer:
@staticmethod
def click_character(
obj: Atspi.Accessible, offset: int | None = None, button: int = 1
obj: Atspi.Accessible, offset: Optional[int] = None, button: int = 1
) -> bool:
"""Single click on the current character in obj using the specified button."""
@@ -198,7 +199,7 @@ class AXEventSynthesizer:
@staticmethod
def _scroll_to_location(
obj: Atspi.Accessible, location: Atspi.ScrollType,
start_offset: int | None = None, end_offset: int | None = None
start_offset: Optional[int] = None, end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll to the specified location."""
@@ -220,7 +221,7 @@ class AXEventSynthesizer:
@staticmethod
def _scroll_to_point(
obj: Atspi.Accessible, x_coord: int, y_coord: int,
start_offset: int | None = None, end_offset: int | None = None
start_offset: Optional[int] = None, end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the specified point."""
@@ -242,8 +243,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_into_view(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj into view."""
@@ -253,8 +254,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_center(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the center of its window."""
@@ -272,8 +273,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_top_edge(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the top edge."""
@@ -283,8 +284,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_top_left(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the top left."""
@@ -294,8 +295,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_left_edge(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the left edge."""
@@ -305,8 +306,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_bottom_edge(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the bottom edge."""
@@ -316,8 +317,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_bottom_right(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the bottom right."""
@@ -327,8 +328,8 @@ class AXEventSynthesizer:
@staticmethod
def scroll_to_right_edge(
obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> None:
"""Attempts to scroll obj to the right edge."""
+4 -3
View File
@@ -32,6 +32,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Igalia, S.L."
__license__ = "LGPL"
from typing import Optional
import os
import re
from urllib.parse import urlparse
@@ -66,7 +67,7 @@ class AXHypertext:
return count
@staticmethod
def _get_link_at_index(obj: Atspi.Accessible, index: int) -> Atspi.Hyperlink | None:
def _get_link_at_index(obj: Atspi.Accessible, index: int) -> Optional[Atspi.Hyperlink]:
"""Returns the hyperlink object at the specified index."""
if not AXObject.supports_hypertext(obj):
@@ -202,7 +203,7 @@ class AXHypertext:
return basename
@staticmethod
def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Atspi.Accessible | None:
def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Optional[Atspi.Accessible]:
"""Attempts to correct for off-by-one brokenness in implementations"""
if child := AXHypertext.get_child_at_offset(obj, offset):
@@ -227,7 +228,7 @@ class AXHypertext:
return None
@staticmethod
def get_child_at_offset(obj: Atspi.Accessible, offset: int) -> Atspi.Accessible | None:
def get_child_at_offset(obj: Atspi.Accessible, offset: int) -> Optional[Atspi.Accessible]:
"""Returns the embedded-object child of obj at the specified offset."""
if not AXObject.supports_hypertext(obj):
+21 -21
View File
@@ -34,7 +34,7 @@ __license__ = "LGPL"
import re
import threading
import time
from typing import Callable, Generator
from typing import Callable, Generator, Optional
import gi
gi.require_version("Atspi", "2.0")
@@ -105,7 +105,7 @@ class AXObject:
return name.lower()
@staticmethod
def get_application(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_application(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the accessible application associated with obj."""
if obj is None:
@@ -313,7 +313,7 @@ class AXObject:
return iface is not None
@staticmethod
def find_real_app_and_window_for(obj: Atspi.Accessible, app: Atspi.Accessible | None = None):
def find_real_app_and_window_for(obj: Atspi.Accessible, app: Optional[Atspi.Accessible] = None):
"""Work around for window events coming from mutter-x11-frames."""
if app is None:
@@ -545,7 +545,7 @@ class AXObject:
return index
@staticmethod
def get_parent(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_parent(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the accessible parent of obj. See also get_parent_checked."""
if not AXObject.is_valid(obj):
@@ -571,7 +571,7 @@ class AXObject:
return parent
@staticmethod
def get_parent_checked(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_parent_checked(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the parent of obj, doing checks for tree validity"""
if not AXObject.is_valid(obj):
@@ -619,7 +619,7 @@ class AXObject:
def get_common_ancestor(
obj1: Atspi.Accessible,
obj2: Atspi.Accessible
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the common ancestor of obj1 and obj2."""
tokens = ["AXObject: Looking for common ancestor of", obj1, "and", obj2]
@@ -647,7 +647,7 @@ class AXObject:
def find_ancestor_inclusive(
obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns obj, or the ancestor of obj, for which the function pred is true"""
if pred(obj):
@@ -659,7 +659,7 @@ class AXObject:
def find_ancestor(
obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the ancestor of obj if the function pred is true"""
if not AXObject.is_valid(obj):
@@ -703,7 +703,7 @@ class AXObject:
return AXObject.find_ancestor(obj, lambda x: x == ancestor) is not None
@staticmethod
def get_child(obj: Atspi.Accessible, index: int) -> Atspi.Accessible | None:
def get_child(obj: Atspi.Accessible, index: int) -> Optional[Atspi.Accessible]:
"""Returns the nth child of obj. See also get_child_checked."""
if not AXObject.is_valid(obj):
@@ -736,7 +736,7 @@ class AXObject:
@staticmethod
def get_child_checked(
obj: Atspi.Accessible, index: int
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the nth child of obj, doing checks for tree validity"""
if not AXObject.is_valid(obj):
@@ -757,7 +757,7 @@ class AXObject:
def get_active_descendant_checked(
container: Atspi.Accessible,
reported_child: Atspi.Accessible
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Checks the reported active descendant and return the real/valid one."""
if not AXObject.has_state(container, Atspi.StateType.MANAGES_DESCENDANTS):
@@ -784,7 +784,7 @@ class AXObject:
def _find_descendant(
obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the descendant of obj if the function pred is true"""
if not AXObject.is_valid(obj):
@@ -806,7 +806,7 @@ class AXObject:
def find_descendant(
obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None:
) -> Optional[Atspi.Accessible]:
"""Returns the descendant of obj if the function pred is true"""
start = time.time()
@@ -816,7 +816,7 @@ class AXObject:
return result
@staticmethod
def find_deepest_descendant(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def find_deepest_descendant(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the deepest descendant of obj"""
if not AXObject.is_valid(obj):
@@ -831,8 +831,8 @@ class AXObject:
@staticmethod
def _find_all_descendants(
obj: Atspi.Accessible,
include_if: Callable[[Atspi.Accessible], bool] | None,
exclude_if: Callable[[Atspi.Accessible], bool] | None,
include_if: Optional[Callable[[Atspi.Accessible], bool]],
exclude_if: Optional[Callable[[Atspi.Accessible], bool]],
matches: list[Atspi.Accessible]
) -> None:
"""Returns all descendants which match the specified inclusion and exclusion"""
@@ -852,8 +852,8 @@ class AXObject:
@staticmethod
def find_all_descendants(
root: Atspi.Accessible,
include_if: Callable[[Atspi.Accessible], bool] | None = None,
exclude_if: Callable[[Atspi.Accessible], bool] | None = None
include_if: Optional[Callable[[Atspi.Accessible], bool]] = None,
exclude_if: Optional[Callable[[Atspi.Accessible], bool]] = None
) -> list[Atspi.Accessible]:
"""Returns all descendants which match the specified inclusion and exclusion"""
@@ -1044,7 +1044,7 @@ class AXObject:
@staticmethod
def iter_children(
obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool] | None = None
pred: Optional[Callable[[Atspi.Accessible], bool]] = None
) -> Generator[Atspi.Accessible, None, None]:
"""Generator to iterate through obj's children. If the function pred is
specified, children for which pred is False will be skipped."""
@@ -1068,7 +1068,7 @@ class AXObject:
yield child
@staticmethod
def get_previous_sibling(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_previous_sibling(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the previous sibling of obj, based on child indices"""
if not AXObject.is_valid(obj):
@@ -1091,7 +1091,7 @@ class AXObject:
return sibling
@staticmethod
def get_next_sibling(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_next_sibling(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the next sibling of obj, based on child indices"""
if not AXObject.is_valid(obj):
+2 -1
View File
@@ -28,6 +28,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2023 Igalia, S.L."
__license__ = "LGPL"
from typing import Optional
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
@@ -60,7 +61,7 @@ class AXSelection:
return count
@staticmethod
def get_selected_child(obj: Atspi.Accessible, index: int) -> Atspi.Accessible | None:
def get_selected_child(obj: Atspi.Accessible, index: int) -> Optional[Atspi.Accessible]:
"""Returns the nth selected child of obj."""
n_children = AXSelection.get_selected_child_count(obj)
+27 -27
View File
@@ -35,7 +35,7 @@ __license__ = "LGPL"
import threading
import time
from typing import Generator
from typing import Generator, Optional
import gi
gi.require_version("Atspi", "2.0")
@@ -62,11 +62,11 @@ class AXTable:
PHYSICAL_SPANS_FROM_TABLE: dict[int, tuple[int, int]] = {}
PHYSICAL_COLUMN_COUNT: dict[int, int] = {}
PHYSICAL_ROW_COUNT: dict[int, int] = {}
PRESENTABLE_COORDINATES: dict[int, tuple[str | None, str | None]] = {}
PRESENTABLE_COORDINATES: dict[int, tuple[Optional[str], Optional[str]]] = {}
PRESENTABLE_COORDINATES_LABELS: dict[int, str] = {}
PRESENTABLE_SPANS: dict[int, tuple[str | None, str | None]] = {}
PRESENTABLE_COLUMN_COUNT: dict[int, int | None] = {}
PRESENTABLE_ROW_COUNT: dict[int, int | None] = {}
PRESENTABLE_SPANS: dict[int, tuple[Optional[str], Optional[str]]] = {}
PRESENTABLE_COLUMN_COUNT: dict[int, Optional[int]] = {}
PRESENTABLE_ROW_COUNT: dict[int, Optional[int]] = {}
COLUMN_HEADERS_FOR_CELL: dict[int, list[Atspi.Accessible]] = {}
ROW_HEADERS_FOR_CELL: dict[int, list[Atspi.Accessible]] = {}
@@ -121,7 +121,7 @@ class AXTable:
AXTable._clear_all_dictionaries(reason)
@staticmethod
def get_caption(table: Atspi.Accessible) -> Atspi.Accessible | None:
def get_caption(table: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the accessible object containing the caption of table."""
if not AXObject.supports_table(table):
@@ -171,7 +171,7 @@ class AXTable:
return count
@staticmethod
def _get_column_count_from_attribute(table: Atspi.Accessible) -> int | None:
def _get_column_count_from_attribute(table: Atspi.Accessible) -> Optional[int]:
"""Returns the value of the 'colcount' object attribute or None if not found."""
if hash(table) in AXTable.PRESENTABLE_COLUMN_COUNT:
@@ -217,7 +217,7 @@ class AXTable:
return count
@staticmethod
def _get_row_count_from_attribute(table: Atspi.Accessible) -> int | None:
def _get_row_count_from_attribute(table: Atspi.Accessible) -> Optional[int]:
"""Returns the value of the 'rowcount' object attribute or None if not found."""
if hash(table) in AXTable.PRESENTABLE_ROW_COUNT:
@@ -344,7 +344,7 @@ class AXTable:
return AXTable.get_selected_column_count(table) == cols
@staticmethod
def get_cell_at(table: Atspi.Accessible, row: int, column: int) -> Atspi.Accessible | None:
def get_cell_at(table: Atspi.Accessible, row: int, column: int) -> Optional[Atspi.Accessible]:
"""Returns the cell at the 0-indexed row and column."""
if not AXObject.supports_table(table):
@@ -405,7 +405,7 @@ class AXTable:
@staticmethod
def _get_cell_spans_from_attribute(
cell: Atspi.Accessible
) -> tuple[str | None, str | None]:
) -> tuple[Optional[str], Optional[str]]:
"""Returns the row and column spans exposed via object attribute, or None, None."""
if hash(cell) in AXTable.PRESENTABLE_SPANS:
@@ -606,7 +606,7 @@ class AXTable:
@staticmethod
def get_new_row_headers(
cell: Atspi.Accessible,
old_cell: Atspi.Accessible | None
old_cell: Optional[Atspi.Accessible]
) -> list[Atspi.Accessible]:
"""Returns row headers of cell that are not also headers of old_cell. """
@@ -623,7 +623,7 @@ class AXTable:
@staticmethod
def get_new_column_headers(
cell: Atspi.Accessible,
old_cell: Atspi.Accessible | None
old_cell: Optional[Atspi.Accessible]
) -> list[Atspi.Accessible]:
"""Returns column headers of cell that are not also headers of old_cell. """
@@ -880,7 +880,7 @@ class AXTable:
@staticmethod
def _get_cell_coordinates_from_attribute(
cell: Atspi.Accessible
) -> tuple[str | None, str | None]:
) -> tuple[Optional[str], Optional[str]]:
"""Returns the 1-based indices for cell exposed via object attribute, or None, None."""
if cell is None:
@@ -941,7 +941,7 @@ class AXTable:
return result
@staticmethod
def get_table(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_table(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns obj if it is a table, otherwise returns the ancestor table of obj."""
if obj is None:
@@ -981,21 +981,21 @@ class AXTable:
return result
@staticmethod
def get_first_cell(table: Atspi.Accessible) -> Atspi.Accessible | None:
def get_first_cell(table: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the first cell in table."""
row, col = 0, 0
return AXTable.get_cell_at(table, row, col)
@staticmethod
def get_last_cell(table: Atspi.Accessible) -> Atspi.Accessible | None:
def get_last_cell(table: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the last cell in table."""
row, col = AXTable.get_row_count(table) - 1, AXTable.get_column_count(table) - 1
return AXTable.get_cell_at(table, row, col)
@staticmethod
def get_cell_above(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_cell_above(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell above cell in table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
@@ -1003,7 +1003,7 @@ class AXTable:
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_below(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_cell_below(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell below cell in table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
@@ -1011,7 +1011,7 @@ class AXTable:
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_on_left(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_cell_on_left(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell to the left."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
@@ -1019,7 +1019,7 @@ class AXTable:
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_on_right(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_cell_on_right(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell to the right."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
@@ -1027,14 +1027,14 @@ class AXTable:
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_start_of_row(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_start_of_row(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell at the start of cell's row."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
return AXTable.get_cell_at(AXTable.get_table(cell), row, 0)
@staticmethod
def get_end_of_row(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_end_of_row(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell at the end of cell's row."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
@@ -1043,14 +1043,14 @@ class AXTable:
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_top_of_column(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_top_of_column(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell at the top of cell's column."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
return AXTable.get_cell_at(AXTable.get_table(cell), 0, col)
@staticmethod
def get_bottom_of_column(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_bottom_of_column(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the cell at the bottom of cell's column."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
@@ -1059,7 +1059,7 @@ class AXTable:
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_formula(cell: Atspi.Accessible) -> str | None:
def get_cell_formula(cell: Atspi.Accessible) -> Optional[str]:
"""Returns the formula associated with this cell."""
attrs = AXObject.get_attributes_dict(cell, use_cache=False)
@@ -1192,7 +1192,7 @@ class AXTable:
return result
@staticmethod
def get_dynamic_row_header(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_dynamic_row_header(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the user-set row header for cell."""
table = AXTable.get_table(cell)
@@ -1207,7 +1207,7 @@ class AXTable:
return AXTable.get_cell_at(table, cell_row, headers_column)
@staticmethod
def get_dynamic_column_header(cell: Atspi.Accessible) -> Atspi.Accessible | None:
def get_dynamic_column_header(cell: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the user-set column header for cell."""
table = AXTable.get_table(cell)
+32 -32
View File
@@ -44,7 +44,7 @@ __license__ = "LGPL"
import enum
import locale
import re
from typing import Generator
from typing import Generator, Optional
import gi
gi.require_version("Atspi", "2.0")
@@ -104,7 +104,7 @@ class AXTextAttribute(enum.Enum):
WRITING_MODE = ("writing-mode", False)
@classmethod
def from_string(cls, string: str) -> "AXTextAttribute" | None:
def from_string(cls, string: str) -> Optional['AXTextAttribute']:
"""Returns the AXTextAttribute for the specified string."""
for attribute in cls:
@@ -114,7 +114,7 @@ class AXTextAttribute(enum.Enum):
return None
@classmethod
def from_localized_string(cls, string: str) -> "AXTextAttribute" | None:
def from_localized_string(cls, string: str) -> Optional['AXTextAttribute']:
"""Returns the AXTextAttribute for the specified localized string."""
for attribute in cls:
@@ -192,7 +192,7 @@ class AXText:
@staticmethod
def get_character_at_offset(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the (character, start, end) for the current or specified offset."""
@@ -239,7 +239,7 @@ class AXText:
@staticmethod
def get_next_character(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the next (character, start, end) for the current or specified offset."""
@@ -264,7 +264,7 @@ class AXText:
@staticmethod
def get_previous_character(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the previous (character, start, end) for the current or specified offset."""
@@ -291,7 +291,7 @@ class AXText:
@staticmethod
def iter_character(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by character in obj starting with the character at offset."""
@@ -312,7 +312,7 @@ class AXText:
@staticmethod
def get_word_at_offset(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the (word, start, end) for the current or specified offset."""
@@ -354,7 +354,7 @@ class AXText:
@staticmethod
def get_next_word(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the next (word, start, end) for the current or specified offset."""
@@ -379,7 +379,7 @@ class AXText:
@staticmethod
def get_previous_word(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the previous (word, start, end) for the current or specified offset."""
@@ -406,7 +406,7 @@ class AXText:
@staticmethod
def iter_word(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by word in obj starting with the word at offset."""
@@ -427,7 +427,7 @@ class AXText:
@staticmethod
def get_line_at_offset(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the (line, start, end) for the current or specified offset."""
@@ -495,7 +495,7 @@ class AXText:
@staticmethod
def get_next_line(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the next (line, start, end) for the current or specified offset."""
@@ -520,7 +520,7 @@ class AXText:
@staticmethod
def get_previous_line(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the previous (line, start, end) for the current or specified offset."""
@@ -549,7 +549,7 @@ class AXText:
@staticmethod
def iter_line(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by line in obj starting with the line at offset."""
@@ -606,7 +606,7 @@ class AXText:
@staticmethod
def _get_sentence_at_offset_fallback(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Fallback sentence detection for broken implementations."""
@@ -633,7 +633,7 @@ class AXText:
@staticmethod
def get_sentence_at_offset(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the (sentence, start, end) for the current or specified offset."""
@@ -682,7 +682,7 @@ class AXText:
@staticmethod
def get_next_sentence(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the next (sentence, start, end) for the current or specified offset."""
@@ -707,7 +707,7 @@ class AXText:
@staticmethod
def get_previous_sentence(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the previous (sentence, start, end) for the current or specified offset."""
@@ -734,7 +734,7 @@ class AXText:
@staticmethod
def iter_sentence(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by sentence in obj starting with the sentence at offset."""
@@ -759,7 +759,7 @@ class AXText:
@staticmethod
def get_paragraph_at_offset(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the (paragraph, start, end) for the current or specified offset."""
@@ -801,7 +801,7 @@ class AXText:
@staticmethod
def get_next_paragraph(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the next (paragraph, start, end) for the current or specified offset."""
@@ -826,7 +826,7 @@ class AXText:
@staticmethod
def get_previous_paragraph(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the previous (paragraph, start, end) for the current or specified offset."""
@@ -852,7 +852,7 @@ class AXText:
@staticmethod
def iter_paragraph(
obj: Atspi.Accessible, offset: int | None = None
obj: Atspi.Accessible, offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by paragraph in obj starting with the paragraph at offset."""
@@ -1206,7 +1206,7 @@ class AXText:
@staticmethod
def get_text_attributes_at_offset(
obj: Atspi.Accessible,
offset: int | None = None
offset: Optional[int] = None
) -> tuple[dict[str, str], int, int]:
"""Returns a (dict, start, end) tuple for attributes at offset in obj."""
@@ -1303,7 +1303,7 @@ class AXText:
return offset
@staticmethod
def get_character_rect(obj: Atspi.Accessible, offset: int | None = None) -> Atspi.Rect:
def get_character_rect(obj: Atspi.Accessible, offset: Optional[int] = None) -> Atspi.Rect:
"""Returns the Atspi rect of the character at the specified offset in obj."""
if not AXObject.supports_text(obj):
@@ -1487,7 +1487,7 @@ class AXText:
return result
@staticmethod
def string_has_spelling_error(obj: Atspi.Accessible, offset: int | None = None) -> bool:
def string_has_spelling_error(obj: Atspi.Accessible, offset: Optional[int] = None) -> bool:
"""Returns True if the text attributes indicate a spelling error."""
attributes = AXText.get_text_attributes_at_offset(obj, offset)[0]
@@ -1502,7 +1502,7 @@ class AXText:
return False
@staticmethod
def string_has_grammar_error(obj: Atspi.Accessible, offset: int | None = None) -> bool:
def string_has_grammar_error(obj: Atspi.Accessible, offset: Optional[int] = None) -> bool:
"""Returns True if the text attributes indicate a grammar error."""
attributes = AXText.get_text_attributes_at_offset(obj, offset)[0]
@@ -1552,8 +1552,8 @@ class AXText:
obj: Atspi.Accessible,
x: int,
y: int,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> bool:
"""Attempts to scroll obj to the specified point."""
@@ -1583,8 +1583,8 @@ class AXText:
def scroll_substring_to_location(
obj: Atspi.Accessible,
location: Atspi.ScrollType,
start_offset: int | None = None,
end_offset: int | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None
) -> bool:
"""Attempts to scroll the substring to the specified Atspi.ScrollType location."""
+13 -12
View File
@@ -34,6 +34,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2023-2025 Igalia, S.L."
__license__ = "LGPL"
from typing import Optional
import functools
import inspect
import queue
@@ -97,7 +98,7 @@ class AXUtilities:
AXUtilities.IS_LAYOUT_ONLY.clear()
@staticmethod
def clear_all_cache_now(obj: Atspi.Accessible | None = None, reason: str = "") -> None:
def clear_all_cache_now(obj: Optional[Atspi.Accessible] = None, reason: str = "") -> None:
"""Clears all cached information immediately."""
AXUtilities._clear_all_dictionaries(reason)
@@ -152,7 +153,7 @@ class AXUtilities:
return True
@staticmethod
def find_active_window() -> Atspi.Accessible | None:
def find_active_window() -> Optional[Atspi.Accessible]:
"""Tries to locate the active window; may or may not succeed."""
candidates = []
@@ -192,7 +193,7 @@ class AXUtilities:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return filtered[0]
guess: Atspi.Accessible | None = None
guess: Optional[Atspi.Accessible] = None
if filtered:
tokens = ["AXUtilities: Still have multiple active windows:", filtered]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
@@ -266,7 +267,7 @@ class AXUtilities:
return AXObject.find_all_descendants(obj, is_match)
@staticmethod
def get_default_button(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_default_button(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the default button descendant of obj"""
result = None
@@ -278,7 +279,7 @@ class AXUtilities:
return AXObject.find_descendant(obj, AXUtilitiesRole.is_default_button)
@staticmethod
def get_focused_object(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_focused_object(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the focused descendant of obj"""
result = None
@@ -290,7 +291,7 @@ class AXUtilities:
return AXObject.find_descendant(obj, AXUtilitiesState.is_focused)
@staticmethod
def get_info_bar(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_info_bar(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the info bar descendant of obj"""
result = None
@@ -302,7 +303,7 @@ class AXUtilities:
return AXObject.find_descendant(obj, AXUtilitiesRole.is_info_bar)
@staticmethod
def get_status_bar(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_status_bar(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the status bar descendant of obj"""
result = None
@@ -748,7 +749,7 @@ class AXUtilities:
return len(ancestors)
@staticmethod
def get_next_object(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_next_object(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the next object (depth first, unless there's a flows-to relation)"""
if not AXObject.is_valid(obj):
@@ -778,7 +779,7 @@ class AXUtilities:
return next_object
@staticmethod
def get_previous_object(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_previous_object(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the previous object (depth first, unless there's a flows-from relation)"""
if not AXObject.is_valid(obj):
@@ -810,7 +811,7 @@ class AXUtilities:
@staticmethod
def is_on_screen(
obj: Atspi.Accessible,
bounding_box: Atspi.Rect | None = None
bounding_box: Optional[Atspi.Rect] = None
) -> bool:
"""Returns true if obj should be treated as being on screen."""
@@ -887,7 +888,7 @@ class AXUtilities:
def _get_on_screen_objects(
root: Atspi.Accessible,
cancellation_event: threading.Event,
bounding_box: Atspi.Rect | None = None
bounding_box: Optional[Atspi.Rect] = None
) -> list:
tokens = ["AXUtilities: Getting on-screen objects in", root, f"({hex(id(root))})"]
@@ -939,7 +940,7 @@ class AXUtilities:
@staticmethod
def get_on_screen_objects(
root: Atspi.Accessible,
bounding_box: Atspi.Rect | None = None,
bounding_box: Optional[Atspi.Rect] = None,
timeout: float = 5.0
) -> list:
"""Returns a list of onscreen objects in the given root."""
+4 -3
View File
@@ -30,6 +30,7 @@ __copyright__ = "Copyright (c) 2023-2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL"
from typing import Optional
import subprocess
import gi
@@ -82,7 +83,7 @@ class AXUtilitiesApplication:
return list(AXObject.iter_children(desktop, pred))
@staticmethod
def get_application(obj: Atspi.Accessible) -> Atspi.Accessible | None:
def get_application(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
"""Returns the accessible application associated with obj"""
if obj is None:
@@ -131,7 +132,7 @@ class AXUtilitiesApplication:
return version
@staticmethod
def get_application_with_pid(pid: int) -> Atspi.Accessible | None:
def get_application_with_pid(pid: int) -> Optional[Atspi.Accessible]:
"""Returns the accessible application with the specified pid"""
applications = AXUtilitiesApplication.get_all_applications()
@@ -144,7 +145,7 @@ class AXUtilitiesApplication:
return None
@staticmethod
def get_desktop() -> Atspi.Accessible | None:
def get_desktop() -> Optional[Atspi.Accessible]:
"""Returns the accessible desktop"""
try:
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -35,6 +35,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL"
from typing import Optional
import threading
import time
@@ -118,7 +119,7 @@ class AXUtilitiesRelation:
def _get_relation(
obj: Atspi.Accessible,
relation_type: Atspi.RelationType
) -> Atspi.Relation | None:
) -> Optional[Atspi.Relation]:
"""Returns the specified Atspi.Relation for obj"""
for relation in AXUtilitiesRelation.get_relations(obj):
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -34,6 +34,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL"
from typing import Optional
import threading
import time
@@ -149,7 +150,7 @@ class AXValue:
return f"{current:.{decimal_places}f}"
@staticmethod
def get_value_as_percent(obj: Atspi.Accessible) -> int | None:
def get_value_as_percent(obj: Atspi.Accessible) -> Optional[int]:
"""Returns the current value as a percent, or None if that is not applicable."""
if not AXObject.supports_value(obj):
+47 -20
View File
@@ -1035,23 +1035,6 @@
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkCheckButton" id="enableModeChangeSoundCheckButton">
<property name="label" translatable="yes" comments="Translators: If this checkbox is checked, Cthulhu will play sounds when switching between browse mode and focus mode in web content.">Play sounds when switching between _browse and focus modes</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="xalign">0</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="enableModeChangeSoundCheckButtonToggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="soundThemeHBox">
<property name="visible">True</property>
@@ -1091,6 +1074,51 @@
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="roleSoundPresentationHBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">12</property>
<child>
<object class="GtkLabel" id="roleSoundPresentationLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">0</property>
<property name="label" translatable="yes" comments="Translators: This is the label for a combo box where users can choose how Cthulhu presents element roles when a role sound is available.">Element _presentation:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">roleSoundPresentationCombo</property>
<accessibility>
<relation type="label-for" target="roleSoundPresentationCombo"/>
</accessibility>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="roleSoundPresentationCombo">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="changed" handler="roleSoundPresentationComboChanged" swapped="no"/>
<accessibility>
<relation type="labelled-by" target="roleSoundPresentationLabel"/>
</accessibility>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
@@ -3571,9 +3599,8 @@
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<items>
<item translatable="yes">Claude Code (Enhanced)</item>
<item translatable="yes">Claude (Anthropic)</item>
<item translatable="yes">ChatGPT (OpenAI)</item>
<item translatable="yes">Claude Code (CLI)</item>
<item translatable="yes">Codex (CLI)</item>
<item translatable="yes">Gemini (Google)</item>
<item translatable="yes">Ollama (Local - Free)</item>
</items>
+3
View File
@@ -219,6 +219,7 @@ from . import settings
from . import settings_manager
from . import speech
from . import sound
from . import sound_theme_manager
from . import mouse_review
from .ax_object import AXObject
from .ax_utilities import AXUtilities
@@ -871,6 +872,7 @@ def shutdown(script=None, inputEvent=None):
cthulhu_state.activeScript.presentationInterrupt()
cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
sound_theme_manager.getManager().playStopSound(wait=True)
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
# Deactivate the event manager first so that it clears its queue and will not
@@ -993,6 +995,7 @@ def main():
debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initialized.", True)
script = cthulhu_state.activeScript
sound_theme_manager.getManager().playStartSound(wait=True)
cthulhuApp.getSignalManager().emitSignal('start-application-completed')
if script:
window = script.utilities.activeWindow()
+1 -1
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2025.12.28"
version = "2025.12.30"
codeName = "master"
+66 -23
View File
@@ -57,6 +57,7 @@ from . import cthulhu_state
from . import settings
from . import settings_manager
from . import input_event
from . import input_event_manager
from . import keybindings
from . import pronunciation_dict
from . import braille
@@ -64,6 +65,7 @@ from . import speech
from . import speechserver
from . import text_attribute_names
from . import sound_theme_manager
from . import script_manager
from .ax_object import AXObject
_settingsManager = settings_manager.getManager()
@@ -1874,10 +1876,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Set provider combo
provider = prefs.get("aiProvider", settings.aiProvider)
providerIndex = 0 # Default to Claude Code
if provider == settings.AI_PROVIDER_GEMINI:
if provider == settings.AI_PROVIDER_CODEX:
providerIndex = 1
elif provider == settings.AI_PROVIDER_OLLAMA:
elif provider == settings.AI_PROVIDER_GEMINI:
providerIndex = 2
elif provider == settings.AI_PROVIDER_OLLAMA:
providerIndex = 3
self.aiProviderCombo.set_active(providerIndex)
# Set API key file
@@ -1947,6 +1951,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Update labels based on provider
if provider == settings.AI_PROVIDER_CLAUDE_CODE:
self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses Claude Code CLI")
elif provider == settings.AI_PROVIDER_CODEX:
self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses Codex CLI")
elif provider == settings.AI_PROVIDER_OLLAMA:
self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses local Ollama")
else:
@@ -1998,13 +2004,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
prefs = self.prefsDict
# Get widget references
self.enableModeChangeSoundCheckButton = self.get_widget(
"enableModeChangeSoundCheckButton")
self.soundThemeCombo = self.get_widget("soundThemeCombo")
# Set enable mode change sound checkbox
enabled = prefs.get("enableModeChangeSound", settings.enableModeChangeSound)
self.enableModeChangeSoundCheckButton.set_active(enabled)
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
# Populate sound theme combo box
themeManager = sound_theme_manager.getManager()
@@ -2029,18 +2030,24 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
else:
self.soundThemeCombo.set_active(0)
# Update sensitivity based on checkbox
self._updateSoundThemeWidgetSensitivity()
def _updateSoundThemeWidgetSensitivity(self):
"""Update sound theme combo sensitivity based on enable checkbox."""
enabled = self.enableModeChangeSoundCheckButton.get_active()
self.soundThemeCombo.set_sensitive(enabled)
def enableModeChangeSoundCheckButtonToggled(self, widget):
"""Signal handler for the enable mode change sound checkbox."""
self.prefsDict["enableModeChangeSound"] = widget.get_active()
self._updateSoundThemeWidgetSensitivity()
self._roleSoundPresentationChoices = [
(settings.ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH, "Sound and speech"),
(settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY, "Speech only"),
(settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY, "Sound only"),
]
self.roleSoundPresentationCombo.remove_all()
for _, label in self._roleSoundPresentationChoices:
self.roleSoundPresentationCombo.append_text(label)
currentPresentation = prefs.get(
"roleSoundPresentation",
settings.roleSoundPresentation
)
presentationIndex = 0
for index, (value, _) in enumerate(self._roleSoundPresentationChoices):
if value == currentPresentation:
presentationIndex = index
break
self.roleSoundPresentationCombo.set_active(presentationIndex)
def soundThemeComboChanged(self, widget):
"""Signal handler for the sound theme combo box."""
@@ -2048,6 +2055,14 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
if activeText:
self.prefsDict["soundTheme"] = activeText
def roleSoundPresentationComboChanged(self, widget):
"""Signal handler for the role sound presentation combo box."""
activeIndex = widget.get_active()
if activeIndex < 0:
return
value = self._roleSoundPresentationChoices[activeIndex][0]
self.prefsDict["roleSoundPresentation"] = value
def _updateCthulhuModifier(self):
combobox = self.get_widget("cthulhuModifierComboBox")
keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"])
@@ -3065,6 +3080,14 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self._presentMessage(messages.KB_ENTER_NEW_KEY)
cthulhu_state.capturingKeys = True
try:
script_manager.get_manager().get_active_script().removeKeyGrabs()
except Exception:
pass
try:
input_event_manager.get_manager().unmap_all_modifiers()
except Exception:
pass
editable.connect('key-press-event', self.kbKeyPressed)
return
@@ -3073,6 +3096,10 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
cthulhu_state.capturingKeys = False
self._capturedKey = []
try:
script_manager.get_manager().get_active_script().refreshKeyGrabs()
except Exception:
pass
return
def _processKeyCaptured(self, keyPressedEvent):
@@ -3090,9 +3117,17 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
entries = entries_for_keycode[-1]
eventString = Gdk.keyval_name(entries[0])
eventState = keyPressedEvent.state
eventKeyvalName = Gdk.keyval_name(keyPressedEvent.keyval)
cthulhuMods = settings.cthulhuModifierKeys
if eventString in cthulhuMods:
if eventKeyvalName in cthulhuMods:
eventString = eventKeyvalName
self._capturedKey = ['', keybindings.CTHULHU_MODIFIER_MASK, 0]
return False
if eventKeyvalName == "KP_0" \
and "KP_Insert" in cthulhuMods \
and eventState & Gdk.ModifierType.SHIFT_MASK:
eventString = "KP_Insert"
self._capturedKey = ['', keybindings.CTHULHU_MODIFIER_MASK, 0]
return False
@@ -3180,6 +3215,10 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
cthulhu_state.capturingKeys = False
self._capturedKey = []
try:
script_manager.get_manager().get_active_script().refreshKeyGrabs()
except Exception:
pass
myiter = treeModel.get_iter_from_string(path)
try:
originalBinding = treeModel.get_value(myiter, text)
@@ -3793,7 +3832,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
def aiProviderChanged(self, widget):
"""AI Provider combo box changed handler"""
providers = [settings.AI_PROVIDER_CLAUDE_CODE, settings.AI_PROVIDER_GEMINI, settings.AI_PROVIDER_OLLAMA]
providers = [
settings.AI_PROVIDER_CLAUDE_CODE,
settings.AI_PROVIDER_CODEX,
settings.AI_PROVIDER_GEMINI,
settings.AI_PROVIDER_OLLAMA
]
activeIndex = widget.get_active()
if 0 <= activeIndex < len(providers):
provider = providers[activeIndex]
@@ -3970,4 +4014,3 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
"""OCR copy to clipboard checkbox toggled handler"""
self.prefsDict["ocrCopyToClipboard"] = widget.get_active()
+5 -5
View File
@@ -29,7 +29,7 @@ __license__ = "LGPL"
import enum
import inspect
from typing import Callable
from typing import Callable, Optional
try:
from dasbus.connection import SessionMessageBus
@@ -156,7 +156,7 @@ class _HandlerInfo:
description: str,
action: Callable[..., bool],
handler_type: 'HandlerType' = HandlerType.COMMAND,
parameters: list[tuple[str, str]] | None = None
parameters: Optional[list[tuple[str, str]]] = None
):
self.python_function_name: str = python_function_name
self.description: str = description
@@ -548,10 +548,10 @@ class CthulhuRemoteController:
OBJECT_PATH = "/org/stormux/Cthulhu/Service"
def __init__(self) -> None:
self._dbus_service_interface: CthulhuDBusServiceInterface | None = None
self._dbus_service_interface: Optional[CthulhuDBusServiceInterface] = None
self._is_running: bool = False
self._bus: SessionMessageBus | None = None
self._event_loop: EventLoop | None = None
self._bus: Optional[SessionMessageBus] = None
self._event_loop: Optional[EventLoop] = None
self._pending_registrations: dict[str, object] = {}
self._total_commands: int = 0
self._total_getters: int = 0
+25 -25
View File
@@ -40,7 +40,7 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
"Copyright (c) 2016-2023 Igalia, S.L."
__license__ = "LGPL"
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
import gi
gi.require_version("Atspi", "2.0")
@@ -75,13 +75,13 @@ class FocusManager:
"""Manages the focused object, window, etc."""
def __init__(self) -> None:
self._window: Atspi.Accessible | None = cthulhu_state.activeWindow
self._focus: Atspi.Accessible | None = cthulhu_state.locusOfFocus
self._object_of_interest: Atspi.Accessible | None = cthulhu_state.objOfInterest
self._active_mode: str | None = cthulhu_state.activeMode
self._window: Optional[Atspi.Accessible] = cthulhu_state.activeWindow
self._focus: Optional[Atspi.Accessible] = cthulhu_state.locusOfFocus
self._object_of_interest: Optional[Atspi.Accessible] = cthulhu_state.objOfInterest
self._active_mode: Optional[str] = cthulhu_state.activeMode
self._last_cell_coordinates: tuple[int, int] = (-1, -1)
self._last_cursor_position: tuple[Atspi.Accessible | None, int] = (None, -1)
self._penultimate_cursor_position: tuple[Atspi.Accessible | None, int] = (None, -1)
self._last_cursor_position: tuple[Optional[Atspi.Accessible], int] = (None, -1)
self._penultimate_cursor_position: tuple[Optional[Atspi.Accessible], int] = (None, -1)
msg = "FOCUS MANAGER: Registering D-Bus commands."
debug.print_message(debug.LEVEL_INFO, msg, True)
@@ -104,7 +104,7 @@ class FocusManager:
cthulhu_state.objOfInterest = None
cthulhu_state.activeMode = None
def find_focused_object(self) -> Atspi.Accessible | None:
def find_focused_object(self) -> Optional[Atspi.Accessible]:
"""Returns the focused object in the active window."""
result = _get_ax_utilities().get_focused_object(self._window)
@@ -147,9 +147,9 @@ class FocusManager:
def emit_region_changed(
self, obj: Atspi.Accessible,
start_offset: int | None = None,
end_offset: int | None = None,
mode: str | None = None
start_offset: Optional[int] = None,
end_offset: Optional[int] = None,
mode: Optional[str] = None
) -> None:
"""Notifies interested clients that the current region of interest has changed."""
@@ -192,7 +192,7 @@ class FocusManager:
def get_active_mode_and_object_of_interest(
self
) -> tuple[str | None, Atspi.Accessible | None]:
) -> tuple[Optional[str], Optional[Atspi.Accessible]]:
"""Returns the current mode and associated object of interest"""
tokens = ["FOCUS MANAGER: Active mode:", self._active_mode,
@@ -200,7 +200,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return self._active_mode, self._object_of_interest
def get_penultimate_cursor_position(self) -> tuple[Atspi.Accessible | None, int]:
def get_penultimate_cursor_position(self) -> tuple[Optional[Atspi.Accessible], int]:
"""Returns the penultimate cursor position as a tuple of (object, offset)."""
obj, offset = self._penultimate_cursor_position
@@ -208,7 +208,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return obj, offset
def get_last_cursor_position(self) -> tuple[Atspi.Accessible | None, int]:
def get_last_cursor_position(self) -> tuple[Optional[Atspi.Accessible], int]:
"""Returns the last cursor position as a tuple of (object, offset)."""
obj, offset = self._last_cursor_position
@@ -216,7 +216,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return obj, offset
def set_last_cursor_position(self, obj: Atspi.Accessible | None, offset: int) -> None:
def set_last_cursor_position(self, obj: Optional[Atspi.Accessible], offset: int) -> None:
"""Sets the last cursor position as a tuple of (object, offset)."""
tokens = ["FOCUS MANAGER: Setting last cursor position to", obj, offset]
@@ -239,7 +239,7 @@ class FocusManager:
debug.print_message(debug.LEVEL_INFO, msg, True)
self._last_cell_coordinates = row, column
def get_locus_of_focus(self) -> Atspi.Accessible | None:
def get_locus_of_focus(self) -> Optional[Atspi.Accessible]:
"""Returns the current locus of focus (i.e. the object with visual focus)."""
tokens = ["FOCUS MANAGER: Locus of focus is", self._focus]
@@ -248,8 +248,8 @@ class FocusManager:
def set_locus_of_focus(
self,
event: Atspi.Event | None,
obj: Atspi.Accessible | None,
event: Optional[Atspi.Event],
obj: Optional[Atspi.Accessible],
notify_script: bool = True,
force: bool = False
) -> None:
@@ -340,7 +340,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return is_active
def get_active_window(self) -> Atspi.Accessible | None:
def get_active_window(self) -> Optional[Atspi.Accessible]:
"""Returns the currently-active window (i.e. without searching or verifying)."""
tokens = ["FOCUS MANAGER: Active window is", self._window]
@@ -349,8 +349,8 @@ class FocusManager:
def set_active_window(
self,
frame: Atspi.Accessible | None,
app: Atspi.Accessible | None = None,
frame: Optional[Atspi.Accessible],
app: Optional[Atspi.Accessible] = None,
set_window_as_focus: bool = False,
notify_script: bool = False
) -> None:
@@ -390,7 +390,7 @@ class FocusManager:
def toggle_presentation_mode(
self,
script: default.Script,
event: InputEvent | None = None,
event: Optional[InputEvent] = None,
notify_user: bool = True
) -> bool:
"""Switches between browse mode and focus mode (web content only)."""
@@ -401,7 +401,7 @@ class FocusManager:
def toggle_layout_mode(
self,
script: default.Script,
event: InputEvent | None = None,
event: Optional[InputEvent] = None,
notify_user: bool = True
) -> bool:
"""Switches between object mode and layout mode for line presentation (web content only)."""
@@ -412,7 +412,7 @@ class FocusManager:
def enable_sticky_browse_mode(
self,
script: default.Script,
event: InputEvent | None = None,
event: Optional[InputEvent] = None,
notify_user: bool = True
) -> bool:
"""Enables sticky browse mode (web content only)."""
@@ -423,7 +423,7 @@ class FocusManager:
def enable_sticky_focus_mode(
self,
script: default.Script,
event: InputEvent | None = None,
event: Optional[InputEvent] = None,
notify_user: bool = True
) -> bool:
"""Enables sticky focus mode (web content only)."""
+6 -6
View File
@@ -915,11 +915,11 @@ formatting = {
},
Atspi.Role.CHECK_BOX: {
'focused': 'checkedState',
'unfocused': 'roleName + checkedState + required + invalid + availability',
'unfocused': 'checkedState + required + invalid + availability',
},
Atspi.Role.CHECK_MENU_ITEM: {
'focused': 'checkedState',
'unfocused': 'roleName + checkedState + availability + positionInSet',
'unfocused': 'checkedState + availability + positionInSet',
},
Atspi.Role.COMBO_BOX: {
'focused': 'expandableState',
@@ -970,11 +970,11 @@ formatting = {
},
Atspi.Role.RADIO_BUTTON: {
'focused': 'radioState',
'unfocused': 'roleName + radioState + availability + positionInSet',
'unfocused': 'radioState + availability + positionInSet',
},
Atspi.Role.RADIO_MENU_ITEM: {
'focused': 'radioState',
'unfocused': 'roleName + checkedState + availability + positionInSet',
'unfocused': 'checkedState + availability + positionInSet',
},
Atspi.Role.SCROLL_BAR: {
'focused': 'percentage',
@@ -994,7 +994,7 @@ formatting = {
},
'ROLE_SWITCH': {
'focused': 'switchState',
'unfocused': 'roleName + switchState + availability',
'unfocused': 'switchState + availability',
},
Atspi.Role.TABLE_CELL: {
'focused': 'expandableState',
@@ -1008,7 +1008,7 @@ formatting = {
},
Atspi.Role.TOGGLE_BUTTON: {
'focused': 'expandableState or toggleState',
'unfocused': 'roleName + (expandableState or toggleState) + availability',
'unfocused': '(expandableState or toggleState) + availability',
},
Atspi.Role.TREE_ITEM: {
'focused': 'expandableState',
-5
View File
@@ -929,11 +929,6 @@ USE_CARET_NAVIGATION = _("Control caret navigation")
# of a checkbox in which users can indicate their default preference.
USE_STRUCTURAL_NAVIGATION = _("Enable _structural navigation")
# Translators: This is the label for a checkbox in the preferences dialog.
# When enabled, Cthulhu will play sounds when switching between browse mode
# and focus mode in web content.
ENABLE_MODE_CHANGE_SOUND = _("Play sounds when switching between _browse and focus modes")
# Translators: This is the label for a combo box in the preferences dialog
# where users can select a sound theme. A sound theme is a collection of
# audio files that Cthulhu plays for various events.
+9 -4
View File
@@ -39,7 +39,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL"
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
import gi
gi.require_version("Atspi", "2.0")
@@ -50,6 +50,7 @@ from . import focus_manager
from . import input_event
from . import script_manager
from . import settings
from . import cthulhu_state
from .ax_object import AXObject
from .ax_utilities import AXUtilities
@@ -60,9 +61,9 @@ class InputEventManager:
"""Provides utilities for managing input events."""
def __init__(self) -> None:
self._last_input_event: input_event.InputEvent | None = None
self._last_non_modifier_key_event: input_event.KeyboardEvent | None = None
self._device: Atspi.Device | None = None
self._last_input_event: Optional[input_event.InputEvent] = None
self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None
self._device: Optional[Atspi.Device] = None
self._mapped_keycodes: list[int] = []
self._mapped_keysyms: list[int] = []
self._grabbed_bindings: dict[int, keybindings.KeyBinding] = {}
@@ -267,6 +268,10 @@ class InputEventManager:
msg = "INPUT EVENT MANAGER: Keyboard event processing is paused."
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
if cthulhu_state.capturingKeys:
msg = "INPUT EVENT MANAGER: Capturing keys; ignoring keyboard event."
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
event = input_event.KeyboardEvent(pressed, keycode, keysym, modifiers, text)
if event in [self._last_input_event, self._last_non_modifier_key_event]:
+49 -5
View File
@@ -38,6 +38,8 @@ import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Gdk
from . import cmdnames
from . import debug
@@ -275,16 +277,20 @@ class NotificationPresenter:
class NotificationListGUI:
"""The dialog containing the notifications list."""
RESPONSE_COPY = 1
def __init__(self, script, title, column_headers, rows):
self._script = script
self._model = None
self._tree = None
self._gui = self._create_dialog(title, column_headers, rows)
def _create_dialog(self, title, column_headers, rows):
dialog = Gtk.Dialog(title,
None,
Gtk.DialogFlags.MODAL,
(Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY,
(Gtk.STOCK_COPY, self.RESPONSE_COPY,
Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY,
Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE))
dialog.set_default_size(600, 400)
@@ -315,6 +321,11 @@ class NotificationListGUI:
self._model.set_value(row_iter, i, cell)
tree.set_model(self._model)
selection = tree.get_selection()
selection.set_mode(Gtk.SelectionMode.SINGLE)
if self._model.iter_n_children(None) > 0:
selection.select_path(0)
self._tree = tree
dialog.connect("response", self.on_response)
return dialog
@@ -325,6 +336,10 @@ class NotificationListGUI:
self._gui.destroy()
return
if response == self.RESPONSE_COPY:
self._copy_selected_notification()
return
if response == Gtk.ResponseType.APPLY and self._model is not None:
self._model.clear()
getPresenter().clear_list()
@@ -336,10 +351,39 @@ class NotificationListGUI:
"""Shows the notifications list dialog."""
self._gui.show_all()
time_stamp = cthulhu_state.lastInputEvent.timestamp
if time_stamp == 0:
time_stamp = Gtk.get_current_event_time()
self._gui.present_with_time(time_stamp)
time_stamp = Gtk.get_current_event_time()
if not time_stamp or time_stamp > 0xFFFFFFFF:
time_stamp = Gdk.CURRENT_TIME
self._gui.present_with_time(int(time_stamp))
def _copy_selected_notification(self):
if self._model is None or self._tree is None:
return
selection = self._tree.get_selection()
model, paths = selection.get_selected_rows()
if not paths and self._model.iter_n_children(None) > 0:
selection.select_path(0)
model, paths = selection.get_selected_rows()
if not paths:
return
iter_for_path = model.get_iter(paths[0])
if iter_for_path is None:
return
message = model.get_value(iter_for_path, 0)
timestamp = model.get_value(iter_for_path, 1)
if timestamp:
text = f"{message}\t{timestamp}"
else:
text = f"{message}"
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
clipboard.store()
self._script.presentMessage(messages.CLIPBOARD_COPIED_FULL)
_presenter = None
def getPresenter():
+3 -3
View File
@@ -59,9 +59,9 @@ class Plugin:
self.plugin_info = plugin_info
if plugin_info:
self.module_name = getattr(plugin_info, 'module_name', '')
self.name = getattr(plugin_info, 'name', '')
self.version = getattr(plugin_info, 'version', '')
self.description = getattr(plugin_info, 'description', '')
self.name = plugin_info.get_name()
self.version = plugin_info.get_version()
self.description = plugin_info.get_description()
@cthulhu_hookimpl
def activate(self, plugin=None):
+40 -8
View File
@@ -12,6 +12,7 @@ import os
import inspect
import importlib.util
import logging
import configparser
from enum import IntEnum
# Import pluggy if available
@@ -29,6 +30,12 @@ logger = logging.getLogger(__name__)
if PLUGIN_DEBUG:
logger.setLevel(logging.DEBUG)
_manager = None
def getManager():
"""Return the shared PluginSystemManager instance."""
return _manager
class PluginType(IntEnum):
"""Types of plugins we support."""
SYSTEM = 1
@@ -78,8 +85,10 @@ class PluginSystemManager:
"""Cthulhu Plugin Manager using pluggy."""
def __init__(self, app):
global _manager
self.app = app
logger.info("Initializing PluginSystemManager")
_manager = self
# Initialize plugin manager
if PLUGGY_AVAILABLE:
@@ -378,15 +387,38 @@ class PluginSystemManager:
if os.path.isfile(metadata_file):
try:
with open(metadata_file, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
with open(metadata_file, 'r', encoding='utf-8') as f:
contents = f.read()
if '=' in line:
key, value = line.split('=', 1)
metadata[key.strip()] = value.strip()
has_section_header = False
for line in contents.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith('#') or stripped.startswith(';'):
continue
if stripped.startswith('[') and stripped.endswith(']'):
has_section_header = True
break
if has_section_header:
parser = configparser.ConfigParser()
try:
parser.read_string(contents)
if parser.sections():
for section in parser.sections():
for key, value in parser[section].items():
metadata[key.strip().lower()] = value.strip()
return metadata
except configparser.Error as e:
logger.warning(f"Plugin metadata INI parse failed for {metadata_file}: {e}")
for line in contents.splitlines():
line = line.strip()
if not line or line.startswith('#') or line.startswith(';') or line.startswith('['):
continue
if '=' in line:
key, value = line.split('=', 1)
metadata[key.strip().lower()] = value.strip()
except Exception as e:
logger.error(f"Error loading plugin metadata: {e}")
@@ -334,6 +334,172 @@ Keep descriptions informative and well-structured."""
return base_prompt
class CodexProvider(AIProvider):
"""Codex CLI provider - uses installed Codex CLI."""
def __init__(self, codex_path=None, **kwargs):
super().__init__(**kwargs)
self.codex_path = codex_path or self._resolve_codex_path()
def describe_screen(self, screenshot_data, accessibility_data):
"""Generate a description using Codex CLI."""
try:
prompt = self._build_prompt("describe", None, accessibility_data)
return self._call_codex(prompt, screenshot_data)
except Exception as e:
logger.error(f"Codex describe error: {e}")
return f"Error getting screen description: {e}"
def answer_question(self, question, screenshot_data, accessibility_data):
"""Answer a question using Codex CLI."""
try:
prompt = self._build_prompt("question", question, accessibility_data)
return self._call_codex(prompt, screenshot_data)
except Exception as e:
logger.error(f"Codex question error: {e}")
return f"Error answering question: {e}"
def suggest_actions(self, request, screenshot_data, accessibility_data):
"""Suggest actions using Codex CLI."""
try:
prompt = self._build_prompt("action", request, accessibility_data)
return self._call_codex(prompt, screenshot_data)
except Exception as e:
logger.error(f"Codex action error: {e}")
return f"Error suggesting actions: {e}"
def analyze_images(self, user_question, screenshot_data, accessibility_data):
"""Analyze images visible on screen using Codex CLI."""
try:
prompt = self._build_prompt("image", user_question, accessibility_data)
return self._call_codex(prompt, screenshot_data)
except Exception as e:
logger.error(f"Codex image analysis error: {e}")
return f"Error analyzing images: {e}"
def _build_prompt(self, task_type, user_input, accessibility_data):
"""Build the complete prompt for Codex CLI."""
system_prompt = self._prepare_system_prompt(task_type)
if task_type == "image":
if user_input == "ANALYZE_SINGLE_IMAGE_FILE":
prompt = (
f"{system_prompt}\n\nAnalyze and describe the single image file provided. "
"Focus on visual content only - describe what you see in the image: objects, "
"people, scenery, colors, text, composition, and any other visual details."
)
else:
prompt = f"{system_prompt}\n\nCurrent screen context (focus on images):\n"
if user_input:
prompt += f"User question about images: {user_input}\n\n"
prompt += "Analyze and describe any images visible on this screen. Focus on visual content, not UI elements."
else:
prompt = (
f"{system_prompt}\n\nCurrent accessibility information:\n"
f"```json\n{json.dumps(accessibility_data, indent=2)}\n```\n\n"
)
if task_type == "describe":
prompt += "Please describe what's on this screen."
elif task_type == "question":
prompt += f"User question: {user_input}"
elif task_type == "action":
prompt += f"User wants to: {user_input}\n\nProvide the action analysis in the required format."
return prompt
def _resolve_codex_path(self):
import shutil
import os
codex_path = shutil.which('codex')
if not codex_path and os.path.isfile('/usr/bin/codex'):
codex_path = '/usr/bin/codex'
return codex_path
def _call_codex(self, prompt, screenshot_data):
"""Call Codex CLI with the prompt and optional image."""
import subprocess
import tempfile
import os
import base64
if not self.codex_path:
return "Codex CLI not found"
output_path = None
image_path = None
try:
# Write screenshot to a temp file if available
if screenshot_data:
image_format = screenshot_data.get('format', 'png')
suffix = f".{image_format}"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
image_data = base64.b64decode(screenshot_data['data'])
temp_file.write(image_data)
image_path = temp_file.name
with tempfile.NamedTemporaryFile(delete=False) as output_file:
output_path = output_file.name
cmd = [
self.codex_path,
'exec',
'--skip-git-repo-check',
'--color',
'never',
'--output-last-message',
output_path,
'-'
]
if image_path:
cmd.extend(['-i', image_path])
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
error_msg = result.stderr.strip() or result.stdout.strip() or "Codex CLI error"
logger.error(error_msg)
return f"Codex CLI error: {error_msg}"
response_text = ""
if output_path and os.path.exists(output_path):
with open(output_path, 'r') as f:
response_text = f.read().strip()
if not response_text:
response_text = result.stdout.strip()
return response_text or "No response from Codex"
except subprocess.TimeoutExpired:
error_msg = "Codex CLI timed out"
logger.error(error_msg)
return error_msg
except Exception as e:
error_msg = f"Error calling Codex CLI: {e}"
logger.error(error_msg)
return error_msg
finally:
if image_path and os.path.exists(image_path):
try:
os.unlink(image_path)
except Exception:
pass
if output_path and os.path.exists(output_path):
try:
os.unlink(output_path)
except Exception:
pass
class OllamaProvider(AIProvider):
"""Ollama local AI provider."""
@@ -563,6 +729,8 @@ def create_provider(provider_type, **kwargs):
"""Factory function to create AI providers."""
if provider_type == "claude_code":
return ClaudeCodeProvider(**kwargs)
elif provider_type == "codex":
return CodexProvider(**kwargs)
elif provider_type == "ollama":
return OllamaProvider(**kwargs)
elif provider_type == "gemini":
+36
View File
@@ -204,6 +204,11 @@ class AIAssistant(Plugin):
result = self._check_claude_code_availability()
logger.info(f"Claude Code availability check result: {result}")
return result
elif self._provider_type == settings.AI_PROVIDER_CODEX:
logger.info("Checking Codex CLI availability")
result = self._check_codex_availability()
logger.info(f"Codex CLI availability check result: {result}")
return result
elif self._provider_type == settings.AI_PROVIDER_GEMINI:
logger.info("Checking Gemini API key")
if not self._api_key:
@@ -259,11 +264,42 @@ class AIAssistant(Plugin):
logger.warning(f"Claude Code CLI not available: {e}")
return False
def _check_codex_availability(self):
"""Check if Codex CLI is available."""
try:
import subprocess
import shutil
codex_path = shutil.which('codex')
if not codex_path and os.path.isfile('/usr/bin/codex'):
codex_path = '/usr/bin/codex'
if not codex_path:
logger.warning("Codex CLI not found in PATH")
return False
result = subprocess.run([codex_path, '--version'],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
logger.info("Codex CLI is available")
return True
else:
logger.warning(f"Codex CLI not responding: {result.stderr}")
return False
except subprocess.TimeoutExpired:
logger.warning("Codex CLI timeout")
return False
except Exception as e:
logger.warning(f"Codex CLI not available: {e}")
return False
def _initialize_ai_provider(self):
"""Initialize the AI provider based on settings."""
try:
if self._provider_type == settings.AI_PROVIDER_CLAUDE_CODE:
self._ai_provider = create_provider("claude_code")
elif self._provider_type == settings.AI_PROVIDER_CODEX:
self._ai_provider = create_provider("codex")
elif self._provider_type == settings.AI_PROVIDER_OLLAMA:
self._ai_provider = create_provider("ollama", model=self._ollama_model, base_url=self._ollama_endpoint)
elif self._provider_type == settings.AI_PROVIDER_GEMINI:
+2 -2
View File
@@ -5,10 +5,10 @@ ocrdesktop_python_sources = files([
python3.install_sources(
ocrdesktop_python_sources,
subdir: 'cthulhu/plugins/OCRDesktop'
subdir: 'cthulhu/plugins/OCR'
)
install_data(
'plugin.info',
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'OCRDesktop'
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'OCR'
)
+7 -7
View File
@@ -223,7 +223,7 @@ class PluginManager(Plugin):
checkbox.connect("toggled", self._on_plugin_toggled, plugin_name)
# Create plugin info label
info_text = f"<b>{plugin_name}</b>"
info_text = f"<b>{plugin_info.get('name', plugin_name)}</b>"
if plugin_info.get('description'):
info_text += f"\n{plugin_info['description']}"
if plugin_info.get('version'):
@@ -257,17 +257,17 @@ class PluginManager(Plugin):
from cthulhu import plugin_system_manager
# Use existing plugin manager to get plugins
if hasattr(plugin_system_manager, '_manager') and plugin_system_manager._manager:
manager = plugin_system_manager._manager
manager = plugin_system_manager.getManager()
if manager:
manager.rescanPlugins()
for plugin_info in manager.plugins:
plugin_name = plugin_info.get_module_name()
plugins[plugin_name] = {
'name': plugin_name,
'description': getattr(plugin_info, 'description', ''),
'version': getattr(plugin_info, 'version', ''),
'path': getattr(plugin_info, 'module_dir', '')
'name': plugin_info.get_name(),
'description': plugin_info.get_description(),
'version': plugin_info.get_version(),
'path': plugin_info.get_module_dir()
}
else:
# Fallback: manually scan plugin directories
+77 -14
View File
@@ -131,7 +131,7 @@ class Script(default.Script):
focus = cthulhu_state.locusOfFocus
inApp = AXObject.get_application(focus) == self.app if focus else False
inDoc = self.utilities.inDocumentContent(focus)
inDoc = self._focusInDocumentContent()
suspend = not (inDoc and inApp)
reason = f"script activation, not in document content in this app: {suspend}"
@@ -179,6 +179,13 @@ class Script(default.Script):
tokens = ["WEB: Navigation suspended:", suspend, reason]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
def _focusInDocumentContent(self):
focus = cthulhu_state.locusOfFocus
if not focus or AXObject.is_dead(focus):
return False
return self.utilities.getDocumentForObject(focus) is not None
def getAppKeyBindings(self):
"""Returns the application-specific keybindings for this script."""
@@ -1211,13 +1218,18 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
if self._navSuspended:
if debugOutput:
msg = "WEB: Not using caret navigation: navigation suspended."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
inDoc = self._focusInDocumentContent()
if not self.utilities.inDocumentContent():
if self._navSuspended:
if inDoc:
self._setNavigationSuspended(False, "focus confirmed in document content")
else:
if debugOutput:
msg = "WEB: Not using caret navigation: navigation suspended."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
if not inDoc:
if debugOutput:
tokens = ["WEB: Not using caret navigation: locusOfFocus",
cthulhu_state.locusOfFocus, "is not in document content."]
@@ -1251,13 +1263,18 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
if self._navSuspended:
if debugOutput:
msg = "WEB: Not using structural navigation: navigation suspended."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
inDoc = self._focusInDocumentContent()
if not self.utilities.inDocumentContent():
if self._navSuspended:
if inDoc:
self._setNavigationSuspended(False, "focus confirmed in document content")
else:
if debugOutput:
msg = "WEB: Not using structural navigation: navigation suspended."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
if not inDoc:
if debugOutput:
tokens = ["WEB: Not using structural navigation: locusOfFocus",
cthulhu_state.locusOfFocus, "is not in document content."]
@@ -1377,7 +1394,8 @@ class Script(default.Script):
self.utilities.setCaretContext(AXObject.get_parent(parent), -1)
if not self._loadingDocumentContent:
self.presentMessage(messages.MODE_BROWSE)
sound_theme_manager.getManager().playBrowseModeSound()
if not self._shouldSuppressBrowseModeSound(obj, inputEvent):
sound_theme_manager.getManager().playBrowseModeSound()
else:
if not self.utilities.grabFocusWhenSettingCaret(obj) \
and (self._lastCommandWasCaretNav \
@@ -1392,6 +1410,51 @@ class Script(default.Script):
self._browseModeIsSticky = False
self.refreshKeyGrabs()
def _shouldSuppressBrowseModeSound(self, obj, inputEvent):
if inputEvent is not None:
return False
if _settingsManager.getSetting('roleSoundPresentation') \
== settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY:
return False
if not _settingsManager.getSetting('enableSound'):
return False
icon = self._getControlSoundIcon(obj)
return icon is not None and icon.isValid()
def _getControlSoundIcon(self, obj):
if not obj:
return None
role = AXObject.get_role(obj)
if self.utilities.isSwitch(obj):
role = Atspi.Role.SWITCH
manager = sound_theme_manager.getManager()
icon = manager.getRoleSoundIcon(role)
if icon:
return icon
stateKey = None
if AXUtilities.is_checkable(obj) or AXUtilities.is_check_menu_item(obj):
if AXUtilities.is_indeterminate(obj):
stateKey = "mixed"
elif AXUtilities.is_checked(obj):
stateKey = "checked"
else:
stateKey = "unchecked"
elif AXUtilities.is_radio_button(obj):
stateKey = "checked" if AXUtilities.is_checked(obj) else "unchecked"
elif AXUtilities.is_toggle_button(obj) or AXUtilities.is_switch(obj):
stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \
else "unchecked"
if stateKey:
return manager.getRoleStateSoundIcon(role, stateKey)
return None
def _tryClickableActivation(self, keyboardEvent):
"""Try to activate clickable element - returns True if we should consume the event."""
+12 -1
View File
@@ -324,6 +324,10 @@ class Utilities(script_utilities.Utilities):
and AXDocument.get_uri(obj)
)
focusDoc = self.getTopLevelDocumentForObject(cthulhu_state.locusOfFocus)
if focusDoc and AXObject.is_dead(focusDoc):
focusDoc = None
if len(documents) == 1:
document = documents[0]
if documentHasUri(document):
@@ -335,12 +339,16 @@ class Utilities(script_utilities.Utilities):
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return fallback
if focusDoc and documentHasUri(focusDoc):
tokens = ["WEB: Single showing document lacks URI, using focus-based document:", focusDoc]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return focusDoc
return document
# If multiple documents are showing (e.g., multi-tab browser), use the
# locus of focus to determine which document is currently active.
if documents:
focusDoc = self.getTopLevelDocumentForObject(cthulhu_state.locusOfFocus)
if focusDoc in documents and documentHasUri(focusDoc):
tokens = ["WEB: Multiple showing documents, using focus-based document:", focusDoc]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
@@ -4001,6 +4009,9 @@ class Utilities(script_utilities.Utilities):
if not (obj and self.inDocumentContent(obj)):
return super().isSwitch(obj)
if AXObject.get_role(obj) == Atspi.Role.SWITCH:
return True
return 'switch' in self._getXMLRoles(obj)
def isNonNavigableEmbeddedDocument(self, obj):
+27 -2
View File
@@ -43,6 +43,7 @@ from cthulhu import object_properties
from cthulhu import cthulhu_state
from cthulhu import settings
from cthulhu import settings_manager
from cthulhu import sound_theme_manager
from cthulhu import speech_generator
from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText
@@ -550,9 +551,18 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
if roledescription:
result = [roledescription]
result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args))
return result
role = args.get('role', AXObject.get_role(obj))
roleSoundPresentation = _settingsManager.getSetting('roleSoundPresentation')
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \
and _settingsManager.getSetting('enableSound'):
if AXUtilities.is_check_box(obj) \
or AXUtilities.is_check_menu_item(obj) \
or AXUtilities.is_radio_button(obj) \
or AXUtilities.is_radio_menu_item(obj) \
or AXUtilities.is_toggle_button(obj) \
or AXUtilities.is_switch(obj):
return []
enabled, disabled = self._getEnabledAndDisabledContextRoles()
if role in disabled:
return []
@@ -656,7 +666,22 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
and (index == total - 1 or AXObject.get_name(obj) == AXObject.get_name(ancestor)):
result.extend(self._generateRoleName(ancestor))
return result
if not result:
return result
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY:
return result
if not _settingsManager.getSetting('enableSound'):
return result
roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role)
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY:
return [roleSoundIcon] if roleSoundIcon else []
if not roleSoundIcon:
return result
return [roleSoundIcon] + result
def _generatePageSummary(self, obj, **args):
if not self._script.utilities.inDocumentContent(obj):
+7 -2
View File
@@ -78,8 +78,8 @@ userCustomizableSettings = [
"playSoundForState",
"playSoundForPositionInSet",
"playSoundForValue",
"roleSoundPresentation",
"soundTheme",
"enableModeChangeSound",
"verbalizePunctuationStyle",
"presentToolTips",
"sayAllStyle",
@@ -210,6 +210,7 @@ CHAT_SPEAK_FOCUSED_CHANNEL = 2
# AI Assistant constants - simplified to providers that don't need complex API key management
AI_PROVIDER_CLAUDE_CODE = "claude_code"
AI_PROVIDER_CODEX = "codex"
AI_PROVIDER_GEMINI = "gemini"
AI_PROVIDER_OLLAMA = "ollama"
@@ -308,14 +309,18 @@ textAttributesBrailleIndicator = BRAILLE_UNDERLINE_NONE
brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE
# Sound
ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH = "sound_and_speech"
ROLE_SOUND_PRESENTATION_SPEECH_ONLY = "speech_only"
ROLE_SOUND_PRESENTATION_SOUND_ONLY = "sound_only"
enableSound = True
soundVolume = 0.5
playSoundForRole = False
playSoundForState = False
playSoundForPositionInSet = False
playSoundForValue = False
roleSoundPresentation = ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH
soundTheme = "default"
enableModeChangeSound = True
# Keyboard and Echo
keyboardLayout = GENERAL_KEYBOARD_LAYOUT_DESKTOP
+49
View File
@@ -93,6 +93,36 @@ class Player:
self._player.set_property('uri', f'file://{icon.path}')
self._player.set_state(Gst.State.PLAYING)
def _playIconAndWait(self, icon, interrupt=True, timeout_seconds=10):
"""Plays a sound icon and waits for completion."""
if interrupt:
self._player.set_state(Gst.State.NULL)
self._player.set_property('uri', f'file://{icon.path}')
self._player.set_state(Gst.State.PLAYING)
bus = self._player.get_bus()
if not bus:
return False
if timeout_seconds is None:
timeout_ns = Gst.CLOCK_TIME_NONE
else:
timeout_ns = int(timeout_seconds * Gst.SECOND)
message = bus.timed_pop_filtered(
timeout_ns,
Gst.MessageType.EOS | Gst.MessageType.ERROR
)
if message and message.type == Gst.MessageType.ERROR:
error, info = message.parse_error()
msg = f'SOUND ERROR: {error}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._player.set_state(Gst.State.NULL)
return message is not None and message.type == Gst.MessageType.EOS
def _playTone(self, tone, interrupt=True):
"""Plays a tone, interrupting the current play first unless specified."""
@@ -152,6 +182,25 @@ class Player:
tokens = ["SOUND ERROR:", item, "is not an Icon or Tone"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
def playAndWait(self, item, interrupt=True, timeout_seconds=10):
"""Plays a sound and blocks until completion or timeout."""
if not self._player:
if _gstreamerAvailable and not self._initialized:
self.init()
if not self._player:
return False
if isinstance(item, Icon):
return self._playIconAndWait(
item,
interrupt=interrupt,
timeout_seconds=timeout_seconds
)
self.play(item, interrupt)
return False
def stop(self, element=None):
"""Stops play."""
+5
View File
@@ -371,6 +371,11 @@ class SoundGenerator(generator.Generator):
return []
role = args.get('role', AXObject.get_role(obj))
if isinstance(role, str):
if role == "ROLE_SWITCH":
role = Atspi.Role.SWITCH
else:
return []
filename = Atspi.role_get_name(role).replace(' ', '_')
result = self._convertFilenameToIcon(filename)
if result:
+137 -4
View File
@@ -38,6 +38,7 @@ __license__ = "LGPL"
import os
from gi.repository import GLib
from gi.repository import Atspi
from . import debug
from . import settings_manager
@@ -47,9 +48,34 @@ from .sound_generator import Icon
_settingsManager = settings_manager.getManager()
# Sound event constants - add new events here for easy extensibility
SOUND_FOCUS_MODE = "editbox"
SOUND_FOCUS_MODE = "focus_mode"
SOUND_BROWSE_MODE = "browse_mode"
SOUND_BUTTON = "button"
SOUND_START = "start"
SOUND_STOP = "stop"
ROLE_SOUND_ALIASES = {
Atspi.Role.PUSH_BUTTON: ["button", "push_button"],
Atspi.Role.TOGGLE_BUTTON: ["toggle_button", "button"],
Atspi.Role.RADIO_BUTTON: ["radio_button", "radio"],
Atspi.Role.CHECK_BOX: ["checkbox", "check_box"],
Atspi.Role.COMBO_BOX: ["combobox", "combo_box"],
}
ROLE_STATE_SOUND_BASES = {
Atspi.Role.CHECK_BOX: ["checkbox", "check_box"],
Atspi.Role.CHECK_MENU_ITEM: ["checkbox", "check_box"],
Atspi.Role.RADIO_BUTTON: ["radiobutton", "radio_button"],
Atspi.Role.RADIO_MENU_ITEM: ["radiobutton", "radio_button"],
Atspi.Role.TOGGLE_BUTTON: ["togglebutton", "toggle_button"],
Atspi.Role.SWITCH: ["switch"],
}
STATE_SOUND_SUFFIXES = {
"checked": ["checked", "on", "pressed", "selected"],
"unchecked": ["unchecked", "off", "not_pressed", "unselected"],
"mixed": ["mixed", "partially_checked", "indeterminate"],
}
# Special theme name for no sounds
THEME_NONE = "none"
@@ -144,17 +170,87 @@ class SoundThemeManager:
return None
def playSound(self, soundName, interrupt=True):
"""Play a sound from the current theme if enabled.
def _getRoleSoundCandidates(self, role):
"""Return candidate sound names for a given role."""
role = self._normalizeRole(role)
if role is None:
return []
candidates = ROLE_SOUND_ALIASES.get(role, [])
if candidates:
return candidates
roleName = Atspi.role_get_name(role)
if roleName:
return [roleName.replace(' ', '_')]
return []
def _normalizeRole(self, role):
if isinstance(role, str):
if role == "ROLE_SWITCH":
return Atspi.Role.SWITCH
return None
return role
def _getRoleStateSoundCandidates(self, role, stateKey):
"""Return candidate sound names for a given role/state pair."""
role = self._normalizeRole(role)
if role is None:
return []
bases = ROLE_STATE_SOUND_BASES.get(role, [])
if not bases:
return []
suffixes = STATE_SOUND_SUFFIXES.get(stateKey, [])
if not suffixes:
return []
candidates = []
for base in bases:
for suffix in suffixes:
candidates.append(f"{base}_{suffix}")
return candidates
def getRoleSoundIcon(self, role, themeName=None):
"""Return an Icon for the role sound from the current theme, if any."""
themeName = themeName or _settingsManager.getSetting('soundTheme') or 'default'
if themeName == THEME_NONE:
return None
for candidate in self._getRoleSoundCandidates(role):
soundPath = self.getSoundPath(themeName, candidate)
if soundPath:
return Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
return None
def getRoleStateSoundIcon(self, role, stateKey, themeName=None):
"""Return an Icon for the role/state sound from the current theme, if any."""
themeName = themeName or _settingsManager.getSetting('soundTheme') or 'default'
if themeName == THEME_NONE:
return None
for candidate in self._getRoleStateSoundCandidates(role, stateKey):
soundPath = self.getSoundPath(themeName, candidate)
if soundPath:
return Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
return None
def _playThemeSound(self, soundName, interrupt=True, wait=False,
requireSoundSetting=False):
"""Play a themed sound with optional gating and blocking.
Args:
soundName: The name of the sound file (without extension)
interrupt: Whether to interrupt currently playing sounds
wait: Whether to block until the sound finishes playing
requireSoundSetting: Honor enableSound setting
Returns:
True if sound was played, False otherwise
"""
if not _settingsManager.getSetting('enableModeChangeSound'):
if requireSoundSetting and not _settingsManager.getSetting('enableSound'):
return False
themeName = _settingsManager.getSetting('soundTheme')
@@ -175,6 +271,8 @@ class SoundThemeManager:
icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
if icon.isValid():
player = sound.getPlayer()
if wait and hasattr(player, "playAndWait"):
return player.playAndWait(icon, interrupt=interrupt)
player.play(icon, interrupt=interrupt)
return True
except Exception as e:
@@ -183,6 +281,23 @@ class SoundThemeManager:
return False
def playSound(self, soundName, interrupt=True, wait=False):
"""Play a sound from the current theme if enabled.
Args:
soundName: The name of the sound file (without extension)
interrupt: Whether to interrupt currently playing sounds
wait: Whether to block until the sound finishes playing
Returns:
True if sound was played, False otherwise
"""
return self._playThemeSound(
soundName,
interrupt=interrupt,
wait=wait
)
def playFocusModeSound(self):
"""Play sound for entering focus mode."""
return self.playSound(SOUND_FOCUS_MODE)
@@ -195,6 +310,24 @@ class SoundThemeManager:
"""Play sound for button focus (future use)."""
return self.playSound(SOUND_BUTTON)
def playStartSound(self, wait=False):
"""Play sound for application startup."""
return self._playThemeSound(
SOUND_START,
interrupt=True,
wait=wait,
requireSoundSetting=True
)
def playStopSound(self, wait=False):
"""Play sound for application shutdown."""
return self._playThemeSound(
SOUND_STOP,
interrupt=True,
wait=wait,
requireSoundSetting=True
)
_manager = None
+11 -1
View File
@@ -39,6 +39,8 @@ from . import debug
from . import logger
from . import settings
from . import speech_generator
from . import sound
from .sound_generator import Icon
from .speechserver import VoiceFamily
from .acss import ACSS
@@ -240,7 +242,7 @@ def speak(content, acss=None, interrupt=True):
return
validTypes = (str, list, speech_generator.Pause,
speech_generator.LineBreak, ACSS)
speech_generator.LineBreak, ACSS, Icon)
error = "SPEECH: bad content sent to speak(): '%s'"
if not isinstance(content, validTypes):
debug.printMessage(debug.LEVEL_INFO, error % content, True)
@@ -277,6 +279,14 @@ def speak(content, acss=None, interrupt=True):
elif isinstance(element, str):
if len(element):
toSpeak.append(element)
elif isinstance(element, Icon):
if toSpeak:
string = " ".join(toSpeak)
_speak(string, activeVoice, interrupt)
toSpeak = []
if element.isValid():
player = sound.getPlayer()
player.play(element, interrupt=False)
elif toSpeak:
newVoice = ACSS(acss)
newItemsToSpeak = []
+69
View File
@@ -49,6 +49,7 @@ from . import messages
from . import object_properties
from . import settings
from . import settings_manager
from . import sound_theme_manager
from . import speech
from . import text_attribute_names
from .ax_object import AXObject
@@ -579,6 +580,20 @@ class SpeechGenerator(generator.Generator):
result = []
role = args.get('role', AXObject.get_role(obj))
roleSoundPresentation = _settingsManager.getSetting('roleSoundPresentation')
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \
and _settingsManager.getSetting('enableSound'):
if AXUtilities.is_check_box(obj) \
or AXUtilities.is_check_menu_item(obj) \
or AXUtilities.is_radio_button(obj) \
or AXUtilities.is_radio_menu_item(obj) \
or AXUtilities.is_toggle_button(obj) \
or AXUtilities.is_switch(obj):
return []
roleSoundIcon = None
if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \
and _settingsManager.getSetting('enableSound'):
roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role)
doNotPresent = [Atspi.Role.UNKNOWN,
Atspi.Role.REDUNDANT_OBJECT,
@@ -620,6 +635,14 @@ class SpeechGenerator(generator.Generator):
if role not in doNotPresent and not result:
result.append(self.getLocalizedRoleName(obj, **args))
result.extend(self.voice(SYSTEM, obj=obj, **args))
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY:
return [roleSoundIcon] if roleSoundIcon else []
if result and roleSoundIcon:
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH:
return [roleSoundIcon] + result
return result
def getRoleName(self, obj, **args):
@@ -693,6 +716,26 @@ class SpeechGenerator(generator.Generator):
# #
#####################################################################
def _applyStateSound(self, result, role, stateKey):
if not result:
return result
roleSoundPresentation = _settingsManager.getSetting('roleSoundPresentation')
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY:
return result
if not _settingsManager.getSetting('enableSound'):
return result
icon = sound_theme_manager.getManager().getRoleStateSoundIcon(role, stateKey)
if not icon:
return result
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY:
return [icon]
return [icon] + result
def _generateCheckedState(self, obj, **args):
"""Returns an array of strings for use by speech and braille that
represent the checked state of the object. This is typically
@@ -705,6 +748,16 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateCheckedState(self, obj, **args)
if result:
result.extend(self.voice(STATE, obj=obj, **args))
role = args.get('role', AXObject.get_role(obj))
if AXUtilities.is_checkable(obj) or AXUtilities.is_check_menu_item(obj):
role = Atspi.Role.CHECK_BOX
if AXUtilities.is_indeterminate(obj):
stateKey = "mixed"
elif AXUtilities.is_checked(obj):
stateKey = "checked"
else:
stateKey = "unchecked"
result = self._applyStateSound(result, role, stateKey)
return result
def _generateExpandableState(self, obj, **args):
@@ -742,6 +795,11 @@ class SpeechGenerator(generator.Generator):
_generateMenuItemCheckedState(self, obj, **args)
if result:
result.extend(self.voice(STATE, obj=obj, **args))
result = self._applyStateSound(
result,
Atspi.Role.CHECK_MENU_ITEM,
"checked"
)
return result
def _generateMultiselectableState(self, obj, **args):
@@ -770,6 +828,9 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateRadioState(self, obj, **args)
if result:
result.extend(self.voice(STATE, obj=obj, **args))
stateKey = "checked" if AXUtilities.is_checked(obj) else "unchecked"
role = args.get('role', AXObject.get_role(obj))
result = self._applyStateSound(result, role, stateKey)
return result
def _generateSwitchState(self, obj, **args):
@@ -780,6 +841,10 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateSwitchState(self, obj, **args)
if result:
result.extend(self.voice(STATE, obj=obj, **args))
stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \
else "unchecked"
role = args.get('role', AXObject.get_role(obj))
result = self._applyStateSound(result, role, stateKey)
return result
def _generateToggleState(self, obj, **args):
@@ -794,6 +859,10 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateToggleState(self, obj, **args)
if result:
result.extend(self.voice(STATE, obj=obj, **args))
stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \
else "unchecked"
role = args.get('role', AXObject.get_role(obj))
result = self._applyStateSound(result, role, stateKey)
return result
#####################################################################
+3 -2
View File
@@ -17,6 +17,7 @@ falls off the end, it may be added again later.
from __future__ import annotations
from typing import Optional
import threading
from collections import deque
@@ -76,7 +77,7 @@ def is_capture_paused() -> bool:
return _pauseCount > 0
def add(text: str | None, source: str = "") -> bool:
def add(text: Optional[str], source: str = "") -> bool:
"""Add text to speech history if it's not already present.
Returns True if the item was added; False otherwise.
@@ -141,7 +142,7 @@ def get_items() -> list[str]:
return list(_historyItems)
def remove(text: str | None) -> bool:
def remove(text: Optional[str]) -> bool:
"""Remove an item from the history (if present)."""
if text is None:
return False
+17 -2
View File
@@ -806,14 +806,26 @@ class StructuralNavigation:
self.clearCache()
self._inModalDialog = inModalDialog
def filterZombies(matchList):
if not matchList:
return []
return [match for match in matchList if not self._script.utilities.isZombie(match)]
document = self._script.utilities.documentFrame()
cache = self._objectCache.get(hash(document), {})
key = f"{structuralNavigationObject.objType}:{arg}"
matches = cache.get(key, [])
if matches:
tokens = ["STRUCTURAL NAVIGATION: Returning", len(matches), "matches from cache"]
matches = filterZombies(matches)
if matches:
tokens = ["STRUCTURAL NAVIGATION: Returning", len(matches), "matches from cache"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return matches.copy()
tokens = ["STRUCTURAL NAVIGATION: Cached matches are zombies; refreshing"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return matches.copy()
cache.pop(key, None)
self._objectCache[hash(document)] = cache
if structuralNavigationObject.getter:
matches = structuralNavigationObject.getter(document, arg)
@@ -834,6 +846,7 @@ class StructuralNavigation:
"objects outside of modal dialog", modalDialog]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
matches = filterZombies(matches)
rv = matches.copy()
cache[key] = matches
self._objectCache[hash(document)] = cache
@@ -899,6 +912,8 @@ class StructuralNavigation:
def _isValidMatch(obj):
if AXObject.is_dead(obj):
return False
if self._script.utilities.isZombie(obj):
return False
if self._script.utilities.isHidden(obj) or self._script.utilities.isEmpty(obj):
return False
if structuralNavigationObject.predicate is None: