%PDF- %PDF-
| Direktori : /lib/python3/dist-packages/orca/ |
| Current File : //lib/python3/dist-packages/orca/liveregions.py |
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
import bisect
import copy
import time
from gi.repository import GLib
from . import cmdnames
from . import debug
from . import focus_manager
from . import keybindings
from . import messages
from . import input_event
from . import settings_manager
from .ax_collection import AXCollection
from .ax_object import AXObject
from .ax_text import AXText
# define 'live' property types
LIVE_OFF = -1
LIVE_NONE = 0
LIVE_POLITE = 1
LIVE_ASSERTIVE = 2
LIVE_RUDE = 3
# Seconds a message is held in the queue before it is discarded
MSG_KEEPALIVE_TIME = 45 # in seconds
# The number of messages that are cached and can later be reviewed via
# LiveRegionManager.reviewLiveAnnouncement.
CACHE_SIZE = 9 # corresponds to one of nine key bindings
class PriorityQueue:
""" This class represents a thread **UNSAFE** priority queue where priority
is determined by the given integer priority. The entries are also
maintained in chronological order.
TODO: experiment with Queue.Queue to make thread safe
"""
def __init__(self):
self.queue = []
def enqueue(self, data, priority, obj):
""" Add a new element to the queue according to 1) priority and
2) timestamp. """
bisect.insort_left(self.queue, (priority, time.time(), data, obj))
def dequeue(self):
"""get the highest priority element from the queue. """
return self.queue.pop(0)
def clear(self):
""" Clear the queue """
self.queue = []
def purgeByKeepAlive(self):
""" Purge items from the queue that are older than the keepalive time """
currenttime = time.time()
def myfilter(item):
return item and item[1] + MSG_KEEPALIVE_TIME > currenttime
self.queue = list(filter(myfilter, self.queue))
def purgeByPriority(self, priority):
""" Purge items from the queue that have a lower than or equal priority
than the given argument """
def myfilter(item):
return item and item[0] > priority
self.queue = list(filter(myfilter, self.queue))
def __len__(self):
""" Return the length of the queue """
return len(self.queue)
class LiveRegionManager:
def __init__(self, script):
self._script = script
# message priority queue
self.msg_queue = PriorityQueue()
# To make it possible for focus mode to suspend commands without changing
# the user's preferred setting.
self._suspended = False
self._handlers = self.get_handlers(True)
self._bindings = keybindings.KeyBindings()
# This is temporary.
self.functions = [self.advancePoliteness,
self.setLivePolitenessOff,
self.toggleMonitoring,
self.reviewLiveAnnouncement]
# Message cache. Used to store up to 9 previous messages so user can
# review if desired.
self.msg_cache = []
# User overrides for politeness settings.
self._politenessOverrides = None
self._restoreOverrides = None
# last live obj to be announced
self.lastliveobj = None
# Used to track whether a user wants to monitor all live regions
# Not to be confused with the global Gecko.liveRegionsOn which
# completely turns off live region support. This one is based on
# a user control by changing politeness levels to LIVE_OFF or back
# to the bookmark or markup politeness value.
self.monitoring = True
# Set up politeness level overrides and subscribe to bookmarks
# for load and save user events.
# We are initialized after bookmarks so call the load handler once
# to get initialized.
#
self.bookmarkLoadHandler()
script.bookmarks.addSaveObserver(self.bookmarkSaveHandler)
script.bookmarks.addLoadObserver(self.bookmarkLoadHandler)
def get_bindings(self, refresh=False, is_desktop=True):
"""Returns the live-region-manager keybindings."""
if refresh:
msg = "LIVE REGION MANAGER: Refreshing bindings."
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._setup_bindings()
elif self._bindings.isEmpty():
self._setup_bindings()
return self._bindings
def get_handlers(self, refresh=False):
"""Returns the live-region-manager handlers."""
if refresh:
msg = "LIVE REGION MANAGER: Refreshing handlers."
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._setup_handlers()
return self._handlers
def _setup_handlers(self):
"""Sets up the live-region-manager input event handlers."""
self._handlers = {}
self._handlers["advanceLivePoliteness"] = \
input_event.InputEventHandler(
self.advancePoliteness,
cmdnames.LIVE_REGIONS_ADVANCE_POLITENESS,
enabled = not self._suspended)
self._handlers["setLivePolitenessOff"] = \
input_event.InputEventHandler(
self.setLivePolitenessOff,
cmdnames.LIVE_REGIONS_SET_POLITENESS_OFF,
enabled = not self._suspended)
self._handlers["monitorLiveRegions"] = \
input_event.InputEventHandler(
self.toggleMonitoring,
cmdnames.LIVE_REGIONS_MONITOR,
enabled = not self._suspended)
self._handlers["reviewLiveAnnouncement"] = \
input_event.InputEventHandler(
self.reviewLiveAnnouncement,
cmdnames.LIVE_REGIONS_REVIEW,
enabled = not self._suspended)
msg = f"LIVE REGION MANAGER: Handlers set up. Suspended: {self._suspended}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _setup_bindings(self):
"""Sets up the live-region-manager key bindings."""
self._bindings = keybindings.KeyBindings()
self._bindings.add(
keybindings.KeyBinding(
"backslash",
keybindings.defaultModifierMask,
keybindings.NO_MODIFIER_MASK,
self._handlers.get("advanceLivePoliteness"),
1,
not self._suspended))
self._bindings.add(
keybindings.KeyBinding(
"backslash",
keybindings.defaultModifierMask,
keybindings.SHIFT_MODIFIER_MASK,
self._handlers.get("setLivePolitenessOff"),
1,
not self._suspended))
self._bindings.add(
keybindings.KeyBinding(
"backslash",
keybindings.defaultModifierMask,
keybindings.ORCA_SHIFT_MODIFIER_MASK,
self._handlers.get("monitorLiveRegions"),
1,
not self._suspended))
for key in ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9"]:
self._bindings.add(
keybindings.KeyBinding(
key,
keybindings.defaultModifierMask,
keybindings.ORCA_MODIFIER_MASK,
self._handlers.get("reviewLiveAnnouncement"),
1,
not self._suspended))
# This pulls in the user's overrides to alternative keys.
self._bindings = settings_manager.getManager().overrideKeyBindings(
self._handlers, self._bindings, False)
msg = f"LIVE REGION MANAGER: Bindings set up. Suspended: {self._suspended}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
tokens = [self._bindings]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
def refresh_bindings_and_grabs(self, script, reason=""):
"""Refreshes live region bindings and grabs for script."""
msg = "LIVE REGION MANAGER: Refreshing bindings and grabs"
if reason:
msg += f": {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
for binding in self._bindings.keyBindings:
script.keyBindings.remove(binding, includeGrabs=True)
self._handlers = self.get_handlers(True)
self._bindings = self.get_bindings(True)
for binding in self._bindings.keyBindings:
script.keyBindings.add(binding, includeGrabs=not self._suspended)
def suspend_commands(self, script, suspended, reason=""):
"""Suspends live region commands independent of the enabled setting."""
if suspended == self._suspended:
return
msg = f"LIVE REGION MANAGER: Commands suspended: {suspended}"
if reason:
msg += f": {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._suspended = suspended
self.refresh_bindings_and_grabs(script, f"Suspended changed to {suspended}")
def reset(self):
# First we will purge our politeness override dictionary of LIVE_NONE
# objects that are not registered for this page
newpoliteness = {}
currenturi = self._script.bookmarks.getURIKey()
for key, value in self._politenessOverrides.items():
if key[0] == currenturi or value != LIVE_NONE:
newpoliteness[key] = value
self._politenessOverrides = newpoliteness
def bookmarkSaveHandler(self):
"""Bookmark save callback"""
self._script.bookmarks.saveBookmarksToDisk(self._politenessOverrides,
filename='politeness')
def bookmarkLoadHandler(self):
"""Bookmark load callback"""
# readBookmarksFromDisk() returns None on error. Just initialize to an
# empty dictionary if this is the case.
self._politenessOverrides = \
self._script.bookmarks.readBookmarksFromDisk(filename='politeness') \
or {}
def handleEvent(self, event):
"""Main live region event handler"""
politeness = self._getLiveType(event.source)
if politeness == LIVE_OFF:
return
if politeness == LIVE_NONE:
# All the 'registered' LIVE_NONE objects will be set to off
# if not monitoring. We will ignore LIVE_NONE objects that
# arrive after the user switches off monitoring.
if not self.monitoring:
return
elif politeness == LIVE_POLITE:
# Nothing to do for now
pass
elif politeness == LIVE_ASSERTIVE:
self.msg_queue.purgeByPriority(LIVE_POLITE)
elif politeness == LIVE_RUDE:
self.msg_queue.purgeByPriority(LIVE_ASSERTIVE)
message = self._getMessage(event)
if message:
if len(self.msg_queue) == 0:
GLib.timeout_add(100, self.pumpMessages)
self.msg_queue.enqueue(message, politeness, event.source)
def pumpMessages(self):
""" Main gobject callback for live region support. Handles both
purging the message queue and outputting any queued messages that
were queued up in the handleEvent() method.
"""
if len(self.msg_queue) > 0:
debug.printMessage(debug.eventDebugLevel, "\nvvvvv PRESENT LIVE REGION MESSAGE vvvvv")
self.msg_queue.purgeByKeepAlive()
politeness, timestamp, message, obj = self.msg_queue.dequeue()
# Form output message. No need to repeat labels and content.
# TODO: really needs to be tested in real life cases. Perhaps
# a verbosity setting?
if message['labels'] == message['content']:
utts = message['content']
else:
utts = message['labels'] + message['content']
if self.monitoring:
self._script.presentMessage(utts)
else:
msg = "INFO: Not presenting message because monitoring is off"
debug.printMessage(debug.LEVEL_INFO, msg, True)
# set the last live obj to be announced
self.lastliveobj = obj
# cache our message
self._cacheMessage(utts)
# We still want to maintain our queue if we are not monitoring
if not self.monitoring:
self.msg_queue.purgeByKeepAlive()
msg = f'LIVE REGIONS: messages in queue: {len(self.msg_queue)}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
debug.printMessage(debug.eventDebugLevel, "^^^^^ PRESENT LIVE REGION MESSAGE ^^^^^\n")
# See you again soon, stay in event loop if we still have messages.
return len(self.msg_queue) > 0
def getLiveNoneObjects(self):
"""Return the live objects that are registered and have a politeness
of LIVE_NONE. """
retval = []
currenturi = self._script.bookmarks.getURIKey()
for uri, objectid in self._politenessOverrides:
if uri == currenturi and isinstance(objectid, tuple):
retval.append(self._script.bookmarks.pathToObj(objectid))
return retval
def advancePoliteness(self, script, inputEvent):
"""Advance the politeness level of the given object"""
if not settings_manager.getManager().getSetting('inferLiveRegions'):
self._script.presentMessage(messages.LIVE_REGIONS_OFF)
return
obj = focus_manager.getManager().get_locus_of_focus()
objectid = self._getObjectId(obj)
uri = self._script.bookmarks.getURIKey()
try:
# The current priority is either a previous override or the
# live property. If an exception is thrown, an override for
# this object has never occurred and the object does not have
# live markup. In either case, set the override to LIVE_NONE.
cur_priority = self._politenessOverrides[(uri, objectid)]
except KeyError:
cur_priority = self._liveStringToType(obj)
if cur_priority == LIVE_OFF or cur_priority == LIVE_NONE:
self._politenessOverrides[(uri, objectid)] = LIVE_POLITE
self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_POLITE)
elif cur_priority == LIVE_POLITE:
self._politenessOverrides[(uri, objectid)] = LIVE_ASSERTIVE
self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_ASSERTIVE)
elif cur_priority == LIVE_ASSERTIVE:
self._politenessOverrides[(uri, objectid)] = LIVE_RUDE
self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_RUDE)
elif cur_priority == LIVE_RUDE:
self._politenessOverrides[(uri, objectid)] = LIVE_OFF
self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_OFF)
def goLastLiveRegion(self):
"""Move the caret to the last announced live region and speak the
contents of that object"""
if self.lastliveobj:
self._script.utilities.setCaretPosition(self.lastliveobj, 0)
self._script.speakContents(self._script.utilities.getObjectContentsAtOffset(
self.lastliveobj, 0))
def reviewLiveAnnouncement(self, script, inputEvent):
"""Speak the given number cached message"""
msgnum = int(inputEvent.event_string[1:])
if not settings_manager.getManager().getSetting('inferLiveRegions'):
self._script.presentMessage(messages.LIVE_REGIONS_OFF)
return
if msgnum > len(self.msg_cache):
self._script.presentMessage(messages.LIVE_REGIONS_NO_MESSAGE)
else:
self._script.presentMessage(self.msg_cache[-msgnum])
def setLivePolitenessOff(self, script, inputEvent):
"""User toggle to set all live regions to LIVE_OFF or back to their
original politeness."""
if not settings_manager.getManager().getSetting('inferLiveRegions'):
self._script.presentMessage(messages.LIVE_REGIONS_OFF)
return
# start at the document frame
docframe = self._script.utilities.documentFrame()
# get the URI of the page. It is used as a partial key.
uri = self._script.bookmarks.getURIKey()
# The user is currently monitoring live regions but now wants to
# change all live region politeness on page to LIVE_OFF
if self.monitoring:
self._script.presentMessage(messages.LIVE_REGIONS_ALL_OFF)
self.msg_queue.clear()
# First we'll save off a copy for quick restoration
self._restoreOverrides = copy.copy(self._politenessOverrides)
# Set all politeness overrides to LIVE_OFF.
for override in self._politenessOverrides.keys():
self._politenessOverrides[override] = LIVE_OFF
# look through all the objects on the page and set/add to
# politeness overrides. This only adds live regions with good
# markup.
matches = self.getAllLiveRegions(docframe)
for match in matches:
objectid = self._getObjectId(match)
self._politenessOverrides[(uri, objectid)] = LIVE_OFF
# Toggle our flag
self.monitoring = False
# The user wants to restore politeness levels
else:
for key, value in self._restoreOverrides.items():
self._politenessOverrides[key] = value
self._script.presentMessage(messages.LIVE_REGIONS_ALL_RESTORED)
# Toggle our flag
self.monitoring = True
def getAllLiveRegions(self, document):
attrs = []
levels = ["off", "polite", "assertive"]
for level in levels:
attrs.append('container-live:' + level)
rule = AXCollection.create_match_rule(attributes=attrs)
result = AXCollection.get_all_matches(document, rule)
msg = f'LIVE REGIONS: {len(result)} regions found'
debug.printMessage(debug.LEVEL_INFO, msg, True)
return result
def generateLiveRegionDescription(self, obj, **args):
"""Used in conjunction with whereAmI to output description and
politeness of the given live region object"""
objectid = self._getObjectId(obj)
uri = self._script.bookmarks.getURIKey()
results = []
# get the description if there is one.
relation = AXObject.get_relation(obj, Atspi.RelationType.DESCRIBED_BY)
if relation:
targetobj = relation.get_target(0)
# We will add on descriptions if they don't duplicate
# what's already in the object's description.
# See http://bugzilla.gnome.org/show_bug.cgi?id=568467
# for more information.
description = AXText.get_all_text(targetobj)
if description.strip() != AXObject.get_description(obj).strip():
results.append(description)
# get the politeness level as a string
try:
livepriority = self._politenessOverrides[(uri, objectid)]
liveprioritystr = self._liveTypeToString(livepriority)
except KeyError:
liveprioritystr = 'none'
# We will only output useful information
#
if results or liveprioritystr != 'none':
results.append(messages.LIVE_REGIONS_LEVEL % liveprioritystr)
return results
def _findContainer(self, obj):
def isContainer(x):
return self._getAttrDictionary(x).get('atomic')
if isContainer(obj):
return obj
return AXObject.find_ancestor(obj, isContainer)
def _getMessage(self, event):
"""Gets the message associated with a given live event."""
attrs = self._getAttrDictionary(event.source)
content = ""
labels = ""
# A message is divided into two parts: labels and content. We
# will first try to get the content. If there is None,
# assume it is an invalid message and return None
if event.type.startswith('object:children-changed:add'):
if attrs.get('container-atomic') == 'true':
content = self._script.utilities.expandEOCs(event.source)
else:
content = self._script.utilities.expandEOCs(event.any_data)
elif event.type.startswith('object:text-changed:insert'):
if attrs.get('container-atomic') != 'true':
if "\ufffc" not in event.any_data:
content = event.any_data
else:
content = self._script.utilities.expandEOCs(
event.source, event.detail1, event.detail1 + event.detail2)
else:
container = self._findContainer(event.source)
content = self._script.utilities.expandEOCs(container)
if not content:
return None
content = content.strip()
# Proper live regions typically come with proper aria labels. These
# labels are typically exposed as names. Failing that, descriptions.
# Looking for actual labels seems a non-performant waste of time.
name = (AXObject.get_name(event.source) or AXObject.get_description(event.source)).strip()
if name and name != content:
labels = name
# instantly send out notify messages
if attrs.get('channel') == 'notify':
utts = labels + content
self._script.presentationInterrupt()
self._script.presentMessage(utts)
return None
return {'content':[content], 'labels':[labels]}
def flushMessages(self):
self.msg_queue.clear()
def _cacheMessage(self, utts):
"""Cache a message in our cache list of length CACHE_SIZE"""
self.msg_cache.append(utts)
if len(self.msg_cache) > CACHE_SIZE:
self.msg_cache.pop(0)
def _getLiveType(self, obj):
"""Returns the live politeness setting for a given object. Also,
registers LIVE_NONE objects in politeness overrides when monitoring."""
objectid = self._getObjectId(obj)
uri = self._script.bookmarks.getURIKey()
if (uri, objectid) in self._politenessOverrides:
# look to see if there is a user politeness override
return self._politenessOverrides[(uri, objectid)]
else:
livetype = self._liveStringToType(obj)
# We'll save off a reference to LIVE_NONE if we are monitoring
# to give the user a chance to change the politeness level. It
# is done here for performance sake (objectid, uri are expensive)
if livetype == LIVE_NONE and self.monitoring:
self._politenessOverrides[(uri, objectid)] = livetype
return livetype
def _getObjectId(self, obj):
"""Returns the HTML 'id' or a path to the object is an HTML id is
unavailable"""
attrs = self._getAttrDictionary(obj)
if attrs is None:
return self._getPath(obj)
try:
return attrs['id']
except KeyError:
return self._getPath(obj)
def _liveStringToType(self, obj, attributes=None):
"""Returns the politeness enum for a given object"""
attrs = attributes or self._getAttrDictionary(obj)
try:
if attrs['container-live'] == 'off':
return LIVE_OFF
elif attrs['container-live'] == 'polite':
return LIVE_POLITE
elif attrs['container-live'] == 'assertive':
return LIVE_ASSERTIVE
elif attrs['container-live'] == 'rude':
return LIVE_RUDE
else:
return LIVE_NONE
except KeyError:
return LIVE_NONE
def _liveTypeToString(self, politeness):
"""Returns the politeness level as a string given a politeness enum"""
if politeness == LIVE_OFF:
return 'off'
elif politeness == LIVE_POLITE:
return 'polite'
elif politeness == LIVE_ASSERTIVE:
return 'assertive'
elif politeness == LIVE_RUDE:
return 'rude'
elif politeness == LIVE_NONE:
return 'none'
else:
return 'unknown'
def _getAttrDictionary(self, obj):
return AXObject.get_attributes_dict(obj)
def _getPath(self, obj):
""" Returns, as a tuple of integers, the path from the given object
to the document frame."""
docframe = self._script.utilities.documentFrame()
path = []
while True:
if obj == docframe or AXObject.get_parent(obj) is None:
path.reverse()
return tuple(path)
path.append(AXObject.get_index_in_parent(obj))
obj = AXObject.get_parent(obj)
def toggleMonitoring(self, script, inputEvent):
if not settings_manager.getManager().getSetting('inferLiveRegions'):
settings_manager.getManager().setSetting('inferLiveRegions', True)
self._script.presentMessage(messages.LIVE_REGIONS_MONITORING_ON)
else:
settings_manager.getManager().setSetting('inferLiveRegions', False)
self.flushMessages()
self._script.presentMessage(messages.LIVE_REGIONS_MONITORING_OFF)