Repository: spyder-ide/spyder
Pull request: PR: Add fuzzy search to the Variable Explorer
<!--- Make sure to read the Contributing Guidelines:                   --->
<!--- https://github.com/spyder-ide/spyder/blob/master/CONTRIBUTING.md --->
<!--- and follow PEP 8, PEP 257 and Spyder's code style:               --->
<!--- https://github.com/spyder-ide/spyder/wiki/Dev:-Coding-Style      --->

## Description of Changes


* [ ] Wrote at least one-line docstrings (for any new functions)
* [ ] Added unit test(s) covering the changes (if testable)
<!--- Remember that an image/animation is worth a thousand words! --->
* [x] Included a screenshot or animation (if affecting the UI, see [Licecap](https://www.cockos.com/licecap/))


<!--- Explain what you've done and why --->
![fuzzy](https://user-images.githubusercontent.com/16781833/60739896-2ed99e00-9f29-11e9-84f3-024dbf6d94af.gif)



### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

Fixes #4067 


### Affirmation

By submitting this Pull Request or typing my (user)name below,
I affirm the [Developer Certificate of Origin](https://developercertificate.org)
with respect to all commits and content included in this PR,
and understand I am releasing the same under Spyder's MIT (Expat) license.

<!--- TYPE YOUR USER/NAME AFTER THE FOLLOWING: --->
I certify the above statement is true and correct: dalthviz

<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
Add fuzzy search functionality to the Variable Explorer
It is hard to explain so let me present a scenario:
You are researching and have many dataframes with various names in your current session. you want to look at some dataframe you just created. printing it is rarely helpful.
Right now, I search for my dataframe in the variable explorer and open it there to have a nicer view of it.
But if I created many variables searching for the one I want becomes tedious.
What I want is the ability to have faster access to the variable by providing the variable's name.
maybe writing:
view(mydataframe)

Or having a shortcut to search all variables in variable explorer (hopefully in a fuzzy manner) and hitting Enter to view it.

Any suggestions to the tired researcher?

Hints from issue discussion:
Cool suggestion!! We'll try to work on it for Spyder 4 :-)
Or you could give it a try for Spyder 3. We already have a fuzzy search library, so it'd be a matter of using that library with the variable explorer.

@goanpeca, what do you think? Should we try to add this in Spyder 3.2?
@ccordoba12 yep sounds like a good idea.

This would be a good moment to make the fuzzy search dialog more generic. The current one use for files makes a lot of file assumptions
I was just thinking on adding a search text box to the Variable Explorer toolbar and connecting it to the variable names.
Or add Ctrl F ?

Lets think this through a bit more. I dont want to clutter the interface.
Good idea. We could add a search icon that when pressed adds a search widget, like the one present in the Editor and consoles :-)
Hi. I was going to post an issue very similar to this one. My suggestion is the following:

**1.** Since the Variable Explorer do not accept typing when focused, my suggestion is that a key press could highlight the first variable that starts with that letter. If the same key is pressed again it would highlight the second occurrence and so on..

**2.** If "Ctrl + F" is pressed when Variable Explorer is focused, it could open a search box (maybe the same search box in the editor or a new one at the bottom of the Variable Explorer window) and filter variables that match the search text.
@jnsebgosselin, this could be a fun project for you ;-)
@ccordoba12 Alright, this definitely sounds interesting :). I will take a look into this soon.
@Prikers I said I would work on this one, but I think this might be more suitable for you since you've worked a lot on the variable explorer recently. Besides, I don't know when I'll be able to work on this one. So if you are interested, go for it :+1:

Commit message: Variable Explorer: Fix code style issues

Patch to review:
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
index cdf2f585a05..3c55407f91a 100644
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -108,7 +108,7 @@ def reset_run_code(qtbot, shell, code_editor, nsb):
     """Reset state after a run code test"""
     with qtbot.waitSignal(shell.executed):
         shell.execute('%reset -f')
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 0, timeout=EVAL_TIMEOUT)
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 0, timeout=EVAL_TIMEOUT)
     code_editor.setFocus()
     qtbot.keyClick(code_editor, Qt.Key_Home, modifier=Qt.ControlModifier)
 
@@ -618,7 +618,7 @@ def test_dedicated_consoles(main_window, qtbot):
     assert main_window.ipyconsole.filenames == ['', test_file]
     assert main_window.ipyconsole.tabwidget.tabText(1) == 'script.py/A'
     qtbot.wait(500)
-    assert nsb.editor.model.rowCount() == 4
+    assert nsb.editor.source_model.rowCount() == 4
 
     # --- Assert only runfile text is present and there's no banner text ---
     # See PR #5301
@@ -658,7 +658,7 @@ def test_connection_to_external_kernel(main_window, qtbot):
     main_window.variableexplorer._visibility_changed(True)
     nsb = main_window.variableexplorer.get_focus_widget()
     qtbot.wait(500)
-    assert nsb.editor.model.rowCount() == 0
+    assert nsb.editor.source_model.rowCount() == 0
 
     # Test with a kernel from Spyder
     spykm, spykc = start_new_kernel(spykernel=True)
@@ -673,7 +673,7 @@ def test_connection_to_external_kernel(main_window, qtbot):
     main_window.variableexplorer._visibility_changed(True)
     nsb = main_window.variableexplorer.get_focus_widget()
     qtbot.wait(500)
-    assert nsb.editor.model.rowCount() == 1
+    assert nsb.editor.source_model.rowCount() == 1
 
     # Shutdown the kernels
     spykm.shutdown_kernel(now=True)
@@ -694,7 +694,7 @@ def test_change_types_in_varexp(main_window, qtbot):
     # Edit object
     main_window.variableexplorer._visibility_changed(True)
     nsb = main_window.variableexplorer.get_focus_widget()
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() > 0, timeout=EVAL_TIMEOUT)
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() > 0, timeout=EVAL_TIMEOUT)
     nsb.editor.setFocus()
     nsb.editor.edit_item()
 
@@ -790,7 +790,7 @@ def test_run_cython_code(main_window, qtbot):
     nsb = main_window.variableexplorer.get_focus_widget()
 
     # Wait until an object appears
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 1,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 1,
                     timeout=COMPILE_AND_EVAL_TIMEOUT)
 
     # Verify result
@@ -809,7 +809,7 @@ def test_run_cython_code(main_window, qtbot):
     qtbot.keyClick(code_editor, Qt.Key_F5)
 
     # Wait until all objects have appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 1,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 1,
                     timeout=COMPILE_AND_EVAL_TIMEOUT)
 
     # Verify result
@@ -936,7 +936,7 @@ def test_run_code(main_window, qtbot, tmpdir):
     qtbot.keyClick(code_editor, Qt.Key_F5)
 
     # Wait until all objects have appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 4,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 4,
                     timeout=EVAL_TIMEOUT)
 
     # Verify result
@@ -954,7 +954,7 @@ def test_run_code(main_window, qtbot, tmpdir):
         qtbot.wait(200)
 
     # Wait until all objects have appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 4,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 4,
                     timeout=EVAL_TIMEOUT)
 
     # Verify result
@@ -976,7 +976,7 @@ def test_run_code(main_window, qtbot, tmpdir):
     assert 'Error:' not in shell._control.toPlainText()
 
     # Wait until all objects have appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 4,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 4,
                     timeout=EVAL_TIMEOUT)
 
     # Verify result
@@ -993,7 +993,7 @@ def test_run_code(main_window, qtbot, tmpdir):
     qtbot.keyClick(code_editor, Qt.Key_Return, modifier=Qt.ControlModifier)
 
     # Wait until the object has appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 1,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 1,
                     timeout=EVAL_TIMEOUT)
 
     # Verify result
