Merge branch 'hints'

This commit is contained in:
Florian Bruhin 2016-06-07 15:43:25 +02:00
commit b972acf20c
9 changed files with 332 additions and 53 deletions

View File

@ -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
------

View File

@ -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

View File

@ -179,10 +179,11 @@
|<<hints-mode,mode>>|Mode to use for hints.
|<<hints-chars,chars>>|Chars used for hint strings.
|<<hints-min-chars,min-chars>>|Minimum number of chars used for hint strings.
|<<hints-scatter,scatter>>|Whether to scatter hint key chains (like Vimium) or not (like dwb).
|<<hints-scatter,scatter>>|Whether to scatter hint key chains (like Vimium) or not (like dwb). Ignored for number hints.
|<<hints-uppercase,uppercase>>|Make chars in hint strings uppercase.
|<<hints-dictionary,dictionary>>|The dictionary file to be used by the word hints.
|<<hints-auto-follow,auto-follow>>|Follow a hint immediately when the hint text is completely matched.
|<<hints-auto-follow-timeout,auto-follow-timeout>>|A timeout to inhibit normal-mode key bindings after a successfulauto-follow.
|<<hints-next-regexes,next-regexes>>|A comma-separated list of regexes to use for 'next' links.
|<<hints-prev-regexes,prev-regexes>>|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.

View File

@ -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(
'<font color="{}">{}</font>{}'.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:

View File

@ -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,'

View File

@ -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):

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Issue 1186</title>
</head>
<body>
<p>
This page contains 10 hints to test backspace handling, see #1186.
</p>
<p>
This requires setting hints -&gt; mode to number.
</p>
<p>
When pressing f, x, 0, Backspace, only hints starting with x should be
shown.
</p>
<ul>
<li><a href="/data/numbers/1.txt">x one</a></li>
<li><a href="/data/numbers/2.txt">x two</a></li>
<li><a href="/data/numbers/3.txt">x three</a></li>
<li><a href="/data/numbers/4.txt">x four</a></li>
<li><a href="/data/numbers/5.txt">x five</a></li>
<li><a href="/data/numbers/6.txt">x six</a></li>
<li><a href="/data/numbers/7.txt">x seven</a></li>
<li><a href="/data/numbers/8.txt">x eight</a></li>
<li><a href="/data/numbers/9.txt">x nine</a></li>
<li><a href="/data/numbers/10.txt">x ten</a></li>
<li><a href="/data/numbers/11.txt">x eleven</a></li>
<li><a href="/data/numbers/12.txt">twelve</a></li>
</ul>
</body>
</html>

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Numbered hints</title>
</head>
<body>
<p>
This page contains various links to test numbered hints. This requires setting hints -> mode to number.
</p>
<p>
Related issues:
<ul>
<li>#308 - Renumber hints when filtering them in number mode.</li>
<li>#576 - Keep hint filtering when rapid hinting in number mode</li>
<li>#674 (comment) - Multi-word matching for hints </li>
</ul>
</p>
<ul>
<li><a href="/data/numbers/1.txt">one</a></li>
<li><a href="/data/numbers/2.txt">two</a></li>
<li><a href="/data/numbers/3.txt">three</a></li>
<li><a href="/data/numbers/4.txt">four</a></li>
<li><a href="/data/numbers/5.txt">five</a></li>
<li><a href="/data/numbers/6.txt">six</a></li>
<li><a href="/data/numbers/7.txt">seven</a></li>
<li><a href="/data/numbers/8.txt">eight</a></li>
<li><a href="/data/numbers/9.txt">nine</a></li>
<li><a href="/data/numbers/10.txt">ten</a></li>
<li><a href="/data/numbers/11.txt">tenorhorn posaune (eleven)</a></li>
</ul>
</body>
</html>

View File

@ -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 "<Backspace>"
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