diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 7169a24b7..21fcfb461 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -71,6 +71,9 @@ Changed - The `:buffer` completion now also filters using the first column (id). - `:undo` has been improved to reopen tabs at the position they were closed. - `:navigate` now takes a count for `up`/`increment`/`decrement`. +- The `hints -> auto-follow` setting now can be set to + `always`/`full-match`/`unique-match`/`never` to more precisely control when + hints should be followed automatically. Deprecated ~~~~~~~~~~ diff --git a/README.asciidoc b/README.asciidoc index 420c5cc21..7a1510e29 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -142,10 +142,10 @@ Contributors, sorted by the number of commits in descending order: * Florian Bruhin * Daniel Schadt * Ryan Roden-Corrent +* Jakub Klinkovský * Antoni Boucher * Lamar Pavel * Bruno Oliveira -* Jakub Klinkovský * Jan Verbeek * Alexander Cogneau * Marshall Lochbaum diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index cef74eb0e..08d0dd51e 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -185,7 +185,7 @@ |<>|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. +|<>|Controls when a hint can be automatically followed without the user pressing Enter. |<>|A timeout (in milliseconds) to inhibit normal-mode key bindings after a successful auto-follow. |<>|A comma-separated list of regexes to use for 'next' links. |<>|A comma-separated list of regexes to use for 'prev' links. @@ -1660,14 +1660,16 @@ Default: +pass:[/usr/share/dict/words]+ [[hints-auto-follow]] === auto-follow -Follow a hint immediately when the hint text is completely matched. +Controls when a hint can be automatically followed without the user pressing Enter. Valid values: - * +true+ - * +false+ + * +always+: Auto-follow whenever there is only a single hint on a page. + * +unique-match+: Auto-follow whenever there is a unique non-empty match in either the hint string (word mode) or filter (number mode). + * +full-match+: Follow the hint when the user typed the whole hint (letter, word or number mode) or the element's text (only in number mode). + * +never+: The user will always need to press Enter to follow a hint. -Default: +pass:[true]+ +Default: +pass:[unique-match]+ [[hints-auto-follow-timeout]] === auto-follow-timeout diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index ce6cb42de..8dac93e03 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -62,7 +62,6 @@ class HintContext: """Context namespace used for hinting. Attributes: - frames: The QWebFrames to use. 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. @@ -79,6 +78,7 @@ class HintContext: to_follow: The link to follow when enter is pressed. args: Custom arguments for userscript/spawn rapid: Whether to do rapid hinting. + filterstr: Used to save the filter string for restoring in rapid mode. tab: The WebTab object we started hinting in. group: The group of web elements to hint. """ @@ -90,7 +90,7 @@ class HintContext: self.baseurl = None self.to_follow = None self.rapid = False - self.frames = [] + self.filterstr = None self.args = [] self.tab = None self.group = None @@ -309,7 +309,6 @@ 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: See HintActions @@ -342,7 +341,6 @@ class HintManager(QObject): self._win_id = win_id self._tab_id = tab_id self._context = None - self._filterstr = None self._word_hinter = WordHinter() self._actions = HintActions(win_id) @@ -381,7 +379,6 @@ class HintManager(QObject): window=self._win_id) message_bridge.maybe_reset_text(text) self._context = None - self._filterstr = None def _hint_strings(self, elems): """Calculate the hint strings for elems. @@ -394,6 +391,8 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ + if not elems: + return [] hint_mode = self._context.hint_mode if hint_mode == 'word': try: @@ -630,6 +629,15 @@ class HintManager(QObject): # Do multi-word matching return all(word in elemstr for word in filterstr.split()) + def _filter_matches_exactly(self, filterstr, elemstr): + """Return True if `filterstr` exactly matches `elemstr`.""" + # Empty string and None never match + if not filterstr: + return False + filterstr = filterstr.casefold() + elemstr = elemstr.casefold() + return filterstr == elemstr + def _start_cb(self, elems): """Initialize the elements and labels based on the context set.""" filterfunc = webelem.FILTERS.get(self._context.group, lambda e: True) @@ -656,6 +664,9 @@ class HintManager(QObject): modeman.enter(self._win_id, usertypes.KeyMode.hint, 'HintManager.start') + # to make auto-follow == 'always' work + self._handle_auto_follow() + @cmdutils.register(instance='hintmanager', scope='tab', name='hint', star_args_optional=True, maxsplit=2, backend=usertypes.Backend.QtWebKit) @@ -753,7 +764,6 @@ class HintManager(QObject): self._context.baseurl = tabbed_browser.current_url() except qtutils.QtValueError: raise cmdexc.CommandError("No URL set for this page yet!") - self._context.tab = tab self._context.args = args self._context.group = group selector = webelem.SELECTORS[self._context.group] @@ -767,6 +777,51 @@ class HintManager(QObject): return self._context.hint_mode + def _get_visible_hints(self): + """Get elements which are currently visible.""" + visible = {} + for string, elem in self._context.elems.items(): + try: + if not self._is_hidden(elem.label): + visible[string] = elem + except webelem.Error: + pass + return visible + + def _handle_auto_follow(self, keystr="", filterstr="", visible=None): + """Handle the auto-follow option.""" + if visible is None: + visible = self._get_visible_hints() + + if len(visible) != 1: + return + + auto_follow = config.get('hints', 'auto-follow') + + if auto_follow == "always": + follow = True + elif auto_follow == "unique-match": + follow = keystr or filterstr + elif auto_follow == "full-match": + elemstr = str(list(visible.values())[0].elem) + filter_match = self._filter_matches_exactly(filterstr, elemstr) + follow = (keystr in visible) or filter_match + else: + follow = False + # save the keystr of the only one visible hint to be picked up + # later by self.follow_hint + self._context.to_follow = list(visible.keys())[0] + + if follow: + # 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) + def handle_partial_key(self, keystr): """Handle a new partial keypress.""" log.hints.debug("Handling new keystring: '{}'".format(keystr)) @@ -790,57 +845,7 @@ class HintManager(QObject): self._hide_elem(elem.label) except webelem.Error: 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.Error: - 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.set_inner_xml(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.Error: - pass - if not visible: - # Whoops, filtered all hints - modeman.leave(self._win_id, usertypes.KeyMode.hint, - 'all filtered') - return visible + self._handle_auto_follow(keystr=keystr) def filter_hints(self, filterstr): """Filter displayed hints according to a text. @@ -852,13 +857,15 @@ class HintManager(QObject): and `self._filterstr` are None, all hints are shown. """ if filterstr is None: - filterstr = self._filterstr + filterstr = self._context.filterstr else: - self._filterstr = filterstr + self._context.filterstr = filterstr + visible = [] for elem in self._context.all_elems: try: if self._filter_matches(filterstr, str(elem.elem)): + visible.append(elem) if self._is_hidden(elem.label): # hidden element which matches again -> show it self._show_elem(elem.label) @@ -868,35 +875,37 @@ class HintManager(QObject): except webelem.Error: pass - if self._context.hint_mode == 'number': - visible = self._filter_number_hints() - else: - visible = self._filter_non_number_hints() + if not visible: + # Whoops, filtered all hints + modeman.leave(self._win_id, usertypes.KeyMode.hint, + 'all filtered') + return - 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') + if self._context.hint_mode == 'number': + # renumber filtered hints + strings = self._hint_strings(visible) + self._context.elems = {} + for elem, string in zip(visible, strings): + elem.label.set_inner_xml(string) + self._context.elems[string] = elem 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) + keyparser = keyparsers[usertypes.KeyMode.hint] + keyparser.update_bindings(strings, preserve_filter=True) - def fire(self, keystr, force=False): + # Note: filter_hints can be called with non-None filterstr only + # when number mode is active + if filterstr is not None: + # pass self._context.elems as the dict of visible hints + self._handle_auto_follow(filterstr=filterstr, + visible=self._context.elems) + + def _fire(self, keystr): """Fire a completed hint. Args: keystr: The keychain string to follow. - force: When True, follow even when auto-follow is false. """ - if not (force or config.get('hints', 'auto-follow')): - self.handle_partial_key(keystr) - self._context.to_follow = keystr - return - # Handlers which take a QWebElement elem_handlers = { Target.normal: self._actions.click, @@ -969,7 +978,7 @@ class HintManager(QObject): keystring = self._context.to_follow elif keystring not in self._context.elems: raise cmdexc.CommandError("No hint {}!".format(keystring)) - self.fire(keystring, force=True) + self._fire(keystring) @pyqtSlot() def on_contents_size_changed(self): diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 5ef7d0ee6..df7ce6b96 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -365,6 +365,8 @@ class ConfigManager(QObject): _get_value_transformer({'false': 'none', 'true': 'debug'}), ('ui', 'keyhint-blacklist'): _get_value_transformer({'false': '*', 'true': ''}), + ('hints', 'auto-follow'): + _get_value_transformer({'false': 'never', 'true': 'unique-match'}), } changed = pyqtSignal(str, str) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 3704a9bf6..dba8d70be 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -936,9 +936,21 @@ def data(readonly=False): "The dictionary file to be used by the word hints."), ('auto-follow', - SettingValue(typ.Bool(), 'true'), - "Follow a hint immediately when the hint text is completely " - "matched."), + SettingValue(typ.String( + valid_values=typ.ValidValues( + ('always', "Auto-follow whenever there is only a single " + "hint on a page."), + ('unique-match', "Auto-follow whenever there is a unique " + "non-empty match in either the hint string (word mode) " + "or filter (number mode)."), + ('full-match', "Follow the hint when the user typed the " + "whole hint (letter, word or number mode) or the " + "element's text (only in number mode)."), + ('never', "The user will always need to press Enter to " + "follow a hint."), + )), 'unique-match'), + "Controls when a hint can be automatically followed without the " + "user pressing Enter."), ('auto-follow-timeout', SettingValue(typ.Int(), '0'), diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 223a09925..fb923bf92 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -231,7 +231,7 @@ class HintKeyParser(keyparser.CommandKeyParser): if keytype == self.Type.chain: hintmanager = objreg.get('hintmanager', scope='tab', window=self._win_id, tab='current') - hintmanager.fire(cmdstr) + hintmanager.handle_partial_key(cmdstr) else: # execute as command super().execute(cmdstr, keytype, count) diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 61874a1c6..f735bea38 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -39,10 +39,10 @@ Feature: Using hints - data/hello.txt (active) Scenario: Entering and leaving hinting mode (issue 1464) - When I open data/hints/html/simple.html - And I run :hint - And I run :fake-key -g - Then no crash should happen + When I open data/hints/html/simple.html + And I run :hint + And I run :fake-key -g + Then no crash should happen Scenario: Using :hint spawn with flags and -- (issue 797) When I open data/hints/html/simple.html @@ -225,7 +225,7 @@ Feature: Using hints 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 to unique-match And I set hints -> auto-follow-timeout to 0 And I run :hint all And I press the keys "ten pos" @@ -265,3 +265,133 @@ Feature: Using hints And I press the key "s" And I run :follow-hint 1 Then data/numbers/7.txt should be loaded + + ### auto-follow option + + Scenario: Using hints -> auto-follow == 'always' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to always + And I run :hint + Then data/hello.txt should be loaded + + # unique-match is actually the same as full-match in letter mode + Scenario: Using hints -> auto-follow == 'unique-match' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to unique-match + And I run :hint + And I press the key "a" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'full-match' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to full-match + And I run :hint + And I press the key "a" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'never' without Enter in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to never + And I run :hint + And I press the key "a" + Then "Leaving mode KeyMode.hint (reason: followed)" should not be logged + + Scenario: Using hints -> auto-follow == 'never' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to never + And I run :hint + And I press the key "a" + And I press the key "" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'always' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to always + And I run :hint + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'unique-match' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to unique-match + And I run :hint + And I press the key "f" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'full-match' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to full-match + And I run :hint + # this actually presses the keys one by one + And I press the key "follow me!" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'never' without Enter in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to never + And I run :hint + # this actually presses the keys one by one + And I press the key "follow me!" + Then "Leaving mode KeyMode.hint (reason: followed)" should not be logged + + Scenario: Using hints -> auto-follow == 'never' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to never + And I run :hint + # this actually presses the keys one by one + And I press the key "follow me!" + And I press the key "" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'always' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to always + And I run :hint + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'unique-match' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to unique-match + And I run :hint + # the link gets "hello" as the hint + And I press the key "h" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'full-match' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to full-match + And I run :hint + # this actually presses the keys one by one + And I press the key "hello" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'never' without Enter in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to never + And I run :hint + # this actually presses the keys one by one + And I press the key "hello" + Then "Leaving mode KeyMode.hint (reason: followed)" should not be logged + + Scenario: Using hints -> auto-follow == 'never' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to never + And I run :hint + # this actually presses the keys one by one + And I press the key "hello" + And I press the key "" + Then data/hello.txt should be loaded diff --git a/tests/end2end/features/test_hints_bdd.py b/tests/end2end/features/test_hints_bdd.py index 775c347be..b5304cb74 100644 --- a/tests/end2end/features/test_hints_bdd.py +++ b/tests/end2end/features/test_hints_bdd.py @@ -17,5 +17,30 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import textwrap + +import pytest + import pytest_bdd as bdd bdd.scenarios('hints.feature') + + +@pytest.fixture(autouse=True) +def set_up_word_hints(tmpdir, quteproc): + dict_file = tmpdir / 'dict' + dict_file.write(textwrap.dedent(""" + one + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + thirteen + """)) + quteproc.set_setting('hints', 'dictionary', str(dict_file))