qutebrowser/qutebrowser/browser/hints.py

1181 lines
46 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.QtWebKit import QWebElement
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
2014-09-08 10:30:05 +02:00
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-01-01 19:37:00 +01:00
class WordHintingError(Exception):
2016-01-05 20:42:32 +01:00
2016-01-01 19:37:00 +01:00
"""Exception raised on errors during word hinting."""
2015-12-29 18:48:01 +01:00
@pyqtSlot(usertypes.KeyMode)
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.
destroyed_frames: id()'s of QWebFrames which have been destroyed.
(Workaround for https://github.com/The-Compiler/qutebrowser/issues/152)
all_elems: A list of all (elem, label) namedtuples ever created.
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.
mainframe: The main QWebFrame where 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 = []
self.destroyed_frames = []
2014-07-29 01:45:42 +02:00
self.args = []
self.mainframe = None
self.group = 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
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:
2014-04-21 16:59:03 +02:00
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.
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()
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."""
for elem in self._context.all_elems:
2014-09-04 08:00:05 +02:00
try:
2014-08-04 03:14:14 +02:00
elem.label.removeFromDocument()
2014-09-04 08:00:05 +02:00
except webelem.IsNullError:
pass
for f in self._context.frames:
log.hints.debug("Disconnecting frame {}".format(f))
if id(f) in self._context.destroyed_frames:
# WORKAROUND for
# https://github.com/The-Compiler/qutebrowser/issues/152
log.hints.debug("Frame has been destroyed, ignoring.")
continue
try:
f.contentsSizeChanged.disconnect(self.on_contents_size_changed)
except TypeError:
# It seems we can get this here:
# TypeError: disconnect() failed between
# 'contentsSizeChanged' and 'on_contents_size_changed'
# See # https://github.com/The-Compiler/qutebrowser/issues/263
pass
2014-10-07 23:10:58 +02:00
log.hints.debug("Disconnected.")
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
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
"""
2016-01-01 19:37:00 +01:00
hint_mode = config.get('hints', 'mode')
if hint_mode == 'word':
try:
2015-12-29 18:48:01 +01:00
return self._word_hinter.hint(elems)
except WordHintingError as e:
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.styleProperty('display', QWebElement.InlineStyle)
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."""
elem.setStyleProperty('display', 'inline !important')
def _hide_elem(self, elem):
"""Hide a given element."""
elem.setStyleProperty('display', 'none !important')
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
config.get('hints', '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:
label.setStyleProperty(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.
"""
rect = elem.rect_on_view(adjust_zoom=False)
left = rect.x()
top = rect.y()
log.hints.vdebug("Drawing label '{!r}' at {}/{} for element '{!r}'"
.format(label, left, top, elem))
2015-01-06 17:10:54 +01:00
label.setStyleProperty('left', '{}px !important'.format(left))
label.setStyleProperty('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
"""
2014-05-12 07:49:44 +02:00
doc = elem.webFrame().documentElement()
2014-04-23 14:34:00 +02:00
# It seems impossible to create an empty QWebElement for which isNull()
# is false so we can work with it.
# As a workaround, we use appendInside() with markup as argument, and
# then use lastChild() to get a reference to it.
# See: http://stackoverflow.com/q/7364852/2085149
body = doc.findFirst('body')
if not body.isNull():
parent = body
else:
parent = doc
parent.appendInside('<span></span>')
label = webelem.WebElementWrapper(parent.lastChild())
label['class'] = 'qutehint'
self._set_style_properties(elem, label)
label.setPlainText(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 _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,
2016-03-30 15:51:03 +02:00
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
2014-11-02 15:37:34 +01:00
events = [
2014-05-12 07:49:44 +02:00
QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton,
Qt.NoModifier),
2014-11-02 15:37:34 +01:00
]
if context.target != Target.hover:
2014-11-02 15:37:34 +01:00
events += [
2014-11-02 20:04:41 +01:00
QMouseEvent(QEvent.MouseButtonPress, pos, Qt.LeftButton,
Qt.LeftButton, modifiers),
2014-11-02 20:04:41 +01:00
QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton,
Qt.NoButton, modifiers),
2014-11-02 15:37:34 +01:00
]
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("'")
2016-03-30 15:51:03 +02:00
if context.target == Target.current:
2016-03-30 19:08:10 +02:00
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.webFrame().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:
2015-03-31 20:49:29 +02:00
url: The URL to open as a QUrl.
context: The HintContext to use.
"""
2016-05-08 22:18:14 +02:00
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.
"""
2014-12-13 00:30:35 +01:00
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):
2014-06-19 17:58:46 +02:00
"""Download a hint URL.
Args:
elem: The QWebElement to download.
_context: The HintContext to use.
2014-06-19 17:58:46 +02:00
"""
url = self._resolve_url(elem, context.baseurl)
if url is None:
self._show_url_error()
return
2015-08-06 19:09:21 +02:00
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.webFrame().page(),
2015-08-06 19:09:21 +02:00
prompt_download_directory=prompt)
2014-06-19 17:58:46 +02:00
def _call_userscript(self, elem, context):
2015-06-29 17:48:30 +02:00
"""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:]
frame = context.mainframe
env = {
'QUTE_MODE': 'hints',
'QUTE_SELECTED_TEXT': str(elem),
'QUTE_SELECTED_HTML': elem.toOuterXml(),
}
url = self._resolve_url(elem, context.baseurl)
if url is not None:
env['QUTE_URL'] = url.toString(QUrl.FullyEncoded)
env.update(userscripts.store_source(frame))
userscripts.run(cmd, *args, win_id=self._win_id, env=env)
2014-07-29 01:45:42 +02:00
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.
"""
2014-07-29 01:45:42 +02:00
urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword)
args = context.get_args(urlstr)
commandrunner = runners.CommandRunner(self._win_id)
commandrunner.run_safely('spawn ' + ' '.join(args))
def _resolve_url(self, elem, baseurl):
"""Resolve a URL and check if we want to keep it.
2014-04-21 23:53:13 +02:00
Args:
elem: The QWebElement to get the URL of.
baseurl: The baseurl of the current tab.
2014-04-21 23:53:13 +02:00
Return:
A QUrl with the absolute URL, or None.
2014-04-21 23:53:13 +02:00
"""
for attr in ('href', 'src'):
if attr in elem:
text = elem[attr]
break
2014-12-13 00:30:35 +01:00
else:
2014-04-21 23:53:13 +02:00
return None
url = QUrl(text)
if not url.isValid():
return None
if url.isRelative():
url = baseurl.resolved(url)
qtutils.ensure_valid(url)
return url
2014-04-21 23:53:13 +02:00
2014-05-01 16:35:26 +02:00
def _find_prevnext(self, frame, prev=False):
"""Find a prev/next element in frame."""
2014-04-30 18:01:03 +02:00
# First check for <link rel="prev(ious)|next">
2016-04-27 18:30:54 +02:00
elems = frame.findAllElements(webelem.SELECTORS[webelem.Group.links])
rel_values = ('prev', 'previous') if prev else ('next')
2014-04-30 18:01:03 +02:00
for e in elems:
2014-09-04 08:00:05 +02:00
e = webelem.WebElementWrapper(e)
try:
rel_attr = e['rel']
except KeyError:
continue
if rel_attr in rel_values:
log.hints.debug("Found '{}' with rel={}".format(
2014-09-04 08:00:05 +02:00
e.debug_text(), rel_attr))
2014-05-01 16:35:26 +02:00
return e
2014-07-16 09:17:59 +02:00
# Then check for regular links/buttons.
2014-05-06 21:09:09 +02:00
elems = frame.findAllElements(
webelem.SELECTORS[webelem.Group.prevnext])
2015-08-03 23:21:20 +02:00
elems = [webelem.WebElementWrapper(e) for e in elems]
filterfunc = webelem.FILTERS[webelem.Group.prevnext]
elems = [e for e in elems if filterfunc(e)]
2014-04-30 18:01:03 +02:00
option = 'prev-regexes' if prev else 'next-regexes'
2014-05-01 16:35:26 +02:00
if not elems:
return None
for regex in config.get('hints', option):
log.hints.vdebug("== Checking regex '{}'.".format(regex.pattern))
2014-05-01 16:35:26 +02:00
for e in elems:
2014-09-04 08:00:05 +02:00
text = str(e)
if not text:
continue
if regex.search(text):
log.hints.debug("Regex '{}' matched on '{}'.".format(
regex.pattern, text))
2014-05-01 16:35:26 +02:00
return e
else:
log.hints.vdebug("No match on '{}'!".format(text))
2014-05-01 16:35:26 +02:00
return None
2014-05-13 21:13:53 +02:00
def _connect_frame_signals(self):
"""Connect the contentsSizeChanged signals to all frames."""
for f in self._context.frames:
log.hints.debug("Connecting frame {}".format(f))
f.contentsSizeChanged.connect(self.on_contents_size_changed)
2014-05-13 21:13:53 +02:00
2014-09-14 23:09:01 +02:00
def _check_args(self, target, *args):
2014-09-05 06:55:53 +02:00
"""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))
2014-12-13 00:30:35 +01:00
if target in (Target.userscript, Target.spawn, Target.run,
Target.fill):
2014-09-14 23:09:01 +02:00
if not args:
2014-09-05 06:55:53 +02:00
raise cmdexc.CommandError(
2014-12-13 00:30:35 +01:00
"'args' is required with target userscript/spawn/run/"
"fill.")
2014-09-05 06:55:53 +02:00
else:
2014-09-14 23:09:01 +02:00
if args:
2014-09-05 06:55:53 +02:00
raise cmdexc.CommandError(
"'args' is only allowed with target userscript/spawn.")
def _init_elements(self):
"""Initialize the elements and labels based on the context set."""
2014-09-05 06:55:53 +02:00
elems = []
for f in self._context.frames:
elems += f.findAllElements(webelem.SELECTORS[self._context.group])
elems = [e for e in elems
if webelem.is_visible(e, self._context.mainframe)]
# We wrap the elements late for performance reasons, as wrapping 1000s
# of elements (with ~50 methods each) just takes too much time...
elems = [webelem.WebElementWrapper(e) for e in elems]
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)
def _update_strings(self, elems):
"""Update the `self._context.elems` mapping based on filtered elements.
Args:
elems: List of ElemTuple objects.
"""
strings = self._hint_strings(elems)
self._context.elems = {}
for elem, string in zip(elems, strings):
elem.label.setInnerXml(string)
self._context.elems[string] = elem
keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id)
keyparser = keyparsers[usertypes.KeyMode.hint]
keyparser.update_bindings(strings, preserve_filter=True)
2014-09-05 06:55:53 +02:00
def _filter_matches(self, filterstr, elemstr):
2016-02-05 17:13:14 +01:00
"""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())
2014-10-06 17:58:40 +02:00
def follow_prevnext(self, frame, baseurl, prev=False, tab=False,
background=False, window=False):
2014-05-01 16:35:26 +02:00
"""Click a "previous"/"next" element on the page.
Args:
frame: The frame where the element is in.
2014-05-01 16:40:14 +02:00
baseurl: The base URL of the current tab.
2014-05-01 16:35:26 +02:00
prev: True to open a "previous" link, False to open a "next" link.
2014-10-06 17:58:40 +02:00
tab: True to open in a new tab, False for the current tab.
background: True to open in a background tab.
window: True to open in a new window, False for the current one.
2014-05-01 16:35:26 +02:00
"""
from qutebrowser.mainwindow import mainwindow
2014-05-01 16:35:26 +02:00
elem = self._find_prevnext(frame, prev)
if elem is None:
2014-08-26 19:10:14 +02:00
raise cmdexc.CommandError("No {} links found!".format(
2014-05-14 18:00:40 +02:00
"prev" if prev else "forward"))
url = self._resolve_url(elem, baseurl)
if url is None:
2014-08-26 19:10:14 +02:00
raise cmdexc.CommandError("No {} links found!".format(
2014-05-14 18:00:40 +02:00
"prev" if prev else "forward"))
2014-09-25 07:44:11 +02:00
qtutils.ensure_valid(url)
2014-10-06 17:58:40 +02:00
if window:
new_window = mainwindow.MainWindow()
new_window.show()
2014-09-28 23:23:02 +02:00
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=new_window.win_id)
2014-10-06 18:47:55 +02:00
tabbed_browser.tabopen(url, background=False)
elif tab:
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
2014-10-06 17:58:40 +02:00
tabbed_browser.tabopen(url, background=background)
2014-09-25 07:44:11 +02:00
else:
2014-10-06 18:47:55 +02:00
webview = objreg.get('webview', scope='tab', window=self._win_id,
tab=self._tab_id)
webview.openurl(url)
2014-04-30 18:01:03 +02:00
@cmdutils.register(instance='hintmanager', scope='tab', name='hint',
star_args_optional=True, maxsplit=2)
@cmdutils.argument('win_id', win_id=True)
def start(self, rapid=False, group=webelem.Group.all, target=Target.normal,
*args, win_id):
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`.
2014-09-25 07:44:11 +02:00
group: The hinting mode to use.
- `all`: All clickable elements.
- `links`: Only links.
- `images`: Only images.
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.
*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)
2014-09-25 07:44:11 +02:00
widget = tabbed_browser.currentWidget()
if widget is None:
raise cmdexc.CommandError("No WebView available yet!")
mainframe = widget.page().mainFrame()
if mainframe is None:
2014-09-25 07:44:11 +02:00
raise cmdexc.CommandError("No frame focused!")
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))
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.target = target
self._context.rapid = rapid
try:
self._context.baseurl = tabbed_browser.current_url()
except qtutils.QtValueError:
raise cmdexc.CommandError("No URL set for this page yet!")
2014-09-05 06:55:53 +02:00
self._context.frames = webelem.get_child_frames(mainframe)
for frame in self._context.frames:
# WORKAROUND for
# https://github.com/The-Compiler/qutebrowser/issues/152
frame.destroyed.connect(functools.partial(
self._context.destroyed_frames.append, id(frame)))
2014-09-14 23:09:01 +02:00
self._context.args = args
self._context.mainframe = mainframe
self._context.group = group
self._init_elements()
2014-09-28 23:23:02 +02:00
message_bridge = objreg.get('message-bridge', scope='window',
window=self._win_id)
message_bridge.set_text(self._get_text())
2014-05-13 21:13:53 +02:00
self._connect_frame_signals()
modeman.enter(self._win_id, usertypes.KeyMode.hint,
'HintManager.start')
2014-04-20 17:25:46 +02:00
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')
elem.label.setInnerXml(
'<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
self._hide_elem(elem.label)
except webelem.IsNullError:
pass
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
for elem in self._context.all_elems:
try:
if self._filter_matches(filterstr, str(elem.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.IsNullError:
pass
if config.get('hints', 'mode') == 'number':
# renumber filtered hints
elems = []
for e in self._context.all_elems:
try:
if not self._is_hidden(e.label):
elems.append(e)
except webelem.IsNullError:
pass
if not elems:
# Whoops, filtered all hints
2016-02-05 17:13:14 +01:00
modeman.leave(self._win_id, usertypes.KeyMode.hint,
'all filtered')
return
self._update_strings(elems)
visible = self._context.elems
else:
visible = {}
for string, elem in self._context.elems.items():
try:
if not self._is_hidden(elem.label):
visible[string] = elem
except webelem.IsNullError:
pass
if not visible:
# Whoops, filtered all hints
2016-02-05 17:13:14 +01:00
modeman.leave(self._win_id, usertypes.KeyMode.hint,
'all filtered')
return
if (len(visible) == 1 and
config.get('hints', 'auto-follow') and
filterstr is not None):
2015-12-16 19:41:07 +01:00
# apply auto-follow-timeout
timeout = config.get('hints', 'auto-follow-timeout')
2016-06-07 14:30:46 +02:00
keyparsers = objreg.get('keyparsers', scope='window',
window=self._win_id)
normal_parser = keyparsers[usertypes.KeyMode.normal]
2015-12-16 19:41:07 +01:00
normal_parser.set_inhibited_timeout(timeout)
2014-05-02 17:53:16 +02:00
# unpacking gets us the first (and only) key in the dict.
2014-05-06 17:02:32 +02:00
self.fire(*visible)
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
# Handlers which take a QWebElement
elem_handlers = {
2014-05-05 07:45:36 +02:00
Target.normal: self._click,
2016-03-30 15:51:03 +02:00
Target.current: self._click,
2014-05-05 07:45:36 +02:00
Target.tab: self._click,
Target.tab_fg: self._click,
2014-05-16 23:01:40 +02:00
Target.tab_bg: self._click,
2014-10-06 17:58:40 +02:00
Target.window: self._click,
2014-11-02 15:37:34 +01:00
Target.hover: self._click,
# _download needs a QWebElement to get the frame.
Target.download: self._download,
Target.userscript: self._call_userscript,
}
# Handlers which take a QUrl
url_handlers = {
2014-05-05 07:45:36 +02:00
Target.yank: self._yank,
Target.yank_primary: self._yank,
Target.run: self._run_cmd,
Target.fill: self._preset_cmd_text,
2014-07-29 01:45:42 +02:00
Target.spawn: self._spawn,
}
elem = self._context.elems[keystr].elem
if elem.webFrame() is None:
2016-04-27 18:30:54 +02:00
message.error(self._win_id,
"This element has no webframe.",
immediately=True)
return
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 = self._resolve_url(elem, 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!")
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():
elem.label.setInnerXml(string)
handler()
@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('QSize')
2014-04-22 17:53:27 +02:00
def on_contents_size_changed(self, _size):
"""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:
if e.elem.webFrame() is None:
# This sometimes happens for some reason...
e.label.removeFromDocument()
continue
self._set_style_position(e.elem, e.label)
except webelem.IsNullError:
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 {}: {}"
raise WordHintingError(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"],
"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"]
})
2016-01-01 19:37:00 +01:00
2016-01-01 20:16:49 +01:00
return (attr_extractors[attr](elem)
2016-01-01 19:37:00 +01:00
for attr in extractable_attrs[elem.tagName()]
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:
raise WordHintingError("Not enough words in the dictionary.")
2015-12-29 18:48:01 +01:00
used_hints.add(hint)
hints.append(hint)
return hints