浏览代码

wxGUI: module search GUI improved (#1194)

* filtering functionality moved to treemodel
* change search modules layout
* remove unused code in goutput
* change data catalog's search widget, add cancel button, add menu to data catalog search
* change behavior also for Extensions search
Anna Petrasova 4 年之前
父节点
当前提交
7275e780f7

+ 55 - 14
gui/wxpython/core/treemodel.py

@@ -17,6 +17,7 @@ This program is free software under the GNU General Public License
 """
 import six
 import weakref
+import copy
 
 from grass.script.utils import naturally_sort
 
@@ -153,6 +154,37 @@ class TreeModel(object):
         if node.children:
             naturally_sort(node.children, key=lambda node: node.label)
 
+    def Filtered(self, **kwargs):
+        """Filters model based on parameters in kwargs
+        that are passed to node's match function.
+        Copies tree and returns a filtered copy."""
+        def _filter(node):
+            if node.children:
+                to_remove = []
+                for child in node.children:
+                    match = _filter(child)
+                    if not match:
+                        to_remove.append(child)
+                for child in reversed(to_remove):
+                    fmodel.RemoveNode(child)
+                if node.children:
+                    return True
+            return node.match(**kwargs)
+
+        fmodel = copy.deepcopy(self)
+        _filter(fmodel.root)
+
+        return fmodel
+
+    def GetLeafCount(self, node):
+        """Returns the number of leaves in a node."""
+        if node.children:
+            count = 0
+            for child in node.children:
+                count += self.GetLeafCount(child)
+            return count
+        return 1
+
     def __str__(self):
         """Print tree."""
         text = []
@@ -225,21 +257,30 @@ class ModuleNode(DictNode):
         keywords or description."""
         if not self.data:
             return False
-        if key not in ('command', 'keywords', 'description'):
-            return False
-        try:
-            text = self.data[key]
-        except KeyError:
-            return False
-        if not text:
-            return False
-        if case_sensitive:
-            # start supported but unused, so testing last
-            return value in text or value == '*'
+        if isinstance(key, str):
+            keys = [key]
         else:
-            # this works fully only for English and requires accents
-            # to be exact match (even Python 3 casefold() does not help)
-            return value.lower() in text.lower() or value == '*'
+            keys = key
+
+        for key in keys:
+            if key not in ('command', 'keywords', 'description'):
+                return False
+            try:
+                text = self.data[key]
+            except KeyError:
+                continue
+            if not text:
+                continue
+            if case_sensitive:
+                # start supported but unused, so testing last
+                if value in text or value == '*':
+                    return True
+            else:
+                # this works fully only for English and requires accents
+                # to be exact match (even Python 3 casefold() does not help)
+                if value.lower() in text.lower() or value == '*':
+                    return True
+        return False
 
 
 def main():

+ 2 - 2
gui/wxpython/datacatalog/catalog.py

@@ -138,5 +138,5 @@ class DataCatalog(wx.Panel):
         """Allow editing other mapsets or restrict editing to current mapset"""
         self.tree.SetRestriction(restrict)
 
-    def Filter(self, text):
-        self.tree.Filter(text=text)
+    def Filter(self, text, element=None):
+        self.tree.Filter(text=text, element=element)

+ 32 - 7
gui/wxpython/datacatalog/toolbars.py

@@ -16,7 +16,7 @@ This program is free software under the GNU General Public License
 
 import wx
 from gui_core.toolbars import BaseToolbar
-from gui_core.wrap import StaticText, TextCtrl
+from gui_core.wrap import StaticText, SearchCtrl
 from icons.icon import MetaIcon
 
 icons = {
@@ -58,16 +58,28 @@ class DataCatalogToolbar(BaseToolbar):
         BaseToolbar.__init__(self, parent)
 
         self.InitToolbar(self._toolbarData())
-        self.filter = TextCtrl(parent=self)
-        self.filter.SetSize((120, self.filter.GetBestSize()[1]))
+        self.filter_element = None
+        self.filter = SearchCtrl(parent=self)
+        self.filter.SetDescriptiveText(_('Search'))
+        self.filter.ShowCancelButton(True)
+        self.filter.SetSize((150, self.filter.GetBestSize()[1]))
         self.filter.Bind(wx.EVT_TEXT,
                          lambda event: self.parent.Filter(
-                         self.filter.GetValue()))
-        self.AddControl(StaticText(self, label=_("Search:")))
+                         self.filter.GetValue(), self.filter_element))
+        self.filter.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN,
+                         lambda evt: self.parent.Filter(''))
         self.AddControl(self.filter)