@@ -1002,7 +1002,7 @@ def test_run_code(main_window, qtbot, tmpdir):
     # Press Ctrl+Enter a second time to verify that we're *not* advancing
     # to the next cell
     qtbot.keyClick(code_editor, Qt.Key_Return, modifier=Qt.ControlModifier)
-    assert nsb.editor.model.rowCount() == 1
+    assert nsb.editor.source_model.rowCount() == 1
 
     reset_run_code(qtbot, shell, code_editor, nsb)
 
@@ -1013,7 +1013,7 @@ def test_run_code(main_window, qtbot, tmpdir):
     qtbot.keyClick(code_editor, Qt.Key_Return, modifier=Qt.ShiftModifier)
 
     # Wait until objects have appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 2,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 2,
                     timeout=EVAL_TIMEOUT)
 
     # Clean namespace
@@ -1021,14 +1021,14 @@ def test_run_code(main_window, qtbot, tmpdir):
         shell.execute('%reset -f')
 
     # Wait until there are no objects in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 0,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 0,
                     timeout=EVAL_TIMEOUT)
 
     # Re-run last cell
     qtbot.keyClick(code_editor, Qt.Key_Return, modifier=Qt.AltModifier)
 
     # Wait until the object has appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 1,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 1,
                     timeout=EVAL_TIMEOUT)
     assert shell.get_value('li') == [1, 2, 3]
 
@@ -1078,7 +1078,7 @@ def test_run_cell_copy(main_window, qtbot, tmpdir):
     assert 'Error:' not in shell._control.toPlainText()
 
     # Wait until all objects have appeared in the variable explorer
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 4,
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 4,
                     timeout=EVAL_TIMEOUT)
 
     # Verify result
@@ -1182,7 +1182,7 @@ def test_issue_4066(main_window, qtbot):
 
     # Open editor associated with that object and get a reference to it
     nsb = main_window.variableexplorer.get_focus_widget()
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() > 0, timeout=EVAL_TIMEOUT)
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() > 0, timeout=EVAL_TIMEOUT)
     nsb.editor.setFocus()
     nsb.editor.edit_item()
     obj_editor_id = list(nsb.editor.delegate._editors.keys())[0]
@@ -1192,7 +1192,7 @@ def test_issue_4066(main_window, qtbot):
     main_window.ipyconsole.get_focus_widget().setFocus()
     with qtbot.waitSignal(shell.executed):
         shell.execute('del myobj')
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() == 0, timeout=EVAL_TIMEOUT)
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 0, timeout=EVAL_TIMEOUT)
 
     # Close editor
     ok_widget = obj_editor.btn_close
@@ -1223,7 +1223,7 @@ def test_varexp_edit_inline(main_window, qtbot):
     # Edit object
     main_window.variableexplorer._visibility_changed(True)
     nsb = main_window.variableexplorer.get_focus_widget()
-    qtbot.waitUntil(lambda: nsb.editor.model.rowCount() > 0, timeout=EVAL_TIMEOUT)
+    qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() > 0, timeout=EVAL_TIMEOUT)
     nsb.editor.setFocus()
     nsb.editor.edit_item()
 
@@ -1268,13 +1268,13 @@ def test_c_and_n_pdb_commands(main_window, qtbot):
     qtbot.keyClicks(control, 'c')
     qtbot.keyClick(control, Qt.Key_Enter)
     qtbot.wait(500)
-    assert nsb.editor.model.rowCount() == 1
+    assert nsb.editor.source_model.rowCount() == 1
 
     # Verify that n works
     qtbot.keyClicks(control, 'n')
     qtbot.keyClick(control, Qt.Key_Enter)
     qtbot.wait(500)
-    assert nsb.editor.model.rowCount() == 2
+    assert nsb.editor.source_model.rowCount() == 2
 
     # Verify that doesn't go to sitecustomize.py with next and stops
     # the debugging session.
@@ -1286,7 +1286,7 @@ def test_c_and_n_pdb_commands(main_window, qtbot):
     qtbot.keyClick(control, Qt.Key_Enter)
     qtbot.wait(500)
 
-    assert nsb.editor.model.rowCount() == 3
+    assert nsb.editor.source_model.rowCount() == 3
 
     qtbot.keyClicks(control, 'n')
     qtbot.keyClick(control, Qt.Key_Enter)
@@ -1344,7 +1344,7 @@ def test_stop_dbg(main_window, qtbot):
     qtbot.wait(1000)
 
     # Assert that there is only one entry in the Variable Explorer
-    assert nsb.editor.model.rowCount() == 1
+    assert nsb.editor.source_model.rowCount() == 1
 
     # Remove breakpoint and close test file
     main_window.editor.clear_all_breakpoints()
diff --git a/spyder/plugins/variableexplorer/widgets/collectionseditor.py b/spyder/plugins/variableexplorer/widgets/collectionseditor.py
index 0cb0ecc6c9a..78bffa92d07 100644
--- a/spyder/plugins/variableexplorer/widgets/collectionseditor.py
+++ b/spyder/plugins/variableexplorer/widgets/collectionseditor.py
@@ -22,6 +22,7 @@
 from __future__ import print_function
 import datetime
 import gc
+import re
 import sys
 import warnings
 
@@ -31,7 +32,8 @@
 from qtpy.QtCore import (QAbstractTableModel, QDateTime, QModelIndex, Qt,
                          Signal, Slot)
 from qtpy.QtGui import QColor, QKeySequence
