From 49e6b656f64c3772131628497ce04a7ee1fabd5b Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 09:52:06 +0100 Subject: [PATCH 01/27] add word hints --- qutebrowser/browser/hints.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 8ba342db7..93cb84621 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -19,6 +19,7 @@ """A HintManager to draw hints over links.""" +import os import math import functools import collections @@ -34,7 +35,8 @@ from qutebrowser.config import config from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.browser import webelem 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, standarddir) from qutebrowser.misc import guiprocess @@ -136,6 +138,9 @@ class HintManager(QObject): Target.spawn: "Spawn command via hint", } + with open(os.path.join(standarddir.config(), "hints")) as hintfile: + HINT_WORDS = [hint.rstrip() for hint in hintfile] + mouse_event = pyqtSignal('QMouseEvent') start_hinting = pyqtSignal(usertypes.ClickTarget) stop_hinting = pyqtSignal() @@ -198,6 +203,8 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ + if config.get('hints', 'mode') == 'words': + return HINT_WORDS[:len(elems)] if config.get('hints', 'mode') == 'number': chars = '0123456789' else: From 3be81ba62a0cdaa0e0936d9ec5b17b0a69a12610 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 10:12:40 +0100 Subject: [PATCH 02/27] word hints should be generated on first call --- qutebrowser/browser/hints.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 93cb84621..e88ec6378 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -138,9 +138,6 @@ class HintManager(QObject): Target.spawn: "Spawn command via hint", } - with open(os.path.join(standarddir.config(), "hints")) as hintfile: - HINT_WORDS = [hint.rstrip() for hint in hintfile] - mouse_event = pyqtSignal('QMouseEvent') start_hinting = pyqtSignal(usertypes.ClickTarget) stop_hinting = pyqtSignal() @@ -155,6 +152,12 @@ class HintManager(QObject): window=win_id) mode_manager.left.connect(self.on_mode_left) + def _get_word_hints(self, words=[]): + if not words: + with open(os.path.join(standarddir.config(), "hints")) as hintfile: + words.extend(hint.rstrip() for hint in hintfile) + return words + def _get_text(self): """Get a hint text based on the current context.""" text = self.HINT_TEXTS[self._context.target] @@ -204,7 +207,7 @@ class HintManager(QObject): A list of hint strings, in the same order as the elements. """ if config.get('hints', 'mode') == 'words': - return HINT_WORDS[:len(elems)] + return self._get_word_hints()[:len(elems)] if config.get('hints', 'mode') == 'number': chars = '0123456789' else: From f549b4aa1ee00c0ff94ce4112a22b7f864f43a4a Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 10:12:55 +0100 Subject: [PATCH 03/27] add word hint generator script --- scripts/dictionary_to_word_hints.py | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 scripts/dictionary_to_word_hints.py diff --git a/scripts/dictionary_to_word_hints.py b/scripts/dictionary_to_word_hints.py new file mode 100644 index 000000000..761baa4d5 --- /dev/null +++ b/scripts/dictionary_to_word_hints.py @@ -0,0 +1,39 @@ + +import sys +import string + +def filter_hints(words): + + alphabet = set("asdflkjqwerpoiu") + hints = set() + + for word in words: + + # hints should be lowercase + word = word.lower() + + # hints should be alphabetic + if not set(word) <= alphabet: + continue + + # hints shouldn't be longer than 5 characters + if len(word) > 5: + continue + + # hints should not be prefixes of other hints. we prefer the + # longer ones. + for i in range(len(word)): + hints.discard(word[:i+1]) + + hints.add(word) + + yield from hints + +def main(): + inlines = (line.rstrip() for line in sys.stdin) + outlines = ("{}\n".format(hint) for hint in filter_hints(inlines)) + sys.stdout.writelines(outlines) + +if __name__ == "__main__": + main() + From aaad8588b60f0062789258e110c2cd0a50d13287 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 15:17:26 +0100 Subject: [PATCH 04/27] include dictionary parsing in first hinting I though this would be to slow, but it's actually OK --- qutebrowser/browser/hints.py | 21 +++++++++++++--- scripts/dictionary_to_word_hints.py | 39 ----------------------------- 2 files changed, 18 insertions(+), 42 deletions(-) delete mode 100644 scripts/dictionary_to_word_hints.py diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index e88ec6378..ba00cf75d 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -20,6 +20,7 @@ """A HintManager to draw hints over links.""" import os +import string import math import functools import collections @@ -154,8 +155,17 @@ class HintManager(QObject): def _get_word_hints(self, words=[]): if not words: - with open(os.path.join(standarddir.config(), "hints")) as hintfile: - words.extend(hint.rstrip() for hint in hintfile) + with open("/usr/share/dict/words") as wordfile: + alphabet = set(string.ascii_lowercase) + hints = set() + lines = (line.rstrip().lower() for line in wordfile) + for word in lines: + if not set(word) <= alphabet: continue + if not len(word) <= 4: continue + for i in range(len(word)): + hints.discard(word[:i+1]) + hints.add(word) + words.extend(hints) return words def _get_text(self): @@ -207,7 +217,12 @@ class HintManager(QObject): A list of hint strings, in the same order as the elements. """ if config.get('hints', 'mode') == 'words': - return self._get_word_hints()[:len(elems)] + try: + return self._get_word_hints()[:len(elems)] + except IOError: + message.error(self._win_id, "Word hints require a dictionary" + + " at /usr/share/dict/words.", immediately=True) + # falls back on letter hints if config.get('hints', 'mode') == 'number': chars = '0123456789' else: diff --git a/scripts/dictionary_to_word_hints.py b/scripts/dictionary_to_word_hints.py deleted file mode 100644 index 761baa4d5..000000000 --- a/scripts/dictionary_to_word_hints.py +++ /dev/null @@ -1,39 +0,0 @@ - -import sys -import string - -def filter_hints(words): - - alphabet = set("asdflkjqwerpoiu") - hints = set() - - for word in words: - - # hints should be lowercase - word = word.lower() - - # hints should be alphabetic - if not set(word) <= alphabet: - continue - - # hints shouldn't be longer than 5 characters - if len(word) > 5: - continue - - # hints should not be prefixes of other hints. we prefer the - # longer ones. - for i in range(len(word)): - hints.discard(word[:i+1]) - - hints.add(word) - - yield from hints - -def main(): - inlines = (line.rstrip() for line in sys.stdin) - outlines = ("{}\n".format(hint) for hint in filter_hints(inlines)) - sys.stdout.writelines(outlines) - -if __name__ == "__main__": - main() - From 86828930a23820bfbae7855c8da4080e27cc30f0 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 16:51:33 +0100 Subject: [PATCH 05/27] use object state in stead of class state to store hint words --- qutebrowser/browser/hints.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index ba00cf75d..0badabadf 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -149,12 +149,13 @@ class HintManager(QObject): self._win_id = win_id self._tab_id = tab_id self._context = None + self._words = [] # initialized on first word hint use mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.left.connect(self.on_mode_left) - def _get_word_hints(self, words=[]): - if not words: + def _get_word_hints(self): + if not self._words: with open("/usr/share/dict/words") as wordfile: alphabet = set(string.ascii_lowercase) hints = set() @@ -165,8 +166,8 @@ class HintManager(QObject): for i in range(len(word)): hints.discard(word[:i+1]) hints.add(word) - words.extend(hints) - return words + self._words.extend(hints) + return self._words def _get_text(self): """Get a hint text based on the current context.""" From 50b7f260c760c94d50212753cb02ea0d1aa2af1a Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 17:28:10 +0100 Subject: [PATCH 06/27] use link text as hints --- qutebrowser/browser/hints.py | 39 ++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 0badabadf..8128f7131 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -149,12 +149,12 @@ class HintManager(QObject): self._win_id = win_id self._tab_id = tab_id self._context = None - self._words = [] # initialized on first word hint use + self._words = set() # initialized on first word hint use mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.left.connect(self.on_mode_left) - def _get_word_hints(self): + def _initialize_word_hints(self): if not self._words: with open("/usr/share/dict/words") as wordfile: alphabet = set(string.ascii_lowercase) @@ -166,7 +166,7 @@ class HintManager(QObject): for i in range(len(word)): hints.discard(word[:i+1]) hints.add(word) - self._words.extend(hints) + self._words.update(hints) return self._words def _get_text(self): @@ -219,7 +219,8 @@ class HintManager(QObject): """ if config.get('hints', 'mode') == 'words': try: - return self._get_word_hints()[:len(elems)] + self._initialize_word_hints() + return self._hint_words(elems) except IOError: message.error(self._win_id, "Word hints require a dictionary" + " at /usr/share/dict/words.", immediately=True) @@ -234,6 +235,29 @@ class HintManager(QObject): else: return self._hint_linear(min_chars, chars, elems) + def _hint_words(self, elems): + """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. + """ + hints = [] + hintss = set() + words = iter(self._words) + for elem in elems: + hint = _html_text_to_hint(str(elem)) or next(words) + while set(hint[:i+1] for i in range(len(hint))) & set(hintss): + hint = next(words) + hintss.add(hint) + hints.append(hint) + return hints + + def _hint_scattered(self, min_chars, chars, elems): """Produce scattered hint labels with variable length (like Vimium). @@ -985,3 +1009,10 @@ class HintManager(QObject): # hinting. return self._cleanup() + +def _html_text_to_hint(text): + if not text: return None + hint = text.split()[0].lower() + if hint.isalpha(): + return hint + return None From 2f9051c6e1cc0f85b8f724215fcaa1d99e895583 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Thu, 10 Dec 2015 18:17:13 +0100 Subject: [PATCH 07/27] shorten unique word hints --- qutebrowser/browser/hints.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 8128f7131..171b09dde 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -246,12 +246,26 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ + + def html_text_to_hint(text): + if not text: return None + hint = text.split()[0].lower() + if hint.isalpha(): + return hint + return None + + def is_prefix(hint, existing): + return set(hint[:i+1] for i in range(len(hint))) & set(existing) + hints = [] hintss = set() words = iter(self._words) for elem in elems: - hint = _html_text_to_hint(str(elem)) or next(words) - while set(hint[:i+1] for i in range(len(hint))) & set(hintss): + hint = html_text_to_hint(str(elem)) + if hint and len(hint) >= 3 and not is_prefix(hint, hintss): + hint = next(hint[:i] for i in range(3, len(hint) + 1) + if not is_prefix(hint[:i], hintss)) + while not hint or is_prefix(hint, hintss): hint = next(words) hintss.add(hint) hints.append(hint) @@ -1010,9 +1024,3 @@ class HintManager(QObject): return self._cleanup() -def _html_text_to_hint(text): - if not text: return None - hint = text.split()[0].lower() - if hint.isalpha(): - return hint - return None From 1dfcf99d2221706f57abda72177078b66382afc8 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sun, 13 Dec 2015 23:40:36 +0100 Subject: [PATCH 08/27] more extensive smart hinting --- qutebrowser/browser/hints.py | 58 +++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 171b09dde..abd066fe2 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -19,11 +19,13 @@ """A HintManager to draw hints over links.""" -import os -import string -import math -import functools import collections +import itertools +import functools +import math +import os +import re +import string from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) @@ -49,7 +51,6 @@ Target = usertypes.enum('Target', ['normal', 'tab', 'tab_fg', 'tab_bg', 'fill', 'hover', 'download', 'userscript', 'spawn']) - @pyqtSlot(usertypes.KeyMode) def on_mode_entered(mode, win_id): """Stop hinting when insert mode was entered.""" @@ -139,6 +140,8 @@ class HintManager(QObject): Target.spawn: "Spawn command via hint", } + FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') + mouse_event = pyqtSignal('QMouseEvent') start_hinting = pyqtSignal(usertypes.ClickTarget) stop_hinting = pyqtSignal() @@ -247,27 +250,42 @@ class HintManager(QObject): A list of hint strings, in the same order as the elements. """ - def html_text_to_hint(text): - if not text: return None - hint = text.split()[0].lower() - if hint.isalpha(): - return hint - return None + def html_elem_to_hints(elem): + candidates = [] + if elem.tagName() == "IMG": + "alt" in elem and candidates.append(elem["alt"]) + "title" in elem and candidates.append(elem["title"]) + "src" in elem and candidates.append(elem["src"].split('/')[-1]) + elif elem.tagName() == "A": + candidates.append(str(elem)) + "title" in elem and candidates.append(elem["title"]) + "href" in elem and candidates.append(elem["href"].split('/')[-1]) + elif elem.tagName() == "INPUT": + "name" in elem and candidates.append(elem["name"]) + for candidate in candidates: + if not candidate: continue + match = self.FIRST_ALPHABETIC.search(candidate) + if not match: continue + yield candidate[match.start():match.end()].lower() def is_prefix(hint, existing): return set(hint[:i+1] for i in range(len(hint))) & set(existing) + def first_good_hint(new, existing): + for hint in new: + # some none's + if not hint: continue + if len(hint) < 3: continue + # not a prefix of an existing hint + if set(hint[:i+1] for i in range(len(hint))) & set(existing): continue + return hint + hints = [] - hintss = set() - words = iter(self._words) + used_hints = set() + words = iter(self._initialize_word_hints()) for elem in elems: - hint = html_text_to_hint(str(elem)) - if hint and len(hint) >= 3 and not is_prefix(hint, hintss): - hint = next(hint[:i] for i in range(3, len(hint) + 1) - if not is_prefix(hint[:i], hintss)) - while not hint or is_prefix(hint, hintss): - hint = next(words) - hintss.add(hint) + hint = first_good_hint(html_elem_to_hints(elem), used_hints) or next(words) + used_hints.add(hint) hints.append(hint) return hints From 766a94a539cdc30e3cb92677dbe840b3d6a9b8d0 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 18 Dec 2015 09:45:58 +0100 Subject: [PATCH 09/27] fixed when new hints are prefixes of existing good thing I used this some days before any merging --- qutebrowser/browser/hints.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index abd066fe2..bbdad75d1 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -268,16 +268,15 @@ class HintManager(QObject): if not match: continue yield candidate[match.start():match.end()].lower() - def is_prefix(hint, existing): - return set(hint[:i+1] for i in range(len(hint))) & set(existing) + def any_prefix(hint, existing): + return any(hint.startswith(e) or e.startswith(hint) for e in existing) def first_good_hint(new, existing): for hint in new: # some none's if not hint: continue if len(hint) < 3: continue - # not a prefix of an existing hint - if set(hint[:i+1] for i in range(len(hint))) & set(existing): continue + if any_prefix(hint, existing): continue return hint hints = [] From 351420310d96612fa97331aa32965e29b6376579 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 18 Dec 2015 22:12:58 +0100 Subject: [PATCH 10/27] fix some of the style warnings and errors --- qutebrowser/browser/hints.py | 48 ++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index bbdad75d1..94a29257f 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -25,7 +25,6 @@ import functools import math import os import re -import string from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) @@ -51,6 +50,7 @@ Target = usertypes.enum('Target', ['normal', 'tab', 'tab_fg', 'tab_bg', 'fill', 'hover', 'download', 'userscript', 'spawn']) + @pyqtSlot(usertypes.KeyMode) def on_mode_entered(mode, win_id): """Stop hinting when insert mode was entered.""" @@ -152,7 +152,7 @@ class HintManager(QObject): self._win_id = win_id self._tab_id = tab_id self._context = None - self._words = set() # initialized on first word hint use + self._words = set() # initialized on first word hint use mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.left.connect(self.on_mode_left) @@ -164,10 +164,12 @@ class HintManager(QObject): hints = set() lines = (line.rstrip().lower() for line in wordfile) for word in lines: - if not set(word) <= alphabet: continue - if not len(word) <= 4: continue + if not set(word) <= alphabet: + continue + if not len(word) <= 4: + continue for i in range(len(word)): - hints.discard(word[:i+1]) + hints.discard(word[:i + 1]) hints.add(word) self._words.update(hints) return self._words @@ -222,8 +224,8 @@ class HintManager(QObject): """ if config.get('hints', 'mode') == 'words': try: - self._initialize_word_hints() - return self._hint_words(elems) + words = iter(self._initialize_word_hints()) + return self._hint_words(words, elems) except IOError: message.error(self._win_id, "Word hints require a dictionary" + " at /usr/share/dict/words.", immediately=True) @@ -238,8 +240,10 @@ class HintManager(QObject): else: return self._hint_linear(min_chars, chars, elems) - def _hint_words(self, elems): - """Produce hint words based on the link text and random words + def _hint_words(self, words, 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: @@ -249,46 +253,43 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ - def html_elem_to_hints(elem): candidates = [] if elem.tagName() == "IMG": - "alt" in elem and candidates.append(elem["alt"]) + "alt" in elem and candidates.append(elem["alt"]) "title" in elem and candidates.append(elem["title"]) - "src" in elem and candidates.append(elem["src"].split('/')[-1]) + "src" in elem and candidates.append(elem["src"].split('/')[-1]) elif elem.tagName() == "A": candidates.append(str(elem)) "title" in elem and candidates.append(elem["title"]) - "href" in elem and candidates.append(elem["href"].split('/')[-1]) + "href" in elem and candidates.append(elem["href"].split('/')[-1]) elif elem.tagName() == "INPUT": "name" in elem and candidates.append(elem["name"]) for candidate in candidates: - if not candidate: continue + if not candidate: + continue match = self.FIRST_ALPHABETIC.search(candidate) - if not match: continue + if not match: + continue yield candidate[match.start():match.end()].lower() def any_prefix(hint, existing): return any(hint.startswith(e) or e.startswith(hint) for e in existing) def first_good_hint(new, existing): - for hint in new: - # some none's - if not hint: continue - if len(hint) < 3: continue - if any_prefix(hint, existing): continue - return hint + new = filter(bool, new) + new = filter(lambda h: len(h) > 4, new) + new = filter(lambda h: not any_prefix(h, existing), new) + return next(hint, None) # either the first good, or None hints = [] used_hints = set() - words = iter(self._initialize_word_hints()) for elem in elems: hint = first_good_hint(html_elem_to_hints(elem), used_hints) or next(words) used_hints.add(hint) hints.append(hint) return hints - def _hint_scattered(self, min_chars, chars, elems): """Produce scattered hint labels with variable length (like Vimium). @@ -1040,4 +1041,3 @@ class HintManager(QObject): # hinting. return self._cleanup() - From 38803375f5feba112d5c5a102d69fedd4f895cfc Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 18 Dec 2015 22:28:37 +0100 Subject: [PATCH 11/27] add dictionary config value and fix wrong variable --- qutebrowser/browser/hints.py | 5 +++-- qutebrowser/config/configdata.py | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 94a29257f..87a1e2f9a 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -25,6 +25,7 @@ import functools import math import os import re +import string from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) @@ -159,7 +160,7 @@ class HintManager(QObject): def _initialize_word_hints(self): if not self._words: - with open("/usr/share/dict/words") as wordfile: + with open(config.get("hints", "dictionary")) as wordfile: alphabet = set(string.ascii_lowercase) hints = set() lines = (line.rstrip().lower() for line in wordfile) @@ -280,7 +281,7 @@ class HintManager(QObject): new = filter(bool, new) new = filter(lambda h: len(h) > 4, new) new = filter(lambda h: not any_prefix(h, existing), new) - return next(hint, None) # either the first good, or None + return next(new, None) # either the first good, or None hints = [] used_hints = set() diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index a9fbb2669..2ee19f652 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -863,7 +863,8 @@ def data(readonly=False): valid_values=typ.ValidValues( ('number', "Use numeric 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'), "Mode to use for hints."), @@ -888,6 +889,10 @@ def data(readonly=False): SettingValue(typ.Bool(), 'false'), "Make chars in hint strings uppercase."), + ('dictionary', + SettingValue(typ.File(), '/usr/share/dict/words'), + "The dictionary file to be used by the word hints."), + ('auto-follow', SettingValue(typ.Bool(), 'true'), "Whether to auto-follow a hint if there's only one left."), From 4814abe286198b95b41812aa0b14b824310d01b2 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 18 Dec 2015 23:06:26 +0100 Subject: [PATCH 12/27] refactor tag extraction and fix string shadowing --- qutebrowser/browser/hints.py | 60 ++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 87a1e2f9a..8b5fb82f3 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -25,7 +25,7 @@ import functools import math import os import re -import string +from string import ascii_lowercase from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) @@ -161,7 +161,7 @@ class HintManager(QObject): def _initialize_word_hints(self): if not self._words: with open(config.get("hints", "dictionary")) 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: @@ -254,39 +254,53 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ - def html_elem_to_hints(elem): - candidates = [] - if elem.tagName() == "IMG": - "alt" in elem and candidates.append(elem["alt"]) - "title" in elem and candidates.append(elem["title"]) - "src" in elem and candidates.append(elem["src"].split('/')[-1]) - elif elem.tagName() == "A": - candidates.append(str(elem)) - "title" in elem and candidates.append(elem["title"]) - "href" in elem and candidates.append(elem["href"].split('/')[-1]) - elif elem.tagName() == "INPUT": - "name" in elem and candidates.append(elem["name"]) - for candidate in candidates: - if not candidate: - continue + just_get_it = lambda tag: lambda elem: elem[tag] + take_last_part = lambda tag: lambda elem: elem[tag].split('/')[-1] + tag_extractors = { + "alt": just_get_it("alt"), + "title": just_get_it("title"), + "src": take_last_part("src"), + "href": take_last_part("href"), + "name": just_get_it("name"), + } + + tags_for = collections.defaultdict(list, { + "IMG": ["alt", "title", "src"], + "A": ["title", "href"], + "INPUT": ["name"], + }) + + def extract_tag_words(elem): + if elem.tagName() == "A": + # link text is a special case, alas. + yield str(elem) + yield from (tag_extractors[tag](elem) + for tag in tags_for[elem.tagName()] + if tag in elem) + + def tag_words_to_hints(words): + for candidate in filter(bool, words): match = self.FIRST_ALPHABETIC.search(candidate) if not match: continue + if match.end() - match.start() < 4: + continue yield candidate[match.start():match.end()].lower() def any_prefix(hint, existing): - return any(hint.startswith(e) or e.startswith(hint) for e in existing) + return any(hint.startswith(e) or e.startswith(hint) + for e in existing) - def first_good_hint(new, existing): - new = filter(bool, new) - new = filter(lambda h: len(h) > 4, new) + def new_hint_for(elem, existing): + new = tag_words_to_hints(extract_tag_words(elem)) new = filter(lambda h: not any_prefix(h, existing), new) - return next(new, None) # either the first good, or None + # either the first good, or None + return next(new, None) hints = [] used_hints = set() for elem in elems: - hint = first_good_hint(html_elem_to_hints(elem), used_hints) or next(words) + hint = new_hint_for(elem, used_hints) or next(words) used_hints.add(hint) hints.append(hint) return hints From d0979b9fac4ab531192822515d165467916c3eb1 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 18 Dec 2015 23:40:44 +0100 Subject: [PATCH 13/27] fix pep8 and pylint errors Though I quite disagree on some remarks, as usual. --- qutebrowser/browser/hints.py | 37 ++++++++++++++++---------------- qutebrowser/config/configdata.py | 3 ++- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 8b5fb82f3..712b2ce5e 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -20,10 +20,8 @@ """A HintManager to draw hints over links.""" import collections -import itertools import functools import math -import os import re from string import ascii_lowercase @@ -39,7 +37,7 @@ from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.browser import webelem from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.utils import (usertypes, log, qtutils, message, - objreg, standarddir) + objreg) from qutebrowser.misc import guiprocess @@ -165,9 +163,10 @@ class HintManager(QObject): hints = set() lines = (line.rstrip().lower() for line in wordfile) for word in lines: - if not set(word) <= alphabet: + if set(word) - alphabet: + # contains none-alphabetic chars continue - if not len(word) <= 4: + if len(word) > 4: continue for i in range(len(word)): hints.discard(word[:i + 1]) @@ -254,21 +253,23 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ - just_get_it = lambda tag: lambda elem: elem[tag] - take_last_part = lambda tag: lambda elem: elem[tag].split('/')[-1] - tag_extractors = { - "alt": just_get_it("alt"), - "title": just_get_it("title"), - "src": take_last_part("src"), - "href": take_last_part("href"), - "name": just_get_it("name"), - } + def just_get_it(tag): + return lambda elem: elem[tag] + + def take_last_part(tag): + return lambda elem: elem[tag].split('/')[-1] + + tag_extractors = dict( + alt=just_get_it("alt"), + title=just_get_it("title"), + src=take_last_part("src"), + href=take_last_part("href"), + name=just_get_it("name")) tags_for = collections.defaultdict(list, { - "IMG": ["alt", "title", "src"], - "A": ["title", "href"], - "INPUT": ["name"], - }) + "IMG": ["alt", "title", "src"], + "A": ["title", "href"], + "INPUT": ["name"]}) def extract_tag_words(elem): if elem.tagName() == "A": diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 2ee19f652..40e2d83ed 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -864,7 +864,8 @@ def data(readonly=False): ('number', "Use numeric hints."), ('letter', "Use the chars in the hints -> " "chars setting."), - ('word', "Use hints words based on the html elements and the extra words."), + ('word', "Use hints words based on the html " + "elements and the extra words."), )), 'letter'), "Mode to use for hints."), From fc06283d91d69825d60cbcb39b0121ac1f6851cb Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sat, 19 Dec 2015 10:46:49 +0100 Subject: [PATCH 14/27] fix more pep8/pylint complaints --- qutebrowser/browser/hints.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 712b2ce5e..ce7cadc3a 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -158,7 +158,8 @@ class HintManager(QObject): def _initialize_word_hints(self): if not self._words: - with open(config.get("hints", "dictionary")) as wordfile: + dictionary = config.get("hints", "dictionary") + with open(dictionary, encoding="UTF-8") as wordfile: alphabet = set(ascii_lowercase) hints = set() lines = (line.rstrip().lower() for line in wordfile) @@ -272,6 +273,7 @@ class HintManager(QObject): "INPUT": ["name"]}) def extract_tag_words(elem): + "Extract tag words form the given element." if elem.tagName() == "A": # link text is a special case, alas. yield str(elem) @@ -280,7 +282,10 @@ class HintManager(QObject): if tag in elem) def tag_words_to_hints(words): - for candidate in filter(bool, words): + "Takes words and transform them to proper hints if possible." + for candidate in words: + if not candidate: + continue match = self.FIRST_ALPHABETIC.search(candidate) if not match: continue @@ -293,10 +298,11 @@ class HintManager(QObject): for e in existing) def new_hint_for(elem, existing): + "Returns a hint for elem, without conflicting with the existing." new = tag_words_to_hints(extract_tag_words(elem)) - new = filter(lambda h: not any_prefix(h, existing), new) + without_prefixes = (h for h in new if not any_prefix(h, existing)) # either the first good, or None - return next(new, None) + return next(without_prefixes, None) hints = [] used_hints = set() From aa9e58b5202a40f7a71e4a309073d84f3221818f Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sat, 19 Dec 2015 10:52:02 +0100 Subject: [PATCH 15/27] take this, pep8 --- qutebrowser/browser/hints.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index ce7cadc3a..bb68e6cdf 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -260,17 +260,18 @@ class HintManager(QObject): def take_last_part(tag): return lambda elem: elem[tag].split('/')[-1] - tag_extractors = dict( - alt=just_get_it("alt"), - title=just_get_it("title"), - src=take_last_part("src"), - href=take_last_part("href"), - name=just_get_it("name")) + tag_extractors = dict(alt=just_get_it("alt"), + title=just_get_it("title"), + src=take_last_part("src"), + href=take_last_part("href"), + name=just_get_it("name")) - tags_for = collections.defaultdict(list, { + tags_for = collections.defaultdict( + list, { "IMG": ["alt", "title", "src"], "A": ["title", "href"], - "INPUT": ["name"]}) + "INPUT": ["name"] + }) def extract_tag_words(elem): "Extract tag words form the given element." From cb8b16ecc56ae98f42cc2eb02063a024eb0c1d39 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sat, 19 Dec 2015 11:22:23 +0100 Subject: [PATCH 16/27] yes, this looks less complex --- qutebrowser/browser/hints.py | 100 +++++++++++++++++------------------ 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index bb68e6cdf..ae9733bbc 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -141,6 +141,23 @@ class HintManager(QObject): FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') + TAG_EXTRACTORS = dict( + 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=lambda elem: str(elem), + ) + + TAGS_FOR = collections.defaultdict( + list, { + "IMG": ["alt", "title", "src"], + "A": ["title", "href", "text"], + "INPUT": ["name"] + }) + + mouse_event = pyqtSignal('QMouseEvent') start_hinting = pyqtSignal(usertypes.ClickTarget) stop_hinting = pyqtSignal() @@ -241,6 +258,36 @@ class HintManager(QObject): else: return self._hint_linear(min_chars, chars, elems) + def _extract_tag_words(self, elem): + """Extract tag words form the given element.""" + yield from (self.TAG_EXTRACTORS[tag](elem) + for tag in self.TAGS_FOR[elem.tagName()] + if tag in elem or tag == "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 = self.FIRST_ALPHABETIC.search(candidate) + if not match: + continue + if match.end() - match.start() < 4: + continue + 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_words(self, words, elems): """Produce hint labels based on the html tags. @@ -254,61 +301,10 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ - def just_get_it(tag): - return lambda elem: elem[tag] - - def take_last_part(tag): - return lambda elem: elem[tag].split('/')[-1] - - tag_extractors = dict(alt=just_get_it("alt"), - title=just_get_it("title"), - src=take_last_part("src"), - href=take_last_part("href"), - name=just_get_it("name")) - - tags_for = collections.defaultdict( - list, { - "IMG": ["alt", "title", "src"], - "A": ["title", "href"], - "INPUT": ["name"] - }) - - def extract_tag_words(elem): - "Extract tag words form the given element." - if elem.tagName() == "A": - # link text is a special case, alas. - yield str(elem) - yield from (tag_extractors[tag](elem) - for tag in tags_for[elem.tagName()] - if tag in elem) - - def tag_words_to_hints(words): - "Takes words and transform them to proper hints if possible." - for candidate in words: - if not candidate: - continue - match = self.FIRST_ALPHABETIC.search(candidate) - if not match: - continue - if match.end() - match.start() < 4: - continue - yield candidate[match.start():match.end()].lower() - - def any_prefix(hint, existing): - return any(hint.startswith(e) or e.startswith(hint) - for e in existing) - - def new_hint_for(elem, existing): - "Returns a hint for elem, without conflicting with the existing." - new = tag_words_to_hints(extract_tag_words(elem)) - without_prefixes = (h for h in new if not any_prefix(h, existing)) - # either the first good, or None - return next(without_prefixes, None) - hints = [] used_hints = set() for elem in elems: - hint = new_hint_for(elem, used_hints) or next(words) + hint = self._new_hint_for(elem, used_hints) or next(words) used_hints.add(hint) hints.append(hint) return hints From 9f81a9c3c6118ec6c2978ea5f87fe5418a1c0072 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sat, 19 Dec 2015 11:28:00 +0100 Subject: [PATCH 17/27] lines also hey, a useful suggestion --- qutebrowser/browser/hints.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index ae9733bbc..a8d85b079 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -147,7 +147,7 @@ class HintManager(QObject): title=lambda elem: elem["title"], src=lambda elem: elem["src"].split('/')[-1], href=lambda elem: elem["href"].split('/')[-1], - text=lambda elem: str(elem), + text=str, ) TAGS_FOR = collections.defaultdict( @@ -157,7 +157,6 @@ class HintManager(QObject): "INPUT": ["name"] }) - mouse_event = pyqtSignal('QMouseEvent') start_hinting = pyqtSignal(usertypes.ClickTarget) stop_hinting = pyqtSignal() @@ -287,7 +286,6 @@ class HintManager(QObject): # either the first good, or None return next(no_prefixes, None) - def _hint_words(self, words, elems): """Produce hint labels based on the html tags. From b89e0f8803cb88eb949064fd1cd9b1c930b931a6 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Tue, 29 Dec 2015 18:48:01 +0100 Subject: [PATCH 18/27] refactor all the things --- qutebrowser/browser/hints.py | 237 +++++++++++++++++++---------------- 1 file changed, 132 insertions(+), 105 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index a8d85b079..4e781046b 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -23,7 +23,7 @@ import collections import functools import math import re -from string import ascii_lowercase +import string from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) @@ -41,6 +41,10 @@ from qutebrowser.utils import (usertypes, log, qtutils, message, from qutebrowser.misc import guiprocess +__all__ = ("ElemTuple", "Target", "on_mode_entered", "HintContext", + "HintManager") + + ElemTuple = collections.namedtuple('ElemTuple', ['elem', 'label']) @@ -50,6 +54,9 @@ Target = usertypes.enum('Target', ['normal', 'tab', 'tab_fg', 'tab_bg', 'spawn']) +WordHintingError = Exception + + @pyqtSlot(usertypes.KeyMode) def on_mode_entered(mode, win_id): """Stop hinting when insert mode was entered.""" @@ -139,24 +146,6 @@ class HintManager(QObject): Target.spawn: "Spawn command via hint", } - FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') - - TAG_EXTRACTORS = dict( - 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, - ) - - TAGS_FOR = collections.defaultdict( - list, { - "IMG": ["alt", "title", "src"], - "A": ["title", "href", "text"], - "INPUT": ["name"] - }) - mouse_event = pyqtSignal('QMouseEvent') start_hinting = pyqtSignal(usertypes.ClickTarget) stop_hinting = pyqtSignal() @@ -167,30 +156,11 @@ class HintManager(QObject): self._win_id = win_id self._tab_id = tab_id self._context = None - self._words = set() # initialized on first word hint use + self._word_hinter = WordHinter() mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.left.connect(self.on_mode_left) - def _initialize_word_hints(self): - if not self._words: - dictionary = config.get("hints", "dictionary") - with open(dictionary, encoding="UTF-8") as wordfile: - alphabet = set(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: - continue - for i in range(len(word)): - hints.discard(word[:i + 1]) - hints.add(word) - self._words.update(hints) - return self._words - def _get_text(self): """Get a hint text based on the current context.""" text = self.HINT_TEXTS[self._context.target] @@ -241,11 +211,9 @@ class HintManager(QObject): """ if config.get('hints', 'mode') == 'words': try: - words = iter(self._initialize_word_hints()) - return self._hint_words(words, elems) - except IOError: - message.error(self._win_id, "Word hints require a dictionary" + - " at /usr/share/dict/words.", immediately=True) + 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 config.get('hints', 'mode') == 'number': chars = '0123456789' @@ -257,56 +225,6 @@ class HintManager(QObject): else: return self._hint_linear(min_chars, chars, elems) - def _extract_tag_words(self, elem): - """Extract tag words form the given element.""" - yield from (self.TAG_EXTRACTORS[tag](elem) - for tag in self.TAGS_FOR[elem.tagName()] - if tag in elem or tag == "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 = self.FIRST_ALPHABETIC.search(candidate) - if not match: - continue - if match.end() - match.start() < 4: - continue - 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_words(self, words, 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. - """ - hints = [] - used_hints = set() - for elem in elems: - hint = self._new_hint_for(elem, used_hints) or next(words) - used_hints.add(hint) - hints.append(hint) - return hints - def _hint_scattered(self, min_chars, chars, elems): """Produce scattered hint labels with variable length (like Vimium). @@ -472,7 +390,7 @@ class HintManager(QObject): label.setStyleProperty('left', '{}px !important'.format(left)) label.setStyleProperty('top', '{}px !important'.format(top)) - def _draw_label(self, elem, string): + def _draw_label(self, elem, strng): """Draw a hint label over an element. Args: @@ -497,7 +415,7 @@ class HintManager(QObject): label = webelem.WebElementWrapper(parent.lastChild()) label['class'] = 'qutehint' self._set_style_properties(elem, label) - label.setPlainText(string) + label.setPlainText(strng) return label def _show_url_error(self): @@ -760,9 +678,9 @@ class HintManager(QObject): if not elems: raise cmdexc.CommandError("No elements found.") strings = self._hint_strings(elems) - for e, string in zip(elems, strings): - label = self._draw_label(e, string) - self._context.elems[string] = ElemTuple(e, label) + for e, strng in zip(elems, strings): + label = self._draw_label(e, strng) + self._context.elems[strng] = ElemTuple(e, label) keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) keyparser = keyparsers[usertypes.KeyMode.hint] @@ -909,11 +827,11 @@ class HintManager(QObject): def handle_partial_key(self, keystr): """Handle a new partial keypress.""" log.hints.debug("Handling new keystring: '{}'".format(keystr)) - for (string, elems) in self._context.elems.items(): + for (strng, elems) in self._context.elems.items(): try: - if string.startswith(keystr): - matched = string[:len(keystr)] - rest = string[len(keystr):] + if strng.startswith(keystr): + matched = strng[:len(keystr)] + rest = strng[len(keystr):] match_color = config.get('colors', 'hints.fg.match') elems.label.setInnerXml( '{}{}'.format( @@ -1014,8 +932,8 @@ class HintManager(QObject): # Show all hints again self.filter_hints(None) # Undo keystring highlighting - for (string, elems) in self._context.elems.items(): - elems.label.setInnerXml(string) + for (strng, elems) in self._context.elems.items(): + elems.label.setInnerXml(strng) handler() @cmdutils.register(instance='hintmanager', scope='tab', hide=True, @@ -1058,3 +976,112 @@ class HintManager(QObject): # hinting. return self._cleanup() + + +class WordHinter(object): + + """Generator for word hints. + + Class attributes: + + Attributes: + + """ + + TAG_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, + } + + TAGS_FOR = collections.defaultdict( + list, { + "IMG": ["alt", "title", "src"], + "A": ["title", "href", "text"], + "INPUT": ["name"] + } + ) + + FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') + + 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: + continue + for i in range(len(word)): + hints.discard(word[:i + 1]) + hints.add(word) + self.words.update(hints) + except IOError: + error = "Word hints require a dictionary at {}." + raise WordHintingError(error.format(dictionary)) + + def extract_tag_words(self, elem): + """Extract tag words form the given element.""" + yield from (self.TAG_EXTRACTORS[tag](elem) + for tag in self.TAGS_FOR[elem.tagName()] + if tag in elem or tag == "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 = self.FIRST_ALPHABETIC.search(candidate) + if not match: + continue + if match.end() - match.start() < 4: + continue + 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 From 12cc96a94b54d92866c50aacc2d0bcc765533cce Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 1 Jan 2016 19:37:00 +0100 Subject: [PATCH 19/27] fix most of the-compiler's remarks --- qutebrowser/browser/hints.py | 66 +++++++++++++++++------------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 4e781046b..5316338ad 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -41,10 +41,6 @@ from qutebrowser.utils import (usertypes, log, qtutils, message, from qutebrowser.misc import guiprocess -__all__ = ("ElemTuple", "Target", "on_mode_entered", "HintContext", - "HintManager") - - ElemTuple = collections.namedtuple('ElemTuple', ['elem', 'label']) @@ -54,7 +50,8 @@ Target = usertypes.enum('Target', ['normal', 'tab', 'tab_fg', 'tab_bg', 'spawn']) -WordHintingError = Exception +class WordHintingError(Exception): + """Exception raised on errors during word hinting.""" @pyqtSlot(usertypes.KeyMode) @@ -209,13 +206,14 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ - if config.get('hints', 'mode') == 'words': + hint_mode = config.get('hints', 'mode') + if hint_mode == 'words': 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 config.get('hints', 'mode') == 'number': + if hint_mode == 'number': chars = '0123456789' else: chars = config.get('hints', 'chars') @@ -390,7 +388,7 @@ class HintManager(QObject): label.setStyleProperty('left', '{}px !important'.format(left)) label.setStyleProperty('top', '{}px !important'.format(top)) - def _draw_label(self, elem, strng): + def _draw_label(self, elem, text): """Draw a hint label over an element. Args: @@ -415,7 +413,7 @@ class HintManager(QObject): label = webelem.WebElementWrapper(parent.lastChild()) label['class'] = 'qutehint' self._set_style_properties(elem, label) - label.setPlainText(strng) + label.setPlainText(text) return label def _show_url_error(self): @@ -978,7 +976,7 @@ class HintManager(QObject): self._cleanup() -class WordHinter(object): +class WordHinter: """Generator for word hints. @@ -987,24 +985,6 @@ class WordHinter(object): Attributes: """ - - TAG_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, - } - - TAGS_FOR = collections.defaultdict( - list, { - "IMG": ["alt", "title", "src"], - "A": ["title", "href", "text"], - "INPUT": ["name"] - } - ) - FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') def __init__(self): @@ -1030,15 +1010,32 @@ class WordHinter(object): hints.discard(word[:i + 1]) hints.add(word) self.words.update(hints) - except IOError: - error = "Word hints require a dictionary at {}." - raise WordHintingError(error.format(dictionary)) + 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.""" - yield from (self.TAG_EXTRACTORS[tag](elem) - for tag in self.TAGS_FOR[elem.tagName()] - if tag in elem or tag == "text") + 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[tag](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.""" @@ -1085,3 +1082,4 @@ class WordHinter(object): used_hints.add(hint) hints.append(hint) return hints + From 32de5b76a9b74eb0b369b1ea3aa4ac2aa3526ca7 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Fri, 1 Jan 2016 20:16:49 +0100 Subject: [PATCH 20/27] urgh be awake --- qutebrowser/browser/hints.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 5316338ad..a8d073805 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -1033,9 +1033,9 @@ class WordHinter: } ) - return (attr_extractors[tag](elem) + return (attr_extractors[attr](elem) for attr in extractable_attrs[elem.tagName()] - if attr in elem or attr == "text")) + if attr in elem or attr == "text") def tag_words_to_hints(self, words): """Take words and transform them to proper hints if possible.""" @@ -1082,4 +1082,3 @@ class WordHinter: used_hints.add(hint) hints.append(hint) return hints - From e28c1bf9b8ae26afaf75047e1b346d463051dcaf Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Tue, 5 Jan 2016 20:42:32 +0100 Subject: [PATCH 21/27] allow config files to be optional --- qutebrowser/browser/hints.py | 2 ++ qutebrowser/config/configdata.py | 2 +- qutebrowser/config/configtypes.py | 24 ++++++++++++++---------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index a8d073805..f43fb6053 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -51,6 +51,7 @@ Target = usertypes.enum('Target', ['normal', 'tab', 'tab_fg', 'tab_bg', class WordHintingError(Exception): + """Exception raised on errors during word hinting.""" @@ -985,6 +986,7 @@ class WordHinter: Attributes: """ + FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') def __init__(self): diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 40e2d83ed..624220ec9 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -891,7 +891,7 @@ def data(readonly=False): "Make chars in hint strings uppercase."), ('dictionary', - SettingValue(typ.File(), '/usr/share/dict/words'), + SettingValue(typ.File(required=False), '/usr/share/dict/words'), "The dictionary file to be used by the word hints."), ('auto-follow', diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index bd9af1f33..e7463f8a0 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -891,6 +891,10 @@ class File(BaseType): """A file on the local filesystem.""" + def __init__(self, required=True, **kwargs): + super().__init__(**kwargs) + self.required = required + def transform(self, value): if not value: return None @@ -899,7 +903,9 @@ class File(BaseType): if not os.path.isabs(value): cfgdir = standarddir.config() assert cfgdir is not None - return os.path.join(cfgdir, value) + value = os.path.join(cfgdir, value) + #if not self.required and not os.access(value, os.F_OK | os.R_OK): + # return None return value def validate(self, value): @@ -915,15 +921,13 @@ class File(BaseType): raise configexc.ValidationError( value, "must be an absolute path when not using a " "config directory!") - elif not os.path.isfile(os.path.join(cfgdir, value)): - raise configexc.ValidationError( - value, "must be a valid path relative to the config " - "directory!") - else: - return - elif not os.path.isfile(value): - raise configexc.ValidationError( - value, "must be a valid file!") + value = os.path.join(cfgdir, value) + not_isfile_message = ("must be a valid path relative to the " + "config directory!") + else: + not_isfile_message = "must be a valid file!" + if self.required and not os.path.isfile(value): + raise configexc.ValidationError(value, not_isfile_message) except UnicodeEncodeError as e: raise configexc.ValidationError(value, e) From 8873aba09fbdea6f10d94cc8a7edce2f492d2ad7 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Tue, 5 Jan 2016 21:44:29 +0100 Subject: [PATCH 22/27] rename strng to more sensible names --- qutebrowser/browser/hints.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index f43fb6053..faa645a71 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -676,10 +676,10 @@ class HintManager(QObject): elems = [e for e in elems if filterfunc(e)] if not elems: raise cmdexc.CommandError("No elements found.") - strings = self._hint_strings(elems) - for e, strng in zip(elems, strings): - label = self._draw_label(e, strng) - self._context.elems[strng] = ElemTuple(e, label) + hints = self._hint_strings(elems) + for e, hint in zip(elems, hints): + label = self._draw_label(e, hint) + self._context.elems[hint] = ElemTuple(e, label) keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) keyparser = keyparsers[usertypes.KeyMode.hint] @@ -826,11 +826,11 @@ class HintManager(QObject): def handle_partial_key(self, keystr): """Handle a new partial keypress.""" log.hints.debug("Handling new keystring: '{}'".format(keystr)) - for (strng, elems) in self._context.elems.items(): + for (text, elems) in self._context.elems.items(): try: - if strng.startswith(keystr): - matched = strng[:len(keystr)] - rest = strng[len(keystr):] + if text.startswith(keystr): + matched = text[:len(keystr)] + rest = text[len(keystr):] match_color = config.get('colors', 'hints.fg.match') elems.label.setInnerXml( '{}{}'.format( @@ -931,8 +931,8 @@ class HintManager(QObject): # Show all hints again self.filter_hints(None) # Undo keystring highlighting - for (strng, elems) in self._context.elems.items(): - elems.label.setInnerXml(strng) + for (text, elems) in self._context.elems.items(): + elems.label.setInnerXml(text) handler() @cmdutils.register(instance='hintmanager', scope='tab', hide=True, From 362db3d9869d470c5beabaea098e894a0df4cafe Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Tue, 5 Jan 2016 22:45:52 +0100 Subject: [PATCH 23/27] fix remarks --- qutebrowser/browser/hints.py | 13 ++++++------- qutebrowser/config/configtypes.py | 2 -- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index faa645a71..8039174aa 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -683,7 +683,7 @@ class HintManager(QObject): keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) keyparser = keyparsers[usertypes.KeyMode.hint] - keyparser.update_bindings(strings) + keyparser.update_bindings(hints) def follow_prevnext(self, frame, baseurl, prev=False, tab=False, background=False, window=False): @@ -981,14 +981,11 @@ class WordHinter: """Generator for word hints. - Class attributes: - Attributes: - + words: A set of words to be used when no "smart hint" can be + derived from the hinted element. """ - FIRST_ALPHABETIC = re.compile('[A-Za-z]{3,}') - def __init__(self): # will be initialized on first use. self.words = set() @@ -1007,8 +1004,10 @@ class WordHinter: # 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) @@ -1044,7 +1043,7 @@ class WordHinter: for candidate in words: if not candidate: continue - match = self.FIRST_ALPHABETIC.search(candidate) + match = re.search('[A-Za-z]{3,}', candidate) if not match: continue if match.end() - match.start() < 4: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index e7463f8a0..0ebaf38f9 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -904,8 +904,6 @@ class File(BaseType): cfgdir = standarddir.config() assert cfgdir is not None value = os.path.join(cfgdir, value) - #if not self.required and not os.access(value, os.F_OK | os.R_OK): - # return None return value def validate(self, value): From 9a889c686634588ab5c85202d0877165c6744c0c Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sun, 17 Jan 2016 20:38:33 +0100 Subject: [PATCH 24/27] extended tests to cover new file argument --- tests/unit/config/test_configtypes.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index d2dd2886b..e8e4d45ba 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -1256,12 +1256,21 @@ class TestRegexList: assert klass().transform(val) == expected + +def unrequired_class(**kwargs): + return configtypes.File(required=False, **kwargs) + + @pytest.mark.usefixtures('qapp') class TestFileAndUserStyleSheet: """Test File/UserStyleSheet.""" - @pytest.fixture(params=[configtypes.File, configtypes.UserStyleSheet]) + @pytest.fixture(params=[ + configtypes.File, + configtypes.UserStyleSheet, + unrequired_class, + ]) def klass(self, request): return request.param @@ -1281,6 +1290,8 @@ class TestFileAndUserStyleSheet: return arg elif klass is configtypes.UserStyleSheet: return QUrl.fromLocalFile(arg) + elif klass is unrequired_class: + return arg else: assert False, klass @@ -1297,6 +1308,11 @@ class TestFileAndUserStyleSheet: with pytest.raises(configexc.ValidationError): 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): """Test validate with a file which does not exist (UserStyleSheet).""" os_mock.path.isfile.return_value = False @@ -1331,7 +1347,9 @@ class TestFileAndUserStyleSheet: (configtypes.File(), 'foobar', True), (configtypes.UserStyleSheet(), 'foobar', False), (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, value, raises): """Test with a relative path and standarddir.config returning None.""" From beba5a3d6c985c7494018d54785f7edc52b2b135 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sun, 17 Jan 2016 20:44:14 +0100 Subject: [PATCH 25/27] limit smart hint length --- qutebrowser/browser/hints.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 8039174aa..b091e9228 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -1046,7 +1046,8 @@ class WordHinter: match = re.search('[A-Za-z]{3,}', candidate) if not match: continue - if match.end() - match.start() < 4: + length = match.end() - match.start() + if 4 > length or length > 8: continue yield candidate[match.start():match.end()].lower() From cbb6e73b1fff363db947ec7311ab26cc0bb12829 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Sun, 17 Jan 2016 21:06:36 +0100 Subject: [PATCH 26/27] cleaner condition, less force --- qutebrowser/browser/hints.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index b091e9228..42adf2be6 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -1046,10 +1046,8 @@ class WordHinter: match = re.search('[A-Za-z]{3,}', candidate) if not match: continue - length = match.end() - match.start() - if 4 > length or length > 8: - continue - yield candidate[match.start():match.end()].lower() + 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) From 6d33e7843e352b1700503fe781fcbb30be7040d6 Mon Sep 17 00:00:00 2001 From: Felix Van der Jeugt Date: Tue, 19 Jan 2016 11:44:25 +0100 Subject: [PATCH 27/27] should use the same keyword in config and code hey this config check on startup is actually useful --- qutebrowser/browser/hints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 20b94edb0..20a91ec94 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -208,7 +208,7 @@ class HintManager(QObject): A list of hint strings, in the same order as the elements. """ hint_mode = config.get('hints', 'mode') - if hint_mode == 'words': + if hint_mode == 'word': try: return self._word_hinter.hint(elems) except WordHintingError as e: