Improvements to action menu.
This commit is contained in:
@@ -23,7 +23,9 @@
|
|||||||
# Fork of Orca Screen Reader (GNOME)
|
# Fork of Orca Screen Reader (GNOME)
|
||||||
# Original source: https://gitlab.gnome.org/GNOME/orca
|
# 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$"
|
__id__ = "$Id$"
|
||||||
__version__ = "$Revision$"
|
__version__ = "$Revision$"
|
||||||
@@ -31,156 +33,262 @@ __date__ = "$Date$"
|
|||||||
__copyright__ = "Copyright (c) 2023 Igalia, S.L."
|
__copyright__ = "Copyright (c) 2023 Igalia, S.L."
|
||||||
__license__ = "LGPL"
|
__license__ = "LGPL"
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gdk", "3.0")
|
gi.require_version("Gdk", "3.0")
|
||||||
gi.require_version("Gtk", "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 cmdnames
|
||||||
from . import debug
|
from . import debug
|
||||||
|
from . import guilabels
|
||||||
from . import input_event
|
from . import input_event
|
||||||
from . import keybindings
|
from . import keybindings
|
||||||
from . import messages
|
from . import messages
|
||||||
from . import cthulhu
|
from . import cthulhu
|
||||||
from . import cthulhu_state
|
from . import cthulhu_state
|
||||||
|
from . import script_manager
|
||||||
from .ax_object import AXObject
|
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:
|
class ActionPresenter:
|
||||||
"""Provides menu for performing accessible actions on an object."""
|
"""Provides a list for performing accessible actions on an object."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._handlers = self._setup_handlers()
|
self._handlers = self.get_handlers(True)
|
||||||
self._bindings = self._setup_bindings()
|
self._bindings = keybindings.KeyBindings()
|
||||||
self._gui = None
|
self._gui = None
|
||||||
self._obj = None
|
self._obj = None
|
||||||
|
self._window = None
|
||||||
|
|
||||||
def get_bindings(self):
|
def get_handlers(self, refresh: bool = False) -> dict:
|
||||||
"""Returns the action-presenter keybindings."""
|
"""Returns a dictionary of input event handlers."""
|
||||||
|
|
||||||
return self._bindings
|
if self._handlers is None or refresh:
|
||||||
|
msg = "ACTION PRESENTER: Getting input event handlers."
|
||||||
def get_handlers(self):
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
"""Returns the action-presenter handlers."""
|
self._setup_handlers()
|
||||||
|
|
||||||
return self._handlers
|
return self._handlers
|
||||||
|
|
||||||
def _setup_handlers(self):
|
def get_bindings(self, refresh: bool = False, is_desktop: bool = True) -> keybindings.KeyBindings:
|
||||||
"""Sets up and returns the action-presenter input event handlers."""
|
"""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(
|
input_event.InputEventHandler(
|
||||||
self.show_actions_menu,
|
self.show_actions_list,
|
||||||
cmdnames.SHOW_ACTIONS_MENU)
|
cmdnames.SHOW_ACTIONS_LIST)
|
||||||
|
|
||||||
return handlers
|
msg = "ACTION PRESENTER: Handlers set up."
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
def _setup_bindings(self):
|
def _setup_bindings(self) -> None:
|
||||||
"""Sets up and returns the action-presenter key bindings."""
|
"""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(
|
self._bindings = keybindings.KeyBindings()
|
||||||
keybindings.KeyBinding(
|
self._bindings.add(keybindings.KeyBinding(
|
||||||
"a",
|
"a",
|
||||||
keybindings.defaultModifierMask,
|
keybindings.DEFAULT_MODIFIER_MASK,
|
||||||
keybindings.CTHULHU_SHIFT_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."""
|
"""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)
|
result = AXObject.do_named_action(self._obj, action)
|
||||||
tokens = ["ACTION PRESENTER: Performing", action, "on", self._obj, "succeeded:", result]
|
tokens = ["ACTION PRESENTER: Performing", action, "on", self._obj, "succeeded:", result]
|
||||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
self._gui = None
|
|
||||||
|
|
||||||
def show_actions_menu(self, script, event=None):
|
if not result:
|
||||||
"""Shows a menu with all the available accessible actions."""
|
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:
|
if obj is None:
|
||||||
full = messages.LOCATION_NOT_FOUND_FULL
|
obj = cthulhu_state.locusOfFocus
|
||||||
brief = messages.LOCATION_NOT_FOUND_BRIEF
|
|
||||||
script.presentMessage(full, brief)
|
|
||||||
return True
|
|
||||||
|
|
||||||
actions = {}
|
if obj is None:
|
||||||
for i in range(AXObject.get_n_actions(obj)):
|
full_message = messages.NO_ACCESSIBLE_ACTIONS
|
||||||
name = AXObject.get_action_name(obj, i)
|
cthulhu.presentMessage(full_message)
|
||||||
description = AXObject.get_action_description(obj, i)
|
return False
|
||||||
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 not actions.items():
|
actions = AXObject.get_action_names(obj)
|
||||||
name = AXObject.get_name(obj) or script.speechGenerator.getLocalizedRoleName(obj)
|
if not actions:
|
||||||
script.presentMessage(messages.NO_ACTIONS_FOUND_ON % name)
|
full_message = messages.NO_ACCESSIBLE_ACTIONS
|
||||||
return True
|
cthulhu.presentMessage(full_message)
|
||||||
|
return False
|
||||||
|
|
||||||
self._obj = obj
|
self._obj = obj
|
||||||
self._gui = ActionMenu(actions, self._perform_action)
|
self._window = cthulhu_state.activeWindow
|
||||||
self._gui.show_gui()
|
|
||||||
|
self._gui = ActionList(self)
|
||||||
|
self._gui.populate_actions(actions)
|
||||||
|
|
||||||
return True
|
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):
|
start_time = time.time()
|
||||||
"""A simple Gtk.Menu containing a list of accessible actions."""
|
return self.present_with_time(input_event.get_object(), start_time)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
_presenter = None
|
_presenter = ActionPresenter()
|
||||||
def getPresenter():
|
|
||||||
global _presenter
|
def getPresenter() -> ActionPresenter:
|
||||||
if _presenter is None:
|
"""Returns the Action Presenter singleton."""
|
||||||
_presenter = ActionPresenter()
|
|
||||||
return _presenter
|
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
|
# 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.
|
# 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
|
# 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
|
# 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 debug
|
||||||
from cthulhu import cthulhu
|
from cthulhu import cthulhu
|
||||||
|
from cthulhu import cthulhu_state
|
||||||
from cthulhu.ax_object import AXObject
|
from cthulhu.ax_object import AXObject
|
||||||
from cthulhu.ax_utilities import AXUtilities
|
from cthulhu.ax_utilities import AXUtilities
|
||||||
from cthulhu.scripts import default
|
from cthulhu.scripts import default
|
||||||
@@ -316,6 +317,17 @@ class Script(web.Script):
|
|||||||
if super().onSelectedChanged(event):
|
if super().onSelectedChanged(event):
|
||||||
return
|
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"
|
msg = "CHROMIUM: Passing along event to default script"
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
default.Script.onSelectedChanged(self, event)
|
default.Script.onSelectedChanged(self, event)
|
||||||
|
Reference in New Issue
Block a user