-from qtpy.QtWidgets import (QAbstractItemDelegate, QApplication, QDateEdit,
+from qtpy.QtWidgets import (QAbstractItemDelegate, QAbstractItemView,
+                            QApplication, QDateEdit,
                             QDateTimeEdit, QDialog, QHBoxLayout, QHeaderView,
                             QInputDialog, QItemDelegate, QLineEdit, QMenu,
                             QMessageBox, QPushButton, QTableView,
@@ -54,8 +56,10 @@
 from spyder.utils.misc import getcwd_or_home
 from spyder.utils.qthelpers import (add_actions, create_action,
                                     mimedata2url)
+from spyder.utils.stringmatching import get_search_scores
 from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard
 from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor
+from spyder.widgets.helperwidgets import CustomSortFilterProxy
 from spyder.plugins.variableexplorer.widgets.objectexplorer import (
     ObjectExplorer)
 
@@ -129,6 +133,8 @@ def __init__(self, parent, data, title="", names=False,
         QAbstractTableModel.__init__(self, parent)
         if data is None:
             data = {}
+        self._parent = parent
+        self.scores = []
         self.names = names
         self.minmax = minmax
         self.dataframe_format = dataframe_format
@@ -146,7 +152,12 @@ def __init__(self, parent, data, title="", names=False,
         self.sizes = []
         self.types = []
         self.set_data(data)
-        
+
+    def current_index(self):
+        """Get the currently selected index in the parent table view."""
+        idx = self._parent.proxy_model.mapToSource(self._parent.currentIndex())
+        return idx
+
     def get_data(self):
         """Return model data"""
         return self._data
@@ -198,6 +209,10 @@ def set_data(self, data, coll_filter=None):
             self.rows_loaded = self.total_rows
         self.sig_setting_data.emit()
         self.set_size_and_type()
+        if len(self.keys):
+            # Needed to update search scores when
+            # adding values to the namespace
+            self.update_search_letters()
         self.reset()
 
     def set_size_and_type(self, start=None, stop=None):
@@ -260,7 +275,7 @@ def sort(self, column, order=Qt.AscendingOrder):
                 self.sizes.sort(reverse=reverse)
             except:
                 pass
-        elif column == 3:
+        elif column in [3, 4]:
             values = [self._data[key] for key in self.keys]
             self.keys = sort_against(self.keys, values, reverse)
             self.sizes = sort_against(self.sizes, values, reverse)
@@ -270,7 +285,7 @@ def sort(self, column, order=Qt.AscendingOrder):
 
     def columnCount(self, qindex=QModelIndex()):
         """Array column number"""
-        return 4
+        return 5
 
     def rowCount(self, index=QModelIndex()):
         """Array row number"""
@@ -329,11 +344,30 @@ def get_bgcolor(self, index):
             color.setAlphaF(.3)
         return color
 
+    def update_search_letters(self, text=""):
+        """Update search letters with text input in search box."""
+        self.letters = text
+        names = [str(key) for key in self.keys]
+        results = get_search_scores(text, names, template='<b>{0}</b>')
+        self.normal_text, _, self.scores = zip(*results)
+        self.reset()
+
+    def row(self, row_num):
+        """Get row based on model index. Needed for the custom proxy model."""
+        return self.keys[row_num]
+
     def data(self, index, role=Qt.DisplayRole):
         """Cell content"""
         if not index.isValid():
             return to_qvariant()
         value = self.get_value(index)
+        if index.column() == 4 and role == Qt.DisplayRole:
+            # TODO: Check the effect of not hidding the column
+            # Treating search scores as a table column simplifies the
+            # sorting once a score for a specific string in the finder
+            # has been defined. This column however should always remain
+            # hidden.
+            return to_qvariant(self.scores[index.row()])
         if index.column() == 3 and self.remote:
             value = value['view']
         if index.column() == 3:
@@ -367,7 +401,8 @@ def headerData(self, section, orientation, role=Qt.DisplayRole):
             return to_qvariant()
         i_column = int(section)
         if orientation == Qt.Horizontal:
-            headers = (self.header0, _("Type"), _("Size"), _("Value"))
+            headers = (self.header0, _("Type"), _("Size"), _("Value"),
+                       _("Score"))
             return to_qvariant( headers[i_column] )
         else:
             return to_qvariant()
@@ -430,14 +465,16 @@ class CollectionsDelegate(QItemDelegate):
     def __init__(self, parent=None):
         QItemDelegate.__init__(self, parent)
         self._editors = {} # keep references on opened editors
-        
+
     def get_value(self, index):
         if index.isValid():
-            return index.model().get_value(index)
-    
+            source_index = index.model().mapToSource(index)
+            return source_index.model().get_value(index)
+
     def set_value(self, index, value):
         if index.isValid():
-            index.model().set_value(index, value)
+            source_index = index.model().mapToSource(index)
+            source_index.model().set_value(index, value)
 
     def show_warning(self, index):
         """
@@ -447,14 +484,15 @@ def show_warning(self, index):
         This avoids getting the variables' value to know its
         size and type, using instead those already computed by
         the TableModel.
-        
+
         The problem is when a variable is too big, it can take a
         lot of time just to get its value
         """
         try:
-            val_size = index.model().sizes[index.row()]
-            val_type = index.model().types[index.row()]
-        except:
+            source_index = index.model().mapToSource(index)
+            val_size = source_index.model().sizes[source_index.row()]
+            val_type = source_index.model().types[source_index.row()]
+        except Exception:
             return False
         if val_type in ['list', 'set', 'tuple', 'dict'] and \
                 int(val_size) > 1e5:
@@ -467,10 +505,11 @@ def createEditor(self, parent, option, index, object_explorer=False):
         if index.column() < 3:
             return None
         if self.show_warning(index):
-            answer = QMessageBox.warning(self.parent(), _("Warning"),
-                                      _("Opening this variable can be slow\n\n"
-                                        "Do you want to continue anyway?"),
-                                      QMessageBox.Yes | QMessageBox.No)
+            answer = QMessageBox.warning(
+                self.parent(), _("Warning"),
+                _("Opening this variable can be slow\n\n"
+                  "Do you want to continue anyway?"),
+                QMessageBox.Yes | QMessageBox.No)
             if answer == QMessageBox.No:
                 return None
         try:
@@ -478,14 +517,15 @@ def createEditor(self, parent, option, index, object_explorer=False):
             if value is None:
                 return None
         except Exception as msg:
-            QMessageBox.critical(self.parent(), _("Error"),
-                                 _("Spyder was unable to retrieve the value of "
-                                   "this variable from the console.<br><br>"
-                                   "The error mesage was:<br>"
-                                   "<i>%s</i>"
-                                   ) % to_text_string(msg))
+            QMessageBox.critical(
+                self.parent(), _("Error"),
+                _("Spyder was unable to retrieve the value of "
+                  "this variable from the console.<br><br>"
+                  "The error mesage was:<br>"
+                  "<i>%s</i>") % to_text_string(msg))
             return
-        key = index.model().get_key(index)
+        source_index = index.model().mapToSource(index)
+        key = source_index.model().get_key(source_index)
         readonly = (isinstance(value, (tuple, set)) or self.parent().readonly
                     or not is_known_type(value))
         # CollectionsEditor for a list, tuple, dict, etc.
@@ -493,7 +533,8 @@ def createEditor(self, parent, option, index, object_explorer=False):
             editor = CollectionsEditor(parent=parent)
             editor.setup(value, key, icon=self.parent().windowIcon(),
                          readonly=readonly)
-            self.create_dialog(editor, dict(model=index.model(), editor=editor,
+            self.create_dialog(editor, dict(model=source_index.model(),
+                                            editor=editor,
                                             key=key, readonly=readonly))
             return None
         # ArrayEditor for a Numpy array
@@ -502,7 +543,8 @@ def createEditor(self, parent, option, index, object_explorer=False):
             editor = ArrayEditor(parent=parent)
             if not editor.setup_and_check(value, title=key, readonly=readonly):
                 return
-            self.create_dialog(editor, dict(model=index.model(), editor=editor,
+            self.create_dialog(editor, dict(model=source_index.model(),
+                                            editor=editor,
                                             key=key, readonly=readonly))
             return None
         # ArrayEditor for an images
@@ -513,7 +555,8 @@ def createEditor(self, parent, option, index, object_explorer=False):
             if not editor.setup_and_check(arr, title=key, readonly=readonly):
                 return
             conv_func = lambda arr: Image.fromarray(arr, mode=value.mode)
-            self.create_dialog(editor, dict(model=index.model(), editor=editor,
+            self.create_dialog(editor, dict(model=source_index.model(),
+                                            editor=editor,
                                             key=key, readonly=readonly,
                                             conv=conv_func))
             return None
@@ -523,9 +566,11 @@ def createEditor(self, parent, option, index, object_explorer=False):
             editor = DataFrameEditor(parent=parent)
             if not editor.setup_and_check(value, title=key):
                 return
-            editor.dataModel.set_format(index.model().dataframe_format)
+            editor.dataModel.set_format(
+                index.model().sourceModel().dataframe_format)
             editor.sig_option_changed.connect(self.change_option)
-            self.create_dialog(editor, dict(model=index.model(), editor=editor,
+            self.create_dialog(editor, dict(model=source_index.model(),
+                                            editor=editor,
                                             key=key, readonly=readonly))
             return None
         # QDateEdit and QDateTimeEdit for a dates or datetime respectively
@@ -546,9 +591,10 @@ def createEditor(self, parent, option, index, object_explorer=False):
             if te.setup_and_check(value):
                 editor = TextEditor(value, key,
                                     readonly=readonly, parent=parent)
-                self.create_dialog(editor, dict(model=index.model(),
-                                                editor=editor, key=key,
-                                                readonly=readonly))
+                self.create_dialog(editor,
+                                   dict(model=source_index.model(),
+                                        editor=editor, key=key,
+                                        readonly=readonly))
             return None
         # QLineEdit for an individual value (int, float, short string, etc)
         elif is_editable_type(value) and not object_explorer:
@@ -566,15 +612,16 @@ def createEditor(self, parent, option, index, object_explorer=False):
                 return editor
         # ObjectExplorer for an arbitrary Python object
         else:
-            show_callable_attributes = index.model().show_callable_attributes
-            show_special_attributes = index.model().show_special_attributes
+            show_callable_attributes = (
+                source_index.model().show_callable_attributes)
+            show_special_attributes = (
+                source_index.model().show_special_attributes)
 
             if show_callable_attributes is None:
                 show_callable_attributes = False
             if show_special_attributes is None:
                 show_special_attributes = False
 
-            key = index.model().keys[index.row()]
             editor = ObjectExplorer(
                 value,
                 name=key,
@@ -582,7 +629,7 @@ def createEditor(self, parent, option, index, object_explorer=False):
                 show_callable_attributes=show_callable_attributes,
                 show_special_attributes=show_special_attributes)
             editor.sig_option_changed.connect(self.change_option)
-            self.create_dialog(editor, dict(model=index.model(),
+            self.create_dialog(editor, dict(model=source_index.model(),
                                             editor=editor,
                                             key=key, readonly=readonly))
             return None
@@ -673,15 +720,17 @@ def setEditorData(self, editor, index):
         elif isinstance(editor, QDateTimeEdit):
             editor.setDateTime(QDateTime(value.date(), value.time()))
 
+
     def setModelData(self, editor, model, index):
         """
         Overriding method setModelData
         Editor --> Model
         """
+        model = model.sourceModel()
         if not hasattr(model, "set_value"):
             # Read-only mode
             return
-        
+
         if isinstance(editor, QLineEdit):
             value = editor.text()
             try:
@@ -697,14 +746,14 @@ def setModelData(self, editor, model, index):
                 return
         elif isinstance(editor, QDateEdit):
             qdate = editor.date()
-            value = datetime.date( qdate.year(), qdate.month(), qdate.day() )
+            value = datetime.date(qdate.year(), qdate.month(), qdate.day())
         elif isinstance(editor, QDateTimeEdit):
             qdatetime = editor.dateTime()
             qdate = qdatetime.date()
             qtime = qdatetime.time()
-            value = datetime.datetime( qdate.year(), qdate.month(),
-                                       qdate.day(), qtime.hour(),
-                                       qtime.minute(), qtime.second() )
+            value = datetime.datetime(qdate.year(), qdate.month(),
+                                      qdate.day(), qtime.hour(),
+                                      qtime.minute(), qtime.second())
         else:
             # Should not happen...
             raise RuntimeError("Unsupported editor widget")
@@ -761,6 +810,7 @@ def __init__(self, parent):
         self.minmax_action = None
         self.rename_action = None
         self.duplicate_action = None
+        self.last_regex = ''
         self.view_action = None
         self.delegate = None
         self.setAcceptDrops(True)
@@ -775,7 +825,7 @@ def setup_table(self):
         self.adjust_columns()
         # Sorting columns
         self.setSortingEnabled(True)
-        self.sortByColumn(0, Qt.AscendingOrder)
+        self.sortByColumn(4, Qt.DescendingOrder)
     
     def setup_menu(self, minmax):
         """Setup context menu"""
@@ -852,7 +902,44 @@ def setup_menu(self, minmax):
                      None, resize_action, resize_columns_action])
         return menu
     
-    #------ Remote/local API --------------------------------------------------
+    # ------ Remote/local API -------------------------------------------------
+    def selection(self, index):
+        """Update selected row."""
+        self.update()
+        self.isActiveWindow()
+
+    def set_regex(self, regex=None, reset=False):
+        """Update the regex text for the shortcut finder."""
+        if reset:
+            text = ''
+        else:
+            text = self.finder.text().replace(' ', '').lower()
+
+        self.proxy_model.set_filter(text)
+        self.source_model.update_search_letters(text)
+        # TODO: Use constants for column numbers
+        self.sortByColumn(4, Qt.DescendingOrder)  # Col 4 for score
+
+        if self.last_regex != regex:
+            self.selectRow(0)
+        self.last_regex = regex
+
+    def next_row(self):
+        """Move to next row from currently selected row."""
+        row = self.currentIndex().row()
+        rows = self.proxy_model.rowCount()
+        if row + 1 == rows:
+            row = -1
+        self.selectRow(row + 1)
+
+    def previous_row(self):
+        """Move to previous row from currently selected row."""
+        row = self.currentIndex().row()
+        rows = self.proxy_model.rowCount()
+        if row == 0:
+            row = rows
+        self.selectRow(row - 1)
+
     def remove_values(self, keys):
         """Remove values from data"""
         raise NotImplementedError
@@ -920,7 +1007,7 @@ def refresh_menu(self):
         
     def refresh_plot_entries(self, index):
         if index.isValid():
-            key = self.model.get_key(index)
+            key = self.source_model.get_key(index)
             is_list = self.is_list(key)
             is_array = self.is_array(key) and self.get_len(key) != 0
             condition_plot = (is_array and len(self.get_array_shape(key)) <= 2)
@@ -953,8 +1040,8 @@ def adjust_columns(self):
     def set_data(self, data):
         """Set table data"""
         if data is not None:
-            self.model.set_data(data, self.dictfilter)
-            self.sortByColumn(0, Qt.AscendingOrder)
+            self.source_model.set_data(data, self.dictfilter)
+            self.sortByColumn(4, Qt.DescendingOrder)
 
     def mousePressEvent(self, event):
         """Reimplement Qt method"""
@@ -998,7 +1085,7 @@ def keyPressEvent(self, event):
         
     def contextMenuEvent(self, event):
         """Reimplement Qt method"""
-        if self.model.showndata:
+        if self.source_model.showndata:
             self.refresh_menu()
             self.menu.popup(event.globalPos())
             event.accept()
@@ -1047,7 +1134,7 @@ def toggle_show_special_attributes(self, state):
     def toggle_minmax(self, state):
         """Toggle min/max display for numpy arrays"""
         self.sig_option_changed.emit('minmax', state)
-        self.model.minmax = state
+        self.source_model.minmax = state
 
     @Slot(str)
     def set_dataframe_format(self, new_format):
@@ -1058,7 +1145,7 @@ def set_dataframe_format(self, new_format):
             new_format (string): e.g. "%.3f"
         """
         self.sig_option_changed.emit('dataframe_format', new_format)
-        self.model.dataframe_format = new_format
+        self.source_model.dataframe_format = new_format
 
     @Slot()
     def edit_item(self):
@@ -1085,7 +1172,7 @@ def remove_item(self):
                                       QMessageBox.Yes | QMessageBox.No)
         if answer == QMessageBox.Yes:
             idx_rows = unsorted_unique([idx.row() for idx in indexes])
-            keys = [ self.model.keys[idx_row] for idx_row in idx_rows ]
+            keys = [self.source_model.keys[idx_row] for idx_row in idx_rows]
             self.remove_values(keys)
 
     def copy_item(self, erase_original=False):
@@ -1096,14 +1183,14 @@ def copy_item(self, erase_original=False):
         idx_rows = unsorted_unique([idx.row() for idx in indexes])
         if len(idx_rows) > 1 or not indexes[0].isValid():
             return
-        orig_key = self.model.keys[idx_rows[0]]
+        orig_key = self.source_model.keys[idx_rows[0]]
         if erase_original:
             title = _('Rename')
             field_text = _('New variable name:')
         else:
             title = _('Duplicate')
             field_text = _('Variable name:')
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         if isinstance(data, (list, set)):
             new_key, valid = len(data), True
         else:
@@ -1132,10 +1219,10 @@ def insert_item(self):
         """Insert item"""
         index = self.currentIndex()
         if not index.isValid():
-            row = self.model.rowCount()
+            row = self.source_model.rowCount()
         else:
             row = index.row()
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         if isinstance(data, list):
             key = row
             data.insert(row, '')
@@ -1182,7 +1269,7 @@ def plot_item(self, funcname):
         """Plot item"""
         index = self.currentIndex()
         if self.__prepare_plot():
-            key = self.model.get_key(index)
+            key = self.source_model.get_key(index)
             try:
                 self.plot(key, funcname)
             except (ValueError, TypeError) as error:
@@ -1196,7 +1283,7 @@ def imshow_item(self):
         """Imshow item"""
         index = self.currentIndex()
         if self.__prepare_plot():
-            key = self.model.get_key(index)
+            key = self.source_model.get_key(index)
             try:
                 if self.is_image(key):
                     self.show_image(key)
@@ -1281,7 +1368,7 @@ def copy(self):
 
     def import_from_string(self, text, title=None):
         """Import data from string"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         # Check if data is a dict
         if not hasattr(data, "keys"):
             return
@@ -1316,11 +1403,25 @@ def __init__(self, parent, data, readonly=False, title="",
         self.readonly = readonly or isinstance(data, (tuple, set))
         CollectionsModelClass = ReadOnlyCollectionsModel if self.readonly \
                                 else CollectionsModel
-        self.model = CollectionsModelClass(self, data, title, names=names,
-                                           minmax=minmax)
-        self.setModel(self.model)
+        self.source_model = CollectionsModelClass(self, data, title,
+                                                  names=names,
+                                                  minmax=minmax)
+        self.proxy_model = CollectionsCustomSortFilterProxy(self)
+
+        self.proxy_model.setSourceModel(self.source_model)
+        self.proxy_model.setDynamicSortFilter(True)
+        self.proxy_model.setFilterKeyColumn(0)  # Col 0 for Name
+        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
+        self.setModel(self.proxy_model)
+
+        self.hideColumn(4)  # Column 4 for Score
+
         self.delegate = CollectionsDelegate(self)
         self.setItemDelegate(self.delegate)
+        self.setSelectionBehavior(QAbstractItemView.SelectItems)
+        self.setSelectionMode(QAbstractItemView.SingleSelection)
+        self.setSortingEnabled(True)
+        self.selectionModel().selectionChanged.connect(self.selection)
 
         self.setup_table()
         self.menu = self.setup_menu(minmax)
@@ -1331,14 +1432,14 @@ def __init__(self, parent, data, readonly=False, title="",
     #------ Remote/local API --------------------------------------------------
     def remove_values(self, keys):
         """Remove values from data"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         for key in sorted(keys, reverse=True):
             data.pop(key)
             self.set_data(data)
 
     def copy_value(self, orig_key, new_key):
         """Copy value"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         if isinstance(data, list):
             data.append(data[orig_key])
         if isinstance(data, set):
@@ -1349,60 +1450,60 @@ def copy_value(self, orig_key, new_key):
     
     def new_value(self, key, value):
         """Create new value in data"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         data[key] = value
         self.set_data(data)
         
     def is_list(self, key):
         """Return True if variable is a list or a tuple"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return isinstance(data[key], (tuple, list))
 
     def is_set(self, key):
         """Return True if variable is a set"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return isinstance(data[key], set)
 
     def get_len(self, key):
         """Return sequence length"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return len(data[key])
         
     def is_array(self, key):
         """Return True if variable is a numpy array"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return isinstance(data[key], (ndarray, MaskedArray))
         
     def is_image(self, key):
         """Return True if variable is a PIL.Image image"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return isinstance(data[key], Image)
     
     def is_dict(self, key):
         """Return True if variable is a dictionary"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return isinstance(data[key], dict)
         
     def get_array_shape(self, key):
         """Return array's shape"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return data[key].shape
         
     def get_array_ndim(self, key):
         """Return array's ndim"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         return data[key].ndim
 
     def oedit(self, key):
         """Edit item"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         from spyder.plugins.variableexplorer.widgets.objecteditor import (
                 oedit)
         oedit(data[key])
 
     def plot(self, key, funcname):
         """Plot item"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         import spyder.pyplot as plt
         plt.figure()
         getattr(plt, funcname)(data[key])
@@ -1410,7 +1511,7 @@ def plot(self, key, funcname):
     
     def imshow(self, key):
         """Show item's image"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         import spyder.pyplot as plt
         plt.figure()
         plt.imshow(data[key])
@@ -1418,13 +1519,13 @@ def imshow(self, key):
             
     def show_image(self, key):
         """Show image (item is a PIL image)"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         data[key].show()
     #--------------------------------------------------------------------------
 
     def refresh_menu(self):
         """Refresh context menu"""
-        data = self.model.get_data()
+        data = self.source_model.get_data()
         index = self.currentIndex()
         condition = (not isinstance(data, (tuple, set))) and index.isValid() \
                     and not self.readonly
@@ -1460,7 +1561,7 @@ def set_data(self, data):
         
     def get_title(self):
         """Get model title"""
-        return self.editor.model.title
+        return self.editor.source_model.title
 
 
 class CollectionsEditor(QDialog):
@@ -1510,7 +1611,7 @@ def setup(self, data, title='', readonly=False, width=650, remote=False,
         self.widget = CollectionsEditorWidget(self, self.data_copy,
                                               title=title, readonly=readonly,
                                               remote=remote)
-        self.widget.editor.model.sig_setting_data.connect(
+        self.widget.editor.source_model.sig_setting_data.connect(
                                                     self.save_and_close_enable)
         layout = QVBoxLayout()
         layout.addWidget(self.widget)
@@ -1576,12 +1677,14 @@ def __init__(self, parent=None):
 
     def get_value(self, index):
         if index.isValid():
-            name = index.model().keys[index.row()]
+            source_index = index.model().mapToSource(index)
+            name = source_index.model().keys[source_index.row()]
             return self.parent().get_value(name)
-    
+
     def set_value(self, index, value):
         if index.isValid():
-            name = index.model().keys[index.row()]
+            source_index = index.model().mapToSource(index)
+            name = source_index.model().keys[source_index.row()]
             self.parent().new_value(name, value)
 
 
@@ -1597,26 +1700,39 @@ def __init__(self, parent, data, minmax=False, shellwidget=None,
         self.var_properties = {}
 
         self.dictfilter = None
-        self.model = None
+        self.source_model = None
         self.delegate = None
         self.readonly = False
-        self.model = CollectionsModel(
+        self.source_model = CollectionsModel(
             self, data, names=True,
             minmax=minmax,
             dataframe_format=dataframe_format,
             show_callable_attributes=show_callable_attributes,
             show_special_attributes=show_special_attributes,
             remote=True)
-        self.setModel(self.model)
+
+        self.proxy_model = CollectionsCustomSortFilterProxy(self)
+
+        self.proxy_model.setSourceModel(self.source_model)
+        self.proxy_model.setDynamicSortFilter(True)
+        self.proxy_model.setFilterKeyColumn(0)  # Col 0 for Name
+        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
+        self.setModel(self.proxy_model)
+
+        self.hideColumn(4)  # Column 4 for Score
 
         self.delegate = RemoteCollectionsDelegate(self)
         self.delegate.sig_free_memory.connect(self.sig_free_memory.emit)
         self.setItemDelegate(self.delegate)
+        self.setSelectionBehavior(QAbstractItemView.SelectItems)
+        self.setSelectionMode(QAbstractItemView.SingleSelection)
+        self.setSortingEnabled(True)
+        self.selectionModel().selectionChanged.connect(self.selection)
 
         self.setup_table()
         self.menu = self.setup_menu(minmax)
 
-    #------ Remote/local API --------------------------------------------------
+    # ------ Remote/local API -------------------------------------------------
     def get_value(self, name):
         """Get the value of a variable"""
         value = self.shellwidget.get_value(name)
@@ -1726,6 +1842,28 @@ def setup_menu(self, minmax):
         return menu
 
 
+class CollectionsCustomSortFilterProxy(CustomSortFilterProxy):
+    """
+    Custom column filter based on regex and model data.
+
+    Reimplements the filterAcceptsRow to follow NamespaceBrowser model.
+    """
+    def filterAcceptsRow(self, row_num, parent):
+        """
+        Qt override.
+
+        Reimplemented from base class to allow the use of custom filtering.
+        """
+        model = self.sourceModel()
+        name = to_text_string(model.row(row_num))
+        r = re.search(self.pattern, name)
+
+        if r is None:
+            return False
+        else:
+            return True
+
+
 # =============================================================================
 # Tests
 # =============================================================================
diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
index b632a9b98f3..00307455aa6 100644
--- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
+++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
@@ -18,7 +18,8 @@
 from qtpy.QtCore import Qt, Signal, Slot
 from qtpy.QtGui import QCursor
 from qtpy.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, QMenu,
-                            QMessageBox, QToolButton, QVBoxLayout, QWidget)
+                            QMessageBox, QLabel, QToolButton, QVBoxLayout,
+                            QWidget)
 
 from spyder_kernels.utils.iofuncs import iofunctions
 from spyder_kernels.utils.misc import fix_reference_name
@@ -38,6 +39,7 @@
 from spyder.plugins.variableexplorer.widgets.collectionseditor import (
     RemoteCollectionsEditorTableView)
 from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard
+from spyder.widgets.helperwidgets import FinderLineEdit
 
 
 SUPPORTED_TYPES = get_supported_types()
@@ -146,6 +148,17 @@ def setup(self, check_all=None, exclude_private=None,
         # Setup layout.
 
         layout = create_plugin_layout(self.tools_layout, self.editor)
+
+        # Fuzzy search layout
+        finder_layout = QHBoxLayout()
+        label_finder = QLabel(_("Search: "))
+        text_finder = NamespacesBrowserFinder(self.editor,
+                                              self.editor.set_regex)
+        self.editor.finder = text_finder
+        finder_layout.addWidget(label_finder)
+        finder_layout.addWidget(text_finder)
+        layout.addLayout(finder_layout)
+
         self.setLayout(layout)
 
         self.sig_option_changed.connect(self.option_changed)
@@ -274,7 +287,7 @@ def set_var_properties(self, properties):
 
     def set_data(self, data):
         """Set data."""
-        if data != self.editor.model.get_data():
+        if data != self.editor.source_model.get_data():
             self.editor.set_data(data)
             self.editor.adjust_columns()
         
@@ -392,3 +405,20 @@ def save_data(self, filename=None):
                     '<br><br>Error message:<br>') + error_message
             QMessageBox.critical(self, _("Save data"), save_data_message)
         self.save_button.setEnabled(self.filename is not None)
+
+
+class NamespacesBrowserFinder(FinderLineEdit):
+    """Textbox for filtering listed variables in the table."""
+
+    def keyPressEvent(self, event):
+        """Qt and FilterLineEdit Override."""
+        key = event.key()
+        if key in [Qt.Key_Up]:
+            self._parent.previous_row()
+        elif key in [Qt.Key_Down]:
+            self._parent.next_row()
+        elif key in [Qt.Key_Enter, Qt.Key_Return]:
+            # TODO: Check if an editor needs to be shown
+            pass
+        else:
+            super(NamespacesBrowserFinder, self).keyPressEvent(event)
diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_collectioneditor.py b/spyder/plugins/variableexplorer/widgets/tests/test_collectioneditor.py
index 1affb95ad23..77e38cd6e46 100644
--- a/spyder/plugins/variableexplorer/widgets/tests/test_collectioneditor.py
+++ b/spyder/plugins/variableexplorer/widgets/tests/test_collectioneditor.py
@@ -47,7 +47,7 @@
 # Utility functions
 # =============================================================================
 def data(cm, i, j):
-    return cm.data(cm.createIndex(i, j))
+    return cm.data(cm.index(i, j))
 
 
 def data_table(cm, n_rows, n_cols):
@@ -79,7 +79,7 @@ def test_create_dataframeeditor_with_correct_format(qtbot, monkeypatch):
     editor = CollectionsEditorTableView(None, {'df': df})
     qtbot.addWidget(editor)
     editor.set_dataframe_format('%10d')
-    editor.delegate.createEditor(None, None, editor.model.createIndex(0, 3))
+    editor.delegate.createEditor(None, None, editor.model().index(0, 3))
     mockDataFrameEditor_instance.dataModel.set_format.assert_called_once_with('%10d')
 
 def test_accept_sig_option_changed_from_dataframeeditor(qtbot, monkeypatch):
@@ -87,20 +87,21 @@ def test_accept_sig_option_changed_from_dataframeeditor(qtbot, monkeypatch):
     editor = CollectionsEditorTableView(None, {'df': df})
     qtbot.addWidget(editor)
     editor.set_dataframe_format('%10d')
-    assert editor.model.dataframe_format == '%10d'
-    editor.delegate.createEditor(None, None, editor.model.createIndex(0, 3))
+    assert editor.source_model.dataframe_format == '%10d'
+    editor.delegate.createEditor(None, None, editor.model().index(0, 3))
     dataframe_editor = next(iter(editor.delegate._editors.values()))['editor']
     qtbot.addWidget(dataframe_editor)
     dataframe_editor.sig_option_changed.emit('dataframe_format', '%5f')
-    assert editor.model.dataframe_format == '%5f'
+    assert editor.source_model.dataframe_format == '%5f'
 
 def test_collectionsmodel_with_two_ints():
     coll = {'x': 1, 'y': 2}
     cm = CollectionsModel(None, coll)
     assert cm.rowCount() == 2
-    assert cm.columnCount() == 4
+    assert cm.columnCount() == 5
     # dict is unordered, so first row might be x or y
-    assert data(cm, 0, 0) in {'x', 'y'}
+    assert data(cm, 0, 0) in {'x',
+                              'y'}
     if data(cm, 0, 0) == 'x':
         row_with_x = 0
         row_with_y = 1
@@ -138,7 +139,7 @@ def test_shows_dataframeeditor_when_editing_index(qtbot, monkeypatch):
         coll = {'rng': rng}
         editor = CollectionsEditorTableView(None, coll)
         editor.delegate.createEditor(None, None,
-                                     editor.model.createIndex(0, 3))
+                                     editor.model().index(0, 3))
         mockDataFrameEditor_instance.show.assert_called_once_with()
 
 
@@ -146,7 +147,7 @@ def test_sort_collectionsmodel():
     coll = [1, 3, 2]
     cm = CollectionsModel(None, coll)
     assert cm.rowCount() == 3
-    assert cm.columnCount() == 4
+    assert cm.columnCount() == 5
     cm.sort(0)  # sort by index
     assert data_table(cm, 3, 4) == [['0', '1', '2'],
                                     ['int', 'int', 'int'],
@@ -160,7 +161,7 @@ def test_sort_collectionsmodel():
     coll = [[1, 2], 3]
     cm = CollectionsModel(None, coll)
     assert cm.rowCount() == 2
-    assert cm.columnCount() == 4
+    assert cm.columnCount() == 5
     cm.sort(1)  # sort by type
     assert data_table(cm, 2, 4) == [['1', '0'],
                                     ['int', 'list'],
@@ -177,7 +178,7 @@ def test_sort_collectionsmodel_with_many_rows():
     coll = list(range(2*LARGE_NROWS))
     cm = CollectionsModel(None, coll)
     assert cm.rowCount() == cm.rows_loaded == ROWS_TO_LOAD
-    assert cm.columnCount() == 4
+    assert cm.columnCount() == 5
     cm.sort(1)  # This was causing an issue (#5232)
     cm.fetchMore()
     assert cm.rowCount() == 2 * ROWS_TO_LOAD
@@ -195,13 +196,13 @@ def test_rename_and_duplicate_item_in_collection_editor():
         editor = CollectionsEditorTableView(None, coll)
         assert editor.rename_action.isEnabled()
         assert editor.duplicate_action.isEnabled()
-        editor.setCurrentIndex(editor.model.createIndex(0, 0))
+        editor.setCurrentIndex(editor.source_model.index(0, 0))
         editor.refresh_menu()
         assert editor.rename_action.isEnabled() == rename_enabled
         assert editor.duplicate_action.isEnabled() == duplicate_enabled
         if isinstance(coll, list):
             editor.duplicate_item()
-            assert editor.model.get_data() == coll_copy + [coll_copy[0]]
+            assert editor.source_model.get_data() == coll_copy + [coll_copy[0]]
 
 
 def test_edit_mutable_and_immutable_types(monkeypatch):
@@ -240,31 +241,31 @@ def test_edit_mutable_and_immutable_types(monkeypatch):
 
     # Directly editable values inside list
     editor_list_value = editor_list.delegate.createEditor(
-        None, None, editor_list.model.createIndex(0, 3))
+        None, None, editor_list.model().index(0, 3))
     assert editor_list_value is not None
     assert MockQLineEdit.call_count == 1
 
     # Text Editor for long text inside list
     editor_list.delegate.createEditor(None, None,
-                                      editor_list.model.createIndex(1, 3))
+                                      editor_list.model().index(1, 3))
     assert MockTextEditor.call_count == 2
     assert not MockTextEditor.call_args[1]["readonly"]
 
     # Datetime inside list
     editor_list_datetime = editor_list.delegate.createEditor(
-        None, None, editor_list.model.createIndex(2, 3))
+        None, None, editor_list.model().index(2, 3))
     assert editor_list_datetime is not None
     assert MockQDateTimeEdit.call_count == 1
 
     # List inside list
     editor_list.delegate.createEditor(None, None,
-                                      editor_list.model.createIndex(3, 3))
+                                      editor_list.model().index(3, 3))
     assert mockCollectionsEditor_instance.show.call_count == 1
     assert not mockCollectionsEditor_instance.setup.call_args[1]["readonly"]
 
     # Tuple inside list
     editor_list.delegate.createEditor(None, None,
-                                      editor_list.model.createIndex(4, 3))
+                                      editor_list.model().index(4, 3))
     assert mockCollectionsEditor_instance.show.call_count == 2
     assert mockCollectionsEditor_instance.setup.call_args[1]["readonly"]
 
@@ -273,31 +274,31 @@ def test_edit_mutable_and_immutable_types(monkeypatch):
 
     # Directly editable values inside tuple
     editor_tup_value = editor_tup.delegate.createEditor(
-        None, None, editor_tup.model.createIndex(0, 3))
+        None, None, editor_tup.model().index(0, 3))
     assert editor_tup_value is None
     assert MockQLineEdit.call_count == 1
 
     # Text Editor for long text inside tuple
     editor_tup.delegate.createEditor(None, None,
-                                     editor_tup.model.createIndex(1, 3))
+                                     editor_tup.model().index(1, 3))
     assert MockTextEditor.call_count == 4
     assert MockTextEditor.call_args[1]["readonly"]
 
     # Datetime inside tuple
     editor_tup_datetime = editor_tup.delegate.createEditor(
-        None, None, editor_tup.model.createIndex(2, 3))
+        None, None, editor_tup.model().index(2, 3))
     assert editor_tup_datetime is None
     assert MockQDateTimeEdit.call_count == 1
 
     # List inside tuple
     editor_tup.delegate.createEditor(None, None,
-                                     editor_tup.model.createIndex(3, 3))
+                                     editor_tup.model().index(3, 3))
     assert mockCollectionsEditor_instance.show.call_count == 3
     assert mockCollectionsEditor_instance.setup.call_args[1]["readonly"]
 
     # Tuple inside tuple
     editor_tup.delegate.createEditor(None, None,
-                                     editor_tup.model.createIndex(4, 3))
+                                     editor_tup.model().index(4, 3))
     assert mockCollectionsEditor_instance.show.call_count == 4
     assert mockCollectionsEditor_instance.setup.call_args[1]["readonly"]
 
@@ -325,7 +326,7 @@ def test_notimplementederror_multiindex():
                                                             time_deltas])
     col_model = CollectionsModel(None, time_delta_multiindex)
     assert col_model.rowCount() == col_model.rows_loaded == ROWS_TO_LOAD
-    assert col_model.columnCount() == 4
+    assert col_model.columnCount() == 5
     col_model.fetchMore()
     assert col_model.rowCount() == 2 * ROWS_TO_LOAD
     for _ in range(3):
@@ -381,7 +382,7 @@ def test_editor_parent_set(monkeypatch):
                                       MockObjectExplorer,
                                       MockTextEditor]):
         col_editor.delegate.createEditor(col_editor.parent(), None,
-                                         col_editor.model.createIndex(idx, 3))
+                                         col_editor.model().index(idx, 3))
         assert mock_class.call_count == 1 + (idx // 4)
         assert mock_class.call_args[1]["parent"] is test_parent
 
@@ -451,7 +452,7 @@ def test_edit_nonsettable_objects(qtbot, nonsettable_objects_data):
         col_editor.show()
         qtbot.waitForWindowShown(col_editor)
         view = col_editor.widget.editor
-        indicies = [view.model.get_index_from_key(key) for key in keys]
+        indicies = [view.source_model.get_index_from_key(key) for key in keys]
 
         for _ in range(3):
             qtbot.keyClick(view, Qt.Key_Right)
diff --git a/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
index 7eae3a3d6a9..7f7986ca72d 100644
--- a/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
+++ b/spyder/plugins/variableexplorer/widgets/tests/test_namespacebrowser.py
@@ -25,7 +25,7 @@ def test_setup_sets_dataframe_format(qtbot):
     browser.setup(exclude_private=True, exclude_uppercase=True,
                   exclude_capitalized=True, exclude_unsupported=True,
                   minmax=False, dataframe_format='%10.5f')
-    assert browser.editor.model.dataframe_format == '%10.5f'
+    assert browser.editor.source_model.dataframe_format == '%10.5f'
 
 
 def test_automatic_column_width(qtbot):
diff --git a/spyder/preferences/shortcuts.py b/spyder/preferences/shortcuts.py
index 1294b221c80..52ab49cd6ff 100644
--- a/spyder/preferences/shortcuts.py
+++ b/spyder/preferences/shortcuts.py
@@ -13,9 +13,8 @@
 # Third party imports
 from qtpy import PYQT5
 from qtpy.compat import from_qvariant, to_qvariant
-from qtpy.QtCore import (QAbstractTableModel, QModelIndex, QRegExp,
-                         QSortFilterProxyModel, Qt, Slot, QEvent)
-from qtpy.QtGui import (QKeySequence, QRegExpValidator, QIcon)
+from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Slot, QEvent
+from qtpy.QtGui import QKeySequence, QIcon
 from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog,
                             QGridLayout, QHBoxLayout, QLabel,
                             QLineEdit, QMessageBox, QPushButton, QSpacerItem,
@@ -29,9 +28,10 @@
 from spyder.preferences.configdialog import GeneralConfigPage
 from spyder.utils import icon_manager as ima
 from spyder.utils.qthelpers import get_std_icon, create_toolbutton
-from spyder.utils.stringmatching import get_search_scores, get_search_regex
-from spyder.widgets.helperwidgets import HTMLDelegate
-from spyder.widgets.helperwidgets import HelperToolButton
+from spyder.utils.stringmatching import get_search_scores
+from spyder.widgets.helperwidgets import (CustomSortFilterProxy,
+                                          FinderLineEdit, HelperToolButton,
+                                          HTMLDelegate, VALID_FINDER_CHARS)
 
 
 # Valid shortcut keys
@@ -41,11 +41,6 @@
                                     "PageUp", "PageDown", "Clear",  "Pause",
                                     "Left", "Up", "Right", "Down"]
 
-# Valid finder chars. To be improved
-VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ"
-VALID_FINDER_CHARS = r"[A-Za-z\s{0}]".format(VALID_ACCENT_CHARS)
-
-
 # Key sequences blacklist for the shortcut editor dialog
 BLACKLIST = {}
 
@@ -118,29 +113,11 @@ def setText(self, sequence):
         super(ShortcutLineEdit, self).setText(sequence)
 
 
-class ShortcutFinder(QLineEdit):
+class ShortcutFinder(FinderLineEdit):
     """Textbox for filtering listed shortcuts in the table."""
 
-    def __init__(self, parent, callback=None):
-        super(ShortcutFinder, self).__init__(parent)
-        self._parent = parent
-
-        # Widget setup
-        regex = QRegExp(VALID_FINDER_CHARS + "{100}")
-        self.setValidator(QRegExpValidator(regex))
-
-        # Signals
-        if callback:
-            self.textChanged.connect(callback)
-
-    def set_text(self, text):
-        """Set the filter text."""
-        text = text.strip()
-        new_text = self.text() + text
-        self.setText(new_text)
-
     def keyPressEvent(self, event):
-        """Qt Override."""
+        """Qt and FilterLineEdit Override."""
         key = event.key()
         if key in [Qt.Key_Up]:
             self._parent.previous_row()
@@ -648,38 +625,6 @@ def reset(self):
         self.endResetModel()
 
 
-class CustomSortFilterProxy(QSortFilterProxyModel):
-    """Custom column filter based on regex."""
-
-    def __init__(self, parent=None):
-        super(CustomSortFilterProxy, self).__init__(parent)
-        self._parent = parent
-        self.pattern = re.compile(r'')
-
-    def set_filter(self, text):
-        """Set regular expression for filter."""
-        self.pattern = get_search_regex(text)
-        if self.pattern:
-            self._parent.setSortingEnabled(False)
-        else:
-            self._parent.setSortingEnabled(True)
-        self.invalidateFilter()
-
-    def filterAcceptsRow(self, row_num, parent):
-        """Qt override.
-
-        Reimplemented from base class to allow the use of custom filtering.
-        """
-        model = self.sourceModel()
-        name = model.row(row_num).name
-        r = re.search(self.pattern, name)
-
-        if r is None:
-            return False
-        else:
-            return True
-
-
 class ShortcutsTable(QTableView):
     def __init__(self,
                  parent=None, text_color=None, text_color_highlight=None):
diff --git a/spyder/widgets/helperwidgets.py b/spyder/widgets/helperwidgets.py
index 4e5d5602173..3af4173d244 100644
--- a/spyder/widgets/helperwidgets.py
+++ b/spyder/widgets/helperwidgets.py
@@ -8,10 +8,14 @@
 Helper widgets.
 """
 
+# Standard imports
+import re
+
 # Third party imports
 from qtpy import PYQT5
-from qtpy.QtCore import QPoint, QSize, Qt
-from qtpy.QtGui import QAbstractTextDocumentLayout, QPainter, QTextDocument
+from qtpy.QtCore import QPoint, QRegExp, QSize, QSortFilterProxyModel, Qt
+from qtpy.QtGui import (QAbstractTextDocumentLayout, QPainter,
+                        QRegExpValidator, QTextDocument)
 from qtpy.QtWidgets import (QApplication, QCheckBox, QLineEdit, QMessageBox,
                             QSpacerItem, QStyle, QStyledItemDelegate,
                             QStyleOptionViewItem, QToolButton, QToolTip,
@@ -21,6 +25,7 @@
 from spyder.config.base import _
 from spyder.utils import icon_manager as ima
 from spyder.utils.qthelpers import get_std_icon
+from spyder.utils.stringmatching import get_search_regex
 
 
 class HelperToolButton(QToolButton):
@@ -283,6 +288,84 @@ def paintEvent(self, event):
             self._refresh()
 
 
+# Valid finder chars. To be improved
+VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ"
+VALID_FINDER_CHARS = r"[A-Za-z\s{0}]".format(VALID_ACCENT_CHARS)
+
+
+class FinderLineEdit(QLineEdit):
+    """QLineEdit for filtering listed elements in the parent widget."""
+
+    def __init__(self, parent, callback=None):
+        super(FinderLineEdit, self).__init__(parent)
+        self._parent = parent
+
+        # Widget setup
+        regex = QRegExp(VALID_FINDER_CHARS + "{100}")
+        self.setValidator(QRegExpValidator(regex))
+
+        # Signals
+        if callback:
+            self.textChanged.connect(callback)
+
+    def set_text(self, text):
+        """Set the filter text."""
+        text = text.strip()
+        new_text = self.text() + text
+        self.setText(new_text)
+
+    def keyPressEvent(self, event):
+        """
+        Qt Override.
+
+        The parent needs to implement the methods to handle focus on the
+        next/previous row and a action over the selected element.
+
+        This should be override.
+        """
+        key = event.key()
+        if key in [Qt.Key_Up]:
+            self._parent.previous_row()
+        elif key in [Qt.Key_Down]:
+            self._parent.next_row()
+        elif key in [Qt.Key_Enter, Qt.Key_Return]:
+            self._parent.selected_element()
+        else:
+            super(FinderLineEdit, self).keyPressEvent(event)
+
+
+class CustomSortFilterProxy(QSortFilterProxyModel):
+    """Custom column filter based on regex."""
+
+    def __init__(self, parent=None):
+        super(CustomSortFilterProxy, self).__init__(parent)
+        self._parent = parent
+        self.pattern = re.compile(r'')
+
+    def set_filter(self, text):
+        """Set regular expression for filter."""
+        self.pattern = get_search_regex(text)
+        if self.pattern:
+            self._parent.setSortingEnabled(False)
+        else:
+            self._parent.setSortingEnabled(True)
+        self.invalidateFilter()
+
+    def filterAcceptsRow(self, row_num, parent):
+        """Qt override.
+
+        Reimplemented from base class to allow the use of custom filtering.
+        """
+        model = self.sourceModel()
+        name = model.row(row_num).name
+        r = re.search(self.pattern, name)
+
+        if r is None:
+            return False
+        else:
+            return True
+
+
 def test_msgcheckbox():
     from spyder.utils.qthelpers import qapplication
     app = qapplication()


Code review comments:
diff_hunk: @@ -322,11 +332,30 @@ def get_bgcolor(self, index):
             color.setAlphaF(.3)
         return color
 
+    def update_search_letters(self, text):
+        """Update search letters with text input in search box."""
+        self.letters = text
+        names = self.keys
+        results = get_search_scores(text, names, template='<b>{0}</b>')
+        self.normal_text, self.rich_text, self.scores = zip(*results)
path: spyder/plugins/variableexplorer/widgets/collectionseditor.py
text: @user1:
It looks nice, but leave the code somewhere while we can fix the HTMLDelegate height as we discussed on the meeting