Improvements to action menu.

This commit is contained in:
Storm Dragon
2025-08-04 00:36:27 -04:00
parent ecb1ae4fe5
commit 97f6cec0ed
3 changed files with 223 additions and 103 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)