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