diff --git a/addon/globalPlugins/webAccess/__init__.py b/addon/globalPlugins/webAccess/__init__.py index af3c262b..100e3f4c 100644 --- a/addon/globalPlugins/webAccess/__init__.py +++ b/addon/globalPlugins/webAccess/__init__.py @@ -56,58 +56,48 @@ import os import re -import core import wx from NVDAObjects.IAccessible import IAccessible from NVDAObjects.IAccessible.MSHTML import MSHTML from NVDAObjects.IAccessible.ia2Web import Ia2Web from NVDAObjects.IAccessible.mozilla import Mozilla -from scriptHandler import script import addonHandler import api import baseObject from buildVersion import version_detailed as NVDA_VERSION import controlTypes +import core import eventHandler import globalPluginHandler import gui from logHandler import log -import scriptHandler -import speech +from scriptHandler import script import ui import virtualBuffers -from . import nodeHandler -from . import overlay -from . import webAppLib -from .webAppLib import * +from . import overlay, webModuleHandler +from .webAppLib import playWebAccessSound, sleep from .webAppScheduler import WebAppScheduler -from . import webModuleHandler addonHandler.initTranslation() -TRACE = lambda *args, **kwargs: None # @UnusedVariable -#TRACE = log.info - SCRIPT_CATEGORY = "WebAccess" - -# -# defines sound directory -# - SOUND_DIRECTORY = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "..", "sounds") +SUPPORTED_HOSTS = ['brave', 'firefox', 'chrome', 'java', 'iexplore', 'microsoftedgecp', 'msedge'] +TRACE = lambda *args, **kwargs: None # @UnusedVariable +#TRACE = log.info +# Currently dead code, but will likely be revived for issue #17. +activeWebModule = None -supportedWebAppHosts = ['brave', 'firefox', 'chrome', 'java', 'iexplore', 'microsoftedgecp', 'msedge'] - -activeWebApp = None webAccessEnabled = True scheduler = None + class DefaultBrowserScripts(baseObject.ScriptableObject): def __init__(self, warningMessage): @@ -118,7 +108,7 @@ def __init__(self, warningMessage): self.__class__.__gestures["kb:control+shift+%s" % character] = "notAssigned" def script_notAssigned(self, gesture): # @UnusedVariable - playWebAppSound("keyError") + playWebAccessSound("keyError") sleep(0.2) ui.message(self.warningMessage) @@ -202,6 +192,7 @@ def chooseNVDAObjectOverlayClasses(self, obj, clsList): if cls in clsList ): if obj.role in ( + controlTypes.ROLE_APPLICATION, controlTypes.ROLE_DIALOG, controlTypes.ROLE_DOCUMENT, ): @@ -220,27 +211,36 @@ def script_showWebAccessGui(self, gesture): # @UnusedVariable def showWebAccessGui(self): obj = api.getFocusObject() - if obj is None or obj.appModule is None: - # Translators: Error message when attempting to show the Web Access GUI. - ui.message(_("The current object does not support Web Access.")) - return - if not supportWebApp(obj): + if not canHaveWebAccessSupport(obj): # Translators: Error message when attempting to show the Web Access GUI. ui.message(_("You must be in a web browser to use Web Access.")) return - if obj.treeInterceptor is None or not isinstance(obj, overlay.WebAccessObject): + if not isinstance(obj.treeInterceptor, overlay.WebAccessBmdti): # Translators: Error message when attempting to show the Web Access GUI. ui.message(_("You must be on the web page to use Web Access.")) return - from .gui import menu - context = {} - context["webAccess"] = self - context["focusObject"] = obj - webModule = obj.webAccess.webModule - if webModule is not None: + context = { + "webAccess": self, + "focusObject": obj, + } + # Use the helper of the TreeInterceptor to get the WebModule at caret + # rather than the one where the focus is stuck. + webModule = obj.treeInterceptor.webAccess.webModule + if webModule: context["webModule"] = webModule context["pageTitle"] = webModule.pageTitle + mgr = webModule.ruleManager + context["result"] = mgr.getResultAtCaret() + stack = [] + while True: + stack.append(webModule) + try: + webModule = webModule.ruleManager.parentRuleManager.webModule + except AttributeError: + break + if len(stack) > 1: + context["webModuleStackAtCaret"] = stack menu.show(context) @script( @@ -262,125 +262,6 @@ def script_showWebAccessSettings(self, gesture): # @UnusedVariable # Now part of the public API as of NVDA PR #15121 gui.mainFrame.popupSettingsDialog(WebAccessSettingsDialog) - @script( - # Translators: Input help mode message for a command. - description=_("Toggle debug mode."), - category=SCRIPT_CATEGORY, - gesture="kb:nvda+control+shift+w" - ) - def script_debugWebModule(self, gesture): # @UnusedVariable - global activeWebApp - focus = api.getFocusObject() - if \ - activeWebApp is None \ - and not hasattr(focus, "_webApp") \ - and not hasattr(focus, "treeInterceptor") \ - and not hasattr(focus.treeInterceptor, "_webApp") \ - and not hasattr(focus.treeInterceptor, "nodeManager"): - ui.message("Pas de WebModule actif") - return - - diverged = False - focusModule = None - treeModule = None - msg = "Divergence :" - msg += os.linesep - msg += "activeWebApp = {webModule}".format( - webModule=activeWebApp.storeRef - if hasattr(activeWebApp, "storeRef") - else activeWebApp - ) - if activeWebApp is not None: - msg += " ({id})".format(id=id(activeWebApp)) - if not hasattr(focus, "_webApp"): - msg += os.linesep - msg += "focus._webApp absent" - else: - focusModule = focus._webApp - if activeWebApp is not focusModule: - diverged = True - msg += os.linesep - msg += "focus._webApp = {webModule}".format( - webModule= - focusModule.storeRef - if hasattr(focusModule, "storeRef") - else focusModule - ) - if focusModule is not None: - msg += " ({id})".format(id=id(focusModule)) - if not hasattr(focus, "treeInterceptor"): - diverged = True - msg += os.linesep - msg += "focus.treeInterceptor absent" - else: - if focus.treeInterceptor is None: - diverged = True - msg += os.linesep - msg += "focus.treeInterceptor None" -# if not hasattr(focusModule, "treeInterceptor"): -# diverged = True -# msg += os.linesep -# msg += u"focus._webApp.treeInterceptor absent" -# elif focusModule.treeInterceptor is None: -# diverged = True -# msg += os.linesep -# msg += u"focus._webApp.treeInterceptor None" -# elif focus.treeInterceptor is not focusModule.treeInterceptor: -# diverged = True -# msg += os.linesep -# msg += u"TreeInterceptors différents" - if hasattr(focus.treeInterceptor, "_webApp"): - treeModule = focus.treeInterceptor._webApp - if \ - treeModule is not focusModule \ - or treeModule is not activeWebApp: - diverged = True - msg += os.linesep - msg += "treeInterceptor._webApp = {webModule}".format( - webModule= - treeModule.storeRef - if hasattr(treeModule, "storeRef") - else treeModule - ) - if treeModule is not None: - msg += " ({id})".format(id=id(treeModule)) - if hasattr(focus.treeInterceptor, "nodeManager"): - if focusModule is None: - diverged = True - msg += "treeInterceptor.nodeManager " - if focus.treeInterceptor.nodeManager is None: - msg += "est None" - else: - msg += "n'est pas None" - elif \ - focusModule.ruleManager.nodeManager is not \ - focus.treeInterceptor.nodeManager: - diverged = True - msg += os.linesep - msg += "NodeManagers différents" - elif focusModule.ruleManager.nodeManager is None: - msg += os.linesep - msg += "NodeManagers None" - - - allMsg = "" - - if not diverged: - try: - from six import text_type - except ImportError: - # NVDA version < 2018.3 - text_type = str - msg = text_type(focusModule.storeRef) - speech.speakMessage(msg) - allMsg += msg + os.linesep - - treeInterceptor = html.getTreeInterceptor() - msg = "nodeManager %d caractères, %s, %s" % (treeInterceptor.nodeManager.treeInterceptorSize, treeInterceptor.nodeManager.isReady, treeInterceptor.nodeManager.mainNode is not None) - speech.speakMessage(msg) - allMsg += msg + os.linesep - api.copyToClip(allMsg) - @script( # Translators: Input help mode message for show Web Access menu command. description=_("Show the element description."), @@ -417,23 +298,23 @@ def script_toggleWebAccessSupport(self, gesture): # @UnusedVariable ui.message(_("Web Access support enabled.")) # FR: u"Support Web Access activé." -def getActiveWebApp(): - global activeWebApp - return activeWebApp +def getActiveWebModule(): + global activeWebModule + return activeWebModule -def webAppLoseFocus(obj): - global activeWebApp - if activeWebApp is not None: - sendWebAppEvent('webApp_loseFocus', obj, activeWebApp) - activeWebApp = None - #log.info("Losing webApp focus for object:\n%s\n" % ("\n".join(obj.devInfo))) +def webModuleLoseFocus(obj): + global activeWebModule + if activeWebModule is not None: + sendWebModuleEvent('webModule_loseFocus', obj, activeWebModule) + activeWebModule = None + #log.info("Losing webModule focus for object:\n%s\n" % ("\n".join(obj.devInfo))) -def supportWebApp(obj): +def canHaveWebAccessSupport(obj): if obj is None or obj.appModule is None: - return None - return obj.appModule.appName in supportedWebAppHosts + return False + return obj.appModule.appName in SUPPORTED_HOSTS def VirtualBuffer_changeNotify(cls, rootDocHandle, rootID): @@ -448,10 +329,10 @@ def virtualBuffer_loadBufferDone(self, success=True): virtualBuffer_loadBufferDone.super.__get__(self)(success=success) -def sendWebAppEvent(eventName, obj, webApp=None): - if webApp is None: +def sendWebModuleEvent(eventName, obj, webModule=None): + if webModule is None: return - scheduler.send(eventName="webApp", name=eventName, obj=obj, webApp=webApp) + scheduler.send(eventName="webModule", name=eventName, obj=obj, webModule=webModule) def eventExecuter_gen(self, eventName, obj): @@ -465,21 +346,18 @@ def eventExecuter_gen(self, eventName, obj): if func: yield func, (obj, self.next) - # webApp level - if not supportWebApp(obj) and eventName in ["gainFocus"] and activeWebApp is not None: - # log.info("Received event %s on a non-hosted object" % eventName) - webAppLoseFocus(obj) + # WebModule level. + webModule = obj.webAccess.webModule if isinstance(obj, overlay.WebAccessObject) else None + if webModule is None: + # Currently dead code, but will likely be revived for issue #17. + if activeWebModule is not None and obj.hasFocus: + #log.info("Disabling active webApp event %s" % eventName) + webAppLoseFocus(obj) else: - webApp = obj.webAccess.webModule if isinstance(obj, overlay.WebAccessObject) else None - if webApp is None: - if activeWebApp is not None and obj.hasFocus: - #log.info("Disabling active webApp event %s" % eventName) - webAppLoseFocus(obj) - else: - # log.info("Getting method %s -> %s" %(webApp.name, funcName)) - func = getattr(webApp, funcName, None) - if func: - yield func,(obj, self.next) + # log.info("Getting method %s -> %s" %(webApp.name, funcName)) + func = getattr(webModule, funcName, None) + if func: + yield func, (obj, self.next) # App module level. app = obj.appModule diff --git a/addon/globalPlugins/webAccess/gui/__init__.py b/addon/globalPlugins/webAccess/gui/__init__.py index 7934c0e0..55f4c853 100644 --- a/addon/globalPlugins/webAccess/gui/__init__.py +++ b/addon/globalPlugins/webAccess/gui/__init__.py @@ -38,7 +38,8 @@ import wx import wx.lib.mixins.listctrl as listmix -from gui import guiHelper, nvdaControls, _isDebug +import gui +from gui import guiHelper, nvdaControls from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit from gui.settingsDialogs import ( MultiCategorySettingsDialog, @@ -57,9 +58,9 @@ if sys.version_info[1] < 9: - from typing import Mapping, Sequence, Set + from typing import Iterable, Mapping, Sequence, Set else: - from collections.abc import Mapping, Sequence, Set + from collections.abc import Iterable, Mapping, Sequence, Set addonHandler.initTranslation() @@ -217,6 +218,7 @@ def __str__(self): class ScalingMixin(DpiScalingHelperMixinWithoutInit): + def scale(self, *args): sizes = tuple(( self.scaleSize(arg) if arg > 0 else arg @@ -246,6 +248,13 @@ def _buildGui(self): self.SetSizer(self.mainSizer) +# TODO: Consider migrating to NVDA's SettingsDialog once we hit 2023.2 as minimum version +class ContextualDialog(ScalingMixin, wx.Dialog): + + def initData(self, context): + self.context = context + + class ContextualSettingsPanel(FillableSettingsPanel, metaclass=guiHelper.SIPABCMeta): """ABC for the different editor panels. @@ -298,7 +307,9 @@ class FillableMultiCategorySettingsDialog(MultiCategorySettingsDialog, ScalingMi See `FillableSettingsPanel` """ - + + onCategoryChange = guarded(MultiCategorySettingsDialog.onCategoryChange) + def _getCategoryPanel(self, catId): # Changes to the original implementation: # - Add `proportion=1` @@ -331,7 +342,7 @@ def _getCategoryPanel(self, catId): panel.SetAccessible(SettingsPanelAccessible(panel)) return panel - + @guarded def _enterActivatesOk_ctrlSActivatesApply(self, evt): if evt.KeyCode in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER): @@ -403,6 +414,7 @@ def focusContainerControl(self, index: int): class ContextualMultiCategorySettingsDialog( KbNavMultiCategorySettingsDialog, configuredSettingsDialogType(hasApplyButton=False), + ContextualDialog, ): def __new__(cls, *args, **kwargs): @@ -456,20 +468,17 @@ def _getCategoryPanel(self, catId): # Changed from NVDA's MultiCategorySettingsDialog: Use ValidationError instead of ValueError, # in order to not misinterpret a real unintentional ValueError. - # Hence, ContextualSettingsPanel.isValid can either return False or willingly raise a ValidationError - # with the same outcome of cancelling the save operation and the destruction of the dialog. # Additionnaly, this implementation selects the category for the invalid panel. def _validateAllPanels(self): """Check if all panels are valid, and can be saved @note: raises ValidationError if a panel is not valid. See c{SettingsPanel.isValid} """ for panel in self.catIdToInstanceMap.values(): - if panel.isValid() is False: + if not panel.isValid(): self.selectPanel(panel) raise ValidationError("Validation for %s blocked saving settings" % panel.__class__.__name__) - class TreeContextualPanel(ContextualSettingsPanel): CATEGORY_PARAMS_CONTEXT_KEY = "TreeContextualPanel.categoryParams" @@ -783,12 +792,23 @@ def updateTreeParams(self, tree, treeNode, treeParent=None): prm.treeParent = treeParent -def showContextualDialog(cls, context, parent, *args, **kwargs): +def showContextualDialog( + cls: type(ContextualDialog), + context: Mapping[str, Any], + parent: wx.Window, + *args, + **kwargs +): + """ + Show a `ContextualDialog` + + If a `parent` is specified, the dialog is shown modal and this function + returns its return code. + """ if parent is not None: with cls(parent, *args, **kwargs) as dlg: dlg.initData(context) return dlg.ShowModal() - import gui gui.mainFrame.prePopup() try: dlg = cls(gui.mainFrame, *args, **kwargs) @@ -882,6 +902,12 @@ def __refresh(self, default=None): self.setSelectedChoiceKey(default) +class Change(Enum): + CREATION = auto() + UPDATE = auto() + DELETION = auto() + + @dataclass class EditorTypeValue: editorClass: type(wx.Control) = None @@ -890,15 +916,10 @@ class EditorTypeValue: isLabeled: bool = None -class Change(Enum): - CREATION = auto() - UPDATE = auto() - DELETION = auto() - - class EditorType(Enum): CHECKBOX = EditorTypeValue(wx.CheckBox, wx.EVT_CHECKBOX, "onEditor_checkBox", True) CHOICE = EditorTypeValue(wx.Choice, wx.EVT_CHOICE, "onEditor_choice", False) + COMBO = EditorTypeValue(wx.ComboBox, wx.EVT_TEXT, "onEditor_combo", False) TEXT = EditorTypeValue(wx.TextCtrl, wx.EVT_TEXT, "onEditor_text", False) @@ -945,6 +966,11 @@ def editorChoices(self) -> Mapping[Any, str]: def editorLabel(self) -> wx.Control: raise NotImplementedError + @property + @abstractmethod + def editorSuggestions(self) -> Iterable[str]: + raise NotImplementedError + @property @abstractmethod def editorType(self) -> EditorType: @@ -978,6 +1004,11 @@ def onEditor_choice(self, evt): self.setFieldValue(tuple(self.editorChoices.keys())[index]) self.onEditor_change() + @guarded + def onEditor_combo(self, evt): + self.setFieldValue(evt.EventObject.Value) + self.onEditor_change() + @guarded def onEditor_text(self, evt): self.setFieldValue(evt.EventObject.Value) @@ -997,7 +1028,7 @@ def toggleFieldValue(self, previous: bool = False) -> None: notifyError(f"value: {value!r}, choices: {choices!r}") return value = keys[index] - elif editorType is EditorType.TEXT: + elif editorType in (EditorType.COMBO, EditorType.TEXT): self.editor.SetFocus() return else: @@ -1023,7 +1054,7 @@ def updateEditor(self) -> None: elif editorType is EditorType.CHOICE: # Does not emit wx.EVT_CHOICE editor.Selection = tuple(self.editorChoices.keys()).index(value) - elif editorType is EditorType.TEXT: + elif editorType in (EditorType.COMBO, EditorType.TEXT): # Does not emit wx.EVT_TEXT editor.ChangeValue(value if value is not None else "") @@ -1032,6 +1063,14 @@ def updateEditorChoices(self): editor.Clear() editor.AppendItems(tuple(self.editorChoices.values())) + def updateEditorSuggestions(self): + editor = self.editor + value = editor.Value + editor.SetEvtHandlerEnabled(False) + editor.Set(tuple(self.editorSuggestions)) + editor.SetEvtHandlerEnabled(True) + editor.ChangeValue(value) + def updateEditorLabel(self): # Translators: A field label. French typically adds a space before the colon. self.editorLabel.Label = _("{field}:").format(field=self.fieldDisplayName) @@ -1055,6 +1094,7 @@ class SingleFieldEditorPanelBase(SingleFieldEditorMixin, TreeContextualPanel): @dataclass class CategoryParams(TreeContextualPanel.CategoryParams): editorChoices: Mapping[Any, str] = None + editorSuggestions: Sequence[str] = None fieldDisplayName: str = None fieldName: str = None # Type hint "Self" was added only in Python 3.11 (NVDA >= 2024.1) @@ -1081,6 +1121,10 @@ def __init__(self, *args, editorType: EditorType = None, **kwargs): def editorChoices(self) -> str: return self.categoryParams.editorChoices + @property + def editorSuggestions(self) -> str: + return self.categoryParams.editorSuggestions + @property def fieldDisplayName(self) -> str: return self.categoryParams.fieldDisplayName @@ -1113,6 +1157,8 @@ def initData(self, context): self.editorLabel = self.editor if editorType is EditorType.CHOICE: self.updateEditorChoices() + elif editorType is EditorType.COMBO: + self.updateEditorSuggestions() self.updateEditor() self.updateEditorLabel() diff --git a/addon/globalPlugins/webAccess/gui/elementDescription.py b/addon/globalPlugins/webAccess/gui/elementDescription.py index 462c8b8e..919e6366 100644 --- a/addon/globalPlugins/webAccess/gui/elementDescription.py +++ b/addon/globalPlugins/webAccess/gui/elementDescription.py @@ -23,7 +23,12 @@ # Get ready for Python 3 -__author__ = "Frédéric Brugnot " +__authors__ = ( + "Frédéric Brugnot ", + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) __license__ = "GPL" @@ -98,17 +103,18 @@ def formatAttributes(dic): def getNodeDescription(): import api from ..overlay import WebAccessObject - focus = api.getFocusObject() - if not ( - isinstance(focus, WebAccessObject) - and focus.webAccess.nodeManager - ): + for focus in (api.getFocusObject(), gui.mainFrame.prevFocus): + if ( + isinstance(focus, WebAccessObject) + and focus.webAccess.nodeManager + ): + break + else: return _("No NodeManager") ruleManager = focus.webAccess.ruleManager results = ruleManager.getResults() if ruleManager else [] node = focus.webAccess.nodeManager.getCaretNode() node = node.parent - obj = node.getNVDAObject() branch = [] while node is not None: parts = [] @@ -131,10 +137,11 @@ def getNodeDescription(): ))))) if node.src: parts.append(" src %s" % node.src) + if node.url: + parts.append(" url %s" % node.url) parts.append(" text %s" % truncText(node)) branch.append("\n".join(parts)) node = node.parent - obj = obj.parent return "\n\n".join(branch) diff --git a/addon/globalPlugins/webAccess/gui/menu.py b/addon/globalPlugins/webAccess/gui/menu.py index db56c3ab..b02f1cbe 100644 --- a/addon/globalPlugins/webAccess/gui/menu.py +++ b/addon/globalPlugins/webAccess/gui/menu.py @@ -22,19 +22,22 @@ """Web Access GUI.""" -__author__ = "Julien Cochuyt " +__authors__ = ( + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) import wx import addonHandler +import config import gui from ... import webAccess from .. import ruleHandler -from .. import webModuleHandler from ..utils import guarded -from . import webModulesManager addonHandler.initTranslation() @@ -52,17 +55,15 @@ def __init__(self, context): self.context = context if webAccess.webAccessEnabled: - webModule = context["webModule"] if "webModule" in context else None + webModule = context.get("webModule") - if webModule is not None: + if webModule: item = self.Append( wx.ID_ANY, # Translators: Web Access menu item label. _("&New rule...") ) self.Bind(wx.EVT_MENU, self.onRuleCreate, item) - - if webModule is not None: item = self.Append( wx.ID_ANY, # Translators: Web Access menu item label. @@ -71,13 +72,27 @@ def __init__(self, context): self.Bind(wx.EVT_MENU, self.onRulesManager, item) self.AppendSeparator() - if webModule is None: + if not webModule: item = self.Append( wx.ID_ANY, # Translators: Web Access menu item label. _("&New web module...")) self.Bind(wx.EVT_MENU, self.onWebModuleCreate, item) - else: + + stack = context.get("webModuleStackAtCaret", []).copy() + if stack: + subMenu = wx.Menu() + while stack: + mod = stack.pop(0) + handler = lambda evt, webModule=mod: self.onWebModuleEdit(evt, webModule=webModule) + item = subMenu.Append(wx.ID_ANY, mod.name) + subMenu.Bind(wx.EVT_MENU, handler, item) + self.AppendSubMenu( + subMenu, + # Translators: Web Access menu item label. + _("Edit &web module") + ) + elif webModule: item = self.Append( wx.ID_ANY, # Translators: Web Access menu item label. @@ -94,6 +109,16 @@ def __init__(self, context): self.AppendSeparator() + if config.conf["webAccess"]["devMode"]: + item = self.Append( + wx.ID_ANY, + # Translators: Web Access menu item label. + _("&Element description...") + ) + self.Bind(wx.EVT_MENU, self.onElementDescription, item) + + self.AppendSeparator() + item = self.AppendCheckItem( wx.ID_ANY, # Translators: Web Access menu item label. @@ -107,25 +132,39 @@ def show(self): gui.mainFrame.PopupMenu(self) gui.mainFrame.postPopup() + @guarded + def onElementDescription(self, evt): + from .elementDescription import showElementDescriptionDialog + showElementDescriptionDialog() + @guarded def onRuleCreate(self, evt): - ruleHandler.showCreator(self.context) + self.context["new"] = True + from .rule.editor import show + show(self.context, gui.mainFrame) @guarded def onRulesManager(self, evt): - ruleHandler.showManager(self.context) + from .rule.manager import show + show(self.context, gui.mainFrame) @guarded - def onWebModuleCreate(self, evt): - webModuleHandler.showCreator(self.context) + def onWebModuleCreate(self, evt, webModule=None): + self.context["new"] = True + from .webModule.editor import show + show(self.context) @guarded - def onWebModuleEdit(self, evt): - webModuleHandler.showEditor(self.context) + def onWebModuleEdit(self, evt, webModule=None): + if webModule is not None: + self.context["webModule"] = webModule + from .webModule.editor import show + show(self.context) @guarded def onWebModulesManager(self, evt): - webModuleHandler.showManager(self.context) + from .webModule.manager import show + show(self.context) @guarded def onWebAccessToggle(self, evt): diff --git a/addon/globalPlugins/webAccess/gui/rule/__init__.py b/addon/globalPlugins/webAccess/gui/rule/__init__.py index e69de29b..79f0f180 100644 --- a/addon/globalPlugins/webAccess/gui/rule/__init__.py +++ b/addon/globalPlugins/webAccess/gui/rule/__init__.py @@ -0,0 +1,89 @@ +# globalPlugins/webAccess/gui/rule/__init__.py +# -*- coding: utf-8 -*- + +# This file is part of Web Access for NVDA. +# Copyright (C) 2015-2024 Accessolutions (http://accessolutions.fr) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See the file COPYING.txt at the root of this distribution for more details. + + +__author__ = "Julien Cochuyt " + + +import sys +from typing import Any + +import wx + +import addonHandler +import gui +from logHandler import log + + +if sys.version_info[1] < 9: + from typing import Mapping +else: + from collections.abc import Mapping + + +addonHandler.initTranslation() + + +def createMissingSubModule( + context: Mapping[str, Any], + data: Mapping[str, Any], + parent: wx.Window + ) -> bool: + """Create the missing SubModule from Rule or Criteria data + + If a SubModule is specified in the provided data, it is looked-up in the catalog. + If it is missing from the catalog, the user is prompted for creating it. + + This function returns: + - `None` if no creation was necessary or if the user declined the prompt. + - `False` if the user canceled the prompt or if the creation failed or has been canceled. + - `True` if the creation succeeded. + """ + name = data.get("properties", {}).get("subModule") + if not name: + return None + from ...webModuleHandler import getCatalog + if any(meta["name"] == name for ref, meta in getCatalog()): + return True + res = gui.messageBox( + message=( + # Translators: A prompt for creation of a missing SubModule + _(f"""SubModule {name} could not be found. + +Do you want to create it now?""") + ), + style=wx.YES_NO | wx.CANCEL | wx.ICON_QUESTION, + parent=parent, + ) + if res is wx.NO: + return None + elif res is wx.CANCEL: + return False + context = context.copy() + context["new"] = True + context["data"] = {"webModule": {"name": name, "subModule": True}} + from ..webModule.editor import show + res = show(context, parent) + if res: + newName = context["webModule"].name + if newName != name: + data["properties"]["subModule"] = newName + return res diff --git a/addon/globalPlugins/webAccess/gui/rule/abc.py b/addon/globalPlugins/webAccess/gui/rule/abc.py index 5e598079..cf1968d3 100644 --- a/addon/globalPlugins/webAccess/gui/rule/abc.py +++ b/addon/globalPlugins/webAccess/gui/rule/abc.py @@ -31,7 +31,7 @@ class RuleAwarePanelBase(ContextualSettingsPanel, metaclass=guiHelper.SIPABCMeta): def getRuleData(self): - return self.context["data"].setdefault("rule", {}) + return self.context.setdefault("data", {}).setdefault("rule", {}) def getRuleManager(self): return self.context["webModule"].ruleManager diff --git a/addon/globalPlugins/webAccess/gui/criteriaEditor.py b/addon/globalPlugins/webAccess/gui/rule/criteriaEditor.py similarity index 91% rename from addon/globalPlugins/webAccess/gui/criteriaEditor.py rename to addon/globalPlugins/webAccess/gui/rule/criteriaEditor.py index 99f60388..34bfd2a5 100644 --- a/addon/globalPlugins/webAccess/gui/criteriaEditor.py +++ b/addon/globalPlugins/webAccess/gui/rule/criteriaEditor.py @@ -1,4 +1,4 @@ -# globalPlugins/webAccess/gui/criteriaEditor.py +# globalPlugins/webAccess/gui/rule/criteria.py # -*- coding: utf-8 -*- # This file is part of Web Access for NVDA. @@ -21,7 +21,13 @@ -__author__ = "Shirley Noël " +__authors__ = ( + "Shirley Noël ", + "Julien Cochuyt ", + "André-Abush Clause ", + "Sendhil Randon ", + "Gatien Bouyssou ", +) from collections import OrderedDict @@ -41,9 +47,9 @@ import ui import addonHandler -from ..ruleHandler import builtinRuleActions, ruleTypes -from ..utils import guarded, notifyError, updateOrDrop -from . import ( +from ...ruleHandler import ruleTypes +from ...utils import guarded, notifyError, updateOrDrop +from .. import ( ContextualMultiCategorySettingsDialog, ContextualSettingsPanel, DropDownWithHideableChoices, @@ -54,8 +60,9 @@ stripAccel, stripAccelAndColon, ) -from .actions import ActionsPanelBase -from .rule.abc import RuleAwarePanelBase +from . import createMissingSubModule +from .abc import RuleAwarePanelBase +from .gestures import GesturesPanelBase from .properties import Properties, PropertiesPanelBase, Property @@ -168,7 +175,7 @@ def getSummary_context(data) -> Sequence[str]: parts.append("{} {}".format(stripAccel(label), value)) if not parts: # Translators: A mention on the Criteria summary report - parts.append(_("Global - Applies to the whole web module")) + parts.append(_("General - Applies to the whole web module")) return parts @@ -229,16 +236,20 @@ def getSummary(context, data, indent="", condensed=False) -> str: def testCriteria(context): ruleData = deepcopy(context["data"]["rule"]) ruleData["name"] = "__tmp__" - ruleData.pop("new", None) - ruleData["type"] = ruleTypes.MARKER critData = context["data"]["criteria"].copy() critData.pop("new", None) critData.pop("criteriaIndex", None) ruleData["criteria"] = [critData] - ruleData.setdefault("properties", {})['multiple'] = True - critData.setdefault("properties", {}).pop("multiple", True) + # Ensure the user is informed about all the match occurrences, even if only + # the first is retained by a disabled "multiple" property. + # All rule types do not support this property, hence force the rule type "marker". + # Rather than filtering out properties not supported for this type, simply drop them all + # as they have no impact on the actual search. + ruleData["type"] = ruleTypes.MARKER + ruleData["properties"] = {"multiple": True} + critData["properties"] = {"multiple": True} mgr = context["webModule"].ruleManager - from ..ruleHandler import Rule + from ...ruleHandler import Rule rule = Rule(mgr, ruleData) import time start = time.time() @@ -256,7 +267,9 @@ def testCriteria(context): class CriteriaEditorPanel(RuleAwarePanelBase): def getData(self): - return self.context["data"].setdefault("criteria", {}) + # Should always be initialized, as the Rule Editor populates it with at least + # the index of this Alternative Criteria Set ("criteriaIndex"). + return self.context["data"]["criteria"] class GeneralPanel(CriteriaEditorPanel): @@ -324,17 +337,18 @@ def makeSettings(self, settingsSizer): def initData(self, context): super().initData(context) - data = self.getData() - new = data.get("new", False) self.sequenceOrderChoice.Clear() - nbCriteria = len(context["data"]["rule"]["criteria"]) + (1 if new else 0) - if nbCriteria == 1: + nbAlternatives = len(context["data"]["rule"]["criteria"]) + if context.get("new"): + nbAlternatives += 1 + data = self.getData() + if nbAlternatives == 1: for item in self.hideable: item.Show(False) else: - for index in range(nbCriteria): + for index in range(nbAlternatives): self.sequenceOrderChoice.Append(str(index + 1)) - index = data.get("criteriaIndex", nbCriteria + 1) + index = data.get("criteriaIndex", nbAlternatives + 1) self.sequenceOrderChoice.SetSelection(index) self.criteriaName.Value = data.get("name", "") self.commentText.Value = data.get("comment", "") @@ -396,6 +410,8 @@ class CriteriaPanel(CriteriaEditorPanel): # Translator: The label for a Rule Criteria field ("src", pgettext("webAccess.ruleCriteria", "Ima&ge source:")), # Translator: The label for a Rule Criteria field + ("url", pgettext("webAccess.ruleCriteria", "Document &URL:")), + # Translator: The label for a Rule Criteria field ("relativePath", pgettext("webAccess.ruleCriteria", "R&elative path:")), # Translator: The label for a Rule Criteria field ("index", pgettext("webAccess.ruleCriteria", "Inde&x:")), @@ -417,7 +433,7 @@ def makeSettings(self, settingsSizer): item = self.contextMacroDropDown = DropDownWithHideableChoices(self) item.setChoices(( # Translator: A selection value for the Context field on the Criteria editor - ("global", _("Global - Applies to the whole web module")), + ("general", _("General - Applies to the whole web module")), # Translator: A selection value for the Context field on the Criteria editor ("contextPageTitle", _("Page title - Applies only to pages with the given title")), # Translator: A selection value for the Context field on the Criteria editor @@ -560,6 +576,16 @@ def makeSettings(self, settingsSizer): row += 1 gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), pos=(row, 0)) + row += 1 + item = wx.StaticText(self, label=self.FIELDS["url"]) + gbSizer.Add(item, pos=(row, 0)) + gbSizer.Add(scale(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL, 0), pos=(row, 1)) + item = self.urlCombo = SizeFrugalComboBox(self) + gbSizer.Add(item, pos=(row, 2), flag=wx.EXPAND) + + row += 1 + gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), pos=(row, 0)) + row += 1 item = wx.StaticText(self, label=self.FIELDS["relativePath"]) gbSizer.Add(item, pos=(row, 0)) @@ -603,7 +629,7 @@ def initData(self, context): rule = result.rule if ( rule.type in (ruleTypes.PARENT, ruleTypes.ZONE) - and node in result.node + and result.containsNode(node) ): parents.insert(0, rule.name) self.contextParentCombo.Set(parents) @@ -623,6 +649,7 @@ def initData(self, context): classChoices = [] statesChoices = [] srcChoices = [] + urlChoices = [] # todo: actually there are empty choices created while node is not None: roleChoices.append(controlTypes.roleLabels.get(node.role, "") or "") @@ -631,6 +658,7 @@ def initData(self, context): classChoices.append(node.className or "") statesChoices.append(getStatesLblExprForSet(node.states) or "") srcChoices.append(node.src or "") + urlChoices.append(node.url or "") node = node.parent self.textCombo.Set(textChoices) @@ -640,6 +668,7 @@ def initData(self, context): self.classNameCombo.Set(classChoices) self.statesCombo.Set(statesChoices) self.srcCombo.Set(srcChoices) + self.urlCombo.Set(urlChoices) self.refreshContextMacroChoices(initial=True) self.onContextMacroChoice(None) @@ -662,6 +691,7 @@ def initData(self, context): else: self.statesCombo.Value = translateStatesIdToLbl(value) self.srcCombo.Value = data.get("src", "") + self.urlCombo.Value = data.get("url", "") self.relativePathCombo.Value = str(data.get("relativePath", "")) value = data.get("index", "") if isinstance(value, InvalidValue): @@ -689,6 +719,7 @@ def updateData(self): except ValidationError: data["states"] = InvalidValue(value) updateOrDrop(data, "src", self.srcCombo.Value) + updateOrDrop(data, "url", self.urlCombo.Value) updateOrDrop(data, "relativePath", self.relativePathCombo.Value) value = self.indexText.Value try: @@ -718,11 +749,11 @@ def refreshContextMacroChoices(self, initial=False): if data.get(field) ] if not filled: - dropDown.setSelectedChoiceKey("global") + dropDown.setSelectedChoiceKey("general") elif len(filled) > 1: dropDown.setSelectedChoiceKey("advanced") else: - dropDown.setSelectedChoiceKey(filled[0], default="global") + dropDown.setSelectedChoiceKey(filled[0], default="general") self.onContextMacroChoice(None) def onContextMacroChoice(self, evt): @@ -832,44 +863,8 @@ def isValid(self): return True -class ActionsPanel(ActionsPanelBase, CriteriaEditorPanel): - - def makeSettings(self, settingsSizer): - super().makeSettings(settingsSizer) - self.autoActionChoice.Bind(wx.EVT_CHAR_HOOK, self.onAutoActionChoice_charHook) - - def getAutoAction(self): - return self.getData().get("properties", {}).get( - "autoAction", self.getRuleAutoAction() - ) - - def getRuleAutoAction(self): - return self.getRuleData().get("properties", {}).get("autoAction") - - def getAutoActionChoices(self): - choices = super().getAutoActionChoices() - ruleValue = self.getRuleAutoAction() - # Translators: An entry in the Automatic Action list on the Criteria Editor denoting the rule value - choices[ruleValue] = "{action} (default)".format( - action=choices.get(ruleValue, f"*{ruleValue}") - ) - return choices - - @guarded - def onAutoActionChoice_charHook(self, evt): - keycode = evt.GetKeyCode() - mods = evt.GetModifiers() - if keycode == wx.WXK_DELETE and not mods: - self.resetAutoAction() - return - evt.Skip() - - def resetAutoAction(self): - data = self.getData().setdefault("properties", {}) - data["autoAction"] = self.getRuleAutoAction() - self.updateAutoActionChoice(refreshChoices=False) - # Translators: Announced when resetting a property to its default value in the editor - ui.message(_("Reset to {value}").format(value=self.autoActionChoice.StringSelection)) +class GesturesPanel(GesturesPanelBase, CriteriaEditorPanel): + pass class PropertyOverrideSelectMenu(wx.Menu): @@ -965,9 +960,9 @@ def onAddPropBtn(self, evt): if not prop: return prop.value = prop.default # Setting any value actually adds to the ChainMap based container - self.listCtrl_update_all() self.prop = prop - if prop.editorType is EditorType.TEXT: + self.listCtrl_update_all() + if prop.editorType in (EditorType.COMBO, EditorType.TEXT): self.editor.SetFocus() else: self.listCtrl.SetFocus() @@ -995,9 +990,14 @@ def prop_reset(self): class CriteriaEditorDialog(ContextualMultiCategorySettingsDialog): # Translators: The title of the Criteria Editor dialog. title = _("WebAccess Criteria Set editor") - categoryClasses = [GeneralPanel, CriteriaPanel, ActionsPanel, PropertiesPanel] + categoryClasses = [GeneralPanel, CriteriaPanel, GesturesPanel, PropertiesPanel] INITIAL_SIZE = (900, 580) + def getData(self): + # Should always be initialized, as the Rule Editor populates it with at least + # the index of this Alternative Criteria Set ("criteriaIndex"). + return self.context["data"]["criteria"] + def makeSettings(self, settingsSizer): super().makeSettings(settingsSizer) idTestCriteria = wx.NewId() @@ -1009,8 +1009,13 @@ def makeSettings(self, settingsSizer): def onTestCriteria(self, evt): self.currentCategory.updateData() testCriteria(self.context) + + def _saveAllPanels(self): + super()._saveAllPanels() + if createMissingSubModule(self.context, self.getData(), self) is False: + raise ValidationError() # Cancels closing of the dialog def show(context, parent=None): - from . import showContextualDialog + from .. import showContextualDialog return showContextualDialog(CriteriaEditorDialog, context, parent) diff --git a/addon/globalPlugins/webAccess/gui/rule/editor.py b/addon/globalPlugins/webAccess/gui/rule/editor.py index 7ca2b67a..af1c666f 100644 --- a/addon/globalPlugins/webAccess/gui/rule/editor.py +++ b/addon/globalPlugins/webAccess/gui/rule/editor.py @@ -31,6 +31,7 @@ from abc import abstractmethod from collections import OrderedDict +import config from dataclasses import dataclass from enum import Enum from functools import partial @@ -48,7 +49,7 @@ import ui from ... import webModuleHandler -from ...ruleHandler import RuleManager, builtinRuleActions, ruleTypes +from ...ruleHandler import RuleManager, ruleTypes from ...ruleHandler.controlMutation import ( MUTATIONS_BY_RULE_TYPE, mutationLabels @@ -61,22 +62,22 @@ TreeContextualPanel, TreeMultiCategorySettingsDialog, TreeNodeInfo, - criteriaEditor, - gestureBinding, + ValidationError, showContextualDialog, stripAccel, stripAccelAndColon, stripAccelAndColon, ) -from ..actions import ActionsPanelBase -from ..properties import ( +from . import createMissingSubModule, criteriaEditor, gestureBinding +from .abc import RuleAwarePanelBase +from .gestures import GesturesPanelBase +from .properties import ( EditorType, Property, Properties, PropertiesPanelBase, SinglePropertyEditorPanelBase, ) -from .abc import RuleAwarePanelBase if sys.version_info[1] < 9: @@ -139,12 +140,14 @@ def getSummary(context, data): name = alternative.get("name") if name: # Translators: The label for a section on the Rule Summary report - altHeader = _('Alternative #{index} "{name}":').format(index=index, name=name) + altHeader = _('Alternative #{index} "{name}":').format(index=index + 1, name=name) else: # Translators: The label for a section on the Rule Summary report - altHeader = _("Alternative #{index}:").format(index=index) + altHeader = _("Alternative #{index}:").format(index=index + 1) subParts.append(" " + altHeader) - subParts.append(criteriaEditor.getSummary(context, alternative, indent=" ")) + subParts.append(criteriaEditor.getSummary( + context, alternative, indent=" ", condensed=True + )) parts.extend(subParts) return "\n".join(parts) @@ -157,7 +160,7 @@ def getData(self): def onRuleType_change(self): prm = self.categoryParams categoryClasses = tuple(nodeInfo.categoryClass for nodeInfo in self.Parent.Parent.categoryClasses) - for index in (categoryClasses.index(cls) for cls in (ActionsPanel, PropertiesPanel)): + for index in (categoryClasses.index(cls) for cls in (GeneralPanel, GesturesPanel, PropertiesPanel)): category = prm.tree.getXChild(prm.tree.GetRootItem(), index) self.refreshParent(category) @@ -230,23 +233,12 @@ def makeSettings(self, settingsSizer): def initData(self, context: Mapping[str, Any]) -> None: super().initData(context) data = self.getData() - if 'type' in data: - self.ruleType.SetSelection(list(ruleTypes.ruleTypeLabels.keys()).index(data['type'])) - else: - self.ruleType.SetSelection(0) - + self.ruleType.SetSelection(tuple(ruleTypes.ruleTypeLabels.keys()).index(data["type"])) # Does not emit EVT_TEXT self.ruleName.ChangeValue(data.get("name", "")) self.commentText.ChangeValue(data.get("comment", "")) self.refreshSummary() - @staticmethod - def initRuleTypeChoice(data, ruleTypeChoice): - for index, key in enumerate(ruleTypes.ruleTypeLabels.keys()): - if key == data["type"]: - ruleTypeChoice.Selection = index - break - def updateData(self): data = self.getData() # The type and name are already stored by their respective event handlers and should @@ -328,36 +320,34 @@ def isValid(self): ) self.ruleName.SetFocus() return False - - mgr = self.getRuleManager() - layerName = self.context["rule"].layer if "rule" in self.context else None - webModule = webModuleHandler.getEditableWebModule(mgr.webModule, layerName=layerName) - if not webModule: - return False - if layerName == "addon": - if not webModule.getLayer("addon") and webModule.getLayer("scratchpad"): - layerName = "scratchpad" - elif layerName is None: - layerName = webModule._getWritableLayer().name - if layerName is None: - layerName = False - try: - rule = mgr.getRule(self.ruleName.Value, layer=layerName) - except LookupError: - rule = None - if rule is not None: - moduleRules = self.getRuleManager().getRules() - isExists = [True if i.name is rule.name else False for i in moduleRules] - if "new" in self.context and self.context["new"]: - if isExists: - gui.messageBox( - # Translators: Error message when another rule with the same name already exists - message=_("There already is another rule with the same name."), - caption=_("Error"), - style=wx.ICON_ERROR | wx.OK, - parent=self - ) - return False + newName = data["name"] + context = self.context + if context.get("new"): + prevName = None + webModule = webModuleHandler.getEditableWebModule(context["webModule"]) + if not webModule: + # Raising rather than returning False does not focus the panel + raise ValidationError("The WebModule is not editable") + layer = webModule.getWritableLayer() + else: + rule = context["rule"] + prevName = rule.name + layer = rule.layer + if newName != prevName: + mgr = self.getRuleManager() + try: + mgr.getRule(newName, layer) + except LookupError: + pass + else: + gui.messageBox( + # Translators: Error message when another rule with the same name already exists + message=_("There already is another rule with the same name."), + caption=_("Error"), + style=wx.ICON_ERROR | wx.OK, + parent=self + ) + return False return True @@ -480,28 +470,30 @@ def onCriteriaChange(self, change: Change, index: int): @guarded def onNewCriteria(self, evt): - context = self.context prm = self.categoryParams - context["data"]["criteria"] = OrderedDict({ - "new": True, - "criteriaIndex": len(context["data"]["rule"]["criteria"]) + listData = self.getData() + context = self.context.copy() + context["new"] = True + itemData = context["data"]["criteria"] = OrderedDict({ + "criteriaIndex": len(self.getData()) }) - if criteriaEditor.show(context, parent=self) == wx.ID_OK: - context["data"]["criteria"].pop("new", None) - index = context["data"]["criteria"].pop("criteriaIndex") - context["data"]["rule"]["criteria"].insert(index, context["data"].pop("criteria")) + if criteriaEditor.show(context, parent=self): + index = itemData.pop("criteriaIndex") + listData.insert(index, itemData) self.onCriteriaChange(Change.CREATION, index) @guarded def onEditCriteria(self, evt): - context = self.context + context = self.context.copy() + context["new"] = False + listData = self.getData() index = self.getIndex() - context["data"]["criteria"] = context["data"]["rule"]["criteria"][index].copy() - context["data"]["criteria"]["criteriaIndex"] = index - if criteriaEditor.show(context, self) == wx.ID_OK: - del context["data"]["rule"]["criteria"][index] - index = context["data"]["criteria"].pop("criteriaIndex") - context["data"]["rule"]["criteria"].insert(index, context["data"].pop("criteria")) + itemData = context["data"]["criteria"] = listData[index].copy() + itemData["criteriaIndex"] = index + if criteriaEditor.show(context, self): + del listData[index] + index = itemData.pop("criteriaIndex") + listData.insert(index, itemData) self.onCriteriaChange(Change.UPDATE, index) @guarded @@ -534,7 +526,18 @@ def updateCriteriaList(self, index=None): data = self.getData() ctrl = self.criteriaList if index is None: - index = max(ctrl.Selection, 0) + index = ctrl.Selection + if index < 0: + # When first displaying the list, attempt to select the + # alternative corresponding to the result at caret, if any. + try: + result = self.context["result"] + if self.getRuleData()["name"] == result.rule.name: + index = self.getData().index(result.criteria.dump()) + except Exception: + pass + if index < 0: + index = 0 ctrl.Clear() for criteria in data: ctrl.Append(self.getCriteriaName(criteria)) @@ -548,14 +551,8 @@ def updateCriteriaList(self, index=None): self.editButton.Disable() self.deleteButton.Disable() - def onSave(self): - super().onSave() - data = super().getData() - if not data.get("gestures"): - data.pop("gestures", None) - -class ActionsPanel(ActionsPanelBase, RuleEditorTreeContextualPanel): +class GesturesPanel(GesturesPanelBase, RuleEditorTreeContextualPanel): def delete(self): wx.Bell() @@ -567,24 +564,6 @@ def onGestureChange(self, change: Change, id: str): def spaceIsPressedOnTreeNode(self, withShift=False): self.gesturesListBox.SetFocus() - - @guarded - def onAutoActionChoice(self, evt): - super().onAutoActionChoice(evt) - # Refresh ChildProperty tree node label - index = tuple( - nodeInfo.categoryClass - for nodeInfo in self.Parent.Parent.categoryClasses - ).index(PropertiesPanel) - prm = self.categoryParams - propsCat = prm.tree.getXChild(prm.tree.GetRootItem(), index) - data = super().getData() - props = Properties(self.context, data) - index = tuple(p.name for p in props).index("autoAction") - prm.tree.SetItemText( - prm.tree.getXChild(propsCat, index), - ChildPropertyPanel.getTreeNodeLabelForProp(props[index]) - ) class PropertiesPanel(PropertiesPanelBase, RuleEditorTreeContextualPanel): @@ -709,7 +688,7 @@ def onCriteriaChange(self, change: Change, index: int): prm.tree.SetFocus() -class ChildActionPanel(RuleEditorTreeContextualPanel): +class ChildGesturePanel(RuleEditorTreeContextualPanel): @dataclass class CategoryParams(TreeContextualPanel.CategoryParams): @@ -780,13 +759,11 @@ def delete(self): @guarded def onAddGesture(self, evt): - context = self.context + context = self.context.copy() context["data"]["gestures"] = self.getData() - if gestureBinding.show(context=context, parent=self) == wx.ID_OK: + if gestureBinding.show(context, parent): index = context["data"]["gestureBinding"]["index"] self.updateTreeAndSelectItemAtIndex(index) - del context["data"]["gestureBinding"] - del context["data"]["gestures"] @guarded def onDeleteGesture(self, evt): @@ -804,17 +781,15 @@ def onDeleteGesture(self, evt): @guarded def onEditGesture(self, evt): prm = self.categoryParams - context = self.context + context = self.context.copy() gestures = context["data"]["gestures"] = self.getData() context["data"]["gestureBinding"] = { "gestureIdentifier": prm.gestureIdentifier, "action": gestures[prm.gestureIdentifier], } - if gestureBinding.show(context=context, parent=self) == wx.ID_OK: + if gestureBinding.show(context, self): index = context["data"]["gestureBinding"]["index"] self.updateTreeAndSelectItemAtIndex(index) - del context["data"]["gestureBinding"] - del context["data"]["gestures"] def updateTreeAndSelectItemAtIndex(self, index): prm = self.categoryParams @@ -850,20 +825,19 @@ def delete(self): class RuleEditorDialog(TreeMultiCategorySettingsDialog): - # Translators: The title of the rule editor - title = _("WebAccess Rule editor") + INITIAL_SIZE = (750, 520) categoryInitList = [ (GeneralPanel, 'getGeneralChildren'), (AlternativesPanel, 'getAlternativeChildren'), - (ActionsPanel, 'getActionsChildren'), + (GesturesPanel, 'getGesturesChildren'), #FIXME PropertiesPanel, 'getPropertiesChildren'), (PropertiesPanel, 'getPropertiesChildren'), ] categoryClasses = [ GeneralPanel, AlternativesPanel, - ActionsPanel, + GesturesPanel, #FIXME PropertiesPanel, PropertiesPanel, ] @@ -876,8 +850,7 @@ def __init__(self, *args, **kwargs): def getGeneralChildren(self): cls = RuleEditorSingleFieldChildPanel - data = self.context["data"]["rule"] - data.setdefault("type", ruleTypes.MARKER) + data = self.getData() return tuple( TreeNodeInfo( partial(cls, editorType=editorType), @@ -901,35 +874,30 @@ def getGeneralChildren(self): ) def getAlternativeChildren(self): - ruleData = self.context['data']['rule'] - criteriaPanels = [] - for criterion in ruleData.get('criteria', []): - title = ChildAlternativePanel.getTreeNodeLabel(criterion) - criteriaPanels.append( - TreeNodeInfo( - ChildAlternativePanel, - title=title, - categoryParams=ChildAlternativePanel.CategoryParams() - ) + return tuple( + TreeNodeInfo( + ChildAlternativePanel, + title=ChildAlternativePanel.getTreeNodeLabel(data), + categoryParams=ChildAlternativePanel.CategoryParams() ) - return criteriaPanels + for data in self.getData().get("criteria", []) + ) - def getActionsChildren(self): - ruleData = self.context['data']['rule'] - type = ruleData.get('type', '') - if type not in [ruleTypes.ZONE, ruleTypes.MARKER]: + def getGesturesChildren(self): + data = self.getData() + if data["type"] not in [ruleTypes.ACTION_TYPES]: return [] mgr = self.context["webModule"].ruleManager - actionsPanel = [] - for key, value in ruleData.get('gestures', {}).items(): - title = ChildActionPanel.getTreeNodeLabel(mgr, key, value) - prm = ChildActionPanel.CategoryParams(title=title, gestureIdentifier=key) - actionsPanel.append(TreeNodeInfo(ChildActionPanel, title=title, categoryParams=prm)) - return actionsPanel + panels = [] + for key, value in data.get('gestures', {}).items(): + title = ChildGesturePanel.getTreeNodeLabel(mgr, key, value) + prm = ChildGesturePanel.CategoryParams(title=title, gestureIdentifier=key) + panels.append(TreeNodeInfo(ChildGesturePanel, title=title, categoryParams=prm)) + return panels def getPropertiesChildren(self) -> Sequence[TreeNodeInfo]: context = self.context - data = context.setdefault("data", {}).setdefault("rule", {}).setdefault("properties", {}) + data = self.getData().setdefault("properties", {}) props = Properties(context, data) cls = ChildPropertyPanel return tuple( @@ -940,47 +908,101 @@ def getPropertiesChildren(self) -> Sequence[TreeNodeInfo]: ) for prop in props ) - + + def getData(self): + return self.context["data"]["rule"] + def initData(self, context: Mapping[str, Any]) -> None: - rule = context.get("rule") - data = context.setdefault("data", {}).setdefault( - "rule", - rule.dump() if rule else {} - ) - mgr = context["webModule"].ruleManager if "webModule" in context else None - if not rule and mgr and mgr.nodeManager: - node = mgr.nodeManager.getCaretNode() + context.setdefault("data", {}) + webModule = context["webModule"] + ruleManager = webModule.ruleManager + if context.get("new"): + data = context["data"]["rule"] = {"type": ruleTypes.MARKER} + if ruleManager.parentZone is not None: + # Translators: A title of the rule editor + title = (_("Sub Module {} - New Rule").format(webModule.name)) + elif ruleManager.subModules.all(): + # Translators: A title of the rule editor + title = (_("Root Module {} - New Rule").format(webModule.name)) + else: + # Translators: A title of the rule editor + title = (_("Web Module {} - New Rule").format(webModule.name)) + else: + data = context["data"]["rule"] = context["rule"].dump() + if ruleManager.parentZone is not None: + # Translators: A title of the rule editor + title = (_("Sub Module {} - Edit Rule {}").format(webModule.name, data.get("name"))) + elif ruleManager.subModules.all(): + # Translators: A title of the rule editor + title = (_("Root Module {} - Edit Rule {}").format(webModule.name, data.get("name"))) + else: + # Translators: A title of the rule editor + title = (_("Web Module {} - Edit Rule {}").format(webModule.name, data.get("name"))) + if config.conf["webAccess"]["devMode"]: + layerName = None + if context.get("new"): + try: + webModule = webModuleHandler.getEditableWebModule(webModule, prompt=False) + if webModule: + layerName = webModule.getWritableLayer().name + except Exception: + log.exception() + else: + layerName = context["rule"].layer + title += f" ({layerName})" + self.SetTitle(title) + nodeManager = ruleManager.nodeManager + if nodeManager: + node = nodeManager.getCaretNode() while node is not None: if node.role in formModeRoles: data.setdefault("properties", {})["formMode"] = True break node = node.parent super().initData(context) - - def _doSave(self): - super()._doSave() + + def _saveAllPanels(self): + super()._saveAllPanels() context = self.context + data = self.getData() + + # Remove gesture bindings and properties not supported for the selected Rule Type. + # This needs to be done here rather than + # - upon changing the Rule Type for easier non-destructive cycle-through + # the different types, + # - in the panel's doSave because the Rule Type might have been changed + # without even instantiating any other panel. + cnts = [data] + [crit for crit in data.get("criteria", tuple())] + for cnt in cnts: + if data["type"] not in ruleTypes.ACTION_TYPES or not cnt.get("gestures"): + cnt.pop("gestures", None) + updateOrDrop( + cnt, + "properties", + Properties(context, cnt.get("properties", {}), iterOnlyFirstMap=True).dump(), + {} + ) + mgr = context["webModule"].ruleManager - data = context["data"]["rule"] - rule = context.get("rule") - layerName = rule.layer if rule is not None else None + if context.get("new"): + layerName = None + else: + rule = context["rule"] + layerName = rule.layer webModule = webModuleHandler.getEditableWebModule(mgr.webModule, layerName=layerName) if not webModule: - return - if rule is not None: - # modification mode, remove old rule + raise ValidationError() # Cancels closing of the dialog + if createMissingSubModule(context, data, self) is False: + raise ValidationError() # Cancels closing of the dialog + if context.get("new"): + layerName = webModule.getWritableLayer().name + else: mgr.removeRule(rule) - if layerName == "addon": - if not webModule.getLayer("addon") and webModule.getLayer("scratchpad"): - layerName = "scratchpad" - elif layerName is None: - layerName = webModule._getWritableLayer().name - - rule = webModule.createRule(data) - mgr.loadRule(layerName, rule.name, data) + context["rule"] = mgr.loadRule(layerName, data["name"], data) webModule.getLayer(layerName, raiseIfMissing=True).dirty = True - webModuleHandler.save(webModule, layerName=layerName) + if not webModuleHandler.save(webModule, layerName=layerName): + raise ValidationError() # Cancels closing of the dialog def show(context, parent=None): - return showContextualDialog(RuleEditorDialog, context, parent) + return showContextualDialog(RuleEditorDialog, context, parent) == wx.ID_OK diff --git a/addon/globalPlugins/webAccess/gui/gestureBinding.py b/addon/globalPlugins/webAccess/gui/rule/gestureBinding.py similarity index 92% rename from addon/globalPlugins/webAccess/gui/gestureBinding.py rename to addon/globalPlugins/webAccess/gui/rule/gestureBinding.py index dce0da70..08e3d485 100644 --- a/addon/globalPlugins/webAccess/gui/gestureBinding.py +++ b/addon/globalPlugins/webAccess/gui/rule/gestureBinding.py @@ -1,4 +1,4 @@ -# globalPlugins/webAccess/gui/gestureBinding.py +# globalPlugins/webAccess/gui/rule/gestureBinding.py # -*- coding: utf-8 -*- # This file is part of Web Access for NVDA. @@ -20,7 +20,14 @@ # See the file COPYING.txt at the root of this distribution for more details. -__author__ = "Shirley Noel " +__authors__ = ( + "Shirley Noel ", + "Frédéric Brugnot ", + "Julien Cochuyt ", + "André-Abush Clause ", + "Sendhil Randon ", + "Gatien Bouyssou ", +) import sys @@ -34,8 +41,8 @@ import speech import ui -from ..utils import guarded, logException -from . import ScalingMixin, showContextualDialog +from ...utils import guarded, logException +from .. import ScalingMixin, showContextualDialog if sys.version_info[1] < 9: @@ -216,4 +223,4 @@ def _captureFunc(self, gesture): def show(context: Mapping[str, Any], parent: wx.Window): - return showContextualDialog(GestureBindingDialog, context, parent) + return showContextualDialog(GestureBindingDialog, context, parent) == wx.ID_OK diff --git a/addon/globalPlugins/webAccess/gui/actions.py b/addon/globalPlugins/webAccess/gui/rule/gestures.py similarity index 62% rename from addon/globalPlugins/webAccess/gui/actions.py rename to addon/globalPlugins/webAccess/gui/rule/gestures.py index a2cd5fed..bc76c72b 100644 --- a/addon/globalPlugins/webAccess/gui/actions.py +++ b/addon/globalPlugins/webAccess/gui/rule/gestures.py @@ -1,4 +1,4 @@ -# globalPlugins/webAccess/gui/actions.py +# globalPlugins/webAccess/gui/rule/gestures.py # -*- coding: utf-8 -*- # This file is part of Web Access for NVDA. @@ -23,6 +23,9 @@ __authors__ = ( "Shirley Noel ", "Julien Cochuyt ", + "André-Abush Clause ", + "Sendhil Randon ", + "Gatien Bouyssou ", ) @@ -35,10 +38,11 @@ import gui from gui import guiHelper -from ..ruleHandler import ruleTypes -from ..utils import guarded -from . import ContextualSettingsPanel, Change, gestureBinding -from .rule.abc import RuleAwarePanelBase +from ...ruleHandler import ruleTypes +from ...utils import guarded +from .. import ContextualSettingsPanel, Change +from . import gestureBinding +from .abc import RuleAwarePanelBase if sys.version_info[1] < 9: @@ -50,22 +54,22 @@ addonHandler.initTranslation() -class ActionsPanelBase(RuleAwarePanelBase, metaclass=guiHelper.SIPABCMeta): - """ABC for Actions panels +class GesturesPanelBase(RuleAwarePanelBase, metaclass=guiHelper.SIPABCMeta): + """ABC for Gestures panels Sub-classes must implement the methods `getData` (inherited from `ContextualSettingsPanel`) and `getRuleType` (inherited from `RuleTypeAware`). Known sub-classes: - - `criteriaEditor.ActionsPanel` - - `ruleEditor.ActionsPanel` + - `criteriaEditor.GesturesPanel` + - `ruleEditor.GesturesPanel` """ # Translators: The label for a category in the Rule and Criteria editors - title = _("Actions") + title = _("Input Gestures") - # Translators: Displayed when the selected rule type doesn't support any action - descriptionIfNoneSupported = _("No action available for the selected rule type.") + # Translators: Displayed when the selected rule type doesn't support input gestures + descriptionIfNoneSupported = _("The selected Rule Type does not support Input Gestures.") def __init__(self, *args, **kwargs): self.hideable: Mapping[str, Sequence[wx.Window]] = {} @@ -83,14 +87,14 @@ def makeSettings(self, settingsSizer): item = wx.StaticText(self, label=self.descriptionIfNoneSupported) item.Hide() items.append(item) - gbSizer.Add(item, pos=(row, 0), span=(1, 5), flag=wx.EXPAND) + gbSizer.Add(item, pos=(row, 0), span=(1, 3), flag=wx.EXPAND) row += 1 items = self.hideable["IfSupported"] = [] # Translators: The label for a list on the Rule Editor dialog item = wx.StaticText(self, label=_("&Gestures")) items.append(item) - gbSizer.Add(item, pos=(row, col), span=(1, 3), flag=wx.EXPAND) + gbSizer.Add(item, pos=(row, col), flag=wx.EXPAND) row += 1 item = gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL), pos=(row, 0)) @@ -99,9 +103,11 @@ def makeSettings(self, settingsSizer): row += 1 item = self.gesturesListBox = wx.ListBox(self, size=scale(-1, 100)) items.append(item) - gbSizer.Add(item, pos=(row, col), span=(6, 3), flag=wx.EXPAND) + gbSizer.Add(item, pos=(row, col), span=(6, 1), flag=wx.EXPAND) + gbSizer.AddGrowableCol(col) + gbSizer.AddGrowableRow(row + 5) - col += 3 + col += 1 item = gbSizer.Add(scale(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL, 0), pos=(row, col)) items.append(item) @@ -133,72 +139,33 @@ def makeSettings(self, settingsSizer): item.Bind(wx.EVT_BUTTON, self.onDeleteGesture) items.append(item) gbSizer.Add(item, pos=(row, col), flag=wx.EXPAND) - - row += 2 - col = 0 - item = gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), pos=(row, col)) - items.append(item) - - row += 1 - # Translators: Automatic action at rule detection input label for the rule dialog's action panel. - item = wx.StaticText(self, label=_("A&utomatic action at rule detection:")) - items.append(item) - gbSizer.Add(item, pos=(row, col)) - - col += 1 - item = gbSizer.Add(scale(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL, 0), pos=(row, col)) - items.append(item) - - col += 1 - item = self.autoActionChoice = wx.Choice(self, choices=[]) - item.Bind(wx.EVT_CHOICE, self.onAutoActionChoice) - items.append(item) - gbSizer.Add(item, pos=(row, col), span=(1, 3), flag=wx.EXPAND) - - gbSizer.AddGrowableCol(2) def initData(self, context: Mapping[str, Any]) -> None: super().initData(context) data = self.getData() self.gesturesMap = data.setdefault("gestures", {}) self.updateGesturesListBox() - self.updateAutoActionChoice(refreshChoices=True) def updateData(self): # Nothing to update: This panel writes directly into the data map. pass - def getAutoAction(self): - return self.getData().get("properties", {}).get("autoAction") - - def getAutoActionChoices(self) -> Mapping[str, str]: - mgr = self.context["webModule"].ruleManager - # Translators: An entry in the Automatic Action selection list. - choices = {None: pgettext("webAccess.action", "No action")} - choices.update(mgr.getActions()) - action = self.getAutoAction() - if action not in choices: - choices[action] = f"*{action}" - return choices - def getSelectedGesture(self): index = self.gesturesListBox.Selection return self.gesturesListBox.GetClientData(index) if index > -1 else None - @guarded - def onAutoActionChoice(self, evt): - action = evt.EventObject.GetClientData(evt.Selection) - self.getData().setdefault("properties", {})["autoAction"] = action - @guarded def onAddGesture(self, evt): context = self.context - context["data"]["gestures"] = self.gesturesMap - if gestureBinding.show(context=context, parent=self) == wx.ID_OK: - id = context["data"].pop("gestureBinding")["gestureIdentifier"] + data = context["data"] + data["gestureBinding"] = {} + data["gestures"] = self.gesturesMap + if gestureBinding.show(context, self): + id = data["gestureBinding"]["gestureIdentifier"] self.onGestureChange(Change.CREATION, id) - del context["data"]["gestures"] - + del data["gestureBinding"] + del data["gestures"] + @guarded def onDeleteGesture(self, evt): index = self.gesturesListBox.Selection @@ -209,33 +176,23 @@ def onDeleteGesture(self, evt): @guarded def onEditGesture(self, evt): context = self.context - gestures = context["data"]["gestures"] = self.gesturesMap id = self.getSelectedGesture() - context["data"]["gestureBinding"] = {"gestureIdentifier": id, "action": gestures[id]} - if gestureBinding.show(context=context, parent=self) == wx.ID_OK: - id = context["data"]["gestureBinding"]["gestureIdentifier"] + data = context["data"] + data["gestureBinding"] = { + "gestureIdentifier": id, "action": self.gesturesMap[id] + } + data["gestures"] = self.gesturesMap + if gestureBinding.show(context=context, parent=self): + id = data["gestureBinding"]["gestureIdentifier"] self.onGestureChange(Change.UPDATE, id) - del context["data"]["gestureBinding"] - del context["data"]["gestures"] + del data["gestureBinding"] + del data["gestures"] def onGestureChange(self, change: Change, id: str): if change is Change.DELETION: index = None self.updateGesturesListBox(selectId=id, focus=True) - def updateAutoActionChoice(self, refreshChoices: bool): - ctrl = self.autoActionChoice - value = self.getAutoAction() - if refreshChoices: - choices = self.getAutoActionChoices() - ctrl.Clear() - for action, displayName in choices.items(): - ctrl.Append(displayName, action) - index = tuple(choices.keys()).index(self.getAutoAction()) - else: - index = tuple(ctrl.GetClientData(i) for i in range(ctrl.Count)).index(value) - ctrl.SetSelection(index) - def updateGesturesListBox(self, selectId: str = None, focus: bool = False): mgr = self.getRuleManager() map = self.gesturesMap @@ -265,7 +222,7 @@ def updateGesturesListBox(self, selectId: str = None, focus: bool = False): @guarded def onPanelActivated(self): super().onPanelActivated() - supported = self.getRuleType() in (ruleTypes.ZONE, ruleTypes.MARKER) + supported = self.getRuleType() in ruleTypes.ACTION_TYPES self.panelDescription = "" if supported else self.descriptionIfNoneSupported self.Freeze() for item in self.hideable["IfSupported"]: @@ -274,13 +231,3 @@ def onPanelActivated(self): item.Show(not supported) self.Thaw() self._sendLayoutUpdatedEvent() - - def onSave(self): - super().onSave() - data = self.getData() - if self.getRuleType() not in (ruleTypes.ZONE, ruleTypes.MARKER): - data.pop("gestures", None) - data.get("properties", {}).pop("autoAction", None) - elif not data.get("gestures"): - data.pop("gestures", None) - diff --git a/addon/globalPlugins/webAccess/gui/rule/manager.py b/addon/globalPlugins/webAccess/gui/rule/manager.py index d05c1bcb..3598686e 100644 --- a/addon/globalPlugins/webAccess/gui/rule/manager.py +++ b/addon/globalPlugins/webAccess/gui/rule/manager.py @@ -20,10 +20,17 @@ # See the file COPYING.txt at the root of this distribution for more details. -__author__ = "Shirley Noël " +__authors__ = ( + "Julien Cochuyt ", + "Shirley Noël ", + "Frédéric Brugnot ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) from collections import namedtuple +import sys import wx import addonHandler @@ -32,20 +39,20 @@ from gui import guiHelper import inputCore import queueHandler +import ui -from ...ruleHandler import ( - Rule, - Result, - Zone, - builtinRuleActions, - ruleTypes, - showCreator, - showEditor, -) +from ...ruleHandler import Rule, Result, Zone, ruleTypes from ...utils import guarded from ...webModuleHandler import getEditableWebModule, save -from .. import ScalingMixin -from ..rule.editor import getSummary +from .. import ContextualDialog, showContextualDialog, stripAccel +from .editor import getSummary + + +if sys.version_info[1] < 9: + from typing import Mapping +else: + from collections.abc import Mapping + try: from six import iteritems @@ -61,15 +68,29 @@ lastActiveOnly = False -def show(context): - gui.mainFrame.prePopup() - Dialog(gui.mainFrame).ShowModal(context) - gui.mainFrame.postPopup() +def show(context, parent): + showContextualDialog(Dialog, context, parent) TreeItemData = namedtuple("TreeItemData", ("label", "obj", "children")) +def getCriteriaLabel(criteria): + rule = criteria.rule + label = rule.name + if len(rule.criteria) > 1: + if criteria.name: + label += f" - {criteria.name}" + else: + label += f" - #{rule.criteria.index(criteria) + 1}" + if rule._gestureMap: + label += " ({gestures})".format(gestures=", ".join( + inputCore.getDisplayTextForGestureIdentifier(identifier)[1] + for identifier in list(rule._gestureMap.keys()) + )) + return label + + def getGestureLabel(gesture): source, main = inputCore.getDisplayTextForGestureIdentifier( inputCore.normalizeGestureIdentifier(gesture) @@ -92,7 +113,7 @@ def getRuleLabel(rule): def getRules(ruleManager): webModule = ruleManager.webModule if not webModule.isReadOnly(): - layer = webModule._getWritableLayer().name + layer = webModule.getWritableLayer().name elif config.conf["webAccess"]["devMode"]: layer = None else: @@ -107,10 +128,10 @@ def rule_getResults_safe(rule): return [] -def getRulesByGesture(ruleManager, filter=None, active=False): +def iterRulesByGesture(ruleManager, filter=None, active=False): gestures = {} noGesture = [] - + for rule in getRules(ruleManager): if filter and filter not in rule.name: continue @@ -122,7 +143,7 @@ def getRulesByGesture(ruleManager, filter=None, active=False): label=( "{rule} - {action}".format( rule=rule.name, - action=builtinRuleActions.get(action, action) + action=rule.ruleManager.getActions().get(action, f"*{action}") ) if action != "moveto" else rule.name @@ -152,6 +173,54 @@ def getRulesByGesture(ruleManager, filter=None, active=False): ) +def getRulesByContext(ruleManager, filter=None, active=False): + contexts: Mapping[tuple[str, str, str], Rule] = {} + for rule in getRules(ruleManager): + if filter and filter not in rule.name.casefold(): + continue + if active: + results = rule_getResults_safe(rule) + if not results: + continue + alternatives = (result.criteria for result in results) + else: + alternatives = (criteria for criteria in rule.criteria) + for criteria in alternatives: + contexts.setdefault(( + criteria.contextPageTitle or "", # Avoiding None eases later sorting + criteria.contextPageType or "", + criteria.contextParent or "", + ), []).append(TreeItemData( + label=getCriteriaLabel(criteria), + obj=rule, + children=[] + )) + for context, tids in sorted( + contexts.items(), + key=lambda item: (item[0] == ("", "", ""), item[0]) # Move "General" to the end + ): + parts = [] + if context[0]: + # Translators: A part of a context grouping label on the Rules Manager + parts.append(_("Page Title: {contextPageTitle}").format(contextPageTitle=context[0])) + if context[1]: + # Translators: A part of a context grouping label on the Rules Manager + parts.append(_("Page Type: {contextPageTitle}").format(contextPageTitle=context[1])) + if context[2]: + # Translators: A part of a context grouping label on the Rules Manager + parts.append(_("Parent: {contextPageTitle}").format(contextPageTitle=context[2])) + if parts: + label = ", ".join(parts) + else: + # Translators: A context grouping label on the Rules Manager + label = "General" + yield TreeItemData( + label=label, + obj=None, + children=sorted(tids, key=lambda tid: tid.label) + ) + + def getRulesByName(ruleManager, filter=None, active=False): return sorted( ( @@ -162,11 +231,11 @@ def getRulesByName(ruleManager, filter=None, active=False): ) for rule in getRules(ruleManager) if ( - (not filter or filter.lower() in rule.name.lower()) + (not filter or filter in rule.name.casefold()) and (not active or rule_getResults_safe(rule)) ) ), - key=lambda tid: tid.label.lower() + key=lambda tid: tid.label.casefold() ) @@ -174,70 +243,52 @@ def getRulesByPosition(ruleManager, filter=None, active=True): """ Yield rules by position. + Includes results from all active WebModules on the document. As position depends on result, the `active` criteria is ignored. """ - Parent = namedtuple("Parent", ("parent", "tid", "zone")) - - def filterChildlessParent(parent): - if ( - not filter - or parent.tid.children - or filter.lower() in parent.tid.obj.name.lower() - ): - return False - if parent.parent: - parent.parent.tid.children.remove(parent) - return True - webModule = ruleManager.webModule if not webModule.isReadOnly(): - layer = webModule._getWritableLayer() + layer = webModule.getWritableLayer().name elif config.conf["webAccess"]["devMode"]: layer = None else: - return - - parent = None - for result in ruleManager.getResults(): + return [] + roots: list[TreeItemData] = [] + ancestors: list[TreeItemData] = [] + for result in ruleManager.rootRuleManager.getAllResults(): rule = result.rule - if layer is not None and rule.layer != layer.name: + if layer and rule.layer != layer: continue tid = TreeItemData( - label=getRuleLabel(rule), + label=getCriteriaLabel(result.criteria), obj=result, children=[] ) - zone = None - if rule.type in (ruleTypes.PARENT, ruleTypes.ZONE): - zone = Zone(result) - elif filter and filter.lower() not in rule.name.lower(): - continue - while parent: - if parent.zone.containsResult(result): - parent.tid.children.append(tid) - if zone: - parent = Parent(parent, tid, zone) + while ancestors: + candidate = ancestors[-1] + if candidate.obj.containsResult(result): + candidate.children.append(tid) break - elif not filterChildlessParent(parent): - yield parent.tid - parent = parent.parent - else: # no parent - assert parent is None - if zone: - parent = Parent(None, tid, zone) - else: - yield tid - while parent: - if not filterChildlessParent(parent): - yield parent.tid - parent = parent.parent + ancestors.pop() + else: + roots.append(tid) + if result.rule.type in (ruleTypes.PARENT, ruleTypes.ZONE): + ancestors.append(tid) + + def passesFilter(tid) -> bool: + for index, child in enumerate(tid.children.copy()): + if not passesFilter(child) : + del tid.children[index] + return tid.children or filter in tid.obj.name.casefold() + + return tuple(tid for tid in roots if passesFilter(tid)) def getRulesByType(ruleManager, filter=None, active=False): types = {} for rule in getRules(ruleManager): if ( - (filter and filter.lower() not in rule.name.lower()) + (filter and filter not in rule.name.casefold()) or (active and not rule_getResults_safe(rule)) ): continue @@ -272,11 +323,17 @@ def getRulesByType(ruleManager, filter=None, active=False): label=pgettext("webAccess.rulesGroupBy", "&Type"), func=getRulesByType ), + GroupBy( + id="type", + # Translator: Grouping option on the RulesManager dialog. + label=pgettext("webAccess.rulesGroupBy", "&Context"), + func=getRulesByContext + ), GroupBy( id="gestures", # Translator: Grouping option on the RulesManager dialog. label=pgettext("webAccess.rulesGroupBy", "&Gestures"), - func=getRulesByGesture + func=iterRulesByGesture ), GroupBy( id="name", @@ -287,54 +344,61 @@ def getRulesByType(ruleManager, filter=None, active=False): ) -class Dialog(wx.Dialog, ScalingMixin): - +class Dialog(ContextualDialog): + def __init__(self, parent): - scale = self.scale super().__init__( - parent=gui.mainFrame, - id=wx.ID_ANY, + parent, style=wx.DEFAULT_DIALOG_STYLE | wx.MAXIMIZE_BOX ) - + + scale = self.scale + self.Bind(wx.EVT_CHAR_HOOK, self.onCharHook) mainSizer = wx.BoxSizer(wx.VERTICAL) contentsSizer = wx.BoxSizer(wx.VERTICAL) - + item = self.groupByRadio = wx.RadioBox( self, # Translator: A label on the RulesManager dialog. label=_("Group by: "), choices=tuple((groupBy.label for groupBy in GROUP_BY)), - majorDimension=len(GROUP_BY) + 3 # +1 for the label ) item.Bind(wx.EVT_RADIOBOX, self.onGroupByRadio) contentsSizer.Add(item, flag=wx.EXPAND) contentsSizer.AddSpacer(scale(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS)) - - filtersSizer = wx.GridSizer(1, 2, 10, 10) - - labeledCtrlHelper = guiHelper.LabeledControlHelper( - self, - # Translator: A label on the RulesManager dialog. - _("&Filter: "), - wx.TextCtrl, size=scale(250, -1), style=wx.TE_PROCESS_ENTER - ) - item = self.filterEdit = labeledCtrlHelper.control + + filtersSizer = wx.GridBagSizer() + filtersSizer.SetEmptyCellSize((0, 0)) + + row = 0 + col = 0 + # Translator: A label on the RulesManager dialog. + item = wx.StaticText(self, label=_("&Filter: ")) + filtersSizer.Add(item, (row, col)) + col += 1 + filtersSizer.Add(scale(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL, 0), (row, col)) + col += 1 + item = self.filterEdit = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) item.Bind(wx.EVT_TEXT, lambda evt: self.refreshRuleList()) item.Bind(wx.EVT_TEXT_ENTER, lambda evt: self.tree.SetFocus()) - filtersSizer.Add(labeledCtrlHelper.sizer, flag=wx.EXPAND) - - self.activeOnlyCheckBox = wx.CheckBox( + filtersSizer.Add(item, (row, col), flag=wx.EXPAND) + filtersSizer.AddGrowableCol(col) + + col += 1 + filtersSizer.Add(scale(20, 0), (row, col)) + + col += 1 + item = self.activeOnlyCheckBox = wx.CheckBox( self, # Translator: A label on the RulesManager dialog. label=_("Include only rules &active on the current page") ) - self.activeOnlyCheckBox.Bind(wx.EVT_CHECKBOX, self.onActiveOnlyCheckBox) - filtersSizer.Add(self.activeOnlyCheckBox) - + item.Bind(wx.EVT_CHECKBOX, self.onActiveOnlyCheckBox) + filtersSizer.Add(item, (row, col)) + contentsSizer.Add(filtersSizer, flag=wx.EXPAND) contentsSizer.AddSpacer(scale(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS)) - + item = self.tree = wx.TreeCtrl( self, size=scale(700, 300), @@ -346,12 +410,12 @@ def __init__(self, parent): self.treeRoot = item.AddRoot("root") contentsSizer.Add(item, flag=wx.EXPAND, proportion=2) contentsSizer.AddSpacer(scale(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS)) - + descSizer = wx.GridBagSizer() descSizer.EmptyCellSize = (0, 0) contentsSizer.Add(descSizer, flag=wx.EXPAND, proportion=1) #contentsSizer.Add(descSizer, flag=wx.EXPAND) - + # Translator: The label for a field on the Rules manager item = wx.StaticText(self, label=_("Summary")) descSizer.Add(item, pos=(0, 0), flag=wx.EXPAND) @@ -360,22 +424,22 @@ def __init__(self, parent): self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP | wx.TE_RICH ) descSizer.Add(item, pos=(2, 0), flag=wx.EXPAND) - + descSizer.Add(scale(guiHelper.SPACE_BETWEEN_BUTTONS_HORIZONTAL, 0), pos=(0, 1)) - + # Translator: The label for a field on the Rules manager item = wx.StaticText(self, label=_("Technical notes")) descSizer.Add(item, pos=(0, 2), flag=wx.EXPAND) descSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL), pos=(1, 2)) item = self.ruleComment = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY) descSizer.Add(item, pos=(2, 2), flag=wx.EXPAND) - + descSizer.AddGrowableCol(0) descSizer.AddGrowableCol(2) descSizer.AddGrowableRow(2) - + contentsSizer.AddSpacer(scale(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS)) - + btnHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) item = self.resultMoveToButton = btnHelper.addButton( self, @@ -385,14 +449,14 @@ def __init__(self, parent): item.Bind(wx.EVT_BUTTON, self.onResultMoveTo) self.AffirmativeId = item.Id item.SetDefault() - + item = btnHelper.addButton( self, # Translator: The label for a button on the RulesManager dialog. label=_("&New rule...") ) item.Bind(wx.EVT_BUTTON, self.onRuleNew) - + item = self.ruleEditButton = btnHelper.addButton( self, # Translator: The label for a button on the RulesManager dialog. @@ -400,7 +464,7 @@ def __init__(self, parent): ) item.Bind(wx.EVT_BUTTON, self.onRuleEdit) item.Enabled = False - + item = self.ruleDeleteButton = btnHelper.addButton( self, # Translator: The label for a button on the RulesManager dialog. @@ -408,7 +472,7 @@ def __init__(self, parent): ) item.Bind(wx.EVT_BUTTON, self.onRuleDelete) item.Enabled = False - + contentsSizer.Add(btnHelper.sizer, flag=wx.ALIGN_RIGHT) mainSizer.Add( contentsSizer, @@ -424,31 +488,23 @@ def __init__(self, parent): mainSizer.Fit(self) self.Sizer = mainSizer self.CentreOnScreen() - + self.tree.SetFocus() + def initData(self, context): global lastGroupBy, lastActiveOnly - self.context = context - ruleManager = self.ruleManager = context["webModule"].ruleManager - webModule = ruleManager.webModule - title = "Web Module - {}".format(webModule.name) - if config.conf["webAccess"]["devMode"]: - title += " ({})".format("/".join((layer.name for layer in webModule.layers))) - self.Title = title + super().initData(context) + context["initialSelectedResult"] = context.get("result") self.activeOnlyCheckBox.Value = lastActiveOnly - self.groupByRadio.Selection = next(( - index - for index, groupBy in enumerate(GROUP_BY) - if groupBy.id == lastGroupBy - )) - self.onGroupByRadio(None, refresh=True) - self.refreshRuleList(selectObj=context.get("result")) - + mgr = context["webModule"].ruleManager + # disableGroupByPosition returns True if it triggered refresh + not mgr.isReady and self.disableGroupByPosition() or self.onGroupByRadio(None) + def getSelectedObject(self): selection = self.tree.Selection if not selection.IsOk(): return None return self.tree.GetItemData(self.tree.Selection).obj - + def getSelectedRule(self): obj = self.getSelectedObject() if not obj: @@ -458,64 +514,109 @@ def getSelectedRule(self): elif isinstance(obj, Result): return obj.rule return None - - def refreshRuleList(self, selectName=None, selectObj=None): + + def cycleGroupBy(self, previous: bool = False, report: bool = True): + radioBox = self.groupByRadio + index = radioBox.Selection + for safeGuard in range(radioBox.Count): + index = (index + (-1 if previous else 1)) % radioBox.Count + if radioBox.IsItemEnabled(index): + break + safeGuard += 1 + radioBox.SetSelection(index) + if report: + # Translators: Reported when cycling through rules grouping on the Rules Manager dialog + ui.message(_("Group by: {}").format( + stripAccel(GROUP_BY[self.groupByRadio.GetSelection()].label).lower()) + ) + self.onGroupByRadio(None) + + def disableGroupByPosition(self) -> bool: + """Returns `True` if the tree was refreshed as of this call. + """ + radioBox = self.groupByRadio + index = next(i for i, g in enumerate(GROUP_BY) if g.id == "position") + if radioBox.IsItemEnabled(index): + radioBox.EnableItem(index, False) + if radioBox.Selection == index: + self.cycleGroupBy(previous=True, report=False) # Selects groupBy name + return True + return False + + def refreshRuleList(self): + context = self.context + result = context.pop("initialSelectedResult", None) groupBy = GROUP_BY[self.groupByRadio.GetSelection()] - filter = self.filterEdit.GetValue() + if groupBy.id == "position": + selectObj = result + else: + # Pop the just created or edited rule in order to avoid keeping it selected + # when later cycling through groupBy + selectObj = context.pop("rule", result.rule if result else None) + filter = self.filterEdit.Value.casefold() active = self.activeOnlyCheckBox.Value - self.tree.DeleteChildren(self.treeRoot) - + tree = self.tree + root = self.treeRoot + tree.DeleteChildren(root) + tids = groupBy.func( - self.ruleManager, + self.context["webModule"].ruleManager, filter, active ) if groupBy.func else [] - - # Would be replaced by use of nonlocal in Python 3 - class SharedScope(object): - __slots__ = ("selectTreeItem",) - - shared = SharedScope() - shared.selectTreeItem = None - selectRule = None - if selectObj and isinstance(selectObj, Result): - selectRule = selectObj.rule - + + selectTreeItem = None + def addToTree(parent, tids): + nonlocal selectTreeItem for tid in tids: - tii = self.tree.AppendItem(parent, tid.label) - self.tree.SetItemData(tii, tid) - if shared.selectTreeItem is None: - if selectName: - if tid.label == selectName: - shared.selectTreeItem = tii - elif selectObj is not None: - if tid.obj is selectObj: - shared.selectTreeItem = tii - elif selectRule is not None and tid.obj is selectRule: - shared.selectTreeItem = tii + tii = tree.AppendItem(parent, tid.label) + tree.SetItemData(tii, tid) + if selectTreeItem is None: + if tid.obj is selectObj: + selectTreeItem = tii if tid.children: addToTree(tii, tid.children) - - addToTree(self.treeRoot, tids) - + + addToTree(root, tids) + if filter or groupBy.id == "position": - self.tree.ExpandAllChildren(self.treeRoot) - - if shared.selectTreeItem is not None: - # Async call ensures the selection won't get lost. - wx.CallAfter(self.tree.SelectItem, shared.selectTreeItem) - # Sync call ensures NVDA won't announce the first item of - # the tree before reporting the selection. - #self.tree.SelectItem(shared.selectTreeItem) - return - - def unselect(): - self.tree.Unselect() - self.onTreeSelChanged(None) - - wx.CallAfter(unselect) - + tree.ExpandAllChildren(root) + + if selectTreeItem is None and groupBy.id != "position": + firstChild, cookie = tree.GetFirstChild(root) + if firstChild.IsOk(): + selectTreeItem = firstChild + + if selectTreeItem: + tree.SelectItem(selectTreeItem) + tree.EnsureVisible(selectTreeItem) + + def refreshTitle(self): + context = self.context + webModule = context["webModule"] + groupBy = GROUP_BY[self.groupByRadio.GetSelection()] + if self.filterEdit.Value: + if groupBy.id != "position" and lastActiveOnly: + # Translators: A possible title of the Rules Manager dialog + title = "Web Module {} - Filtered active rules by {}" + else: + # Translators: A possible title of the Rules Manager dialog + title = "Web Module {} - Filtered rules by {}" + else: + if groupBy.id != "position" and lastActiveOnly: + # Translators: A possible title of the Rules Manager dialog + title = "Web Module {} - Active rules by {}" + else: + # Translators: A possible title of the Rules Manager dialog + title = "Web Module {} - Rules by {}" + title = title.format(webModule.name, stripAccel(groupBy.label).lower()) + if groupBy.id == "position" and webModule.ruleManager.rootRuleManager.subModules.all(): + title += " for all active WebModules on this page" + if config.conf["webAccess"]["devMode"]: + title += " ({})".format("/".join((layer.name for layer in webModule.layers))) + self.Title = title + @guarded def onActiveOnlyCheckBox(self, evt): global lastActiveOnly @@ -523,10 +624,52 @@ def onActiveOnlyCheckBox(self, evt): return lastActiveOnly = self.activeOnlyCheckBox.Value self.refreshRuleList() - + + @guarded + def onCharHook(self, evt: wx.KeyEvent): + keycode = evt.KeyCode + if keycode == wx.WXK_ESCAPE: + # Try to limit the difficulty of closing the dialog using the keyboard + # in the event of an error later in this function + evt.Skip() + return + elif keycode == wx.WXK_F6 and not evt.GetModifiers(): + if self.tree.HasFocus(): + getattr(self, "_lastDetails", self.ruleSummary).SetFocus() + return + else: + for ctrl in (self.ruleSummary, self.ruleComment): + if ctrl.HasFocus(): + self._lastDetails = ctrl + self.tree.SetFocus() + return + elif keycode == wx.WXK_RETURN and not evt.GetModifiers(): + # filterEdit is handled separately (TE_PROCESS_ENTER) + for ctrl in (self.groupByRadio, self.activeOnlyCheckBox): + if ctrl.HasFocus(): + self.tree.SetFocus() + return + elif keycode == wx.WXK_TAB and evt.ControlDown(): + self.cycleGroupBy(previous=evt.ShiftDown()) + return + elif self.tree.HasFocus(): + # Collapse/Expand all instead of current node as there are only two levels. + # To also handle "*" and "/" from alphanum section of the keyboard with respect to the + # currently active keyboard layout would require calling GetKeyboardLayout and ToUnicodeEx + # (passing 0 as vkState) from user32.dll. An example can be found in NVDA's keyboardHandler. + # Probably overkill, though. + if keycode == wx.WXK_NUMPAD_MULTIPLY: + self.tree.ExpandAll() + return + elif keycode == wx.WXK_NUMPAD_DIVIDE: + self.tree.CollapseAll() + return + evt.Skip() + @guarded - def onGroupByRadio(self, evt, refresh=True): + def onGroupByRadio(self, evt=None, report=False): global lastGroupBy, lastActiveOnly + self.refreshTitle() groupBy = GROUP_BY[self.groupByRadio.GetSelection()] lastGroupBy = groupBy.id if groupBy.id == "position": @@ -536,9 +679,8 @@ def onGroupByRadio(self, evt, refresh=True): else: self.activeOnlyCheckBox.Value = lastActiveOnly self.activeOnlyCheckBox.Enabled = True - if refresh: - self.refreshRuleList() - + self.refreshRuleList() + @guarded def onResultMoveTo(self, evt): obj = self.getSelectedObject() @@ -559,7 +701,7 @@ def onResultMoveTo(self, evt): None ) self.Close() - + @guarded def onRuleDelete(self, evt): rule = self.getSelectedRule() @@ -576,60 +718,63 @@ def onRuleDelete(self, evt): # Translator: The title for a confirmation prompt on the # RulesManager dialog. _("Confirm Deletion"), - wx.YES | wx.NO | wx.CANCEL | wx.ICON_QUESTION, self + wx.YES_NO | wx.CANCEL | wx.ICON_QUESTION, self ) == wx.YES: - webModule = getEditableWebModule(self.ruleManager.webModule, layerName=rule.layer) + webModule = getEditableWebModule(self.context["webModule"], layerName=rule.layer) if not webModule: return - self.ruleManager.removeRule(rule) + rule.ruleManager.removeRule(rule) save( webModule=self.context["webModule"], layerName=rule.layer, ) self.refreshRuleList() wx.CallAfter(self.tree.SetFocus) - + @guarded def onRuleEdit(self, evt): rule = self.getSelectedRule() if not rule: wx.Bell() return - context = self.context.copy() # Shallow copy + context = self.context.copy() + context["new"] = False context["rule"] = rule - if showEditor(context, parent=self): - # Pass the eventually changed rule name - self.refreshRuleList(context["data"]["rule"]["name"]) + context["webModule"] = rule.ruleManager.webModule + from .editor import show + if show(context, parent=self): + rule = self.context["rule"] = context["rule"] + # As the rule changed, all results are to be considered obsolete + if not self.disableGroupByPosition(): + self.refreshRuleList() wx.CallAfter(self.tree.SetFocus) - + @guarded def onRuleNew(self, evt): - context = self.context.copy() # Shallow copy - if showCreator(context, parent=self): - self.Close() - return -# self.groupByRadio.SetSelection(next(iter(( -# index -# for index, groupBy in enumerate(GROUP_BY) -# if groupBy.id == "name" -# )))) -# self.refreshRuleList(context["data"]["rule"]["name"]) + context = self.context.copy() + context["new"] = True + from .editor import show + if show(context, self.Parent): + rule = self.context["rule"] = context["rule"] + # As a new rule was created, all results are to be considered obsolete + if not self.disableGroupByPosition(): + self.refreshRuleList() wx.CallAfter(self.tree.SetFocus) - + @guarded def onTreeItemActivated(self, evt): self.onResultMoveTo(evt) - + @guarded def onTreeKeyDown(self, evt): - if evt.KeyCode == wx.WXK_F2: - self.onRuleEdit(evt) - elif evt.KeyCode == wx.WXK_DELETE: - self.onRuleDelete(evt) + keycode = evt.KeyCode + if keycode == wx.WXK_DELETE: + self.onRuleDelete(None) + elif keycode == wx.WXK_F2: + self.onRuleEdit(None) else: - return - evt.Skip() - + evt.Skip() + @guarded def onTreeSelChanged(self, evt): if ( @@ -653,10 +798,3 @@ def onTreeSelChanged(self, evt): context["rule"] = rule self.ruleSummary.Value = getSummary(context, rule.dump()) self.ruleComment.Value = rule.comment or "" - - def ShowModal(self, context): - self.initData(context) - self.Fit() - self.CentreOnScreen() - self.tree.SetFocus() - return super().ShowModal() diff --git a/addon/globalPlugins/webAccess/gui/properties.py b/addon/globalPlugins/webAccess/gui/rule/properties.py similarity index 87% rename from addon/globalPlugins/webAccess/gui/properties.py rename to addon/globalPlugins/webAccess/gui/rule/properties.py index ed87fee6..e1908369 100644 --- a/addon/globalPlugins/webAccess/gui/properties.py +++ b/addon/globalPlugins/webAccess/gui/rule/properties.py @@ -1,4 +1,4 @@ -# globalPlugins/webAccess/gui/properties.py +# globalPlugins/webAccess/gui/rule/properties.py # -*- coding: utf-8 -*- # This file is part of Web Access for NVDA. @@ -19,7 +19,14 @@ # # See the file COPYING.txt at the root of this distribution for more details. -__author__ = "Sendhil Randon " + +__authors__ = ( + "Sendhil Randon ", + "André-Abush Clause ", + "Gatien Bouyssou ", + "Julien Cochuyt ", +) + from collections import ChainMap from abc import abstractmethod @@ -34,10 +41,10 @@ import speech import ui -from ..ruleHandler.controlMutation import MUTATIONS_BY_RULE_TYPE, mutationLabels -from ..ruleHandler.properties import PropertiesBase, PropertySpec, PropertySpecValue, PropertyValue -from ..utils import guarded, logException -from . import ContextualSettingsPanel, EditorType, ListCtrlAutoWidth, SingleFieldEditorMixin +from ...ruleHandler.controlMutation import MUTATIONS_BY_RULE_TYPE, mutationLabels +from ...ruleHandler.properties import PropertiesBase, PropertySpec, PropertySpecValue, PropertyValue +from ...utils import guarded, logException, updateOrDrop +from .. import ContextualSettingsPanel, EditorType, ListCtrlAutoWidth, SingleFieldEditorMixin if sys.version_info[1] < 9: @@ -128,6 +135,8 @@ def displayValueIfUndefined(self) -> str: def editorType(self) -> EditorType: if self.isRestrictedChoice: return EditorType.CHOICE + elif self.hasSuggestions: + return EditorType.COMBO elif issubclass(self.valueType, bool): return EditorType.CHECKBOX elif issubclass(self.valueType, str): @@ -135,6 +144,25 @@ def editorType(self) -> EditorType: else: raise Exception(f"Unable to determine EditorType for property {self.name!r}") + @property + @logException + def suggestions(self): + if self.editorType is not EditorType.COMBO: + return None + container = self._container + context = container._context + cache = context.setdefault("propertySuggestionsCache", {}) + name = self.name + if name in cache: + return cache[name] + if name == "subModule": + from ...webModuleHandler import getCatalog + suggestions = tuple(sorted({meta["name"] for ref, meta in getCatalog()})) + else: + raise ValueError(f"prop.name: {name!r}") + cache[name] = suggestions + return suggestions + @property @logException def value(self) -> PropertyValue: @@ -148,7 +176,10 @@ def getDisplayValue(self, value): if value in (None, ""): return self.displayValueIfUndefined if self.isRestrictedChoice: - return self.choices[value] + try: + return self.choices[value] + except LookupError: + return f"*{value}" if self.valueType is bool: if value: # Translators: The displayed value of a yes/no rule property @@ -165,7 +196,7 @@ def reset(self) -> None: class Properties(PropertiesBase): - + def __init__(self, context: Mapping[str, Any], *maps: Mapping[str, PropertyValue], iterOnlyFirstMap=False): """iterOnlyFirstMap: True: When iterating, include only the properties defined in the first map. @@ -228,6 +259,10 @@ class SinglePropertyEditorPanelBase(SingleFieldEditorMixin, ContextualSettingsPa def editorChoices(self): return self.prop.choices + @property + def editorSuggestions(self): + return self.prop.suggestions + @property def editorType(self): return self.prop.editorType @@ -269,11 +304,6 @@ def setFieldValue(self, value): # @@@ self.prop.value = value - def onSave(self): - super().onSave() - if not self.getData(): - del super().getData()["properties"] - def prop_reset(self): self.prop.reset() self.updateEditor() @@ -307,6 +337,7 @@ def editor(self) -> wx.Control: return { EditorType.CHECKBOX: self.editor_checkBox, EditorType.CHOICE: self.editor_choice_ctrl, + EditorType.COMBO: self.editor_combo_ctrl, EditorType.TEXT: self.editor_text_ctrl, }[self.prop.editorType] @@ -316,6 +347,7 @@ def editorLabel(self) -> wx.Control: return { EditorType.CHECKBOX: self.editor_checkBox, EditorType.CHOICE: self.editor_choice_label, + EditorType.COMBO: self.editor_combo_label, EditorType.TEXT: self.editor_text_label, }[self.prop.editorType] @@ -331,6 +363,8 @@ def prop(self, prop: Property) -> None: editor = self.editor if prop.editorType is EditorType.CHOICE: self.updateEditorChoices() + elif prop.editorType is EditorType.COMBO: + self.updateEditorSuggestions() self.updateEditor() self.updateEditorLabel() self.Freeze() @@ -350,13 +384,13 @@ def makeSettings(self, settingsSizer): gbSizer = wx.GridBagSizer() gbSizer.EmptyCellSize = (0, 0) settingsSizer.Add(gbSizer, flag=wx.EXPAND, proportion=1) - + row = 0 items = hideable["hideIfSupported"] = [] item = wx.StaticText(self, label=self.descriptionIfNoneSupported) items.append(item) gbSizer.Add(item, pos=(row, 0), span=(1, 3), flag=wx.EXPAND) - + row += 1 items = hideable["hideIfNoneSupported"] = [] # Translators: The label for a list on the Rule Editor dialog @@ -364,11 +398,11 @@ def makeSettings(self, settingsSizer): item.Hide() items.append(item) gbSizer.Add(item, pos=(row, 0), span=(1, 3), flag=wx.EXPAND) - + row += 1 item = gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL), pos=(row, 0)) items.append(item) - + row += 1 item = self.listCtrl = ListCtrlAutoWidth(self, style=wx.LC_REPORT | wx.BORDER_SUNKEN) # Translators: A column header on the Rule Editor dialog @@ -380,11 +414,11 @@ def makeSettings(self, settingsSizer): items.append(item) gbSizer.Add(item, pos=(row, 0), span=(4, 3), flag=wx.EXPAND) gbSizer.AddGrowableRow(row + 3) - + row += 4 item = gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), pos=(row, 0)) items.append(item) - + row += 1 items = hideable["hideIfNotCHECKBOX"] = [] item = self.editor_checkBox = wx.CheckBox(self, label="") @@ -392,7 +426,7 @@ def makeSettings(self, settingsSizer): item.Bind(wx.EVT_CHAR_HOOK, self.onEditor_charHook) items.append(item) gbSizer.Add(item, pos=(row, 0), span=(1, 3), flag=wx.EXPAND) - + row += 1 items = hideable["hideIfNotCHOICE"] = [] item = self.editor_choice_label = wx.StaticText(self, label="") @@ -405,7 +439,20 @@ def makeSettings(self, settingsSizer): item.Bind(wx.EVT_CHAR_HOOK, self.onEditor_charHook) items.append(item) gbSizer.Add(item, pos=(row, 2), flag=wx.EXPAND) - + + row += 1 + items = hideable["hideIfNotCOMBO"] = [] + item = self.editor_combo_label = wx.StaticText(self, label="") + items.append(item) + gbSizer.Add(item, pos=(row, 0)) + item = gbSizer.Add(scale(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL, 0), pos=(row, 1)) + items.append(item) + item = self.editor_combo_ctrl = wx.ComboBox(self) + item.Bind(wx.EVT_TEXT, self.onEditor_combo) + item.Bind(wx.EVT_CHAR_HOOK, self.onEditor_charHook) + items.append(item) + gbSizer.Add(item, pos=(row, 2), flag=wx.EXPAND) + row += 1 items = hideable["hideIfNotTEXT"] = [] item = self.editor_text_label = wx.StaticText(self, label="") @@ -418,7 +465,7 @@ def makeSettings(self, settingsSizer): item.Bind(wx.EVT_CHAR_HOOK, self.onEditor_charHook) items.append(item) gbSizer.Add(item, pos=(row, 2), flag=wx.EXPAND) - + for item in ( hideable["hideIfNoneSupported"] + hideable["hideIfNotCHECKBOX"] @@ -426,7 +473,7 @@ def makeSettings(self, settingsSizer): + hideable["hideIfNotTEXT"] ): item.Show(False) - + gbSizer.AddGrowableCol(2) gbSizer.FitInside(self) self.gbSizer = gbSizer @@ -501,7 +548,7 @@ def onEditor_charHook(self, evt): return elif keycode == wx.WXK_DELETE and not mods: prop = self.prop - if prop.editorType is not EditorType.TEXT: + if prop.editorType not in (EditorType.COMBO, EditorType.TEXT): self.prop_reset() return evt.Skip() @@ -538,4 +585,4 @@ def onListCtrl_itemSelected(self, evt): # called by TreeMultiCategorySettingsDialog.onKeyDown def spaceIsPressedOnTreeNode(self, withShift=False): # No shift special handling on category panels tree node - self.listCtrl.SetFocus() \ No newline at end of file + self.listCtrl.SetFocus() diff --git a/addon/globalPlugins/webAccess/gui/webModule/__init__.py b/addon/globalPlugins/webAccess/gui/webModule/__init__.py new file mode 100644 index 00000000..5e1a81d7 --- /dev/null +++ b/addon/globalPlugins/webAccess/gui/webModule/__init__.py @@ -0,0 +1,88 @@ +# globalPlugins/webAccess/gui/rule/__init__.py +# -*- coding: utf-8 -*- + +# This file is part of Web Access for NVDA. +# Copyright (C) 2015-2024 Accessolutions (http://accessolutions.fr) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See the file COPYING.txt at the root of this distribution for more details. + + +__author__ = "Julien Cochuyt " + + +import sys +from typing import Any + +import os +import wx + +import addonHandler +import config +import gui + +from ...webModuleHandler import WebModule + + +if sys.version_info[1] < 9: + from typing import Mapping +else: + from collections.abc import Mapping + + +addonHandler.initTranslation() + + +def promptDelete(webModule: WebModule): + msg = ( + # Translators: Prompt before deleting a web module. + _("Do you really want to delete this web module?") + + os.linesep + + str(webModule.name) + ) + if config.conf["webAccess"]["devMode"]: + msg += " ({})".format("/".join((layer.name for layer in webModule.layers))) + return gui.messageBox( + parent=gui.mainFrame, + message=msg, + style=wx.YES_NO | wx.CANCEL | wx.NO_DEFAULT | wx.ICON_WARNING + ) == wx.YES + + +def promptMask(webModule: WebModule): + ref = webModule.getLayer("addon", raiseIfMissing=True).storeRef + if ref[0] != "addons": + raise ValueError("ref={!r}".format(ref)) + addonName = ref[1] + for addon in addonHandler.getRunningAddons(): + if addon.name == addonName: + addonSummary = addon.manifest["summary"] + break + else: + raise LookupError("addonName={!r}".format(addonName)) + log.info("Proposing to mask {!r} from addon {!r}".format(webModule, addonName)) + msg = _( + """This web module comes with the add-on {addonSummary}. +It cannot be modified at its current location. + +Do you want to make a copy in your scratchpad? +""" + ).format(addonSummary=addonSummary) + return gui.messageBox( + parent=gui.mainFrame, + message=msg, + caption=_("Warning"), + style=wx.ICON_WARNING | wx.YES | wx.NO + ) == wx.YES diff --git a/addon/globalPlugins/webAccess/gui/webModuleEditor.py b/addon/globalPlugins/webAccess/gui/webModule/editor.py similarity index 73% rename from addon/globalPlugins/webAccess/gui/webModuleEditor.py rename to addon/globalPlugins/webAccess/gui/webModule/editor.py index eb55aca1..a2c0ff54 100644 --- a/addon/globalPlugins/webAccess/gui/webModuleEditor.py +++ b/addon/globalPlugins/webAccess/gui/webModule/editor.py @@ -1,4 +1,4 @@ -# globalPlugins/webAccess/gui/webModuleEditor.py +# globalPlugins/webAccess/gui/webModule/editor.py # -*- coding: utf-8 -*- # This file is part of Web Access for NVDA. @@ -23,12 +23,16 @@ __author__ = ( "Yannick Plassiard " "Frédéric Brugnot " - "Julien Cochuyt " + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", ) import itertools import os +import sys +from typing import Any import wx from NVDAObjects import NVDAObject, IAccessible @@ -41,8 +45,15 @@ from logHandler import log import ui -from ..webModuleHandler import WebModule, getEditableWebModule, getUrl, getWindowTitle, save -from . import ScalingMixin +from ...utils import guarded +from ...webModuleHandler import WebModule, getEditableWebModule, getUrl, getWindowTitle, save, store +from .. import ContextualDialog, showContextualDialog + + +if sys.version_info[1] < 9: + from typing import Mapping +else: + from collections.abc import Mapping addonHandler.initTranslation() @@ -69,26 +80,14 @@ def show(context): return result == wx.ID_OK -class Dialog(wx.Dialog, ScalingMixin): - - # Singleton - _instance = None - def __new__(cls, *args, **kwargs): - if Dialog._instance is None: - return super().__new__(cls, *args, **kwargs) - return Dialog._instance +class Dialog(ContextualDialog): def __init__(self, parent): - scale = self.scale - if Dialog._instance is not None: - return - Dialog._instance = self - super().__init__( parent, style=wx.DEFAULT_DIALOG_STYLE | wx.MAXIMIZE_BOX | wx.RESIZE_BORDER, ) - + scale = self.scale mainSizer = wx.BoxSizer(wx.VERTICAL) gbSizer = wx.GridBagSizer() mainSizer.Add( @@ -145,55 +144,57 @@ def __init__(self, parent): mainSizer.Add( self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL), flag=wx.EXPAND | wx.TOP | wx.DOWN, - border=4 + border=scale(guiHelper.BORDER_FOR_DIALOGS), ) self.Bind(wx.EVT_BUTTON, self.onOk, id=wx.ID_OK) - self.Bind(wx.EVT_BUTTON, self.onCancel, id=wx.ID_CANCEL) - #self.Sizer = mainSizer - self.SetSizerAndFit(mainSizer) - + self.SetSize(scale(790, 400)) + self.SetSizer(mainSizer) + self.CentreOnScreen() + self.webModuleName.SetFocus() + + def getData(self) -> Mapping[str, Any]: + return self.context["data"]["webModule"] + def initData(self, context): - self.context = context - webModule = context.get("webModule") - if webModule is None: - new = True + super().initData(context) + data = context.setdefault("data", {}).setdefault("webModule", {}) + if not context.get("new"): + webModule = context.get("webModule") + data.update(webModule.dump(webModule.layers[-1].name).data["WebModule"]) + # Could not have been saved without triggers from this editor unless invoked + # from the Rule or Criteria editor. + subModule = data["subModule"] = not (data.get("url") or data.get("windowTitle")) + # Translators: Web module edition dialog title + title = _("Edit Web Module") + if config.conf["webAccess"]["devMode"]: + title += " ({})".format("/".join((layer.name for layer in webModule.layers))) else: - if any(layer.dirty and layer.storeRef is None for layer in webModule.layers): - new = True - elif any(layer.storeRef is not None for layer in webModule.layers): - new = False - else: - new = True - if new: + subModule = data.get("subModule", False) # Translators: Web module creation dialog title title = _("New Web Module") if config.conf["webAccess"]["devMode"]: - from .. import webModuleHandler try: guineaPig = getEditableWebModule(WebModule(), prompt=False) - store = next(iter(webModuleHandler.store.getSupportingStores( + supportingStore = next(iter(store.getSupportingStores( "create", item=guineaPig ))) if guineaPig is not None else None title += " ({})".format( - store and ("user" if store.name == "userConfig" else store.name) + supportingStore and ( + "user" if supportingStore.name == "userConfig" else supportingStore.name + ) ) except Exception: log.exception() - else: - # Translators: Web module edition dialog title - title = _("Edit Web Module") - if config.conf["webAccess"]["devMode"]: - title += " ({})".format("/".join((layer.name for layer in webModule.layers))) self.Title = title - self.webModuleName.Value = (webModule.name or "") if webModule is not None else "" + self.webModuleName.Value = data.get("name", "") urls = [] selectedUrl = None - if webModule is not None and webModule.url: - url = selectedUrl = ", ".join(webModule.url) - for candidate in itertools.chain([url], webModule.url): + if data.get("url"): + url = selectedUrl = ", ".join(data["url"]) + for candidate in itertools.chain([url], data["url"]): if candidate not in urls: urls.append(candidate) if "focusObject" in context: @@ -201,7 +202,7 @@ def initData(self, context): if focus and focus.treeInterceptor and focus.treeInterceptor.rootNVDAObject: urlFromObject = getUrl(focus.treeInterceptor.rootNVDAObject) if not urlFromObject: - if not webModule: + if context.get("new"): ui.message(_("URL not found")) elif urlFromObject not in urls: urls.append(urlFromObject) @@ -235,16 +236,14 @@ def initData(self, context): ] self.webModuleUrl.SetItems(urlsChoices) self.webModuleUrl.Selection = ( - urlsChoices.index(selectedUrl) - if selectedUrl - else 0 + urlsChoices.index(selectedUrl) if selectedUrl else 0 if not subModule else -1 ) windowTitleChoices = [] windowTitleIsFilled = False - if webModule is not None and webModule.windowTitle: + if data.get("windowTitle"): windowTitleIsFilled = True - windowTitleChoices.append(webModule.windowTitle) + windowTitleChoices.append(data["windowTitle"]) if "focusObject" in context: obj = context["focusObject"] windowTitle = getWindowTitle(obj) @@ -257,10 +256,19 @@ def initData(self, context): else: item.Value = "" - self.help.Value = webModule.help if webModule and webModule.help else "" - + self.help.Value = data.get("help", "") + + def updateData(self): + data = self.getData() + data["name"] = self.webModuleName.Value.strip() + data["url"] = [url.strip() for url in self.webModuleUrl.Value.split(",") if url.strip()] + data["windowTitle"] = self.webModuleWindowTitle.Value.strip() + + @guarded def onOk(self, evt): - name = self.webModuleName.Value.strip() + self.updateData() + data = self.getData() + name = data["name"] if len(name) < 1: gui.messageBox( _("You must enter a name for this web module"), @@ -271,10 +279,11 @@ def onOk(self, evt): self.webModuleName.SetFocus() return - url = [url.strip() for url in self.webModuleUrl.Value.split(",") if url.strip()] - windowTitle = self.webModuleWindowTitle.Value.strip() + subModule: bool = data.get("subModule", False) + url = data["url"] + windowTitle = data["windowTitle"] help = self.help.Value.strip() - if not (url or windowTitle): + if not (url or windowTitle or subModule): gui.messageBox( _("You must specify at least a URL or a window title."), _("Error"), @@ -285,9 +294,10 @@ def onOk(self, evt): return context = self.context - webModule = context.get("webModule") - if webModule is None: - webModule = context["webModule"] = WebModule() + if context.get("new"): + webModule = WebModule() + else: + webModule = context["webModule"] if webModule.isReadOnly(): webModule = getEditableWebModule(webModule) if not webModule: @@ -298,19 +308,12 @@ def onOk(self, evt): webModule.windowTitle = windowTitle webModule.help = help - if not save(webModule): + if not save(webModule, prompt=self.Title): return + context["webModule"] = webModule + self.DestroyLater() + self.SetReturnCode(wx.ID_OK) - assert self.IsModal() - self.EndModal(wx.ID_OK) - - def onCancel(self, evt): - self.EndModal(wx.ID_CANCEL) - - def ShowModal(self, context): - self.initData(context) - self.Fit() - self.CentreOnScreen() - self.webModuleName.SetFocus() - return super().ShowModal() +def show(context, parent=None): + return showContextualDialog(Dialog, context, parent or gui.mainFrame) == wx.ID_OK diff --git a/addon/globalPlugins/webAccess/gui/webModule/manager.py b/addon/globalPlugins/webAccess/gui/webModule/manager.py new file mode 100644 index 00000000..70817063 --- /dev/null +++ b/addon/globalPlugins/webAccess/gui/webModule/manager.py @@ -0,0 +1,264 @@ +# globalPlugins/webAccess/gui/webModule/manager.py +# -*- coding: utf-8 -*- + +# This file is part of Web Access for NVDA. +# Copyright (C) 2015-2024 Accessolutions (https://accessolutions.fr) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See the file COPYING.txt at the root of this distribution for more details. + + +__authors__ = ( + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) + + +import wx + +import addonHandler +addonHandler.initTranslation() +import gui +from gui import guiHelper +from logHandler import log + +from ...utils import guarded +from .. import ContextualDialog, ListCtrlAutoWidth, showContextualDialog + + +class Dialog(ContextualDialog): + + def __init__(self, parent): + super().__init__( + parent, + # Translators: The title of the Web Modules Manager dialog + title=_("Web Modules Manager"), + style=wx.DEFAULT_DIALOG_STYLE|wx.MAXIMIZE_BOX|wx.RESIZE_BORDER, + ) + scale = self.scale + mainSizer = wx.BoxSizer(wx.VERTICAL) + gbSizer = wx.GridBagSizer() + mainSizer.Add( + gbSizer, + border=scale(guiHelper.BORDER_FOR_DIALOGS), + flag=wx.ALL | wx.EXPAND, + proportion=1 + ) + row = 0 + col = 0 + item = modulesListLabel = wx.StaticText( + self, + # Translators: The label for the modules list in the + # Web Modules dialog. + label=_("Available Web Modules:"), + ) + gbSizer.Add(item, (row, col), flag=wx.EXPAND) + + row += 1 + gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL), (row, col)) + + row += 1 + item = self.modulesList = ListCtrlAutoWidth(self, style=wx.LC_REPORT) + # Translators: The label for a column of the web modules list + item.InsertColumn(0, _("Name"), width=150) + # Translators: The label for a column of the web modules list + item.InsertColumn(1, _("Trigger")) + item.Bind( + wx.EVT_LIST_ITEM_FOCUSED, + self.onModulesListItemSelected) + gbSizer.Add(item, (row, col), span=(8, 1), flag=wx.EXPAND) + gbSizer.AddGrowableRow(row+7) + gbSizer.AddGrowableCol(col) + + col += 1 + gbSizer.Add(scale(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL, 0), (row, col)) + + col += 1 + item = self.moduleCreateButton = wx.Button( + self, + # Translators: The label for a button in the Web Modules Manager + label=_("&New web module..."), + ) + item.Bind(wx.EVT_BUTTON, self.onModuleCreate) + gbSizer.Add(item, (row, col), flag=wx.EXPAND) + + row += 1 + gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), (row, col)) + + row += 1 + # Translators: The label for a button in the Web Modules Manager dialog + item = self.moduleEditButton = wx.Button(self, label=_("&Edit...")) + item.Disable() + item.Bind(wx.EVT_BUTTON, self.onModuleEdit) + gbSizer.Add(item, (row, col), flag=wx.EXPAND) + + row += 1 + gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), (row, col)) + + row += 1 + # Translators: The label for a button in the Web Modules Manager dialog + item = self.rulesManagerButton = wx.Button(self, label=_("Manage &rules...")) + item.Disable() + item.Bind(wx.EVT_BUTTON, self.onRulesManager) + gbSizer.Add(item, (row, col), flag=wx.EXPAND) + + row += 1 + gbSizer.Add(scale(0, guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS), (row, col)) + + row += 1 + item = self.moduleDeleteButton = wx.Button( + self, + # Translators: The label for a button in the + # Web Modules Manager dialog + label=_("&Delete")) + item.Disable() + item.Bind(wx.EVT_BUTTON, self.onModuleDelete) + gbSizer.Add(item, (row, col), flag=wx.EXPAND) + + mainSizer.Add( + self.CreateSeparatedButtonSizer(wx.CLOSE), + flag=wx.EXPAND | wx.BOTTOM | wx.LEFT | wx.RIGHT, + border=scale(guiHelper.BORDER_FOR_DIALOGS), + ) + self.Bind(wx.EVT_CHAR_HOOK, self.onCharHook) + self.SetSize(scale(790, 400)) + self.SetSizer(mainSizer) + self.CentreOnScreen() + self.modulesList.SetFocus() + + def initData(self, context): + super().initData(context) + module = context["webModule"] if "webModule" in context else None + self.refreshModulesList(selectItem=module) + + @guarded + def onCharHook(self, evt): + keycode = evt.GetKeyCode() + if keycode == wx.WXK_ESCAPE: + # Try to limit the difficulty of closing the dialog using the keyboard + # in the event of an error later in this function + evt.Skip() + return + elif keycode == wx.WXK_DELETE: + self.onModuleDelete(None) + return + elif keycode == wx.WXK_F2: + self.onModuleEdit(None) + return + elif keycode == wx.WXK_F3: + self.onRulesManager(None) + return + evt.Skip() + + + @guarded + def onModuleCreate(self, evt=None): + context = self.context.copy() + context["new"] = True + from .editor import show + if show(context, self): + self.refreshModulesList(selectItem=context["webModule"]) + + @guarded + def onModuleDelete(self, evt=None): + index = self.modulesList.GetFirstSelected() + if index < 0: + wx.Bell() + return + webModule = self.modules[index] + from ...webModuleHandler import delete + if delete(webModule=webModule): + self.refreshModulesList() + + @guarded + def onModuleEdit(self, evt=None): + index = self.modulesList.GetFirstSelected() + if index < 0: + wx.Bell() + return + context = self.context + context.pop("new", None) + context["webModule"] = self.modules[index] + from .editor import show + if show(context, self): + self.refreshModulesList(selectIndex=index) + + @guarded + def onModulesListItemSelected(self, evt): + self.refreshButtons() + + @guarded + def onRulesManager(self, evt=None): + index = self.modulesList.GetFirstSelected() + if index < 0: + wx.Bell() + return + webModule = self.modules[index] + context = self.context + if not webModule.equals(context.get("webModule")): + context["webModule"] = webModule + context.pop("result", None) + from ..rule.manager import show + show(context, self) + + def refreshButtons(self): + index = self.modulesList.GetFirstSelected() + hasSelection = index >= 0 + self.moduleEditButton.Enable(hasSelection) + self.rulesManagerButton.Enable(hasSelection) + self.moduleDeleteButton.Enable(hasSelection) + + def refreshModulesList(self, selectIndex: int = None, selectItem: "WebModule" = None): + ctrl = self.modulesList + ctrl.DeleteAllItems() + contextModule = self.context.get("webModule") + contextModules = { + (module.name, module.layers[0].storeRef): module + for module in list(reversed(contextModule.ruleManager.subModules.all())) + [contextModule] + } if contextModule else {} + modules = self.modules = [] + from ...webModuleHandler import getWebModules + for index, module in enumerate(getWebModules()): + if selectIndex is None and module.equals(selectItem): + selectIndex = index + module = contextModules.get((module.name, module.layers[0].storeRef), module) + trigger = (" %s " % _("and")).join( + ([ + "url=%s" % url + for url in (module.url if module.url else []) + ]) + ( + ["title=%s" % module.windowTitle] + if module.windowTitle else [] + ) + ) + ctrl.Append(( + module.name, + trigger, + )) + modules.append(module) + + if selectIndex is None: + selectIndex = min(0, len(modules) - 1) + else: + selectIndex %= len(modules) + if selectIndex >= 0: + ctrl.Select(selectIndex, on=1) + ctrl.Focus(selectIndex) + self.refreshButtons() + + +def show(context): + showContextualDialog(Dialog, context, gui.mainFrame) diff --git a/addon/globalPlugins/webAccess/gui/webModulesManager.py b/addon/globalPlugins/webAccess/gui/webModulesManager.py deleted file mode 100644 index 6b815b0d..00000000 --- a/addon/globalPlugins/webAccess/gui/webModulesManager.py +++ /dev/null @@ -1,292 +0,0 @@ -# globalPlugins/webAccess/gui/webModulesManager.py -# -*- coding: utf-8 -*- - -# This file is part of Web Access for NVDA. -# Copyright (C) 2015-2024 Accessolutions (https://accessolutions.fr) -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# See the file COPYING.txt at the root of this distribution for more details. - - -__author__ = "Julien Cochuyt " - - -import os -import wx - -import addonHandler -addonHandler.initTranslation() -import config -import core -import globalVars -import gui -from gui.nvdaControls import AutoWidthColumnListCtrl -import languageHandler -from logHandler import log - -from . import ScalingMixin - - -def promptDelete(webModule): - msg = ( - # Translators: Prompt before deleting a web module. - _("Do you really want to delete this web module?") - + os.linesep - + str(webModule.name) - ) - if config.conf["webAccess"]["devMode"]: - msg += " ({})".format("/".join((layer.name for layer in webModule.layers))) - return gui.messageBox( - parent=gui.mainFrame, - message=msg, - style=wx.YES_NO | wx.ICON_WARNING - ) == wx.YES - - -def promptMask(webModule): - ref = webModule.getLayer("addon", raiseIfMissing=True).storeRef - if ref[0] != "addons": - raise ValueError("ref={!r}".format(ref)) - addonName = ref[1] - for addon in addonHandler.getRunningAddons(): - if addon.name == addonName: - addonSummary = addon.manifest["summary"] - break - else: - raise LookupError("addonName={!r}".format(addonName)) - log.info("Proposing to mask {!r} from addon {!r}".format(webModule, addonName)) - msg = _( - """This web module comes with the add-on {addonSummary}. -It cannot be modified at its current location. - -Do you want to make a copy in your scratchpad? -""" - ).format(addonSummary=addonSummary) - return gui.messageBox( - parent=gui.mainFrame, - message=msg, - caption=_("Warning"), - style=wx.ICON_WARNING | wx.YES | wx.NO - ) == wx.YES - - -def show(context): - gui.mainFrame.prePopup() - Dialog(gui.mainFrame).Show(context) - gui.mainFrame.postPopup() - - -class Dialog(wx.Dialog, ScalingMixin): - # Singleton - _instance = None - def __new__(cls, *args, **kwargs): - if Dialog._instance is None: - return super().__new__(cls, *args, **kwargs) - return Dialog._instance - - def __init__(self, parent): - if Dialog._instance is not None: - return - Dialog._instance = self - - super().__init__( - parent, - # Translators: The title of the Web Modules Manager dialog - title=_("Web Modules Manager"), - style=wx.DEFAULT_DIALOG_STYLE|wx.MAXIMIZE_BOX|wx.RESIZE_BORDER, - #size=(600,400) - ) - - modulesListLabel = wx.StaticText( - self, - # Translators: The label for the modules list in the - # Web Modules dialog. - label=_("Available Web Modules:"), - ) - - item = self.modulesList = AutoWidthColumnListCtrl( - self, - style=wx.LC_REPORT|wx.LC_SINGLE_SEL, - #size=(550,350), - ) - # Translators: The label for a column of the web modules list - item.InsertColumn(0, _("Name"), width=150) - # Translators: The label for a column of the web modules list - item.InsertColumn(1, _("Trigger")) - ## Translators: The label for a column of the web modules list - ##item.InsertColumn(1, _("URL"), width=50) - #item.InsertColumn(1, _("URL")) - ## Translators: The label for a column of the web modules list - ##item.InsertColumn(2, _("Title"), width=50) - #item.InsertColumn(2, _("Title")) - item.resizeLastColumn(50) - item.Bind( - wx.EVT_LIST_ITEM_FOCUSED, - self.onModulesListItemSelected) - - item = self.moduleCreateButton = wx.Button( - self, - # Translators: The label for a button in the Web Modules Manager - label=_("&New web module..."), - ) - item.Bind(wx.EVT_BUTTON, self.onModuleCreate) - - # Translators: The label for a button in the Web Modules Manager dialog - item = self.moduleEditButton = wx.Button(self, label=_("&Edit...")) - item.Disable() - item.Bind(wx.EVT_BUTTON, self.onModuleEdit) - - # Translators: The label for a button in the Web Modules Manager dialog - item = self.rulesManagerButton = wx.Button(self, label=_("Manage &rules...")) - item.Disable() - item.Bind(wx.EVT_BUTTON, self.onRulesManager) - - item = self.moduleDeleteButton = wx.Button( - self, - # Translators: The label for a button in the - # Web Modules Manager dialog - label=_("&Delete")) - item.Disable() - item.Bind(wx.EVT_BUTTON, self.onModuleDelete) - - vSizer = wx.BoxSizer(wx.VERTICAL) - vSizer.Add(self.moduleCreateButton, flag=wx.EXPAND|wx.LEFT|wx.RIGHT, border=4) - vSizer.Add(self.moduleEditButton, flag=wx.EXPAND|wx.LEFT|wx.RIGHT, border=4) - vSizer.Add(self.rulesManagerButton, flag=wx.EXPAND|wx.LEFT|wx.RIGHT, border=4) - vSizer.Add(self.moduleDeleteButton, flag=wx.EXPAND|wx.LEFT|wx.RIGHT, border=4) - - hSizer = wx.BoxSizer(wx.HORIZONTAL) - hSizer.Add(self.modulesList, proportion=1, flag=wx.EXPAND|wx.LEFT|wx.RIGHT, border=4) - hSizer.Add(vSizer) - - vSizer = wx.BoxSizer(wx.VERTICAL) - vSizer.Add(modulesListLabel, flag=wx.EXPAND|wx.LEFT|wx.RIGHT, border=4) - vSizer.Add(hSizer, proportion=1, flag=wx.EXPAND|wx.DOWN, border=4) - vSizer.Add( - self.CreateSeparatedButtonSizer(wx.CLOSE), - flag=wx.EXPAND|wx.ALIGN_LEFT|wx.TOP|wx.DOWN, - border=4 - ) - - hSizer = wx.BoxSizer(wx.HORIZONTAL) - hSizer.Add(vSizer, proportion=1, flag=wx.EXPAND|wx.ALL, border=4) - - self.Sizer = hSizer - - - def __del__(self): - Dialog._instance = None - - def initData(self, context): - self.context = context - module = context["webModule"] if "webModule" in context else None - self.refreshModulesList(selectItem=module) - - def onModuleCreate(self, evt=None): - from .. import webModuleHandler - context = dict(self.context) # Shallow copy - webModuleHandler.showCreator(context) - if "webModule" in context: - module = context["webModule"] - self.refreshModulesList(selectItem=module) - - def onModuleDelete(self, evt=None): - index = self.modulesList.GetFirstSelected() - if index < 0: - return - pass - webModule = self.modules[index] - from .. import webModuleHandler - if webModuleHandler.delete(webModule=webModule): - self.refreshModulesList() - - def onModuleEdit(self, evt=None): - index = self.modulesList.GetFirstSelected() - if index < 0: - return - context = dict(self.context) # Shallow copy - context["webModule"] = self.modules[index] - from .. import webModuleHandler - webModuleHandler.showEditor(context) - self.refreshModulesList(selectIndex=index) - - def onModulesListItemSelected(self, evt): - index = evt.GetIndex() - item = self.modules[index] if index >= 0 else None - self.moduleEditButton.Enable(item is not None) - self.rulesManagerButton.Enable( - item is not None - and hasattr(item, "markerManager") - and item.markerManager.isReady - ) - self.moduleDeleteButton.Enable(item is not None) - - def onRulesManager(self, evt=None): - index = self.modulesList.GetFirstSelected() - if index < 0: - return - webModule = self.modules[index] - context = self.context.copy() # Shallow copy - context["webModule"] = self.modules[index] - from .. import ruleHandler - ruleHandler.showManager(context) - - def refreshModulesList(self, selectIndex=0, selectItem=None): - self.modulesList.DeleteAllItems() - modules = self.modules = [] - modulesList = self.modulesList - - from .. import webModuleHandler - for index, module in enumerate(webModuleHandler.getWebModules()): - if module is selectItem: - selectIndex = index - trigger = (" %s " % _("and")).join( - ([ - "url=%s" % url - for url in (module.url if module.url else []) - ]) - + ( - ["title=%s" % module.windowTitle] - if module.windowTitle else [] - ) - ) - modulesList.Append(( - module.name, - trigger, - #module.url, - #module.windowTitle, - )) - modules.append(module) - - # Select the item at given index, or the first item if unspecified - len_ = len(modules) - if len_ > 0: - if selectIndex == -1: - selectIndex = len_ - 1 - elif selectIndex<0 or selectIndex>=len_: - selectIndex = 0 - modulesList.Select(selectIndex, on=1) - modulesList.Focus(selectIndex) - else: - self.moduleEditButton.Disable() - self.rulesManagerButton.Disable() - self.moduleDeleteButton.Disable() - - def Show(self, context): - self.initData(context) - self.Fit() - self.modulesList.SetFocus() - self.CentreOnScreen() - return super().Show() \ No newline at end of file diff --git a/addon/globalPlugins/webAccess/nodeHandler.py b/addon/globalPlugins/webAccess/nodeHandler.py index d3eae958..d782b06c 100644 --- a/addon/globalPlugins/webAccess/nodeHandler.py +++ b/addon/globalPlugins/webAccess/nodeHandler.py @@ -19,15 +19,18 @@ # # See the file COPYING.txt at the root of this distribution for more details. + __authors__ = ( "Frédéric Brugnot ", "Julien Cochuyt ", + "Yannick Plassiard ", "André-Abush Clause ", ) import gc import re +import sys import time import weakref from ast import literal_eval @@ -44,9 +47,16 @@ import winUser from garbageHandler import TrackedObject +from .utils import tryInt from .webAppLib import * +if sys.version_info[1] < 9: + from typing import Mapping +else: + from collections.abc import Mapping + + TRACE = lambda *args, **kwargs: None # noqa: E731 # TRACE = log.info # noqa: E731 # TRACE = lambda *args, **kwargs: trace.append((args, kwargs)) # noqa: E731 @@ -61,7 +71,7 @@ nodeManagerIndex = 0 -class NodeManager(baseObject.ScriptableObject): +class NodeManager(baseObject.AutoPropertyObject): def __init__(self, treeInterceptor, callbackNodeMoveto=None): super().__init__() @@ -122,7 +132,6 @@ def terminate(self): self.devNode = None self.callbackNodeMoveto = None self.updating = False - self._curNode = self.caretNode = None def formatAttributes(self, attrs): s = "" @@ -223,67 +232,85 @@ def afficheNode(self, node, level=0): s += self.afficheNode(child, level + 1) return s - def update(self): + def update(self, force=False, ruleManager=None, debug=False): + """Analyze the VirtualBuffer + + If a RuleManager is specified, it is immediately updated and no + event is sent to the scheduler. + """ # t = logTimeStart() if self.treeInterceptor is None or not self.treeInterceptor.isReady: self._ready = False + if debug: + log.info(f"No TreeInterceptor") return False try: info = self.treeInterceptor.makeTextInfo(textInfos.POSITION_LAST) except Exception: self._ready = False + if debug: + log.exception() return False try: size = info._endOffset + 1 except Exception: self._ready = False + if debug: + log.exception() return False - if size == self.treeInterceptorSize: + if not force and size == self.treeInterceptorSize: # probably not changed + if debug: + log.info(f"Size unchanged: {size}") return False self.treeInterceptorSize = size - if True: - self.updating = True - info = self.treeInterceptor.makeTextInfo(textInfos.POSITION_ALL) - self.info = info - start = info._startOffset - end = info._endOffset - if start == end: - self._ready = False - return False - text = NVDAHelper.VBuf_getTextInRange( - info.obj.VBufHandle, start, end, True) - if self.mainNode is not None: - self.mainNode.recursiveDelete() - self.parseXML(text) - # logTime("Update node manager %d, text=%d" % (self.index, len(text)), t) - self.info = None - gc.collect() - else: - self.updating = False + self.updating = True + info = self.treeInterceptor.makeTextInfo(textInfos.POSITION_ALL) + self.info = info + start = info._startOffset + end = info._endOffset + if start == end: self._ready = False - log.info("reading vBuff error") + if debug: + log.info("The VirtualBuffer is empty") return False - # self.info = info + text = NVDAHelper.VBuf_getTextInRange( + info.obj.VBufHandle, start, end, True) + if self.mainNode is not None: + self.mainNode.recursiveDelete() + self.parseXML(text) + # logTime("Update node manager %d, text=%d" % (self.index, len(text)), t) + self.info = None + gc.collect() if self.mainNode is None: self.updating = False self._ready = False + if debug: + # @@@ + log.info("NodeManager.update: ") return False self.identifier = time.time() # logTime ("Update node manager %d nodes" % len(fields), t) self.updating = False - # playWebAppSound ("tick") - self._curNode = self.caretNode = self.getCaretNode() + # playWebAccessSound("tick") + if ruleManager: + # Synchronous update, eg. from WebAccessBmdti.script_refreshResults + self._ready = True + return ruleManager.update(nodeManager=self, force=force) try: info = self.treeInterceptor.makeTextInfo(textInfos.POSITION_LAST) except Exception: self._ready = False + if debug: + log.exception() return False size = info._endOffset + 1 from . import webAppScheduler if size != self.treeInterceptorSize: # treeInterceptor has changed during analyze self._ready = False + if debug: + log.info("VirtualBuffer has changed during analysis") webAppScheduler.scheduler.send( eventName="updateNodeManager", treeInterceptor=self.treeInterceptor @@ -346,79 +373,30 @@ def getCaretNode(self): return self.searchOffset(info._startOffset) except Exception: return None - - def getCurrentNode(self): - if not self.isReady: - return None - if self._curNode is None: - self._curNode = self.getCaretNode() - return self._curNode - - def setCurrentNode(self, node): - if hasattr(node, 'control') is False: - self._curNode = node.parent - else: - self._curNode = node - - def event_caret(self, obj, nextHandler): # @UnusedVariable - if not self.isReady: - return - self.display(self._curNode) - nextHandler() - - def script_nextItem(self, gesture): + + def getControlIdToPosition(self): if not self.isReady: - return - if self.treeInterceptor.passThrough is True: - gesture.send() - return - c = self.searchOffset(self._curNode.offset + self._curNode.size + 0) - if c == self._curNode or c is None: - ui.message("Bas du document") - self._curNode.moveto() - return - if c.parent.role not in ( - controlTypes.ROLE_SECTION, controlTypes.ROLE_PARAGRAPH - ): - c = c.parent - # log.info("C set to %s" % c) - self._curNode = c - c.moveto() - - def script_previousItem(self, gesture): - if not self.isReady: - return - if self.treeInterceptor.passThrough is True: - gesture.send() - return - c = self.searchOffset(self._curNode.offset - 1) - # log.info("C is %s" % c) - if c is None: - ui.message("Début du document") - self._curNode.moveto() - return - if c.parent.role not in ( - controlTypes.ROLE_SECTION, controlTypes.ROLE_PARAGRAPH - ): - c = c.parent - # log.info("C set to %s" % c) - self._curNode = c - c.moveto() - - def script_enter(self, gesture): - if not self.isReady: - return - if self.treeInterceptor.passThrough is True: - gesture.send() - return - self._curNode.moveto() - self._curNode.activate() - - __gestures = { - "kb:downarrow": "nextItem", - "kb:uparrow": "previousItem", - "kb:enter": "enter", - } + return {} + map: Mapping[tuple[int, int], tuple[int, int]] = {} + + def walk(node): + controlId = node.controlIdentifier + span = (node.offset, node.offset + node.size) + if controlId: + if controlId in map: + prev = map[controlId] + if prev[1] == span[0]: + # Consecutive spans for the same control. Expand the recorded span. + span = (prev[0], span[1]) + elif not(prev[0] <= span[0] and span[1] <= prev[1]): + # Neither consecutive nor nested + log.warning(f"ControlId double: {controlId} at {prev} and {span}") + map[controlId] = span + for child in node.children: + walk(child) + + walk(self.mainNode) + return {key: startOffset for key, (startOffset, endOffset) in map.items()} class NodeField(TrackedObject): @@ -462,7 +440,10 @@ def __init__(self, nodeType, attrs, parent, offset, nodeManager): self.name = attrs.get("name", "") self.role = attrs["role"] self.states = attrs["states"] - self.controlIdentifier = attrs.get("controlIdentifier_ID", 0) + self.controlIdentifier = ( + tryInt(attrs.get("controlIdentifier_docHandle", 0)), + tryInt(attrs.get("controlIdentifier_ID", 0)), + ) self.tag = attrs.get("IAccessible2::attribute_tag") if not self.tag: self.tag = attrs.get("IHTMLDOMNode::nodeName") @@ -524,13 +505,31 @@ def parent(self): @property def previousTextNode(self): return self._previousTextNode and self._previousTextNode() - + + @property + def url(self): + if hasattr(self, "_url"): + return self._url + if self.role != controlTypes.ROLE_DOCUMENT: + return None + url = None + obj = self.getNVDAObject() + while obj.role != self.role: + try: + obj = obj.parent + except Exception: + break + else: + url = obj.IAccessibleObject.accValue(obj.IAccessibleChildID) + self._url = url + return url + def isReady(self): return self.nodeManager and self.nodeManager.isReady def checkNodeManager(self): if self.nodeManager is None or not self.nodeManager.isReady: - playWebAppSound("keyError") + playWebAccessSound("keyError") return False else: return True @@ -589,24 +588,6 @@ def searchString(self, text, exclude=None, limit=None): return result return [] - def search_eq(self, itemList, value): - if not isinstance(itemList, list): - itemList = [itemList] - for item in itemList: - if item == value: - return True - return False - - def search_in(self, itemList, value): - if value is None or value == "": - return False - if not isinstance(itemList, list): - itemList = [itemList] - for item in itemList: - if item.replace("*", "") in value: - return True - return False - def searchNode( self, exclude=None, @@ -631,14 +612,16 @@ def searchNode( Additional keyword arguments names are of the form: `test_property[#index]` - All of the criteria must be matched (logical `and`). - Values can be lists, in which case any value in the list can match - (logical `or`). + All of the keywords must be matched (logical `and`). + Values are collections, any element can match (logical `or`). Supported tests are: `eq`, `notEq`, `in` and `notIn`. Properties `text` and `prevText` are mutually exclusive, are only valid for the `in` test and do not support multiple values. + Properties `role` and `states`, being integers, are only valid for + the `eq` and `notEq` tests. + Returns a list of the matching nodes. """ # noqa global _count @@ -646,7 +629,7 @@ def searchNode( _count += 1 found = True # Copy kwargs dict to get ready for Python 3: - for key, allowedValues in list(kwargs.copy().items()): + for key, searchedValues in list(kwargs.copy().items()): if "_" not in key: log.warning("Unexpected argument: {arg}".format(arg=key)) continue @@ -662,30 +645,32 @@ def searchNode( candidateValues = (candidateValue,) if prop == "className": if candidateValue is not None: - candidateValues = candidateValue.split(" ") - elif prop in ("role", "states"): - try: - allowedValues = [int(value) for value in allowedValues] - except ValueError: - log.error(( - "Invalid search criterion: {key}={allowedValues!r}" - ).format(**locals())) - if prop == "states": - candidateValues = candidateValue + candidateValue = candidateValue.strip() + if candidateValue: + candidateValues = candidateValue.split(" ") + elif prop == "states": + # states is a set + candidateValues = candidateValue + else: + candidateValues = (candidateValue,) for candidateValue in candidateValues: if test == "eq": - if self.search_eq(allowedValues, candidateValue): + if candidateValue in searchedValues: del kwargs[key] break elif test == "in": - if self.search_in(allowedValues, candidateValue): + if candidateValue and any( + True for searchedValue in searchedValues if searchedValue in candidateValue + ): del kwargs[key] break elif test == "notEq": - if self.search_eq(allowedValues, candidateValue): + if candidateValue in searchedValues: return [] elif test == "notIn": - if self.search_in(allowedValues, candidateValue): + if candidateValue and any( + True for searchedValue in searchedValues if searchedValue in candidateValue + ): return [] else: # no break if test in ("eq", "in"): @@ -914,32 +899,6 @@ def mouseMove(self): winUser.setCursorPos(x, y) mouseHandler.executeMouseMoveEvent(x, y) - def getPresentationString(self): - """Returns the current node text and role for speech and Braille. - @param None - @returns a presentation string - @rtype str - """ - if hasattr(self, 'text'): - return self.text - elif self.role is controlTypes.ROLE_EDITABLETEXT: - return "_name_ _role_" - elif self.role is controlTypes.ROLE_HEADING: - return "_innerText_ _role_ de niveau %s" % self.control["level"] - return "_innerText_ _role_" - - def getBraillePresentationString(self): - return False - - # TODO: Thoroughly check this wasn't used anywhere - # In Python 3, all classes defining __eq__ must also define __hash__ -# def __eq__(self, node): -# if node is None: -# return False -# if self.offset == node.offset: -# return True -# return False - def __lt__(self, node): """ Compare nodes based on their offset. @@ -977,7 +936,7 @@ def __contains__(self, node): Check whether the given node belongs to the subtree of this node, based on their offset. """ - if self <= node: + if self > node: return False if not self.children: return False diff --git a/addon/globalPlugins/webAccess/overlay.py b/addon/globalPlugins/webAccess/overlay.py index d9bb9f0f..77fd0423 100644 --- a/addon/globalPlugins/webAccess/overlay.py +++ b/addon/globalPlugins/webAccess/overlay.py @@ -23,12 +23,19 @@ WebAccess overlay classes """ -__author__ = "Julien Cochuyt " + +__authors__ = ( + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) import weakref import wx +import NVDAObjects +from NVDAObjects.IAccessible import IAccessible import addonHandler import baseObject import browseMode @@ -36,23 +43,19 @@ import controlTypes import core import cursorManager +from garbageHandler import TrackedObject import gui from logHandler import log from scriptHandler import script -import NVDAObjects -from NVDAObjects.IAccessible import IAccessible import speech import textInfos import treeInterceptorHandler import ui import virtualBuffers +from .utils import guarded, logException, tryInt -from six import iteritems -from six.moves import xrange - -from garbageHandler import TrackedObject REASON_CARET = controlTypes.OutputReason.CARET @@ -145,22 +148,23 @@ class WebAccessBmdtiHelper(TrackedObject): Utility methods and properties. """ WALK_ALL_TREES = False - + def __init__(self, treeInterceptor): self.caretHitZoneBorder = False self._nodeManager = None self._treeInterceptor = weakref.ref(treeInterceptor) - self._webModule = None - + self._rootWebModule = None + def terminate(self): - if self._webModule is not None: - self._webModule.terminate() - self._webModule = None + if self._rootWebModule is not None: + self._rootWebModule.terminate() + self._rootWebModule = None if self._nodeManager is not None: self._nodeManager.terminate() self._nodeManager = None - + @property + @logException def nodeManager(self): nodeManager = self._nodeManager ti = self.treeInterceptor @@ -172,53 +176,81 @@ def nodeManager(self): from .webAppScheduler import scheduler nodeManager = self._nodeManager = NodeManager(ti, scheduler.onNodeMoveto) return nodeManager - + @property - def ruleManager(self): - if not self.webModule: + @logException + def rootRuleManager(self): + webModule = self.rootWebModule + if not webModule: return None - return self.webModule.ruleManager - - @property - def treeInterceptor(self): - if hasattr(self, "_treeInterceptor"): - ti = self._treeInterceptor - if isinstance(ti, weakref.ReferenceType): - ti = ti() - if ti and ti in treeInterceptorHandler.runningTable: - return ti - else: - return None - + return webModule.ruleManager.rootRuleManager + @property - def webModule(self): - from . import supportWebApp, webAccessEnabled + @logException + def rootWebModule(self): + from . import canHaveWebAccessSupport, webAccessEnabled if not webAccessEnabled: return None ti = self.treeInterceptor if not ti: - self._webModule = None + self._rootWebModule = None return None - webModule = self._webModule + webModule = self._rootWebModule if not webModule: obj = ti.rootNVDAObject - if not supportWebApp(obj): + if not canHaveWebAccessSupport(obj): return None from . import webModuleHandler try: - webModule = self._webModule = webModuleHandler.getWebModuleForTreeInterceptor(ti) + webModule = self._rootWebModule = webModuleHandler.getWebModuleForTreeInterceptor(ti) except Exception: log.exception() return webModule - + + @property + @logException + @logException + def ruleManager(self): + webModule = self.webModule + if not webModule: + return None + return webModule.ruleManager + + @property + @logException + def treeInterceptor(self): + if hasattr(self, "_treeInterceptor"): + ti = self._treeInterceptor + if isinstance(ti, weakref.ReferenceType): + ti = ti() + if ti and ti in treeInterceptorHandler.runningTable: + return ti + else: + return None + @property + @logException + def webModule(self): + root = self.rootWebModule + if not root: + return None + try: + info = self.treeInterceptor.makeTextInfo(textInfos.POSITION_CARET) + except Exception: + #log.exception(stack_info=True) + return root + return root.ruleManager.subModules.atPosition(info._startOffset) or root + + @property + @logException def zone(self): ruleManager = self.ruleManager if not ruleManager: return None return ruleManager.zone - + @zone.setter + @logException def zone(self, value): if value is None: # Avoid raising an AttributeError if we only want to ensure @@ -229,6 +261,13 @@ def zone(self, value): return # Properly raise AttributeError if there is no RuleManager. self.ruleManager.zone = value + + @logException + def getWebModuleAtTextInfo(self, info): + rootModule = self.webModule + if not rootModule: + return None + return self.ruleManager.subModules.atPosition(info._startOffset) or rootModule class WebAccessBmdtiTextInfo(textInfos.offsets.OffsetsTextInfo): @@ -301,7 +340,7 @@ def updateSelection(self): self.obj.webAccess.zone = None super().updateSelection() - def _getControlFieldAttribs(self, docHandle, controlId): + def _getControlFieldAttribs(self, docHandle, id): info = self.copy() info.expand(textInfos.UNIT_CHARACTER) for field in reversed(info.getTextWithFields()): @@ -312,16 +351,16 @@ def _getControlFieldAttribs(self, docHandle, controlId): continue attrs = field.field if ( - int(attrs["controlIdentifier_docHandle"]) == docHandle - and int(attrs["controlIdentifier_ID"]) == controlId + tryInt(attrs["controlIdentifier_docHandle"]) == docHandle + and tryInt(attrs["controlIdentifier_ID"]) == id ): break else: raise LookupError - mgr = self.obj.webAccess.ruleManager + mgr = self.obj.webAccess.rootRuleManager if not mgr: return attrs - mutated = mgr.getMutatedControl(controlId) + mutated = mgr.getMutatedControl((docHandle, id)) if mutated: attrs.update(mutated.attrs) return attrs @@ -330,7 +369,7 @@ def _getFieldsInRange(self, start, end): fields = super()._getFieldsInRange( start, end ) - mgr = self.obj.webAccess.ruleManager + mgr = self.obj.webAccess.rootRuleManager if not mgr or not mgr.isReady: return fields for field in fields: @@ -340,8 +379,9 @@ def _getFieldsInRange(self, start, end): ): continue attrs = field.field - controlId = int(attrs["controlIdentifier_ID"]) - mutated = mgr.getMutatedControl(controlId) + mutated = mgr.getMutatedControl(( + tryInt(attrs["controlIdentifier_docHandle"]), tryInt(attrs["controlIdentifier_ID"]) + )) if mutated: attrs.update(mutated.attrs) return fields @@ -355,16 +395,13 @@ def __init__(self, itemType, document, textInfo, controlId): super().__init__( itemType, document, textInfo ) - self.controlId = controlId # Support for `virtualBuffers.VirtualBufferQuickNavItem.isChild` # so that the Elements List dialog can relate nested headings. - self.vbufFieldIdentifier = (document.rootDocHandle, controlId) + self.vbufFieldIdentifier = controlId @property def obj(self): - return self.document.getNVDAObjectFromIdentifier( - self.document.rootDocHandle, self.controlId - ) + return self.document.getNVDAObjectFromIdentifier(self.vbufFieldIdentifier) def isChild(self, parent): if self.itemType == "heading": @@ -391,10 +428,7 @@ def propertyGetter(prop): # that is, in the Elements List dialog. info = self.textInfo.copy() info.expand(textInfos.UNIT_CHARACTER) - attrs.update(info._getControlFieldAttribs( - self.document.rootDocHandle, - self.controlId - )) + attrs.update(info._getControlFieldAttribs(*self.vbufFieldIdentifier)) return attrs.get(prop) return self._getLabelForProperties(propertyGetter) @@ -456,7 +490,7 @@ def _get_TextInfo(self): return getDynamicClass((WebAccessBmdtiTextInfo, superCls)) def _set_selection(self, info, reason=REASON_CARET): - webModule = self.webAccess.webModule + webModule = self.webAccess.getWebModuleAtTextInfo(info) if webModule and hasattr(webModule, "_set_selection"): webModule._set_selection(self, info, reason=reason) return @@ -490,7 +524,7 @@ def _caretMovementScriptHelper( self.webAccess.caretHitZoneBorder = True elif ( posUnit == textInfos.POSITION_LAST - and zone.isTextInfoAtEnd(info) + and zone.isTextInfoAtOfAfterEnd(info) ) or ( posUnit == textInfos.POSITION_FIRST and zone.isTextInfoAtStart(info) @@ -504,10 +538,10 @@ def _caretMovementScriptHelper( msg += _("Press escape to cancel zone restriction.") ui.message(msg) if posConstant == textInfos.POSITION_FIRST: - pos = zone.startOffset + pos = zone.result.startOffset posConstant = textInfos.offsets.Offsets(pos, pos) elif posConstant == textInfos.POSITION_LAST: - pos = max(zone.endOffset - 1, zone.startOffset) + pos = max(zone.result.endOffset - 1, zone.result.startOffset) posConstant = textInfos.offsets.Offsets(pos, pos) super()._caretMovementScriptHelper( gesture, @@ -532,7 +566,7 @@ def _iterNodesByType(self, itemType, direction="next", pos=None): yield next(superIter) except StopIteration: return - mgr = self.webAccess.ruleManager + mgr = self.webAccess.rootRuleManager if not mgr: for item in superIter: yield item @@ -556,12 +590,12 @@ def _iterNodesByType(self, itemType, direction="next", pos=None): direction ): if zone: - if item.textInfo._startOffset < zone.startOffset: + if item.textInfo._startOffset < zone.result.startOffset: if direction == "next": continue else: return - elif item.textInfo._startOffset >= zone.endOffset: + elif item.textInfo._startOffset >= zone.result.endOffset: if direction == "previous": continue else: @@ -569,11 +603,11 @@ def _iterNodesByType(self, itemType, direction="next", pos=None): if not isinstance(item, WebAccessMutatedQuickNavItem): controlId = None if isinstance(item, virtualBuffers.VirtualBufferQuickNavItem): - docHandle, controlId = item.vbufFieldIdentifier + controlId = item.vbufFieldIdentifier elif isinstance(item, browseMode.TextInfoQuickNavItem): try: obj = item.textInfo.NVDAObjectAtStart - controlId = obj.IA2UniqueID + controlId = self.getIdentifierFromNVDAObject(obj) except Exception: log.exception() if controlId is None: @@ -714,7 +748,7 @@ def __iterMutatedControlsByCriteria( See `__mutatedControlMatchesCriteria` for details on criteria format. """ - mgr = self.webAccess.ruleManager + mgr = self.webAccess.rootRuleManager if not mgr: return if not criteria: @@ -768,13 +802,12 @@ def __mutatedControlMatchesCriteria(self, criteria, mutated, info=None): if info is None: info = mutated.node.getTextInfo() docHandle = self.rootDocHandle - controlId = mutated.controlId - controlAttrs = info._getControlFieldAttribs(docHandle, controlId) + controlAttrs = info._getControlFieldAttribs(*mutated.controlId) controlNode = mutated.node parentAttrs = None # Fetch lazily as seldom needed parentNode = mutated.node.parent for alternative in criteria: - for key, values in iteritems(alternative): + for key, values in alternative.items(): if key.endswith("::not"): negate = True key = key[:-len("::not")] @@ -785,9 +818,7 @@ def __mutatedControlMatchesCriteria(self, criteria, mutated, info=None): if parentAttrs is None: parent = mutated.node.parent parentInfo = parent.getTextInfo() - parentAttrs = parentInfo._getControlFieldAttribs( - docHandle, int(parent.controlIdentifier) - ) + parentAttrs = parentInfo._getControlFieldAttribs(*parent.controlIdentifier) attrs = parentAttrs node = parentNode else: @@ -863,6 +894,7 @@ def doFindText(self, text, reverse=False, caseSensitive=False, willSayAllResume= if not willSayAllResume: speech.speakTextInfo(info, reason=REASON_CARET) elif self.webAccess.zone: + def ask(): if gui.messageBox( "\n".join(( @@ -879,6 +911,7 @@ def ask(): reverse=reverse, caseSensitive=caseSensitive ) + wx.CallAfter(ask) else: wx.CallAfter( @@ -928,17 +961,10 @@ class Break(Exception): def getScript(self, gesture): webModule = self.webAccess.webModule if webModule: - func = webModule.getScript(gesture) - if func: - return ScriptWrapper( - func, ignoreTreeInterceptorPassThrough=True - ) - mgr = self.webAccess.ruleManager - if mgr: - func = mgr.getScript(gesture) - if func: + script = webModule.getScript(gesture) + if script: return ScriptWrapper( - func, ignoreTreeInterceptorPassThrough=True + script, ignoreTreeInterceptorPassThrough=True ) return super().getScript(gesture) @@ -955,7 +981,12 @@ def script_disablePassThrough(self, gesture): and self.webAccess.zone ): self.webAccess.zone = None - ui.message(_("Zone restriction cancelled")) + if self.webAccess.zone is None: + # Translators: Reported when cancelling zone restriction + ui.message(_("Zone restriction cancelled")) + else: + # Translators: Reported when cancelling zone restriction + ui.message(_("Zone restriction enlarged to a wider zone")) else: super().script_disablePassThrough(gesture) @@ -1080,11 +1111,23 @@ def script_quickNavToPreviousResultLevel3(self, gesture): category=SCRCAT_WEBACCESS, gesture="kb:NVDA+shift+f5" ) + @guarded def script_refreshResults(self, gesture): # Translators: Notified when manually refreshing results ui.message(_("Refresh results")) - self.webAccess.ruleManager.update(force=True) - + try: + res = self.webAccess.nodeManager.update( + force=True, + ruleManager=self.webAccess.rootRuleManager, + ) + except Exception: + log.exception() + res = False + if res: + ui.message(_("Updated")) + else: + ui.message(_("Update failed")) + script_refreshResults.ignoreTreeInterceptorPassThrough = True script_refreshResults.passThroughIfNoWebModule = True @@ -1111,10 +1154,21 @@ class WebAccessObjectHelper(TrackedObject): """ Utility methods and properties. """ - def __init__(self, obj): - self._obj = weakref.ref(obj) - + + def __init__(self, obj: NVDAObjects.NVDAObject): + self._obj: weakref.ref[NVDAObjects.NVDAObject] = weakref.ref(obj) + self._webModule: weakref.ref[WebModule] = None + """Cached to try sustain operations during RuleManager update. + """ + @property + @logException + def controlId(self): + obj = self.obj + return obj.treeInterceptor.getIdentifierFromNVDAObject(obj) + + @property + @logException def nodeManager(self): ti = self.treeInterceptor if not ti: @@ -1122,17 +1176,28 @@ def nodeManager(self): return ti.webAccess.nodeManager @property + @logException def obj(self): return self._obj() @property + @logException def ruleManager(self): + webModule = self.webModule + if not webModule: + return None + return webModule.ruleManager + + @property + @logException + def rootRuleManager(self): ti = self.treeInterceptor - if not ti: + if not isinstance(ti, WebAccessBmdti): return None - return ti.webAccess.ruleManager - + return ti.webAccess.rootRuleManager + @property + @logException def treeInterceptor(self): obj = self.obj while True: @@ -1147,21 +1212,50 @@ def treeInterceptor(self): obj = ti.rootNVDAObject.parent except Exception: return None - + @property + @logException def webModule(self): - ti = self.treeInterceptor - if not ti: + mgr = self.rootRuleManager + if mgr is None: return None - return ti.webAccess.webModule - + try: + controlId = self.controlId + except Exception: + log.exception() + return None + try: + webModule = mgr.getWebModuleForControlId(controlId) + except LookupError: + log.warning(f"Unknown controlId: {controlId}") + webModule = None + if webModule is None and self._webModule is not None: + # The RumeManager is not ready, return the last cached value + return self._webModule() + if webModule is None: + # Lookup by controlId failed and there is no cached value. + # Attempt lookup by position. + ti = self.treeInterceptor + if not ti: + return None + try: + info = ti.makeTextInfo(self.obj) + except Exception: + log.exception(stack_info=True) + # Failback to the WebModule at caret (not cached) + return ti.webAccess.webModule + webModule = ti.webAccess.getWebModuleAtTextInfo(info) + if webModule is not None: + self._webModule = weakref.ref(webModule) + return webModule + + @logException def getMutatedControlAttribute(self, attr, default=None): - mgr = self.ruleManager + mgr = self.rootRuleManager if not mgr: return default - obj = self.obj try: - controlId = obj.treeInterceptor.getIdentifierFromNVDAObject(obj)[1] + controlId = self.controlId except Exception: log.exception() return default diff --git a/addon/globalPlugins/webAccess/ruleHandler/__init__.py b/addon/globalPlugins/webAccess/ruleHandler/__init__.py index 480559b1..a4e0abd6 100644 --- a/addon/globalPlugins/webAccess/ruleHandler/__init__.py +++ b/addon/globalPlugins/webAccess/ruleHandler/__init__.py @@ -29,21 +29,22 @@ ) +from functools import partial from itertools import chain from pprint import pformat import threading import time import sys +from typing import Any import weakref import wx import addonHandler import api -import baseObject +from baseObject import AutoPropertyObject, ScriptableObject import browseMode import controlTypes -import gui import inputCore from logHandler import log import queueHandler @@ -56,10 +57,11 @@ from garbageHandler import TrackedObject from .. import nodeHandler +from ..utils import logException from ..webAppLib import ( html, logTimeStart, - playWebAppSound, + playWebAccessSound, ) from .. import webAppScheduler from . import ruleTypes @@ -68,58 +70,30 @@ if sys.version_info[1] < 9: - from typing import Mapping + from typing import Mapping, Sequence else: - from collections.abc import Mapping + from collections.abc import Mapping, Sequence addonHandler.initTranslation() SCRIPT_CATEGORY = "WebAccess" -builtinRuleActions = {} -# Translators: Action name -builtinRuleActions["moveto"] = pgettext("webAccess.action", "Move to") -# Translators: Action name -builtinRuleActions["sayall"] = pgettext("webAccess.action", "Say all") -# Translators: Action name -builtinRuleActions["speak"] = pgettext("webAccess.action", "Speak") -# Translators: Action name -builtinRuleActions["activate"] = pgettext("webAccess.action", "Activate") -# Translators: Action name -builtinRuleActions["mouseMove"] = pgettext("webAccess.action", "Mouse move") - - -def showCreator(context, parent=None): - context.pop("rule", None) - context["new"] = True - return showEditor(context, parent=parent) - - -def showEditor(context, parent=None): - context.get("data", {}).pop("rule", None) - from ..gui.rule import editor - return editor.show(context, parent=parent) - - -def showManager(context): - api.processPendingEvents() - webModule = context["webModule"] - mgr = webModule.ruleManager - if not mgr.isReady: - playWebAppSound("keyError") - time.sleep(0.2) - speech.cancelSpeech() - ui.message(_("Not ready")) - time.sleep(0.5) - return - focus = context["focusObject"] - context["result"] = mgr.getResultAtCaret(focus=focus) - from ..gui.rule import manager as dlg - dlg.show(context) +builtinActions = { + # Translators: Action name + "moveto": pgettext("webAccess.action", "Move to"), + # Translators: Action name + "sayall": pgettext("webAccess.action", "Say all"), + # Translators: Action name + "speak": pgettext("webAccess.action", "Speak"), + # Translators: Action name + "activate": pgettext("webAccess.action", "Activate"), + # Translators: Action name + "mouseMove": pgettext("webAccess.action", "Mouse move"), +} -class DefaultScripts(baseObject.ScriptableObject): +class DefaultScripts(ScriptableObject): def __init__(self, warningMessage): super().__init__() @@ -129,13 +103,13 @@ def __init__(self, warningMessage): self.__class__.__gestures["kb:control+shift+%s" % character] = "notAssigned" def script_notAssigned(self, gesture): - playWebAppSound("keyError") + playWebAccessSound("keyError") callLater(200, ui.message, self.warningMessage) __gestures = {} -class RuleManager(baseObject.ScriptableObject): +class RuleManager(ScriptableObject): def __init__(self, webModule): super().__init__() @@ -155,14 +129,67 @@ def __init__(self, webModule): self.lastAutoMovetoTime = 0 self.defaultScripts = DefaultScripts("Aucun marqueur associé à cette touche") self.timerCheckAutoAction = None - self.zone = None + self._zone: Zone = None + self.subModules: SubModules = SubModules(self) + self._controlIdToPosition: Mapping[str, int] = {} + """Used for safer retrieval of the WebModule associated to an object in focus mode + """ + self._allResults: Sequence["Result"] = [] + """Results for this WebModule and all of its SubModules. + """ + self.parentZone: Zone = None + """The zone containing this SubModule in its parent WebModule + """ + def _get_rootRuleManager(self): + parent = self.parentRuleManager + if parent: + return parent.rootRuleManager + return self + + def _get_nodeManager(self): + root = self.rootRuleManager + if root is not self: + return root.nodeManager + return self._nodeManager() if self._nodeManager else None + + def _get_parentNode(self): + parentZone = self.parentZone + if parentZone is not None: + return parentZone.result.Node + return self.nodeManager.mainNode + + def _get_parentRuleManager(self): + try: + return self.parentZone.ruleManager + except AttributeError: + return None + def _get_webModule(self): return self._webModule() - def _get_nodeManager(self): - return self._nodeManager and self._nodeManager() - + @logException + def _get_zone(self): + if self.parentZone is not None: + return self.parentRuleManager.zone + else: + return self._zone + + @logException + def _set_zone(self, value, force=False): + if value is None and not force: + curZone = self.zone + curResult = curZone.result if curZone is not None else None + if curResult is not None: + for candidate in self.iterResultsAtCaret(): + if candidate.zone not in (None, curZone) and candidate.containsResult(curResult): + value = candidate.zone + break + if self.parentZone is not None: + self.parentRuleManager.zone = value + else: + self._zone = value + def dump(self, layer): return {name: rule.dump() for name, rule in list(self._layers[layer].items())} @@ -182,16 +209,17 @@ def _initLayer(self, layer, index): ((layerName, layerIndex) for layerIndex, layerName in enumerate(self._layers.keys())) ) - def loadRule(self, layer, name, data): + def loadRule(self, layer: str, name: str, data: Mapping[str, Any]) -> "Rule": if layer not in self._layers: self._initLayer(layer, None) - self._loadRule(layer, name, data) + return self._loadRule(layer, name, data) - def _loadRule(self, layer, name, data): + def _loadRule(self, layer: str, name: str, data: Mapping[str, Any]) -> "Rule": rule = self.webModule.createRule(data) rule.layer = layer self._layers[layer][name] = rule self._rules.setdefault(name, {})[layer] = rule + return rule def unload(self, layer): for index in range(len(self._results)): @@ -211,8 +239,8 @@ def getRules(self, layer=None): return tuple(self._layers[layer].values()) return tuple([ rule - for ruleLayers in list(self._rules.values()) - for rule in list(ruleLayers.values()) + for ruleLayers in self._rules.values() + for rule in reversed(ruleLayers.values()) ]) def getRule(self, name, layer=None): @@ -224,14 +252,23 @@ def getRule(self, name, layer=None): if layer not in (None, False): return ruleLayers[layer] try: - return next(iter(list(ruleLayers.values()))) + return next(iter(reversed(ruleLayers.values()))) except StopIteration: raise LookupError({"name": name, "layer": layer}) - def getResults(self): + def getResults(self) -> tuple["Result"]: + """Get the results for all the layers of this WebModule, excluding SubModules. + """ if not self.isReady: return [] - return self._results + return tuple(self._results) + + def getAllResults(self) -> tuple["Result"]: + """Get the results for all the layers of this WebModule and all its SubModules. + """ + if not self.isReady: + return [] + return tuple(self._allResults) def getResultsByName(self, name, layer=None): return list(self.iterResultsByName(name, layer=layer)) @@ -249,11 +286,13 @@ def iterResultsByName(self, name, layer=None): continue elif ( layer not in (None, False) - or self._layersIndex[layer] >= self._layersIndex[rule.layer] + or tuple(self._layers.keys()).index(layer) >= tuple(self._layers.keys()).index(rule.layer) ): yield result def iterMutatedControls(self, direction="next", offset=None): + if self.parentZone is not None: + raise Exception("Supported on the root RuleManager only") for entry in ( self._mutatedControlsByOffset if direction == "next" else reversed(self._mutatedControlsByOffset) @@ -275,6 +314,8 @@ def iterMutatedControls(self, direction="next", offset=None): yield entry def getMutatedControl(self, controlId): + if self.parentZone is not None: + raise Exception("Supported on the root RuleManager only") return self._mutatedControlsById.get(controlId) def removeResults(self, rule): @@ -283,28 +324,55 @@ def removeResults(self, rule): del self._results[index] def getActions(self) -> Mapping[str, str]: - actions = builtinRuleActions.copy() + actions = builtinActions.copy() prefix = "action_" for key in dir(self.webModule): if key[:len(prefix)] == prefix: actionId = key[len(prefix):] actionLabel = getattr(self.webModule, key).__doc__ or actionId - # Prefix to denote customized action + # Prefix to denote custom action actionLabel = "*" + actionLabel actions.setdefault(actionId, actionLabel) return actions + def getGlobalScript(self, gesture, caret=None, fromParent=False): + if caret is None: + webModuleAtCaret = self.nodeManager.treeInterceptor.webAccess.webModule + return webModuleAtCaret.ruleManager.getGlobalScript(gesture, caret=webModuleAtCaret) + + def gen(): + nonlocal caret + for subMod in self.subModules.all(): + if subMod is not caret: + yield partial(subMod.ruleManager.getGlobalScript, caret=caret, fromParent=True) + for result in self.getResults(): + if result.rule.type == ruleTypes.GLOBAL_MARKER: + yield result.getScript + if not fromParent and self.parentZone: + parent = self.parentRuleManager + if parent.webModule is not caret: + yield partial(parent.getGlobalScript, caret=caret) + + for func in gen(): + script = func(gesture) + if script: + return script + def getScript(self, gesture): - func = super().getScript(gesture) - if func is not None: - return func + script = super().getScript(gesture) + if script: + return script + script = self.getGlobalScript(gesture) + if script: + return script for layer in reversed(list(self._layers.keys())): for result in self.getResults(): - if result.rule.layer != layer: + if result.rule.type is ruleTypes.GLOBAL_MARKER or result.rule.layer != layer: continue - func = result.getScript(gesture) - if func is not None: - return func + script = result.getScript(gesture) + if script: + return script + # Handle script_notFound fallback for rules in reversed(list(self._layers.values())): for rule in list(rules.values()): for criterion in rule.criteria: @@ -315,28 +383,47 @@ def getScript(self, gesture): if func is not None: return func return self.defaultScripts.getScript(gesture) - + + def getWebModuleForControlId(self, controlId): + if self.parentZone is not None: + raise Exception("Supported on the root RuleManager only") + if not self.isReady: + return None + # Raises LookupError on purpose, to distinguish from returning None meaning "Not Ready" + offset = self._controlIdToPosition[controlId] + webModule = self.subModules.atPosition(offset) + if webModule is None: + webModule = self.webModule + return webModule + def _get_isReady(self): if not self._ready or not self.nodeManager or not self.nodeManager.isReady or self.nodeManager.identifier != self.nodeManagerIdentifier: return False return True def terminate(self): - self._webModule = None self._ready = False + self._webModule = None + self.subModules.terminate() try: self.timerCheckAutoAction.cancel() except Exception: pass self.timerCheckAutoAction = None self._nodeManager = None - del self._results[:] + self.clear() + + def clear(self): + self._ready = False + self._results.clear() + self._allResults.clear() self._mutatedControlsById.clear() - self._mutatedControlsByOffset[:] = [] - for ruleLayers in list(self._rules.values()): - for rule in list(ruleLayers.values()): - rule.resetResults() - + self._mutatedControlsByOffset.clear() + self._controlIdToPosition.clear() + for rule in self.getRules(): + rule.resetResults() + + @logException def update(self, nodeManager=None, force=False): if self.webModule is None: # This instance has been terminated @@ -348,42 +435,48 @@ def update(self, nodeManager=None, force=False): except AttributeError: pass self.timerCheckAutoAction = None - if nodeManager is not None: + if nodeManager is None: + nodeManager = self.nodeManager + else: self._nodeManager = weakref.ref(nodeManager) - if self.nodeManager is None or not self.nodeManager.isReady: + if nodeManager is None or not nodeManager.isReady: return False - if not force and self.nodeManagerIdentifier == self.nodeManager.identifier: + if not force and self.nodeManagerIdentifier == nodeManager.identifier: # already updated self._ready = True return False + self.nodeManagerIdentifier = nodeManager.identifier t = logTimeStart() - self._results[:] = [] - self._mutatedControlsById.clear() - self._mutatedControlsByOffset.clear() - for ruleLayers in list(self._rules.values()): - for rule in list(ruleLayers.values()): - rule.resetResults() - - results = self._results + self.clear() + # Do not clear the other mappings in subModules to avoid reloading + # modules that were already loaded on the last update. + self.subModules._results.clear() + + for rule in (rule for rule in self.getRules() if rule.properties.subModule): + results = rule.getResults() + self._results.extend(results) + self.subModules._results.extend(results) for rule in sorted( - [rule for ruleLayers in list(self._rules.values()) for rule in list(ruleLayers.values())], + (rule for rule in self.getRules() if not rule.properties.subModule), key=lambda rule: ( 0 if rule.type in ( ruleTypes.PAGE_TITLE_1, ruleTypes.PAGE_TITLE_2 - ) else 1 + ) else 2 ) ): - results.extend(rule.getResults()) - results.sort() - - for result in results: - if not result.properties.mutation: + self._results.extend(rule.getResults()) + + def resultSortKey(result): + # If two Results start at the same offset, sort first the widest + return result.startOffset#, -result.endOffset + + self._results.sort(key=resultSortKey) + self._allResults.extend(self._results) + + for result in self._results: + if not (hasattr(result, "node") and result.properties.mutation): continue - try: - controlId = int(result.node.controlIdentifier) - except Exception: - log.exception("rule: {}, node: {}".format(result.name, result.node)) - raise + controlId = result.node.controlIdentifier entry = self._mutatedControlsById.get(controlId) if entry is None: entry = MutatedControl(result) @@ -391,15 +484,21 @@ def update(self, nodeManager=None, force=False): self._mutatedControlsByOffset.append(entry) else: entry.apply(result) - + self.subModules.update() + self._allResults.sort(key=resultSortKey) + if self is self.rootRuleManager: + self._mutatedControlsByOffset.sort(key=lambda m: m.start) + self._controlIdToPosition = nodeManager.getControlIdToPosition() self._ready = True - self.nodeManagerIdentifier = self.nodeManager.identifier + # Zone update check can be performed only once ready if self.zone is not None: - if not self.zone.update(): + if not self.zone.update() or not self.zone.containsTextInfo( + nodeManager.treeInterceptor.makeTextInfo(textInfos.POSITION_CARET) + ): self.zone = None #logTime("update marker", t) if self.isReady: - webAppScheduler.scheduler.send(eventName="markerManagerUpdated", markerManager=self) + webAppScheduler.scheduler.send(eventName="ruleManagerUpdated", ruleManager=self) self.timerCheckAutoAction = threading.Timer( 1, # Accepts floating point number for sub-second precision self.checkAutoAction @@ -418,7 +517,12 @@ def checkPageTitle(self): webModule = self.webModule if title != webModule.activePageTitle: webModule.activePageTitle = title - webAppScheduler.scheduler.send(eventName="webApp", name="webApp_pageChanged", obj=title, webApp=webModule) + webAppScheduler.scheduler.send( + eventName="webModule", + name="webModule_pageChanged", + obj=title, + webModule=webModule + ) return True return False @@ -443,7 +547,7 @@ def checkAutoAction(self): if (lastText is None or text != lastText): self.triggeredIdentifiers[controlIdentifier] = text if autoActionName == "speak": - playWebAppSound("errorMessage") + playWebAccessSound("errorMessage") elif autoActionName == "moveto": if lastText is None: # only if it's a new identifier @@ -530,9 +634,9 @@ def getPageTypes(self): def _getIncrementalResult( self, + caret: textInfos.offsets.OffsetsTextInfo, previous=False, relative=True, - caret=None, types=None, name=None, respectZone=False, @@ -546,13 +650,11 @@ def _getIncrementalResult( rule = result.rule if not result.properties.skip or rule.type != ruleTypes.ZONE: continue - zone = Zone(result) + zone = result.zone if not zone.containsTextInfo(caret): skippedZones.append(zone) - for result in ( - reversed(self.getResults()) - if previous else self.getResults() - ): + results = self.getResults() if respectZone and self.zone else self.rootRuleManager.getAllResults() + for result in (reversed(results) if previous else results): rule = result.rule if types and rule.type not in types: continue @@ -569,14 +671,13 @@ def _getIncrementalResult( ): continue if ( - hasattr(result, "node") - and ( + ( not relative or ( not previous - and caret._startOffset < result.node.offset + and caret._startOffset < result.startOffset ) - or (previous and caret._startOffset > result.node.offset) + or (previous and caret._startOffset > result.startOffset) ) and ( not (respectZone or (previous and relative)) @@ -584,35 +685,36 @@ def _getIncrementalResult( or ( ( not respectZone - or self.zone.containsNode(result.node) - ) - and not ( - # If respecting zone restriction or iterating - # backwards relative to the caret position, - # avoid returning the current zone itself. - self.zone.name == result.rule.name - and self.zone.startOffset == result.node.offset + or self.zone.containsResult(result) ) + # If respecting zone restriction or iterating backwards relative to the + # caret position, avoid returning the current zone itself. + and not self.zone.equals(result.zone) ) ) ): return result return None - def getResultAtCaret(self, focus=None): - return next(self.iterResultsAtCaret(focus), None) + def getResultAtCaret(self): + """Includes Results from all WebModules active on the document + """ + return next(self.iterResultsAtCaret(), None) - def iterResultsAtCaret(self, focus=None): - if focus is None: - focus = api.getFocusObject() + def iterResultsAtCaret(self): + """Includes Results from all WebModules active on the document + """ try: - info = focus.treeInterceptor.makeTextInfo(textInfos.POSITION_CARET) + ti = self.nodeManager.treeInterceptor except AttributeError: return + info = ti.makeTextInfo(textInfos.POSITION_CARET) for result in self.iterResultsAtTextInfo(info): yield result def iterResultsAtObject(self, obj): + """Includes Results from all WebModules active on the document + """ try: info = obj.treeInterceptor.makeTextInfo(obj) except AttributeError: @@ -621,26 +723,19 @@ def iterResultsAtObject(self, obj): yield result def iterResultsAtTextInfo(self, info): - if not self.isReady: + """Includes Results from all WebModules active on the document + """ + root = self.rootRuleManager + if not root.isReady: return - if not self.getResults(): + results = root.getAllResults() + if not results: return if not isinstance(info, textInfos.offsets.OffsetsTextInfo): raise ValueError("Not supported {}".format(type(info))) offset = info._startOffset -# for result in self.iterResultsAtOffset(offset): -# yield result -# -# def iterResultsAtOffset(self, offset): -# if not self.isReady: -# return -# if not self.results: -# return - for r in reversed(self.getResults()): - if ( - hasattr(r, "node") - and r.node.offset <= offset < r.node.offset + r.node.size - ): + for r in reversed(results): + if r.startOffset <= offset <= r.endOffset: yield r def quickNav( @@ -655,7 +750,8 @@ def quickNav( quiet=False, ): if not self.isReady: - playWebAppSound("keyError") + playWebAccessSound("keyError") + # Translators: Reported when attempting an action while WebAccess is not ready ui.message(_("Not ready")) return None @@ -664,7 +760,8 @@ def quickNav( position = html.getCaretInfo() if position is None: - playWebAppSound("keyError") + playWebAccessSound("keyError") + # Translators: Reported when attempting an action while WebAccess is not ready ui.message(_("Not ready")) return None @@ -672,8 +769,8 @@ def quickNav( # return the first/last result. for relative in ((True, False) if cycle else (True,)): result = self._getIncrementalResult( - previous=previous, caret=position, + previous=previous, relative=relative, types=types, name=name, @@ -682,11 +779,11 @@ def quickNav( ) if result: if not relative: - playWebAppSound("loop") + playWebAccessSound("loop") time.sleep(0.2) break else: - playWebAppSound("keyError") + playWebAccessSound("keyError") time.sleep(0.2) if quiet: return False @@ -730,14 +827,14 @@ def quickNavToPreviousLevel1(self): self.quickNav(previous=True, types=(ruleTypes.ZONE,), honourSkip=False) def quickNavToNextLevel2(self): - self.quickNav(types=(ruleTypes.ZONE, ruleTypes.MARKER)) + self.quickNav(types=ruleTypes.ACTION_TYPES) def quickNavToPreviousLevel2(self): - self.quickNav(previous=True, types=(ruleTypes.ZONE, ruleTypes.MARKER)) + self.quickNav(previous=True, types=ruleTypes.ACTION_TYPES) def quickNavToNextLevel3(self): self.quickNav( - types=(ruleTypes.ZONE, ruleTypes.MARKER), + types=ruleTypes.ACTION_TYPES, respectZone=True, honourSkip=False, cycle=False @@ -746,13 +843,81 @@ def quickNavToNextLevel3(self): def quickNavToPreviousLevel3(self): self.quickNav( previous=True, - types=(ruleTypes.ZONE, ruleTypes.MARKER), + types=ruleTypes.ACTION_TYPES, respectZone=True, honourSkip=False, cycle=False ) +class SubModules(AutoPropertyObject): + + def __init__(self, ruleManager: RuleManager): + self._ruleManager = weakref.ref(ruleManager) + self._webModulesByNameAndIndex: Mapping[tuple(str, int), "WebModule"] = {} + """Ensure SubModules are not re-instanciated upon update + """ + self._webModulesByPosition: Mapping[tuple(int, int), "WebModule"] = {} + self._results: Sequence["SingleNodeResult"] = [] + """Results for this WebModule that should load a SubModule (ie. `result.properties.subModule` is set) + + Used in `Criteria.iterResults` to not search for nested matches. + """ + + def _get_ruleManager(self): + return self._ruleManager() + + def all(self) -> Sequence["WebModule"]: + return tuple(self._webModulesByPosition.values()) + + @logException + def atPosition(self, offset) -> "WebModule": + for (start, end), webModule in self._webModulesByPosition.items(): + if start <= offset < end: + subModule = webModule.ruleManager.subModules.atPosition(offset) + return subModule if subModule is not None else webModule + + @logException + def update(self): + from ..webModuleHandler import getWebModule + webModulesByNameAndIndex = self._webModulesByNameAndIndex + webModulesByPosition = self._webModulesByPosition + previousByNameAndIndex = webModulesByNameAndIndex.copy() + webModulesByNameAndIndex.clear() + webModulesByPosition.clear() + ruleManager = self.ruleManager + rootRuleManager = ruleManager.rootRuleManager + nodeManager = rootRuleManager.nodeManager + mutatedControlsById = rootRuleManager._mutatedControlsById + mutatedControlsByOffset = rootRuleManager._mutatedControlsByOffset + for result in self._results: + key = (result.rule.name, result.index) + webModule = previousByNameAndIndex.get(key) + if not webModule: + webModule = getWebModule(result.properties.subModule) + else: + webModule.ruleManager.parentZone.update(result=result) + if not webModule: + log.error(f"WebModule not found: {result.properties.subModule!r}") + continue + webModulesByNameAndIndex[key] = webModule + webModulesByPosition[(result.startOffset, result.endOffset)] = webModule + subRuleManager = webModule.ruleManager + subRuleManager.parentZone = result.zone + subRuleManager.update(nodeManager) + ruleManager._allResults.extend(subRuleManager.getAllResults()) + mutatedControlsById.update(subRuleManager._mutatedControlsById) + mutatedControlsByOffset.extend(subRuleManager._mutatedControlsByOffset) + + def terminate(self): + self._ruleManager = None + for webModule in self._webModulesByNameAndIndex.values(): + webModule.terminate() + self._results.clear() + self._webModulesByNameAndIndex.clear() + self._webModulesByPosition.clear() + + class CustomActionDispatcher(object): """ Execute a custom action, eventually overriding a standard action. @@ -841,14 +1006,17 @@ def getCustomFunc(self, webModule=None): ) -class Result(baseObject.ScriptableObject): +class Result(ScriptableObject): - def __init__(self, criteria): + def __init__(self, criteria, context, index): super().__init__() self._criteria = weakref.ref(criteria) + self.context: textInfos.offsets.Offsets = context + self.index = index self.properties = criteria.properties rule = criteria.rule self._rule = weakref.ref(rule) + self.zone = Zone(self) if rule.type == ruleTypes.ZONE else None webModule = rule.ruleManager.webModule prefix = "action_" for key in dir(webModule): @@ -870,6 +1038,12 @@ def __init__(self, criteria): }) self.bindGestures(criteria.gestures) + def __repr__(self): + try: + return f"<{type(self).__name__} of {self.rule.name!r} at {self.startOffset, self.endOffset}>" + except Exception: + return super().__repr__() + def _get_criteria(self): return self._criteria() @@ -888,6 +1062,12 @@ def _get_value(self): return customValue raise NotImplementedError + def _get_startOffset(self): + raise NotImplementedError + + def _get_endOffset(self): + raise NotImplementedError + def script_moveto(self, gesture): raise NotImplementedError @@ -912,9 +1092,22 @@ def script_speak(self, gesture): def script_mouseMove(self, gesture): raise NotImplementedError - def __lt__(self, other): + def __bool__(self): raise NotImplementedError + def __lt__(self, other): + try: + return self.startOffset < other.startOffset + except AttributeError as e: + raise TypeError(f"'<' not supported between instances of '{type(self)}' and '{type(other)}'") from e + + def containsNode(self, node): + offset = node.offset + return self.startOffset <= offset and self.endOffset >= offset + node.size + + def containsResult(self, result): + return self.startOffset <= result.startOffset and self.endOffset >= result.endOffset + def getDisplayString(self): return " ".join( [self.name] @@ -928,17 +1121,22 @@ def getDisplayString(self): class SingleNodeResult(Result): def __init__(self, criteria, node, context, index): - super().__init__(criteria) self._node = weakref.ref(node) - self.context = context - self.index = index + super().__init__(criteria, context, index) def _get_node(self): return self._node() def _get_value(self): return self.properties.customValue or self.node.getTreeInterceptorText() - + + def _get_startOffset(self): + return self.node.offset + + def _get_endOffset(self): + node = self.node + return node.offset + node.size + def script_moveto(self, gesture, fromQuickNav=False, fromSpeak=False): if self.node is None or self.node.nodeManager is None: return @@ -953,29 +1151,27 @@ def script_moveto(self, gesture, fromQuickNav=False, fromSpeak=False): ) elif self.properties.sayName: speech.speakMessage(self.label) - treeInterceptor = self.node.nodeManager.treeInterceptor + treeInterceptor = self.rule.ruleManager.nodeManager.treeInterceptor if not treeInterceptor or not treeInterceptor.isReady: return treeInterceptor.passThrough = self.properties.formMode browseMode.reportPassThrough.last = treeInterceptor.passThrough - if rule.type == ruleTypes.ZONE: - rule.ruleManager.zone = Zone(self) + if self.zone: + rule.ruleManager.zone = self.zone # Ensure the focus does not remain on a control out of the zone treeInterceptor.rootNVDAObject.setFocus() else: for result in reversed(rule.ruleManager.getResults()): - if result.rule.type != ruleTypes.ZONE: + zone = result.zone + if zone is None: continue - zone = Zone(result) if zone.containsResult(self): - if zone != rule.ruleManager.zone: - rule.ruleManager.zone = zone + rule.ruleManager.zone = zone break else: - rule.ruleManager.zone = None - info = treeInterceptor.makeTextInfo( - textInfos.offsets.Offsets(self.node.offset, self.node.offset) - ) + rule.ruleManager._set_zone(rule.ruleManager.parentZone, force=True) + offset = self.startOffset + info = treeInterceptor.makeTextInfo(textInfos.offsets.Offsets(offset, offset)) treeInterceptor.selection = info # Refetch the position in case some dynamic content has shrunk as we left it. info = treeInterceptor.selection.copy() @@ -1051,16 +1247,17 @@ def script_mouseMove(self, gesture): def getTextInfo(self): return self.node.getTextInfo() - def __lt__(self, other): - if hasattr(other, "node") is None: - return other >= self - return self.node.offset < other.node.offset + def __bool__(self): + return bool(self.node) + + def containsNode(self, node): + return node in self.node def getTitle(self): return self.label + " - " + self.node.innerText -class Criteria(baseObject.ScriptableObject): +class Criteria(ScriptableObject): def __init__(self, rule, data): super().__init__() @@ -1091,13 +1288,16 @@ def load(self, data): self.className = data.pop("className", None) self.states = data.pop("states", None) self.src = data.pop("src", None) + self.url = data.pop("url", None) self.relativePath = data.pop("relativePath", None) self.index = data.pop("index", None) self.gestures = data.pop("gestures", {}) + if self.text: + self.text = self.text.strip() gesturesMap = {} for gestureIdentifier in list(self.gestures.keys()): gesturesMap[gestureIdentifier] = "notFound" - self.bindGestures(gesturesMap) + # self.bindGestures(gesturesMap) self.properties.load(data.pop("properties", {})) if data: raise ValueError( @@ -1130,6 +1330,7 @@ def setIfNotNoneOrEmptyString(key, value): setIfNotNoneOrEmptyString("className", self.className) setIfNotNoneOrEmptyString("states", self.states) setIfNotNoneOrEmptyString("src", self.src) + setIfNotNoneOrEmptyString("url", self.url) setIfNotNoneOrEmptyString("relativePath", self.relativePath) setIfNotDefault("index", self.index) setIfNotDefault("gestures", self.gestures, {}) @@ -1204,19 +1405,16 @@ def checkContextPageType(self): if not (found or exclude): return False return True - - def createResult(self, node, context, index): - return SingleNodeResult(self, node, context, index) - + def iterResults(self): t = logTimeStart() - mgr = self.rule.ruleManager + rule = self.rule + mgr = rule.ruleManager text = self.text if not self.checkContextPageTitle(): return if not self.checkContextPageType(): return - # Handle contextParent rootNodes = set() # Set of possible parent nodes excludedNodes = set() # Set of excluded parent nodes @@ -1235,21 +1433,25 @@ def iterResults(self): name = name.strip() if not name: continue - rule = mgr.getRule(name, layer=self.layer) - if rule is None: - log.error(( - "In rule \"{rule}\".contextParent: " - "Rule not found: \"{parent}\"" - ).format(rule=self.name, parent=name)) + parentRule = mgr.getRule(name, layer=self.layer) + if parentRule is None: + log.error( + 'In rule "{rule}", alternative "{alternative}" .contextParent: ' + 'Rule not found: "{parent}"' + ).format( + rule=rule.name, + alternative=self.name or f"#{rule.criteria.index(self)}", + parent=name, + ) return - results = rule.getResults() + results = parentRule.getResults() if not exclude and any(r.properties.multiple for r in results): if multipleContext is None: multipleContext = True else: multipleContext = False if results: - nodes = [result.node for result in results] + nodes = (result.node for result in results) if exclude: excludedNodes.update(nodes) else: @@ -1271,6 +1473,9 @@ def iterResults(self): return rootNodes = newRootNodes kwargs = getSimpleSearchKwargs(self) + excludedNodes.update({ + result.node for result in mgr.subModules._results + }) if excludedNodes: kwargs["exclude"] = excludedNodes limit = None @@ -1278,7 +1483,13 @@ def iterResults(self): limit = self.index or 1 # 1-based index = 0 - for root in rootNodes or (mgr.nodeManager.mainNode,): + if not rootNodes: + parentZone = mgr.parentZone + if parentZone is not None: + rootNodes = (parentZone.result.node,) + else: + rootNodes = (mgr.nodeManager.mainNode,) + for root in rootNodes or (parentNode,): rootLimit = limit if multipleContext: index = 0 @@ -1294,8 +1505,8 @@ def iterResults(self): context = textInfos.offsets.Offsets( startOffset=root.offset, endOffset=root.offset + root.size - ) if root is not self.ruleManager.nodeManager.mainNode else None - yield self.createResult(node, context, index) + ) if root is not mgr.nodeManager.mainNode else None + yield rule.createResult(self, node, context, index) if not self.properties.multiple and not multipleContext: return @@ -1305,7 +1516,7 @@ def script_notFound(self, gesture): ) -class Rule(baseObject.ScriptableObject): +class Rule(ScriptableObject): def __init__(self, ruleManager, data): super().__init__() @@ -1364,6 +1575,9 @@ def load(self, data): + ", ".join(list(data.keys())) ) + def createResult(self, criteria, node, context, index): + return SingleNodeResult(criteria, node, context, index) + def resetResults(self): self._results = None @@ -1409,6 +1623,7 @@ def getSimpleSearchKwargs(criteria, raiseOnUnsupported=False): "states", "tag", "text", + "url", ]: if raiseOnUnsupported: raise ValueError( @@ -1429,8 +1644,10 @@ def getSimpleSearchKwargs(criteria, raiseOnUnsupported=False): kwargs["in_text"] = expr[1:] continue if prop == "className": + # For "className", both space and ampersand are treated as "and" operator expr = expr.replace(" ", "&") - for andIndex, expr in enumerate(expr.split("&")): + # For "url", only space is treated as "and" operator + for andIndex, expr in enumerate(expr.split("&" if prop != "url" else " ")): expr = expr.strip() eq = [] notEq = [] @@ -1441,15 +1658,28 @@ def getSimpleSearchKwargs(criteria, raiseOnUnsupported=False): if not expr: continue if expr[0] == "!": - if "*" in expr: - notIn.append(expr[1:].strip()) + expr = expr[1:].strip() + if prop == "url": + if expr[0] == "=": + notEq.append(expr[1:].strip()) + else: + notIn.append(expr) else: - notEq.append(expr[1:].strip()) + if "*" in (expr[0], expr[-1]): + notIn.append(expr.strip("*").strip()) + else: + notEq.append(expr) else: - if "*" in expr: - in_.append(expr) + if prop == "url": + if expr[0] == "=": + eq.append(expr[1:].strip()) + else: + in_.append(expr) else: - eq.append(expr) + if "*" in (expr[0], expr[-1]): + in_.append(expr.strip("*").strip()) + else: + eq.append(expr) for test, values in ( ("eq", eq), ("notEq", notEq), @@ -1458,6 +1688,11 @@ def getSimpleSearchKwargs(criteria, raiseOnUnsupported=False): ): if not values: continue + if prop in ("role", "states"): + try: + values = [int(value) for value in values] + except ValueError: + log.error(f"Invalid search criterion: {prop} {test} {values}") key = "{test}_{prop}#{index}".format( test=test, prop=prop, @@ -1467,109 +1702,130 @@ def getSimpleSearchKwargs(criteria, raiseOnUnsupported=False): return kwargs -class Zone(textInfos.offsets.Offsets, TrackedObject): +class Zone(AutoPropertyObject): def __init__(self, result): + super().__init__() + self.result = result rule = result.rule self._ruleManager = weakref.ref(rule.ruleManager) + self.layer = rule.layer self.name = rule.name - super().__init__(startOffset=None, endOffset=None) - self._update(result) + self.index = result.index - @property - def ruleManager(self): + def _get_ruleManager(self): return self._ruleManager() - def __bool__(self): # Python 3 - return self.startOffset is not None and self.endOffset is not None + def _get_result(self): + return self._result and self._result() - def __eq__(self, other): - return ( - isinstance(other, Zone) - and other.ruleManager == self.ruleManager - and other.name == self.name - and other.startOffset == self.startOffset - and other.endOffset == self.endOffset - ) + def _set_result(self, result): + self._result = weakref.ref(result) - def __hash__(self): - return hash((self.startOffset, self.endOffset)) + def __bool__(self): + return bool(self.result) def __repr__(self): + layer = self.layer + name = self.name if not self: - return "".format(repr(self.name)) - return "".format( - repr(self.name), self.startOffset, self.endOffset - ) + return f"" + result = self.result + startOffset = result.startOffset + endOffset = result.endOffset + return f"" def containsNode(self, node): - if not self: - return False - return self.startOffset <= node.offset < self.endOffset + offset = node.offset + return self.containsOffsets(offset, offset + node.size) + + def containsOffsets(self, startOffset, endOffset): + result = self.result + return ( + result + and result.startOffset <= startOffset + and result.endOffset >= endOffset + ) def containsResult(self, result): - if not self: - return False - if hasattr(result, "node"): - return self.containsNode(result.node) - return False + r = self.result + return r and r.containsResult(result) def containsTextInfo(self, info): - if not self: - return False - if not isinstance(info, textInfos.offsets.OffsetsTextInfo): - raise ValueError("Not supported {}".format(type(info))) + try: + return self.containsOffsets(info._startOffset, info._endOffset) + except AttributeError: + if not isinstance(info, textInfos.offsets.OffsetsTextInfo): + raise ValueError("Not supported {}".format(type(info))) + raise + + def equals(self, other): + """Check if `obj` represents an instance of the same `Zone`. + + This cannot be achieved by implementing the usual `__eq__` method + because `baseObjects.AutoPropertyObject.__new__` requires it to + operate on identity as it stores the instance as key in a `WeakKeyDictionnary` + in order to later invalidate property cache. + """ return ( - self.startOffset <= info._startOffset - and info._endOffset <= self.endOffset + isinstance(other, type(self)) + and self.name == other.name + and self.index == other.index ) def getRule(self): - return self.ruleManager.getRule(self.name) + return self.ruleManager.getRule(self.name, layer=self.layer) + + def isOffsetAtStart(self, offset): + result = self.result + return result and result.startOffset == offset + + def isOffsetAtEnd(self, offset): + result = self.result + return result and result.endOffset == offset def isTextInfoAtStart(self, info): - if not isinstance(info, textInfos.offsets.OffsetsTextInfo): - raise ValueError("Not supported {}".format(type(info))) - return self and info._startOffset == self.startOffset + try: + return self.isOffsetAtStart(info._startOffset) + except AttributeError: + if not isinstance(info, textInfos.offsets.OffsetsTextInfo): + raise ValueError("Not supported {}".format(type(info))) + raise def isTextInfoAtEnd(self, info): - if not isinstance(info, textInfos.offsets.OffsetsTextInfo): - raise ValueError("Not supported {}".format(type(info))) - return self and info._endOffset == self.endOffset + try: + return self.isOffsetAtEnd(info._endOffset) + except AttributeError as e: + if not isinstance(info, textInfos.offsets.OffsetsTextInfo): + raise ValueError("Not supported {}".format(type(info))) from e def restrictTextInfo(self, info): if not isinstance(info, textInfos.offsets.OffsetsTextInfo): raise ValueError("Not supported {}".format(type(info))) - if not self: + result = self.result + if not result: return False res = False - if info._startOffset < self.startOffset: - res = True - info._startOffset = self.startOffset - elif info._startOffset > self.endOffset: + if info._startOffset < result.startOffset: res = True - info._startOffset = self.endOffset - if info._endOffset < self.startOffset: + info._startOffset = result.startOffset + elif info._startOffset > result.endOffset: res = True - info._endOffset = self.startOffset - elif info._endOffset > self.endOffset: - res = True - info._endOffset = self.endOffset + info._startOffset = result.endOffset return res - def update(self): + def update(self, result=None): + if result is not None: + self._result = result + return True try: - result = next(self.ruleManager.iterResultsByName(self.name)) - except StopIteration: - self.startOffset = self.endOffset = None + # Result index is 1-based + self.result = self.getRule().getResults()[self.index - 1] + except IndexError: + self._result = None return False - return self._update(result) - - def _update(self, result): - node = result.node - if not node: - self.startOffset = self.endOffset = None + except Exception: + log.exception() + self._result = None return False - self.startOffset = node.offset - self.endOffset = node.offset + node.size return True diff --git a/addon/globalPlugins/webAccess/ruleHandler/controlMutation.py b/addon/globalPlugins/webAccess/ruleHandler/controlMutation.py index 6a9d546e..04330cb9 100644 --- a/addon/globalPlugins/webAccess/ruleHandler/controlMutation.py +++ b/addon/globalPlugins/webAccess/ruleHandler/controlMutation.py @@ -20,7 +20,10 @@ # See the file COPYING.txt at the root of this distribution for more details. -__author__ = "Julien Cochuyt " +__authors__ = ( + "Julien Cochuyt ", + "André-Abush Clause ", +) import addonHandler @@ -57,7 +60,7 @@ def __init__(self, result): @property def controlId(self): - return int(self.node.controlIdentifier) + return self.node.controlIdentifier @property def start(self): @@ -69,11 +72,13 @@ def end(self): def apply(self, result): rule = result.rule - mutation = MUTATIONS[result.properties.mutation] - if mutation is None: - raise ValueError("No mutation defined for this rule: {}".format( - rule.name - )) + try: + mutation = MUTATIONS[result.properties.mutation] + except LookupError as e: + if not result.properties.mutation: + raise ValueError(f"No mutation defined for this rule: {rule.name}") from e + else: + raise ValueError(f"Unknown mutation in rule {rule.name!r}: {result.properties.mutation!r}") from e self.attrs.update(mutation.attrs) if mutation.mutateName: self.attrs["name"] = rule.label diff --git a/addon/globalPlugins/webAccess/ruleHandler/properties.py b/addon/globalPlugins/webAccess/ruleHandler/properties.py index 13e62688..6d5f1543 100644 --- a/addon/globalPlugins/webAccess/ruleHandler/properties.py +++ b/addon/globalPlugins/webAccess/ruleHandler/properties.py @@ -32,6 +32,8 @@ from typing import Any, TypeAlias import weakref +from . import ruleTypes + import addonHandler @@ -54,7 +56,13 @@ class PropertySpecValue: """ __slots__ = ( - "ruleTypes", "valueType", "default", "displayName", "displayValueIfUndefined", "isRestrictedChoice" + "ruleTypes", + "valueType", + "default", + "displayName", + "displayValueIfUndefined", + "isRestrictedChoice", + "hasSuggestions", ) ruleTypes: Sequence[str] # Rule types for which the property is supported @@ -62,7 +70,8 @@ class PropertySpecValue: default: PropertyValue displayName: str | Mapping[Sequence[str], str] # Can be different by rule type displayValueIfUndefined: str - isRestrictedChoice: bool # Currently applies only in the editor + isRestrictedChoice: bool # Applies only in the editor + hasSuggestions: bool # Applies only in the editor def getDisplayName(self, ruleType) -> str: displayName = self.displayName @@ -74,67 +83,79 @@ def getDisplayName(self, ruleType) -> str: class PropertySpec(Enum): autoAction = PropertySpecValue( - ruleTypes=("marker", "zone"), + ruleTypes=ruleTypes.ACTION_TYPES, valueType=str, default=None, # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Auto Actions"), # Translators: Displayed if no value is set for the "Auto Actions" property displayValueIfUndefined=pgettext("webAccess.action", "No action"), - isRestrictedChoice=True + isRestrictedChoice=True, + hasSuggestions=False, ) multiple = PropertySpecValue( - ruleTypes=("marker",), + ruleTypes=(ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.PARENT, ruleTypes.ZONE), valueType=bool, default=False, # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Multiple results"), displayValueIfUndefined=None, # Does not apply as there is a sensible default - isRestrictedChoice=False + isRestrictedChoice=False, + hasSuggestions=False, ) formMode = PropertySpecValue( - ruleTypes=("marker", "zone"), + ruleTypes=(ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.ZONE), valueType=bool, default=False, # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Activate form mode"), displayValueIfUndefined=None, # Does not apply as there is a sensible default - isRestrictedChoice=False + isRestrictedChoice=False, + hasSuggestions=False, ) skip = PropertySpecValue( - ruleTypes=("marker", "zone"), + ruleTypes=(ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.ZONE), valueType=bool, default=False, # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Skip with Page Down"), displayValueIfUndefined=None, # Does not apply as there is a sensible default - isRestrictedChoice=False + isRestrictedChoice=False, + hasSuggestions=False, ) sayName = PropertySpecValue( - ruleTypes=("marker", "zone"), + ruleTypes=(ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.ZONE), valueType=bool, default=False, # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Speak rule name"), displayValueIfUndefined=None, # Does not apply as there is a sensible default - isRestrictedChoice=False + isRestrictedChoice=False, + hasSuggestions=False, ) customName = PropertySpecValue( - ruleTypes=("marker", "zone"), + ruleTypes=(ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.ZONE), valueType=str, default="", # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Custom name"), # Translators: Displayed if no value is set for a given rule property displayValueIfUndefined=pgettext("webAccess.ruleProperty", ""), - isRestrictedChoice=False + isRestrictedChoice=False, + hasSuggestions=False, ) customValue = PropertySpecValue( - ruleTypes=("marker", "pageTitle1", "pageTitle2", "zone"), + ruleTypes=( + ruleTypes.GLOBAL_MARKER, + ruleTypes.MARKER, + ruleTypes.PAGE_TITLE_1, + ruleTypes.PAGE_TITLE_2, + ruleTypes.ZONE + ), valueType=str, default="", displayName={ - ("marker", "zone"): + (ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.ZONE): # Translators: The display name for a rule property pgettext("webAccess.ruleProperty", "Custom message"), ("pageTitle1", "pageTitle2"): @@ -143,17 +164,30 @@ class PropertySpec(Enum): }, # Translators: Displayed if no value is set for a given rule property displayValueIfUndefined=pgettext("webAccess.ruleProperty", ""), - isRestrictedChoice=False + isRestrictedChoice=False, + hasSuggestions=False, ) mutation = PropertySpecValue( - ruleTypes=("marker", "zone"), + ruleTypes=(ruleTypes.GLOBAL_MARKER, ruleTypes.MARKER, ruleTypes.ZONE), valueType=str, default=None, # Translators: The display name for a rule property displayName=pgettext("webAccess.ruleProperty", "Transform"), # Translators: Displayed if no value is set for the "Transform" rule property displayValueIfUndefined=pgettext("webAccess.ruleProperty.mutation", "None"), - isRestrictedChoice=True + isRestrictedChoice=True, + hasSuggestions=False, + ) + subModule = PropertySpecValue( + ruleTypes=(ruleTypes.ZONE,), + valueType=str, + default="", + # Translators: The display name for a rule property + displayName=pgettext("webAccess.ruleProperty", "Load sub-module"), + # Translators: The displayed text if there is no value for the "Load sub-module" property + displayValueIfUndefined=pgettext("webAccess.ruleProperty.subModule", "No"), + isRestrictedChoice=False, + hasSuggestions=True, ) def __getattr__(self, name: str): diff --git a/addon/globalPlugins/webAccess/ruleHandler/ruleTypes.py b/addon/globalPlugins/webAccess/ruleHandler/ruleTypes.py index a5a930ba..19fddfcc 100644 --- a/addon/globalPlugins/webAccess/ruleHandler/ruleTypes.py +++ b/addon/globalPlugins/webAccess/ruleHandler/ruleTypes.py @@ -30,6 +30,7 @@ addonHandler.initTranslation() +GLOBAL_MARKER = "globalMarker" MARKER = "marker" ZONE = "zone" PAGE_TYPE = "pageType" @@ -37,8 +38,11 @@ PAGE_TITLE_1 = "pageTitle1" PAGE_TITLE_2 = "pageTitle2" +ACTION_TYPES = (GLOBAL_MARKER, MARKER, ZONE) ruleTypeLabels = { + # Translators: The label for a rule type. + GLOBAL_MARKER: _("Global Marker"), # Translators: The label for a rule type. MARKER: _("Marker"), # Translators: The label for a rule type. diff --git a/addon/globalPlugins/webAccess/store/webModule.py b/addon/globalPlugins/webAccess/store/webModule.py index f44f4bdd..582f7e37 100644 --- a/addon/globalPlugins/webAccess/store/webModule.py +++ b/addon/globalPlugins/webAccess/store/webModule.py @@ -61,7 +61,7 @@ class WebModuleJsonFileDataStore(Store): - def __init__(self, name, basePath, dirName="webModulesMC"): + def __init__(self, name, basePath, dirName="webModulesSM"): super().__init__(name=name) self.basePath = basePath self.path = os.path.join(basePath, dirName) @@ -81,7 +81,8 @@ def catalog(self, errors=None): try: data = self.get(ref).data meta = {} - for key in ("windowTitle", "url"): + for key in ("name", "url", "windowTitle"): + # "WebApp" corresponds to legacy format version (pre 0.1) value = data.get("WebModule", data.get("WebApp", {})).get(key) if value: meta[key] = value @@ -214,7 +215,7 @@ def write(self, path, data): class WebModuleStore(DispatchStore): def __init__(self, *args, **kwargs): - # The order of this list is meaningful. See `catalog` + # The order of this list is meaningful. See `catalog` stores = kwargs["stores"] = [] store = self.userStore = WebModuleJsonFileDataStore( name="userConfig", basePath=globalVars.appArgs.configPath diff --git a/addon/globalPlugins/webAccess/utils.py b/addon/globalPlugins/webAccess/utils.py index 65391f61..17e09e66 100644 --- a/addon/globalPlugins/webAccess/utils.py +++ b/addon/globalPlugins/webAccess/utils.py @@ -20,7 +20,11 @@ # See the file COPYING.txt at the root of this distribution for more details. -__author__ = "Julien Cochuyt " +__authors__ = ( + "Julien Cochuyt ", + "Frédéric Brugnot ", + "André-Abush Clause ", +) from functools import wraps @@ -103,3 +107,13 @@ def wrapper(*args, **kwargs): return wrapper + +def tryInt(value): + """Try to convert the given value to `int` + + If the conversion fails, the value is returned unchanged. + """ + try: + return int(value) + except ValueError: + return value diff --git a/addon/globalPlugins/webAccess/webAppLib/__init__.py b/addon/globalPlugins/webAccess/webAppLib/__init__.py index 6aadbc49..c8257fc1 100644 --- a/addon/globalPlugins/webAccess/webAppLib/__init__.py +++ b/addon/globalPlugins/webAccess/webAppLib/__init__.py @@ -22,7 +22,11 @@ # Get ready for Python 3 -__author__ = "Frédéric Brugnot " +__authors__ = ( + "Frédéric Brugnot ", + "Julien Cochuyt ", + "André-Abush Clause ", +) import os @@ -62,12 +66,12 @@ def speechOn(delay=0): api.processPendingEvents () speech.setSpeechMode(speech.SpeechMode.talk) -def playWebAppSound (name): +def playWebAccessSound(name): from ... import webAccess try: playSound(os.path.join(webAccess.SOUND_DIRECTORY, "%s.wav" % name)) - except: - pass + except Exception: + log.exception() def playSound(sound): sound = os.path.abspath(os.path.join(os.path.dirname(__file__), sound)) diff --git a/addon/globalPlugins/webAccess/webAppScheduler.py b/addon/globalPlugins/webAccess/webAppScheduler.py index a3039ee8..4d56ddd8 100644 --- a/addon/globalPlugins/webAccess/webAppScheduler.py +++ b/addon/globalPlugins/webAccess/webAppScheduler.py @@ -22,7 +22,12 @@ # Get ready for Python 3 -__author__ = "Frédéric Brugnot " +__authors__ = ( + "Frédéric Brugnot ", + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) import threading @@ -80,7 +85,7 @@ def run(self): else: log.info("event %s is not found" % eventName) - log.info ("webAppScheduler stopped !") + log.info("webAppScheduler stopped!") def send(self, **kwargs): self.queue.put(kwargs) @@ -103,10 +108,10 @@ def event_timeout(self): def fakeNext(self = None): return True - def event_webApp(self, name=None, obj=None, webApp=None): + def event_webModule(self, name=None, obj=None, webModule=None): funcName = 'event_%s' % name - #log.info("webApp %s will handle the event %s" % (webApp.name, name)) - func = getattr(webApp, funcName, None) + #log.info("webModule %s will handle the event %s" % (webModule.name, name)) + func = getattr(webModule, funcName, None) if func: func(obj, self.fakeNext) @@ -130,29 +135,6 @@ def event_treeInterceptor_gainFocus(self, treeInterceptor, firstGainFocus): #browseMode.reportPassThrough(treeInterceptor) self.send(eventName="updateNodeManager", treeInterceptor=treeInterceptor) - def event_checkWebAppManager(self): - # TODO: Should not be triggered anymore - log.error("event_checkWebAppManager") - focus = api.getFocusObject() - webApp = focus.webAccess.webModule if isinstance(focus, WebAccessObject) else None - TRACE("event_checkWebAppManager: webApp={webApp}".format( - webApp=id(webApp) if webApp is not None else None - )) - if webApp: - treeInterceptor = focus.treeInterceptor - if treeInterceptor: - #webApp.treeInterceptor = treeInterceptor - nodeManager = getattr(treeInterceptor, "nodeManager", None) - TRACE( - "event_checkWebAppManager: " - "nodeManager={nodeManager}".format( - nodeManager=id(nodeManager) - if nodeManager is not None else None - ) - ) - if nodeManager: - webApp.markerManager.update(nodeManager) - def event_updateNodeManager(self, treeInterceptor): if not ( isinstance(treeInterceptor, WebAccessBmdti) @@ -166,15 +148,14 @@ def event_nodeManagerUpdated(self, nodeManager): nodeManager and nodeManager.treeInterceptor and isinstance(nodeManager.treeInterceptor, WebAccessBmdti) - and nodeManager.treeInterceptor.webAccess.ruleManager + and nodeManager.treeInterceptor.webAccess.rootWebModule ): return - nodeManager.treeInterceptor.webAccess.ruleManager.update(nodeManager) + nodeManager.treeInterceptor.webAccess.rootRuleManager.update(nodeManager) - def event_markerManagerUpdated(self, markerManager): + def event_ruleManagerUpdated(self, ruleManager): # Doesn't work outside of the main thread for Google Chrome 83 - wx.CallAfter(markerManager.checkPageTitle) - # markerManager.checkAutoAction() + wx.CallAfter(ruleManager.checkPageTitle) def event_gainFocus(self, obj): pass @@ -187,7 +168,7 @@ def onNodeMoveto(self, node, reason): if webModule is not None: scheduler.send( - eventName="webApp", + eventName="webModule", name='node_gainFocus', - obj=node, webApp=webModule - ) + obj=node, webModule=webModule + ) diff --git a/addon/globalPlugins/webAccess/webModuleHandler/__init__.py b/addon/globalPlugins/webAccess/webModuleHandler/__init__.py index 8204e3a1..6429387c 100644 --- a/addon/globalPlugins/webAccess/webModuleHandler/__init__.py +++ b/addon/globalPlugins/webAccess/webModuleHandler/__init__.py @@ -44,9 +44,10 @@ from ..overlay import WebAccessBmdti from ..store import DuplicateRefError from ..store import MalformedRefError +from ..utils import notifyError -PACKAGE_NAME = "webModulesMC" +PACKAGE_NAME = "webModulesSM" store = None _catalog = None @@ -55,7 +56,7 @@ def delete(webModule, prompt=True): if prompt: - from ..gui.webModulesManager import promptDelete + from ..gui.webModule import promptDelete if not promptDelete(webModule): return False store.delete(webModule) @@ -77,8 +78,47 @@ def getCatalog(refresh=False, errors=None): return _catalog +def getWebModule(name: str) -> WebModule: + for ref, meta in getCatalog(): + candidate = meta.get("name") + if candidate == name: + return store.get(ref) + + def getWebModuleForTreeInterceptor(treeInterceptor): + from ..overlay import WebAccessObject obj = treeInterceptor.rootNVDAObject + + # Dialogs and Applications are handled by NVDA in a separate TreeInterceptor + # (while, sadly, IFrames are not). + # If the Dialog or Application belongs to a SubModule, we need to pass it here + # as it can't be loaded using the usual triggers (URL or WindowTitle). + # Still, if we simply set the same SubModule on the new TreeInterceptor, its current + # results will be invalidated by the upcoming update and it will finally get terminated + # when the TreeInterceptor gets killed when the modal is discarded. + if ( + isinstance(obj, WebAccessObject) + and obj._get_role(original=True) in (controlTypes.ROLE_DIALOG, controlTypes.ROLE_APPLICATION) + ): + class Break(Exception): + """Block-level break.""" + + try: + parent = obj.parent + if isinstance(parent, WebAccessObject): + webModule = parent.webAccess.webModule + if webModule is None: + raise Break() + # Not testing on purpose if this is a SubModule: Applications/dialogs can be nested in + # one another and, while we could walk up the ancestry to check this, it wouldn't give + # much benefits as in a single WebModule scenario, a new instance would be loaded for + # the new TreeInterceptor anyway. + return store.get(webModule.layers[-1].storeRef) + except Break: + pass + except Exception: + log.exception() + windowTitle = getWindowTitle(obj) if windowTitle: mod = getWebModuleForWindowTitle(windowTitle) @@ -219,8 +259,8 @@ def save(webModule, layerName=None, prompt=True, force=False, fromRuleEditor=Fal except DuplicateRefError as e: if not prompt or force: return False - from ..gui import webModuleEditor - if webModuleEditor.promptOverwrite(): + from ..gui.webModule.editor import promptOverwrite + if promptOverwrite(): return save(webModule, layerName=layerName, prompt=prompt, force=True) return False except MalformedRefError: @@ -232,23 +272,18 @@ def save(webModule, layerName=None, prompt=True, force=False, fromRuleEditor=Fal + os.linesep + "\t" + "\\ / : * ? \" | " ), - caption=webModuleEditor.Dialog._instance.Title, + caption=prompt, style=wx.OK | wx.ICON_EXCLAMATION ) return False except Exception: - log.exception("save(webModule={!r}, layerName=={!r}, prompt=={!r}, force=={!r}".format( + msg = "save(webModule={!r}, layerName=={!r}, prompt=={!r}, force=={!r}".format( webModule, layerName, prompt, force - )) + ) if prompt: - gui.messageBox( - # Translators: The text of a generic error message dialog - message=_("An error occured.\n\nPlease consult NVDA's log."), - # Translators: The title of an error message dialog - caption=_("Web Access for NVDA"), - style=wx.OK | wx.ICON_EXCLAMATION - ) - getWebModules(refresh=True) + notifyError(msg) + else: + log.exception(msg) return False if not fromRuleEditor: # only if webModule creation or modification @@ -258,97 +293,29 @@ def save(webModule, layerName=None, prompt=True, force=False, fromRuleEditor=Fal return True -def showCreator(context): - showEditor(context, new=True) - - -def showEditor(context, new=False): - from ..gui import webModuleEditor - from .webModule import WebModule - - if "data" in context: - del context["data"] - if new: - if "webModule" in context: - del context["webModule"] - webModuleEditor.show(context) - return - keepShowing = True - force = False - while keepShowing: - if webModuleEditor.show(context): - keepTrying = True - while keepTrying: - try: - if new: - webModule = context["webModule"] = WebModule() - webModule.load(None, data=context["data"]) - create( - webModule=webModule, - focus=context.get("focusObject"), - force=force - ) - # Translators: Confirmation message after web module creation. - ui.message( - _( - "Your new web module {name} has been created." - ).format(name=webModule.name) - ) - else: - webModule = context["webModule"] - for name, value in list(context["data"]["WebModule"].items()): - setattr(webModule, name, value) - update( - webModule=webModule, - focus=context.get("focusObject"), - force=force - ) - keepShowing = keepTrying = False - except DuplicateRefError as e: - if webModuleEditor.promptOverwrite(): - force = True - else: - keepTrying = force = False - except MalformedRefError: - keepTrying = force = False - gui.messageBox( - message=( - _("The web module name should be a valid file name.") - + " " + os.linesep - + _("It should not contain any of the following:") - + os.linesep - + "\t" + "\\ / : * ? \" | " - ), - caption=webModuleEditor.Dialog._instance.Title, - style=wx.OK | wx.ICON_EXCLAMATION - ) - finally: - if not new: - getWebModules(refresh=True) - else: - keepShowing = False - if new: - # Translator: Canceling web module creation. - ui.message(_("Cancel")) - - -def showManager(context): - from ..gui import webModulesManager - webModulesManager.show(context) - - def getEditableWebModule(webModule, layerName=None, prompt=True): + """Ensure a WebModule is suitable for edition, eventually initializing a writable layer + + `layerName` + The name of the layer from which a Rule is to be edited. Should be `None` for + updating WebModule properties such as triggers and help content. + + See `WebModuleHandler.webModule.WebModuleDataLayer._get_readonly` for details regarding + what configuration allows editing WebModules from which layer. + + Returns `None` if the current configuration does not allow editing the specified WebModule. + """ try: if layerName is not None: if not webModule.getLayer(layerName).readOnly: return webModule - webModule = getEditableScratchpadWebModule(webModule, layerName=layerName, prompt=prompt) + webModule = _getEditableScratchpadWebModule(webModule, layerName=layerName, prompt=prompt) else: if not webModule.isReadOnly(): return webModule webModule = ( - getEditableUserConfigWebModule(webModule) - or getEditableScratchpadWebModule(webModule, prompt=prompt) + _getEditableUserConfigWebModule(webModule) + or _getEditableScratchpadWebModule(webModule, prompt=prompt) ) except Exception: log.exception("webModule={!r}, layerName={!r}".format(webModule, layerName)) @@ -392,7 +359,7 @@ def getEditableWebModule(webModule, layerName=None, prompt=True): ) -def getEditableScratchpadWebModule(webModule, layerName=None, prompt=True): +def _getEditableScratchpadWebModule(webModule, layerName=None, prompt=True): if not ( config.conf["development"]["enableScratchpadDir"] and config.conf["webAccess"]["devMode"] @@ -412,7 +379,7 @@ def getEditableScratchpadWebModule(webModule, layerName=None, prompt=True): if layerName != "addon": return None if prompt: - from ..gui.webModulesManager import promptMask + from ..gui.webModule import promptMask if not promptMask(webModule): return False data = webModule.dump(layerName).data @@ -422,7 +389,7 @@ def getEditableScratchpadWebModule(webModule, layerName=None, prompt=True): return mask -def getEditableUserConfigWebModule(webModule): +def _getEditableUserConfigWebModule(webModule): if config.conf["webAccess"]["disableUserConfig"]: return None layer = webModule.getLayer("user") diff --git a/addon/globalPlugins/webAccess/webModuleHandler/webModule.py b/addon/globalPlugins/webAccess/webModuleHandler/webModule.py index dbb7f96c..819dbff1 100644 --- a/addon/globalPlugins/webAccess/webModuleHandler/webModule.py +++ b/addon/globalPlugins/webAccess/webModuleHandler/webModule.py @@ -33,6 +33,7 @@ import datetime import json import os +import sys import addonHandler addonHandler.initTranslation() @@ -48,8 +49,14 @@ import ui from ..lib.markdown2 import markdown from ..lib.packaging import version -from ..webAppLib import playWebAppSound -from .. import ruleHandler +from ..webAppLib import playWebAccessSound + + +if sys.version_info[1] < 9: + from typing import Sequence +else: + from collections.abc import Sequence + class InvalidApiVersion(version.InvalidVersion): pass @@ -57,19 +64,16 @@ class InvalidApiVersion(version.InvalidVersion): class WebModuleDataLayer(baseObject.AutoPropertyObject): - def __init__(self, name, data, storeRef, rulesOnly=False, readOnly=None): + def __init__(self, name, data, storeRef, readOnly=None): self.name = name self.data = data self.storeRef = storeRef - self.rulesOnly = rulesOnly if readOnly is not None: self.readOnly = readOnly self.dirty = False def __repr__(self): - return "".format(self.name, self.storeRef) def _get_readOnly(self): storeRef = self.storeRef @@ -95,17 +99,18 @@ def _get_readOnly(self): class WebModule(baseObject.ScriptableObject): - API_VERSION = version.parse("0.5") + API_VERSION = version.parse("0.6") - FORMAT_VERSION_STR = "0.9-dev" + FORMAT_VERSION_STR = "0.10-dev" FORMAT_VERSION = version.parse(FORMAT_VERSION_STR) def __init__(self): super().__init__() - self.layers = [] # List of `WebModuleDataLayer` instances + self.layers: Sequence[WebModuleDataLayer] = [] self.activePageTitle = None self.activePageIdentifier = None - self.ruleManager = ruleHandler.RuleManager(self) + from ..ruleHandler import RuleManager + self.ruleManager = RuleManager(self) def __repr__(self): return "WebModule {name}".format( @@ -173,8 +178,12 @@ def chooseNVDAObjectOverlayClasses(self, obj, clsList): """ return False + def getScript(self,gesture): + return super().getScript(gesture) or self.ruleManager.getScript(gesture) + def createRule(self, data): - return ruleHandler.Rule(self.ruleManager, data) + from ..ruleHandler import Rule + return Rule(self.ruleManager, data) def dump(self, layerName): layer = self.getLayer(layerName, raiseIfMissing=True) @@ -183,13 +192,34 @@ def dump(self, layerName): data["Rules"] = self.ruleManager.dump(layerName) return layer + def equals(self, obj): + """Check if `obj` represents an instance of the same `WebModule`. + + This cannot be achieved by implementing the usual `__eq__` method + because `baseObjects.AutoPropertyObject.__new__` requires it to + operate on identity as it stores the instance as key in a `WeakKeyDictionnary` + in order to later invalidate property cache. + """ + if type(self) is not type(obj): + return False + if self.name != obj.name: + return False + if len(self.layers) != len(obj.layers): + return False + for i in range(len(self.layers)): + l1 = self.layers[i] + l2 = obj.layers[i] + if l1.name != l2.name or l1.storeRef != l2.storeRef: + return False + return True + def isReadOnly(self): try: - return not bool(self._getWritableLayer()) + return not bool(self.getWritableLayer()) except LookupError: return True - def load(self, layerName, index=None, data=None, storeRef=None, rulesOnly=False, readOnly=None): + def load(self, layerName, index=None, data=None, storeRef=None, readOnly=None): for candidateIndex, layer in enumerate(self.layers): if layer.name == layerName: self.unload(layerName) @@ -198,12 +228,11 @@ def load(self, layerName, index=None, data=None, storeRef=None, rulesOnly=False, if data is not None: from .dataRecovery import recover recover(data) - layer = WebModuleDataLayer(layerName, data, storeRef, rulesOnly=rulesOnly) + layer = WebModuleDataLayer(layerName, data, storeRef) elif storeRef is not None: from . import store layer = store.getData(storeRef) layer.name = layerName - layer.rulesOnly = rulesOnly data = layer.data from .dataRecovery import recover recover(data) @@ -211,11 +240,11 @@ def load(self, layerName, index=None, data=None, storeRef=None, rulesOnly=False, data = OrderedDict({"WebModule": OrderedDict()}) data["WebModule"] = OrderedDict() data["WebModule"]["name"] = self.name - for attr in ("url", "windowTitle"): + for attr in ("url", "windowTitle"): # FIXME: Why not "help" as whell? value = getattr(self, attr) if value: data["WebModule"][attr] = value - layer = WebModuleDataLayer(layerName, data, storeRef, rulesOnly=rulesOnly) + layer = WebModuleDataLayer(layerName, data, storeRef) if index is not None: self.layers.insert(index, layer) else: @@ -230,6 +259,17 @@ def getLayer(self, layerName, raiseIfMissing=False): raise LookupError(repr(layerName)) return None + def getWritableLayer(self) -> WebModuleDataLayer: + """Retreive the lowest writable layer of this WebModule + + See also: `webModuleHandler.getEditableWebModule` + """ + for layer in reversed(self.layers): + if not layer.readOnly: + return layer + break + raise LookupError("No suitable data layer") + def unload(self, layerName): for index, layer in enumerate(self.layers): if layer.name == layerName: @@ -244,8 +284,6 @@ def terminate(self): def _getLayeredProperty(self, name, startLayerIndex=-1, raiseIfMissing=False): for index, layer in list(enumerate(self.layers))[startLayerIndex::-1]: - if layer.rulesOnly: - continue data = layer.data["WebModule"] if name not in data: continue @@ -257,15 +295,8 @@ def _getLayeredProperty(self, name, startLayerIndex=-1, raiseIfMissing=False): if raiseIfMissing: raise LookupError("name={!r}, startLayerIndex={!r}".format(name, startLayerIndex)) - def _getWritableLayer(self): - for layer in reversed(self.layers): - if not layer.readOnly and not layer.rulesOnly: - return layer - break - raise LookupError("No suitable data layer") - def _setLayeredProperty(self, name, value): - layer = self._getWritableLayer() + layer = self.getWritableLayer() data = layer.data["WebModule"] if data.get(name) != value: layer.dirty = True @@ -280,19 +311,17 @@ def _setLayeredProperty(self, name, value): layer.dirty = True data["overrides"][name] = overridden - def event_webApp_init(self, obj, nextHandler): - self.loadUserFile() - nextHandler() - - def event_webApp_pageChanged(self, pageTitle, nextHandler): + def event_webModule_pageChanged(self, pageTitle, nextHandler): speech.cancelSpeech() - playWebAppSound("pageChanged") + playWebAccessSound("pageChanged") speech.speakMessage(pageTitle) - def event_webApp_gainFocus(self, obj, nextHandler): + # Currently dead code, but will likely be revived for issue #17 + def event_webModule_gainFocus(self, obj, nextHandler): if obj.role not in [controlTypes.ROLE_DOCUMENT, controlTypes.ROLE_FRAME, controlTypes.ROLE_INTERNALFRAME]: nextHandler() + # Currently dead code, but will likely be revived for issue #17 def event_focusEntered(self, obj, nextHandler): if obj.role != controlTypes.ROLE_DOCUMENT: nextHandler() @@ -300,8 +329,8 @@ def event_focusEntered(self, obj, nextHandler): def event_gainFocus(self, obj, nextHandler): nextHandler() - def event_webApp_loseFocus(self, obj, nextHandler): - playWebAppSound("webAppLoseFocus") + def event_webModule_loseFocus(self, obj, nextHandler): + playWebAccessSound("webAppLoseFocus") nextHandler() def script_contextualHelp(self, gesture): diff --git a/addon/locale/fr/LC_MESSAGES/nvda.po b/addon/locale/fr/LC_MESSAGES/nvda.po index 2d93123d..2d1102b5 100644 --- a/addon/locale/fr/LC_MESSAGES/nvda.po +++ b/addon/locale/fr/LC_MESSAGES/nvda.po @@ -20,10 +20,10 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: nvda-translations@groups.io\n" -"POT-Creation-Date: 2024-10-10 09:27+0200\n" -"PO-Revision-Date: 2024-10-10 09:48+0200\n" -"Last-Translator: Julien Cochuyt \n" +"Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" +"POT-Creation-Date: 2024-12-10 15:36+0100\n" +"PO-Revision-Date: 2024-12-10 16:00+0100\n" +"Last-Translator: Julien Cochuyt \n" "Language-Team: \n" "Language: fr_FR\n" "MIME-Version: 1.0\n" @@ -31,129 +31,137 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.4.4\n" +"X-Generator: Poedit 3.4.1\n" "X-Poedit-Basepath: ../../..\n" "X-Poedit-KeywordsList: pgettext:1c,2\n" "X-Poedit-Bookmarks: -1,2,-1,-1,-1,-1,-1,-1,-1,-1\n" "X-Poedit-SearchPath-0: .\n" -#: addon\globalPlugins\webAccess\overlay.py:500 +#: addon\globalPlugins\webAccess\overlay.py:534 msgid "Zone border" msgstr "Bordure de zone" #. Translators: Hint on how to cancel zone restriction. -#: addon\globalPlugins\webAccess\overlay.py:504 -#: addon\globalPlugins\webAccess\overlay.py:828 -#: addon\globalPlugins\webAccess\overlay.py:849 -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:713 +#: addon\globalPlugins\webAccess\overlay.py:538 +#: addon\globalPlugins\webAccess\overlay.py:859 +#: addon\globalPlugins\webAccess\overlay.py:880 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:817 msgid "Press escape to cancel zone restriction." msgstr "Appuyez sur échappement pour ne plus être restreint à la zone." #. Translators: Complement to quickNav error message in zone. -#: addon\globalPlugins\webAccess\overlay.py:825 -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:710 +#: addon\globalPlugins\webAccess\overlay.py:856 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:814 msgid "in this zone." msgstr "dans cette zone." -#: addon\globalPlugins\webAccess\overlay.py:840 +#: addon\globalPlugins\webAccess\overlay.py:871 msgid "No more focusable element in this zone." msgstr "Pas d'élément focusable suivant dans cette zone." -#: addon\globalPlugins\webAccess\overlay.py:842 +#: addon\globalPlugins\webAccess\overlay.py:873 msgid "No previous focusable element in this zone." msgstr "Pas d'élément focusable précédant dans cette zone." #. Translators: Hint on how to cancel zone restriction. -#: addon\globalPlugins\webAccess\overlay.py:846 +#: addon\globalPlugins\webAccess\overlay.py:877 msgid "Press escape twice to cancel zone restriction." msgstr "" "Appuyez deux fois sur échappement pour ne plus être restreint à la zone." -#: addon\globalPlugins\webAccess\overlay.py:869 -#: addon\globalPlugins\webAccess\overlay.py:886 +#: addon\globalPlugins\webAccess\overlay.py:901 +#: addon\globalPlugins\webAccess\overlay.py:919 #, python-format msgid "text \"%s\" not found" msgstr "texte \"%s\" non trouvé" -#: addon\globalPlugins\webAccess\overlay.py:871 +#: addon\globalPlugins\webAccess\overlay.py:903 msgid "Cancel zone restriction and retry?" msgstr "Lever la restriction à la zone et réessayer ?" -#: addon\globalPlugins\webAccess\overlay.py:873 -#: addon\globalPlugins\webAccess\overlay.py:887 +#: addon\globalPlugins\webAccess\overlay.py:905 +#: addon\globalPlugins\webAccess\overlay.py:920 msgid "Find Error" msgstr "Chaîne non trouvée" -#: addon\globalPlugins\webAccess\overlay.py:958 +#. Translators: Reported when cancelling zone restriction +#: addon\globalPlugins\webAccess\overlay.py:986 msgid "Zone restriction cancelled" msgstr "Restriction au sein de la zone annulée" +#. Translators: Reported when cancelling zone restriction +#: addon\globalPlugins\webAccess\overlay.py:989 +msgid "Zone restriction enlarged to a wider zone" +msgstr "Restriction élargie à la zone environnante" + #. Translators: The description for the elementsListInZone script -#: addon\globalPlugins\webAccess\overlay.py:991 +#: addon\globalPlugins\webAccess\overlay.py:1022 msgid "Lists various types of elements in the current zone" msgstr "Dresser la liste de différents types d'éléments dans la zone courante" #. Translators: The description for the quickNavToNextResultLevel1 script -#: addon\globalPlugins\webAccess\overlay.py:1003 +#: addon\globalPlugins\webAccess\overlay.py:1034 msgid "Move to next zone." msgstr "Aller à la zone suivante." #. Translators: The description for the quickNavToPreviousResultLevel1 script -#: addon\globalPlugins\webAccess\overlay.py:1015 +#: addon\globalPlugins\webAccess\overlay.py:1046 msgid "Move to previous zone." msgstr "Aller à la zone précédente." #. Translators: The description for the quickNavToNextResultLevel2 script -#: addon\globalPlugins\webAccess\overlay.py:1027 +#: addon\globalPlugins\webAccess\overlay.py:1058 msgid "Move to next global marker." msgstr "Aller au marqueur global suivant." #. Translators: The description for the quickNavToPreviousResultLevel2 script -#: addon\globalPlugins\webAccess\overlay.py:1040 +#: addon\globalPlugins\webAccess\overlay.py:1071 msgid "Move to previous global marker." msgstr "Aller au marqueur global précédent." #. Translators: The description for the quickNavToNextResultLevel3 script -#: addon\globalPlugins\webAccess\overlay.py:1054 +#: addon\globalPlugins\webAccess\overlay.py:1085 msgid "Move to next local marker." msgstr "Aller au marqueur local suivant." #. Translators: The description for the quickNavToPreviousResultLevel3 script -#: addon\globalPlugins\webAccess\overlay.py:1067 +#: addon\globalPlugins\webAccess\overlay.py:1098 msgid "Move to previous local marker." msgstr "Aller au marqueur local précédent." #. Translators: The description for the refreshResults script #. Translators: Notified when manually refreshing results -#: addon\globalPlugins\webAccess\overlay.py:1079 -#: addon\globalPlugins\webAccess\overlay.py:1085 +#: addon\globalPlugins\webAccess\overlay.py:1110 +#: addon\globalPlugins\webAccess\overlay.py:1117 msgid "Refresh results" msgstr "Réactualisé les résultats" +#: addon\globalPlugins\webAccess\overlay.py:1127 +msgid "Updated" +msgstr "Mise à jour terminée" + +#: addon\globalPlugins\webAccess\overlay.py:1129 +msgid "Update failed" +msgstr "Échec de la mise à jour" + #. Translators: A generic error message -#: addon\globalPlugins\webAccess\utils.py:59 +#: addon\globalPlugins\webAccess\utils.py:63 msgid "An error occured. See NVDA log for more details." msgstr "Une erreur s'est produite. Voir le journal NVDA pour plus de détails." #. Translators: Input help mode message for show Web Access menu command. -#: addon\globalPlugins\webAccess\__init__.py:214 +#: addon\globalPlugins\webAccess\__init__.py:205 msgid "Show the Web Access menu." msgstr "Afficher le menu Web Access." #. Translators: Error message when attempting to show the Web Access GUI. -#: addon\globalPlugins\webAccess\__init__.py:225 -#: addon\globalPlugins\webAccess\__init__.py:394 -msgid "The current object does not support Web Access." -msgstr "Cet objet n'est pas pris en charge par Web Access." - -#. Translators: Error message when attempting to show the Web Access GUI. -#: addon\globalPlugins\webAccess\__init__.py:229 +#: addon\globalPlugins\webAccess\__init__.py:216 msgid "You must be in a web browser to use Web Access." msgstr "Placez-vous dans un navigateur web pour utiliser Web Access." #. Translators: Error message when attempting to show the Web Access GUI. -#: addon\globalPlugins\webAccess\__init__.py:233 -#: addon\globalPlugins\webAccess\__init__.py:398 +#: addon\globalPlugins\webAccess\__init__.py:220 +#: addon\globalPlugins\webAccess\__init__.py:279 msgid "You must be on the web page to use Web Access." msgstr "Placez-vous sur la page web pour utiliser Web Access." @@ -162,29 +170,29 @@ msgstr "Placez-vous sur la page web pour utiliser Web Access." msgid "Open the Web Access Settings." msgstr "Ouvrir les préférences Web Access." -#. Translators: Input help mode message for a command. -#: addon\globalPlugins\webAccess\__init__.py:267 -msgid "Toggle debug mode." -msgstr "Basculer le mode de débogage." - #. Translators: Input help mode message for show Web Access menu command. -#: addon\globalPlugins\webAccess\__init__.py:386 +#: addon\globalPlugins\webAccess\__init__.py:267 msgid "Show the element description." msgstr "Afficher la description de l'élément." -#: addon\globalPlugins\webAccess\__init__.py:405 +#. Translators: Error message when attempting to show the Web Access GUI. +#: addon\globalPlugins\webAccess\__init__.py:275 +msgid "The current object does not support Web Access." +msgstr "Cet objet n'est pas pris en charge par Web Access." + +#: addon\globalPlugins\webAccess\__init__.py:286 msgid "Toggle Web Access support." msgstr "Basculer le support de Web Access." -#: addon\globalPlugins\webAccess\__init__.py:414 +#: addon\globalPlugins\webAccess\__init__.py:295 msgid "Web Access support disabled." msgstr "Web Access désactivé." -#: addon\globalPlugins\webAccess\__init__.py:417 +#: addon\globalPlugins\webAccess\__init__.py:298 msgid "Web Access support enabled." msgstr "Web Access activé." -#: addon\globalPlugins\webAccess\__init__.py:549 +#: addon\globalPlugins\webAccess\__init__.py:427 msgid "" "This web module has been created by a newer version of Web Access for NVDA " "and could not be loaded:" @@ -192,7 +200,7 @@ msgstr "" "Ce module web ne peut pas être utilisé car il a été créé à l'aide d'une " "version plus récente de Web Access pour NVDA :" -#: addon\globalPlugins\webAccess\__init__.py:553 +#: addon\globalPlugins\webAccess\__init__.py:431 msgid "" "These web modules have been created by a newer version of Web Access for " "NVDA and could not be loaded:" @@ -200,7 +208,7 @@ msgstr "" "Ces modules web ne peuvent pas être utilisé car ils ont été créé à l'aide " "d'une version plus récente de Web Access pour NVDA :" -#: addon\globalPlugins\webAccess\__init__.py:560 +#: addon\globalPlugins\webAccess\__init__.py:438 msgid "" "This Python web module uses a different API version of Web Access for NVDA " "and could not be loaded:" @@ -208,7 +216,7 @@ msgstr "" "Ce module web Python ne peut pas être utilisé car il cible une version " "différente de l'API de Web Access pour NVDA :" -#: addon\globalPlugins\webAccess\__init__.py:564 +#: addon\globalPlugins\webAccess\__init__.py:442 msgid "" "These Python web modules use a different API version of Web Access for NVDA " "and could not be loaded:" @@ -216,1162 +224,1237 @@ msgstr "" "Ces modules web Python ne peuvent pas être utilisé car ils ciblent une " "version différente de l'API de Web Access pour NVDA :" -#: addon\globalPlugins\webAccess\__init__.py:571 +#: addon\globalPlugins\webAccess\__init__.py:449 msgid "An unexpected error occurred while attempting to load this web module:" msgstr "Une erreur s'est produite au chargement de ce module web :" -#: addon\globalPlugins\webAccess\__init__.py:575 +#: addon\globalPlugins\webAccess\__init__.py:453 msgid "" "An unexpected error occurred while attempting to load these web modules:" msgstr "Une erreur s'est produite au chargement de ces modules web :" #. Translators: The title of an error message dialog -#: addon\globalPlugins\webAccess\__init__.py:610 -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:248 +#: addon\globalPlugins\webAccess\__init__.py:488 msgid "Web Access for NVDA" msgstr "Web Access pour NVDA" -#. Translators: The label for a category in the Rule and Criteria editors -#: addon\globalPlugins\webAccess\gui\actions.py:59 -msgid "Actions" -msgstr "Actions" +#: addon\globalPlugins\webAccess\gui\elementDescription.py:54 +msgctxt "webAccess.elementDescription" +msgid "elements" +msgstr "éléments" -#. Translators: Displayed when the selected rule type doesn't support any action -#: addon\globalPlugins\webAccess\gui\actions.py:62 -msgid "No action available for the selected rule type." -msgstr "Aucune action disponible pour le type de règle sélectionné." +#: addon\globalPlugins\webAccess\gui\elementDescription.py:56 +msgctxt "webAccess.elementDescription" +msgid "element" +msgstr "élément" -#. Translators: The label for a list on the Rule Editor dialog -#: addon\globalPlugins\webAccess\gui\actions.py:85 -msgid "&Gestures" -msgstr "&Gestes" +#: addon\globalPlugins\webAccess\gui\elementDescription.py:69 +msgctxt "webAccess.elementDescription" +msgid "from:" +msgstr "de :" -#. Translators: The label for a button in the Rule Editor dialog -#. Translators: The label for a button on the Rule Editor dialog -#. Translators: New criteria button label -#. Translators: The label for a button on the Rule Editor dialog -#: addon\globalPlugins\webAccess\gui\actions.py:104 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:417 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:661 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:754 -msgid "&New..." -msgstr "&Nouvelle..." +#: addon\globalPlugins\webAccess\gui\elementDescription.py:73 +msgctxt "webAccess.elementDescription" +msgid "to:" +msgstr "à :" -#. Translators: The label for a button in the Rule Editor dialog -#. Translators: The label for a button in the Web Modules Manager dialog -#. Translators: The label for a button on the Rule Editor dialog -#. Translators: Edit criteria button label -#. Translator: The label for a button on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\actions.py:115 -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:147 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:426 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:645 -#: addon\globalPlugins\webAccess\gui\rule\manager.py:399 -msgid "&Edit..." -msgstr "&Modifier..." +#: addon\globalPlugins\webAccess\gui\elementDescription.py:113 +msgid "No NodeManager" +msgstr "Pas de NodeManager" -#. Translators: The label for a button in the Rule Editor dialog -#. Translators: The label for a button on the Criteria Editor dialog -#. Translators: The label for a button in the -#. Web Modules Manager dialog -#. Translators: The label for a button on the Rule Editor dialog -#. Translators: Delete criteria button label -#. Translators: The label for a button on the Rule Editor dialog +#: addon\globalPlugins\webAccess\gui\elementDescription.py:167 +msgid "Element description" +msgstr "Description de l'élément" + +#. Translators: Web Access menu item label. #. Translator: The label for a button on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\actions.py:126 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:910 -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:160 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:435 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:654 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:747 -#: addon\globalPlugins\webAccess\gui\rule\manager.py:407 -msgid "&Delete" -msgstr "&Supprimer" +#: addon\globalPlugins\webAccess\gui\menu.py:64 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:456 +msgid "&New rule..." +msgstr "&Nouvelle règle..." -#. Translators: Automatic action at rule detection input label for the rule dialog's action panel. -#: addon\globalPlugins\webAccess\gui\actions.py:138 -msgid "A&utomatic action at rule detection:" -msgstr "Action &automatique à la détection d'une règle :" +#. Translators: Web Access menu item label. +#. Translators: The label for a button in the Web Modules Manager dialog +#: addon\globalPlugins\webAccess\gui\menu.py:70 +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:113 +msgid "Manage &rules..." +msgstr "Gérer les &règles..." -#. Translators: An entry in the Automatic Action selection list. -#. Translators: Displayed if no value is set for the "Auto Actions" property -#: addon\globalPlugins\webAccess\gui\actions.py:171 -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:77 -msgctxt "webAccess.action" -msgid "No action" -msgstr "Ne rien faire" +#. Translators: Web Access menu item label. +#. Translators: The label for a button in the Web Modules Manager +#: addon\globalPlugins\webAccess\gui\menu.py:79 +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:93 +msgid "&New web module..." +msgstr "&Nouveau module web..." + +#. Translators: Web Access menu item label. +#: addon\globalPlugins\webAccess\gui\menu.py:93 +msgid "Edit &web module" +msgstr "Modifier le module &web" + +#. Translators: Web Access menu item label. +#: addon\globalPlugins\webAccess\gui\menu.py:99 +#, python-format +msgid "Edit &web module %s..." +msgstr "Modifier le module &web %s..." + +#. Translators: Web Access menu item label. +#: addon\globalPlugins\webAccess\gui\menu.py:106 +msgid "Manage web &modules..." +msgstr "Gérer les &modules web..." + +#. Translators: Web Access menu item label. +#: addon\globalPlugins\webAccess\gui\menu.py:116 +msgid "&Element description..." +msgstr "D&escription de l'élément..." + +#. Translators: Web Access menu item label. +#: addon\globalPlugins\webAccess\gui\menu.py:125 +msgid "Temporarily &disable all web modules" +msgstr "&Désactiver temporairement tous les modules web" + +#. Translators: The title of a dialog +#: addon\globalPlugins\webAccess\gui\settings.py:53 +msgid "WebAccess Preferences" +msgstr "Paramètres de Web Access" + +#. Translators: The label for a category in the settings dialog +#: addon\globalPlugins\webAccess\gui\settings.py:91 +msgid "WebAccess" +msgstr "WebAccess" + +#. Translators: The label for a settings in the WebAccess settings panel +#: addon\globalPlugins\webAccess\gui\settings.py:97 +msgid "&Developer mode" +msgstr "Mode développeur" + +#. Translators: The label for a settings in the WebAccess settings panel +#: addon\globalPlugins\webAccess\gui\settings.py:102 +msgid "Disable all &user WebModules (activate only scratchpad and addons)" +msgstr "" +"Désactiver tous les WebModules utilisateur (ne charger que répertoire Bloc-" +"notes du Développeur et extensions)" + +#. Translators: The label for a settings in the WebAccess settings panel +#: addon\globalPlugins\webAccess\gui\settings.py:107 +msgid "Write into add-ons' \"webModules\" folder (not recommended)" +msgstr "" +"Écrire directement dans le dossier \"webModules\" des extensions (non " +"recommandé)" + +#. Translators: Announced when a list is empty +#: addon\globalPlugins\webAccess\gui\__init__.py:174 +msgid "Empty" +msgstr "Vide" + +#. Translator: The placeholder for an invalid value in summary reports +#: addon\globalPlugins\webAccess\gui\__init__.py:217 +msgid "" +msgstr "" + +#. Translators: The label for the list of categories in a multi category settings dialog. +#: addon\globalPlugins\webAccess\gui\__init__.py:540 +msgid "&Categories:" +msgstr "&Catégories :" + +#. Translators: The display value of a yes/no field +#. Translators: The displayed value of a yes/no rule property +#: addon\globalPlugins\webAccess\gui\__init__.py:948 +#: addon\globalPlugins\webAccess\gui\rule\properties.py:186 +msgid "Yes" +msgstr "Oui" + +#. Translators: The displayed value of a yes/no field +#. Translators: The displayed value of a yes/no rule property +#: addon\globalPlugins\webAccess\gui\__init__.py:951 +#: addon\globalPlugins\webAccess\gui\rule\properties.py:189 +msgid "No" +msgstr "Non" + +#. Translators: A field label. French typically adds a space before the colon. +#: addon\globalPlugins\webAccess\gui\__init__.py:1076 +#, python-brace-format +msgid "{field}:" +msgstr "{field} :" + +#. Translators: The label for a node in the category tree on a multi-category dialog +#. Translators: A mention on the Rule Summary report +#: addon\globalPlugins\webAccess\gui\__init__.py:1113 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:121 +#, python-brace-format +msgid "{field}: {value}" +msgstr "{field} : {value}" #. Translators: A mention on the Criteria summary report #. Translator: A selection value for the Context field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:165 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:414 -msgid "Global - Applies to the whole web module" -msgstr "Global - S'applique à l'ensemble du module web" +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:178 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:436 +msgid "General - Applies to the whole web module" +msgstr "Général - S'applique à l'ensemble du module web" #. Translators: A mention on the Criteria Summary report -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:202 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:215 #, python-brace-format msgid "{indent}{field}: {value}" msgstr "{indent}{field} : {value}" #. Translators: The label for a section on the Criteria Summary report #. Translators: The label for a section on the Rule Summary report -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:210 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:118 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:223 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:125 #, python-brace-format msgid "{section}:" msgstr "{section} :" #. Translators: A mention on the Criteria Summary report -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:219 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:232 msgid "No criteria" msgstr "Pas de critère" -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:242 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:259 msgid "Found 1 result in {:.3f} seconds." msgstr "Trouvé 1 résultat en {:.3f} secondes." -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:244 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:261 msgid "Found {} results in {:.3f} seconds." msgstr "Trouvé {} résultats en {:.3f} secondes." -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:246 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:263 msgid "No result found on the current page." msgstr "Aucun résultat trouvé sur la page actuelle." -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:247 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:264 msgid "Criteria test" msgstr "Critères d'évaluation" #. Translators: The label for a Criteria editor category. #. Translators: The label for the General settings panel. -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:258 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:165 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:277 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:172 msgid "General" msgstr "Général" #. Translator: The label for a field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:272 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:291 msgid "Criteria Set &name:" msgstr "Nom du jeu de critères :" #. Translator: The label for a field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:284 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:303 msgid "&Sequence order:" msgstr "Ordre de &séquence :" #. Translator: The label for a field on the Criteria editor #. Translators: The label for a field on the Rule editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:298 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:203 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:392 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:317 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:210 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:386 msgid "Summar&y:" msgstr "&Résumé :" #. Translator: The label for a field on the Criteria editor #. Translator: The label for a field on the Rule editor #. Translators: The label for a field on the Rule editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:310 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:215 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:404 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:329 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:222 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:398 msgid "Technical n&otes:" msgstr "N&otes techniques :" #. Translators: The label for a Criteria editor category. #. Translators: The label for a category in the rule editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:367 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:360 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:387 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:354 msgid "Criteria" msgstr "Critères" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:373 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:393 msgctxt "webAccess.ruleCriteria" msgid "Page &title:" msgstr "&Titre de page :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:375 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:395 msgctxt "webAccess.ruleCriteria" msgid "Page t&ype" msgstr "T&ype de page" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:377 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:397 msgctxt "webAccess.ruleCriteria" msgid "&Parent element" msgstr "Élément &parent" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:379 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:399 msgctxt "webAccess.ruleCriteria" msgid "&Text:" msgstr "&Texte :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:381 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:401 msgctxt "webAccess.ruleCriteria" msgid "&Role:" msgstr "&Rôle :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:383 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:403 msgctxt "webAccess.ruleCriteria" msgid "T&ag:" msgstr "B&alise :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:385 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:405 msgctxt "webAccess.ruleCriteria" msgid "&ID:" msgstr "&ID :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:387 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:407 msgctxt "webAccess.ruleCriteria" msgid "&Class:" msgstr "&Classe :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:389 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:409 msgctxt "webAccess.ruleCriteria" msgid "&States:" msgstr "État&s :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:391 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:411 msgctxt "webAccess.ruleCriteria" msgid "Ima&ge source:" msgstr "Source de l'ima&ge :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:393 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:413 +msgctxt "webAccess.ruleCriteria" +msgid "Document &URL:" +msgstr "&URL du document :" + +#. Translator: The label for a Rule Criteria field +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:415 msgctxt "webAccess.ruleCriteria" msgid "R&elative path:" msgstr "Ch&emin relatif :" #. Translator: The label for a Rule Criteria field -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:395 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:417 msgctxt "webAccess.ruleCriteria" msgid "Inde&x:" msgstr "Inde&x :" -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:409 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:431 msgid "Context:" msgstr "Contexte :" #. Translator: A selection value for the Context field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:416 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:438 msgid "Page title - Applies only to pages with the given title" msgstr "Titre de page - S'applique uniquement aux pages portant le titre donné" #. Translator: A selection value for the Context field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:418 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:440 msgid "Page type - Applies only to pages with the given type" msgstr "Type de page - S'applique uniquement aux pages du type donné" #. Translator: A selection value for the Context field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:420 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:442 msgid "Parent element - Applies only within the results of another rule" msgstr "" "Élément parent - S'applique uniquement dans le cadre des résultats d'une " "autre règle" #. Translator: A selection value for the Context field on the Criteria editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:422 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:444 msgid "Advanced" msgstr "Avancé" #. Translators: The label for a button in the Criteria Editor dialog -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:579 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:611 msgid "Test these criteria (F5)" msgstr "Tester ces critères (F5)" #. Translators: Error message when the field doesn't meet the required syntax -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:757 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:786 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:794 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:823 #, python-brace-format msgid "Syntax error in the field \"{field}\"" msgstr "Erreur de syntaxe dans le champ \"{field}\"" #. Translators: The title of an error message dialog -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:759 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:773 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:788 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:802 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:819 -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:267 -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:280 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:307 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:319 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:350 -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:390 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:796 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:810 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:825 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:839 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:856 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:303 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:315 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:344 +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:275 +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:289 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:357 msgid "Error" msgstr "Erreur" #. Translators: Error message when the field doesn't match any known identifier -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:771 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:800 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:808 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:837 #, python-brace-format msgid "Unknown identifier in the field \"{field}\"" msgstr "Identifiant inconnu dans le champ \"{field}\"" #. Translators: Error message when the index is not positive -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:818 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:855 msgid "Index, if set, must be a positive integer." msgstr "Index, si renseigné, doit être un nombre entier strictement positif." -#. Translators: Announced when resetting a property to its default value in the editor -#. Avoid announcing the whole eventual control refresh -#. Translators: Announced when resetting a property to its default value in the editor -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:866 -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:986 -#: addon\globalPlugins\webAccess\gui\properties.py:277 -#, python-brace-format -msgid "Reset to {value}" -msgstr "Réinitialiser à {value}" - -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:874 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:875 msgid "Select a property to override" msgstr "Sélectionnez une propriété à surcharger" #. Translators: The label for a list on the Criteria Editor dialog -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:887 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:888 msgid "Properties specific to this criteria set" msgstr "Propriétés spécifiques à cet ensemble de critères" #. Translators: A hint stating a list is empty and how to populate it on the Criteria Editor dialog -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:890 +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:891 msgid "None. Press alt+n to override a property." msgstr "Aucune. Appuyez sur alt+n pour remplacer une propriété." -#. Translators: A column header in the Criteria Editor dialog -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:892 -msgid "Rule value" -msgstr "Valeur de la règle" - -#. Translators: The label for a button on the Criteria Editor dialog -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:900 -msgid "&New" -msgstr "&Nouveau" - -#. Translators: The title of the Criteria Editor dialog. -#: addon\globalPlugins\webAccess\gui\criteriaEditor.py:991 -msgid "WebAccess Criteria Set editor" -msgstr "Éditeur de jeux de critères WebAccess" - -#: addon\globalPlugins\webAccess\gui\elementDescription.py:49 -msgctxt "webAccess.elementDescription" -msgid "elements" -msgstr "éléments" - -#: addon\globalPlugins\webAccess\gui\elementDescription.py:51 -msgctxt "webAccess.elementDescription" -msgid "element" -msgstr "élément" - -#: addon\globalPlugins\webAccess\gui\elementDescription.py:64 -msgctxt "webAccess.elementDescription" -msgid "from:" -msgstr "de :" - -#: addon\globalPlugins\webAccess\gui\elementDescription.py:68 -msgctxt "webAccess.elementDescription" -msgid "to:" -msgstr "à :" - -#: addon\globalPlugins\webAccess\gui\elementDescription.py:106 -msgid "No NodeManager" -msgstr "Pas de NodeManager" - -#: addon\globalPlugins\webAccess\gui\elementDescription.py:160 -msgid "Element description" -msgstr "Description de l'élément" - -#. Translators: The title for a dialog -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:54 -msgid "Input Gesture" -msgstr "Gestes de saisie" - -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:69 -msgid "Gesture: " -msgstr "Geste : " - -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:78 -msgid "&Action to execute" -msgstr "&Action à exécuter" - -#. Translators: A prompt for selection in the action list on the Input Gesture dialog -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:114 -msgid "Select an action" -msgstr "Sélectionner une action" - -#. Translators: The prompt to enter a gesture on the Input Gesture dialog -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:129 -msgid "Type now the desired key combination" -msgstr "Pressez maintenant la combinaison de touches souhaitée" - -#. Translators: A message on the Input Gesture dialog -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:145 -msgid "You must define a shortcut" -msgstr "Vous devez entrer un geste de commande" - -#. Translators: A message on the Input Gesture dialog -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:155 -msgid "You must choose an action" -msgstr "Vous devez choisir une action" - -#. Translators: Displayed when no gesture as been entered on the Input Gesture dialog -#: addon\globalPlugins\webAccess\gui\gestureBinding.py:188 -msgid "None" -msgstr "Aucune" - -#. Translators: Web Access menu item label. -#. Translator: The label for a button on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\menu.py:61 -#: addon\globalPlugins\webAccess\gui\rule\manager.py:392 -msgid "&New rule..." -msgstr "&Nouvelle règle..." - -#. Translators: Web Access menu item label. -#. Translators: The label for a button in the Web Modules Manager dialog -#: addon\globalPlugins\webAccess\gui\menu.py:69 -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:152 -msgid "Manage &rules..." -msgstr "Gérer les &règles..." - -#. Translators: Web Access menu item label. -#. Translators: The label for a button in the Web Modules Manager -#: addon\globalPlugins\webAccess\gui\menu.py:78 -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:142 -msgid "&New web module..." -msgstr "&Nouveau module web..." - -#. Translators: Web Access menu item label. -#: addon\globalPlugins\webAccess\gui\menu.py:84 -#, python-format -msgid "Edit &web module %s..." -msgstr "Modifier le module &web %s..." - -#. Translators: Web Access menu item label. -#: addon\globalPlugins\webAccess\gui\menu.py:91 -msgid "Manage web &modules..." -msgstr "Gérer les &modules web..." - -#. Translators: Web Access menu item label. -#: addon\globalPlugins\webAccess\gui\menu.py:100 -msgid "Temporarily &disable all web modules" -msgstr "&Désactiver temporairement tous les modules web" - -#. Translators: The displayed value of a yes/no rule property -#. Translators: The display value of a yes/no field -#: addon\globalPlugins\webAccess\gui\properties.py:149 -#: addon\globalPlugins\webAccess\gui\__init__.py:919 -msgid "Yes" -msgstr "Oui" - -#. Translators: The displayed value of a yes/no rule property -#. Translators: The displayed value of a yes/no field -#: addon\globalPlugins\webAccess\gui\properties.py:152 -#: addon\globalPlugins\webAccess\gui\__init__.py:922 -msgid "No" -msgstr "Non" - -#. Translators: The label for a category in the rule editor -#: addon\globalPlugins\webAccess\gui\properties.py:218 -msgid "Properties" -msgstr "Propriétés" - -#. Translators: Displayed when the selected rule type doesn't support any property -#: addon\globalPlugins\webAccess\gui\properties.py:292 -msgid "No property available for this rule type." -msgstr "Aucune propriété n'est disponible pour ce type de règle." - -#. Translators: The label for a list on the Rule Editor dialog -#: addon\globalPlugins\webAccess\gui\properties.py:357 -msgid "&Properties" -msgstr "&Propriétés" - -#. Translators: A column header on the Rule Editor dialog -#: addon\globalPlugins\webAccess\gui\properties.py:369 -msgctxt "webAccess.ruleEditor" -msgid "Property" -msgstr "Propriété" - -#. Translators: A column header on the Rule Editor dialog -#: addon\globalPlugins\webAccess\gui\properties.py:371 -msgctxt "webAccess.ruleEditor" -msgid "Value" -msgstr "Valeur" - -#. Translators: The title of a dialog -#: addon\globalPlugins\webAccess\gui\settings.py:53 -msgid "WebAccess Preferences" -msgstr "Paramètres de Web Access" - -#. Translators: The label for a category in the settings dialog -#: addon\globalPlugins\webAccess\gui\settings.py:91 -msgid "WebAccess" -msgstr "WebAccess" - -#. Translators: The label for a settings in the WebAccess settings panel -#: addon\globalPlugins\webAccess\gui\settings.py:97 -msgid "&Developer mode" -msgstr "Mode développeur" - -#. Translators: The label for a settings in the WebAccess settings panel -#: addon\globalPlugins\webAccess\gui\settings.py:102 -msgid "Disable all &user WebModules (activate only scratchpad and addons)" -msgstr "" -"Désactiver tous les WebModules utilisateur (ne charger que répertoire Bloc-" -"notes du Développeur et extensions)" - -#. Translators: The label for a settings in the WebAccess settings panel -#: addon\globalPlugins\webAccess\gui\settings.py:107 -msgid "Write into add-ons' \"webModules\" folder (not recommended)" -msgstr "" -"Écrire directement dans le dossier \"webModules\" des extensions (non " -"recommandé)" - -#. Translators: Error message while naming a web module. -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:56 -msgid "A web module already exists with this name." -msgstr "Un autre module web porte déjà ce nom." - -#. Translators: Prompt before overwriting a web module. -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:59 -msgid "Do you want to overwrite it?" -msgstr "Voulez-vous le remplacer ?" - -#. Translators: The label for a field in the WebModule editor -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:103 -msgid "Web module name:" -msgstr "Nom du module web :" - -#. Translators: The label for a field in the WebModule editor -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:114 -msgid "URL:" -msgstr "URL :" - -#. Translators: The label for a field in the WebModule editor -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:125 -msgid "Window title:" -msgstr "Titre de la fenêtre :" - -#. Translators: The label for a field in the WebModule editor -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:136 -msgid "Help (in Markdown):" -msgstr "Aide du module (format Markdown)" - -#. Translators: Web module creation dialog title -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:169 -msgid "New Web Module" -msgstr "Nouveau Module web" - -#. Translators: Web module edition dialog title -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:185 -msgid "Edit Web Module" -msgstr "Modifier le module web" - -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:205 -msgid "URL not found" -msgstr "URL introuvable" - -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:266 -msgid "You must enter a name for this web module" -msgstr "Vous devez entrer un nom pour ce module web" - -#: addon\globalPlugins\webAccess\gui\webModuleEditor.py:279 -msgid "You must specify at least a URL or a window title." -msgstr "Vous devez spécifier au moins une URL ou un titre de fenêtre." - -#. Translators: Prompt before deleting a web module. -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:45 -msgid "Do you really want to delete this web module?" -msgstr "Êtes-vous sûr de vouloir supprimer ce module web ?" - -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:71 -#, python-brace-format -msgid "" -"This web module comes with the add-on {addonSummary}.\n" -"It cannot be modified at its current location.\n" -"\n" -"Do you want to make a copy in your scratchpad?\n" -msgstr "" -"Ce module web est fourni par le module complémentaire {addonSummary}.\n" -"Il ne peut être modifié à son emplacement actuel.\n" -"\n" -"Voulez-vous en faire une copie dans votre répertoire Bloc-notes du " -"Développeur (scratchpad) ?\n" - -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:80 -msgid "Warning" -msgstr "Avertissement" - -#. Translators: The title of the Web Modules Manager dialog -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:107 -msgid "Web Modules Manager" -msgstr "Gestion des modules web" - -#. Translators: The label for the modules list in the -#. Web Modules dialog. -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:116 -msgid "Available Web Modules:" -msgstr "Modules web disponibles :" - -#. Translators: The label for a column of the web modules list -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:125 -msgid "Name" -msgstr "Nom" - -#. Translators: The label for a column of the web modules list -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:127 -msgid "Trigger" -msgstr "Déclencheur" - -#: addon\globalPlugins\webAccess\gui\webModulesManager.py:255 -msgid "and" -msgstr "et" - -#. Translators: Announced when a list is empty -#: addon\globalPlugins\webAccess\gui\__init__.py:165 -msgid "Empty" -msgstr "Vide" +#. Translators: A column header in the Criteria Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:893 +msgid "Rule value" +msgstr "Valeur de la règle" -#. Translator: The placeholder for an invalid value in summary reports -#: addon\globalPlugins\webAccess\gui\__init__.py:208 -msgid "" -msgstr "" +#. Translators: The label for a button on the Criteria Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:901 +msgid "&New" +msgstr "&Nouveau" -#. Translators: The label for the list of categories in a multi category settings dialog. -#: addon\globalPlugins\webAccess\gui\__init__.py:523 -msgid "&Categories:" -msgstr "&Catégories :" +#. Translators: The label for a button on the Criteria Editor dialog +#. Translators: The label for a button on the Rule Editor dialog +#. Translators: Delete criteria button label +#. Translators: The label for a button on the Rule Editor dialog +#. Translators: The label for a button in the Rule Editor dialog +#. Translator: The label for a button on the RulesManager dialog. +#. Translators: The label for a button in the +#. Web Modules Manager dialog +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:911 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:429 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:637 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:730 +#: addon\globalPlugins\webAccess\gui\rule\gestures.py:138 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:471 +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:126 +msgid "&Delete" +msgstr "&Supprimer" -#. Translators: A field label. French typically adds a space before the colon. -#: addon\globalPlugins\webAccess\gui\__init__.py:1029 +#. Avoid announcing the whole eventual control refresh +#. Translators: Announced when resetting a property to its default value in the editor +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:987 +#: addon\globalPlugins\webAccess\gui\rule\properties.py:313 #, python-brace-format -msgid "{field}:" -msgstr "{field} :" +msgid "Reset to {value}" +msgstr "Réinitialiser à {value}" -#. Translators: The label for a node in the category tree on a multi-category dialog -#. Translators: A mention on the Rule Summary report -#: addon\globalPlugins\webAccess\gui\__init__.py:1064 -#: addon\globalPlugins\webAccess\gui\rule\editor.py:114 -#, python-brace-format -msgid "{field}: {value}" -msgstr "{field} : {value}" +#. Translators: The title of the Criteria Editor dialog. +#: addon\globalPlugins\webAccess\gui\rule\criteriaEditor.py:992 +msgid "WebAccess Criteria Set editor" +msgstr "Éditeur de jeux de critères WebAccess" #. Translators: The Label for a field on the Rule editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:91 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:98 msgid "Rule &type:" msgstr "&Type de règle :" #. Translators: The Label for a field on the Rule editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:93 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:100 msgid "Rule &name:" msgstr "&Nom de la règle :" #. Translators: A mention on the Rule summary report -#: addon\globalPlugins\webAccess\gui\rule\editor.py:101 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:108 msgid "No rule type selected." msgstr "Aucun type de règle n'est sélectionné." #. Translators: The label for a section on the Rule Summary report -#: addon\globalPlugins\webAccess\gui\rule\editor.py:127 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:134 msgid "Criteria:" msgstr "Critères :" #. Translators: The label for a section on the Rule Summary report -#: addon\globalPlugins\webAccess\gui\rule\editor.py:131 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:138 msgid "Multiple criteria sets:" msgstr "Jeux de critères multiples :" #. Translators: The label for a section on the Rule Summary report -#: addon\globalPlugins\webAccess\gui\rule\editor.py:136 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:143 #, python-brace-format msgid "Alternative #{index} \"{name}\":" msgstr "Alternative #{index} \"{name}\" :" #. Translators: The label for a section on the Rule Summary report -#: addon\globalPlugins\webAccess\gui\rule\editor.py:139 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:146 #, python-brace-format msgid "Alternative #{index}:" msgstr "Alternative #{index} :" #. todo: change tooltip's text #. Translators: Tooltip for rule type choice list. -#: addon\globalPlugins\webAccess\gui\rule\editor.py:184 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:191 msgid "TOOLTIP EXEMPLE" msgstr "EXEMPLE DE BULLE D'AIDE" #. Translators: Error message when no type is chosen before saving the rule -#: addon\globalPlugins\webAccess\gui\rule\editor.py:306 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:302 msgid "You must choose a type for this rule" msgstr "Vous devez choisir un type pour cette règle" #. Translators: Error message when no name is entered before saving the rule -#: addon\globalPlugins\webAccess\gui\rule\editor.py:318 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:314 msgid "You must choose a name for this rule" msgstr "Vous devez choisir un nom pour cette règle" #. Translators: Error message when another rule with the same name already exists -#: addon\globalPlugins\webAccess\gui\rule\editor.py:349 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:343 msgid "There already is another rule with the same name." msgstr "Il existe déjà une autre règle portant le même nom." #. Translators: Label for a control in the Rule Editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:374 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:368 msgid "&Alternatives" msgstr "&Alternatives" +#. Translators: The label for a button on the Rule Editor dialog +#. Translators: New criteria button label +#. Translators: The label for a button on the Rule Editor dialog +#. Translators: The label for a button in the Rule Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\editor.py:411 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:644 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:737 +#: addon\globalPlugins\webAccess\gui\rule\gestures.py:116 +msgid "&New..." +msgstr "&Nouveau..." + +#. Translators: The label for a button on the Rule Editor dialog +#. Translators: Edit criteria button label +#. Translators: The label for a button in the Rule Editor dialog +#. Translator: The label for a button on the RulesManager dialog. +#. Translators: The label for a button in the Web Modules Manager dialog +#: addon\globalPlugins\webAccess\gui\rule\editor.py:420 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:628 +#: addon\globalPlugins\webAccess\gui\rule\gestures.py:127 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:463 +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:103 +msgid "&Edit..." +msgstr "&Modifier..." + #. Translator: A confirmation prompt on the Rule editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:507 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:503 msgid "Are you sure you want to delete this alternative?" msgstr "Êtes-vous sûr de vouloir supprimer cette alternative ?" #. Translator: The title for a confirmation prompt on the Rule editor #. Translator: The title for a confirmation prompt on the #. RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\editor.py:509 -#: addon\globalPlugins\webAccess\gui\rule\manager.py:578 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:505 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:720 msgid "Confirm Deletion" msgstr "Confirmation d'effacement" #. Translators: The label for a field on the Rule editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:610 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:593 msgid "Summar&y" msgstr "&Résumé" #. Translators: The label for a field on the Rule editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:627 +#: addon\globalPlugins\webAccess\gui\rule\editor.py:610 msgid "Technical n&otes" msgstr "&Notes techniques" -#. Translators: The title of the rule editor -#: addon\globalPlugins\webAccess\gui\rule\editor.py:848 -msgid "WebAccess Rule editor" -msgstr "Éditeur de règles WebAccess" +#. Translators: A title of the rule editor +#: addon\globalPlugins\webAccess\gui\rule\editor.py:921 +msgid "Sub Module {} - New Rule" +msgstr "Sous-module {} - Nouvelle règle" + +#. Translators: A title of the rule editor +#: addon\globalPlugins\webAccess\gui\rule\editor.py:924 +msgid "Root Module {} - New Rule" +msgstr "Module racine {} - Nouvelle règle" + +#. Translators: A title of the rule editor +#: addon\globalPlugins\webAccess\gui\rule\editor.py:927 +msgid "Web Module {} - New Rule" +msgstr "Module web {} - Nouvelle règle" + +#. Translators: A title of the rule editor +#: addon\globalPlugins\webAccess\gui\rule\editor.py:932 +msgid "Sub Module {} - Edit Rule {}" +msgstr "Sous-module {} - Modifier la règle {}" + +#. Translators: A title of the rule editor +#: addon\globalPlugins\webAccess\gui\rule\editor.py:935 +msgid "Root Module {} - Edit Rule {}" +msgstr "Module racine - Modifier la règle {}" + +#. Translators: A title of the rule editor +#: addon\globalPlugins\webAccess\gui\rule\editor.py:938 +msgid "Web Module {} - Edit Rule {}" +msgstr "Module web {} - Modifier la règle {}" + +#. Translators: The title for a dialog +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:67 +msgid "Input Gesture" +msgstr "Gestes de saisie" + +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:82 +msgid "Gesture: " +msgstr "Geste : " + +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:91 +msgid "&Action to execute" +msgstr "&Action à exécuter" + +#. Translators: A prompt for selection in the action list on the Input Gesture dialog +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:127 +msgid "Select an action" +msgstr "Sélectionner une action" + +#. Translators: The prompt to enter a gesture on the Input Gesture dialog +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:142 +msgid "Type now the desired key combination" +msgstr "Pressez maintenant la combinaison de touches souhaitée" + +#. Translators: A message on the Input Gesture dialog +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:158 +msgid "You must define a shortcut" +msgstr "Vous devez entrer un geste de commande" + +#. Translators: A message on the Input Gesture dialog +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:168 +msgid "You must choose an action" +msgstr "Vous devez choisir une action" + +#. Translators: Displayed when no gesture as been entered on the Input Gesture dialog +#: addon\globalPlugins\webAccess\gui\rule\gestureBinding.py:201 +msgid "None" +msgstr "Aucune" + +#. Translators: The label for a category in the Rule and Criteria editors +#: addon\globalPlugins\webAccess\gui\rule\gestures.py:69 +msgid "Input Gestures" +msgstr "Gestes de commande" + +#. Translators: Displayed when the selected rule type doesn't support input gestures +#: addon\globalPlugins\webAccess\gui\rule\gestures.py:72 +msgid "The selected Rule Type does not support Input Gestures." +msgstr "" +"Le type de règle sélectionné ne prend pas en charge les gestes de commande." + +#. Translators: The label for a list on the Rule Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\gestures.py:95 +msgid "&Gestures" +msgstr "&Gestes" #. Translator: TreeItem label on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:149 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:170 msgctxt "webAccess.ruleGesture" msgid "" msgstr "" +#. Translators: A part of a context grouping label on the Rules Manager +#: addon\globalPlugins\webAccess\gui\rule\manager.py:205 +#, python-brace-format +msgid "Page Title: {contextPageTitle}" +msgstr "Titre de la page : {contextPageTitle}" + +#. Translators: A part of a context grouping label on the Rules Manager +#: addon\globalPlugins\webAccess\gui\rule\manager.py:208 +#, python-brace-format +msgid "Page Type: {contextPageTitle}" +msgstr "Type de page : {contextPageTitle}" + +#. Translators: A part of a context grouping label on the Rules Manager +#: addon\globalPlugins\webAccess\gui\rule\manager.py:211 +#, python-brace-format +msgid "Parent: {contextPageTitle}" +msgstr "Parent : {contextPageTitle}" + #. Translator: Grouping option on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:266 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:317 msgctxt "webAccess.rulesGroupBy" msgid "&Position" msgstr "&Position" #. Translator: Grouping option on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:272 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:323 msgctxt "webAccess.rulesGroupBy" msgid "&Type" msgstr "&Type" #. Translator: Grouping option on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:278 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:329 +msgctxt "webAccess.rulesGroupBy" +msgid "&Context" +msgstr "&Contexte :" + +#. Translator: Grouping option on the RulesManager dialog. +#: addon\globalPlugins\webAccess\gui\rule\manager.py:335 msgctxt "webAccess.rulesGroupBy" msgid "&Gestures" msgstr "&Geste de commande" #. Translator: Grouping option on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:284 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:341 msgctxt "webAccess.rulesGroupBy" msgid "Nam&e" msgstr "N&om" #. Translator: A label on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:306 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:363 msgid "Group by: " msgstr "Regrouper par : " #. Translator: A label on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:319 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:376 msgid "&Filter: " msgstr "&Filtre : " #. Translator: A label on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:330 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:394 msgid "Include only rules &active on the current page" msgstr "N'inclure que les règles &actives sur la page courante" #. contentsSizer.Add(descSizer, flag=wx.EXPAND) #. Translator: The label for a field on the Rules manager -#: addon\globalPlugins\webAccess\gui\rule\manager.py:356 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:420 msgid "Summary" msgstr "Résumé" #. Translator: The label for a field on the Rules manager -#: addon\globalPlugins\webAccess\gui\rule\manager.py:367 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:431 msgid "Technical notes" msgstr "Notes techniques" #. Translator: The label for a button on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:383 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:447 msgid "Move to" msgstr "Aller à" +#. Translators: Reported when cycling through rules grouping on the Rules Manager dialog +#: addon\globalPlugins\webAccess\gui\rule\manager.py:529 +#, fuzzy +#| msgid "Group by: " +msgid "Group by: {}" +msgstr "Regrouper par : " + #. Translator: A confirmation prompt on the RulesManager dialog. -#: addon\globalPlugins\webAccess\gui\rule\manager.py:572 +#: addon\globalPlugins\webAccess\gui\rule\manager.py:714 msgid "Are you sure you want to delete this rule?" msgstr "Êtes-vous sûr de vouloir supprimer cette règle ?" +#. Translators: The label for a category in the rule editor +#: addon\globalPlugins\webAccess\gui\rule\properties.py:255 +msgid "Properties" +msgstr "Propriétés" + +#. Translators: Displayed when the selected rule type doesn't support any property +#: addon\globalPlugins\webAccess\gui\rule\properties.py:328 +msgid "No property available for this rule type." +msgstr "Aucune propriété n'est disponible pour ce type de règle." + +#. Translators: The label for a list on the Rule Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\properties.py:397 +msgid "&Properties" +msgstr "&Propriétés" + +#. Translators: A column header on the Rule Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\properties.py:409 +msgctxt "webAccess.ruleEditor" +msgid "Property" +msgstr "Propriété" + +#. Translators: A column header on the Rule Editor dialog +#: addon\globalPlugins\webAccess\gui\rule\properties.py:411 +msgctxt "webAccess.ruleEditor" +msgid "Value" +msgstr "Valeur" + +#. Translators: A prompt for creation of a missing SubModule +#: addon\globalPlugins\webAccess\gui\rule\__init__.py:69 +#, python-brace-format +msgid "" +"SubModule {name} could not be found.\n" +"\n" +"Do you want to create it now?" +msgstr "" +"Le sous-module {name} n'existe pas encore.\n" +"\n" +"Voulez-vous le créer maintenant ?" + +#. Translators: Error message while naming a web module. +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:67 +msgid "A web module already exists with this name." +msgstr "Un autre module web porte déjà ce nom." + +#. Translators: Prompt before overwriting a web module. +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:70 +msgid "Do you want to overwrite it?" +msgstr "Voulez-vous le remplacer ?" + +#. Translators: The label for a field in the WebModule editor +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:102 +msgid "Web module name:" +msgstr "Nom du module web :" + +#. Translators: The label for a field in the WebModule editor +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:113 +msgid "URL:" +msgstr "URL :" + +#. Translators: The label for a field in the WebModule editor +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:124 +msgid "Window title:" +msgstr "Titre de la fenêtre :" + +#. Translators: The label for a field in the WebModule editor +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:135 +msgid "Help (in Markdown):" +msgstr "Aide du module (format Markdown)" + +#. Translators: Web module edition dialog title +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:168 +msgid "Edit Web Module" +msgstr "Modifier le module web" + +#. Translators: Web module creation dialog title +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:174 +msgid "New Web Module" +msgstr "Nouveau Module web" + +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:206 +msgid "URL not found" +msgstr "URL introuvable" + +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:274 +msgid "You must enter a name for this web module" +msgstr "Vous devez entrer un nom pour ce module web" + +#: addon\globalPlugins\webAccess\gui\webModule\editor.py:288 +msgid "You must specify at least a URL or a window title." +msgstr "Vous devez spécifier au moins une URL ou un titre de fenêtre." + +#. Translators: The title of the Web Modules Manager dialog +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:48 +msgid "Web Modules Manager" +msgstr "Gestion des modules web" + +#. Translators: The label for the modules list in the +#. Web Modules dialog. +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:66 +msgid "Available Web Modules:" +msgstr "Modules web disponibles :" + +#. Translators: The label for a column of the web modules list +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:76 +msgid "Name" +msgstr "Nom" + +#. Translators: The label for a column of the web modules list +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:78 +msgid "Trigger" +msgstr "Déclencheur" + +#: addon\globalPlugins\webAccess\gui\webModule\manager.py:238 +msgid "and" +msgstr "et" + +#. Translators: Prompt before deleting a web module. +#: addon\globalPlugins\webAccess\gui\webModule\__init__.py:51 +msgid "Do you really want to delete this web module?" +msgstr "Êtes-vous sûr de vouloir supprimer ce module web ?" + +#: addon\globalPlugins\webAccess\gui\webModule\__init__.py:77 +#, python-brace-format +msgid "" +"This web module comes with the add-on {addonSummary}.\n" +"It cannot be modified at its current location.\n" +"\n" +"Do you want to make a copy in your scratchpad?\n" +msgstr "" +"Ce module web est fourni par le module complémentaire {addonSummary}.\n" +"Il ne peut être modifié à son emplacement actuel.\n" +"\n" +"Voulez-vous en faire une copie dans votre répertoire Bloc-notes du " +"Développeur (scratchpad) ?\n" + +#: addon\globalPlugins\webAccess\gui\webModule\__init__.py:86 +msgid "Warning" +msgstr "Avertissement" + #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:144 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:149 msgctxt "webAccess.controlMutation" msgid "Button" msgstr "Bouton" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:146 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:151 msgctxt "webAccess.controlMutation" msgid "Header level 1" msgstr "Titre de niveau 1" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:148 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:153 msgctxt "webAccess.controlMutation" msgid "Header level 2" msgstr "Titre de niveau 2" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:150 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:155 msgctxt "webAccess.controlMutation" msgid "Header level 3" msgstr "Titre de niveau 3" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:152 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:157 msgctxt "webAccess.controlMutation" msgid "Header level 4" msgstr "Titre de niveau 4" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:154 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:159 msgctxt "webAccess.controlMutation" msgid "Header level 5" msgstr "Titre de niveau 5" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:156 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:161 msgctxt "webAccess.controlMutation" msgid "Header level 6" msgstr "Titre de niveau 6" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:158 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:163 msgctxt "webAccess.controlMutation" msgid "Add a label" msgstr "Ajouter un libellé" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:160 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:165 msgctxt "webAccess.controlMutation" msgid "Section" msgstr "Section" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:162 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:167 msgctxt "webAccess.controlMutation" msgid "Region" msgstr "Région" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:164 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:169 msgctxt "webAccess.controlMutation" msgid "Navigation (named)" msgstr "Navigation (nommée)" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:166 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:171 msgctxt "webAccess.controlMutation" msgid "Navigation (unnamed)" msgstr "Navigation (anonyme)" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:168 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:173 msgctxt "webAccess.controlMutation" msgid "Link" msgstr "Lien" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:170 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:175 msgctxt "webAccess.controlMutation" msgid "Data table (Internet Explorer only)" msgstr "Tableau de données (Internet Explorer uniquement)" #. Translators: The label for a control mutation. -#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:172 +#: addon\globalPlugins\webAccess\ruleHandler\controlMutation.py:177 msgctxt "webAccess.controlMutation" msgid "Layout table" msgstr "Tableau de présentation" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:75 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:90 msgctxt "webAccess.ruleProperty" msgid "Auto Actions" msgstr "Actions automatiques" +#. Translators: Displayed if no value is set for the "Auto Actions" property +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:92 +msgctxt "webAccess.action" +msgid "No action" +msgstr "Ne rien faire" + #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:85 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:101 msgctxt "webAccess.ruleProperty" msgid "Multiple results" msgstr "Résultats multiples" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:94 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:111 msgctxt "webAccess.ruleProperty" msgid "Activate form mode" msgstr "Activer le mode formulaire" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:103 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:121 msgctxt "webAccess.ruleProperty" msgid "Skip with Page Down" msgstr "Ignorer avec Page Suivante" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:112 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:131 msgctxt "webAccess.ruleProperty" msgid "Speak rule name" msgstr "Énoncer le nom de la règle" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:121 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:141 msgctxt "webAccess.ruleProperty" msgid "Custom name" msgstr "Nom personnalisé" #. Translators: Displayed if no value is set for a given rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:123 -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:139 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:143 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:166 msgctxt "webAccess.ruleProperty" msgid "" msgstr "" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:133 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:160 msgctxt "webAccess.ruleProperty" msgid "Custom message" msgstr "Message personnalisé" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:136 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:163 msgctxt "webAccess.ruleProperty" msgid "Custom page title" msgstr "Titre de page personnalisé" #. Translators: The display name for a rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:147 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:175 msgctxt "webAccess.ruleProperty" msgid "Transform" msgstr "Transformation" #. Translators: Displayed if no value is set for the "Transform" rule property -#: addon\globalPlugins\webAccess\ruleHandler\properties.py:149 +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:177 msgctxt "webAccess.ruleProperty.mutation" msgid "None" msgstr "Aucune" +#. Translators: The display name for a rule property +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:186 +msgctxt "webAccess.ruleProperty" +msgid "Load sub-module" +msgstr "Charger le sous-module" + +#. Translators: The displayed text if there is no value for the "Load sub-module" property +#: addon\globalPlugins\webAccess\ruleHandler\properties.py:188 +#, fuzzy +#| msgid "No" +msgctxt "webAccess.ruleProperty.subModule" +msgid "No" +msgstr "Non" + +#. Translators: The label for a rule type. +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:45 +msgid "Global Marker" +msgstr "Marqueur global" + #. Translators: The label for a rule type. -#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:43 +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:47 msgid "Marker" msgstr "Marqueur" #. Translators: The label for a rule type. -#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:45 +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:49 msgid "Zone" msgstr "Zone" #. Translators: The label for a rule type. -#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:47 +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:51 msgid "Page type" msgstr "Type de page" #. Translators: The label for a rule type. -#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:49 +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:53 msgid "Parent element" msgstr "Élément parent" #. Translators: The label for a rule type. -#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:51 +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:55 msgid "Page main title" msgstr "Titre de page - principal" #. Translators: The label for a rule type. -#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:53 +#: addon\globalPlugins\webAccess\ruleHandler\ruleTypes.py:57 msgid "Page secondary title" msgstr "Titre de page - secondaire" #. Translators: Action name -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:75 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:84 msgctxt "webAccess.action" msgid "Move to" msgstr "Aller à" #. Translators: Action name -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:77 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:86 msgctxt "webAccess.action" msgid "Say all" msgstr "Dire tout" #. Translators: Action name -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:79 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:88 msgctxt "webAccess.action" msgid "Speak" msgstr "Énoncer" #. Translators: Action name -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:81 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:90 msgctxt "webAccess.action" msgid "Activate" msgstr "Activer" #. Translators: Action name -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:83 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:92 msgctxt "webAccess.action" msgid "Mouse move" msgstr "Déplacer la souris" -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:106 -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:652 -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:661 +#. Translators: Reported when attempting an action while WebAccess is not ready +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:755 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:765 msgid "Not ready" msgstr "Pas encore prêt" #. Translator: Error message in quickNav (page up/down) -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:690 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:794 msgid "No previous zone" msgstr "Pas de zone précédente" #. Translator: Error message in quickNav (page up/down) -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:693 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:797 msgid "No next zone" msgstr "Pas de zone suivante" #. Translator: Error message in quickNav (page up/down) -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:696 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:800 msgid "No zone" msgstr "Pas de zone" #. Translator: Error message in quickNav (page up/down) -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:700 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:804 msgid "No marker" msgstr "Pas de marqueur" #. Translator: Error message in quickNav (page up/down) -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:703 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:807 msgid "No previous marker" msgstr "Pas de marqueur précédent" #. Translator: Error message in quickNav (page up/down) -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:706 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:810 msgid "No next marker" msgstr "Pas de marqueur suivant" #. Translators: Speak rule name on "Move to" action -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:944 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:1149 #, python-brace-format msgid "Move to {ruleName}" msgstr "Aller à {ruleName}" -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:1296 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:1514 #, python-brace-format msgid "{criteriaName} not found" msgstr "{criteriaName} non trouvé" -#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:1373 +#: addon\globalPlugins\webAccess\ruleHandler\__init__.py:1594 #, python-brace-format msgid "{ruleName} not found" msgstr "{ruleName} introuvable" #. Translators: Presented when requesting a missing contextual help -#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:310 +#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:339 msgid "No contextual help available." msgstr "Aucune aide contextuelle disponible." #. Translators: Title of the Contextual Help dialog -#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:315 +#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:344 msgid "Contextual Help" msgstr "Aide contextuelle" -#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:328 +#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:357 #, python-format msgid "%s copied to clipboard" msgstr "%s copié dans le presse-papier" #. Translators: Speak name of current web module -#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:332 +#: addon\globalPlugins\webAccess\webModuleHandler\webModule.py:361 #, python-brace-format msgid "Current web module is: {name}" msgstr "Le module web actuel est : {name}" #. Translators: Confirmation message after web module creation. -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:215 -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:294 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:255 #, python-brace-format msgid "Your new web module {name} has been created." msgstr "Votre nouveau module web {name} a été créé." -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:229 -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:316 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:269 msgid "The web module name should be a valid file name." msgstr "Le nom d'un web module doit être un nom de fichier valide." -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:231 -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:318 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:271 msgid "It should not contain any of the following:" msgstr "Il ne doit contenir aucun des caractères suivants :" -#. Translators: The text of a generic error message dialog -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:246 -msgid "" -"An error occured.\n" -"\n" -"Please consult NVDA's log." -msgstr "" -"Une erreur s'est produite.\n" -"\n" -"Consultez le journal de NVDA pour plus de détails." - -#. Translator: Canceling web module creation. -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:332 -msgid "Cancel" -msgstr "Annuler" - #. Translators: An error message upon attempting to save a modification -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:359 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:326 msgid "This modification cannot be saved under the current configuration." msgstr "" "Cette modification ne peut pas être enregistrée de par la configuration " "actuelle." #. Translators: A hint on how to allow to save a modification -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:363 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:330 msgid "• In the WebAccess category, enable User WebModules." msgstr "• Dans la catégorie WebAccess, activer les WebModules utilisateur." #. Translators: A hint on how to allow to save a modification -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:369 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:336 msgid "• In the WebAccess category, enable Developper Mode." msgstr "• Dans la catégorie WebAccess, activer le mode développeur." #. Translators: A hint on how to allow to save a modification -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:378 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:345 msgid "• In the Advanced category, enable loading of the Scratchpad directory" msgstr "" "• Dans la catégorie Avancé, activer le chargement de code personnalisé " "depuis le répertoire Bloc-notes du Développeur" -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:381 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:348 msgid "or" msgstr "ou" #. Translators: An introduction to hints on how to allow to save a modification -#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:384 +#: addon\globalPlugins\webAccess\webModuleHandler\__init__.py:351 msgid "You may, in NVDA Preferences:" msgstr "Vous pouvez, dans les paramètres de NVDA :" @@ -1394,3 +1477,30 @@ msgid "Web application modules support for modern or complex web sites." msgstr "" "Gestion des modules d'applications web pour la navigation sur sites web " "riches ou complexes." + +#~ msgid "Toggle debug mode." +#~ msgstr "Basculer le mode de débogage." + +#~ msgid "Actions" +#~ msgstr "Actions" + +#~ msgid "No action available for the selected rule type." +#~ msgstr "Aucune action disponible pour le type de règle sélectionné." + +#~ msgid "A&utomatic action at rule detection:" +#~ msgstr "Action &automatique à la détection d'une règle :" + +#~ msgid "WebAccess Rule editor" +#~ msgstr "Éditeur de règles WebAccess" + +#~ msgid "" +#~ "An error occured.\n" +#~ "\n" +#~ "Please consult NVDA's log." +#~ msgstr "" +#~ "Une erreur s'est produite.\n" +#~ "\n" +#~ "Consultez le journal de NVDA pour plus de détails." + +#~ msgid "Cancel" +#~ msgstr "Annuler" diff --git a/buildVars.py b/buildVars.py index 332041f4..8261549b 100644 --- a/buildVars.py +++ b/buildVars.py @@ -26,7 +26,7 @@ def _(arg): # Translators: Long description to be shown for this add-on on add-on information from add-ons manager "addon_description" : _("""Web application modules support for modern or complex web sites."""), # version - "addon_version" : "2024.08.31", + "addon_version" : "2024.12.06-dev+subModules", # Author(s) "addon_author" : ( "Accessolutions (https://accessolutions.fr), " @@ -44,7 +44,7 @@ def _(arg): # Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) "addon_minimumNVDAVersion": "2021.1", # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) - "addon_lastTestedNVDAVersion": "2024.1", + "addon_lastTestedNVDAVersion": "2024.2", # Add-on update channel (default is None, denoting stable releases, # and for development releases, use "dev".) # Do not change unless you know what you are doing!