From 49ba6c570cf2e988f2ad531099810d45dfd971a4 Mon Sep 17 00:00:00 2001 From: Julien Cochuyt Date: Mon, 26 Aug 2024 02:51:10 +0200 Subject: [PATCH] Rules offline edition (#42) - RulesManager: Disable group by position when results aren't available - GUI: RulesManager: Add current group by option to dialog title - GUI: RulesManager: `ctrl(+shift)+tab` cycles through group by options - GUI: RulesManager: `F6` switches from the list to the summary or comments back and forth - GUI: RulesManager: `enter` on the "Group By" radios sets focus to the tree - GUI: RulesManager: `*` and `/` on the numpad while on the tree expands/collapses it all - GUI: RulesManager: Fix layout - GUI: RuleEditor: Fix checking for conflicting name when renaming a rule - GUI: RuleEditor: Fix layout - Remove `showManager`, `showCreator` and `showEditor` from `webModuleHandler` and `ruleHandler` - webModuleHandler: Remove `getEditableScratchpadWebModule` and `getEditableUserConfigWebModule` from public API - WebModule: Add `getWritableLayer` to public API - WebModuleDataLayer: Remove obsolete `rulesOnly` attribute - Bump API version to 0.6 --- addon/globalPlugins/webAccess/__init__.py | 14 +- addon/globalPlugins/webAccess/gui/__init__.py | 38 +- addon/globalPlugins/webAccess/gui/actions.py | 20 +- .../webAccess/gui/criteriaEditor.py | 17 +- .../webAccess/gui/elementDescription.py | 21 +- .../webAccess/gui/gestureBinding.py | 13 +- addon/globalPlugins/webAccess/gui/menu.py | 37 +- addon/globalPlugins/webAccess/gui/rule/abc.py | 4 +- .../webAccess/gui/rule/editor.py | 179 +++++---- .../webAccess/gui/rule/manager.py | 357 +++++++++++------- .../webAccess/gui/webModuleEditor.py | 92 ++--- .../webAccess/gui/webModulesManager.py | 252 ++++++------- .../webAccess/ruleHandler/__init__.py | 40 +- .../webAccess/store/webModule.py | 5 +- .../webAccess/webModuleHandler/__init__.py | 120 ++---- .../webAccess/webModuleHandler/webModule.py | 68 ++-- 16 files changed, 635 insertions(+), 642 deletions(-) diff --git a/addon/globalPlugins/webAccess/__init__.py b/addon/globalPlugins/webAccess/__init__.py index 5f821cf2..253bae58 100644 --- a/addon/globalPlugins/webAccess/__init__.py +++ b/addon/globalPlugins/webAccess/__init__.py @@ -211,10 +211,6 @@ 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 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.")) @@ -223,15 +219,17 @@ def showWebAccessGui(self): # 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 + context = { + "webAccess": self, + "focusObject": obj, + } webModule = obj.webAccess.webModule if webModule is not None: context["webModule"] = webModule context["pageTitle"] = webModule.pageTitle + mgr = webModule.ruleManager + context["result"] = mgr.getResultAtCaret(focus=obj) menu.show(context) @script( diff --git a/addon/globalPlugins/webAccess/gui/__init__.py b/addon/globalPlugins/webAccess/gui/__init__.py index 7b32f6a9..cc38adad 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, @@ -210,6 +211,7 @@ def __str__(self): class ScalingMixin(DpiScalingHelperMixinWithoutInit): + def scale(self, *args): sizes = tuple(( self.scaleSize(arg) if arg > 0 else arg @@ -239,6 +241,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. @@ -291,7 +300,9 @@ class FillableMultiCategorySettingsDialog(MultiCategorySettingsDialog, ScalingMi See `FillableSettingsPanel` """ - + + onCategoryChange = guarded(MultiCategorySettingsDialog.onCategoryChange) + def _getCategoryPanel(self, catId): # Changes to the original implementation: # - Add `proportion=1` @@ -324,7 +335,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): @@ -396,6 +407,7 @@ def focusContainerControl(self, index: int): class ContextualMultiCategorySettingsDialog( KbNavMultiCategorySettingsDialog, configuredSettingsDialogType(hasApplyButton=False), + ContextualDialog, ): def __new__(cls, *args, **kwargs): @@ -449,20 +461,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" @@ -776,12 +785,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) diff --git a/addon/globalPlugins/webAccess/gui/actions.py b/addon/globalPlugins/webAccess/gui/actions.py index e96c9b5e..8026d5fd 100644 --- a/addon/globalPlugins/webAccess/gui/actions.py +++ b/addon/globalPlugins/webAccess/gui/actions.py @@ -20,10 +20,13 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.24" +__version__ = "2024.08.25" __authors__ = ( "Shirley Noel ", "Julien Cochuyt ", + "André-Abush Clause ", + "Sendhil Randon ", + "Gatien Bouyssou ", ) @@ -187,13 +190,12 @@ def onAutoActionChoice(self, evt): @guarded def onAddGesture(self, evt): - context = self.context + context = self.context.copy() context["data"]["gestures"] = self.gesturesMap - if gestureBinding.show(context=context, parent=self) == wx.ID_OK: - id = context["data"].pop("gestureBinding")["gestureIdentifier"] + if gestureBinding.show(context, self): + id = context["data"]["gestureBinding"]["gestureIdentifier"] self.onGestureChange(Change.CREATION, id) - del context["data"]["gestures"] - + @guarded def onDeleteGesture(self, evt): index = self.gesturesListBox.Selection @@ -203,15 +205,13 @@ def onDeleteGesture(self, evt): @guarded def onEditGesture(self, evt): - context = self.context + context = self.context.copy() 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: + if gestureBinding.show(context=context, parent=self): id = context["data"]["gestureBinding"]["gestureIdentifier"] self.onGestureChange(Change.UPDATE, id) - del context["data"]["gestureBinding"] - del context["data"]["gestures"] def onGestureChange(self, change: Change, id: str): if change is Change.DELETION: diff --git a/addon/globalPlugins/webAccess/gui/criteriaEditor.py b/addon/globalPlugins/webAccess/gui/criteriaEditor.py index 9b972420..a7d20b7e 100644 --- a/addon/globalPlugins/webAccess/gui/criteriaEditor.py +++ b/addon/globalPlugins/webAccess/gui/criteriaEditor.py @@ -229,14 +229,14 @@ def getSummary(context, data, indent="", condensed=False) -> str: def testCriteria(context): ruleData = copy.deepcopy(context["data"]["rule"]) ruleData["name"] = "__tmp__" - ruleData.pop("new", None) + # Other rule types might not support the "multiple" property we are forcing for the test 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) + critData.setdefault("properties", {}).pop("multiple", None) mgr = context["webModule"].ruleManager from ..ruleHandler import Rule rule = Rule(mgr, ruleData) @@ -324,17 +324,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", "") diff --git a/addon/globalPlugins/webAccess/gui/elementDescription.py b/addon/globalPlugins/webAccess/gui/elementDescription.py index fda85e9d..5cd4952a 100644 --- a/addon/globalPlugins/webAccess/gui/elementDescription.py +++ b/addon/globalPlugins/webAccess/gui/elementDescription.py @@ -23,8 +23,13 @@ # Get ready for Python 3 -__version__ = "2024.07.19" -__author__ = "Frédéric Brugnot " +__version__ = "2024.08.25" +__authors__ = ( + "Frédéric Brugnot ", + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) __license__ = "GPL" @@ -99,11 +104,13 @@ 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 [] diff --git a/addon/globalPlugins/webAccess/gui/gestureBinding.py b/addon/globalPlugins/webAccess/gui/gestureBinding.py index 9204e499..6d4c06e3 100644 --- a/addon/globalPlugins/webAccess/gui/gestureBinding.py +++ b/addon/globalPlugins/webAccess/gui/gestureBinding.py @@ -20,8 +20,15 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.19" -__author__ = "Shirley Noel " +__version__ = "2024.08.25" +__authors__ = ( + "Shirley Noel ", + "Frédéric Brugnot ", + "Julien Cochuyt ", + "André-Abush Clause ", + "Sendhil Randon ", + "Gatien Bouyssou ", +) from collections.abc import Mapping @@ -211,4 +218,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/menu.py b/addon/globalPlugins/webAccess/gui/menu.py index fb774989..608f2e75 100644 --- a/addon/globalPlugins/webAccess/gui/menu.py +++ b/addon/globalPlugins/webAccess/gui/menu.py @@ -22,8 +22,12 @@ """Web Access GUI.""" -__version__ = "2024.08.02" -__author__ = "Julien Cochuyt " +__version__ = "2024.08.25" +__authors__ = ( + "Julien Cochuyt ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) import wx @@ -33,7 +37,6 @@ from ... import webAccess from .. import ruleHandler -from .. import webModuleHandler from ..utils import guarded from . import webModulesManager @@ -53,17 +56,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. @@ -72,13 +73,14 @@ 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: + + if webModule: item = self.Append( wx.ID_ANY, # Translators: Web Access menu item label. @@ -110,23 +112,30 @@ def show(self): @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) + self.context["new"] = True + from .webModuleEditor import show + show(self.context, gui.mainFrame) @guarded def onWebModuleEdit(self, evt): - webModuleHandler.showEditor(self.context) + from .webModuleEditor import show + show(self.context) @guarded def onWebModulesManager(self, evt): - webModuleHandler.showManager(self.context) + from .webModulesManager import show + show(self.context) @guarded def onWebAccessToggle(self, evt): diff --git a/addon/globalPlugins/webAccess/gui/rule/abc.py b/addon/globalPlugins/webAccess/gui/rule/abc.py index 5e3a5cc6..ac8d2eee 100644 --- a/addon/globalPlugins/webAccess/gui/rule/abc.py +++ b/addon/globalPlugins/webAccess/gui/rule/abc.py @@ -20,7 +20,7 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.02" +__version__ = "2024.08.25" __author__ = "Julien Cochuyt " @@ -32,7 +32,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/rule/editor.py b/addon/globalPlugins/webAccess/gui/rule/editor.py index 5b21e32e..0866a6d3 100644 --- a/addon/globalPlugins/webAccess/gui/rule/editor.py +++ b/addon/globalPlugins/webAccess/gui/rule/editor.py @@ -62,6 +62,7 @@ TreeContextualPanel, TreeMultiCategorySettingsDialog, TreeNodeInfo, + ValidationError, criteriaEditor, gestureBinding, showContextualDialog, @@ -323,36 +324,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 @@ -475,28 +474,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 @@ -775,13 +776,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): @@ -799,17 +798,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 @@ -871,8 +868,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), @@ -896,27 +892,22 @@ 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]: + data = self.getData() + if data["type"] not in [ruleTypes.ZONE, ruleTypes.MARKER]: return [] mgr = self.context["webModule"].ruleManager actionsPanel = [] - for key, value in ruleData.get('gestures', {}).items(): + for key, value in data.get('gestures', {}).items(): title = ChildActionPanel.getTreeNodeLabel(mgr, key, value) prm = ChildActionPanel.CategoryParams(title=title, gestureIdentifier=key) actionsPanel.append(TreeNodeInfo(ChildActionPanel, title=title, categoryParams=prm)) @@ -924,7 +915,7 @@ def getActionsChildren(self): 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( @@ -935,16 +926,19 @@ 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", {}) + if context.get("new"): + data = context["data"]["rule"] = {"type": ruleTypes.MARKER} + else: + data = context["data"]["rule"] = context["rule"].dump() + mgr = context["webModule"].ruleManager.nodeManager + if mgr: + node = mgr.getCaretNode() while node is not None: if node.role in formModeRoles: data.setdefault("properties", {})["formMode"] = True @@ -955,27 +949,24 @@ def initData(self, context: Mapping[str, Any]) -> None: def _doSave(self): super()._doSave() context = self.context + data = self.getData() 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 + 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) 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/rule/manager.py b/addon/globalPlugins/webAccess/gui/rule/manager.py index faa9762e..d11f4ab3 100644 --- a/addon/globalPlugins/webAccess/gui/rule/manager.py +++ b/addon/globalPlugins/webAccess/gui/rule/manager.py @@ -20,8 +20,14 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.02" -__author__ = "Shirley Noël " +__version__ = "2024.08.29" +__authors__ = ( + "Julien Cochuyt ", + "Shirley Noël ", + "Frédéric Brugnot ", + "André-Abush Clause ", + "Gatien Bouyssou ", +) from collections import namedtuple @@ -33,6 +39,7 @@ from gui import guiHelper import inputCore import queueHandler +import ui from ...ruleHandler import ( Rule, @@ -40,13 +47,11 @@ Zone, builtinRuleActions, ruleTypes, - showCreator, - showEditor, ) 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 try: from six import iteritems @@ -62,10 +67,8 @@ 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")) @@ -93,7 +96,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: @@ -111,7 +114,7 @@ def rule_getResults_safe(rule): def getRulesByGesture(ruleManager, filter=None, active=False): gestures = {} noGesture = [] - + for rule in getRules(ruleManager): if filter and filter not in rule.name: continue @@ -178,7 +181,7 @@ def getRulesByPosition(ruleManager, filter=None, active=True): As position depends on result, the `active` criteria is ignored. """ Parent = namedtuple("Parent", ("parent", "tid", "zone")) - + def filterChildlessParent(parent): if ( not filter @@ -189,19 +192,19 @@ def filterChildlessParent(parent): 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(): 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), @@ -288,19 +291,19 @@ 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. @@ -311,9 +314,9 @@ def __init__(self, parent): 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. @@ -324,7 +327,7 @@ def __init__(self, parent): 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( self, # Translator: A label on the RulesManager dialog. @@ -332,10 +335,10 @@ def __init__(self, parent): ) self.activeOnlyCheckBox.Bind(wx.EVT_CHECKBOX, self.onActiveOnlyCheckBox) filtersSizer.Add(self.activeOnlyCheckBox) - + 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), @@ -347,12 +350,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) @@ -361,22 +364,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, @@ -386,14 +389,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. @@ -401,7 +404,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. @@ -409,7 +412,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, @@ -425,31 +428,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.refreshRuleList() + 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: @@ -459,64 +454,104 @@ 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 refreshGroupByRadio(self): + radioBox = self.groupByRadio + index = next(i for i, g in enumerate(GROUP_BY) if g.id == lastGroupBy) + if not radioBox.IsItemEnabled(index): + index = next(i for i in range(radioBox.Count) if radioBox.IsItemEnabled(i)) + if radioBox.Selection != index: + radioBox.SetSelection(index) + self.onGroupByRadio(None) + + def refreshRuleList(self): + context = self.context + result = context.pop("initialSelectedResult", None) groupBy = GROUP_BY[self.groupByRadio.GetSelection()] + 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.GetValue() 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): + webModule = self.context["webModule"] + # Translators: The title of the Rules Manager dialog + title = "Web Module {} - Rules by {}".format( + webModule.name, + stripAccel(GROUP_BY[self.groupByRadio.GetSelection()].label).lower(), + ) + 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 @@ -524,10 +559,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, report=False): global lastGroupBy, lastActiveOnly + self.refreshTitle() groupBy = GROUP_BY[self.groupByRadio.GetSelection()] lastGroupBy = groupBy.id if groupBy.id == "position": @@ -537,9 +614,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() @@ -560,7 +636,7 @@ def onResultMoveTo(self, evt): None ) self.Close() - + @guarded def onRuleDelete(self, evt): rule = self.getSelectedRule() @@ -577,9 +653,9 @@ 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) @@ -589,48 +665,50 @@ def onRuleDelete(self, evt): ) 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"]) + 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 ( @@ -654,10 +732,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/webModuleEditor.py b/addon/globalPlugins/webAccess/gui/webModuleEditor.py index ed6e35ed..efb04af2 100644 --- a/addon/globalPlugins/webAccess/gui/webModuleEditor.py +++ b/addon/globalPlugins/webAccess/gui/webModuleEditor.py @@ -43,7 +43,7 @@ import ui from ..webModuleHandler import WebModule, getEditableWebModule, getUrl, getWindowTitle, save -from . import ScalingMixin +from . import ContextualDialog, showContextualDialog addonHandler.initTranslation() @@ -70,26 +70,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( @@ -146,26 +134,25 @@ 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 initData(self, context): - self.context = context - webModule = context.get("webModule") - if webModule is None: - new = True + super().initData(context) + data = context.setdefault("data", {})["webModule"] = {} + if not context.get("new"): + webModule = context.get("webModule") + data.update(webModule.dump(webModule.layers[-1].name).data["WebModule"]) + # 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: # Translators: Web module creation dialog title title = _("New Web Module") if config.conf["webAccess"]["devMode"]: @@ -181,20 +168,15 @@ def initData(self, context): ) 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: @@ -202,7 +184,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) @@ -243,9 +225,9 @@ def initData(self, context): 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) @@ -258,7 +240,7 @@ 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 onOk(self, evt): name = self.webModuleName.Value.strip() @@ -286,9 +268,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: @@ -299,19 +282,12 @@ def onOk(self, evt): webModule.windowTitle = windowTitle webModule.help = help - if not save(webModule): + if not save(webModule, prompt=self.Title): return - assert self.IsModal() - self.EndModal(wx.ID_OK) - - def onCancel(self, evt): - self.EndModal(wx.ID_CANCEL) + self.DestroyLater() + self.SetReturnCode(wx.ID_OK) - 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/webModulesManager.py b/addon/globalPlugins/webAccess/gui/webModulesManager.py index 153aab32..e06c3c41 100644 --- a/addon/globalPlugins/webAccess/gui/webModulesManager.py +++ b/addon/globalPlugins/webAccess/gui/webModulesManager.py @@ -20,7 +20,7 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.21" +__version__ = "2024.08.25" __authors__ = ( "Julien Cochuyt ", "André-Abush Clause ", @@ -37,11 +37,12 @@ import core import globalVars import gui -from gui.nvdaControls import AutoWidthColumnListCtrl +from gui import guiHelper import languageHandler from logHandler import log -from . import ScalingMixin +from ..utils import guarded +from . import ContextualDialog, ListCtrlAutoWidth, showContextualDialog def promptDelete(webModule): @@ -87,77 +88,86 @@ def promptMask(webModule): ) == 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 - +class Dialog(ContextualDialog): + 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( + ) + 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:"), - ) - - item = self.modulesList = AutoWidthColumnListCtrl( - self, - style=wx.LC_REPORT|wx.LC_SINGLE_SEL, - #size=(550,350), - ) + ) + 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")) - ## 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) - + 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 @@ -165,135 +175,117 @@ def __init__(self, parent): label=_("&Delete")) item.Disable() item.Bind(wx.EVT_BUTTON, self.onModuleDelete) + gbSizer.Add(item, (row, col), flag=wx.EXPAND) - 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( + mainSizer.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 - - + flag=wx.EXPAND | wx.BOTTOM | wx.LEFT | wx.RIGHT, + border=scale(guiHelper.BORDER_FOR_DIALOGS), + ) + self.SetSize(scale(790, 400)) + self.SetSizer(mainSizer) + self.CentreOnScreen() + self.modulesList.SetFocus() + def __del__(self): Dialog._instance = None - + def initData(self, context): - self.context = context + super().initData(context) module = context["webModule"] if "webModule" in context else None self.refreshModulesList(selectItem=module) - + + @guarded 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) - + context = self.context.copy() + context["new"] = True + from .webModuleEditor import show + if show(context, self): + self.refreshModulesList(selectItem=context["webModule"]) + + @guarded 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() - + + @guarded def onModuleEdit(self, evt=None): index = self.modulesList.GetFirstSelected() if index < 0: + wx.Bell() return - context = dict(self.context) # Shallow copy + context = self.context + context.pop("new", None) context["webModule"] = self.modules[index] - from .. import webModuleHandler - webModuleHandler.showEditor(context) - self.refreshModulesList(selectIndex=index) - + from .webModuleEditor import show + if show(context, self): + self.refreshModulesList(selectIndex=index) + + @guarded 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 - # FIXME: This test never succeeds as a live WebModule is not - # taken from the context. - # TODO: Remove this restriction for issue #42 - and item.ruleManager.isReady - ) - self.moduleDeleteButton.Enable(item is not None) - + 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.copy() # Shallow copy - context["webModule"] = self.modules[index] - from .. import ruleHandler - ruleHandler.showManager(context) - - def refreshModulesList(self, selectIndex=0, selectItem=None): - self.modulesList.DeleteAllItems() + 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") modules = self.modules = [] - modulesList = self.modulesList - from .. import webModuleHandler for index, module in enumerate(webModuleHandler.getWebModules()): - if module is selectItem: + if selectIndex is None and module.equals(selectItem): selectIndex = index + if module.equals(contextModule): + module = contextModule 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(( + ) + ctrl.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) + if selectIndex is None: + selectIndex = min(0, len(modules) - 1) else: - self.moduleEditButton.Disable() - self.rulesManagerButton.Disable() - self.moduleDeleteButton.Disable() + selectIndex %= len(modules) + if selectIndex >= 0: + ctrl.Select(selectIndex, on=1) + ctrl.Focus(selectIndex) + self.refreshButtons() - def Show(self, context): - self.initData(context) - self.Fit() - self.modulesList.SetFocus() - self.CentreOnScreen() - return super().Show() + +def show(context): + showContextualDialog(Dialog, context, gui.mainFrame) diff --git a/addon/globalPlugins/webAccess/ruleHandler/__init__.py b/addon/globalPlugins/webAccess/ruleHandler/__init__.py index 9d5b1bda..dfedbc08 100644 --- a/addon/globalPlugins/webAccess/ruleHandler/__init__.py +++ b/addon/globalPlugins/webAccess/ruleHandler/__init__.py @@ -20,7 +20,7 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.19" +__version__ = "2024.08.25" __authors__ = ( "Frédéric Brugnot ", "Julien Cochuyt ", @@ -35,6 +35,7 @@ from pprint import pformat import threading import time +from typing import Any import weakref import wx @@ -44,7 +45,6 @@ import baseObject import browseMode import controlTypes -import gui import inputCore from logHandler import log import queueHandler @@ -84,35 +84,6 @@ 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: - playWebAccessSound("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) - - class DefaultScripts(baseObject.ScriptableObject): def __init__(self, warningMessage): @@ -176,16 +147,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)): diff --git a/addon/globalPlugins/webAccess/store/webModule.py b/addon/globalPlugins/webAccess/store/webModule.py index 9a382cde..aebc84a1 100644 --- a/addon/globalPlugins/webAccess/store/webModule.py +++ b/addon/globalPlugins/webAccess/store/webModule.py @@ -22,7 +22,7 @@ """Web Module data store.""" -__version__ = "2024.08.23" +__version__ = "2024.08.25" __author__ = "Julien Cochuyt " @@ -83,6 +83,7 @@ def catalog(self, errors=None): data = self.get(ref).data meta = {} for key in ("windowTitle", "url"): + # "WebApp" corresponds to legacy format version (pre 0.1) value = data.get("WebModule", data.get("WebApp", {})).get(key) if value: meta[key] = value @@ -215,7 +216,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/webModuleHandler/__init__.py b/addon/globalPlugins/webAccess/webModuleHandler/__init__.py index db759136..5ff65879 100644 --- a/addon/globalPlugins/webAccess/webModuleHandler/__init__.py +++ b/addon/globalPlugins/webAccess/webModuleHandler/__init__.py @@ -22,7 +22,7 @@ """Web Access GUI.""" -__version__ = "2024.07.26" +__version__ = "2024.08.25" __author__ = "Julien Cochuyt " @@ -45,6 +45,7 @@ from ..overlay import WebAccessBmdti from ..store import DuplicateRefError from ..store import MalformedRefError +from ..utils import notifyError PACKAGE_NAME = "webModulesSM" @@ -233,23 +234,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 @@ -259,97 +255,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)) @@ -393,7 +321,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"] @@ -423,7 +351,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 6483c1d4..00556127 100644 --- a/addon/globalPlugins/webAccess/webModuleHandler/webModule.py +++ b/addon/globalPlugins/webAccess/webModuleHandler/webModule.py @@ -20,7 +20,7 @@ # See the file COPYING.txt at the root of this distribution for more details. -__version__ = "2024.08.23" +__version__ = "2024.08.25" __authors__ = ( "Yannick Plassiard ", "Frédéric Brugnot ", @@ -31,6 +31,7 @@ from collections import OrderedDict +from collections.abc import Sequence import datetime import json import os @@ -58,19 +59,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 @@ -96,14 +94,14 @@ def _get_readOnly(self): class WebModule(baseObject.ScriptableObject): - API_VERSION = version.parse("0.5") + API_VERSION = version.parse("0.6") 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) @@ -184,13 +182,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) @@ -199,12 +218,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) @@ -212,11 +230,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: @@ -231,6 +249,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: @@ -245,8 +274,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 @@ -258,15 +285,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