diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 42e7a710aec..7d59b099ed9 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -10,10 +10,13 @@ from abc import abstractmethod, ABCMeta import copy import re +import threading +from typing import Sequence, Tuple, Union import wx from vision.providerBase import VisionEnhancementProviderSettings from wx.lib import scrolledpanel from wx.lib.expando import ExpandoTextCtrl +from wx.lib.mixins.treemixin import VirtualTree import wx.lib.newevent import winUser import logHandler @@ -3897,18 +3900,97 @@ def onFilterEditTextChange(self, evt): self._refreshVisibleItems() evt.Skip() + +#: A type hint used throughout the InputGesturesDialog +FlattenedGestureMappings = Sequence[Tuple[str, Sequence[Tuple[str, inputCore.AllGesturesScriptInfo]]]] + + class InputGesturesDialog(SettingsDialog): # Translators: The title of the Input Gestures dialog where the user can remap input gestures for commands. title = _("Input Gestures") - def __init__(self, parent): + class GesturesTree(VirtualTree, wx.TreeCtrl): + def __init__(self, parent): + super().__init__( + parent, + size=wx.Size(600, 400), + style=wx.TR_HAS_BUTTONS | wx.TR_HIDE_ROOT | wx.TR_LINES_AT_ROOT | wx.TR_SINGLE + ) + + def OnGetChildrenCount(self, index: Tuple[int, ...]) -> int: + if not index: # Root node + return len(self.Parent.filteredGestures) + commands = self.Parent.filteredGestures[index[0]][1] + if len(index) == 1: # Category + return len(commands) + scriptInfo = commands[index[1]][1] + if len(index) == 2: # Command + count = len(scriptInfo.gestures) + if ( + self.Parent.newGesturePromptIndex + and self.Parent.newGesturePromptIndex[:2] == index + ): + count += 1 + return count + assert len(index) == 3 + return 0 # Gesture + + def OnGetItemText(self, index: Tuple[int, ...], column: int = 0) -> str: + if index == self.Parent.newGesturePromptIndex: + # Translators: The prompt to enter a gesture in the Input Gestures dialog. + return _("Enter input gesture:") + category, commands = self.Parent.filteredGestures[index[0]] + if len(index) == 1: + if self.Parent.filteredGestures is self.Parent.flattenedGestures: + return category + nbResults = len(commands) + if nbResults == 1: + # Translators: The label for a filtered category in the Input Gestures dialog. + return _("{category} (1 result)").format(category=category) + # Translators: The label for a filtered category in the Input Gestures dialog. + return _("{category} ({nbResults} results)").format( + category=category, nbResults=len(commands) + ) + command, scriptInfo = commands[index[1]] + if len(index) == 2: + return command + assert len(index) == 3 + return self.Parent._formatGesture(scriptInfo.gestures[index[2]]) + + def getData(self, index: Tuple[int, ...]) -> Union[inputCore.AllGesturesScriptInfo, str]: + assert 2 <= len(index) <= 3 + category, commands = self.Parent.filteredGestures[index[0]] + command, scriptInfo = commands[index[1]] + if len(index) == 2: + return scriptInfo + return scriptInfo.gestures[index[2]] + + def __init__(self, parent: "InputGesturesDialog"): + #: Token used to cancel async filtering + self.filterToken: object = False + #: The index in the GesturesTree of the prompt for entering a new gesture + self.newGesturePromptIndex: Tuple[int, int, int] = None + gestures = inputCore.manager.getAllGestureMappings( + obj=gui.mainFrame.prevFocus, + ancestors=gui.mainFrame.prevFocusAncestors + ) + # Flatten the gestures mappings for faster access by the VirtualTree + self.flattenedGestures: FlattenedGestureMappings = [] + for category in sorted(gestures): + commands = gestures[category] + self.flattenedGestures.append(( + category, + [(command, commands[command]) for command in sorted(commands)] + )) + #: The L{GesturesTree} actually reads from this attribute + self.filteredGestures: FlattenedGestureMappings = self.flattenedGestures super().__init__(parent, resizeable=True) def makeSettings(self, settingsSizer): filterSizer = wx.BoxSizer(wx.HORIZONTAL) # Translators: The label of a text field to search for gestures in the Input Gestures dialog. filterLabel = wx.StaticText(self, label=pgettext("inputGestures", "&Filter by:")) - filter = self.filter = wx.TextCtrl(self) + filter = wx.TextCtrl(self) filterSizer.Add(filterLabel, flag=wx.ALIGN_CENTER_VERTICAL) filterSizer.AddSpacer(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL) filterSizer.Add(filter, proportion=1) @@ -3916,15 +3998,10 @@ def makeSettings(self, settingsSizer): settingsSizer.AddSpacer(5) filter.Bind(wx.EVT_TEXT, self.onFilterChange, filter) - tree = self.tree = wx.TreeCtrl(self, size=wx.Size(600, 400), style=wx.TR_HAS_BUTTONS | wx.TR_HIDE_ROOT | wx.TR_LINES_AT_ROOT | wx.TR_SINGLE ) - - self.treeRoot = tree.AddRoot("root") + tree = self.tree = self.GesturesTree(self) tree.Bind(wx.EVT_TREE_SEL_CHANGED, self.onTreeSelect) settingsSizer.Add(tree, proportion=1, flag=wx.EXPAND) - self.gestures = inputCore.manager.getAllGestureMappings(obj=gui.mainFrame.prevFocus, ancestors=gui.mainFrame.prevFocusAncestors) - self.populateTree() - settingsSizer.AddSpacer(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL) bHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) @@ -3951,36 +4028,82 @@ def makeSettings(self, settingsSizer): settingsSizer.Add(bHelper.sizer, flag=wx.EXPAND) def postInit(self): + self.tree.RefreshItems() self.tree.SetFocus() - def populateTree(self, filter=''): - if filter: - #This regexp uses a positive lookahead (?=...) for every word in the filter, which just makes sure the word is present in the string to be tested without matching position or order. - # #5060: Escape the filter text to prevent unexpected matches and regexp errors. - # Because we're escaping, words must then be split on "\ ". - filter = re.escape(filter) - filterReg = re.compile(r'(?=.*?' + r')(?=.*?'.join(filter.split('\ ')) + r')', re.U|re.IGNORECASE) - for category in sorted(self.gestures): - treeCat = self.tree.AppendItem(self.treeRoot, category) - commands = self.gestures[category] - for command in sorted(commands): - if filter and not filterReg.match(command): - continue - treeCom = self.tree.AppendItem(treeCat, command) - commandInfo = commands[command] - self.tree.SetItemData(treeCom, commandInfo) - for gesture in commandInfo.gestures: - treeGes = self.tree.AppendItem(treeCom, self._formatGesture(gesture)) - self.tree.SetItemData(treeGes, gesture) - if not self.tree.ItemHasChildren(treeCat): - self.tree.Delete(treeCat) - elif filter: - self.tree.Expand(treeCat) + def _onWindowDestroy(self, evt): + super()._onWindowDestroy(evt) + self.filterToken = None def onFilterChange(self, evt): - filter=evt.GetEventObject().GetValue() - self.tree.DeleteChildren(self.treeRoot) - self.populateTree(filter) + filter = evt.GetEventObject().GetValue() + token = self.filterToken = object() + log.debug(f"new filter token {token}") + self.filter(token, filter) + + def filter(self, token: object, filter: str): + + def run(): + try: + self._filter(token, filter) + except: # noqa: E722 + log.exception() + + threading.Thread( + target=run, + name=f"{self.__class__.__module__}.{self.filter.__func__.__qualname__}", + ).start() + + def _filter(self, token: object, filter: str): + if not filter: + wx.CallAfter(self.refreshTree, token, self.flattenedGestures) + return + filteredGestures = [] + # This regexp uses a positive lookahead (?=...) for every word in the filter, which just makes sure + # the word is present in the string to be tested without matching position or order. + # #5060: Escape the filter text to prevent unexpected matches and regexp errors. + # Because we're escaping, words must then be split on r"\ ". + filter = re.escape(filter) + pattern = re.compile(r"(?=.*?" + r")(?=.*?".join(filter.split(r"\ ")) + r")", re.U | re.IGNORECASE) + nbCommands = 0 + for category, commands in self.flattenedGestures: + if token is not self.filterToken: + log.debug(f"filter token {token} superseded by {self.filterToken}") + return + filteredCommands = [ + (command, scriptInfo) + for command, scriptInfo in commands + if pattern.match(command) + ] + if filteredCommands: + filteredGestures.append((category, filteredCommands)) + nbCommands += len(filteredCommands) + if token is not self.filterToken: + log.debug(f"filter token {token} superseded by {self.filterToken}") + return + # Expanding categories can be expensive: Only do it if there are few results. + wx.CallAfter(self.refreshTree, token, filteredGestures, expandCategories=nbCommands <= 10) + + def refreshTree( + self, + token: object, + filteredGestures: FlattenedGestureMappings, + expandCategories: bool = False, + ): + if token is not self.filterToken: + log.debug(f"filter token {token} superseded by {self.filterToken}") + return + self.tree.CollapseAll() + self.filteredGestures = filteredGestures + self.tree.RefreshItems() + if not expandCategories: + return + for index in range(len(self.filteredGestures)): + if token is not self.filterToken: + log.debug(f"filter token {token} superseded by {self.filterToken}") + return + # Expand categories + self.tree.Expand(self.tree.GetItemByIndex((index,))) def _formatGesture(self, identifier): try: @@ -3993,14 +4116,15 @@ def _formatGesture(self, identifier): return identifier def onTreeSelect(self, evt): + if evt: + evt.Skip() # #7077: Check if the treeview is still alive. try: - item = self.tree.Selection + index = self.tree.GetIndexOfItem(self.tree.Selection) except RuntimeError: return - data = self.tree.GetItemData(item) - isCommand = isinstance(data, inputCore.AllGesturesScriptInfo) - isGesture = isinstance(data, str) + isCommand = len(index) == 2 + isGesture = len(index) == 3 self.addButton.Enabled = isCommand or isGesture self.removeButton.Enabled = isGesture @@ -4008,25 +4132,28 @@ def onAdd(self, evt): if inputCore.manager._captureFunc: return - treeCom = self.tree.Selection - scriptInfo = self.tree.GetItemData(treeCom) - if not isinstance(scriptInfo, inputCore.AllGesturesScriptInfo): - treeCom = self.tree.GetItemParent(treeCom) - scriptInfo = self.tree.GetItemData(treeCom) - # Translators: The prompt to enter a gesture in the Input Gestures dialog. - treeGes = self.tree.AppendItem(treeCom, _("Enter input gesture:")) - self.tree.SelectItem(treeGes) + selIdx = self.tree.GetIndexOfItem(self.tree.Selection) + comIdx = selIdx[:2] + scriptInfo = self.tree.getData(comIdx) + gesIdx = self.newGesturePromptIndex = comIdx + (self.tree.OnGetChildrenCount(comIdx),) + catIdx = comIdx[:1] + catItem = self.tree.GetItemByIndex(catIdx) + self.tree.RefreshChildrenRecursively(catItem) + comItem = self.tree.GetItemByIndex(comIdx) + self.tree.Expand(comItem) + gesItem = self.tree.GetItemByIndex(gesIdx) + self.tree.SelectItem(gesItem) self.tree.SetFocus() def addGestureCaptor(gesture): if gesture.isModifier: return False inputCore.manager._captureFunc = None - wx.CallAfter(self._addCaptured, treeGes, scriptInfo, gesture) + wx.CallAfter(self._addCaptured, scriptInfo, gesture) return False inputCore.manager._captureFunc = addGestureCaptor - def _addCaptured(self, treeGes, scriptInfo, gesture): + def _addCaptured(self, scriptInfo, gesture): gids = gesture.normalizedIdentifiers if len(gids) > 1: # Multiple choices. Present them in a pop-up menu. @@ -4034,44 +4161,48 @@ def _addCaptured(self, treeGes, scriptInfo, gesture): for gid in gids: disp = self._formatGesture(gid) item = menu.Append(wx.ID_ANY, disp) - self.Bind(wx.EVT_MENU, - lambda evt, gid=gid, disp=disp: self._addChoice(treeGes, scriptInfo, gid, disp), - item) + self.Bind( + wx.EVT_MENU, + lambda evt, gid=gid, disp=disp: self._addChoice(scriptInfo, gid, disp), + item + ) self.PopupMenu(menu) - if not self.tree.GetItemData(treeGes): + if self.newGesturePromptIndex: # No item was selected, so use the first. - self._addChoice(treeGes, scriptInfo, gids[0], - self._formatGesture(gids[0])) + self._addChoice(scriptInfo, gids[0], self._formatGesture(gids[0])) menu.Destroy() else: - self._addChoice(treeGes, scriptInfo, gids[0], - self._formatGesture(gids[0])) + self._addChoice(scriptInfo, gids[0], self._formatGesture(gids[0])) - def _addChoice(self, treeGes, scriptInfo, gid, disp): + def _addChoice(self, scriptInfo, gid, disp): entry = (gid, scriptInfo.moduleName, scriptInfo.className, scriptInfo.scriptName) try: # If this was just removed, just undo it. self.pendingRemoves.remove(entry) except KeyError: self.pendingAdds.add(entry) - self.tree.SetItemText(treeGes, disp) - self.tree.SetItemData(treeGes, gid) scriptInfo.gestures.append(gid) + catIdx = self.newGesturePromptIndex[:1] + catItem = self.tree.GetItemByIndex(catIdx) + self.newGesturePromptIndex = None + self.tree.RefreshChildrenRecursively(catItem) self.onTreeSelect(None) def onRemove(self, evt): - treeGes = self.tree.Selection - gesture = self.tree.GetItemData(treeGes) - treeCom = self.tree.GetItemParent(treeGes) - scriptInfo = self.tree.GetItemData(treeCom) + gesIdx = self.tree.GetIndexOfItem(self.tree.Selection) + gesture = self.tree.getData(gesIdx) + comIdx = gesIdx[:2] + scriptInfo = self.tree.getData(comIdx) entry = (gesture, scriptInfo.moduleName, scriptInfo.className, scriptInfo.scriptName) try: # If this was just added, just undo it. self.pendingAdds.remove(entry) except KeyError: self.pendingRemoves.add(entry) - self.tree.Delete(treeGes) scriptInfo.gestures.remove(gesture) + catIdx = comIdx[:1] + catItem = self.tree.GetItemByIndex(catIdx) + self.tree.RefreshChildrenRecursively(catItem) self.tree.SetFocus() def onReset(self, evt):