Merge branch 'NoctuaNivalis-master'

This commit is contained in:
Florian Bruhin 2016-02-02 18:59:06 +01:00
commit 2636467ea7
7 changed files with 190 additions and 31 deletions

View File

@ -26,6 +26,8 @@ Added
- A new command `:paste-primary` got added to paste the primary selection, and - A new command `:paste-primary` got added to paste the primary selection, and
`<Shift-Insert>` got added as a binding so it pastes primary rather than `<Shift-Insert>` got added as a binding so it pastes primary rather than
clipboard. clipboard.
- New mode `word` for `hints -> mode` which uses a dictionary and link-texts
for hints instead of single characters.
Changed Changed
~~~~~~~ ~~~~~~~

View File

@ -149,6 +149,7 @@ Contributors, sorted by the number of commits in descending order:
* Martin Tournoij * Martin Tournoij
* Raphael Pierzina * Raphael Pierzina
* Joel Torstensson * Joel Torstensson
* Felix Van der Jeugt
* Claude * Claude
* meles5 * meles5
* Patric Schmitz * Patric Schmitz
@ -171,7 +172,6 @@ Contributors, sorted by the number of commits in descending order:
* error800 * error800
* Zach-Button * Zach-Button
* Halfwit * Halfwit
* Felix Van der Jeugt
* rikn00 * rikn00
* Michael Ilsaas * Michael Ilsaas
* Martin Zimmermann * Martin Zimmermann

View File

@ -179,6 +179,7 @@
|<<hints-min-chars,min-chars>>|Minimum number of 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).
|<<hints-uppercase,uppercase>>|Make chars in hint strings uppercase. |<<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>>|Whether to auto-follow a hint if there's only one left. |<<hints-auto-follow,auto-follow>>|Whether to auto-follow a hint if there's only one left.
|<<hints-next-regexes,next-regexes>>|A comma-separated list of regexes to use for 'next' links. |<<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. |<<hints-prev-regexes,prev-regexes>>|A comma-separated list of regexes to use for 'prev' links.
@ -1538,6 +1539,7 @@ Valid values:
* +number+: Use numeric hints. * +number+: Use numeric hints.
* +letter+: Use the chars in the hints -> chars setting. * +letter+: Use the chars in the hints -> chars setting.
* +word+: Use hints words based on the html elements and the extra words.
Default: +pass:[letter]+ Default: +pass:[letter]+
@ -1575,6 +1577,12 @@ Valid values:
Default: +pass:[false]+ Default: +pass:[false]+
[[hints-dictionary]]
=== dictionary
The dictionary file to be used by the word hints.
Default: +pass:[/usr/share/dict/words]+
[[hints-auto-follow]] [[hints-auto-follow]]
=== auto-follow === auto-follow
Whether to auto-follow a hint if there's only one left. Whether to auto-follow a hint if there's only one left.

View File

