class WebView(QWebView): """One browser tab in TabbedBrowser. Our own subclass of a QWebView with some added bells and whistles. Attributes: hintmanager: The HintManager instance for this view. progress: loading progress of this page. scroll_pos: The current scroll position as (x%, y%) tuple. statusbar_message: The current javascript statusbar message. inspector: The QWebInspector used for this webview. load_status: loading status of this page (index into LoadStatus) viewing_source: Whether the webview is currently displaying source code. keep_icon: Whether the (e.g. cloned) icon should not be cleared on page load. registry: The ObjectRegistry associated with this tab. tab_id: The tab ID of the view. win_id: The window ID of the view. search_text: The text of the last search. search_flags: The search flags of the last search. _has_ssl_errors: Whether SSL errors occurred during loading. _zoom: A NeighborList with the zoom levels. _old_scroll_pos: The old scroll position. _check_insertmode: If True, in mouseReleaseEvent we should check if we need to enter/leave insert mode. _default_zoom_changed: Whether the zoom was changed from the default. _ignore_wheel_event: Ignore the next wheel event. See https://github.com/The-Compiler/qutebrowser/issues/395 Signals: scroll_pos_changed: Scroll percentage of current tab changed. arg 1: x-position in %. arg 2: y-position in %. linkHovered: QWebPages linkHovered signal exposed. load_status_changed: The loading status changed url_text_changed: Current URL string changed. shutting_down: Emitted when the view is shutting down. """ scroll_pos_changed = pyqtSignal(int, int) linkHovered = pyqtSignal(str, str, str) load_status_changed = pyqtSignal(str) url_text_changed = pyqtSignal(str) shutting_down = pyqtSignal() def __init__(self, win_id, parent=None): super().__init__(parent) if sys.platform == 'darwin' and qtutils.version_check('5.4'): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-42948 # See https://github.com/The-Compiler/qutebrowser/issues/462 self.setStyle(QStyleFactory.create('Fusion')) self.win_id = win_id self.load_status = LoadStatus.none self._check_insertmode = False self.inspector = None self.scroll_pos = (-1, -1) self.statusbar_message = '' self._old_scroll_pos = (-1, -1) self._zoom = None self._has_ssl_errors = False self._ignore_wheel_event = False self.keep_icon = False self.search_text = None self.search_flags = 0 self.selection_enabled = False self.init_neighborlist() self._set_bg_color() cfg = objreg.get('config') cfg.changed.connect(self.init_neighborlist) # For some reason, this signal doesn't get disconnected automatically # when the WebView is destroyed on older PyQt versions. # See https://github.com/The-Compiler/qutebrowser/issues/390 self.destroyed.connect(functools.partial( cfg.changed.disconnect, self.init_neighborlist)) self.cur_url = QUrl() self._orig_url = QUrl() self.progress = 0 self.registry = objreg.ObjectRegistry() self.tab_id = next(tab_id_gen) tab_registry = objreg.get('tab-registry', scope='window', window=win_id) tab_registry[self.tab_id] = self objreg.register('webview', self, registry=self.registry) page = self._init_page() hintmanager = hints.HintManager(win_id, self.tab_id, self) hintmanager.mouse_event.connect(self.on_mouse_event) hintmanager.start_hinting.connect(page.on_start_hinting) hintmanager.stop_hinting.connect(page.on_stop_hinting) objreg.register('hintmanager', hintmanager, registry=self.registry) mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.entered.connect(self.on_mode_entered) mode_manager.left.connect(self.on_mode_left) self.viewing_source = False self.setZoomFactor(float(config.get('ui', 'default-zoom')) / 100) self._default_zoom_changed = False if config.get('input', 'rocker-gestures'): self.setContextMenuPolicy(Qt.PreventContextMenu) self.urlChanged.connect(self.on_url_changed) self.loadProgress.connect(lambda p: setattr(self, 'progress', p)) objreg.get('config').changed.connect(self.on_config_changed) @pyqtSlot() def on_initial_layout_completed(self): """Add url to history now that we have displayed something.""" history = objreg.get('web-history') if not self._orig_url.matches(self.cur_url, QUrl.UrlFormattingOption(0)): # If the url of the page is different than the url of the link # originally clicked, save them both. url = self._orig_url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) history.add_url(url, self.title(), hidden=True) url = self.cur_url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) history.add_url(url, self.title()) def _init_page(self): """Initialize the QWebPage used by this view.""" page = webpage.BrowserPage(self.win_id, self.tab_id, self) self.setPage(page) page.linkHovered.connect(self.linkHovered) page.mainFrame().loadStarted.connect(self.on_load_started) page.mainFrame().loadFinished.connect(self.on_load_finished) page.mainFrame().initialLayoutCompleted.connect( self.on_initial_layout_completed) page.statusBarMessage.connect( lambda msg: setattr(self, 'statusbar_message', msg)) page.networkAccessManager().sslErrors.connect( lambda *args: setattr(self, '_has_ssl_errors', True)) return page def __repr__(self): url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), 100) return utils.get_repr(self, tab_id=self.tab_id, url=url) def __del__(self): # Explicitly releasing the page here seems to prevent some segfaults # when quitting. # Copied from: # https://code.google.com/p/webscraping/source/browse/webkit.py#325 try: self.setPage(None) except RuntimeError: # It seems sometimes Qt has already deleted the QWebView and we # get: RuntimeError: wrapped C/C++ object of type WebView has been # deleted pass def _set_load_status(self, val): """Setter for load_status.""" if not isinstance(val, LoadStatus): raise TypeError("Type {} is no LoadStatus member!".format(val)) log.webview.debug("load status for {}: {}".format(repr(self), val)) self.load_status = val self.load_status_changed.emit(val.name) if val == LoadStatus.loading: self._orig_url = self.cur_url def _set_bg_color(self): """Set the webpage background color as configured.""" col = config.get('colors', 'webpage.bg') palette = self.palette() if col is None: col = self.style().standardPalette().color(QPalette.Base) palette.setColor(QPalette.Base, col) self.setPalette(palette) @pyqtSlot(str, str) def on_config_changed(self, section, option): """Reinitialize the zoom neighborlist if related config changed.""" if section == 'ui' and option in ('zoom-levels', 'default-zoom'): if not self._default_zoom_changed: self.setZoomFactor(float(config.get('ui', 'default-zoom')) / 100) self._default_zoom_changed = False self.init_neighborlist() elif section == 'input' and option == 'rocker-gestures': if config.get('input', 'rocker-gestures'): self.setContextMenuPolicy(Qt.PreventContextMenu) else: self.setContextMenuPolicy(Qt.DefaultContextMenu) elif section == 'colors' and option == 'webpage.bg': self._set_bg_color() def init_neighborlist(self): """Initialize the _zoom neighborlist.""" levels = config.get('ui', 'zoom-levels') self._zoom = usertypes.NeighborList( levels, mode=usertypes.NeighborList.Modes.edge) self._zoom.fuzzyval = config.get('ui', 'default-zoom') def _mousepress_backforward(self, e): """Handle back/forward mouse button presses. Args: e: The QMouseEvent. """ if e.button() in (Qt.XButton1, Qt.LeftButton): # Back button on mice which have it, or rocker gesture if self.page().history().canGoBack(): self.back() else: message.error(self.win_id, "At beginning of history.", immediately=True) elif e.button() in (Qt.XButton2, Qt.RightButton): # Forward button on mice which have it, or rocker gesture if self.page().history().canGoForward(): self.forward() else: message.error(self.win_id, "At end of history.", immediately=True) def _mousepress_insertmode(self, e): """Switch to insert mode when an editable element was clicked. Args: e: The QMouseEvent. """ pos = e.pos() frame = self.page().frameAt(pos) if frame is None: # This happens when we click inside the webview, but not actually # on the QWebPage - for example when clicking the scrollbar # sometimes. log.mouse.debug("Clicked at {} but frame is None!".format(pos)) return # You'd think we have to subtract frame.geometry().topLeft() from the # position, but it seems QWebFrame::hitTestContent wants a position # relative to the QWebView, not to the frame. This makes no sense to # me, but it works this way. hitresult = frame.hitTestContent(pos) if hitresult.isNull(): # For some reason, the whole hit result can be null sometimes (e.g. # on doodle menu links). If this is the case, we schedule a check # later (in mouseReleaseEvent) which uses webelem.focus_elem. log.mouse.debug("Hitresult is null!") self._check_insertmode = True return try: elem = webelem.WebElementWrapper(hitresult.element()) except webelem.IsNullError: # For some reason, the hit result element can be a null element # sometimes (e.g. when clicking the timetable fields on # http://www.sbb.ch/ ). If this is the case, we schedule a check # later (in mouseReleaseEvent) which uses webelem.focus_elem. log.mouse.debug("Hitresult element is null!") self._check_insertmode = True return if ((hitresult.isContentEditable() and elem.is_writable()) or elem.is_editable()): log.mouse.debug("Clicked editable element!") modeman.enter(self.win_id, usertypes.KeyMode.insert, 'click', only_if_normal=True) else: log.mouse.debug("Clicked non-editable element!") if config.get('input', 'auto-leave-insert-mode'): modeman.maybe_leave(self.win_id, usertypes.KeyMode.insert, 'click') def mouserelease_insertmode(self): """If we have an insertmode check scheduled, handle it.""" if not self._check_insertmode: return self._check_insertmode = False try: elem = webelem.focus_elem(self.page().currentFrame()) except (webelem.IsNullError, RuntimeError): log.mouse.debug("Element/page vanished!") return if elem.is_editable(): log.mouse.debug("Clicked editable element (delayed)!") modeman.enter(self.win_id, usertypes.KeyMode.insert, 'click-delayed', only_if_normal=True) else: log.mouse.debug("Clicked non-editable element (delayed)!") if config.get('input', 'auto-leave-insert-mode'): modeman.maybe_leave(self.win_id, usertypes.KeyMode.insert, 'click-delayed') def _mousepress_opentarget(self, e): """Set the open target when something was clicked. Args: e: The QMouseEvent. """ if e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier: background_tabs = config.get('tabs', 'background-tabs') if e.modifiers() & Qt.ShiftModifier: background_tabs = not background_tabs if background_tabs: target = usertypes.ClickTarget.tab_bg else: target = usertypes.ClickTarget.tab self.page().open_target = target log.mouse.debug("Middle click, setting target: {}".format(target)) else: self.page().open_target = usertypes.ClickTarget.normal log.mouse.debug("Normal click, setting normal target") def shutdown(self): """Shut down the webview.""" self.shutting_down.emit() # We disable javascript because that prevents some segfaults when # quitting it seems. log.destroy.debug("Shutting down {!r}.".format(self)) settings = self.settings() settings.setAttribute(QWebSettings.JavascriptEnabled, False) self.stop() self.page().shutdown() def openurl(self, url): """Open a URL in the browser. Args: url: The URL to load as QUrl """ qtutils.ensure_valid(url) urlstr = url.toDisplayString() log.webview.debug("New title: {}".format(urlstr)) self.titleChanged.emit(urlstr) self.cur_url = url self.url_text_changed.emit(url.toDisplayString()) self.load(url) if url.scheme() == 'qute': frame = self.page().mainFrame() frame.javaScriptWindowObjectCleared.connect(self.add_js_bridge) @pyqtSlot() def add_js_bridge(self): """Add the javascript bridge for qute:... pages.""" frame = self.sender() if not isinstance(frame, QWebFrame): log.webview.error("Got non-QWebFrame {!r} in " "add_js_bridge!".format(frame)) return if frame.url().scheme() == 'qute': bridge = objreg.get('js-bridge') frame.addToJavaScriptWindowObject('qute', bridge) def zoom_perc(self, perc, fuzzyval=True): """Zoom to a given zoom percentage. Args: perc: The zoom percentage as int. fuzzyval: Whether to set the NeighborLists fuzzyval. """ if fuzzyval: self._zoom.fuzzyval = int(perc) if perc < 0: raise ValueError("Can't zoom {}%!".format(perc)) self.setZoomFactor(float(perc) / 100) self._default_zoom_changed = True def zoom(self, offset): """Increase/Decrease the zoom level. Args: offset: The offset in the zoom level list. Return: The new zoom percentage. """ level = self._zoom.getitem(offset) self.zoom_perc(level, fuzzyval=False) return level @pyqtSlot('QUrl') def on_url_changed(self, url): """Update cur_url when URL has changed. If the URL is invalid, we just ignore it here. """ if url.isValid(): self.cur_url = url self.url_text_changed.emit(url.toDisplayString()) if not self.title(): self.titleChanged.emit(self.url().toDisplayString()) @pyqtSlot('QMouseEvent') def on_mouse_event(self, evt): """Post a new mouse event from a hintmanager.""" log.modes.debug("Hint triggered, focusing {!r}".format(self)) self.setFocus() QApplication.postEvent(self, evt) @pyqtSlot() def on_load_started(self): """Leave insert/hint mode and set vars when a new page is loading.""" self.progress = 0 self.viewing_source = False self._has_ssl_errors = False self._set_load_status(LoadStatus.loading) @pyqtSlot() def on_load_finished(self): """Handle a finished page load. We don't take loadFinished's ok argument here as it always seems to be true when the QWebPage has an ErrorPageExtension implemented. See https://github.com/The-Compiler/qutebrowser/issues/84 """ ok = not self.page().error_occurred if ok and not self._has_ssl_errors: if self.cur_url.scheme() == 'https': self._set_load_status(LoadStatus.success_https) else: self._set_load_status(LoadStatus.success) elif ok: self._set_load_status(LoadStatus.warn) else: self._set_load_status(LoadStatus.error) if not self.title(): self.titleChanged.emit(self.url().toDisplayString()) self._handle_auto_insert_mode(ok) def _handle_auto_insert_mode(self, ok): """Handle auto-insert-mode after loading finished.""" if not config.get('input', 'auto-insert-mode'): return mode_manager = objreg.get('mode-manager', scope='window', window=self.win_id) cur_mode = mode_manager.mode if cur_mode == usertypes.KeyMode.insert or not ok: return frame = self.page().currentFrame() try: elem = webelem.WebElementWrapper(frame.findFirstElement(':focus')) except webelem.IsNullError: log.webview.debug("Focused element is null!") return log.modes.debug("focus element: {}".format(repr(elem))) if elem.is_editable(): modeman.enter(self.win_id, usertypes.KeyMode.insert, 'load finished', only_if_normal=True) @pyqtSlot(usertypes.KeyMode) def on_mode_entered(self, mode): """Ignore attempts to focus the widget if in any status-input mode.""" if mode in (usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno): log.webview.debug("Ignoring focus because mode {} was " "entered.".format(mode)) self.setFocusPolicy(Qt.NoFocus) elif mode == usertypes.KeyMode.caret: settings = self.settings() settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) self.selection_enabled = bool(self.page().selectedText()) if self.isVisible(): # Sometimes the caret isn't immediately visible, but unfocusing # and refocusing it fixes that. self.clearFocus() self.setFocus(Qt.OtherFocusReason) # Move the caret to the first element in the viewport if there # isn't any text which is already selected. # # Note: We can't use hasSelection() here, as that's always # true in caret mode. if not self.page().selectedText(): self.page().currentFrame().evaluateJavaScript( utils.read_file('javascript/position_caret.js')) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): """Restore focus policy if status-input modes were left.""" if mode in (usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno): log.webview.debug("Restoring focus policy because mode {} was " "left.".format(mode)) elif mode == usertypes.KeyMode.caret: settings = self.settings() if settings.testAttribute(QWebSettings.CaretBrowsingEnabled): if self.selection_enabled and self.hasSelection(): # Remove selection if it exists self.triggerPageAction(QWebPage.MoveToNextChar) settings.setAttribute(QWebSettings.CaretBrowsingEnabled, False) self.selection_enabled = False self.setFocusPolicy(Qt.WheelFocus) def search(self, text, flags): """Search for text in the current page. Args: text: The text to search for. flags: The QWebPage::FindFlags. """ log.webview.debug("Searching with text '{}' and flags " "0x{:04x}.".format(text, int(flags))) old_scroll_pos = self.scroll_pos flags = QWebPage.FindFlags(flags) found = self.findText(text, flags) backward = flags & QWebPage.FindBackward if not found and not flags & QWebPage.HighlightAllOccurrences and text: # User disabled wrapping; but findText() just returns False. If we # have a selection, we know there's a match *somewhere* on the page if (not flags & QWebPage.FindWrapsAroundDocument and self.hasSelection()): if not backward: message.warning(self.win_id, "Search hit BOTTOM without " "match for: {}".format(text), immediately=True) else: message.warning(self.win_id, "Search hit TOP without " "match for: {}".format(text), immediately=True) else: message.warning(self.win_id, "Text '{}' not found on " "page!".format(text), immediately=True) else: def check_scroll_pos(): """Check if the scroll position got smaller and show info.""" if not backward and self.scroll_pos < old_scroll_pos: message.info(self.win_id, "Search hit BOTTOM, continuing " "at TOP", immediately=True) elif backward and self.scroll_pos > old_scroll_pos: message.info(self.win_id, "Search hit TOP, continuing at " "BOTTOM", immediately=True) # We first want QWebPage to refresh. QTimer.singleShot(0, check_scroll_pos) def createWindow(self, wintype): """Called by Qt when a page wants to create a new window. This function is called from the createWindow() method of the associated QWebPage, each time the page wants to create a new window of the given type. This might be the result, for example, of a JavaScript request to open a document in a new window. Args: wintype: This enum describes the types of window that can be created by the createWindow() function. QWebPage::WebBrowserWindow: The window is a regular web browser window. QWebPage::WebModalDialog: The window acts as modal dialog. Return: The new QWebView object. """ if wintype == QWebPage.WebModalDialog: log.webview.warning("WebModalDialog requested, but we don't " "support that!") tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self.win_id) return tabbed_browser.tabopen(background=False) 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. Return: The superclass event return value. """ frame = self.page().mainFrame() new_pos = (frame.scrollBarValue(Qt.Horizontal), frame.scrollBarValue(Qt.Vertical)) if self._old_scroll_pos != new_pos: self._old_scroll_pos = new_pos 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 = perc self.scroll_pos_changed.emit(*perc) # Let superclass handle the event super().paintEvent(e) def mousePressEvent(self, e): """Extend QWidget::mousePressEvent(). This does the following things: - Check if a link was clicked with the middle button or Ctrl and set the page's open_target attribute accordingly. - Emit the editable_elem_selected signal if an editable element was clicked. Args: e: The arrived event. Return: The superclass return value. """ is_rocker_gesture = (config.get('input', 'rocker-gestures') and e.buttons() == Qt.LeftButton | Qt.RightButton) if e.button() in (Qt.XButton1, Qt.XButton2) or is_rocker_gesture: self._mousepress_backforward(e) super().mousePressEvent(e) return self._mousepress_insertmode(e) self._mousepress_opentarget(e) self._ignore_wheel_event = True super().mousePressEvent(e) def mouseReleaseEvent(self, e): """Extend mouseReleaseEvent to enter insert mode if needed.""" super().mouseReleaseEvent(e) # We want to make sure we check the focus element after the WebView is # updated completely. QTimer.singleShot(0, self.mouserelease_insertmode) def contextMenuEvent(self, e): """Save a reference to the context menu so we can close it.""" menu = self.page().createStandardContextMenu() self.shutting_down.connect(menu.close) modeman.instance(self.win_id).entered.connect(menu.close) menu.exec_(e.globalPos()) def wheelEvent(self, e): """Zoom on Ctrl-Mousewheel. Args: e: The QWheelEvent. """ if self._ignore_wheel_event: self._ignore_wheel_event = False # See https://github.com/The-Compiler/qutebrowser/issues/395 return if e.modifiers() & Qt.ControlModifier: e.accept() divider = config.get('input', 'mouse-zoom-divider') factor = self.zoomFactor() + e.angleDelta().y() / divider if factor < 0: return perc = int(100 * factor) message.info(self.win_id, "Zoom level: {}%".format(perc)) self._zoom.fuzzyval = perc self.setZoomFactor(factor) self._default_zoom_changed = True else: super().wheelEvent(e)
def ignore_certificate_error( *, request_url: QUrl, first_party_url: QUrl, error: usertypes.AbstractCertificateErrorWrapper, abort_on: Iterable[pyqtBoundSignal], ) -> bool: """Display a certificate error question. Args: request_url: The URL of the request where the errors happened. first_party_url: The URL of the page we're visiting. Might be an invalid QUrl. error: A single error. abort_on: Signals aborting a question. Return: True if the error should be ignored, False otherwise. """ conf = config.instance.get('content.tls.certificate_errors', url=request_url) log.network.debug(f"Certificate error {error!r}, config {conf}") assert error.is_overridable(), repr(error) # We get the first party URL with a heuristic - with HTTP -> HTTPS redirects, the # scheme might not match. is_resource = ( first_party_url.isValid() and not request_url.matches( first_party_url, QUrl.RemoveScheme)) # type: ignore[arg-type] if conf == 'ask' or conf == 'ask-block-thirdparty' and not is_resource: err_template = jinja.environment.from_string(""" {% if is_resource %} <p> Error while loading resource <b>{{request_url.toDisplayString()}}</b><br/> on page <b>{{first_party_url.toDisplayString()}}</b>: </p> {% else %} <p>Error while loading page <b>{{request_url.toDisplayString()}}</b>:</p> {% endif %} {{error.html()|safe}} {% if is_resource %} <p><i>Consider reporting this to the website operator, or set <tt>content.tls.certificate_errors</tt> to <tt>ask-block-thirdparty</tt> to always block invalid resource loads.</i></p> {% endif %} Do you want to ignore these errors and continue loading the page <b>insecurely</b>? """.strip()) msg = err_template.render( request_url=request_url, first_party_url=first_party_url, is_resource=is_resource, error=error, ) urlstr = request_url.toString( QUrl.RemovePassword | QUrl.FullyEncoded) # type: ignore[arg-type] ignore = message.ask(title="Certificate error", text=msg, mode=usertypes.PromptMode.yesno, default=False, abort_on=abort_on, url=urlstr) if ignore is None: # prompt aborted ignore = False return ignore elif conf == 'load-insecurely': message.error(f'Certificate error: {error}') return True elif conf == 'block': return False elif conf == 'ask-block-thirdparty' and is_resource: log.network.error( f"Certificate error in resource load: {error}\n" f" request URL: {request_url.toDisplayString()}\n" f" first party URL: {first_party_url.toDisplayString()}") return False raise utils.Unreachable(conf, is_resource)
class WebView(QWebView): """One browser tab in TabbedBrowser. Our own subclass of a QWebView with some added bells and whistles. Attributes: hintmanager: The HintManager instance for this view. progress: loading progress of this page. scroll_pos: The current scroll position as (x%, y%) tuple. statusbar_message: The current javascript statusbar message. inspector: The QWebInspector used for this webview. load_status: loading status of this page (index into LoadStatus) viewing_source: Whether the webview is currently displaying source code. keep_icon: Whether the (e.g. cloned) icon should not be cleared on page load. registry: The ObjectRegistry associated with this tab. tab_id: The tab ID of the view. win_id: The window ID of the view. search_text: The text of the last search. search_flags: The search flags of the last search. _has_ssl_errors: Whether SSL errors occurred during loading. _zoom: A NeighborList with the zoom levels. _old_scroll_pos: The old scroll position. _check_insertmode: If True, in mouseReleaseEvent we should check if we need to enter/leave insert mode. _default_zoom_changed: Whether the zoom was changed from the default. _ignore_wheel_event: Ignore the next wheel event. See https://github.com/The-Compiler/qutebrowser/issues/395 Signals: scroll_pos_changed: Scroll percentage of current tab changed. arg 1: x-position in %. arg 2: y-position in %. linkHovered: QWebPages linkHovered signal exposed. load_status_changed: The loading status changed url_text_changed: Current URL string changed. shutting_down: Emitted when the view is shutting down. """ scroll_pos_changed = pyqtSignal(int, int) linkHovered = pyqtSignal(str, str, str) load_status_changed = pyqtSignal(str) url_text_changed = pyqtSignal(str) shutting_down = pyqtSignal() def __init__(self, win_id, parent=None): super().__init__(parent) if sys.platform == 'darwin' and qtutils.version_check('5.4'): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-42948 # See https://github.com/The-Compiler/qutebrowser/issues/462 self.setStyle(QStyleFactory.create('Fusion')) self.win_id = win_id self.load_status = LoadStatus.none self._check_insertmode = False self.inspector = None self.scroll_pos = (-1, -1) self.statusbar_message = '' self._old_scroll_pos = (-1, -1) self._zoom = None self._has_ssl_errors = False self._ignore_wheel_event = False self.keep_icon = False self.search_text = None self.search_flags = 0 self.selection_enabled = False self.init_neighborlist() self._set_bg_color() cfg = objreg.get('config') cfg.changed.connect(self.init_neighborlist) # For some reason, this signal doesn't get disconnected automatically # when the WebView is destroyed on older PyQt versions. # See https://github.com/The-Compiler/qutebrowser/issues/390 self.destroyed.connect( functools.partial(cfg.changed.disconnect, self.init_neighborlist)) self.cur_url = QUrl() self._orig_url = QUrl() self.progress = 0 self.registry = objreg.ObjectRegistry() self.tab_id = next(tab_id_gen) tab_registry = objreg.get('tab-registry', scope='window', window=win_id) tab_registry[self.tab_id] = self objreg.register('webview', self, registry=self.registry) page = self._init_page() hintmanager = hints.HintManager(win_id, self.tab_id, self) hintmanager.mouse_event.connect(self.on_mouse_event) hintmanager.start_hinting.connect(page.on_start_hinting) hintmanager.stop_hinting.connect(page.on_stop_hinting) objreg.register('hintmanager', hintmanager, registry=self.registry) mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.entered.connect(self.on_mode_entered) mode_manager.left.connect(self.on_mode_left) self.viewing_source = False self.setZoomFactor(float(config.get('ui', 'default-zoom')) / 100) self._default_zoom_changed = False if config.get('input', 'rocker-gestures'): self.setContextMenuPolicy(Qt.PreventContextMenu) self.urlChanged.connect(self.on_url_changed) self.loadProgress.connect(lambda p: setattr(self, 'progress', p)) objreg.get('config').changed.connect(self.on_config_changed) @pyqtSlot() def on_initial_layout_completed(self): """Add url to history now that we have displayed something.""" history = objreg.get('web-history') if not self._orig_url.matches(self.cur_url, QUrl.UrlFormattingOption(0)): # If the url of the page is different than the url of the link # originally clicked, save them both. url = self._orig_url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) history.add_url(url, self.title(), hidden=True) url = self.cur_url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) history.add_url(url, self.title()) def _init_page(self): """Initialize the QWebPage used by this view.""" page = webpage.BrowserPage(self.win_id, self.tab_id, self) self.setPage(page) page.linkHovered.connect(self.linkHovered) page.mainFrame().loadStarted.connect(self.on_load_started) page.mainFrame().loadFinished.connect(self.on_load_finished) page.mainFrame().initialLayoutCompleted.connect( self.on_initial_layout_completed) page.statusBarMessage.connect( lambda msg: setattr(self, 'statusbar_message', msg)) page.networkAccessManager().sslErrors.connect( lambda *args: setattr(self, '_has_ssl_errors', True)) return page def __repr__(self): url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), 100) return utils.get_repr(self, tab_id=self.tab_id, url=url) def __del__(self): # Explicitly releasing the page here seems to prevent some segfaults # when quitting. # Copied from: # https://code.google.com/p/webscraping/source/browse/webkit.py#325 try: self.setPage(None) except RuntimeError: # It seems sometimes Qt has already deleted the QWebView and we # get: RuntimeError: wrapped C/C++ object of type WebView has been # deleted pass def _set_load_status(self, val): """Setter for load_status.""" if not isinstance(val, LoadStatus): raise TypeError("Type {} is no LoadStatus member!".format(val)) log.webview.debug("load status for {}: {}".format(repr(self), val)) self.load_status = val self.load_status_changed.emit(val.name) if val == LoadStatus.loading: self._orig_url = self.cur_url def _set_bg_color(self): """Set the webpage background color as configured.""" col = config.get('colors', 'webpage.bg') palette = self.palette() if col is None: col = self.style().standardPalette().color(QPalette.Base) palette.setColor(QPalette.Base, col) self.setPalette(palette) @pyqtSlot(str, str) def on_config_changed(self, section, option): """Reinitialize the zoom neighborlist if related config changed.""" if section == 'ui' and option in ('zoom-levels', 'default-zoom'): if not self._default_zoom_changed: self.setZoomFactor( float(config.get('ui', 'default-zoom')) / 100) self._default_zoom_changed = False self.init_neighborlist() elif section == 'input' and option == 'rocker-gestures': if config.get('input', 'rocker-gestures'): self.setContextMenuPolicy(Qt.PreventContextMenu) else: self.setContextMenuPolicy(Qt.DefaultContextMenu) elif section == 'colors' and option == 'webpage.bg': self._set_bg_color() def init_neighborlist(self): """Initialize the _zoom neighborlist.""" levels = config.get('ui', 'zoom-levels') self._zoom = usertypes.NeighborList( levels, mode=usertypes.NeighborList.Modes.edge) self._zoom.fuzzyval = config.get('ui', 'default-zoom') def _mousepress_backforward(self, e): """Handle back/forward mouse button presses. Args: e: The QMouseEvent. """ if e.button() in (Qt.XButton1, Qt.LeftButton): # Back button on mice which have it, or rocker gesture if self.page().history().canGoBack(): self.back() else: message.error(self.win_id, "At beginning of history.", immediately=True) elif e.button() in (Qt.XButton2, Qt.RightButton): # Forward button on mice which have it, or rocker gesture if self.page().history().canGoForward(): self.forward() else: message.error(self.win_id, "At end of history.", immediately=True) def _mousepress_insertmode(self, e): """Switch to insert mode when an editable element was clicked. Args: e: The QMouseEvent. """ pos = e.pos() frame = self.page().frameAt(pos) if frame is None: # This happens when we click inside the webview, but not actually # on the QWebPage - for example when clicking the scrollbar # sometimes. log.mouse.debug("Clicked at {} but frame is None!".format(pos)) return # You'd think we have to subtract frame.geometry().topLeft() from the # position, but it seems QWebFrame::hitTestContent wants a position # relative to the QWebView, not to the frame. This makes no sense to # me, but it works this way. hitresult = frame.hitTestContent(pos) if hitresult.isNull(): # For some reason, the whole hit result can be null sometimes (e.g. # on doodle menu links). If this is the case, we schedule a check # later (in mouseReleaseEvent) which uses webelem.focus_elem. log.mouse.debug("Hitresult is null!") self._check_insertmode = True return try: elem = webelem.WebElementWrapper(hitresult.element()) except webelem.IsNullError: # For some reason, the hit result element can be a null element # sometimes (e.g. when clicking the timetable fields on # http://www.sbb.ch/ ). If this is the case, we schedule a check # later (in mouseReleaseEvent) which uses webelem.focus_elem. log.mouse.debug("Hitresult element is null!") self._check_insertmode = True return if ((hitresult.isContentEditable() and elem.is_writable()) or elem.is_editable()): log.mouse.debug("Clicked editable element!") modeman.enter(self.win_id, usertypes.KeyMode.insert, 'click', only_if_normal=True) else: log.mouse.debug("Clicked non-editable element!") if config.get('input', 'auto-leave-insert-mode'): modeman.maybe_leave(self.win_id, usertypes.KeyMode.insert, 'click') def mouserelease_insertmode(self): """If we have an insertmode check scheduled, handle it.""" if not self._check_insertmode: return self._check_insertmode = False try: elem = webelem.focus_elem(self.page().currentFrame()) except (webelem.IsNullError, RuntimeError): log.mouse.debug("Element/page vanished!") return if elem.is_editable(): log.mouse.debug("Clicked editable element (delayed)!") modeman.enter(self.win_id, usertypes.KeyMode.insert, 'click-delayed', only_if_normal=True) else: log.mouse.debug("Clicked non-editable element (delayed)!") if config.get('input', 'auto-leave-insert-mode'): modeman.maybe_leave(self.win_id, usertypes.KeyMode.insert, 'click-delayed') def _mousepress_opentarget(self, e): """Set the open target when something was clicked. Args: e: The QMouseEvent. """ if e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier: background_tabs = config.get('tabs', 'background-tabs') if e.modifiers() & Qt.ShiftModifier: background_tabs = not background_tabs if background_tabs: target = usertypes.ClickTarget.tab_bg else: target = usertypes.ClickTarget.tab self.page().open_target = target log.mouse.debug("Middle click, setting target: {}".format(target)) else: self.page().open_target = usertypes.ClickTarget.normal log.mouse.debug("Normal click, setting normal target") def shutdown(self): """Shut down the webview.""" self.shutting_down.emit() # We disable javascript because that prevents some segfaults when # quitting it seems. log.destroy.debug("Shutting down {!r}.".format(self)) settings = self.settings() settings.setAttribute(QWebSettings.JavascriptEnabled, False) self.stop() self.page().shutdown() def openurl(self, url): """Open a URL in the browser. Args: url: The URL to load as QUrl """ qtutils.ensure_valid(url) urlstr = url.toDisplayString() log.webview.debug("New title: {}".format(urlstr)) self.titleChanged.emit(urlstr) self.cur_url = url self.url_text_changed.emit(url.toDisplayString()) self.load(url) if url.scheme() == 'qute': frame = self.page().mainFrame() frame.javaScriptWindowObjectCleared.connect(self.add_js_bridge) @pyqtSlot() def add_js_bridge(self): """Add the javascript bridge for qute:... pages.""" frame = self.sender() if not isinstance(frame, QWebFrame): log.webview.error("Got non-QWebFrame {!r} in " "add_js_bridge!".format(frame)) return if frame.url().scheme() == 'qute': bridge = objreg.get('js-bridge') frame.addToJavaScriptWindowObject('qute', bridge) def zoom_perc(self, perc, fuzzyval=True): """Zoom to a given zoom percentage. Args: perc: The zoom percentage as int. fuzzyval: Whether to set the NeighborLists fuzzyval. """ if fuzzyval: self._zoom.fuzzyval = int(perc) if perc < 0: raise ValueError("Can't zoom {}%!".format(perc)) self.setZoomFactor(float(perc) / 100) self._default_zoom_changed = True def zoom(self, offset): """Increase/Decrease the zoom level. Args: offset: The offset in the zoom level list. Return: The new zoom percentage. """ level = self._zoom.getitem(offset) self.zoom_perc(level, fuzzyval=False) return level @pyqtSlot('QUrl') def on_url_changed(self, url): """Update cur_url when URL has changed. If the URL is invalid, we just ignore it here. """ if url.isValid(): self.cur_url = url self.url_text_changed.emit(url.toDisplayString()) if not self.title(): self.titleChanged.emit(self.url().toDisplayString()) @pyqtSlot('QMouseEvent') def on_mouse_event(self, evt): """Post a new mouse event from a hintmanager.""" log.modes.debug("Hint triggered, focusing {!r}".format(self)) self.setFocus() QApplication.postEvent(self, evt) @pyqtSlot() def on_load_started(self): """Leave insert/hint mode and set vars when a new page is loading.""" self.progress = 0 self.viewing_source = False self._has_ssl_errors = False self._set_load_status(LoadStatus.loading) @pyqtSlot() def on_load_finished(self): """Handle a finished page load. We don't take loadFinished's ok argument here as it always seems to be true when the QWebPage has an ErrorPageExtension implemented. See https://github.com/The-Compiler/qutebrowser/issues/84 """ ok = not self.page().error_occurred if ok and not self._has_ssl_errors: if self.cur_url.scheme() == 'https': self._set_load_status(LoadStatus.success_https) else: self._set_load_status(LoadStatus.success) elif ok: self._set_load_status(LoadStatus.warn) else: self._set_load_status(LoadStatus.error) if not self.title(): self.titleChanged.emit(self.url().toDisplayString()) self._handle_auto_insert_mode(ok) def _handle_auto_insert_mode(self, ok): """Handle auto-insert-mode after loading finished.""" if not config.get('input', 'auto-insert-mode'): return mode_manager = objreg.get('mode-manager', scope='window', window=self.win_id) cur_mode = mode_manager.mode if cur_mode == usertypes.KeyMode.insert or not ok: return frame = self.page().currentFrame() try: elem = webelem.WebElementWrapper(frame.findFirstElement(':focus')) except webelem.IsNullError: log.webview.debug("Focused element is null!") return log.modes.debug("focus element: {}".format(repr(elem))) if elem.is_editable(): modeman.enter(self.win_id, usertypes.KeyMode.insert, 'load finished', only_if_normal=True) @pyqtSlot(usertypes.KeyMode) def on_mode_entered(self, mode): """Ignore attempts to focus the widget if in any status-input mode.""" if mode in (usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno): log.webview.debug("Ignoring focus because mode {} was " "entered.".format(mode)) self.setFocusPolicy(Qt.NoFocus) elif mode == usertypes.KeyMode.caret: settings = self.settings() settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) self.selection_enabled = bool(self.page().selectedText()) if self.isVisible(): # Sometimes the caret isn't immediately visible, but unfocusing # and refocusing it fixes that. self.clearFocus() self.setFocus(Qt.OtherFocusReason) # Move the caret to the first element in the viewport if there # isn't any text which is already selected. # # Note: We can't use hasSelection() here, as that's always # true in caret mode. if not self.page().selectedText(): self.page().currentFrame().evaluateJavaScript( utils.read_file('javascript/position_caret.js')) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): """Restore focus policy if status-input modes were left.""" if mode in (usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno): log.webview.debug("Restoring focus policy because mode {} was " "left.".format(mode)) elif mode == usertypes.KeyMode.caret: settings = self.settings() if settings.testAttribute(QWebSettings.CaretBrowsingEnabled): if self.selection_enabled and self.hasSelection(): # Remove selection if it exists self.triggerPageAction(QWebPage.MoveToNextChar) settings.setAttribute(QWebSettings.CaretBrowsingEnabled, False) self.selection_enabled = False self.setFocusPolicy(Qt.WheelFocus) def search(self, text, flags): """Search for text in the current page. Args: text: The text to search for. flags: The QWebPage::FindFlags. """ log.webview.debug("Searching with text '{}' and flags " "0x{:04x}.".format(text, int(flags))) old_scroll_pos = self.scroll_pos flags = QWebPage.FindFlags(flags) found = self.findText(text, flags) backward = flags & QWebPage.FindBackward if not found and not flags & QWebPage.HighlightAllOccurrences and text: # User disabled wrapping; but findText() just returns False. If we # have a selection, we know there's a match *somewhere* on the page if (not flags & QWebPage.FindWrapsAroundDocument and self.hasSelection()): if not backward: message.warning(self.win_id, "Search hit BOTTOM without " "match for: {}".format(text), immediately=True) else: message.warning(self.win_id, "Search hit TOP without " "match for: {}".format(text), immediately=True) else: message.warning(self.win_id, "Text '{}' not found on " "page!".format(text), immediately=True) else: def check_scroll_pos(): """Check if the scroll position got smaller and show info.""" if not backward and self.scroll_pos < old_scroll_pos: message.info(self.win_id, "Search hit BOTTOM, continuing " "at TOP", immediately=True) elif backward and self.scroll_pos > old_scroll_pos: message.info(self.win_id, "Search hit TOP, continuing at " "BOTTOM", immediately=True) # We first want QWebPage to refresh. QTimer.singleShot(0, check_scroll_pos) def createWindow(self, wintype): """Called by Qt when a page wants to create a new window. This function is called from the createWindow() method of the associated QWebPage, each time the page wants to create a new window of the given type. This might be the result, for example, of a JavaScript request to open a document in a new window. Args: wintype: This enum describes the types of window that can be created by the createWindow() function. QWebPage::WebBrowserWindow: The window is a regular web browser window. QWebPage::WebModalDialog: The window acts as modal dialog. Return: The new QWebView object. """ if wintype == QWebPage.WebModalDialog: log.webview.warning("WebModalDialog requested, but we don't " "support that!") tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self.win_id) return tabbed_browser.tabopen(background=False) 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. Return: The superclass event return value. """ frame = self.page().mainFrame() new_pos = (frame.scrollBarValue(Qt.Horizontal), frame.scrollBarValue(Qt.Vertical)) if self._old_scroll_pos != new_pos: self._old_scroll_pos = new_pos 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 = perc self.scroll_pos_changed.emit(*perc) # Let superclass handle the event super().paintEvent(e) def mousePressEvent(self, e): """Extend QWidget::mousePressEvent(). This does the following things: - Check if a link was clicked with the middle button or Ctrl and set the page's open_target attribute accordingly. - Emit the editable_elem_selected signal if an editable element was clicked. Args: e: The arrived event. Return: The superclass return value. """ is_rocker_gesture = (config.get('input', 'rocker-gestures') and e.buttons() == Qt.LeftButton | Qt.RightButton) if e.button() in (Qt.XButton1, Qt.XButton2) or is_rocker_gesture: self._mousepress_backforward(e) super().mousePressEvent(e) return self._mousepress_insertmode(e) self._mousepress_opentarget(e) self._ignore_wheel_event = True super().mousePressEvent(e) def mouseReleaseEvent(self, e): """Extend mouseReleaseEvent to enter insert mode if needed.""" super().mouseReleaseEvent(e) # We want to make sure we check the focus element after the WebView is # updated completely. QTimer.singleShot(0, self.mouserelease_insertmode) def contextMenuEvent(self, e): """Save a reference to the context menu so we can close it.""" menu = self.page().createStandardContextMenu() self.shutting_down.connect(menu.close) modeman.instance(self.win_id).entered.connect(menu.close) menu.exec_(e.globalPos()) def wheelEvent(self, e): """Zoom on Ctrl-Mousewheel. Args: e: The QWheelEvent. """ if self._ignore_wheel_event: self._ignore_wheel_event = False # See https://github.com/The-Compiler/qutebrowser/issues/395 return if e.modifiers() & Qt.ControlModifier: e.accept() divider = config.get('input', 'mouse-zoom-divider') factor = self.zoomFactor() + e.angleDelta().y() / divider if factor < 0: return perc = int(100 * factor) message.info(self.win_id, "Zoom level: {}%".format(perc)) self._zoom.fuzzyval = perc self.setZoomFactor(factor) self._default_zoom_changed = True else: super().wheelEvent(e)