+        filterMenu = wx.Menu()
+        item = filterMenu.AppendRadioItem(-1, "All")
+        self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+        item = filterMenu.AppendRadioItem(-1, "Raster maps")
+        self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+        item = filterMenu.AppendRadioItem(-1, "Vector maps")
+        self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+        item = filterMenu.AppendRadioItem(-1, "3D raster maps")
+        self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+        self.filter.SetMenu(filterMenu)
         help = _("Type to search database by map type or name. "
-                 "Use prefix 'r:', 'v:' and 'r3:'"
-                 "to show only raster, vector or 3D raster data, respectively. "
                  "Use Python regular expressions to refine your search.")
         self.SetToolShortHelp(self.filter.GetId(), help)
         # realize the toolbar
@@ -92,6 +104,19 @@ class DataCatalogToolbar(BaseToolbar):
                                      ("addMapset", icons['addMapset'],
                                       self.parent.OnCreateMapset)
                                      ))
+    def OnFilterMenu(self, event):
+        """Decide the element to filter by"""
+        filterMenu = self.filter.GetMenu().GetMenuItems()
+        self.filter_element = None
+        if filterMenu[1].IsChecked():
+            self.filter_element = 'raster'
+        elif filterMenu[2].IsChecked():
+            self.filter_element = 'vector'
+        elif filterMenu[3].IsChecked():
+            self.filter_element = 'raster_3d'
+        # trigger filter on change
+        if self.filter.GetValue():
+            self.parent.Filter(self.filter.GetValue(), self.filter_element)
 
     def OnSetRestriction(self, event):
         if self.GetToolState(self.lock):

+ 30 - 72
gui/wxpython/datacatalog/tree.py

@@ -82,55 +82,6 @@ from grass.exceptions import CalledModuleError
 updateMapset, EVT_UPDATE_MAPSET = NewEvent()
 
 
-def filterModel(model, element=None, name=None):
-    """Filter tree model based on type or name of map using regular expressions.
-    Copies tree and remove nodes which don't match."""
-    fmodel = copy.deepcopy(model)
-    nodesToRemove = []
-    if name:
-        try:
-            regex = re.compile(name)
-        except:
-            return fmodel
-    for gisdbase in fmodel.root.children:
-        for location in gisdbase.children:
-            for mapset in location.children:
-                for layer in mapset.children:
-                    if element and layer.data['type'] != element:
-                        nodesToRemove.append(layer)
-                        continue
-                    if name and regex.search(layer.data['name']) is None:
-                        nodesToRemove.append(layer)
-
-    for node in reversed(nodesToRemove):
-        fmodel.RemoveNode(node)
-
-    cleanUpTree(fmodel)
-    return fmodel
-
-
-def cleanUpTree(model):
-    """Removes empty element/mapsets/locations nodes.
-    It first removes empty elements, then mapsets, then locations"""
-    # removes empty mapsets
-    nodesToRemove = []
-    for gisdbase in model.root.children:
-        for location in gisdbase.children:
-            for mapset in location.children:
-                if not mapset.children:
-                    nodesToRemove.append(mapset)
-    for node in reversed(nodesToRemove):
-        model.RemoveNode(node)
-    # removes empty locations
-    nodesToRemove = []
-    for gisdbase in model.root.children:
-        for location in gisdbase.children:
-            if not location.children:
-                nodesToRemove.append(location)
-    for node in reversed(nodesToRemove):
-        model.RemoveNode(node)
-
-
 def getLocationTree(gisdbase, location, queue, mapsets=None):
     """Creates dictionary with mapsets, elements, layers for given location.
     Returns tuple with the dictionary and error (or None)"""