@ -19,9 +19,11 @@
"""A HintManager to draw hints over links.""" """A HintManager to draw hints over links."""
import math
import functools
import collections import collections
import functools
import math
import re
import string
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl,
QTimer) QTimer)
@ -34,7 +36,8 @@ from qutebrowser.config import config
from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.keyinput import modeman, modeparsers
from qutebrowser.browser import webelem from qutebrowser.browser import webelem
from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners
from qutebrowser.utils import usertypes, log, qtutils, message, objreg from qutebrowser.utils import (usertypes, log, qtutils, message,
objreg)
from qutebrowser.misc import guiprocess from qutebrowser.misc import guiprocess
@ -47,6 +50,11 @@ Target = usertypes.enum('Target', ['normal', 'tab', 'tab_fg', 'tab_bg',
'spawn']) 'spawn'])
class WordHintingError(Exception):
"""Exception raised on errors during word hinting."""
@pyqtSlot(usertypes.KeyMode) @pyqtSlot(usertypes.KeyMode)
def on_mode_entered(mode, win_id): def on_mode_entered(mode, win_id):
"""Stop hinting when insert mode was entered.""" """Stop hinting when insert mode was entered."""
@ -146,6 +154,7 @@ class HintManager(QObject):
self._win_id = win_id self._win_id = win_id
self._tab_id = tab_id self._tab_id = tab_id
self._context = None self._context = None
self._word_hinter = WordHinter()
mode_manager = objreg.get('mode-manager', scope='window', mode_manager = objreg.get('mode-manager', scope='window',
window=win_id) window=win_id)
mode_manager.left.connect(self.on_mode_left) mode_manager.left.connect(self.on_mode_left)
@ -198,7 +207,14 @@ class HintManager(QObject):
Return: Return:
A list of hint strings, in the same order as the elements. A list of hint strings, in the same order as the elements.
""" """
if config.get('hints', 'mode') == 'number': hint_mode = config.get('hints', 'mode')
if hint_mode == 'word':
try:
return self._word_hinter.hint(elems)
except WordHintingError as e:
message.error(self._win_id, str(e), immediately=True)
# falls back on letter hints
if hint_mode == 'number':
chars = '0123456789' chars = '0123456789'
else: else:
chars = config.get('hints', 'chars') chars = config.get('hints', 'chars')
@ -373,7 +389,7 @@ class HintManager(QObject):
label.setStyleProperty('left', '{}px !important'.format(left)) label.setStyleProperty('left', '{}px !important'.format(left))
label.setStyleProperty('top', '{}px !important'.format(top)) label.setStyleProperty('top', '{}px !important'.format(top))
def _draw_label(self, elem, string): def _draw_label(self, elem, text):
"""Draw a hint label over an element. """Draw a hint label over an element.
Args: Args:
@ -398,7 +414,7 @@ class HintManager(QObject):
label = webelem.WebElementWrapper(parent.lastChild()) label = webelem.WebElementWrapper(parent.lastChild())
label['class'] = 'qutehint' label['class'] = 'qutehint'
self._set_style_properties(elem, label) self._set_style_properties(elem, label)
label.setPlainText(string) label.setPlainText(text)
return label return label
def _show_url_error(self): def _show_url_error(self):
@ -662,14 +678,14 @@ class HintManager(QObject):
elems = [e for e in elems if filterfunc(e)] elems = [e for e in elems if filterfunc(e)]
if not elems: if not elems:
raise cmdexc.CommandError("No elements found.") raise cmdexc.CommandError("No elements found.")
strings = self._hint_strings(elems) hints = self._hint_strings(elems)
for e, string in zip(elems, strings): for e, hint in zip(elems, hints):
label = self._draw_label(e, string) label = self._draw_label(e, hint)
self._context.elems[string] = ElemTuple(e, label) self._context.elems[hint] = ElemTuple(e, label)
keyparsers = objreg.get('keyparsers', scope='window', keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id) window=self._win_id)
keyparser = keyparsers[usertypes.KeyMode.hint] keyparser = keyparsers[usertypes.KeyMode.hint]
keyparser.update_bindings(strings) keyparser.update_bindings(hints)
def follow_prevnext(self, frame, baseurl, prev=False, tab=False, def follow_prevnext(self, frame, baseurl, prev=False, tab=False,
background=False, window=False): background=False, window=False):
@ -812,11 +828,11 @@ class HintManager(QObject):
def handle_partial_key(self, keystr): def handle_partial_key(self, keystr):
"""Handle a new partial keypress.""" """Handle a new partial keypress."""
log.hints.debug("Handling new keystring: '{}'".format(keystr)) log.hints.debug("Handling new keystring: '{}'".format(keystr))
for (string, elems) in self._context.elems.items(): for (text, elems) in self._context.elems.items():
try: try:
if string.startswith(keystr): if text.startswith(keystr):
matched = string[:len(keystr)] matched = text[:len(keystr)]
rest = string[len(keystr):] rest = text[len(keystr):]
match_color = config.get('colors', 'hints.fg.match') match_color = config.get('colors', 'hints.fg.match')
elems.label.setInnerXml( elems.label.setInnerXml(
'<font color="{}">{}</font>{}'.format( '<font color="{}">{}</font>{}'.format(
@ -917,8 +933,8 @@ class HintManager(QObject):
# Show all hints again # Show all hints again
self.filter_hints(None) self.filter_hints(None)
# Undo keystring highlighting # Undo keystring highlighting
for (string, elems) in self._context.elems.items(): for (text, elems) in self._context.elems.items():
elems.label.setInnerXml(string) elems.label.setInnerXml(text)
handler() handler()
@cmdutils.register(instance='hintmanager', scope='tab', hide=True, @cmdutils.register(instance='hintmanager', scope='tab', hide=True,
@ -961,3 +977,110 @@ class HintManager(QObject):
# hinting. # hinting.
return return
self._cleanup() self._cleanup()
class WordHinter:
"""Generator for word hints.
Attributes:
words: A set of words to be used when no "smart hint" can be
derived from the hinted element.
"""
def __init__(self):
# will be initialized on first use.
self.words = set()
def ensure_initialized(self):
"""Generate the used words if yet uninialized."""
if not self.words:
dictionary = config.get("hints", "dictionary")
try:
with open(dictionary, encoding="UTF-8") as wordfile:
alphabet = set(string.ascii_lowercase)
hints = set()
lines = (line.rstrip().lower() for line in wordfile)
for word in lines:
if set(word) - alphabet:
# contains none-alphabetic chars
continue
if len(word) > 4:
# we don't need words longer than 4
continue
for i in range(len(word)):
# remove all prefixes of this word
hints.discard(word[:i + 1])
hints.add(word)
self.words.update(hints)
except IOError as e:
error = "Word hints requires reading the file at {}: {}"
raise WordHintingError(error.format(dictionary, str(e)))
def extract_tag_words(self, elem):
"""Extract tag words form the given element."""
attr_extractors = {
"alt": lambda elem: elem["alt"],
"name": lambda elem: elem["name"],
"title": lambda elem: elem["title"],
"src": lambda elem: elem["src"].split('/')[-1],
"href": lambda elem: elem["href"].split('/')[-1],
"text": str,
}
extractable_attrs = collections.defaultdict(
list, {
"IMG": ["alt", "title", "src"],
"A": ["title", "href", "text"],
"INPUT": ["name"]
}
)
return (attr_extractors[attr](elem)
for attr in extractable_attrs[elem.tagName()]
if attr in elem or attr == "text")
def tag_words_to_hints(self, words):
"""Take words and transform them to proper hints if possible."""
for candidate in words:
if not candidate:
continue
match = re.search('[A-Za-z]{3,}', candidate)
if not match:
continue
if 4 < match.end() - match.start() < 8:
yield candidate[match.start():match.end()].lower()
def any_prefix(self, hint, existing):
return any(hint.startswith(e) or e.startswith(hint)
for e in existing)
def new_hint_for(self, elem, existing):
"""Return a hint for elem, not conflicting with the existing."""
new = self.tag_words_to_hints(self.extract_tag_words(elem))
no_prefixes = (h for h in new if not self.any_prefix(h, existing))
# either the first good, or None
return next(no_prefixes, None)
def hint(self, elems):
"""Produce hint labels based on the html tags.
Produce hint words based on the link text and random words
from the words arg as fallback.
Args:
words: Words to use as fallback when no link text can be used.
elems: The elements to get hint strings for.
Return:
A list of hint strings, in the same order as the elements.
"""
self.ensure_initialized()
hints = []
used_hints = set()
words = iter(self.words)
for elem in elems:
hint = self.new_hint_for(elem, used_hints) or next(words)
used_hints.add(hint)
hints.append(hint)
return hints

View File

@ -863,7 +863,9 @@ def data(readonly=False):
valid_values=typ.ValidValues( valid_values=typ.ValidValues(
('number', "Use numeric hints."), ('number', "Use numeric hints."),
('letter', "Use the chars in the hints -> " ('letter', "Use the chars in the hints -> "
"chars setting.") "chars setting."),
('word', "Use hints words based on the html "
"elements and the extra words."),
)), 'letter'), )), 'letter'),
"Mode to use for hints."), "Mode to use for hints."),
@ -888,6 +890,10 @@ def data(readonly=False):
SettingValue(typ.Bool(), 'false'), SettingValue(typ.Bool(), 'false'),
"Make chars in hint strings uppercase."), "Make chars in hint strings uppercase."),
('dictionary',
SettingValue(typ.File(required=False), '/usr/share/dict/words'),
"The dictionary file to be used by the word hints."),
('auto-follow', ('auto-follow',
SettingValue(typ.Bool(), 'true'), SettingValue(typ.Bool(), 'true'),
"Whether to auto-follow a hint if there's only one left."), "Whether to auto-follow a hint if there's only one left."),

