Try to make Cthulhu python 3.9 compatible. Fixed keybinding and notifications bugs.

This commit is contained in:
Storm Dragon
2025-12-29 05:17:33 -05:00
parent fee5800220
commit 87786e9c72
34 changed files with 1003 additions and 686 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ subdir('PluginName')
- Community: IRC #stormux on irc.stormux.org - Community: IRC #stormux on irc.stormux.org
### Key Dependencies ### 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 - AT-SPI2, ATK for accessibility
- Optional: BrlTTY/BrlAPI (braille), Speech Dispatcher, liblouis, GStreamer - Optional: BrlTTY/BrlAPI (braille), Speech Dispatcher, liblouis, GStreamer
+1 -1
View File
@@ -49,7 +49,7 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit.
### Core Requirements ### Core Requirements
* **Python 3.10+** - Python platform * **Python 3.9+** - Python platform
* **pygobject-3.0** - Python bindings for the GObject library * **pygobject-3.0** - Python bindings for the GObject library
* **gtk+-3.0** - GTK+ toolkit (minimal usage for AT-SPI integration) * **gtk+-3.0** - GTK+ toolkit (minimal usage for AT-SPI integration)
* **AT-SPI2** - Assistive Technology Service Provider Interface * **AT-SPI2** - Assistive Technology Service Provider Interface
+22 -35
View File
@@ -6,85 +6,72 @@
set -e # Exit on error set -e # Exit on error
# Colors for output (only if stdout is a terminal) echo "Cthulhu Meson Local Build Script"
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 "Building and installing Cthulhu to ~/.local" echo "Building and installing Cthulhu to ~/.local"
echo "================================================" echo "================================================"
# Check if we're in the right directory # Check if we're in the right directory
if [[ ! -f "meson.build" ]] || [[ ! -f "src/cthulhu/cthulhu.py" ]]; then 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 exit 1
fi fi
# Check for required dependencies # Check for required dependencies
echo -e "${YELLOW}Checking dependencies...${NC}" echo "Checking dependencies..."
for cmd in meson ninja python3; do for cmd in meson ninja python3; do
if ! command -v "$cmd" &> /dev/null; then 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" echo "Please install: meson ninja python"
exit 1 exit 1
fi fi
done done
# Check for optional dependencies # Check for optional dependencies
missing_optional=() missingOptional=()
for module in gi speech; do if ! python3 -c "import gi" 2>/dev/null; then
if ! python3 -c "import $module" 2>/dev/null; then missingOptional+=("python-gi")
missing_optional+=("python-$module") fi
fi
done
if [[ ${#missing_optional[@]} -gt 0 ]]; then if [[ ${#missingOptional[@]} -gt 0 ]]; then
echo -e "${YELLOW}Warning: Optional dependencies missing: ${missing_optional[*]}${NC}" echo "Warning: Optional dependencies missing: ${missingOptional[*]}"
echo "Cthulhu may not function properly without these." echo "Cthulhu may not function properly without these."
fi fi
# Clean any cached bytecode
echo "Removing __pycache__ directories..."
find . -type d -name "__pycache__" -prune -exec rm -rf {} +
# Clean any existing build directory # Clean any existing build directory
if [[ -d "_build" ]]; then if [[ -d "_build" ]]; then
echo -e "${YELLOW}Removing existing build directory...${NC}" echo "Removing existing build directory..."
rm -rf _build rm -rf _build
fi fi
# Setup Meson build # Setup Meson build
echo -e "${YELLOW}Setting up Meson build...${NC}" echo "Setting up Meson build..."
meson setup _build --prefix="$HOME/.local" --buildtype=debugoptimized meson setup _build --prefix="$HOME/.local" --buildtype=debugoptimized
# Build # Build
echo -e "${YELLOW}Building Cthulhu...${NC}" echo "Building Cthulhu..."
meson compile -C _build meson compile -C _build
# Install # Install
echo -e "${YELLOW}Installing Cthulhu to ~/.local...${NC}" echo "Installing Cthulhu to ~/.local..."
meson install -C _build meson install -C _build
# Update desktop database and icon cache # Update desktop database and icon cache
if command -v update-desktop-database &> /dev/null; then 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 update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
fi fi
if command -v gtk-update-icon-cache &> /dev/null; then 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 gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
fi fi
echo echo
echo -e "${GREEN}Build completed successfully!${NC}" echo "Build completed successfully!"
echo echo
echo "To run Cthulhu:" echo "To run Cthulhu:"
echo " ~/.local/bin/cthulhu" echo " ~/.local/bin/cthulhu"
@@ -93,4 +80,4 @@ echo "To run Cthulhu setup:"
echo " ~/.local/bin/cthulhu -s" echo " ~/.local/bin/cthulhu -s"
echo echo
echo "Build artifacts are in: _build/" echo "Build artifacts are in: _build/"
echo -e "${BLUE}To clean build artifacts, run: ${YELLOW}rm -rf _build${NC}" echo "To clean build artifacts, run: rm -rf _build"
+1 -1
View File
@@ -6,7 +6,7 @@ project('cthulhu',
python = import('python') python = import('python')
i18n = import('i18n') i18n = import('i18n')
python_minimum_version = '3.10' python_minimum_version = '3.9'
python3 = python.find_installation('python3', required: true) python3 = python.find_installation('python3', required: true)
if not python3.language_version().version_compare(f'>= @python_minimum_version@') if not python3.language_version().version_compare(f'>= @python_minimum_version@')
error(f'Python @python_minimum_version@ or newer is required.') error(f'Python @python_minimum_version@ or newer is required.')
+1 -1
View File
@@ -7,7 +7,7 @@ name = "cthulhu"
dynamic = ["version"] dynamic = ["version"]
description = "Fork of the Orca screen reader" description = "Fork of the Orca screen reader"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.9"
license = { text = "LGPL-2.1-or-later" } license = { text = "LGPL-2.1-or-later" }
dependencies = [ dependencies = [
"pygobject>=3.18", "pygobject>=3.18",
+2 -2
View File
@@ -34,7 +34,7 @@ __copyright__ = "Copyright (c) 2023 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Union
import gi import gi
@@ -103,7 +103,7 @@ class ActionList(Gtk.Window):
GLib.idle_add(self._presenter._clear_gui_and_restore_focus) 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.""" """Populates the list with accessible actions."""
if isinstance(actions, dict): if isinstance(actions, dict):
+7 -6
View File
@@ -29,6 +29,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2023 Igalia, S.L." __copyright__ = "Copyright (c) 2023 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import time import time
import gi import gi
@@ -48,15 +49,15 @@ class AXCollection:
# pylint: disable=R0913,R0914 # pylint: disable=R0913,R0914
@staticmethod @staticmethod
def create_match_rule( def create_match_rule(
states: list[str] | None = None, states: Optional[list[str]] = None,
state_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL, 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, 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, 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, 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.""" """Creates a match rule based on the supplied criteria."""
if states is None: if states is None:
@@ -136,7 +137,7 @@ class AXCollection:
obj: Atspi.Accessible, obj: Atspi.Accessible,
rule: Atspi.MatchRule, rule: Atspi.MatchRule,
order: Atspi.CollectionSortOrder = Atspi.CollectionSortOrder.CANONICAL order: Atspi.CollectionSortOrder = Atspi.CollectionSortOrder.CANONICAL
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the first object matching the specified rule.""" """Returns the first object matching the specified rule."""
if not AXObject.supports_collection(obj): if not AXObject.supports_collection(obj):
+5 -4
View File
@@ -34,6 +34,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc." "Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import functools import functools
import gi import gi
@@ -285,7 +286,7 @@ class AXComponent:
@staticmethod @staticmethod
def _find_descendant_at_point( def _find_descendant_at_point(
obj: Atspi.Accessible, x: int, y: int 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.""" """Checks each child to see if it has a descendant at the specified point."""
for child in AXObject.iter_children(obj): for child in AXObject.iter_children(obj):
@@ -297,7 +298,7 @@ class AXComponent:
return None return None
@staticmethod @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.""" """Returns the child (or descendant?) of obj at the specified point."""
if not AXObject.supports_component(obj): if not AXObject.supports_component(obj):
@@ -317,7 +318,7 @@ class AXComponent:
@staticmethod @staticmethod
def _get_descendant_at_point( def _get_descendant_at_point(
obj: Atspi.Accessible, x: int, y: int obj: Atspi.Accessible, x: int, y: int
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the deepest descendant of obj at the specified point.""" """Returns the deepest descendant of obj at the specified point."""
child = AXComponent._get_object_at_point(obj, x, y) child = AXComponent._get_object_at_point(obj, x, y)
@@ -338,7 +339,7 @@ class AXComponent:
@staticmethod @staticmethod
def get_descendant_at_point( def get_descendant_at_point(
obj: Atspi.Accessible, x: int, y: int obj: Atspi.Accessible, x: int, y: int
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the deepest descendant of obj at the specified point.""" """Returns the deepest descendant of obj at the specified point."""
result = AXComponent._get_descendant_at_point(obj, x, y) result = AXComponent._get_descendant_at_point(obj, x, y)
+24 -23
View File
@@ -34,6 +34,7 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
"Copyright (c) 2018-2023 Igalia, S.L." "Copyright (c) 2018-2023 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
@@ -60,8 +61,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def _is_scrolled_off_screen( def _is_scrolled_off_screen(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None, offset: Optional[int] = None,
ancestor: Atspi.Accessible | None = None ancestor: Optional[Atspi.Accessible] = None
) -> bool: ) -> bool:
"""Returns true if obj, or the caret offset therein, is scrolled off-screen.""" """Returns true if obj, or the caret offset therein, is scrolled off-screen."""
@@ -119,7 +120,7 @@ class AXEventSynthesizer:
return True return True
@staticmethod @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.""" """Performs the specified mouse event on the current character in obj."""
if offset is None: if offset is None:
@@ -164,7 +165,7 @@ class AXEventSynthesizer:
return AXEventSynthesizer._generate_mouse_event(obj, relative_x, relative_y, event) return AXEventSynthesizer._generate_mouse_event(obj, relative_x, relative_y, event)
@staticmethod @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.""" """Routes the pointer to the current character in obj."""
tokens = [f"AXEventSynthesizer: Attempting to route to offset {offset} in", obj] tokens = [f"AXEventSynthesizer: Attempting to route to offset {offset} in", obj]
@@ -181,7 +182,7 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def click_character( def click_character(
obj: Atspi.Accessible, offset: int | None = None, button: int = 1 obj: Atspi.Accessible, offset: Optional[int] = None, button: int = 1
) -> bool: ) -> bool:
"""Single click on the current character in obj using the specified button.""" """Single click on the current character in obj using the specified button."""
@@ -198,7 +199,7 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def _scroll_to_location( def _scroll_to_location(
obj: Atspi.Accessible, location: Atspi.ScrollType, 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: ) -> None:
"""Attempts to scroll to the specified location.""" """Attempts to scroll to the specified location."""
@@ -220,7 +221,7 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def _scroll_to_point( def _scroll_to_point(
obj: Atspi.Accessible, x_coord: int, y_coord: int, 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: ) -> None:
"""Attempts to scroll obj to the specified point.""" """Attempts to scroll obj to the specified point."""
@@ -242,8 +243,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_into_view( def scroll_into_view(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj into view.""" """Attempts to scroll obj into view."""
@@ -253,8 +254,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_center( def scroll_to_center(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the center of its window.""" """Attempts to scroll obj to the center of its window."""
@@ -272,8 +273,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_top_edge( def scroll_to_top_edge(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the top edge.""" """Attempts to scroll obj to the top edge."""
@@ -283,8 +284,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_top_left( def scroll_to_top_left(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the top left.""" """Attempts to scroll obj to the top left."""
@@ -294,8 +295,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_left_edge( def scroll_to_left_edge(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the left edge.""" """Attempts to scroll obj to the left edge."""
@@ -305,8 +306,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_bottom_edge( def scroll_to_bottom_edge(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the bottom edge.""" """Attempts to scroll obj to the bottom edge."""
@@ -316,8 +317,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_bottom_right( def scroll_to_bottom_right(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the bottom right.""" """Attempts to scroll obj to the bottom right."""
@@ -327,8 +328,8 @@ class AXEventSynthesizer:
@staticmethod @staticmethod
def scroll_to_right_edge( def scroll_to_right_edge(
obj: Atspi.Accessible, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> None: ) -> None:
"""Attempts to scroll obj to the right edge.""" """Attempts to scroll obj to the right edge."""
+4 -3
View File
@@ -32,6 +32,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Igalia, S.L." __copyright__ = "Copyright (c) 2024 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import os import os
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -66,7 +67,7 @@ class AXHypertext:
return count return count
@staticmethod @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.""" """Returns the hyperlink object at the specified index."""
if not AXObject.supports_hypertext(obj): if not AXObject.supports_hypertext(obj):
@@ -202,7 +203,7 @@ class AXHypertext:
return basename return basename
@staticmethod @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""" """Attempts to correct for off-by-one brokenness in implementations"""
if child := AXHypertext.get_child_at_offset(obj, offset): if child := AXHypertext.get_child_at_offset(obj, offset):
@@ -227,7 +228,7 @@ class AXHypertext:
return None return None
@staticmethod @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.""" """Returns the embedded-object child of obj at the specified offset."""
if not AXObject.supports_hypertext(obj): if not AXObject.supports_hypertext(obj):
+21 -21
View File
@@ -34,7 +34,7 @@ __license__ = "LGPL"
import re import re
import threading import threading
import time import time
from typing import Callable, Generator from typing import Callable, Generator, Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
@@ -105,7 +105,7 @@ class AXObject:
return name.lower() return name.lower()
@staticmethod @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.""" """Returns the accessible application associated with obj."""
if obj is None: if obj is None:
@@ -313,7 +313,7 @@ class AXObject:
return iface is not None return iface is not None
@staticmethod @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.""" """Work around for window events coming from mutter-x11-frames."""
if app is None: if app is None:
@@ -545,7 +545,7 @@ class AXObject:
return index return index
@staticmethod @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.""" """Returns the accessible parent of obj. See also get_parent_checked."""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -571,7 +571,7 @@ class AXObject:
return parent return parent
@staticmethod @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""" """Returns the parent of obj, doing checks for tree validity"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -619,7 +619,7 @@ class AXObject:
def get_common_ancestor( def get_common_ancestor(
obj1: Atspi.Accessible, obj1: Atspi.Accessible,
obj2: Atspi.Accessible obj2: Atspi.Accessible
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the common ancestor of obj1 and obj2.""" """Returns the common ancestor of obj1 and obj2."""
tokens = ["AXObject: Looking for 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( def find_ancestor_inclusive(
obj: Atspi.Accessible, obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool] 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""" """Returns obj, or the ancestor of obj, for which the function pred is true"""
if pred(obj): if pred(obj):
@@ -659,7 +659,7 @@ class AXObject:
def find_ancestor( def find_ancestor(
obj: Atspi.Accessible, obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool] pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the ancestor of obj if the function pred is true""" """Returns the ancestor of obj if the function pred is true"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -703,7 +703,7 @@ class AXObject:
return AXObject.find_ancestor(obj, lambda x: x == ancestor) is not None return AXObject.find_ancestor(obj, lambda x: x == ancestor) is not None
@staticmethod @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.""" """Returns the nth child of obj. See also get_child_checked."""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -736,7 +736,7 @@ class AXObject:
@staticmethod @staticmethod
def get_child_checked( def get_child_checked(
obj: Atspi.Accessible, index: int obj: Atspi.Accessible, index: int
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the nth child of obj, doing checks for tree validity""" """Returns the nth child of obj, doing checks for tree validity"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -757,7 +757,7 @@ class AXObject:
def get_active_descendant_checked( def get_active_descendant_checked(
container: Atspi.Accessible, container: Atspi.Accessible,
reported_child: Atspi.Accessible reported_child: Atspi.Accessible
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Checks the reported active descendant and return the real/valid one.""" """Checks the reported active descendant and return the real/valid one."""
if not AXObject.has_state(container, Atspi.StateType.MANAGES_DESCENDANTS): if not AXObject.has_state(container, Atspi.StateType.MANAGES_DESCENDANTS):
@@ -784,7 +784,7 @@ class AXObject:
def _find_descendant( def _find_descendant(
obj: Atspi.Accessible, obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool] pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the descendant of obj if the function pred is true""" """Returns the descendant of obj if the function pred is true"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -806,7 +806,7 @@ class AXObject:
def find_descendant( def find_descendant(
obj: Atspi.Accessible, obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool] pred: Callable[[Atspi.Accessible], bool]
) -> Atspi.Accessible | None: ) -> Optional[Atspi.Accessible]:
"""Returns the descendant of obj if the function pred is true""" """Returns the descendant of obj if the function pred is true"""
start = time.time() start = time.time()
@@ -816,7 +816,7 @@ class AXObject:
return result return result
@staticmethod @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""" """Returns the deepest descendant of obj"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -831,8 +831,8 @@ class AXObject:
@staticmethod @staticmethod
def _find_all_descendants( def _find_all_descendants(
obj: Atspi.Accessible, obj: Atspi.Accessible,
include_if: Callable[[Atspi.Accessible], bool] | None, include_if: Optional[Callable[[Atspi.Accessible], bool]],
exclude_if: Callable[[Atspi.Accessible], bool] | None, exclude_if: Optional[Callable[[Atspi.Accessible], bool]],
matches: list[Atspi.Accessible] matches: list[Atspi.Accessible]
) -> None: ) -> None:
"""Returns all descendants which match the specified inclusion and exclusion""" """Returns all descendants which match the specified inclusion and exclusion"""
@@ -852,8 +852,8 @@ class AXObject:
@staticmethod @staticmethod
def find_all_descendants( def find_all_descendants(
root: Atspi.Accessible, root: Atspi.Accessible,
include_if: Callable[[Atspi.Accessible], bool] | None = None, include_if: Optional[Callable[[Atspi.Accessible], bool]] = None,
exclude_if: Callable[[Atspi.Accessible], bool] | None = None exclude_if: Optional[Callable[[Atspi.Accessible], bool]] = None
) -> list[Atspi.Accessible]: ) -> list[Atspi.Accessible]:
"""Returns all descendants which match the specified inclusion and exclusion""" """Returns all descendants which match the specified inclusion and exclusion"""
@@ -1044,7 +1044,7 @@ class AXObject:
@staticmethod @staticmethod
def iter_children( def iter_children(
obj: Atspi.Accessible, obj: Atspi.Accessible,
pred: Callable[[Atspi.Accessible], bool] | None = None pred: Optional[Callable[[Atspi.Accessible], bool]] = None
) -> Generator[Atspi.Accessible, None, None]: ) -> Generator[Atspi.Accessible, None, None]:
"""Generator to iterate through obj's children. If the function pred is """Generator to iterate through obj's children. If the function pred is
specified, children for which pred is False will be skipped.""" specified, children for which pred is False will be skipped."""
@@ -1068,7 +1068,7 @@ class AXObject:
yield child yield child
@staticmethod @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""" """Returns the previous sibling of obj, based on child indices"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -1091,7 +1091,7 @@ class AXObject:
return sibling return sibling
@staticmethod @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""" """Returns the next sibling of obj, based on child indices"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
+2 -1
View File
@@ -28,6 +28,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2023 Igalia, S.L." __copyright__ = "Copyright (c) 2023 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
from gi.repository import Atspi from gi.repository import Atspi
@@ -60,7 +61,7 @@ class AXSelection:
return count return count
@staticmethod @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.""" """Returns the nth selected child of obj."""
n_children = AXSelection.get_selected_child_count(obj) n_children = AXSelection.get_selected_child_count(obj)
+27 -27
View File
@@ -35,7 +35,7 @@ __license__ = "LGPL"
import threading import threading
import time import time
from typing import Generator from typing import Generator, Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
@@ -62,11 +62,11 @@ class AXTable:
PHYSICAL_SPANS_FROM_TABLE: dict[int, tuple[int, int]] = {} PHYSICAL_SPANS_FROM_TABLE: dict[int, tuple[int, int]] = {}
PHYSICAL_COLUMN_COUNT: dict[int, int] = {} PHYSICAL_COLUMN_COUNT: dict[int, int] = {}
PHYSICAL_ROW_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_COORDINATES_LABELS: dict[int, str] = {}
PRESENTABLE_SPANS: dict[int, tuple[str | None, str | None]] = {} PRESENTABLE_SPANS: dict[int, tuple[Optional[str], Optional[str]]] = {}
PRESENTABLE_COLUMN_COUNT: dict[int, int | None] = {} PRESENTABLE_COLUMN_COUNT: dict[int, Optional[int]] = {}
PRESENTABLE_ROW_COUNT: dict[int, int | None] = {} PRESENTABLE_ROW_COUNT: dict[int, Optional[int]] = {}
COLUMN_HEADERS_FOR_CELL: dict[int, list[Atspi.Accessible]] = {} COLUMN_HEADERS_FOR_CELL: dict[int, list[Atspi.Accessible]] = {}
ROW_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) AXTable._clear_all_dictionaries(reason)
@staticmethod @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.""" """Returns the accessible object containing the caption of table."""
if not AXObject.supports_table(table): if not AXObject.supports_table(table):
@@ -171,7 +171,7 @@ class AXTable:
return count return count
@staticmethod @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.""" """Returns the value of the 'colcount' object attribute or None if not found."""
if hash(table) in AXTable.PRESENTABLE_COLUMN_COUNT: if hash(table) in AXTable.PRESENTABLE_COLUMN_COUNT:
@@ -217,7 +217,7 @@ class AXTable:
return count return count
@staticmethod @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.""" """Returns the value of the 'rowcount' object attribute or None if not found."""
if hash(table) in AXTable.PRESENTABLE_ROW_COUNT: if hash(table) in AXTable.PRESENTABLE_ROW_COUNT:
@@ -344,7 +344,7 @@ class AXTable:
return AXTable.get_selected_column_count(table) == cols return AXTable.get_selected_column_count(table) == cols
@staticmethod @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.""" """Returns the cell at the 0-indexed row and column."""
if not AXObject.supports_table(table): if not AXObject.supports_table(table):
@@ -405,7 +405,7 @@ class AXTable:
@staticmethod @staticmethod
def _get_cell_spans_from_attribute( def _get_cell_spans_from_attribute(
cell: Atspi.Accessible 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.""" """Returns the row and column spans exposed via object attribute, or None, None."""
if hash(cell) in AXTable.PRESENTABLE_SPANS: if hash(cell) in AXTable.PRESENTABLE_SPANS:
@@ -606,7 +606,7 @@ class AXTable:
@staticmethod @staticmethod
def get_new_row_headers( def get_new_row_headers(
cell: Atspi.Accessible, cell: Atspi.Accessible,
old_cell: Atspi.Accessible | None old_cell: Optional[Atspi.Accessible]
) -> list[Atspi.Accessible]: ) -> list[Atspi.Accessible]:
"""Returns row headers of cell that are not also headers of old_cell. """ """Returns row headers of cell that are not also headers of old_cell. """
@@ -623,7 +623,7 @@ class AXTable:
@staticmethod @staticmethod
def get_new_column_headers( def get_new_column_headers(
cell: Atspi.Accessible, cell: Atspi.Accessible,
old_cell: Atspi.Accessible | None old_cell: Optional[Atspi.Accessible]
) -> list[Atspi.Accessible]: ) -> list[Atspi.Accessible]:
"""Returns column headers of cell that are not also headers of old_cell. """ """Returns column headers of cell that are not also headers of old_cell. """
@@ -880,7 +880,7 @@ class AXTable:
@staticmethod @staticmethod
def _get_cell_coordinates_from_attribute( def _get_cell_coordinates_from_attribute(
cell: Atspi.Accessible 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.""" """Returns the 1-based indices for cell exposed via object attribute, or None, None."""
if cell is None: if cell is None:
@@ -941,7 +941,7 @@ class AXTable:
return result return result
@staticmethod @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.""" """Returns obj if it is a table, otherwise returns the ancestor table of obj."""
if obj is None: if obj is None:
@@ -981,21 +981,21 @@ class AXTable:
return result return result
@staticmethod @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.""" """Returns the first cell in table."""
row, col = 0, 0 row, col = 0, 0
return AXTable.get_cell_at(table, row, col) return AXTable.get_cell_at(table, row, col)
@staticmethod @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.""" """Returns the last cell in table."""
row, col = AXTable.get_row_count(table) - 1, AXTable.get_column_count(table) - 1 row, col = AXTable.get_row_count(table) - 1, AXTable.get_column_count(table) - 1
return AXTable.get_cell_at(table, row, col) return AXTable.get_cell_at(table, row, col)
@staticmethod @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.""" """Returns the cell above cell in table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) 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) return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod @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.""" """Returns the cell below cell in table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) 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) return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod @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.""" """Returns the cell to the left."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) 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) return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod @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.""" """Returns the cell to the right."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) 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) return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod @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.""" """Returns the cell at the start of cell's row."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
return AXTable.get_cell_at(AXTable.get_table(cell), row, 0) return AXTable.get_cell_at(AXTable.get_table(cell), row, 0)
@staticmethod @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.""" """Returns the cell at the end of cell's row."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] 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) return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod @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.""" """Returns the cell at the top of cell's column."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
return AXTable.get_cell_at(AXTable.get_table(cell), 0, col) return AXTable.get_cell_at(AXTable.get_table(cell), 0, col)
@staticmethod @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.""" """Returns the cell at the bottom of cell's column."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] 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) return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod @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.""" """Returns the formula associated with this cell."""
attrs = AXObject.get_attributes_dict(cell, use_cache=False) attrs = AXObject.get_attributes_dict(cell, use_cache=False)
@@ -1192,7 +1192,7 @@ class AXTable:
return result return result
@staticmethod @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.""" """Returns the user-set row header for cell."""
table = AXTable.get_table(cell) table = AXTable.get_table(cell)
@@ -1207,7 +1207,7 @@ class AXTable:
return AXTable.get_cell_at(table, cell_row, headers_column) return AXTable.get_cell_at(table, cell_row, headers_column)
@staticmethod @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.""" """Returns the user-set column header for cell."""
table = AXTable.get_table(cell) table = AXTable.get_table(cell)
+32 -32
View File
@@ -44,7 +44,7 @@ __license__ = "LGPL"
import enum import enum
import locale import locale
import re import re
from typing import Generator from typing import Generator, Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
@@ -104,7 +104,7 @@ class AXTextAttribute(enum.Enum):
WRITING_MODE = ("writing-mode", False) WRITING_MODE = ("writing-mode", False)
@classmethod @classmethod
def from_string(cls, string: str) -> "AXTextAttribute" | None: def from_string(cls, string: str) -> Optional['AXTextAttribute']:
"""Returns the AXTextAttribute for the specified string.""" """Returns the AXTextAttribute for the specified string."""
for attribute in cls: for attribute in cls:
@@ -114,7 +114,7 @@ class AXTextAttribute(enum.Enum):
return None return None
@classmethod @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.""" """Returns the AXTextAttribute for the specified localized string."""
for attribute in cls: for attribute in cls:
@@ -192,7 +192,7 @@ class AXText:
@staticmethod @staticmethod
def get_character_at_offset( def get_character_at_offset(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the (character, start, end) for the current or specified offset.""" """Returns the (character, start, end) for the current or specified offset."""
@@ -239,7 +239,7 @@ class AXText:
@staticmethod @staticmethod
def get_next_character( def get_next_character(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the next (character, start, end) for the current or specified offset.""" """Returns the next (character, start, end) for the current or specified offset."""
@@ -264,7 +264,7 @@ class AXText:
@staticmethod @staticmethod
def get_previous_character( def get_previous_character(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the previous (character, start, end) for the current or specified offset.""" """Returns the previous (character, start, end) for the current or specified offset."""
@@ -291,7 +291,7 @@ class AXText:
@staticmethod @staticmethod
def iter_character( def iter_character(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]: ) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by character in obj starting with the character at offset.""" """Generator to iterate by character in obj starting with the character at offset."""
@@ -312,7 +312,7 @@ class AXText:
@staticmethod @staticmethod
def get_word_at_offset( def get_word_at_offset(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the (word, start, end) for the current or specified offset.""" """Returns the (word, start, end) for the current or specified offset."""
@@ -354,7 +354,7 @@ class AXText:
@staticmethod @staticmethod
def get_next_word( def get_next_word(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the next (word, start, end) for the current or specified offset.""" """Returns the next (word, start, end) for the current or specified offset."""
@@ -379,7 +379,7 @@ class AXText:
@staticmethod @staticmethod
def get_previous_word( def get_previous_word(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the previous (word, start, end) for the current or specified offset.""" """Returns the previous (word, start, end) for the current or specified offset."""
@@ -406,7 +406,7 @@ class AXText:
@staticmethod @staticmethod
def iter_word( def iter_word(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]: ) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by word in obj starting with the word at offset.""" """Generator to iterate by word in obj starting with the word at offset."""
@@ -427,7 +427,7 @@ class AXText:
@staticmethod @staticmethod
def get_line_at_offset( def get_line_at_offset(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the (line, start, end) for the current or specified offset.""" """Returns the (line, start, end) for the current or specified offset."""
@@ -495,7 +495,7 @@ class AXText:
@staticmethod @staticmethod
def get_next_line( def get_next_line(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the next (line, start, end) for the current or specified offset.""" """Returns the next (line, start, end) for the current or specified offset."""
@@ -520,7 +520,7 @@ class AXText:
@staticmethod @staticmethod
def get_previous_line( def get_previous_line(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the previous (line, start, end) for the current or specified offset.""" """Returns the previous (line, start, end) for the current or specified offset."""
@@ -549,7 +549,7 @@ class AXText:
@staticmethod @staticmethod
def iter_line( def iter_line(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]: ) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by line in obj starting with the line at offset.""" """Generator to iterate by line in obj starting with the line at offset."""
@@ -606,7 +606,7 @@ class AXText:
@staticmethod @staticmethod
def _get_sentence_at_offset_fallback( def _get_sentence_at_offset_fallback(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Fallback sentence detection for broken implementations.""" """Fallback sentence detection for broken implementations."""
@@ -633,7 +633,7 @@ class AXText:
@staticmethod @staticmethod
def get_sentence_at_offset( def get_sentence_at_offset(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the (sentence, start, end) for the current or specified offset.""" """Returns the (sentence, start, end) for the current or specified offset."""
@@ -682,7 +682,7 @@ class AXText:
@staticmethod @staticmethod
def get_next_sentence( def get_next_sentence(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the next (sentence, start, end) for the current or specified offset.""" """Returns the next (sentence, start, end) for the current or specified offset."""
@@ -707,7 +707,7 @@ class AXText:
@staticmethod @staticmethod
def get_previous_sentence( def get_previous_sentence(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the previous (sentence, start, end) for the current or specified offset.""" """Returns the previous (sentence, start, end) for the current or specified offset."""
@@ -734,7 +734,7 @@ class AXText:
@staticmethod @staticmethod
def iter_sentence( def iter_sentence(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]: ) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by sentence in obj starting with the sentence at offset.""" """Generator to iterate by sentence in obj starting with the sentence at offset."""
@@ -759,7 +759,7 @@ class AXText:
@staticmethod @staticmethod
def get_paragraph_at_offset( def get_paragraph_at_offset(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the (paragraph, start, end) for the current or specified offset.""" """Returns the (paragraph, start, end) for the current or specified offset."""
@@ -801,7 +801,7 @@ class AXText:
@staticmethod @staticmethod
def get_next_paragraph( def get_next_paragraph(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the next (paragraph, start, end) for the current or specified offset.""" """Returns the next (paragraph, start, end) for the current or specified offset."""
@@ -826,7 +826,7 @@ class AXText:
@staticmethod @staticmethod
def get_previous_paragraph( def get_previous_paragraph(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[str, int, int]: ) -> tuple[str, int, int]:
"""Returns the previous (paragraph, start, end) for the current or specified offset.""" """Returns the previous (paragraph, start, end) for the current or specified offset."""
@@ -852,7 +852,7 @@ class AXText:
@staticmethod @staticmethod
def iter_paragraph( 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[tuple[str, int, int], None, None]:
"""Generator to iterate by paragraph in obj starting with the paragraph at offset.""" """Generator to iterate by paragraph in obj starting with the paragraph at offset."""
@@ -1206,7 +1206,7 @@ class AXText:
@staticmethod @staticmethod
def get_text_attributes_at_offset( def get_text_attributes_at_offset(
obj: Atspi.Accessible, obj: Atspi.Accessible,
offset: int | None = None offset: Optional[int] = None
) -> tuple[dict[str, str], int, int]: ) -> tuple[dict[str, str], int, int]:
"""Returns a (dict, start, end) tuple for attributes at offset in obj.""" """Returns a (dict, start, end) tuple for attributes at offset in obj."""
@@ -1303,7 +1303,7 @@ class AXText:
return offset return offset
@staticmethod @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.""" """Returns the Atspi rect of the character at the specified offset in obj."""
if not AXObject.supports_text(obj): if not AXObject.supports_text(obj):
@@ -1487,7 +1487,7 @@ class AXText:
return result return result
@staticmethod @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.""" """Returns True if the text attributes indicate a spelling error."""
attributes = AXText.get_text_attributes_at_offset(obj, offset)[0] attributes = AXText.get_text_attributes_at_offset(obj, offset)[0]
@@ -1502,7 +1502,7 @@ class AXText:
return False return False
@staticmethod @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.""" """Returns True if the text attributes indicate a grammar error."""
attributes = AXText.get_text_attributes_at_offset(obj, offset)[0] attributes = AXText.get_text_attributes_at_offset(obj, offset)[0]
@@ -1552,8 +1552,8 @@ class AXText:
obj: Atspi.Accessible, obj: Atspi.Accessible,
x: int, x: int,
y: int, y: int,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> bool: ) -> bool:
"""Attempts to scroll obj to the specified point.""" """Attempts to scroll obj to the specified point."""
@@ -1583,8 +1583,8 @@ class AXText:
def scroll_substring_to_location( def scroll_substring_to_location(
obj: Atspi.Accessible, obj: Atspi.Accessible,
location: Atspi.ScrollType, location: Atspi.ScrollType,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None end_offset: Optional[int] = None
) -> bool: ) -> bool:
"""Attempts to scroll the substring to the specified Atspi.ScrollType location.""" """Attempts to scroll the substring to the specified Atspi.ScrollType location."""
+13 -12
View File
@@ -34,6 +34,7 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2023-2025 Igalia, S.L." __copyright__ = "Copyright (c) 2023-2025 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import functools import functools
import inspect import inspect
import queue import queue
@@ -97,7 +98,7 @@ class AXUtilities:
AXUtilities.IS_LAYOUT_ONLY.clear() AXUtilities.IS_LAYOUT_ONLY.clear()
@staticmethod @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.""" """Clears all cached information immediately."""
AXUtilities._clear_all_dictionaries(reason) AXUtilities._clear_all_dictionaries(reason)
@@ -152,7 +153,7 @@ class AXUtilities:
return True return True
@staticmethod @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.""" """Tries to locate the active window; may or may not succeed."""
candidates = [] candidates = []
@@ -192,7 +193,7 @@ class AXUtilities:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return filtered[0] return filtered[0]
guess: Atspi.Accessible | None = None guess: Optional[Atspi.Accessible] = None
if filtered: if filtered:
tokens = ["AXUtilities: Still have multiple active windows:", filtered] tokens = ["AXUtilities: Still have multiple active windows:", filtered]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
@@ -266,7 +267,7 @@ class AXUtilities:
return AXObject.find_all_descendants(obj, is_match) return AXObject.find_all_descendants(obj, is_match)
@staticmethod @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""" """Returns the default button descendant of obj"""
result = None result = None
@@ -278,7 +279,7 @@ class AXUtilities:
return AXObject.find_descendant(obj, AXUtilitiesRole.is_default_button) return AXObject.find_descendant(obj, AXUtilitiesRole.is_default_button)
@staticmethod @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""" """Returns the focused descendant of obj"""
result = None result = None
@@ -290,7 +291,7 @@ class AXUtilities:
return AXObject.find_descendant(obj, AXUtilitiesState.is_focused) return AXObject.find_descendant(obj, AXUtilitiesState.is_focused)
@staticmethod @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""" """Returns the info bar descendant of obj"""
result = None result = None
@@ -302,7 +303,7 @@ class AXUtilities:
return AXObject.find_descendant(obj, AXUtilitiesRole.is_info_bar) return AXObject.find_descendant(obj, AXUtilitiesRole.is_info_bar)
@staticmethod @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""" """Returns the status bar descendant of obj"""
result = None result = None
@@ -748,7 +749,7 @@ class AXUtilities:
return len(ancestors) return len(ancestors)
@staticmethod @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)""" """Returns the next object (depth first, unless there's a flows-to relation)"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -778,7 +779,7 @@ class AXUtilities:
return next_object return next_object
@staticmethod @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)""" """Returns the previous object (depth first, unless there's a flows-from relation)"""
if not AXObject.is_valid(obj): if not AXObject.is_valid(obj):
@@ -810,7 +811,7 @@ class AXUtilities:
@staticmethod @staticmethod
def is_on_screen( def is_on_screen(
obj: Atspi.Accessible, obj: Atspi.Accessible,
bounding_box: Atspi.Rect | None = None bounding_box: Optional[Atspi.Rect] = None
) -> bool: ) -> bool:
"""Returns true if obj should be treated as being on screen.""" """Returns true if obj should be treated as being on screen."""
@@ -887,7 +888,7 @@ class AXUtilities:
def _get_on_screen_objects( def _get_on_screen_objects(
root: Atspi.Accessible, root: Atspi.Accessible,
cancellation_event: threading.Event, cancellation_event: threading.Event,
bounding_box: Atspi.Rect | None = None bounding_box: Optional[Atspi.Rect] = None
) -> list: ) -> list:
tokens = ["AXUtilities: Getting on-screen objects in", root, f"({hex(id(root))})"] tokens = ["AXUtilities: Getting on-screen objects in", root, f"({hex(id(root))})"]
@@ -939,7 +940,7 @@ class AXUtilities:
@staticmethod @staticmethod
def get_on_screen_objects( def get_on_screen_objects(
root: Atspi.Accessible, root: Atspi.Accessible,
bounding_box: Atspi.Rect | None = None, bounding_box: Optional[Atspi.Rect] = None,
timeout: float = 5.0 timeout: float = 5.0
) -> list: ) -> list:
"""Returns a list of onscreen objects in the given root.""" """Returns a list of onscreen objects in the given root."""
+4 -3
View File
@@ -30,6 +30,7 @@ __copyright__ = "Copyright (c) 2023-2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc." "Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import subprocess import subprocess
import gi import gi
@@ -82,7 +83,7 @@ class AXUtilitiesApplication:
return list(AXObject.iter_children(desktop, pred)) return list(AXObject.iter_children(desktop, pred))
@staticmethod @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""" """Returns the accessible application associated with obj"""
if obj is None: if obj is None:
@@ -131,7 +132,7 @@ class AXUtilitiesApplication:
return version return version
@staticmethod @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""" """Returns the accessible application with the specified pid"""
applications = AXUtilitiesApplication.get_all_applications() applications = AXUtilitiesApplication.get_all_applications()
@@ -144,7 +145,7 @@ class AXUtilitiesApplication:
return None return None
@staticmethod @staticmethod
def get_desktop() -> Atspi.Accessible | None: def get_desktop() -> Optional[Atspi.Accessible]:
"""Returns the accessible desktop""" """Returns the accessible desktop"""
try: try:
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -35,6 +35,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc." "Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import threading import threading
import time import time
@@ -118,7 +119,7 @@ class AXUtilitiesRelation:
def _get_relation( def _get_relation(
obj: Atspi.Accessible, obj: Atspi.Accessible,
relation_type: Atspi.RelationType relation_type: Atspi.RelationType
) -> Atspi.Relation | None: ) -> Optional[Atspi.Relation]:
"""Returns the specified Atspi.Relation for obj""" """Returns the specified Atspi.Relation for obj"""
for relation in AXUtilitiesRelation.get_relations(obj): for relation in AXUtilitiesRelation.get_relations(obj):
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -34,6 +34,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc." "Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL" __license__ = "LGPL"
from typing import Optional
import threading import threading
import time import time
@@ -149,7 +150,7 @@ class AXValue:
return f"{current:.{decimal_places}f}" return f"{current:.{decimal_places}f}"
@staticmethod @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.""" """Returns the current value as a percent, or None if that is not applicable."""
if not AXObject.supports_value(obj): if not AXObject.supports_value(obj):
+2 -3
View File
@@ -3571,9 +3571,8 @@
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<items> <items>
<item translatable="yes">Claude Code (Enhanced)</item> <item translatable="yes">Claude Code (CLI)</item>
<item translatable="yes">Claude (Anthropic)</item> <item translatable="yes">Codex (CLI)</item>
<item translatable="yes">ChatGPT (OpenAI)</item>
<item translatable="yes">Gemini (Google)</item> <item translatable="yes">Gemini (Google)</item>
<item translatable="yes">Ollama (Local - Free)</item> <item translatable="yes">Ollama (Local - Free)</item>
</items> </items>
+39 -5
View File
@@ -57,6 +57,7 @@ from . import cthulhu_state
from . import settings from . import settings
from . import settings_manager from . import settings_manager
from . import input_event from . import input_event
from . import input_event_manager
from . import keybindings from . import keybindings
from . import pronunciation_dict from . import pronunciation_dict
from . import braille from . import braille
@@ -64,6 +65,7 @@ from . import speech
from . import speechserver from . import speechserver
from . import text_attribute_names from . import text_attribute_names
from . import sound_theme_manager from . import sound_theme_manager
from . import script_manager
from .ax_object import AXObject from .ax_object import AXObject
_settingsManager = settings_manager.getManager() _settingsManager = settings_manager.getManager()
@@ -1874,10 +1876,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Set provider combo # Set provider combo
provider = prefs.get("aiProvider", settings.aiProvider) provider = prefs.get("aiProvider", settings.aiProvider)
providerIndex = 0 # Default to Claude Code providerIndex = 0 # Default to Claude Code
if provider == settings.AI_PROVIDER_GEMINI: if provider == settings.AI_PROVIDER_CODEX:
providerIndex = 1 providerIndex = 1
elif provider == settings.AI_PROVIDER_OLLAMA: elif provider == settings.AI_PROVIDER_GEMINI:
providerIndex = 2 providerIndex = 2
elif provider == settings.AI_PROVIDER_OLLAMA:
providerIndex = 3
self.aiProviderCombo.set_active(providerIndex) self.aiProviderCombo.set_active(providerIndex)
# Set API key file # Set API key file
@@ -1947,6 +1951,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Update labels based on provider # Update labels based on provider
if provider == settings.AI_PROVIDER_CLAUDE_CODE: if provider == settings.AI_PROVIDER_CLAUDE_CODE:
self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses Claude Code CLI") 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: elif provider == settings.AI_PROVIDER_OLLAMA:
self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses local Ollama") self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses local Ollama")
else: else:
@@ -3065,6 +3071,14 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self._presentMessage(messages.KB_ENTER_NEW_KEY) self._presentMessage(messages.KB_ENTER_NEW_KEY)
cthulhu_state.capturingKeys = True 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) editable.connect('key-press-event', self.kbKeyPressed)
return return
@@ -3073,6 +3087,10 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
cthulhu_state.capturingKeys = False cthulhu_state.capturingKeys = False
self._capturedKey = [] self._capturedKey = []
try:
script_manager.get_manager().get_active_script().refreshKeyGrabs()
except Exception:
pass
return return
def _processKeyCaptured(self, keyPressedEvent): def _processKeyCaptured(self, keyPressedEvent):
@@ -3090,9 +3108,17 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
entries = entries_for_keycode[-1] entries = entries_for_keycode[-1]
eventString = Gdk.keyval_name(entries[0]) eventString = Gdk.keyval_name(entries[0])
eventState = keyPressedEvent.state eventState = keyPressedEvent.state
eventKeyvalName = Gdk.keyval_name(keyPressedEvent.keyval)
cthulhuMods = settings.cthulhuModifierKeys 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] self._capturedKey = ['', keybindings.CTHULHU_MODIFIER_MASK, 0]
return False return False
@@ -3180,6 +3206,10 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
cthulhu_state.capturingKeys = False cthulhu_state.capturingKeys = False
self._capturedKey = [] self._capturedKey = []
try:
script_manager.get_manager().get_active_script().refreshKeyGrabs()
except Exception:
pass
myiter = treeModel.get_iter_from_string(path) myiter = treeModel.get_iter_from_string(path)
try: try:
originalBinding = treeModel.get_value(myiter, text) originalBinding = treeModel.get_value(myiter, text)
@@ -3793,7 +3823,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
def aiProviderChanged(self, widget): def aiProviderChanged(self, widget):
"""AI Provider combo box changed handler""" """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() activeIndex = widget.get_active()
if 0 <= activeIndex < len(providers): if 0 <= activeIndex < len(providers):
provider = providers[activeIndex] provider = providers[activeIndex]
@@ -3970,4 +4005,3 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
"""OCR copy to clipboard checkbox toggled handler""" """OCR copy to clipboard checkbox toggled handler"""
self.prefsDict["ocrCopyToClipboard"] = widget.get_active() self.prefsDict["ocrCopyToClipboard"] = widget.get_active()
+5 -5
View File
@@ -29,7 +29,7 @@ __license__ = "LGPL"
import enum import enum
import inspect import inspect
from typing import Callable from typing import Callable, Optional
try: try:
from dasbus.connection import SessionMessageBus from dasbus.connection import SessionMessageBus
@@ -156,7 +156,7 @@ class _HandlerInfo:
description: str, description: str,
action: Callable[..., bool], action: Callable[..., bool],
handler_type: 'HandlerType' = HandlerType.COMMAND, 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.python_function_name: str = python_function_name
self.description: str = description self.description: str = description
@@ -548,10 +548,10 @@ class CthulhuRemoteController:
OBJECT_PATH = "/org/stormux/Cthulhu/Service" OBJECT_PATH = "/org/stormux/Cthulhu/Service"
def __init__(self) -> None: def __init__(self) -> None:
self._dbus_service_interface: CthulhuDBusServiceInterface | None = None self._dbus_service_interface: Optional[CthulhuDBusServiceInterface] = None
self._is_running: bool = False self._is_running: bool = False
self._bus: SessionMessageBus | None = None self._bus: Optional[SessionMessageBus] = None
self._event_loop: EventLoop | None = None self._event_loop: Optional[EventLoop] = None
self._pending_registrations: dict[str, object] = {} self._pending_registrations: dict[str, object] = {}
self._total_commands: int = 0 self._total_commands: int = 0
self._total_getters: int = 0 self._total_getters: int = 0
+25 -25
View File
@@ -40,7 +40,7 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
"Copyright (c) 2016-2023 Igalia, S.L." "Copyright (c) 2016-2023 Igalia, S.L."
__license__ = "LGPL" __license__ = "LGPL"
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
@@ -75,13 +75,13 @@ class FocusManager:
"""Manages the focused object, window, etc.""" """Manages the focused object, window, etc."""
def __init__(self) -> None: def __init__(self) -> None:
self._window: Atspi.Accessible | None = cthulhu_state.activeWindow self._window: Optional[Atspi.Accessible] = cthulhu_state.activeWindow
self._focus: Atspi.Accessible | None = cthulhu_state.locusOfFocus self._focus: Optional[Atspi.Accessible] = cthulhu_state.locusOfFocus
self._object_of_interest: Atspi.Accessible | None = cthulhu_state.objOfInterest self._object_of_interest: Optional[Atspi.Accessible] = cthulhu_state.objOfInterest
self._active_mode: str | None = cthulhu_state.activeMode self._active_mode: Optional[str] = cthulhu_state.activeMode
self._last_cell_coordinates: tuple[int, int] = (-1, -1) self._last_cell_coordinates: tuple[int, int] = (-1, -1)
self._last_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[Atspi.Accessible | None, int] = (None, -1) self._penultimate_cursor_position: tuple[Optional[Atspi.Accessible], int] = (None, -1)
msg = "FOCUS MANAGER: Registering D-Bus commands." msg = "FOCUS MANAGER: Registering D-Bus commands."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
@@ -104,7 +104,7 @@ class FocusManager:
cthulhu_state.objOfInterest = None cthulhu_state.objOfInterest = None
cthulhu_state.activeMode = 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.""" """Returns the focused object in the active window."""
result = _get_ax_utilities().get_focused_object(self._window) result = _get_ax_utilities().get_focused_object(self._window)
@@ -147,9 +147,9 @@ class FocusManager:
def emit_region_changed( def emit_region_changed(
self, obj: Atspi.Accessible, self, obj: Atspi.Accessible,
start_offset: int | None = None, start_offset: Optional[int] = None,
end_offset: int | None = None, end_offset: Optional[int] = None,
mode: str | None = None mode: Optional[str] = None
) -> None: ) -> None:
"""Notifies interested clients that the current region of interest has changed.""" """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( def get_active_mode_and_object_of_interest(
self self
) -> tuple[str | None, Atspi.Accessible | None]: ) -> tuple[Optional[str], Optional[Atspi.Accessible]]:
"""Returns the current mode and associated object of interest""" """Returns the current mode and associated object of interest"""
tokens = ["FOCUS MANAGER: Active mode:", self._active_mode, tokens = ["FOCUS MANAGER: Active mode:", self._active_mode,
@@ -200,7 +200,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return self._active_mode, self._object_of_interest 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).""" """Returns the penultimate cursor position as a tuple of (object, offset)."""
obj, offset = self._penultimate_cursor_position obj, offset = self._penultimate_cursor_position
@@ -208,7 +208,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return obj, offset 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).""" """Returns the last cursor position as a tuple of (object, offset)."""
obj, offset = self._last_cursor_position obj, offset = self._last_cursor_position
@@ -216,7 +216,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return obj, offset 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).""" """Sets the last cursor position as a tuple of (object, offset)."""
tokens = ["FOCUS MANAGER: Setting last cursor position to", obj, 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) debug.print_message(debug.LEVEL_INFO, msg, True)
self._last_cell_coordinates = row, column 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).""" """Returns the current locus of focus (i.e. the object with visual focus)."""
tokens = ["FOCUS MANAGER: Locus of focus is", self._focus] tokens = ["FOCUS MANAGER: Locus of focus is", self._focus]
@@ -248,8 +248,8 @@ class FocusManager:
def set_locus_of_focus( def set_locus_of_focus(
self, self,
event: Atspi.Event | None, event: Optional[Atspi.Event],
obj: Atspi.Accessible | None, obj: Optional[Atspi.Accessible],
notify_script: bool = True, notify_script: bool = True,
force: bool = False force: bool = False
) -> None: ) -> None:
@@ -340,7 +340,7 @@ class FocusManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return is_active 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).""" """Returns the currently-active window (i.e. without searching or verifying)."""
tokens = ["FOCUS MANAGER: Active window is", self._window] tokens = ["FOCUS MANAGER: Active window is", self._window]
@@ -349,8 +349,8 @@ class FocusManager:
def set_active_window( def set_active_window(
self, self,
frame: Atspi.Accessible | None, frame: Optional[Atspi.Accessible],
app: Atspi.Accessible | None = None, app: Optional[Atspi.Accessible] = None,
set_window_as_focus: bool = False, set_window_as_focus: bool = False,
notify_script: bool = False notify_script: bool = False
) -> None: ) -> None:
@@ -390,7 +390,7 @@ class FocusManager:
def toggle_presentation_mode( def toggle_presentation_mode(
self, self,
script: default.Script, script: default.Script,
event: InputEvent | None = None, event: Optional[InputEvent] = None,
notify_user: bool = True notify_user: bool = True
) -> bool: ) -> bool:
"""Switches between browse mode and focus mode (web content only).""" """Switches between browse mode and focus mode (web content only)."""
@@ -401,7 +401,7 @@ class FocusManager:
def toggle_layout_mode( def toggle_layout_mode(
self, self,
script: default.Script, script: default.Script,
event: InputEvent | None = None, event: Optional[InputEvent] = None,
notify_user: bool = True notify_user: bool = True
) -> bool: ) -> bool:
"""Switches between object mode and layout mode for line presentation (web content only).""" """Switches between object mode and layout mode for line presentation (web content only)."""
@@ -412,7 +412,7 @@ class FocusManager:
def enable_sticky_browse_mode( def enable_sticky_browse_mode(
self, self,
script: default.Script, script: default.Script,
event: InputEvent | None = None, event: Optional[InputEvent] = None,
notify_user: bool = True notify_user: bool = True
) -> bool: ) -> bool:
"""Enables sticky browse mode (web content only).""" """Enables sticky browse mode (web content only)."""
@@ -423,7 +423,7 @@ class FocusManager:
def enable_sticky_focus_mode( def enable_sticky_focus_mode(
self, self,
script: default.Script, script: default.Script,
event: InputEvent | None = None, event: Optional[InputEvent] = None,
notify_user: bool = True notify_user: bool = True
) -> bool: ) -> bool:
"""Enables sticky focus mode (web content only).""" """Enables sticky focus mode (web content only)."""
+9 -4
View File
@@ -39,7 +39,7 @@ __copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc." "Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL" __license__ = "LGPL"
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
import gi import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
@@ -50,6 +50,7 @@ from . import focus_manager
from . import input_event from . import input_event
from . import script_manager from . import script_manager
from . import settings from . import settings
from . import cthulhu_state
from .ax_object import AXObject from .ax_object import AXObject
from .ax_utilities import AXUtilities from .ax_utilities import AXUtilities
@@ -60,9 +61,9 @@ class InputEventManager:
"""Provides utilities for managing input events.""" """Provides utilities for managing input events."""
def __init__(self) -> None: def __init__(self) -> None:
self._last_input_event: input_event.InputEvent | None = None self._last_input_event: Optional[input_event.InputEvent] = None
self._last_non_modifier_key_event: input_event.KeyboardEvent | None = None self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None
self._device: Atspi.Device | None = None self._device: Optional[Atspi.Device] = None
self._mapped_keycodes: list[int] = [] self._mapped_keycodes: list[int] = []
self._mapped_keysyms: list[int] = [] self._mapped_keysyms: list[int] = []
self._grabbed_bindings: dict[int, keybindings.KeyBinding] = {} self._grabbed_bindings: dict[int, keybindings.KeyBinding] = {}
@@ -267,6 +268,10 @@ class InputEventManager:
msg = "INPUT EVENT MANAGER: Keyboard event processing is paused." msg = "INPUT EVENT MANAGER: Keyboard event processing is paused."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
return False 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) event = input_event.KeyboardEvent(pressed, keycode, keysym, modifiers, text)
if event in [self._last_input_event, self._last_non_modifier_key_event]: if event in [self._last_input_event, self._last_non_modifier_key_event]:
+49 -5
View File
@@ -38,6 +38,8 @@ import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import GObject from gi.repository import GObject
from gi.repository import Gtk from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Gdk
from . import cmdnames from . import cmdnames
from . import debug from . import debug
@@ -275,16 +277,20 @@ class NotificationPresenter:
class NotificationListGUI: class NotificationListGUI:
"""The dialog containing the notifications list.""" """The dialog containing the notifications list."""
RESPONSE_COPY = 1
def __init__(self, script, title, column_headers, rows): def __init__(self, script, title, column_headers, rows):
self._script = script self._script = script
self._model = None self._model = None
self._tree = None
self._gui = self._create_dialog(title, column_headers, rows) self._gui = self._create_dialog(title, column_headers, rows)
def _create_dialog(self, title, column_headers, rows): def _create_dialog(self, title, column_headers, rows):
dialog = Gtk.Dialog(title, dialog = Gtk.Dialog(title,
None, None,
Gtk.DialogFlags.MODAL, 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)) Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE))
dialog.set_default_size(600, 400) dialog.set_default_size(600, 400)
@@ -315,6 +321,11 @@ class NotificationListGUI:
self._model.set_value(row_iter, i, cell) self._model.set_value(row_iter, i, cell)
tree.set_model(self._model) 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) dialog.connect("response", self.on_response)
return dialog return dialog
@@ -325,6 +336,10 @@ class NotificationListGUI:
self._gui.destroy() self._gui.destroy()
return return
if response == self.RESPONSE_COPY:
self._copy_selected_notification()
return
if response == Gtk.ResponseType.APPLY and self._model is not None: if response == Gtk.ResponseType.APPLY and self._model is not None:
self._model.clear() self._model.clear()
getPresenter().clear_list() getPresenter().clear_list()
@@ -336,10 +351,39 @@ class NotificationListGUI:
"""Shows the notifications list dialog.""" """Shows the notifications list dialog."""
self._gui.show_all() self._gui.show_all()
time_stamp = cthulhu_state.lastInputEvent.timestamp time_stamp = Gtk.get_current_event_time()
if time_stamp == 0: if not time_stamp or time_stamp > 0xFFFFFFFF:
time_stamp = Gtk.get_current_event_time() time_stamp = Gdk.CURRENT_TIME
self._gui.present_with_time(time_stamp) 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 _presenter = None
def getPresenter(): def getPresenter():
+3 -3
View File
@@ -59,9 +59,9 @@ class Plugin:
self.plugin_info = plugin_info self.plugin_info = plugin_info
if plugin_info: if plugin_info:
self.module_name = getattr(plugin_info, 'module_name', '') self.module_name = getattr(plugin_info, 'module_name', '')
self.name = getattr(plugin_info, 'name', '') self.name = plugin_info.get_name()
self.version = getattr(plugin_info, 'version', '') self.version = plugin_info.get_version()
self.description = getattr(plugin_info, 'description', '') self.description = plugin_info.get_description()
@cthulhu_hookimpl @cthulhu_hookimpl
def activate(self, plugin=None): def activate(self, plugin=None):
+40 -8
View File
@@ -12,6 +12,7 @@ import os
import inspect import inspect
import importlib.util import importlib.util
import logging import logging
import configparser
from enum import IntEnum from enum import IntEnum
# Import pluggy if available # Import pluggy if available
@@ -29,6 +30,12 @@ logger = logging.getLogger(__name__)
if PLUGIN_DEBUG: if PLUGIN_DEBUG:
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
_manager = None
def getManager():
"""Return the shared PluginSystemManager instance."""
return _manager
class PluginType(IntEnum): class PluginType(IntEnum):
"""Types of plugins we support.""" """Types of plugins we support."""
SYSTEM = 1 SYSTEM = 1
@@ -78,8 +85,10 @@ class PluginSystemManager:
"""Cthulhu Plugin Manager using pluggy.""" """Cthulhu Plugin Manager using pluggy."""
def __init__(self, app): def __init__(self, app):
global _manager
self.app = app self.app = app
logger.info("Initializing PluginSystemManager") logger.info("Initializing PluginSystemManager")
_manager = self
# Initialize plugin manager # Initialize plugin manager
if PLUGGY_AVAILABLE: if PLUGGY_AVAILABLE:
@@ -378,15 +387,38 @@ class PluginSystemManager:
if os.path.isfile(metadata_file): if os.path.isfile(metadata_file):
try: try:
with open(metadata_file, 'r') as f: with open(metadata_file, 'r', encoding='utf-8') as f:
for line in f: contents = f.read()
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line: has_section_header = False
key, value = line.split('=', 1) for line in contents.splitlines():
metadata[key.strip()] = value.strip() 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: except Exception as e:
logger.error(f"Error loading plugin metadata: {e}") logger.error(f"Error loading plugin metadata: {e}")
+169 -1
View File
@@ -334,6 +334,172 @@ Keep descriptions informative and well-structured."""
return base_prompt 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): class OllamaProvider(AIProvider):
"""Ollama local AI provider.""" """Ollama local AI provider."""
@@ -563,9 +729,11 @@ def create_provider(provider_type, **kwargs):
"""Factory function to create AI providers.""" """Factory function to create AI providers."""
if provider_type == "claude_code": if provider_type == "claude_code":
return ClaudeCodeProvider(**kwargs) return ClaudeCodeProvider(**kwargs)
elif provider_type == "codex":
return CodexProvider(**kwargs)
elif provider_type == "ollama": elif provider_type == "ollama":
return OllamaProvider(**kwargs) return OllamaProvider(**kwargs)
elif provider_type == "gemini": elif provider_type == "gemini":
return GeminiProvider(**kwargs) return GeminiProvider(**kwargs)
else: else:
raise ValueError(f"Unknown provider type: {provider_type}") raise ValueError(f"Unknown provider type: {provider_type}")
+36
View File
@@ -204,6 +204,11 @@ class AIAssistant(Plugin):
result = self._check_claude_code_availability() result = self._check_claude_code_availability()
logger.info(f"Claude Code availability check result: {result}") logger.info(f"Claude Code availability check result: {result}")
return 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: elif self._provider_type == settings.AI_PROVIDER_GEMINI:
logger.info("Checking Gemini API key") logger.info("Checking Gemini API key")
if not self._api_key: if not self._api_key:
@@ -258,12 +263,43 @@ class AIAssistant(Plugin):
except Exception as e: except Exception as e:
logger.warning(f"Claude Code CLI not available: {e}") logger.warning(f"Claude Code CLI not available: {e}")
return False 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): def _initialize_ai_provider(self):
"""Initialize the AI provider based on settings.""" """Initialize the AI provider based on settings."""
try: try:
if self._provider_type == settings.AI_PROVIDER_CLAUDE_CODE: if self._provider_type == settings.AI_PROVIDER_CLAUDE_CODE:
self._ai_provider = create_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: elif self._provider_type == settings.AI_PROVIDER_OLLAMA:
self._ai_provider = create_provider("ollama", model=self._ollama_model, base_url=self._ollama_endpoint) self._ai_provider = create_provider("ollama", model=self._ollama_model, base_url=self._ollama_endpoint)
elif self._provider_type == settings.AI_PROVIDER_GEMINI: elif self._provider_type == settings.AI_PROVIDER_GEMINI:
+3 -3
View File
@@ -5,10 +5,10 @@ ocrdesktop_python_sources = files([
python3.install_sources( python3.install_sources(
ocrdesktop_python_sources, ocrdesktop_python_sources,
subdir: 'cthulhu/plugins/OCRDesktop' subdir: 'cthulhu/plugins/OCR'
) )
install_data( install_data(
'plugin.info', 'plugin.info',
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'OCRDesktop' install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'OCR'
) )
+7 -7
View File
@@ -223,7 +223,7 @@ class PluginManager(Plugin):
checkbox.connect("toggled", self._on_plugin_toggled, plugin_name) checkbox.connect("toggled", self._on_plugin_toggled, plugin_name)
# Create plugin info label # 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'): if plugin_info.get('description'):
info_text += f"\n{plugin_info['description']}" info_text += f"\n{plugin_info['description']}"
if plugin_info.get('version'): if plugin_info.get('version'):
@@ -257,17 +257,17 @@ class PluginManager(Plugin):
from cthulhu import plugin_system_manager from cthulhu import plugin_system_manager
# Use existing plugin manager to get plugins # Use existing plugin manager to get plugins
if hasattr(plugin_system_manager, '_manager') and plugin_system_manager._manager: manager = plugin_system_manager.getManager()
manager = plugin_system_manager._manager if manager:
manager.rescanPlugins() manager.rescanPlugins()
for plugin_info in manager.plugins: for plugin_info in manager.plugins:
plugin_name = plugin_info.get_module_name() plugin_name = plugin_info.get_module_name()
plugins[plugin_name] = { plugins[plugin_name] = {
'name': plugin_name, 'name': plugin_info.get_name(),
'description': getattr(plugin_info, 'description', ''), 'description': plugin_info.get_description(),
'version': getattr(plugin_info, 'version', ''), 'version': plugin_info.get_version(),
'path': getattr(plugin_info, 'module_dir', '') 'path': plugin_info.get_module_dir()
} }
else: else:
# Fallback: manually scan plugin directories # Fallback: manually scan plugin directories
+1
View File
@@ -210,6 +210,7 @@ CHAT_SPEAK_FOCUSED_CHANNEL = 2
# AI Assistant constants - simplified to providers that don't need complex API key management # AI Assistant constants - simplified to providers that don't need complex API key management
AI_PROVIDER_CLAUDE_CODE = "claude_code" AI_PROVIDER_CLAUDE_CODE = "claude_code"
AI_PROVIDER_CODEX = "codex"
AI_PROVIDER_GEMINI = "gemini" AI_PROVIDER_GEMINI = "gemini"
AI_PROVIDER_OLLAMA = "ollama" AI_PROVIDER_OLLAMA = "ollama"
+3 -2
View File
@@ -17,6 +17,7 @@ falls off the end, it may be added again later.
from __future__ import annotations from __future__ import annotations
from typing import Optional
import threading import threading
from collections import deque from collections import deque
@@ -76,7 +77,7 @@ def is_capture_paused() -> bool:
return _pauseCount > 0 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. """Add text to speech history if it's not already present.
Returns True if the item was added; False otherwise. Returns True if the item was added; False otherwise.
@@ -141,7 +142,7 @@ def get_items() -> list[str]:
return list(_historyItems) return list(_historyItems)
def remove(text: str | None) -> bool: def remove(text: Optional[str]) -> bool:
"""Remove an item from the history (if present).""" """Remove an item from the history (if present)."""
if text is None: if text is None:
return False return False