@@ -284,18 +235,32 @@ class DataCatalogNode(DictNode):
 
         return _("{name}").format(**data)
 
-    def match(self, **kwargs):
+    def match(self, method='exact', **kwargs):
         """Method used for searching according to given parameters.
 
-        :param value: dictionary value to be matched
-        :param key: data dictionary key
+        :param method: 'exact' for exact match or 'filtering' for filtering by type/name
+        :param kwargs key-value to be matched, filtering method uses 'type' and 'name'
+               where 'name' is compiled regex
         """
         if not kwargs:
             return False
 
-        for key in kwargs:
-            if not (key in self.data and self.data[key] == kwargs[key]):
-                return False
+        if method == 'exact':
+            for key, value in kwargs.items():
+                if not (key in self.data and self.data[key] == value):
+                    return False
+            return True
+        # for filtering            
+        if (
+            'type' in kwargs and 'type' in self.data
+            and kwargs['type'] != self.data['type']
+        ):
+            return False
+        if (
+            'name' in kwargs and 'name' in self.data
+            and not kwargs['name'].search(self.data['name'])
+        ):
+            return False
         return True
 
 
@@ -1685,28 +1650,21 @@ class DataCatalogTree(TreeView):
             wx.TheClipboard.SetData(do)
             wx.TheClipboard.Close()
 
-    def Filter(self, text):
+    def Filter(self, text, element=None):
         """Filter tree based on name and type."""
-        text = text.strip()
-        if len(text.split(':')) > 1:
-            name = text.split(':')[1].strip()
-            elem = text.split(':')[0].strip()
-            if 'r' == elem:
-                element = 'raster'
-            elif 'r3' == elem:
-                element = 'raster_3d'
-            elif 'v' == elem:
-                element = 'vector'
-            else:
-                element = None
+        name = text.strip()
+        if not name:
+            self._model = self._orig_model
         else:
-            element = None
-            name = text.strip()
-
-        self._model = filterModel(self._orig_model, name=name, element=element)
+            if element:
+                self._model = self._orig_model.Filtered(method='filtering', name=re.compile(name), type=element)
+            else:
+                self._model = self._orig_model.Filtered(method='filtering', name=re.compile(name))
         self.UpdateCurrentDbLocationMapsetNode()
         self.RefreshItems()
         self.ExpandCurrentMapset()
+        if self._model.GetLeafCount(self._model.root) <= 50:
+            self.ExpandAll()
 
     def _getNewMapName(self, message, title, value, element, mapset, env):
         """Dialog for simple text entry"""

+ 4 - 55
gui/wxpython/gui_core/goutput.py

@@ -41,12 +41,10 @@ from gui_core.prompt import GPromptSTC
 from gui_core.wrap import Button, ClearButton, ToggleButton, StaticText, \
     StaticBox
 from core.settings import UserSettings
-from gui_core.widgets import SearchModuleWidget
 
 
 GC_EMPTY = 0
-GC_SEARCH = 1
-GC_PROMPT = 2
+GC_PROMPT = 1
 
 
 class GConsoleWindow(wx.SplitterWindow):
@@ -64,8 +62,7 @@ class GConsoleWindow(wx.SplitterWindow):
         :param margin: use margin in output pane (GStc)
         :param style: wx.SplitterWindow style
         :param gcstyle: GConsole style