View File

@ -901,6 +901,10 @@ class File(BaseType):
"""A file on the local filesystem.""" """A file on the local filesystem."""
def __init__(self, required=True, **kwargs):
super().__init__(**kwargs)
self.required = required
def transform(self, value): def transform(self, value):
if not value: if not value:
return None return None
@ -909,7 +913,7 @@ class File(BaseType):
if not os.path.isabs(value): if not os.path.isabs(value):
cfgdir = standarddir.config() cfgdir = standarddir.config()
assert cfgdir is not None assert cfgdir is not None
return os.path.join(cfgdir, value) value = os.path.join(cfgdir, value)
return value return value
def validate(self, value): def validate(self, value):
@ -925,15 +929,13 @@ class File(BaseType):
raise configexc.ValidationError( raise configexc.ValidationError(
value, "must be an absolute path when not using a " value, "must be an absolute path when not using a "
"config directory!") "config directory!")
elif not os.path.isfile(os.path.join(cfgdir, value)): value = os.path.join(cfgdir, value)
raise configexc.ValidationError( not_isfile_message = ("must be a valid path relative to the "
value, "must be a valid path relative to the config " "config directory!")
"directory!") else:
else: not_isfile_message = "must be a valid file!"
return if self.required and not os.path.isfile(value):
elif not os.path.isfile(value): raise configexc.ValidationError(value, not_isfile_message)
raise configexc.ValidationError(
value, "must be a valid file!")
except UnicodeEncodeError as e: except UnicodeEncodeError as e:
raise configexc.ValidationError(value, e) raise configexc.ValidationError(value, e)

