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