-                        (GC_EMPTY, GC_PROMPT to show command prompt,
-                        GC_SEARCH to show search widget)
+                        (GC_EMPTY, GC_PROMPT to show command prompt)
         """
         wx.SplitterWindow.__init__(
             self, parent, id=wx.ID_ANY, style=style, **kwargs)
@@ -113,7 +110,7 @@ class GConsoleWindow(wx.SplitterWindow):
             margin=margin,
             wrap=None)
 
-        # search & command prompt
+        # command prompt
         # move to the if below
         # search depends on cmd prompt
         self.cmdPrompt = GPromptSTC(
@@ -126,26 +123,6 @@ class GConsoleWindow(wx.SplitterWindow):
         if not self._gcstyle & GC_PROMPT:
             self.cmdPrompt.Hide()
 
-        if self._gcstyle & GC_SEARCH:
-            self.infoCollapseLabelExp = _(
-                "Click here to show search module engine")
-            self.infoCollapseLabelCol = _(
-                "Click here to hide search module engine")
-            self.searchPane = wx.CollapsiblePane(
-                parent=self.panelOutput, label=self.infoCollapseLabelExp,
-                style=wx.CP_DEFAULT_STYLE | wx.CP_NO_TLW_RESIZE | wx.EXPAND)
-            self.MakeSearchPaneContent(
-                self.searchPane.GetPane(), self._menuModel)
-            self.searchPane.Collapse(True)
-            self.Bind(
-                wx.EVT_COLLAPSIBLEPANE_CHANGED,
-                self.OnSearchPaneChanged,
-                self.searchPane)
-            self.search.moduleSelected.connect(
-                lambda name: self.cmdPrompt.SetTextAndFocus(name + ' '))
-        else:
-            self.search = None
-
         if self._gcstyle & GC_PROMPT:
             cmdLabel = _("Command prompt")
             self.outputBox = StaticBox(
@@ -214,9 +191,6 @@ class GConsoleWindow(wx.SplitterWindow):
             promptSizer.Add(helpText,
                             proportion=0, flag=wx.EXPAND | wx.LEFT, border=5)
 
-        if self._gcstyle & GC_SEARCH:
-            self.outputSizer.Add(self.searchPane, proportion=0,
-                                 flag=wx.EXPAND | wx.ALL, border=3)
         self.outputSizer.Add(self.cmdOutput, proportion=1,
                              flag=wx.EXPAND | wx.ALL, border=3)
         if self._gcstyle & GC_PROMPT:
@@ -288,31 +262,6 @@ class GConsoleWindow(wx.SplitterWindow):
         self.SetAutoLayout(True)
         self.Layout()
 
-    def MakeSearchPaneContent(self, pane, model):
-        """Create search pane"""
-        border = wx.BoxSizer(wx.VERTICAL)
-
-        self.search = SearchModuleWidget(parent=pane,
-                                         model=model)
-
-        self.search.showNotification.connect(self.showNotification)
-
-        border.Add(self.search, proportion=0,
-                   flag=wx.EXPAND | wx.ALL, border=1)
-
-        pane.SetSizer(border)
-        border.Fit(pane)
-
-    def OnSearchPaneChanged(self, event):
-        """Collapse search module box"""
-        if self.searchPane.IsExpanded():
-            self.searchPane.SetLabel(self.infoCollapseLabelCol)
-        else:
-            self.searchPane.SetLabel(self.infoCollapseLabelExp)
-
-        self.panelOutput.Layout()
-        self.panelOutput.SendSizeEvent()
-
     def GetPanel(self, prompt=True):
         """Get panel
 
