Moving closer to tagged release.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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.
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
+242
-241
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
#####################################################################
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user