diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 87f081c07..50fd684e7 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -32,6 +32,8 @@ Added
- New `:messages` command to show error messages
- New pop-up showing possible keybinding when the first key of a keychain is
pressed. This can be turned off using `:set ui keyhint-blacklist *`.
+- New `hints -> auto-follow-timeout` setting to ignore keypresses after
+ following a hint when filtering in number mode.
Changed
~~~~~~~
@@ -60,6 +62,10 @@ Changed
- `:zoom-in` or `:zoom-out` (`+`/`-`) with a too large count now zooms to the
smallest/largest zoom instead of doing nothing.
- The commandline now accepts partially typed commands if they're unique.
+- Number hints are now kept filtered after following a hint in rapid mode.
+- Number hints are now renumbered after filtering
+- Number hints can now be filtered with multiple space-separated search terms
+- `hints -> scatter` is now ignored for number hints
Fixed
-----
@@ -80,6 +86,7 @@ Fixed
- Fixed crash when cancelling a download which belongs to a MHTML download
- Fixed rebinding of keybindings being case-sensitive
- Fix for tab indicators getting lost when moving tabs
+- Fixed handling of backspace in number hinting mode
v0.6.2
------
diff --git a/README.asciidoc b/README.asciidoc
index 2182f363d..ecf88893d 100644
--- a/README.asciidoc
+++ b/README.asciidoc
@@ -148,9 +148,9 @@ Contributors, sorted by the number of commits in descending order:
* Alexander Cogneau
* Felix Van der Jeugt
* Martin Tournoij
+* Jakub Klinkovský
* Raphael Pierzina
* Joel Torstensson
-* Jakub Klinkovský
* Tarcisio Fedrizzi
* Patric Schmitz
* Claude
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 2a2a2dc42..e8bde520f 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -179,10 +179,11 @@
|<>|Mode to use for hints.
|<>|Chars used for hint strings.
|<>|Minimum number of chars used for hint strings.
-|<>|Whether to scatter hint key chains (like Vimium) or not (like dwb).
+|<>|Whether to scatter hint key chains (like Vimium) or not (like dwb). Ignored for number hints.
|<>|Make chars in hint strings uppercase.
|<>|The dictionary file to be used by the word hints.
|<>|Follow a hint immediately when the hint text is completely matched.
+|<>|A timeout to inhibit normal-mode key bindings after a successfulauto-follow.
|<>|A comma-separated list of regexes to use for 'next' links.
|<>|A comma-separated list of regexes to use for 'prev' links.
|==============
@@ -1581,7 +1582,7 @@ Default: +pass:[1]+
[[hints-scatter]]
=== scatter
-Whether to scatter hint key chains (like Vimium) or not (like dwb).
+Whether to scatter hint key chains (like Vimium) or not (like dwb). Ignored for number hints.
Valid values:
@@ -1618,6 +1619,12 @@ Valid values:
Default: +pass:[true]+
+[[hints-auto-follow-timeout]]
+=== auto-follow-timeout
+A timeout to inhibit normal-mode key bindings after a successfulauto-follow.
+
+Default: +pass:[0]+
+
[[hints-next-regexes]]
=== next-regexes
A comma-separated list of regexes to use for 'next' links.
diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py
index 7b098fd25..9e5f666c6 100644
--- a/qutebrowser/browser/hints.py
+++ b/qutebrowser/browser/hints.py
@@ -23,7 +23,7 @@ import collections
import functools
import math
import re
-import string
+from string import ascii_lowercase
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl,
QTimer)
@@ -67,7 +67,9 @@ class HintContext:
frames: The QWebFrames to use.
destroyed_frames: id()'s of QWebFrames which have been destroyed.
(Workaround for https://github.com/The-Compiler/qutebrowser/issues/152)
+ all_elems: A list of all (elem, label) namedtuples ever created.
elems: A mapping from key strings to (elem, label) namedtuples.
+ May contain less elements than `all_elems` due to filtering.
baseurl: The URL of the current page.
target: What to do with the opened links.
normal/current/tab/tab_fg/tab_bg/window: Get passed to
@@ -86,6 +88,7 @@ class HintContext:
"""
def __init__(self):
+ self.all_elems = []
self.elems = {}
self.target = None
self.baseurl = None
@@ -117,6 +120,7 @@ class HintManager(QObject):
_context: The HintContext for the current invocation.
_win_id: The window ID this HintManager is associated with.
_tab_id: The tab ID this HintManager is associated with.
+ _filterstr: Used to save the filter string for restoring in rapid mode.
Signals:
mouse_event: Mouse event to be posted in the web view.
@@ -153,6 +157,7 @@ class HintManager(QObject):
self._win_id = win_id
self._tab_id = tab_id
self._context = None
+ self._filterstr = None
self._word_hinter = WordHinter()
mode_manager = objreg.get('mode-manager', scope='window',
window=win_id)
@@ -168,7 +173,7 @@ class HintManager(QObject):
def _cleanup(self):
"""Clean up after hinting."""
- for elem in self._context.elems.values():
+ for elem in self._context.all_elems:
try:
elem.label.removeFromDocument()
except webelem.IsNullError:
@@ -218,7 +223,7 @@ class HintManager(QObject):
else:
chars = config.get('hints', 'chars')
min_chars = config.get('hints', 'min-chars')
- if config.get('hints', 'scatter'):
+ if config.get('hints', 'scatter') and hint_mode != 'number':
return self._hint_scattered(min_chars, chars, elems)
else:
return self._hint_linear(min_chars, chars, elems)
@@ -384,7 +389,7 @@ class HintManager(QObject):
label.setStyleProperty('left', '{}px !important'.format(left))
label.setStyleProperty('top', '{}px !important'.format(top))
- def _draw_label(self, elem, text):
+ def _draw_label(self, elem, string):
"""Draw a hint label over an element.
Args:
@@ -409,7 +414,7 @@ class HintManager(QObject):
label = webelem.WebElementWrapper(parent.lastChild())
label['class'] = 'qutehint'
self._set_style_properties(elem, label)
- label.setPlainText(text)
+ label.setPlainText(string)
return label
def _show_url_error(self):
@@ -691,15 +696,27 @@ class HintManager(QObject):
elems = [e for e in elems if filterfunc(e)]
if not elems:
raise cmdexc.CommandError("No elements found.")
- hints = self._hint_strings(elems)
- log.hints.debug("hints: {}".format(', '.join(hints)))
- for e, hint in zip(elems, hints):
- label = self._draw_label(e, hint)
- self._context.elems[hint] = ElemTuple(e, label)
+ strings = self._hint_strings(elems)
+ log.hints.debug("hints: {}".format(', '.join(strings)))
+ for e, string in zip(elems, strings):
+ label = self._draw_label(e, string)
+ elem = ElemTuple(e, label)
+ self._context.all_elems.append(elem)
+ self._context.elems[string] = elem
keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id)
keyparser = keyparsers[usertypes.KeyMode.hint]
- keyparser.update_bindings(hints)
+ keyparser.update_bindings(strings)
+
+ def _filter_matches(self, filterstr, elemstr):
+ """Return True if `filterstr` matches `elemstr`."""
+ # Empty string and None always match
+ if not filterstr:
+ return True
+ filterstr = filterstr.casefold()
+ elemstr = elemstr.casefold()
+ # Do multi-word matching
+ return all(word in elemstr for word in filterstr.split())
def follow_prevnext(self, frame, baseurl, prev=False, tab=False,
background=False, window=False):
@@ -844,55 +861,115 @@ class HintManager(QObject):
def handle_partial_key(self, keystr):
"""Handle a new partial keypress."""
log.hints.debug("Handling new keystring: '{}'".format(keystr))
- for (text, elems) in self._context.elems.items():
+ for string, elem in self._context.elems.items():
try:
- if text.startswith(keystr):
- matched = text[:len(keystr)]
- rest = text[len(keystr):]
+ if string.startswith(keystr):
+ matched = string[:len(keystr)]
+ rest = string[len(keystr):]
match_color = config.get('colors', 'hints.fg.match')
- elems.label.setInnerXml(
+ elem.label.setInnerXml(
'{}{}'.format(
match_color, matched, rest))
- if self._is_hidden(elems.label):
+ if self._is_hidden(elem.label):
# hidden element which matches again -> show it
- self._show_elem(elems.label)
+ self._show_elem(elem.label)
else:
# element doesn't match anymore -> hide it
- self._hide_elem(elems.label)
+ self._hide_elem(elem.label)
except webelem.IsNullError:
pass
+ def _filter_number_hints(self):
+ """Apply filters for numbered hints and renumber them.
+
+ Return:
+ Elements which are still visible
+ """
+ # renumber filtered hints
+ elems = []
+ for e in self._context.all_elems:
+ try:
+ if not self._is_hidden(e.label):
+ elems.append(e)
+ except webelem.IsNullError:
+ pass
+ if not elems:
+ # Whoops, filtered all hints
+ modeman.leave(self._win_id, usertypes.KeyMode.hint,
+ 'all filtered')
+ return
+
+ strings = self._hint_strings(elems)
+ self._context.elems = {}
+ for elem, string in zip(elems, strings):
+ elem.label.setInnerXml(string)
+ self._context.elems[string] = elem
+ keyparsers = objreg.get('keyparsers', scope='window',
+ window=self._win_id)
+ keyparser = keyparsers[usertypes.KeyMode.hint]
+ keyparser.update_bindings(strings, preserve_filter=True)
+
+ return self._context.elems
+
+ def _filter_non_number_hints(self):
+ """Apply filters for letter/word hints.
+
+ Return:
+ Elements which are still visible
+ """
+ visible = {}
+ for string, elem in self._context.elems.items():
+ try:
+ if not self._is_hidden(elem.label):
+ visible[string] = elem
+ except webelem.IsNullError:
+ pass
+ if not visible:
+ # Whoops, filtered all hints
+ modeman.leave(self._win_id, usertypes.KeyMode.hint,
+ 'all filtered')
+ return visible
+
def filter_hints(self, filterstr):
"""Filter displayed hints according to a text.
Args:
- filterstr: The string to filter with, or None to show all.
+ filterstr: The string to filter with, or None to use the filter
+ from previous call (saved in `self._filterstr`). If
+ `filterstr` is an empty string or if both `filterstr`
+ and `self._filterstr` are None, all hints are shown.
"""
- for elems in self._context.elems.values():
+ if filterstr is None:
+ filterstr = self._filterstr
+ else:
+ self._filterstr = filterstr
+
+ for elem in self._context.all_elems:
try:
- if (filterstr is None or
- filterstr.casefold() in str(elems.elem).casefold()):
- if self._is_hidden(elems.label):
+ if self._filter_matches(filterstr, str(elem.elem)):
+ if self._is_hidden(elem.label):
# hidden element which matches again -> show it
- self._show_elem(elems.label)
+ self._show_elem(elem.label)
else:
# element doesn't match anymore -> hide it
- self._hide_elem(elems.label)
+ self._hide_elem(elem.label)
except webelem.IsNullError:
pass
- visible = {}
- for k, e in self._context.elems.items():
- try:
- if not self._is_hidden(e.label):
- visible[k] = e
- except webelem.IsNullError:
- pass
- if not visible:
- # Whoops, filtered all hints
- modeman.leave(self._win_id, usertypes.KeyMode.hint, 'all filtered')
- elif (len(visible) == 1 and
- config.get('hints', 'auto-follow') and
- filterstr is not None):
+
+ if config.get('hints', 'mode') == 'number':
+ visible = self._filter_number_hints()
+ else:
+ visible = self._filter_non_number_hints()
+
+ if (len(visible) == 1 and
+ config.get('hints', 'auto-follow') and
+ filterstr is not None):
+ # apply auto-follow-timeout
+ timeout = config.get('hints', 'auto-follow-timeout')
+ keyparsers = objreg.get('keyparsers', scope='window',
+ window=self._win_id)
+ normal_parser = keyparsers[usertypes.KeyMode.normal]
+ normal_parser.set_inhibited_timeout(timeout)
# unpacking gets us the first (and only) key in the dict.
self.fire(*visible)
@@ -950,11 +1027,11 @@ class HintManager(QObject):
modeman.maybe_leave(self._win_id, usertypes.KeyMode.hint,
'followed')
else:
- # Show all hints again
+ # Reset filtering
self.filter_hints(None)
# Undo keystring highlighting
- for (text, elems) in self._context.elems.items():
- elems.label.setInnerXml(text)
+ for string, elem in self._context.elems.items():
+ elem.label.setInnerXml(string)
handler()
@cmdutils.register(instance='hintmanager', scope='tab', hide=True,
@@ -978,13 +1055,13 @@ class HintManager(QObject):
def on_contents_size_changed(self, _size):
"""Reposition hints if contents size changed."""
log.hints.debug("Contents size changed...!")
- for elems in self._context.elems.values():
+ for e in self._context.all_elems:
try:
- if elems.elem.webFrame() is None:
+ if e.elem.webFrame() is None:
# This sometimes happens for some reason...
- elems.label.removeFromDocument()
+ e.label.removeFromDocument()
continue
- self._set_style_position(elems.elem, elems.label)
+ self._set_style_position(e.elem, e.label)
except webelem.IsNullError:
pass
@@ -1021,7 +1098,7 @@ class WordHinter:
self.dictionary = dictionary
try:
with open(dictionary, encoding="UTF-8") as wordfile:
- alphabet = set(string.ascii_lowercase)
+ alphabet = set(ascii_lowercase)
hints = set()
lines = (line.rstrip().lower() for line in wordfile)
for word in lines:
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 659ef08b2..7016bee00 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -898,7 +898,7 @@ def data(readonly=False):
('scatter',
SettingValue(typ.Bool(), 'true'),
"Whether to scatter hint key chains (like Vimium) or not (like "
- "dwb)."),
+ "dwb). Ignored for number hints."),
('uppercase',
SettingValue(typ.Bool(), 'false'),
@@ -913,6 +913,11 @@ def data(readonly=False):
"Follow a hint immediately when the hint text is completely "
"matched."),
+ ('auto-follow-timeout',
+ SettingValue(typ.Int(), '0'),
+ "A timeout to inhibit normal-mode key bindings after a successful"
+ "auto-follow."),
+
('next-regexes',
SettingValue(typ.RegexList(flags=re.IGNORECASE),
r'\bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b,'
diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py
index f8c54d79a..4876bbce6 100644
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -49,6 +49,9 @@ class NormalKeyParser(keyparser.CommandKeyParser):
self.read_config('normal')
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
+ self._inhibited = False
+ self._inhibited_timer = usertypes.Timer(self, 'normal-inhibited')
+ self._inhibited_timer.setSingleShot(True)
def __repr__(self):
return utils.get_repr(self)
@@ -63,6 +66,10 @@ class NormalKeyParser(keyparser.CommandKeyParser):
A self.Match member.
"""
txt = e.text().strip()
+ if self._inhibited:
+ self._debug_log("Ignoring key '{}', because the normal mode is "
+ "currently inhibited.".format(txt))
+ return self.Match.none
if not self._keystring and any(txt == c for c in STARTCHARS):
message.set_cmd_text(self._win_id, txt)
return self.Match.definitive
@@ -75,6 +82,15 @@ class NormalKeyParser(keyparser.CommandKeyParser):
self._partial_timer.start()
return match
+ def set_inhibited_timeout(self, timeout):
+ if timeout != 0:
+ self._debug_log("Inhibiting the normal mode for {}ms.".format(
+ timeout))
+ self._inhibited = True
+ self._inhibited_timer.setInterval(timeout)
+ self._inhibited_timer.timeout.connect(self._clear_inhibited)
+ self._inhibited_timer.start()
+
@pyqtSlot()
def _clear_partial_match(self):
"""Clear a partial keystring after a timeout."""
@@ -83,6 +99,12 @@ class NormalKeyParser(keyparser.CommandKeyParser):
self._keystring = ''
self.keystring_updated.emit(self._keystring)
+ @pyqtSlot()
+ def _clear_inhibited(self):
+ """Reset inhibition state after a timeout."""
+ self._debug_log("Releasing inhibition state of normal mode.")
+ self._inhibited = False
+
@pyqtSlot()
def _stop_timers(self):
super()._stop_timers()
@@ -92,6 +114,12 @@ class NormalKeyParser(keyparser.CommandKeyParser):
except TypeError:
# no connections
pass
+ self._inhibited_timer.stop()
+ try:
+ self._inhibited_timer.timeout.disconnect(self._clear_inhibited)
+ except TypeError:
+ # no connections
+ pass
class PromptKeyParser(keyparser.CommandKeyParser):
@@ -153,6 +181,11 @@ class HintKeyParser(keyparser.CommandKeyParser):
elif self._last_press == LastPress.keystring and self._keystring:
self._keystring = self._keystring[:-1]
self.keystring_updated.emit(self._keystring)
+ if not self._keystring and self._filtertext:
+ # Switch back to hint filtering mode (this can happen only
+ # in numeric mode after the number has been deleted).
+ hintmanager.filter_hints(self._filtertext)
+ self._last_press = LastPress.filtertext
return True
else:
return super()._handle_special_key(e)
@@ -203,14 +236,17 @@ class HintKeyParser(keyparser.CommandKeyParser):
# execute as command
super().execute(cmdstr, keytype, count)
- def update_bindings(self, strings):
+ def update_bindings(self, strings, preserve_filter=False):
"""Update bindings when the hint strings changed.
Args:
strings: A list of hint strings.
+ preserve_filter: Whether to keep the current value of
+ `self._filtertext`.
"""
self.bindings = {s: s for s in strings}
- self._filtertext = ''
+ if not preserve_filter:
+ self._filtertext = ''
@pyqtSlot(str)
def on_keystring_updated(self, keystr):
diff --git a/tests/end2end/data/hints/issue1186.html b/tests/end2end/data/hints/issue1186.html
new file mode 100644
index 000000000..29411b987
--- /dev/null
+++ b/tests/end2end/data/hints/issue1186.html
@@ -0,0 +1,35 @@
+
+
+
+
+ Issue 1186
+
+
+
+ This page contains 10 hints to test backspace handling, see #1186.
+
+
+
+ This requires setting hints -> mode to number.
+
+
+
+ When pressing f, x, 0, Backspace, only hints starting with x should be
+ shown.
+
+
+
+
diff --git a/tests/end2end/data/hints/number.html b/tests/end2end/data/hints/number.html
new file mode 100644
index 000000000..47b9d27b1
--- /dev/null
+++ b/tests/end2end/data/hints/number.html
@@ -0,0 +1,35 @@
+
+
+
+
+ Numbered hints
+
+
+
+ This page contains various links to test numbered hints. This requires setting hints -> mode to number.
+
+
+
+ Related issues:
+
+ - #308 - Renumber hints when filtering them in number mode.
+ - #576 - Keep hint filtering when rapid hinting in number mode
+ - #674 (comment) - Multi-word matching for hints
+
+
+
+
+
+
diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature
index 149f4d327..1099aea00 100644
--- a/tests/end2end/features/hints.feature
+++ b/tests/end2end/features/hints.feature
@@ -140,3 +140,80 @@ Feature: Using hints
Then the following tabs should be open:
- data/hints/iframe_target.html
- data/hello.txt (active)
+
+ ### hints -> auto-follow-timeout
+
+ Scenario: Ignoring key presses after auto-following hints
+ When I set hints -> auto-follow-timeout to 200
+ And I set hints -> mode to number
+ And I run :bind --force , message-error "This should not happen"
+ And I open data/hints/html/simple.html
+ And I run :hint all
+ And I press the key "f"
+ And I wait until data/hello.txt is loaded
+ And I press the key ","
+ # Waiting here so we don't affect the next test
+ And I wait for "Releasing inhibition state of normal mode." in the log
+ Then "Ignoring key ',', because the normal mode is currently inhibited." should be logged
+
+ Scenario: Turning off auto-follow-timeout
+ When I set hints -> auto-follow-timeout to 0
+ And I set hints -> mode to number
+ And I run :bind --force , message-info "Keypress worked!"
+ And I open data/hints/html/simple.html
+ And I run :hint all
+ And I press the key "f"
+ And I wait until data/hello.txt is loaded
+ And I press the key ","
+ Then the message "Keypress worked!" should be shown
+
+ ### Number hint mode
+
+ # https://github.com/The-Compiler/qutebrowser/issues/308
+ Scenario: Renumbering hints when filtering
+ When I open data/hints/number.html
+ And I set hints -> mode to number
+ And I run :hint all
+ And I press the key "s"
+ And I run :follow-hint 1
+ Then data/numbers/7.txt should be loaded
+
+ # https://github.com/The-Compiler/qutebrowser/issues/576
+ Scenario: Keeping hint filter in rapid mode
+ When I open data/hints/number.html
+ And I set hints -> mode to number
+ And I run :hint all tab-bg --rapid
+ And I press the key "t"
+ And I run :follow-hint 0
+ And I run :follow-hint 1
+ Then data/numbers/2.txt should be loaded
+ And data/numbers/3.txt should be loaded
+
+ # https://github.com/The-Compiler/qutebrowser/issues/1186
+ Scenario: Keeping hints filter when using backspace
+ When I open data/hints/issue1186.html
+ And I set hints -> mode to number
+ And I run :hint all
+ And I press the key "x"
+ And I press the key "0"
+ And I press the key ""
+ And I run :follow-hint 11
+ Then the error "No hint 11!" should be shown
+
+ # https://github.com/The-Compiler/qutebrowser/issues/674#issuecomment-165096744
+ Scenario: Multi-word matching
+ When I open data/hints/number.html
+ And I set hints -> mode to number
+ And I set hints -> auto-follow to true
+ And I set hints -> auto-follow-timeout to 0
+ And I run :hint all
+ And I press the keys "ten pos"
+ Then data/numbers/11.txt should be loaded
+
+ Scenario: Scattering is ignored with number hints
+ When I open data/hints/number.html
+ And I set hints -> mode to number
+ And I set hints -> scatter to true
+ And I run :hint all
+ And I run :follow-hint 00
+ Then data/numbers/1.txt should be loaded