@@ -822,7 +771,7 @@ class GConsoleFrame(wx.Frame):
         self.gconsole = GConsole(guiparent=self)
         self.goutput = GConsoleWindow(parent=panel, gconsole=self.gconsole,
                                       menuModel=menuTreeBuilder.GetModel(),
-                                      gcstyle=GC_SEARCH | GC_PROMPT)
+                                      gcstyle=GC_PROMPT)
 
         mainSizer = wx.BoxSizer(wx.VERTICAL)
         mainSizer.Add(

+ 43 - 54
gui/wxpython/gui_core/menu.py

@@ -26,9 +26,8 @@ import wx
 from core import globalvar
 from core import utils
 from core.gcmd import EncodeString
-from gui_core.widgets import SearchModuleWidget
 from gui_core.treeview import CTreeView
-from gui_core.wrap import Button, StaticText
+from gui_core.wrap import Button, StaticText, SearchCtrl
 from gui_core.wrap import Menu as MenuWidget
 from icons.icon import MetaIcon
 
@@ -143,50 +142,39 @@ class SearchModuleWindow(wx.Panel):
         self.parent = parent
         self._handlerObj = handlerObj
         self._giface = giface
+        self._model = model
 
         self.showNotification = Signal('SearchModuleWindow.showNotification')
         wx.Panel.__init__(self, parent=parent, id=id, **kwargs)
 
+        # search widget
+        self._search = SearchCtrl(self)
+        self._search.SetDescriptiveText(_('Search'))
+        self._search.ShowCancelButton(True)
+        self._btnAdvancedSearch = Button(self, id=wx.ID_ANY,
+                                         label=_("Adva&nced search..."))
+        self._btnAdvancedSearch.SetToolTip(
+            _("Do advanced search using %s module") % 'g.search.module')
         # tree
         self._tree = CTreeView(model=model, parent=self)
         self._tree.SetToolTip(
-            _("Double-click or Ctrl-Enter to run selected module"))
-
-#        self._dataBox = wx.StaticBox(parent = self, id = wx.ID_ANY,
-#                                     label = " %s " % _("Module tree"))
-
-        # search widget
-        self._search = SearchModuleWidget(parent=self,
-                                          model=model,
-                                          showChoice=False)
-        self._search.showSearchResult.connect(
-            lambda result: self._tree.Select(result))
-        self._search.showNotification.connect(self.showNotification)
-
-        self._helpText = StaticText(
-            parent=self, id=wx.ID_ANY,
-            label="Press Enter for next match, Ctrl+Enter to run command")
-        self._helpText.SetForegroundColour(
-            wx.SystemSettings.GetColour(
-                wx.SYS_COLOUR_GRAYTEXT))
+            _("Double-click to run selected module"))
 
         # buttons
-        self._btnRun = Button(self, id=wx.ID_OK, label=_("&Run"))
+        self._btnRun = Button(self, id=wx.ID_OK, label=_("&Run..."))
         self._btnRun.SetToolTip(_("Run selected module from the tree"))
         self._btnHelp = Button(self, id=wx.ID_ANY, label=_("H&elp"))
         self._btnHelp.SetToolTip(
             _("Show manual for selected module from the tree"))
-        self._btnAdvancedSearch = Button(self, id=wx.ID_ANY,
-                                         label=_("Adva&nced search..."))
-        self._btnAdvancedSearch.SetToolTip(
-            _("Do advanced search using %s module") % 'g.search.module')
 
         # bindings
+        self._search.Bind(wx.EVT_TEXT, lambda evt: self.Filter(evt.GetString()))
+        self._search.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN,
+                          lambda evt: self.Filter(''))
         self._btnRun.Bind(wx.EVT_BUTTON, lambda evt: self.Run())
         self._btnHelp.Bind(wx.EVT_BUTTON, lambda evt: self.Help())
         self._btnAdvancedSearch.Bind(wx.EVT_BUTTON,
                                      lambda evt: self.AdvancedSearch())
-        self.Bind(wx.EVT_KEY_UP, self.OnKeyUp)
 
         self._tree.selectionChanged.connect(self.OnItemSelected)
         self._tree.itemActivated.connect(lambda node: self.Run(node))
@@ -199,39 +187,41 @@ class SearchModuleWindow(wx.Panel):
         """Do dialog layout"""
         sizer = wx.BoxSizer(wx.VERTICAL)
 
