Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Input Gestures dialog: Async filtering for smoother response #10307

Merged
merged 8 commits into from
Jul 2, 2020
259 changes: 195 additions & 64 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3897,34 +3900,108 @@ 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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until wx properly supports DPI settings we need to accommodate manually. See the scale size helper. To test change your DPI setting in windows, log out and back in, then re-run NVDA.

Though I now note that this is just moved code. I'll accept this without changes.

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)
settingsSizer.Add(filterSizer, flag=wx.EXPAND)
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)
Expand All @@ -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:
Expand All @@ -3993,85 +4116,93 @@ 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

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.
menu = wx.Menu()
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):
Expand Down