Coverage for qutebrowser/browser/hints.py : 23%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org> # # This file is part of qutebrowser. # # qutebrowser is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # qutebrowser is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
'window', 'yank', 'yank_primary', 'run', 'fill', 'hover', 'download', 'userscript', 'spawn'])
"""Exception raised on errors during hinting."""
"""Stop hinting when insert mode was entered.""" if mode == usertypes.KeyMode.insert: modeman.leave(win_id, usertypes.KeyMode.hint, 'insert mode', maybe=True)
"""A label for a link.
Attributes: elem: The element this label belongs to. _context: The current hinting context. """
QLabel { background-color: {{ conf.colors.hints.bg }}; color: {{ conf.colors.hints.fg }}; font: {{ conf.fonts.hints }}; border: {{ conf.hints.border }}; padding-left: -3px; padding-right: -3px; } """
super().__init__(parent=context.tab) self._context = context self.elem = elem
self.setAttribute(Qt.WA_StyledBackground, True) config.set_register_stylesheet(self)
self._context.tab.contents_size_changed.connect(self._move_to_elem) self._move_to_elem() self.show()
def __repr__(self): try: text = self.text() except RuntimeError: text = '<deleted>' return utils.get_repr(self, elem=self.elem, text=text)
"""Set the text for the hint.
Args: matched: The part of the text which was typed. unmatched: The part of the text which was not typed yet. """ if (config.val.hints.uppercase and self._context.hint_mode in ['letter', 'word']): matched = html.escape(matched.upper()) unmatched = html.escape(unmatched.upper()) else: matched = html.escape(matched) unmatched = html.escape(unmatched)
match_color = html.escape(config.val.colors.hints.match.fg) self.setText('<font color="{}">{}</font>{}'.format( match_color, matched, unmatched)) self.adjustSize()
def _move_to_elem(self): """Reposition the label to its element.""" if not self.elem.has_frame(): # This sometimes happens for some reason... log.hints.debug("Frame for {!r} vanished!".format(self)) self.hide() return no_js = config.val.hints.find_implementation != 'javascript' rect = self.elem.rect_on_view(no_js=no_js) self.move(rect.x(), rect.y())
"""Clean up this element and hide it.""" self.hide() self.deleteLater()
class HintContext:
"""Context namespace used for hinting.
Attributes: all_labels: A list of all HintLabel objects ever created. labels: A mapping from key strings to HintLabel objects. May contain less elements than `all_labels` 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 BrowserTab. yank/yank_primary: Yank to clipboard/primary selection. run: Run a command. fill: Fill commandline with link. download: Download the link. userscript: Call a custom userscript. spawn: Spawn a simple command. to_follow: The link to follow when enter is pressed. args: Custom arguments for userscript/spawn rapid: Whether to do rapid hinting. add_history: Whether to add yanked or spawned link to the history. filterstr: Used to save the filter string for restoring in rapid mode. tab: The WebTab object we started hinting in. group: The group of web elements to hint. """
"""Get the arguments, with {hint-url} replaced by the given URL.""" args = [] for arg in self.args: arg = arg.replace('{hint-url}', urlstr) args.append(arg) return args
"""Actions which can be done after selecting a hint."""
"""Click an element.
Args: elem: The QWebElement to click. context: The HintContext to use. """ target_mapping = { Target.normal: usertypes.ClickTarget.normal, Target.current: usertypes.ClickTarget.normal, Target.tab_fg: usertypes.ClickTarget.tab, Target.tab_bg: usertypes.ClickTarget.tab_bg, Target.window: usertypes.ClickTarget.window, Target.hover: usertypes.ClickTarget.normal, } if config.val.tabs.background: target_mapping[Target.tab] = usertypes.ClickTarget.tab_bg else: target_mapping[Target.tab] = usertypes.ClickTarget.tab
if context.target in [Target.normal, Target.current]: # Set the pre-jump mark ', so we can jump back here after following tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) tabbed_browser.set_mark("'")
try: if context.target == Target.hover: elem.hover() elif context.target == Target.current: elem.remove_blank_target() elem.click(target_mapping[context.target]) else: elem.click(target_mapping[context.target]) except webelem.Error as e: raise HintingError(str(e))
"""Yank an element to the clipboard or primary selection.
Args: url: The URL to open as a QUrl. context: The HintContext to use. """ sel = (context.target == Target.yank_primary and utils.supports_selection())
flags = QUrl.FullyEncoded | QUrl.RemovePassword if url.scheme() == 'mailto': flags |= QUrl.RemoveScheme urlstr = url.toString(flags) utils.set_clipboard(urlstr, selection=sel)
msg = "Yanked URL to {}: {}".format( "primary selection" if sel else "clipboard", urlstr) message.info(msg)
"""Run the command based on a hint URL.
Args: url: The URL to open as a QUrl. context: The HintContext to use. """ urlstr = url.toString(QUrl.FullyEncoded) args = context.get_args(urlstr) commandrunner = runners.CommandRunner(self._win_id) commandrunner.run_safely(' '.join(args))
"""Preset a commandline text based on a hint URL.
Args: url: The URL to open as a QUrl. context: The HintContext to use. """ urlstr = url.toDisplayString(QUrl.FullyEncoded) args = context.get_args(urlstr) text = ' '.join(args) if text[0] not in modeparsers.STARTCHARS: raise HintingError("Invalid command text '{}'.".format(text))
cmd = objreg.get('status-command', scope='window', window=self._win_id) cmd.set_cmd_text(text)
"""Download a hint URL.
Args: elem: The QWebElement to download. _context: The HintContext to use. """ url = elem.resolve_url(context.baseurl) if url is None: raise HintingError("No suitable link found for this element.")
prompt = False if context.rapid else None qnam = context.tab.networkaccessmanager() user_agent = context.tab.user_agent()
# FIXME:qtwebengine do this with QtWebEngine downloads? download_manager = objreg.get('qtnetwork-download-manager') download_manager.get(url, qnam=qnam, user_agent=user_agent, prompt_download_directory=prompt)
"""Call a userscript from a hint.
Args: elem: The QWebElement to use in the userscript. context: The HintContext to use. """ cmd = context.args[0] args = context.args[1:] env = { 'QUTE_MODE': 'hints', 'QUTE_SELECTED_TEXT': str(elem), 'QUTE_SELECTED_HTML': elem.outer_xml(), } url = elem.resolve_url(context.baseurl) if url is not None: env['QUTE_URL'] = url.toString(QUrl.FullyEncoded)
try: userscripts.run_async(context.tab, cmd, *args, win_id=self._win_id, env=env) except userscripts.Error as e: raise HintingError(str(e))
"""Spawn a simple command from a hint.
Args: url: The URL to open as a QUrl. context: The HintContext to use. """ urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) args = context.get_args(urlstr) commandrunner = runners.CommandRunner(self._win_id) commandrunner.run_safely('spawn ' + ' '.join(args))
"""Manage drawing hints over links or other elements.
Class attributes: HINT_TEXTS: Text displayed for different hinting modes.
Attributes: _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.
Signals: See HintActions """
Target.normal: "Follow hint", Target.current: "Follow hint in current tab", Target.tab: "Follow hint in new tab", Target.tab_fg: "Follow hint in foreground tab", Target.tab_bg: "Follow hint in background tab", Target.window: "Follow hint in new window", Target.yank: "Yank hint to clipboard", Target.yank_primary: "Yank hint to primary selection", Target.run: "Run a command on a hint", Target.fill: "Set hint in commandline", Target.hover: "Hover over a hint", Target.download: "Download hint", Target.userscript: "Call userscript via hint", Target.spawn: "Spawn command via hint", }
"""Constructor."""
window=win_id)
"""Get a hint text based on the current context.""" text = self.HINT_TEXTS[self._context.target] if self._context.rapid: text += ' (rapid mode)' text += '...' return text
"""Clean up after hinting.""" for label in self._context.all_labels: label.cleanup()
text = self._get_text() message_bridge = objreg.get('message-bridge', scope='window', window=self._win_id) message_bridge.maybe_reset_text(text) self._context = None
"""Calculate the hint strings for elems.
Inspired by Vimium.
Args: elems: The elements to get hint strings for.
Return: A list of hint strings, in the same order as the elements. """ if not elems: return [] hint_mode = self._context.hint_mode if hint_mode == 'word': try: return self._word_hinter.hint(elems) except HintingError as e: message.error(str(e)) # falls back on letter hints if hint_mode == 'number': chars = '0123456789' else: chars = config.val.hints.chars min_chars = config.val.hints.min_chars if config.val.hints.scatter and hint_mode != 'number': return self._hint_scattered(min_chars, chars, elems) else: return self._hint_linear(min_chars, chars, elems)
"""Produce scattered hint labels with variable length (like Vimium).
Args: min_chars: The minimum length of labels. chars: The alphabet to use for labels. elems: The elements to generate labels for. """ # Determine how many digits the link hints will require in the worst # case. Usually we do not need all of these digits for every link # single hint, so we can show shorter hints for a few of the links. # Short hints are the number of hints we can possibly show which are # (needed - 1) digits in length. # Calculate short_count naively, by finding the avaiable space and # dividing by the number of spots we would loose by adding a # short element len(chars)) # Check if we double counted above to warrant another short_count # https://github.com/qutebrowser/qutebrowser/issues/3242 (len(elems) - short_count)) >= len(chars) - 1: else:
"""Produce linear hint labels with constant length (like dwb).
Args: min_chars: The minimum length of labels. chars: The alphabet to use for labels. elems: The elements to generate labels for. """ strings = [] needed = max(min_chars, math.ceil(math.log(len(elems), len(chars)))) for i in range(len(elems)): strings.append(self._number_to_hint_str(i, chars, needed)) return strings
"""Shuffle the given set of hints so that they're scattered.
Hints starting with the same character will be spread evenly throughout the array.
Inspired by Vimium.
Args: hints: A list of hint strings. length: Length of the available charset.
Return: A list of shuffled hint strings. """
"""Convert a number like "8" into a hint string like "JK".
This is used to sequentially generate all of the hint text. The hint string will be "padded with zeroes" to ensure its length is >= digits.
Inspired by Vimium.
Args: number: The hint number. chars: The charset to use. digits: The minimum output length.
Return: A hint string. """ # Pad the hint string we're returning so that it matches digits.
"""Check the arguments passed to start() and raise if they're wrong.
Args: target: A Target enum member. args: Arguments for userscript/download """ if not isinstance(target, Target): raise TypeError("Target {} is no Target member!".format(target)) if target in [Target.userscript, Target.spawn, Target.run, Target.fill]: if not args: raise cmdexc.CommandError( "'args' is required with target userscript/spawn/run/" "fill.") else: if args: raise cmdexc.CommandError( "'args' is only allowed with target userscript/spawn.")
"""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())
"""Return True if `filterstr` exactly matches `elemstr`.""" # Empty string and None never match if not filterstr: return False filterstr = filterstr.casefold() elemstr = elemstr.casefold() return filterstr == elemstr
"""Initialize the elements and labels based on the context set.""" if self._context is None: log.hints.debug("In _start_cb without context!") return
if elems is None: message.error("There was an error while getting hint elements") return if not elems: message.error("No elements found.") return
strings = self._hint_strings(elems) log.hints.debug("hints: {}".format(', '.join(strings)))
for elem, string in zip(elems, strings): label = HintLabel(elem, self._context) label.update_text('', string) self._context.all_labels.append(label) self._context.labels[string] = label
keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) keyparser = keyparsers[usertypes.KeyMode.hint] keyparser.update_bindings(strings)
message_bridge = objreg.get('message-bridge', scope='window', window=self._win_id) message_bridge.set_text(self._get_text()) modeman.enter(self._win_id, usertypes.KeyMode.hint, 'HintManager.start')
# to make auto_follow == 'always' work self._handle_auto_follow()
star_args_optional=True, maxsplit=2) group=webelem.Group.all, target=Target.normal, *args, win_id, mode=None, add_history=False, rapid=False): """Start hinting.
Args: rapid: Whether to do rapid hinting. With rapid hinting, the hint mode isn't left after a hint is followed, so you can easily open multiple links. This is only possible with targets `tab` (with `tabs.background_tabs=true`), `tab-bg`, `window`, `run`, `hover`, `userscript` and `spawn`. add_history: Whether to add the spawned or yanked link to the browsing history. group: The element types to hint.
- `all`: All clickable elements. - `links`: Only links. - `images`: Only images. - `inputs`: Only input fields.
target: What to do with the selected element.
- `normal`: Open the link. - `current`: Open the link in the current tab. - `tab`: Open the link in a new tab (honoring the `tabs.background_tabs` setting). - `tab-fg`: Open the link in a new foreground tab. - `tab-bg`: Open the link in a new background tab. - `window`: Open the link in a new window. - `hover` : Hover over the link. - `yank`: Yank the link to the clipboard. - `yank-primary`: Yank the link to the primary selection. - `run`: Run the argument as command. - `fill`: Fill the commandline with the command given as argument. - `download`: Download the link. - `userscript`: Call a userscript with `$QUTE_URL` set to the link. - `spawn`: Spawn a command.
mode: The hinting mode to use.
- `number`: Use numeric hints. - `letter`: Use the chars in the hints.chars setting. - `word`: Use hint words based on the html elements and the extra words.
*args: Arguments for spawn/userscript/run/fill.
- With `spawn`: The executable and arguments to spawn. `{hint-url}` will get replaced by the selected URL. - With `userscript`: The userscript to execute. Either store the userscript in `~/.local/share/qutebrowser/userscripts` (or `$XDG_DATA_DIR`), or use an absolute path. - With `fill`: The command to fill the statusbar with. `{hint-url}` will get replaced by the selected URL. - With `run`: Same as `fill`. """ tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) tab = tabbed_browser.currentWidget() if tab is None: raise cmdexc.CommandError("No WebView available yet!")
mode_manager = objreg.get('mode-manager', scope='window', window=self._win_id) if mode_manager.mode == usertypes.KeyMode.hint: modeman.leave(win_id, usertypes.KeyMode.hint, 're-hinting')
if rapid: if target in [Target.tab_bg, Target.window, Target.run, Target.hover, Target.userscript, Target.spawn, Target.download, Target.normal, Target.current]: pass elif target == Target.tab and config.val.tabs.background: pass else: name = target.name.replace('_', '-') raise cmdexc.CommandError("Rapid hinting makes no sense with " "target {}!".format(name))
if mode is None: mode = config.val.hints.mode
self._check_args(target, *args) self._context = HintContext() self._context.tab = tab self._context.target = target self._context.rapid = rapid self._context.hint_mode = mode self._context.add_history = add_history try: self._context.baseurl = tabbed_browser.current_url() except qtutils.QtValueError: raise cmdexc.CommandError("No URL set for this page yet!") self._context.args = args self._context.group = group selector = webelem.SELECTORS[self._context.group] self._context.tab.elements.find_css(selector, self._start_cb, only_visible=True)
"""Return the currently active hinting mode (or None otherwise).""" if self._context is None: return None
return self._context.hint_mode
"""Handle the auto_follow option.""" if visible is None: visible = {string: label for string, label in self._context.labels.items() if label.isVisible()}
if len(visible) != 1: return
auto_follow = config.val.hints.auto_follow
if auto_follow == "always": follow = True elif auto_follow == "unique-match": follow = keystr or filterstr elif auto_follow == "full-match": elemstr = str(list(visible.values())[0].elem) filter_match = self._filter_matches_exactly(filterstr, elemstr) follow = (keystr in visible) or filter_match else: follow = False # save the keystr of the only one visible hint to be picked up # later by self.follow_hint self._context.to_follow = list(visible.keys())[0]
if follow: # apply auto_follow_timeout timeout = config.val.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)
"""Handle a new partial keypress.""" if self._context is None: log.hints.debug("Got key without context!") return log.hints.debug("Handling new keystring: '{}'".format(keystr)) for string, label in self._context.labels.items(): try: if string.startswith(keystr): matched = string[:len(keystr)] rest = string[len(keystr):] label.update_text(matched, rest) # Show label again if it was hidden before label.show() else: # element doesn't match anymore -> hide it, unless in rapid # mode and hide_unmatched_rapid_hints is false (see #1799) if (not self._context.rapid or config.val.hints.hide_unmatched_rapid_hints): label.hide() except webelem.Error: pass self._handle_auto_follow(keystr=keystr)
"""Filter displayed hints according to a text.
Args: 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. """ if filterstr is None: filterstr = self._context.filterstr else: self._context.filterstr = filterstr
log.hints.debug("Filtering hints on {!r}".format(filterstr))
visible = [] for label in self._context.all_labels: try: if self._filter_matches(filterstr, str(label.elem)): visible.append(label) # Show label again if it was hidden before label.show() else: # element doesn't match anymore -> hide it label.hide() except webelem.Error: pass
if not visible: # Whoops, filtered all hints modeman.leave(self._win_id, usertypes.KeyMode.hint, 'all filtered') return
if self._context.hint_mode == 'number': # renumber filtered hints strings = self._hint_strings(visible) self._context.labels = {} for label, string in zip(visible, strings): label.update_text('', string) self._context.labels[string] = label keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) keyparser = keyparsers[usertypes.KeyMode.hint] keyparser.update_bindings(strings, preserve_filter=True)
# Note: filter_hints can be called with non-None filterstr only # when number mode is active if filterstr is not None: # pass self._context.labels as the dict of visible hints self._handle_auto_follow(filterstr=filterstr, visible=self._context.labels)
"""Fire a completed hint.
Args: keystr: The keychain string to follow. """ # Handlers which take a QWebElement elem_handlers = { Target.normal: self._actions.click, Target.current: self._actions.click, Target.tab: self._actions.click, Target.tab_fg: self._actions.click, Target.tab_bg: self._actions.click, Target.window: self._actions.click, Target.hover: self._actions.click, # _download needs a QWebElement to get the frame. Target.download: self._actions.download, Target.userscript: self._actions.call_userscript, } # Handlers which take a QUrl url_handlers = { Target.yank: self._actions.yank, Target.yank_primary: self._actions.yank, Target.run: self._actions.run_cmd, Target.fill: self._actions.preset_cmd_text, Target.spawn: self._actions.spawn, } elem = self._context.labels[keystr].elem
if not elem.has_frame(): message.error("This element has no webframe.") return
if self._context.target in elem_handlers: handler = functools.partial(elem_handlers[self._context.target], elem, self._context) elif self._context.target in url_handlers: url = elem.resolve_url(self._context.baseurl) if url is None: message.error("No suitable link found for this element.") return handler = functools.partial(url_handlers[self._context.target], url, self._context) if self._context.add_history: objreg.get('web-history').add_url(url, "") else: raise ValueError("No suitable handler found!")
if not self._context.rapid: modeman.leave(self._win_id, usertypes.KeyMode.hint, 'followed', maybe=True) else: # Reset filtering self.filter_hints(None) # Undo keystring highlighting for string, label in self._context.labels.items(): label.update_text('', string)
try: handler() except HintingError as e: message.error(str(e))
modes=[usertypes.KeyMode.hint]) """Follow a hint.
Args: keystring: The hint to follow, or None. """ if keystring is None: if self._context.to_follow is None: raise cmdexc.CommandError("No hint to follow") else: keystring = self._context.to_follow elif keystring not in self._context.labels: raise cmdexc.CommandError("No hint {}!".format(keystring)) self._fire(keystring)
def on_mode_left(self, mode): """Stop hinting when hinting mode was left.""" if mode != usertypes.KeyMode.hint or self._context is None: # We have one HintManager per tab, so when this gets called, # self._context might be None, because the current tab is not # hinting. return self._cleanup()
"""Generator for word hints.
Attributes: words: A set of words to be used when no "smart hint" can be derived from the hinted element. """
# will be initialized on first use.
"""Generate the used words if yet uninitialized.""" dictionary = config.val.hints.dictionary if not self.words or self.dictionary != dictionary: self.words.clear() self.dictionary = dictionary try: 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: # 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 HintingError(error.format(dictionary, str(e)))
"""Extract tag words form the given element.""" attr_extractors = { "alt": lambda elem: elem["alt"], "name": lambda elem: elem["name"], "title": lambda elem: elem["title"], "placeholder": lambda elem: elem["placeholder"], "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", "placeholder"], "textarea": ["name", "placeholder"], "button": ["text"] })
return (attr_extractors[attr](elem) for attr in extractable_attrs[elem.tag_name()] if attr in elem or attr == "text")
"""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()
return any(hint.startswith(e) or e.startswith(hint) for e in existing)
return (h for h in hints if not self.any_prefix(h, existing))
"""Return a hint for elem, not conflicting with the existing.""" new = self.tag_words_to_hints(self.extract_tag_words(elem)) new_no_prefixes = self.filter_prefixes(new, existing) fallback_no_prefixes = self.filter_prefixes(fallback, existing) # either the first good, or None return (next(new_no_prefixes, None) or next(fallback_no_prefixes, None))
"""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, words) if not hint: raise HintingError("Not enough words in the dictionary.") used_hints.add(hint) hints.append(hint) return hints |