+        # search
+        searchSizer = wx.BoxSizer(wx.HORIZONTAL)
+        searchSizer.Add(self._search, proportion=1,
+                        flag=wx.EXPAND | wx.RIGHT, border=5)
+        searchSizer.Add(self._btnAdvancedSearch, proportion=0,
+                        flag=wx.EXPAND)
+        sizer.Add(searchSizer, proportion=0,
+                  flag=wx.EXPAND | wx.ALL, border=5)
         # body
-        dataSizer = wx.BoxSizer(wx.HORIZONTAL)
-        dataSizer.Add(self._tree, proportion=1,
-                      flag=wx.EXPAND)
+        sizer.Add(self._tree, proportion=1,
+                  flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=5)
 
         # buttons
         btnSizer = wx.BoxSizer(wx.HORIZONTAL)
-        btnSizer.Add(self._btnAdvancedSearch, proportion=0)
         btnSizer.AddStretchSpacer()
-        btnSizer.Add(self._btnHelp, proportion=0)
+        btnSizer.Add(self._btnHelp, proportion=0,
+                     flag=wx.EXPAND | wx.RIGHT, border=5)
         btnSizer.Add(self._btnRun, proportion=0)
 
-        sizer.Add(dataSizer, proportion=1,
-                  flag=wx.EXPAND | wx.ALL, border=5)
-
-        sizer.Add(self._search, proportion=0,
-                  flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, border=5)
-
         sizer.Add(btnSizer, proportion=0,
-                  flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, border=5)
-
-        sizer.Add(self._helpText,
-                  proportion=0, flag=wx.EXPAND | wx.LEFT, border=5)
-
-        sizer.Fit(self)
-        sizer.SetSizeHints(self)
-
-        self.SetSizer(sizer)
+                  flag=wx.EXPAND | wx.ALL, border=5)
 
-        self.Fit()
+        self.SetSizerAndFit(sizer)
         self.SetAutoLayout(True)
         self.Layout()
 
+    def Filter(self, text):
+        if text:
+            model = self._model.Filtered(key=['command', 'keywords', 'description'],
+                                         value=text)
+            self._tree.SetModel(model)
+            self._tree.ExpandAll()
+        else:
+            self._tree.SetModel(self._model)
+
     def _GetSelectedNode(self):
         selection = self._tree.GetSelected()
         if not selection:
@@ -251,6 +241,11 @@ class SearchModuleWindow(wx.Panel):
         data = node.data
         # non-leaf nodes
         if not data:
+            # expand/collapse location/mapset...
+            if self._tree.IsNodeExpanded(node):
+                self._tree.CollapseNode(node, recursive=False)
+            else:
+                self._tree.ExpandNode(node, recursive=False)
             return
 
         # extract name of the handler and create a new call
@@ -287,12 +282,6 @@ class SearchModuleWindow(wx.Panel):
         """Show advanced search window"""
         self._handlerObj.RunMenuCmd(cmd=['g.search.modules'])
 
-    def OnKeyUp(self, event):
-        """Key or key combination pressed"""
-        if event.ControlDown() and \
-                event.GetKeyCode() in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER):
-            self.Run()
-
     def OnItemSelected(self, node):
         """Item selected"""
         data = node.data

+ 14 - 0
gui/wxpython/gui_core/treeview.py

@@ -135,6 +135,20 @@ class AbstractTreeViewMixin(VirtualTree):
             self.Expand(item)
         self.EnsureVisible(item)
 
+    def ExpandAll(self):
+        """Expand all items.
+        """
+        def _expand(item, root=False):
+            if not root:
+                self.Expand(item)
+            child, cookie = self.GetFirstChild(item)
+            while child:
+                _expand(child)
+                child, cookie = self.GetNextChild(item, cookie)
+
+        item = self.GetRootItem()
+        _expand(item, True)
+
     def IsNodeExpanded(self, node):
         """Check if node is expanded"""
         index = self._model.GetIndexOfNode(node)

