Improvements to action menu.
This commit is contained in:
		@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user