Repository: qutebrowser/qutebrowser

Issue:
Add ability to jump between completion sections
For people having many quickmarks (or searchengines if we decide to show them in the completions) it would be great to be able to jump between the different sections (quickmarks, bookmarks, history, searchengines). 

Keybinding proposal: 
tab : next entry
shift-tab : prev entry
ctrl-tab : next section
shift-ctrl-tab : prev section



Discussion:
Since @rcorre merged `:completion-item-prev` and `:completion-item-next` into `:completion-focus {prev,next}` in #1800, this could be implemented as `:completion-focus prev-category` and `:completion-focus next-category`.


Resolving patch:
diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py
--- a/qutebrowser/completion/completionwidget.py
+++ b/qutebrowser/completion/completionwidget.py
@@ -181,14 +181,44 @@ def _next_idx(self, upwards):
                 # Item is a real item, not a category header -> success
                 return idx
 
+    def _next_category_idx(self, upwards):
+        """Get the index of the previous/next category.
+
+        Args:
+            upwards: Get previous item, not next.
+
+        Return:
+            A QModelIndex.
+        """
+        idx = self.selectionModel().currentIndex()
+        if not idx.isValid():
+            return self._next_idx(upwards).sibling(0, 0)
+        idx = idx.parent()
+        direction = -1 if upwards else 1
+        while True:
+            idx = idx.sibling(idx.row() + direction, 0)
+            if not idx.isValid() and upwards:
+                # wrap around to the first item of the last category
+                return self.model().last_item().sibling(0, 0)
+            elif not idx.isValid() and not upwards:
+                # wrap around to the first item of the first category
+                idx = self.model().first_item()
+                self.scrollTo(idx.parent())
+                return idx
+            elif idx.isValid() and idx.child(0, 0).isValid():
+                # scroll to ensure the category is visible
+                self.scrollTo(idx)
+                return idx.child(0, 0)
+
     @cmdutils.register(instance='completion', hide=True,
                        modes=[usertypes.KeyMode.command], scope='window')
-    @cmdutils.argument('which', choices=['next', 'prev'])
+    @cmdutils.argument('which', choices=['next', 'prev', 'next-category',
+                                         'prev-category'])
     def completion_item_focus(self, which):
         """Shift the focus of the completion menu to another item.
 
         Args:
-            which: 'next' or 'prev'
+            which: 'next', 'prev', 'next-category', or 'prev-category'.
         """
         # selmodel can be None if 'show' and 'auto-open' are set to False
         # https://github.com/The-Compiler/qutebrowser/issues/1731
@@ -196,7 +226,15 @@ def completion_item_focus(self, which):
         if selmodel is None:
             return
 
-        idx = self._next_idx(which == 'prev')
+        if which == 'next':
+            idx = self._next_idx(upwards=False)
+        elif which == 'prev':
+            idx = self._next_idx(upwards=True)
+        elif which == 'next-category':
+            idx = self._next_category_idx(upwards=False)
+        elif which == 'prev-category':
+            idx = self._next_category_idx(upwards=True)
+
         if not idx.isValid():
             return
 
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -1602,6 +1602,8 @@ def data(readonly=False):
         ('command-history-next', ['<Ctrl-N>']),
         ('completion-item-focus prev', ['<Shift-Tab>', '<Up>']),
         ('completion-item-focus next', ['<Tab>', '<Down>']),
+        ('completion-item-focus next-category', ['<Ctrl-Tab>']),
+        ('completion-item-focus prev-category', ['<Ctrl-Shift-Tab>']),
         ('completion-item-del', ['<Ctrl-D>']),
         ('command-accept', RETURN_KEYS),
     ])),


Test patch:
diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py
--- a/tests/unit/completion/test_completionwidget.py
+++ b/tests/unit/completion/test_completionwidget.py
@@ -97,32 +97,56 @@ def test_maybe_resize_completion(completionview, config_stub, qtbot):
         completionview.maybe_resize_completion()
 
 