+ 19 - 15
gui/wxpython/modules/extensions.py

@@ -30,10 +30,10 @@ from core.gcmd import GError, RunCommand, GException, GMessage
 from core.utils import SetAddOnPath
 from core.gthread import gThread
 from core.menutree import TreeModel, ModuleNode
-from gui_core.widgets import GListCtrl, SearchModuleWidget
+from gui_core.widgets import GListCtrl
 from gui_core.treeview import CTreeView
 from core.toolboxes import toolboxesOutdated
-from gui_core.wrap import Button, StaticBox, TextCtrl, Menu, NewId
+from gui_core.wrap import Button, StaticBox, TextCtrl, Menu, NewId, SearchCtrl
 
 
 class InstallExtensionWindow(wx.Frame):
@@ -70,15 +70,9 @@ class InstallExtensionWindow(wx.Frame):
             parent=self.panel,
             model=self.modelBuilder.GetModel())
 
-        self.search = SearchModuleWidget(
-            parent=self.panel,
-            model=self.modelBuilder.GetModel(),
-            showChoice=False)
-        self.search.showSearchResult.connect(
-            lambda result: self.tree.Select(result))
-        # show text in statusbar when notification appears
-        self.search.showNotification.connect(
-            lambda message: self.SetStatusText(message))
+        self.search = SearchCtrl(self.panel)
+        self.search.SetDescriptiveText(_('Search'))
+        self.search.ShowCancelButton(True)
         # load data in different thread
         self.thread = gThread()
 
@@ -127,6 +121,9 @@ class InstallExtensionWindow(wx.Frame):
         # self.btnFetch.Bind(wx.EVT_BUTTON, self.OnFetch)
         self.btnInstall.Bind(wx.EVT_BUTTON, self.OnInstall)
         self.btnHelp.Bind(wx.EVT_BUTTON, self.OnHelp)
+        self.search.Bind(wx.EVT_TEXT, lambda evt: self.Filter(evt.GetString()))
+        self.search.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN,
+                         lambda evt: self.Filter(''))
         self.tree.selectionChanged.connect(self.OnItemSelected)
         self.tree.itemActivated.connect(self.OnItemActivated)
         self.tree.contextMenu.connect(self.OnContextMenu)
@@ -147,8 +144,7 @@ class InstallExtensionWindow(wx.Frame):
         # repoSizer.Add(repo1Sizer,
         #               flag=wx.EXPAND)
 
-        findSizer = wx.BoxSizer(wx.HORIZONTAL)
-        findSizer.Add(self.search, proportion=1)
+        sizer.Add(self.search, proportion=0, flag=wx.EXPAND | wx.ALL, border=3)
 
         treeSizer = wx.StaticBoxSizer(self.treeBox, wx.HORIZONTAL)
         treeSizer.Add(self.tree, proportion=1,
@@ -168,8 +164,6 @@ class InstallExtensionWindow(wx.Frame):
 
         # sizer.Add(repoSizer, proportion=0,
         #           flag=wx.ALL | wx.EXPAND, border=3)
-        sizer.Add(findSizer, proportion=0,
-                  flag=wx.ALL | wx.EXPAND, border=3)
         sizer.Add(treeSizer, proportion=1,
                   flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, border=3)
         sizer.Add(optionSizer, proportion=0,
@@ -227,6 +221,16 @@ class InstallExtensionWindow(wx.Frame):
         self.SetStatusText(_("%d extensions loaded") % nitems, 0)
         wx.EndBusyCursor()
 
+    def Filter(self, text):
+        model = self.modelBuilder.GetModel()
+        if text:
+            model = model.Filtered(key=['command', 'keywords', 'description'],
+                                   value=text)
+            self.tree.SetModel(model)
+            self.tree.ExpandAll()
+        else:
+            self.tree.SetModel(model)
+
     def OnContextMenu(self, node):
         if not hasattr(self, "popupID"):
             self.popupID = dict()