From 97f6cec0ed8951e4bda8bc7602acdad813fb25fc Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 4 Aug 2025 00:36:27 -0400 Subject: [PATCH] Improvements to action menu. --- src/cthulhu/action_presenter.py | 312 ++++++++++++------ src/cthulhu/cmdnames.py | 2 +- .../scripts/toolkits/Chromium/script.py | 12 + 3 files changed, 223 insertions(+), 103 deletions(-) diff --git a/src/cthulhu/action_presenter.py b/src/cthulhu/action_presenter.py index 5c69107..93d742b 100644 --- a/src/cthulhu/action_presenter.py +++ b/src/cthulhu/action_presenter.py @@ -23,7 +23,9 @@ # Fork of Orca Screen Reader (GNOME) # Original source: https://gitlab.gnome.org/GNOME/orca -"""Module for performing accessible actions via a menu""" +"""Module for performing accessible actions via a list""" + +from __future__ import annotations __id__ = "$Id$" __version__ = "$Revision$" @@ -31,156 +33,262 @@ __date__ = "$Date$" __copyright__ = "Copyright (c) 2023 Igalia, S.L." __license__ = "LGPL" +import time +from typing import TYPE_CHECKING + import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, Gtk +from gi.repository import Gdk, GLib, Gtk from . import cmdnames from . import debug +from . import guilabels from . import input_event from . import keybindings from . import messages from . import cthulhu from . import cthulhu_state +from . import script_manager from .ax_object import AXObject +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + from . import script + + +class ActionList(Gtk.Window): + """Window containing a list of accessible actions.""" + + def __init__(self, presenter: ActionPresenter): + super().__init__() + self._presenter = presenter + self._actions = [] + self._setup_gui() + + def _setup_gui(self) -> None: + """Sets up the GUI for the actions list.""" + + self.set_title(guilabels.KB_GROUP_ACTIONS) + self.set_modal(True) + self.set_decorated(False) + self.set_skip_taskbar_hint(True) + self.set_skip_pager_hint(True) + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + self.set_window_position(Gtk.WindowPosition.MOUSE) + self.set_default_size(400, 300) + + # Create scrolled window + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.add(scrolled) + + # Create list box + self._listbox = Gtk.ListBox() + self._listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + self._listbox.connect("row-activated", self._on_row_activated) + scrolled.add(self._listbox) + + # Connect key events + self.connect("key-press-event", self._on_key_press) + self.connect("destroy", self._on_destroy) + + self.show_all() + + def _on_key_press(self, widget, event) -> bool: + """Handles key press events.""" + + if event.keyval == Gdk.KEY_Escape: + self.destroy() + return True + return False + + def _on_row_activated(self, listbox, row) -> None: + """Handles row activation (Enter or double-click).""" + + if row is not None: + action_index = row.get_index() + if 0 <= action_index < len(self._actions): + action_name = self._actions[action_index] + self._presenter._perform_action(action_name) + + def _on_destroy(self, widget) -> None: + """Handles window destruction.""" + + GLib.idle_add(self._presenter._clear_gui_and_restore_focus) + + def populate_actions(self, actions: list[str]) -> None: + """Populates the list with accessible actions.""" + + self._actions = actions + + # Clear existing items + for child in self._listbox.get_children(): + self._listbox.remove(child) + + # Add actions to list + for action in actions: + label = Gtk.Label(label=action) + label.set_xalign(0.0) # Left align + label.set_margin_left(10) + label.set_margin_right(10) + label.set_margin_top(5) + label.set_margin_bottom(5) + + self._listbox.add(label) + + # Select first item + if actions: + first_row = self._listbox.get_row_at_index(0) + if first_row: + self._listbox.select_row(first_row) + first_row.grab_focus() + + self.show_all() + class ActionPresenter: - """Provides menu for performing accessible actions on an object.""" + """Provides a list for performing accessible actions on an object.""" def __init__(self): - self._handlers = self._setup_handlers() - self._bindings = self._setup_bindings() + self._handlers = self.get_handlers(True) + self._bindings = keybindings.KeyBindings() self._gui = None self._obj = None + self._window = None - def get_bindings(self): - """Returns the action-presenter keybindings.""" + def get_handlers(self, refresh: bool = False) -> dict: + """Returns a dictionary of input event handlers.""" - return self._bindings - - def get_handlers(self): - """Returns the action-presenter handlers.""" + if self._handlers is None or refresh: + msg = "ACTION PRESENTER: Getting input event handlers." + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._setup_handlers() return self._handlers - def _setup_handlers(self): - """Sets up and returns the action-presenter input event handlers.""" + def get_bindings(self, refresh: bool = False, is_desktop: bool = True) -> keybindings.KeyBindings: + """Returns keybindings for this presenter.""" - handlers = {} + if self._bindings is None or refresh: + msg = "ACTION PRESENTER: Getting key bindings." + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._setup_bindings() - handlers["show_actions_menu"] = \ + return self._bindings + + def _setup_handlers(self) -> None: + """Sets up input event handlers for this presenter.""" + + msg = "ACTION PRESENTER: Setting up handlers." + debug.printMessage(debug.LEVEL_INFO, msg, True) + + self._handlers = {} + + self._handlers["show_actions_list"] = \ input_event.InputEventHandler( - self.show_actions_menu, - cmdnames.SHOW_ACTIONS_MENU) + self.show_actions_list, + cmdnames.SHOW_ACTIONS_LIST) - return handlers + msg = "ACTION PRESENTER: Handlers set up." + debug.printMessage(debug.LEVEL_INFO, msg, True) - def _setup_bindings(self): - """Sets up and returns the action-presenter key bindings.""" + def _setup_bindings(self) -> None: + """Sets up key bindings for this presenter.""" - bindings = keybindings.KeyBindings() + msg = "ACTION PRESENTER: Setting up bindings." + debug.printMessage(debug.LEVEL_INFO, msg, True) - bindings.add( - keybindings.KeyBinding( + self._bindings = keybindings.KeyBindings() + self._bindings.add(keybindings.KeyBinding( "a", - keybindings.defaultModifierMask, + keybindings.DEFAULT_MODIFIER_MASK, keybindings.CTHULHU_SHIFT_MODIFIER_MASK, - self._handlers.get("show_actions_menu"))) + self._handlers["show_actions_list"])) - return bindings + msg = "ACTION PRESENTER: Bindings set up." + debug.printMessage(debug.LEVEL_INFO, msg, True) - def _perform_action(self, action): + def _restore_focus(self) -> None: + """Restores focus to the object associated with the actions list.""" + + tokens = ["ACTION PRESENTER: Restoring focus to", self._obj, "in", self._window] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + + reason = "Action Presenter list is being destroyed" + app = AXUtilities.get_application(self._obj) + script = script_manager.getManager().getScript(app, self._obj) + script_manager.getManager().setActiveScript(script, reason) + + # Update Cthulhu state + cthulhu_state.activeWindow = self._window + cthulhu_state.locusOfFocus = self._obj + + def _clear_gui_and_restore_focus(self) -> None: + """Clears the GUI reference and then restores focus.""" + + self._gui = None + self._restore_focus() + + def _perform_action(self, action: str) -> None: """Attempts to perform the named action.""" + if self._gui is None: + msg = "ACTION PRESENTER: _perform_action called when self._gui is None." + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + self._gui.hide() result = AXObject.do_named_action(self._obj, action) tokens = ["ACTION PRESENTER: Performing", action, "on", self._obj, "succeeded:", result] debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._gui = None - def show_actions_menu(self, script, event=None): - """Shows a menu with all the available accessible actions.""" + if not result: + full_message = messages.ACTION_NOT_PERFORMED % action + cthulhu.presentMessage(full_message) + + self._gui.destroy() + + def present_with_time(self, obj, start_time: float) -> bool: + """Presents accessible actions for the given object with timing.""" + + if self._gui is not None: + msg = "ACTION PRESENTER: GUI already exists." + debug.printMessage(debug.LEVEL_INFO, msg, True) + return False - obj = cthulhu.getActiveModeAndObjectOfInterest()[1] or cthulhu_state.locusOfFocus if obj is None: - full = messages.LOCATION_NOT_FOUND_FULL - brief = messages.LOCATION_NOT_FOUND_BRIEF - script.presentMessage(full, brief) - return True + obj = cthulhu_state.locusOfFocus - actions = {} - for i in range(AXObject.get_n_actions(obj)): - name = AXObject.get_action_name(obj, i) - description = AXObject.get_action_description(obj, i) - tokens = [f"ACTION PRESENTER: Action {i} on", obj, - f": '{name}' localized description: '{description}'"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - actions[name] = description or name + if obj is None: + full_message = messages.NO_ACCESSIBLE_ACTIONS + cthulhu.presentMessage(full_message) + return False - if not actions.items(): - name = AXObject.get_name(obj) or script.speechGenerator.getLocalizedRoleName(obj) - script.presentMessage(messages.NO_ACTIONS_FOUND_ON % name) - return True + actions = AXObject.get_action_names(obj) + if not actions: + full_message = messages.NO_ACCESSIBLE_ACTIONS + cthulhu.presentMessage(full_message) + return False self._obj = obj - self._gui = ActionMenu(actions, self._perform_action) - self._gui.show_gui() + self._window = cthulhu_state.activeWindow + + self._gui = ActionList(self) + self._gui.populate_actions(actions) + return True + def show_actions_list(self, script: script.Script = None, input_event: input_event.InputEvent = None) -> bool: + """Shows the accessible actions list.""" -class ActionMenu(Gtk.Menu): - """A simple Gtk.Menu containing a list of accessible actions.""" - - def __init__(self, actions, handler): - super().__init__() - self.connect("popped-up", self._on_popped_up) - self.on_option_selected = handler - for name, description in actions.items(): - menu_item = Gtk.MenuItem(label=description) - menu_item.connect("activate", self._on_activate, name) - self.append(menu_item) - - def _on_activate(self, widget, option): - """Handler for the 'activate' menuitem signal""" - - self.on_option_selected(option) - - def _on_popped_up(self, *args): - """Handler for the 'popped-up' menu signal""" - - msg = "ACTION PRESENTER: ActionMenu popped up" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - def show_gui(self): - """Shows the menu""" - - self.show_all() - display = Gdk.Display.get_default() - seat = display.get_default_seat() - device = seat.get_pointer() - screen, x, y = device.get_position() - - event = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS) - event.set_screen(screen) - event.set_device(device) - event.time = Gtk.get_current_event_time() - event.x = x - event.y = y - - rect = Gdk.Rectangle() - rect.x = x - rect.y = y - rect.width = 1 - rect.height = 1 - - window = Gdk.get_default_root_window() - self.popup_at_rect(window, rect, Gdk.Gravity.NORTH_WEST, Gdk.Gravity.NORTH_WEST, event) + start_time = time.time() + return self.present_with_time(input_event.get_object(), start_time) -_presenter = None -def getPresenter(): - global _presenter - if _presenter is None: - _presenter = ActionPresenter() - return _presenter +_presenter = ActionPresenter() + +def getPresenter() -> ActionPresenter: + """Returns the Action Presenter singleton.""" + return _presenter \ No newline at end of file diff --git a/src/cthulhu/cmdnames.py b/src/cthulhu/cmdnames.py index f62959c..34ee347 100644 --- a/src/cthulhu/cmdnames.py +++ b/src/cthulhu/cmdnames.py @@ -115,7 +115,7 @@ SHOW_FIND_GUI = _("Open the Find dialog") # Translators: Cthulhu has a command which presents a menu with accessible actions # that can be performed on the current object. This is the name of that command. -SHOW_ACTIONS_MENU = _("Show actions menu") +SHOW_ACTIONS_LIST = _("Show actions list") # Translators: the Cthulhu "Find" dialog allows a user to search for text in a # window and then move focus to that text. For example, they may want to find diff --git a/src/cthulhu/scripts/toolkits/Chromium/script.py b/src/cthulhu/scripts/toolkits/Chromium/script.py index 636c7ed..45ed7d1 100644 --- a/src/cthulhu/scripts/toolkits/Chromium/script.py +++ b/src/cthulhu/scripts/toolkits/Chromium/script.py @@ -33,6 +33,7 @@ __license__ = "LGPL" from cthulhu import debug from cthulhu import cthulhu +from cthulhu import cthulhu_state from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities from cthulhu.scripts import default @@ -316,6 +317,17 @@ class Script(web.Script): if super().onSelectedChanged(event): return + if event.detail1 and not self.utilities.inDocumentContent(event.source): + # The popup for an input with autocomplete on is a listbox child of a nameless frame. + # It lives outside of the document and also doesn't fire selection-changed events. + if listbox := AXObject.get_parent(event.source, AXUtilities.is_list_box): + parent = AXObject.get_parent(listbox) + if AXUtilities.is_frame(parent) and not AXObject.get_name(parent): + msg = "CHROMIUM: Event source believed to be in autocomplete popup" + debug.printMessage(debug.LEVEL_INFO, msg, True) + cthulhu_state.locusOfFocus = event.source + return + msg = "CHROMIUM: Passing along event to default script" debug.printMessage(debug.LEVEL_INFO, msg, True) default.Script.onSelectedChanged(self, event)