qutebrowser/qutebrowser/browser/hints.py

1098 lines
41 KiB
Python
Raw Normal View History

2014-06-19 09:04:37 +02:00
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2016-01-04 07:12:39 +01:00
# Copyright 2014-2016 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
2014-04-19 17:50:11 +02:00
#
# 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/>.
"""A HintManager to draw hints over links."""
2015-12-13 23:40:36 +01:00
import collections
import functools
import math
import re
from string import ascii_lowercase
2014-04-20 19:24:22 +02:00
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl,
QTimer)
from PyQt5.QtGui import QMouseEvent
from PyQt5.QtWebKitWidgets import QWebPage
2014-04-21 15:20:41 +02:00
2014-08-26 19:10:14 +02:00
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, utils
2014-04-21 15:20:41 +02:00
2014-09-28 00:43:08 +02:00
ElemTuple = collections.namedtuple('ElemTuple', ['elem', 'label'])
2014-04-21 15:45:29 +02:00
2016-03-30 15:51:03 +02:00
Target = usertypes.enum('Target', ['normal', 'current', 'tab', 'tab_fg',
'tab_bg', 'window', 'yank', 'yank_primary',
'run', 'fill', 'hover', 'download',
'userscript', 'spawn'])
2014-05-05 07:45:36 +02:00
2016-08-05 11:19:54 +02:00
class HintingError(Exception):
2016-01-05 20:42:32 +01:00
2016-08-05 11:19:54 +02:00
"""Exception raised on errors during hinting."""
2015-12-29 18:48:01 +01:00
2014-09-28 22:13:14 +02:00
def on_mode_entered(mode, win_id):
"""Stop hinting when insert mode was entered."""
if mode == usertypes.KeyMode.insert:
2014-09-28 22:13:14 +02:00
modeman.maybe_leave(win_id, usertypes.KeyMode.hint, 'insert mode')
class HintContext:
"""Context namespace used for hinting.
Attributes:
frames: The QWebFrames to use.
all_elems: A list of all (elem, label) namedtuples ever created.
2015-03-31 20:49:29 +02:00
elems: A mapping from key strings to (elem, label) namedtuples.
May contain less elements than `all_elems` due to filtering.
baseurl: The URL of the current page.
target: What to do with the opened links.
2016-03-31 11:10:15 +02:00
normal/current/tab/tab_fg/tab_bg/window: Get passed to
BrowserTab.
2014-12-13 00:30:35 +01:00
yank/yank_primary: Yank to clipboard/primary selection.
run: Run a command.
fill: Fill commandline with link.
2014-06-19 17:58:46 +02:00
download: Download the link.
2014-07-29 01:45:42 +02:00
userscript: Call a custom userscript.
spawn: Spawn a simple command.
to_follow: The link to follow when enter is pressed.
2014-07-29 01:45:42 +02:00
args: Custom arguments for userscript/spawn
rapid: Whether to do rapid hinting.
tab: The WebTab object we started hinting in.
2015-03-31 20:49:29 +02:00
group: The group of web elements to hint.
"""
def __init__(self):
self.all_elems = []
self.elems = {}
self.target = None
self.baseurl = None
self.to_follow = None
self.rapid = False
self.frames = []
2014-07-29 01:45:42 +02:00
self.args = []
self.tab = None
self.group = None
self.hint_mode = None
def get_args(self, urlstr):
"""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
2016-08-05 11:19:54 +02:00
class HintActions(QObject):
"""Actions which can be done after selecting a hint.
Signals:
mouse_event: Mouse event to be posted in the web view.
arg: A QMouseEvent
start_hinting: Emitted when hinting starts, before a link is clicked.
arg: The ClickTarget to use.
stop_hinting: Emitted after a link was clicked.
"""
mouse_event = pyqtSignal('QMouseEvent')
start_hinting = pyqtSignal(usertypes.ClickTarget)
stop_hinting = pyqtSignal()
def __init__(self, win_id, parent=None):
super().__init__(parent)
self._win_id = win_id
def click(self, elem, context):
"""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.get('tabs', 'background-tabs'):
target_mapping[Target.tab] = usertypes.ClickTarget.tab_bg
else:
target_mapping[Target.tab] = usertypes.ClickTarget.tab
# Click the center of the largest square fitting into the top/left
# corner of the rectangle, this will help if part of the <a> element
# is hidden behind other elements
# https://github.com/The-Compiler/qutebrowser/issues/1005
rect = elem.rect_on_view()
if rect.width() > rect.height():
rect.setWidth(rect.height())
else:
rect.setHeight(rect.width())
pos = rect.center()
action = "Hovering" if context.target == Target.hover else "Clicking"
log.hints.debug("{} on '{}' at position {}".format(
action, elem.debug_text(), pos))
self.start_hinting.emit(target_mapping[context.target])
if context.target in [Target.tab, Target.tab_fg, Target.tab_bg,
Target.window]:
modifiers = Qt.ControlModifier
else:
modifiers = Qt.NoModifier
events = [
QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton,
Qt.NoModifier),
]
if context.target != Target.hover:
events += [
QMouseEvent(QEvent.MouseButtonPress, pos, Qt.LeftButton,
Qt.LeftButton, modifiers),
QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton,
Qt.NoButton, modifiers),
]
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("'")
if context.target == Target.current:
elem.remove_blank_target()
for evt in events:
self.mouse_event.emit(evt)
if elem.is_text_input() and elem.is_editable():
QTimer.singleShot(0, functools.partial(
elem.frame().page().triggerAction,
QWebPage.MoveToEndOfDocument))
QTimer.singleShot(0, self.stop_hinting.emit)
def yank(self, url, context):
"""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())
urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword)
utils.set_clipboard(urlstr, selection=sel)
msg = "Yanked URL to {}: {}".format(
"primary selection" if sel else "clipboard",
urlstr)
message.info(self._win_id, msg)
def run_cmd(self, url, context):
"""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))
def preset_cmd_text(self, url, context):
"""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:
message.error(self._win_id,
"Invalid command text '{}'.".format(text),
immediately=True)
else:
message.set_cmd_text(self._win_id, text)
def download(self, elem, context):
"""Download a hint URL.
Args:
elem: The QWebElement to download.
_context: The HintContext to use.
"""
url = elem.resolve_url(context.baseurl)
2016-08-05 11:19:54 +02:00
if url is None:
raise HintingError
if context.rapid:
prompt = False
else:
prompt = None
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
download_manager.get(url, page=elem.frame().page(),
prompt_download_directory=prompt)
def call_userscript(self, elem, context):
"""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)
2016-08-05 11:19:54 +02:00
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.UnsupportedError as e:
message.error(self._win_id, str(e), immediately=True)
def spawn(self, url, context):
"""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))
2014-04-21 15:20:41 +02:00
class HintManager(QObject):
2014-04-19 17:50:11 +02:00
"""Manage drawing hints over links or other elements.
Class attributes:
2014-05-13 21:13:53 +02:00
HINT_TEXTS: Text displayed for different hinting modes.
2014-04-19 17:50:11 +02:00
Attributes:
_context: The HintContext for the current invocation.
2014-09-28 22:13:14 +02:00
_win_id: The window ID this HintManager is associated with.
_tab_id: The tab ID this HintManager is associated with.
_filterstr: Used to save the filter string for restoring in rapid mode.
2014-04-21 15:20:41 +02:00
Signals:
2016-08-05 11:19:54 +02:00
See HintActions
2014-04-19 17:50:11 +02:00
"""
2014-05-13 21:13:53 +02:00
HINT_TEXTS = {
Target.normal: "Follow hint",
2016-03-30 15:51:03 +02:00
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",
2014-05-13 21:13:53 +02:00
}
2014-04-21 16:59:03 +02:00
mouse_event = pyqtSignal('QMouseEvent')
start_hinting = pyqtSignal(usertypes.ClickTarget)
stop_hinting = pyqtSignal()
2014-04-21 15:20:41 +02:00
def __init__(self, win_id, tab_id, parent=None):
2014-09-28 22:13:14 +02:00
"""Constructor."""
super().__init__(parent)
2014-09-28 22:13:14 +02:00
self._win_id = win_id
self._tab_id = tab_id
self._context = None
self._filterstr = None
2015-12-29 18:48:01 +01:00
self._word_hinter = WordHinter()
2016-08-05 11:19:54 +02:00
self._actions = HintActions(win_id)
self._actions.start_hinting.connect(self.start_hinting)
self._actions.stop_hinting.connect(self.stop_hinting)
self._actions.mouse_event.connect(self.mouse_event)
2014-09-28 22:13:14 +02:00
mode_manager = objreg.get('mode-manager', scope='window',
window=win_id)
mode_manager.left.connect(self.on_mode_left)
2014-04-19 23:16:39 +02:00
def _get_text(self):
"""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
2014-08-04 03:14:14 +02:00
def _cleanup(self):
"""Clean up after hinting."""
try:
self._context.tab.contents_size_changed.disconnect(
self.on_contents_size_changed)
except TypeError:
# For some reason, this can fail sometimes...
pass
for elem in self._context.all_elems:
2014-09-04 08:00:05 +02:00
try:
elem.label.remove_from_document()
except webelem.Error:
2014-09-04 08:00:05 +02:00
pass
text = self._get_text()
2014-09-28 22:13:14 +02:00
message_bridge = objreg.get('message-bridge', scope='window',
window=self._win_id)
message_bridge.maybe_reset_text(text)
2014-08-04 03:14:14 +02:00
self._context = None
self._filterstr = None
2014-08-04 03:14:14 +02:00
2014-04-20 23:58:14 +02:00
def _hint_strings(self, elems):
"""Calculate the hint strings for elems.
2014-04-22 08:45:56 +02:00
Inspired by Vimium.
2014-04-21 00:24:08 +02:00
Args:
elems: The elements to get hint strings for.
Return:
A list of hint strings, in the same order as the elements.
2014-04-20 23:58:14 +02:00
"""
hint_mode = self._context.hint_mode
if hint_mode == 'word':
try:
2015-12-29 18:48:01 +01:00
return self._word_hinter.hint(elems)
2016-08-05 11:19:54 +02:00
except HintingError as e:
2015-12-29 18:48:01 +01:00
message.error(self._win_id, str(e), immediately=True)
# falls back on letter hints
2016-01-01 19:37:00 +01:00
if hint_mode == 'number':
2014-05-02 17:53:16 +02:00
chars = '0123456789'
else:
chars = config.get('hints', 'chars')
min_chars = config.get('hints', 'min-chars')
if config.get('hints', 'scatter') and hint_mode != 'number':
return self._hint_scattered(min_chars, chars, elems)
else:
return self._hint_linear(min_chars, chars, elems)
def _hint_scattered(self, 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.
"""
2014-04-20 23:58:14 +02:00
# 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.
needed = max(min_chars, math.ceil(math.log(len(elems), len(chars))))
2014-04-20 23:58:14 +02:00
# Short hints are the number of hints we can possibly show which are
# (needed - 1) digits in length.
if needed > min_chars:
short_count = math.floor((len(chars) ** needed - len(elems)) /
2015-02-26 06:13:27 +01:00
len(chars))
else:
short_count = 0
2014-04-20 23:58:14 +02:00
long_count = len(elems) - short_count
strings = []
if needed > 1:
for i in range(short_count):
strings.append(self._number_to_hint_str(i, chars, needed - 1))
start = short_count * len(chars)
for i in range(start, start + long_count):
strings.append(self._number_to_hint_str(i, chars, needed))
return self._shuffle_hints(strings, len(chars))
def _hint_linear(self, min_chars, chars, elems):
"""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
2014-04-20 23:58:14 +02:00
def _shuffle_hints(self, hints, length):
"""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.
2014-04-21 00:24:08 +02:00
Args:
hints: A list of hint strings.
length: Length of the available charset.
Return:
A list of shuffled hint strings.
2014-04-20 23:58:14 +02:00
"""
buckets = [[] for i in range(length)]
for i, hint in enumerate(hints):
buckets[i % len(buckets)].append(hint)
result = []
for bucket in buckets:
result += bucket
return result
def _number_to_hint_str(self, number, chars, digits=0):
"""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.
2014-04-21 00:24:08 +02:00
Args:
number: The hint number.
chars: The charset to use.
digits: The minimum output length.
Return:
A hint string.
2014-04-20 23:58:14 +02:00
"""
base = len(chars)
hintstr = []
remainder = 0
while True:
remainder = number % base
hintstr.insert(0, chars[remainder])
number -= remainder
number //= base
if number <= 0:
break
# Pad the hint string we're returning so that it matches digits.
2014-04-21 00:24:08 +02:00
for _ in range(0, digits - len(hintstr)):
2014-04-20 23:58:14 +02:00
hintstr.insert(0, chars[0])
return ''.join(hintstr)
def _is_hidden(self, elem):
"""Check if the element is hidden via display=none."""
display = elem.style_property('display', strategy='inline')
return display == 'none'
2014-05-06 17:02:32 +02:00
2015-01-06 17:10:54 +01:00
def _show_elem(self, elem):
"""Show a given element."""
2016-07-27 15:11:06 +02:00
elem.set_style_property('display', 'inline !important')
2015-01-06 17:10:54 +01:00
def _hide_elem(self, elem):
"""Hide a given element."""
2016-07-27 15:11:06 +02:00
elem.set_style_property('display', 'none !important')
2015-01-06 17:10:54 +01:00
def _set_style_properties(self, elem, label):
"""Set the hint CSS on the element given.
2014-05-06 17:02:32 +02:00
Args:
elem: The QWebElement to set the style attributes for.
label: The label QWebElement.
2014-05-06 17:02:32 +02:00
"""
attrs = [
('display', 'inline !important'),
('z-index', '{} !important'.format(int(2 ** 32 / 2 - 1))),
('pointer-events', 'none !important'),
('position', 'fixed !important'),
('color', config.get('colors', 'hints.fg') + ' !important'),
('background', config.get('colors', 'hints.bg') + ' !important'),
('font', config.get('fonts', 'hints') + ' !important'),
('border', config.get('hints', 'border') + ' !important'),
('opacity', str(config.get('hints', 'opacity')) + ' !important'),
]
2014-10-26 08:44:47 +01:00
# Make text uppercase if set in config
2014-10-26 17:05:56 +01:00
if (config.get('hints', 'uppercase') and
self._context.hint_mode == 'letter'):
2015-01-06 17:10:54 +01:00
attrs.append(('text-transform', 'uppercase !important'))
2014-10-26 08:44:47 +01:00
else:
2015-01-06 17:10:54 +01:00
attrs.append(('text-transform', 'none !important'))
for k, v in attrs:
2016-07-27 15:11:06 +02:00
label.set_style_property(k, v)
self._set_style_position(elem, label)
def _set_style_position(self, elem, label):
"""Set the CSS position of the label element.
Args:
elem: The QWebElement to set the style attributes for.
label: The label QWebElement.
"""
no_js = config.get('hints', 'find-implementation') != 'javascript'
rect = elem.rect_on_view(adjust_zoom=False, no_js=no_js)
left = rect.x()
top = rect.y()
log.hints.vdebug("Drawing label '{!r}' at {}/{} for element '{!r}' "
"(no_js: {})".format(label, left, top, elem, no_js))
2016-07-27 15:11:06 +02:00
label.set_style_property('left', '{}px !important'.format(left))
label.set_style_property('top', '{}px !important'.format(top))
2014-05-06 17:02:32 +02:00
def _draw_label(self, elem, string):
2014-04-21 00:24:08 +02:00
"""Draw a hint label over an element.
Args:
elem: The QWebElement to use.
string: The hint string to print.
2014-04-21 15:45:29 +02:00
Return:
The newly created label element
2014-04-21 00:24:08 +02:00
"""
2016-07-27 15:11:06 +02:00
doc = elem.document_element()
body = doc.find_first('body')
2016-07-27 15:11:06 +02:00
if body is None:
parent = doc
2016-07-27 15:11:06 +02:00
else:
parent = body
label = parent.create_inside('span')
label['class'] = 'qutehint'
self._set_style_properties(elem, label)
2016-07-27 15:11:06 +02:00
label.set_text(string)
return label
2014-04-19 17:50:11 +02:00
def _show_url_error(self):
"""Show an error because no link was found."""
message.error(self._win_id, "No suitable link found for this element.",
immediately=True)
def _check_args(self, target, *args):
"""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.")
def _filter_matches(self, filterstr, elemstr):
"""Return True if `filterstr` matches `elemstr`."""
# Empty string and None always match
if not filterstr:
return True
filterstr = filterstr.casefold()
elemstr = elemstr.casefold()
# Do multi-word matching
return all(word in elemstr for word in filterstr.split())
def _start_cb(self, elems):
"""Initialize the elements and labels based on the context set."""
filterfunc = webelem.FILTERS.get(self._context.group, lambda e: True)
elems = [e for e in elems if filterfunc(e)]
if not elems:
2014-09-05 06:55:53 +02:00
raise cmdexc.CommandError("No elements found.")
strings = self._hint_strings(elems)
log.hints.debug("hints: {}".format(', '.join(strings)))
for e, string in zip(elems, strings):
label = self._draw_label(e, string)
elem = ElemTuple(e, label)
self._context.all_elems.append(elem)
self._context.elems[string] = elem
2014-09-28 23:23:02 +02:00
keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id)
keyparser = keyparsers[usertypes.KeyMode.hint]
keyparser.update_bindings(strings)
self._context.tab.contents_size_changed.connect(
self.on_contents_size_changed)
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')
2014-04-30 18:01:03 +02:00
@cmdutils.register(instance='hintmanager', scope='tab', name='hint',
star_args_optional=True, maxsplit=2,
backend=usertypes.Backend.QtWebKit)
@cmdutils.argument('win_id', win_id=True)
def start(self, rapid=False, group=webelem.Group.all, target=Target.normal,
*args, win_id, mode=None):
2014-04-19 17:50:11 +02:00
"""Start hinting.
Args:
rapid: Whether to do rapid hinting. This is only possible with
targets `tab` (with background-tabs=true), `tab-bg`,
`window`, `run`, `hover`, `userscript` and `spawn`.
group: The element types to hint.
2014-09-25 07:44:11 +02:00
- `all`: All clickable elements.
- `links`: Only links.
- `images`: Only images.
- `inputs`: Only input fields.
2014-09-25 07:44:11 +02:00
target: What to do with the selected element.
2016-03-30 15:51:03 +02:00
- `normal`: Open the link.
- `current`: Open the link in the current tab.
- `tab`: Open the link in a new tab (honoring the
background-tabs setting).
- `tab-fg`: Open the link in a new foreground tab.
2014-09-25 07:44:11 +02:00
- `tab-bg`: Open the link in a new background tab.
2014-10-06 17:58:40 +02:00
- `window`: Open the link in a new window.
- `hover` : Hover over the link.
2014-09-25 07:44:11 +02:00
- `yank`: Yank the link to the clipboard.
- `yank-primary`: Yank the link to the primary selection.
- `run`: Run the argument as command.
2014-09-25 07:44:11 +02:00
- `fill`: Fill the commandline with the command given as
argument.
- `download`: Download the link.
2015-06-29 17:48:30 +02:00
- `userscript`: Call a userscript with `$QUTE_URL` set to the
2014-09-25 07:44:11 +02:00
link.
- `spawn`: Spawn a command.
mode: The hinting mode to use.
- `number`: Use numeric hints.
- `letter`: Use the chars in the hints->chars settings.
- `word`: Use hint words based on the html elements and the
extra words.
*args: Arguments for spawn/userscript/run/fill.
2014-09-25 07:44:11 +02:00
- 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
2015-10-08 10:47:36 +02:00
`~/.local/share/qutebrowser/userscripts`
(or `$XDG_DATA_DIR`), or use an absolute
path.
2014-09-25 07:44:11 +02:00
- With `fill`: The command to fill the statusbar with.
`{hint-url}` will get replaced by the selected
URL.
- With `run`: Same as `fill`.
2014-04-19 17:50:11 +02:00
"""
2014-09-28 23:23:02 +02:00
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
2016-06-15 14:07:05 +02:00
tab = tabbed_browser.currentWidget()
if tab is None:
2014-09-25 07:44:11 +02:00
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,
2015-08-06 19:09:21 +02:00
Target.hover, Target.userscript, Target.spawn,
Target.download, Target.normal, Target.current]:
pass
elif (target == Target.tab and
config.get('tabs', 'background-tabs')):
pass
else:
name = target.name.replace('_', '-')
raise cmdexc.CommandError("Rapid hinting makes no sense with "
2015-05-06 23:36:01 +02:00
"target {}!".format(name))
if mode is None:
mode = config.get('hints', 'mode')
2014-09-25 07:44:11 +02:00
self._check_args(target, *args)
2014-09-05 06:55:53 +02:00
self._context = HintContext()
self._context.tab = tab
2014-09-05 06:55:53 +02:00
self._context.target = target
self._context.rapid = rapid
self._context.hint_mode = mode
try:
self._context.baseurl = tabbed_browser.current_url()
except qtutils.QtValueError:
raise cmdexc.CommandError("No URL set for this page yet!")
self._context.tab = tab
2014-09-14 23:09:01 +02:00
self._context.args = args
self._context.group = group
selector = webelem.SELECTORS[self._context.group]
self._context.tab.find_all_elements(selector, self._start_cb,
only_visible=True)
2014-04-20 17:25:46 +02:00
def current_mode(self):
2016-08-09 17:28:14 +02:00
"""Return the currently active hinting mode (or None otherwise)."""
if self._context is None:
return None
return self._context.hint_mode
2016-08-09 13:57:56 +02:00
def _get_visible_hints(self):
"""Get elements which are currently visible.
"""
visible = {}
for string, elem in self._context.elems.items():
try:
if not self._is_hidden(elem.label):
visible[string] = elem
except webelem.Error:
pass
if not visible:
# Whoops, filtered all hints
modeman.leave(self._win_id, usertypes.KeyMode.hint,
'all filtered')
return visible
def _handle_auto_follow(self, visible=None):
"""Handle the auto-follow option.
"""
if visible is None:
visible = self._get_visible_hints()
if len(visible) == 1 and config.get('hints', 'auto-follow'):
# apply auto-follow-timeout
timeout = config.get('hints', 'auto-follow-timeout')
keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id)
normal_parser = keyparsers[usertypes.KeyMode.normal]
normal_parser.set_inhibited_timeout(timeout)
# unpacking gets us the first (and only) key in the dict.
self.fire(*visible)
2014-04-21 15:20:41 +02:00
def handle_partial_key(self, keystr):
"""Handle a new partial keypress."""
2014-08-26 20:15:41 +02:00
log.hints.debug("Handling new keystring: '{}'".format(keystr))
for string, elem in self._context.elems.items():
try:
if string.startswith(keystr):
matched = string[:len(keystr)]
rest = string[len(keystr):]
match_color = config.get('colors', 'hints.fg.match')
2016-07-27 15:11:06 +02:00
elem.label.set_inner_xml(
'<font color="{}">{}</font>{}'.format(
match_color, matched, rest))
if self._is_hidden(elem.label):
2015-03-31 20:49:29 +02:00
# hidden element which matches again -> show it
self._show_elem(elem.label)
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.get('hints', 'hide-unmatched-rapid-hints')):
self._hide_elem(elem.label)
except webelem.Error:
pass
self._handle_auto_follow()
2014-04-21 15:20:41 +02:00
2014-05-02 17:53:16 +02:00
def filter_hints(self, filterstr):
2014-10-07 07:45:29 +02:00
"""Filter displayed hints according to a text.
Args:
2016-02-05 17:13:14 +01:00
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.
2014-10-07 07:45:29 +02:00
"""
if filterstr is None:
filterstr = self._filterstr
else:
self._filterstr = filterstr
2016-08-09 13:57:56 +02:00
visible = []
for elem in self._context.all_elems:
try:
if self._filter_matches(filterstr, str(elem.elem)):
2016-08-09 13:57:56 +02:00
visible.append(elem)
if self._is_hidden(elem.label):
2015-03-31 20:49:29 +02:00
# hidden element which matches again -> show it
self._show_elem(elem.label)
else:
# element doesn't match anymore -> hide it
self._hide_elem(elem.label)
except webelem.Error:
pass
if self._context.hint_mode == 'number':
2016-08-09 13:57:56 +02:00
# renumber filtered hints
strings = self._hint_strings(visible)
self._context.elems = {}
for elem, string in zip(visible, strings):
elem.label.set_inner_xml(string)
self._context.elems[string] = elem
keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id)
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.elems as the dict of visible hints
self._handle_auto_follow(self._context.elems)
2014-05-02 17:53:16 +02:00
2014-04-27 21:59:23 +02:00
def fire(self, keystr, force=False):
"""Fire a completed hint.
Args:
keystr: The keychain string to follow.
force: When True, follow even when auto-follow is false.
"""
if not (force or config.get('hints', 'auto-follow')):
self.handle_partial_key(keystr)
self._context.to_follow = keystr
2014-04-27 21:59:23 +02:00
return
2016-08-05 11:19:54 +02:00
# Handlers which take a QWebElement
elem_handlers = {
2016-08-05 11:19:54 +02:00
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.
2016-08-05 11:19:54 +02:00
Target.download: self._actions.download,
Target.userscript: self._actions.call_userscript,
}
# Handlers which take a QUrl
url_handlers = {
2016-08-05 11:19:54 +02:00
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.elems[keystr].elem
2016-08-05 11:19:54 +02:00
2016-07-27 15:11:06 +02:00
if elem.frame() is None:
2016-04-27 18:30:54 +02:00
message.error(self._win_id,
"This element has no webframe.",
immediately=True)
return
2016-08-05 11:19:54 +02:00
if self._context.target in elem_handlers:
2016-04-27 18:30:54 +02:00
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:
self._show_url_error()
return
2016-04-27 18:30:54 +02:00
handler = functools.partial(url_handlers[self._context.target],
url, self._context)
2014-05-05 07:45:36 +02:00
else:
raise ValueError("No suitable handler found!")
2016-08-05 11:19:54 +02:00
if not self._context.rapid:
2014-09-28 22:13:14 +02:00
modeman.maybe_leave(self._win_id, usertypes.KeyMode.hint,
'followed')
2014-10-07 07:45:29 +02:00
else:
# Reset filtering
2014-10-07 07:45:29 +02:00
self.filter_hints(None)
# Undo keystring highlighting
for string, elem in self._context.elems.items():
2016-07-27 15:11:06 +02:00
elem.label.set_inner_xml(string)
2016-08-05 11:19:54 +02:00
try:
handler()
except HintingError:
self._show_url_error()
@cmdutils.register(instance='hintmanager', scope='tab', hide=True,
modes=[usertypes.KeyMode.hint])
2015-11-09 07:35:33 +01:00
def follow_hint(self, keystring=None):
"""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.elems:
raise cmdexc.CommandError("No hint {}!".format(keystring))
2015-11-09 07:35:33 +01:00
self.fire(keystring, force=True)
2014-04-27 21:59:23 +02:00
@pyqtSlot()
def on_contents_size_changed(self):
"""Reposition hints if contents size changed."""
2014-08-26 20:15:41 +02:00
log.hints.debug("Contents size changed...!")
for e in self._context.all_elems:
try:
2016-07-27 15:11:06 +02:00
if e.elem.frame() is None:
# This sometimes happens for some reason...
e.label.remove_from_document()
continue
self._set_style_position(e.elem, e.label)
except webelem.Error:
pass
2014-04-24 23:47:02 +02:00
2014-08-26 19:10:14 +02:00
@pyqtSlot(usertypes.KeyMode)
2014-04-24 23:47:02 +02:00
def on_mode_left(self, mode):
"""Stop hinting when hinting mode was left."""
2014-08-26 19:10:14 +02:00
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.
2014-04-24 23:47:02 +02:00
return
2014-08-04 03:14:14 +02:00
self._cleanup()
2015-12-29 18:48:01 +01:00
2016-01-01 19:37:00 +01:00
class WordHinter:
2015-12-29 18:48:01 +01:00
"""Generator for word hints.
Attributes:
2016-01-05 22:45:52 +01:00
words: A set of words to be used when no "smart hint" can be
derived from the hinted element.
2015-12-29 18:48:01 +01:00
"""
2016-01-05 20:42:32 +01:00
2015-12-29 18:48:01 +01:00
def __init__(self):
# will be initialized on first use.
self.words = set()
2016-04-25 10:48:47 +02:00
self.dictionary = None
2015-12-29 18:48:01 +01:00
def ensure_initialized(self):
"""Generate the used words if yet uninitialized."""
2016-04-25 10:48:47 +02:00
dictionary = config.get("hints", "dictionary")
if not self.words or self.dictionary != dictionary:
self.words.clear()
self.dictionary = dictionary
2015-12-29 18:48:01 +01:00
try:
with open(dictionary, encoding="UTF-8") as wordfile:
alphabet = set(ascii_lowercase)
2015-12-29 18:48:01 +01:00
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:
2016-01-05 22:45:52 +01:00
# we don't need words longer than 4
2015-12-29 18:48:01 +01:00
continue
for i in range(len(word)):
2016-01-05 22:45:52 +01:00
# remove all prefixes of this word
2015-12-29 18:48:01 +01:00
hints.discard(word[:i + 1])
hints.add(word)
self.words.update(hints)
2016-01-01 19:37:00 +01:00
except IOError as e:
error = "Word hints requires reading the file at {}: {}"
2016-08-05 11:19:54 +02:00
raise HintingError(error.format(dictionary, str(e)))
2015-12-29 18:48:01 +01:00
def extract_tag_words(self, elem):
"""Extract tag words form the given element."""
2016-01-01 19:37:00 +01:00
attr_extractors = {
"alt": lambda elem: elem["alt"],
"name": lambda elem: elem["name"],
"title": lambda elem: elem["title"],
"placeholder": lambda elem: elem["placeholder"],
2016-01-01 19:37:00 +01:00
"src": lambda elem: elem["src"].split('/')[-1],
"href": lambda elem: elem["href"].split('/')[-1],
"text": str,
}
2016-04-27 18:30:54 +02:00
extractable_attrs = collections.defaultdict(list, {
"img": ["alt", "title", "src"],
"a": ["title", "href", "text"],
"input": ["name", "placeholder"],
"button": ["text"]
2016-04-27 18:30:54 +02:00
})
2016-01-01 19:37:00 +01:00
2016-01-01 20:16:49 +01:00
return (attr_extractors[attr](elem)
2016-07-27 16:54:26 +02:00
for attr in extractable_attrs[elem.tag_name()]
2016-01-01 20:16:49 +01:00
if attr in elem or attr == "text")
2015-12-29 18:48:01 +01:00
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
2016-01-05 22:45:52 +01:00
match = re.search('[A-Za-z]{3,}', candidate)
2015-12-29 18:48:01 +01:00
if not match:
continue
2016-01-17 21:06:36 +01:00
if 4 < match.end() - match.start() < 8:
yield candidate[match.start():match.end()].lower()
2015-12-29 18:48:01 +01:00
def any_prefix(self, hint, existing):
2016-04-27 18:30:54 +02:00
return any(hint.startswith(e) or e.startswith(hint) for e in existing)
2015-12-29 18:48:01 +01:00
def filter_prefixes(self, hints, existing):
return (h for h in hints if not self.any_prefix(h, existing))
def new_hint_for(self, elem, existing, fallback):
2015-12-29 18:48:01 +01:00
"""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)
2015-12-29 18:48:01 +01:00
# either the first good, or None
2016-04-25 11:35:16 +02:00
return (next(new_no_prefixes, None) or
next(fallback_no_prefixes, None))
2015-12-29 18:48:01 +01:00
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, words)
2016-04-25 10:12:21 +02:00
if not hint:
2016-08-05 11:19:54 +02:00
raise HintingError("Not enough words in the dictionary.")
2015-12-29 18:48:01 +01:00
used_hints.add(hint)
hints.append(hint)
return hints