-@pytest.mark.parametrize('tree, count, expected', [
-    ([['Aa']], 1, 'Aa'),
-    ([['Aa']], -1, 'Aa'),
-    ([['Aa'], ['Ba']], 1, 'Aa'),
-    ([['Aa'], ['Ba']], -1, 'Ba'),
-    ([['Aa'], ['Ba']], 2, 'Ba'),
-    ([['Aa'], ['Ba']], -2, 'Aa'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -1, 'Ca'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -2, 'Bb'),
-    ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -4, 'Ac'),
-    ([[], ['Ba', 'Bb']], 1, 'Ba'),
-    ([[], ['Ba', 'Bb']], -1, 'Bb'),
-    ([[], [], ['Ca', 'Cb']], 1, 'Ca'),
-    ([[], [], ['Ca', 'Cb']], -1, 'Cb'),
-    ([['Aa'], []], 1, 'Aa'),
-    ([['Aa'], []], -1, 'Aa'),
-    ([['Aa'], [], []], 1, 'Aa'),
-    ([['Aa'], [], []], -1, 'Aa'),
-    ([[]], 1, None),
-    ([[]], -1, None),
+@pytest.mark.parametrize('which, tree, count, expected', [
+    ('next', [['Aa']], 1, 'Aa'),
+    ('prev', [['Aa']], 1, 'Aa'),
+    ('next', [['Aa'], ['Ba']], 1, 'Aa'),
+    ('prev', [['Aa'], ['Ba']], 1, 'Ba'),
+    ('next', [['Aa'], ['Ba']], 2, 'Ba'),
+    ('prev', [['Aa'], ['Ba']], 2, 'Aa'),
+    ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'),
+    ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'),
+    ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'),
+    ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'),
+    ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 1, 'Ca'),
+    ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Bb'),
+    ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ac'),
+    ('next', [[], ['Ba', 'Bb']], 1, 'Ba'),
+    ('prev', [[], ['Ba', 'Bb']], 1, 'Bb'),
+    ('next', [[], [], ['Ca', 'Cb']], 1, 'Ca'),
+    ('prev', [[], [], ['Ca', 'Cb']], 1, 'Cb'),
+    ('next', [['Aa'], []], 1, 'Aa'),
+    ('prev', [['Aa'], []], 1, 'Aa'),
+    ('next', [['Aa'], [], []], 1, 'Aa'),
+    ('prev', [['Aa'], [], []], 1, 'Aa'),
+    ('next', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'),
+    ('prev', [['Aa'], [], ['Ca', 'Cb']], 1, 'Cb'),
+    ('next', [[]], 1, None),
+    ('prev', [[]], 1, None),
+    ('next-category', [['Aa']], 1, 'Aa'),
+    ('prev-category', [['Aa']], 1, 'Aa'),
+    ('next-category', [['Aa'], ['Ba']], 1, 'Aa'),
+    ('prev-category', [['Aa'], ['Ba']], 1, 'Ba'),
+    ('next-category', [['Aa'], ['Ba']], 2, 'Ba'),
+    ('prev-category', [['Aa'], ['Ba']], 2, 'Aa'),
+    ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'),
+    ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'),
+    ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ca'),
+    ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Aa'),
+    ('next-category', [[], ['Ba', 'Bb']], 1, 'Ba'),
+    ('prev-category', [[], ['Ba', 'Bb']], 1, 'Ba'),
+    ('next-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'),
+    ('prev-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'),
+    ('next-category', [[], [], ['Ca', 'Cb']], 2, 'Ca'),
+    ('prev-category', [[], [], ['Ca', 'Cb']], 2, 'Ca'),
+    ('next-category', [['Aa'], [], []], 1, 'Aa'),
+    ('prev-category', [['Aa'], [], []], 1, 'Aa'),
+    ('next-category', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'),
+    ('prev-category', [['Aa'], [], ['Ca', 'Cb']], 1, 'Ca'),
+    ('next-category', [[]], 1, None),
+    ('prev-category', [[]], 1, None),
 ])
-def test_completion_item_focus(tree, count, expected, completionview):
+def test_completion_item_focus(which, tree, count, expected, completionview):
     """Test that on_next_prev_item moves the selection properly.
 
     Args:
@@ -140,9 +164,8 @@ def test_completion_item_focus(tree, count, expected, completionview):
     filtermodel = sortfilter.CompletionFilterModel(model,
                                                    parent=completionview)
     completionview.set_model(filtermodel)
-    direction = 'prev' if count < 0 else 'next'
-    for _ in range(abs(count)):
-        completionview.completion_item_focus(direction)
+    for _ in range(count):
+        completionview.completion_item_focus(which)
     idx = completionview.selectionModel().currentIndex()
     assert filtermodel.data(idx) == expected