2014-03-03 21:35:13 +01:00
|
|
|
# Copyright 2014 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/>.
|
|
|
|
|
|
|
|
"""The main browser widgets."""
|
|
|
|
|
|
|
|
import logging
|
|
|
|
import functools
|
|
|
|
|
2014-04-23 07:32:27 +02:00
|
|
|
from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt
|
2014-04-21 16:59:03 +02:00
|
|
|
from PyQt5.QtWidgets import QApplication
|
2014-03-03 21:35:13 +01:00
|
|
|
from PyQt5.QtWebKit import QWebSettings
|
|
|
|
from PyQt5.QtWebKitWidgets import QWebView, QWebPage
|
|
|
|
|
|
|
|
import qutebrowser.utils.url as urlutils
|
|
|
|
import qutebrowser.config.config as config
|
2014-04-10 14:40:02 +02:00
|
|
|
import qutebrowser.utils.message as message
|
2014-04-23 22:22:58 +02:00
|
|
|
import qutebrowser.utils.modemanager as modemanager
|
2014-04-24 16:28:00 +02:00
|
|
|
import qutebrowser.utils.webelem as webelem
|
2014-04-17 09:44:26 +02:00
|
|
|
from qutebrowser.browser.webpage import BrowserPage
|
2014-04-19 17:50:11 +02:00
|
|
|
from qutebrowser.browser.hints import HintManager
|
2014-03-03 21:35:13 +01:00
|
|
|
from qutebrowser.utils.signals import SignalCache
|
|
|
|
from qutebrowser.utils.usertypes import NeighborList
|
|
|
|
|
|
|
|
|
|
|
|
class BrowserTab(QWebView):
|
|
|
|
|
|
|
|
"""One browser tab in TabbedBrowser.
|
|
|
|
|
|
|
|
Our own subclass of a QWebView with some added bells and whistles.
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
page_: The QWebPage behind the view
|
|
|
|
signal_cache: The signal cache associated with the view.
|
2014-04-21 00:24:08 +02:00
|
|
|
hintmanager: The HintManager instance for this view.
|
2014-03-03 21:35:13 +01:00
|
|
|
_zoom: A NeighborList with the zoom levels.
|
|
|
|
_scroll_pos: The old scroll position.
|
|
|
|
_shutdown_callback: Callback to be called after shutdown.
|
2014-04-21 19:03:04 +02:00
|
|
|
_open_target: Where to open the next tab ("normal", "tab", "bgtab")
|
2014-04-21 19:29:11 +02:00
|
|
|
_force_open_target: Override for _open_target.
|
2014-03-03 21:35:13 +01:00
|
|
|
_shutdown_callback: The callback to call after shutting down.
|
|
|
|
_destroyed: Dict of all items to be destroyed on shtudown.
|
|
|
|
|
|
|
|
Signals:
|
|
|
|
scroll_pos_changed: Scroll percentage of current tab changed.
|
|
|
|
arg 1: x-position in %.
|
|
|
|
arg 2: y-position in %.
|
|
|
|
open_tab: A new tab should be opened.
|
2014-04-21 19:03:04 +02:00
|
|
|
arg 1: The address to open
|
|
|
|
arg 2: Whether to open the tab in the background
|
2014-03-03 21:35:13 +01:00
|
|
|
linkHovered: QWebPages linkHovered signal exposed.
|
|
|
|
"""
|
|
|
|
|
|
|
|
scroll_pos_changed = pyqtSignal(int, int)
|
2014-04-21 19:03:04 +02:00
|
|
|
open_tab = pyqtSignal('QUrl', bool)
|
2014-03-03 21:35:13 +01:00
|
|
|
linkHovered = pyqtSignal(str, str, str)
|
|
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
|
super().__init__(parent)
|
|
|
|
self._scroll_pos = (-1, -1)
|
|
|
|
self._shutdown_callback = None
|
2014-04-21 19:03:04 +02:00
|
|
|
self._open_target = "normal"
|
2014-04-21 19:29:11 +02:00
|
|
|
self._force_open_target = None
|
2014-03-03 21:35:13 +01:00
|
|
|
self._destroyed = {}
|
2014-04-10 18:01:16 +02:00
|
|
|
self._zoom = None
|
|
|
|
self._init_neighborlist()
|
2014-03-03 21:35:13 +01:00
|
|
|
self.page_ = BrowserPage(self)
|
|
|
|
self.setPage(self.page_)
|
2014-04-22 14:23:55 +02:00
|
|
|
self.hintmanager = HintManager(self)
|
2014-04-21 16:59:03 +02:00
|
|
|
self.hintmanager.mouse_event.connect(self.on_mouse_event)
|
2014-04-21 19:29:11 +02:00
|
|
|
self.hintmanager.set_open_target.connect(self.set_force_open_target)
|
2014-03-03 21:35:13 +01:00
|
|
|
self.signal_cache = SignalCache(uncached=['linkHovered'])
|
|
|
|
self.page_.setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
|
|
|
|
self.page_.linkHovered.connect(self.linkHovered)
|
|
|
|
self.linkClicked.connect(self.on_link_clicked)
|
2014-04-24 16:03:16 +02:00
|
|
|
self.loadFinished.connect(self.on_load_finished)
|
2014-03-03 21:35:13 +01:00
|
|
|
# FIXME find some way to hide scrollbars without setScrollBarPolicy
|
|
|
|
|
2014-04-10 18:01:16 +02:00
|
|
|
def _init_neighborlist(self):
|
|
|
|
"""Initialize the _zoom neighborlist."""
|
|
|
|
self._zoom = NeighborList(
|
2014-04-17 15:26:27 +02:00
|
|
|
config.get('general', 'zoomlevels'),
|
|
|
|
default=config.get('general', 'defaultzoom'),
|
2014-04-10 18:01:16 +02:00
|
|
|
mode=NeighborList.BLOCK)
|
|
|
|
|
2014-04-22 10:45:07 +02:00
|
|
|
def _on_destroyed(self, sender):
|
|
|
|
"""Called when a subsystem has been destroyed during shutdown.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
sender: The object which called the callback.
|
|
|
|
"""
|
|
|
|
self._destroyed[sender] = True
|
|
|
|
dbgout = '\n'.join(['{}: {}'.format(k.__class__.__name__, v)
|
|
|
|
for (k, v) in self._destroyed.items()])
|
|
|
|
logging.debug("{} has been destroyed, new status:\n{}".format(
|
|
|
|
sender.__class__.__name__, dbgout))
|
|
|
|
if all(self._destroyed.values()):
|
|
|
|
if self._shutdown_callback is not None:
|
|
|
|
logging.debug("Everything destroyed, calling callback")
|
|
|
|
self._shutdown_callback()
|
|
|
|
|
2014-04-24 07:41:20 +02:00
|
|
|
def _is_editable(self, hitresult):
|
2014-04-24 13:13:58 +02:00
|
|
|
"""Check if a hit result needs keyboard focus.
|
2014-04-24 07:41:20 +02:00
|
|
|
|
|
|
|
Args:
|
|
|
|
hitresult: A QWebHitTestResult
|
|
|
|
"""
|
|
|
|
# FIXME is this algorithm accurate?
|
|
|
|
if hitresult.isContentEditable():
|
2014-04-24 13:13:58 +02:00
|
|
|
# text fields and the like
|
2014-04-24 07:41:20 +02:00
|
|
|
return True
|
|
|
|
if not config.get('general', 'insert_mode_on_plugins'):
|
|
|
|
return False
|
|
|
|
elem = hitresult.element()
|
|
|
|
tag = elem.tagName().lower()
|
|
|
|
if tag in ['embed', 'applet']:
|
2014-04-24 13:13:58 +02:00
|
|
|
# Flash/Java/...
|
2014-04-24 07:41:20 +02:00
|
|
|
return True
|
2014-04-24 13:13:58 +02:00
|
|
|
if tag == 'object':
|
|
|
|
# Could be Flash/Java/..., could be image/audio/...
|
2014-04-24 07:41:20 +02:00
|
|
|
if not elem.hasAttribute("type"):
|
2014-04-24 13:13:58 +02:00
|
|
|
logging.debug("<object> without type clicked...")
|
2014-04-24 07:41:20 +02:00
|
|
|
return False
|
|
|
|
objtype = elem.attribute("type")
|
2014-04-24 13:13:58 +02:00
|
|
|
if (objtype.startswith("application/") or
|
|
|
|
elem.hasAttribute("classid")):
|
|
|
|
# Let's hope flash/java stuff has an application/* mimetype OR
|
|
|
|
# at least a classid attribute. Oh, and let's home images/...
|
|
|
|
# DON"T have a classid attribute. HTML sucks.
|
2014-04-24 07:41:20 +02:00
|
|
|
logging.debug("<object type=\"{}\"> clicked.".format(objtype))
|
|
|
|
return True
|
2014-04-24 13:13:58 +02:00
|
|
|
return False
|
2014-04-24 07:41:20 +02:00
|
|
|
|
2014-03-03 21:35:13 +01:00
|
|
|
def openurl(self, url):
|
|
|
|
"""Open an URL in the browser.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
url: The URL to load, as string or QUrl.
|
|
|
|
|
|
|
|
Return:
|
|
|
|
Return status of self.load
|
|
|
|
|
|
|
|
Emit:
|
|
|
|
titleChanged and urlChanged
|
|
|
|
"""
|
2014-04-10 14:40:02 +02:00
|
|
|
try:
|
|
|
|
u = urlutils.fuzzy_url(url)
|
|
|
|
except urlutils.SearchEngineError as e:
|
|
|
|
message.error(str(e))
|
|
|
|
return
|
2014-03-03 21:35:13 +01:00
|
|
|
logging.debug('New title: {}'.format(urlutils.urlstring(u)))
|
|
|
|
self.titleChanged.emit(urlutils.urlstring(u))
|
|
|
|
self.urlChanged.emit(urlutils.qurl(u))
|
|
|
|
return self.load(u)
|
|
|
|
|
|
|
|
def zoom(self, offset):
|
|
|
|
"""Increase/Decrease the zoom level.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
offset: The offset in the zoom level list.
|
|
|
|
"""
|
|
|
|
level = self._zoom.getitem(offset)
|
|
|
|
self.setZoomFactor(float(level) / 100)
|
2014-04-22 10:08:56 +02:00
|
|
|
message.info("Zoom level: {}%".format(level))
|
2014-03-03 21:35:13 +01:00
|
|
|
|
|
|
|
def shutdown(self, callback=None):
|
|
|
|
"""Shut down the tab cleanly and remove it.
|
|
|
|
|
|
|
|
Inspired by [1].
|
|
|
|
|
|
|
|
[1] https://github.com/integricho/path-of-a-pyqter/tree/master/qttut08
|
|
|
|
|
|
|
|
Args:
|
|
|
|
callback: Function to call after shutting down.
|
|
|
|
"""
|
|
|
|
self._shutdown_callback = callback
|
|
|
|
try:
|
|
|
|
# Avoid loading finished signal when stopping
|
|
|
|
self.loadFinished.disconnect()
|
|
|
|
except TypeError:
|
|
|
|
logging.exception("This should never happen.")
|
|
|
|
self.stop()
|
|
|
|
self.close()
|
|
|
|
self.settings().setAttribute(QWebSettings.JavascriptEnabled, False)
|
|
|
|
|
|
|
|
self._destroyed[self.page_] = False
|
|
|
|
self.page_.destroyed.connect(functools.partial(self._on_destroyed,
|
|
|
|
self.page_))
|
|
|
|
self.page_.deleteLater()
|
|
|
|
|
|
|
|
self._destroyed[self] = False
|
|
|
|
self.destroyed.connect(functools.partial(self._on_destroyed, self))
|
|
|
|
self.deleteLater()
|
|
|
|
|
|
|
|
netman = self.page_.network_access_manager
|
|
|
|
self._destroyed[netman] = False
|
|
|
|
netman.abort_requests()
|
|
|
|
netman.destroyed.connect(functools.partial(self._on_destroyed, netman))
|
|
|
|
netman.deleteLater()
|
|
|
|
logging.debug("Tab shutdown scheduled")
|
|
|
|
|
2014-04-22 10:45:07 +02:00
|
|
|
@pyqtSlot(str)
|
|
|
|
def on_link_clicked(self, url):
|
|
|
|
"""Handle a link.
|
|
|
|
|
|
|
|
Called from the linkClicked signal. Checks if it should open it in a
|
|
|
|
tab (middle-click or control) or not, and does so.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
url: The url to handle, as string or QUrl.
|
|
|
|
|
|
|
|
Emit:
|
|
|
|
open_tab: Emitted if window should be opened in a new tab.
|
|
|
|
"""
|
|
|
|
if self._open_target == "tab":
|
|
|
|
self.open_tab.emit(url, False)
|
|
|
|
elif self._open_target == "bgtab":
|
|
|
|
self.open_tab.emit(url, True)
|
|
|
|
else:
|
|
|
|
self.openurl(url)
|
|
|
|
|
2014-04-17 11:39:25 +02:00
|
|
|
@pyqtSlot(str, str)
|
|
|
|
def on_config_changed(self, section, option):
|
2014-04-10 18:01:16 +02:00
|
|
|
"""Update tab config when config was changed."""
|
|
|
|
if section == 'general' and option in ['zoomlevels', 'defaultzoom']:
|
|
|
|
self._init_neighborlist()
|
|
|
|
|
2014-04-21 16:59:03 +02:00
|
|
|
@pyqtSlot('QMouseEvent')
|
|
|
|
def on_mouse_event(self, evt):
|
|
|
|
"""Post a new mouseevent from a hintmanager."""
|
2014-04-21 17:23:22 +02:00
|
|
|
self.setFocus()
|
2014-04-21 16:59:03 +02:00
|
|
|
QApplication.postEvent(self, evt)
|
|
|
|
|
2014-04-24 16:03:16 +02:00
|
|
|
@pyqtSlot(bool)
|
|
|
|
def on_load_finished(self, ok):
|
|
|
|
"""Handle insert mode after loading finished."""
|
2014-04-24 16:27:18 +02:00
|
|
|
if not ok:
|
|
|
|
modemanager.maybe_leave("insert")
|
|
|
|
elif config.get('general', 'auto_insert_mode'):
|
2014-04-24 16:03:16 +02:00
|
|
|
frame = self.page_.currentFrame()
|
2014-04-24 16:28:00 +02:00
|
|
|
elem = frame.findFirstElement(
|
|
|
|
webelem.SELECTORS['editable_focused'])
|
2014-04-24 16:03:16 +02:00
|
|
|
logging.debug("focus element: {}".format(not elem.isNull()))
|
|
|
|
if elem.isNull():
|
|
|
|
modemanager.maybe_leave("insert")
|
|
|
|
else:
|
|
|
|
modemanager.enter("insert")
|
|
|
|
else:
|
|
|
|
modemanager.maybe_leave("insert")
|
|
|
|
|
2014-04-21 19:29:11 +02:00
|
|
|
@pyqtSlot(str)
|
|
|
|
def set_force_open_target(self, target):
|
|
|
|
"""Change the forced link target. Setter for _force_open_target.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
target: A string to set self._force_open_target to.
|
|
|
|
"""
|
|
|
|
self._force_open_target = target
|
|
|
|
|
2014-03-03 21:35:13 +01:00
|
|
|
def paintEvent(self, e):
|
|
|
|
"""Extend paintEvent to emit a signal if the scroll position changed.
|
|
|
|
|
|
|
|
This is a bit of a hack: We listen to repaint requests here, in the
|
|
|
|
hope a repaint will always be requested when scrolling, and if the
|
|
|
|
scroll position actually changed, we emit a signal.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
e: The QPaintEvent.
|
|
|
|
|
|
|
|
Emit:
|
|
|
|
scroll_pos_changed; If the scroll position changed.
|
2014-04-17 17:44:27 +02:00
|
|
|
|
|
|
|
Return:
|
|
|
|
The superclass event return value.
|
2014-03-03 21:35:13 +01:00
|
|
|
"""
|
|
|
|
frame = self.page_.mainFrame()
|
|
|
|
new_pos = (frame.scrollBarValue(Qt.Horizontal),
|
|
|
|
frame.scrollBarValue(Qt.Vertical))
|
|
|
|
if self._scroll_pos != new_pos:
|
|
|
|
self._scroll_pos = new_pos
|
|
|
|
logging.debug("Updating scroll position")
|
|
|
|
m = (frame.scrollBarMaximum(Qt.Horizontal),
|
|
|
|
frame.scrollBarMaximum(Qt.Vertical))
|
|
|
|
perc = (round(100 * new_pos[0] / m[0]) if m[0] != 0 else 0,
|
|
|
|
round(100 * new_pos[1] / m[1]) if m[1] != 0 else 0)
|
|
|
|
self.scroll_pos_changed.emit(*perc)
|
|
|
|
# Let superclass handle the event
|
|
|
|
return super().paintEvent(e)
|
|
|
|
|
2014-04-23 07:32:27 +02:00
|
|
|
def mousePressEvent(self, e):
|
2014-04-23 17:57:59 +02:00
|
|
|
"""Extend QWidget::mousePressEvent().
|
2014-03-03 21:35:13 +01:00
|
|
|
|
2014-04-23 17:57:59 +02:00
|
|
|
This does the following things:
|
|
|
|
- Check if a link was clicked with the middle button or Ctrl and
|
|
|
|
set the _open_target attribute accordingly.
|
|
|
|
- Emit the editable_elem_selected signal if an editable element was
|
|
|
|
clicked.
|
2014-03-03 21:35:13 +01:00
|
|
|
|
|
|
|
Args:
|
|
|
|
e: The arrived event.
|
|
|
|
|
|
|
|
Return:
|
2014-04-23 07:32:27 +02:00
|
|
|
The superclass return value.
|
2014-03-03 21:35:13 +01:00
|
|
|
"""
|
2014-04-23 17:57:59 +02:00
|
|
|
pos = e.pos()
|
|
|
|
frame = self.page_.frameAt(pos)
|
|
|
|
pos -= frame.geometry().topLeft()
|
|
|
|
hitresult = frame.hitTestContent(pos)
|
2014-04-24 07:41:20 +02:00
|
|
|
if self._is_editable(hitresult):
|
2014-04-23 17:57:59 +02:00
|
|
|
logging.debug("Clicked editable element!")
|
2014-04-23 22:22:58 +02:00
|
|
|
modemanager.enter("insert")
|
2014-04-23 23:26:02 +02:00
|
|
|
else:
|
2014-04-24 07:41:20 +02:00
|
|
|
logging.debug("Clicked non-editable element!")
|
2014-04-24 06:44:58 +02:00
|
|
|
try:
|
|
|
|
modemanager.leave("insert")
|
|
|
|
except ValueError:
|
|
|
|
pass
|
2014-04-23 17:57:59 +02:00
|
|
|
|
2014-04-23 07:32:27 +02:00
|
|
|
if self._force_open_target is not None:
|
|
|
|
self._open_target = self._force_open_target
|
|
|
|
self._force_open_target = None
|
|
|
|
logging.debug("Setting force target: {}".format(
|
|
|
|
self._open_target))
|
|
|
|
elif (e.button() == Qt.MidButton or
|
|
|
|
e.modifiers() & Qt.ControlModifier):
|
|
|
|
if config.get('general', 'background_tabs'):
|
|
|
|
self._open_target = "bgtab"
|
2014-04-21 19:03:04 +02:00
|
|
|
else:
|
2014-04-23 07:32:27 +02:00
|
|
|
self._open_target = "tab"
|
|
|
|
logging.debug("Setting target: {}".format(self._open_target))
|
|
|
|
else:
|
|
|
|
self._open_target = "normal"
|
|
|
|
return super().mousePressEvent(e)
|