View File

@ -1268,12 +1268,21 @@ class TestRegexList:
assert klass().transform(val) == expected assert klass().transform(val) == expected
def unrequired_class(**kwargs):
return configtypes.File(required=False, **kwargs)
@pytest.mark.usefixtures('qapp') @pytest.mark.usefixtures('qapp')
class TestFileAndUserStyleSheet: class TestFileAndUserStyleSheet:
"""Test File/UserStyleSheet.""" """Test File/UserStyleSheet."""
@pytest.fixture(params=[configtypes.File, configtypes.UserStyleSheet]) @pytest.fixture(params=[
configtypes.File,
configtypes.UserStyleSheet,
unrequired_class,
])
def klass(self, request): def klass(self, request):
return request.param return request.param
@ -1293,6 +1302,8 @@ class TestFileAndUserStyleSheet:
return arg return arg
elif klass is configtypes.UserStyleSheet: elif klass is configtypes.UserStyleSheet:
return QUrl.fromLocalFile(arg) return QUrl.fromLocalFile(arg)
elif klass is unrequired_class:
return arg
else: else:
assert False, klass assert False, klass
@ -1309,6 +1320,11 @@ class TestFileAndUserStyleSheet:
with pytest.raises(configexc.ValidationError): with pytest.raises(configexc.ValidationError):
configtypes.File().validate('foobar') configtypes.File().validate('foobar')
def test_validate_does_not_exist_optional_file(self, os_mock):
"""Test validate with a file which does not exist (File)."""
os_mock.path.isfile.return_value = False
configtypes.File(required=False).validate('foobar')
def test_validate_does_not_exist_userstylesheet(self, os_mock): def test_validate_does_not_exist_userstylesheet(self, os_mock):
"""Test validate with a file which does not exist (UserStyleSheet).""" """Test validate with a file which does not exist (UserStyleSheet)."""
os_mock.path.isfile.return_value = False os_mock.path.isfile.return_value = False
@ -1343,7 +1359,9 @@ class TestFileAndUserStyleSheet:
(configtypes.File(), 'foobar', True), (configtypes.File(), 'foobar', True),
(configtypes.UserStyleSheet(), 'foobar', False), (configtypes.UserStyleSheet(), 'foobar', False),
(configtypes.UserStyleSheet(), '\ud800', True), (configtypes.UserStyleSheet(), '\ud800', True),
], ids=['file-foobar', 'userstylesheet-foobar', 'userstylesheet-unicode']) (configtypes.File(required=False), 'foobar', False),
], ids=['file-foobar', 'userstylesheet-foobar', 'userstylesheet-unicode',
'file-optional-foobar'])
def test_validate_rel_inexistent(self, os_mock, monkeypatch, configtype, def test_validate_rel_inexistent(self, os_mock, monkeypatch, configtype,
value, raises): value, raises):
"""Test with a relative path and standarddir.config returning None.""" """Test with a relative path and standarddir.config returning None."""