# Copyright 2014 Florian Bruhin (The Compiler) # # 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 . """Widgets needed in the qutebrowser statusbar.""" import logging from PyQt5.QtCore import pyqtSignal, pyqtSlot, pyqtProperty, Qt from PyQt5.QtWidgets import (QWidget, QLineEdit, QProgressBar, QLabel, QHBoxLayout, QStackedLayout, QSizePolicy, QShortcut) from PyQt5.QtGui import QPainter, QKeySequence, QValidator import qutebrowser.config.config as config from qutebrowser.config.style import set_register_stylesheet, get_stylesheet import qutebrowser.commands.keys as keys from qutebrowser.utils.url import urlstring from qutebrowser.utils.usertypes import NeighborList from qutebrowser.commands.parsers import split_cmdline class StatusBar(QWidget): """The statusbar at the bottom of the mainwindow. Attributes: cmd: The Command widget in the statusbar. txt: The Text widget in the statusbar. keystring: The KeyString widget in the statusbar. percentage: The Percentage widget in the statusbar. url: The Url widget in the statusbar. prog: The Progress widget in the statusbar. _hbox: The main QHBoxLayout. _stack: The QStackedLayout with cmd/txt widgets. _error: If there currently is an error, accessed through the error property. STYLESHEET: The stylesheet template. Signals: resized: Emitted when the statusbar has resized, so the completion widget can adjust its size to it. arg: The new size. moved: Emitted when the statusbar has moved, so the completion widget can move the the right position. arg: The new position. """ resized = pyqtSignal('QRect') moved = pyqtSignal('QPoint') STYLESHEET = """ QWidget#StatusBar[error="false"] {{ {color[statusbar.bg]} }} QWidget#StatusBar[error="true"] {{ {color[statusbar.bg.error]} }} QWidget {{ {color[statusbar.fg]} {font[statusbar]} }} """ def __init__(self, parent=None): super().__init__(parent) self.setObjectName(self.__class__.__name__) self.setAttribute(Qt.WA_StyledBackground) set_register_stylesheet(self) self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) self._error = False self._option = None self._hbox = QHBoxLayout(self) self._hbox.setContentsMargins(0, 0, 0, 0) self._hbox.setSpacing(5) self._stack = QStackedLayout() self._stack.setContentsMargins(0, 0, 0, 0) self.cmd = _Command(self) self._stack.addWidget(self.cmd) self.txt = _Text(self) self._stack.addWidget(self.txt) self.cmd.show_cmd.connect(self._show_cmd_widget) self.cmd.hide_cmd.connect(self._hide_cmd_widget) self._hide_cmd_widget() self._hbox.addLayout(self._stack) #self._hbox.addStretch() self.keystring = _KeyString(self) self._hbox.addWidget(self.keystring) self.url = _Url(self) self._hbox.addWidget(self.url) self.percentage = _Percentage(self) self._hbox.addWidget(self.percentage) self.prog = _Progress(self) self._hbox.addWidget(self.prog) @pyqtProperty(bool) def error(self): """Getter for self.error, so it can be used as Qt property.""" # pylint: disable=method-hidden return self._error @error.setter def error(self, val): """Setter for self.error, so it can be used as Qt property. Re-set the stylesheet after setting the value, so everything gets updated by Qt properly. """ self._error = val self.setStyleSheet(get_stylesheet(self.STYLESHEET)) def _show_cmd_widget(self): """Show command widget instead of temporary text.""" self._stack.setCurrentWidget(self.cmd) self.clear_error() def _hide_cmd_widget(self): """Show temporary text instead of command widget.""" self._stack.setCurrentWidget(self.txt) @pyqtSlot(str) def disp_error(self, text): """Display an error in the statusbar.""" self.error = True self.txt.errortext = text @pyqtSlot() def clear_error(self): """Clear a displayed error from the status bar.""" self.error = False self.txt.errortext = '' @pyqtSlot(str) def disp_tmp_text(self, text): """Display a temporary text. Args: text: The text to display, or an empty string to clear. """ self.txt.temptext = text @pyqtSlot() def clear_tmp_text(self): """Clear a temporary text.""" self.disp_tmp_text('') @pyqtSlot('QKeyEvent') def keypress(self, e): """Hide temporary error message if a key was pressed. Args: e: The original QKeyEvent. """ if e.key() in [Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta]: # Only modifier pressed, don't hide yet. return self.clear_tmp_text() def resizeEvent(self, e): """Extend resizeEvent of QWidget to emit a resized signal afterwards. Args: e: The QResizeEvent. Emit: resized: Always emitted. """ super().resizeEvent(e) self.resized.emit(self.geometry()) def moveEvent(self, e): """Extend moveEvent of QWidget to emit a moved signal afterwards. Args: e: The QMoveEvent. Emit: moved: Always emitted. """ super().moveEvent(e) self.moved.emit(e.pos()) class _Command(QLineEdit): """The commandline part of the statusbar. Attributes: history: The command history, with newer commands at the bottom. _statusbar: The statusbar (parent) QWidget. _shortcuts: Defined QShortcuts to prevent GCing. _tmphist: The temporary history for history browsing as NeighborList. _validator: The current command validator. Signals: got_cmd: Emitted when a command is triggered by the user. arg: The command string. got_search: Emitted when the user started a new search. arg: The search term. got_rev_search: Emitted when the user started a new reverse search. arg: The search term. esc_pressed: Emitted when the escape key was pressed. tab_pressed: Emitted when the tab key was pressed. arg: Whether shift has been pressed. clear_completion_selection: Emitted before the completion widget is hidden. hide_completion: Emitted when the completion widget should be hidden. show_cmd: Emitted when command input should be shown. hide_cmd: Emitted when command input can be hidden. """ # FIXME we should probably use a proper model for the command history. got_cmd = pyqtSignal(str) got_search = pyqtSignal(str) got_search_rev = pyqtSignal(str) esc_pressed = pyqtSignal() tab_pressed = pyqtSignal(bool) clear_completion_selection = pyqtSignal() hide_completion = pyqtSignal() show_cmd = pyqtSignal() hide_cmd = pyqtSignal() # FIXME won't the tab key switch to the next widget? # See [0] for a possible fix. # [0] http://www.saltycrane.com/blog/2008/01/how-to-capture-tab-key-press-event-with/ # pylint: disable=line-too-long def __init__(self, statusbar): super().__init__(statusbar) # FIXME self._statusbar = statusbar self._tmphist = None self.setStyleSheet(""" QLineEdit { border: 0px; padding-left: 1px; background-color: transparent; } """) self._validator = _CommandValidator(self) self.setValidator(self._validator) self.returnPressed.connect(self._on_return_pressed) self.textEdited.connect(self._histbrowse_stop) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Ignored) if config.cmd_history.data is None: self.history = [] else: self.history = config.cmd_history.data self._shortcuts = [] for (key, handler) in [ (Qt.Key_Escape, self.esc_pressed), (Qt.Key_Up, self._on_key_up_pressed), (Qt.Key_Down, self._on_key_down_pressed), (Qt.Key_Tab | Qt.SHIFT, lambda: self.tab_pressed.emit(True)), (Qt.Key_Tab, lambda: self.tab_pressed.emit(False)) ]: sc = QShortcut(self) sc.setKey(QKeySequence(key)) sc.setContext(Qt.WidgetWithChildrenShortcut) sc.activated.connect(handler) self._shortcuts.append(sc) def _histbrowse_start(self): """Start browsing to the history. Called when the user presses the up/down key and wasn't browsing the history already. """ pre = self.text().strip() logging.debug('Preset text: "{}"'.format(pre)) if pre: items = [e for e in self.history if e.startswith(pre)] else: items = self.history if not items: raise ValueError("No history found!") self._tmphist = NeighborList(items) return self._tmphist.lastitem() @pyqtSlot() def _histbrowse_stop(self): """Stop browsing the history.""" self._tmphist = None @pyqtSlot() def _on_key_up_pressed(self): """Handle Up presses (go back in history).""" if self._tmphist is None: try: item = self._histbrowse_start() except ValueError: # no history return else: try: item = self._tmphist.previtem() except IndexError: # at beginning of history return if item: self.set_cmd_text(item) @pyqtSlot() def _on_key_down_pressed(self): """Handle Down presses (go forward in history).""" if not self._tmphist: return try: item = self._tmphist.nextitem() except IndexError: logging.debug("At end of history") return if item: self.set_cmd_text(item) @pyqtSlot() def _on_return_pressed(self): """Handle the command in the status bar. Emit: got_cmd: If a new cmd was entered. got_search: If a new search was entered. got_search_rev: If a new reverse search was entered. """ signals = { ':': self.got_cmd, '/': self.got_search, '?': self.got_search_rev, } self._histbrowse_stop() text = self.text() if not self.history or text != self.history[-1]: self.history.append(text) self.setText('') if text[0] in signals: signals[text[0]].emit(text.lstrip(text[0])) @pyqtSlot(str) def set_cmd_text(self, text): """Preset the statusbar to some text. Args: text: The text to set (string). """ self.setText(text) self.setFocus() self.show_cmd.emit() @pyqtSlot(str) def on_change_completed_part(self, newtext): """Change the part we're currently completing in the commandline. Args: text: The text to set (string). """ # FIXME we should consider the cursor position. text = self.text() if text[0] in ':/?': prefix = text[0] text = text[1:] else: prefix = '' parts = split_cmdline(text) parts[-1] = newtext self.setText(prefix + ' '.join(parts)) self.setFocus() self.show_cmd.emit() def focusOutEvent(self, e): """Clear the statusbar text if it's explicitely unfocused. Args: e: The QFocusEvent. Emit: clear_completion_selection: Always emitted. hide_completion: Always emitted so the completion is hidden. """ if e.reason() in [Qt.MouseFocusReason, Qt.TabFocusReason, Qt.BacktabFocusReason, Qt.OtherFocusReason]: self.setText('') self._histbrowse_stop() self.hide_cmd.emit() self.clear_completion_selection.emit() self.hide_completion.emit() super().focusOutEvent(e) class _CommandValidator(QValidator): """Validator to prevent the : from getting deleted.""" def validate(self, string, pos): """Override QValidator::validate. Args: string: The string to validate. pos: The current curser position. Return: A tuple (status, string, pos) as a QValidator should. """ if any(string.startswith(c) for c in keys.STARTCHARS): return (QValidator.Acceptable, string, pos) else: return (QValidator.Invalid, string, pos) class _Progress(QProgressBar): """The progress bar part of the status bar. Attributes: STYLESHEET: The stylesheet template. """ # FIXME for some reason, margin-left is not shown STYLESHEET = """ QProgressBar {{ border-radius: 0px; border: 2px solid transparent; margin-left: 1px; background-color: transparent; }} QProgressBar::chunk {{ {color[statusbar.progress.bg]} }} """ def __init__(self, parent): super().__init__(parent) set_register_stylesheet(self) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Ignored) self.setTextVisible(False) self.hide() @pyqtSlot() def on_load_started(self): """Clear old error and show progress, used as slot to loadStarted.""" self.setValue(0) self.show() class TextBase(QLabel): """A text in the statusbar. Unlike QLabel, the text will get elided. Eliding is loosly based on http://gedgedev.blogspot.ch/2010/12/elided-labels-in-qt.html Attributes: _elidemode: Where to elide the text. _elided_text: The current elided text. """ def __init__(self, bar, elidemode=Qt.ElideRight): super().__init__(bar) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) self._elidemode = elidemode self._elided_text = '' def _update_elided_text(self, width): """Update the elided text when necessary. Args: width: The maximal width the text should take. """ self._elided_text = self.fontMetrics().elidedText( self.text(), self._elidemode, width, Qt.TextShowMnemonic) def setText(self, txt): """Extend QLabel::setText. This update the elided text after setting the text, and also works around a weird QLabel redrawing bug where it doesn't redraw correctly when the text is empty -- we explicitely need to call repaint() to resolve this. See http://stackoverflow.com/q/21890462/2085149 FIXME is there a nicer way to work around this? Args: txt: The text to set (string). """ super().setText(txt) self._update_elided_text(self.geometry().width()) if not txt: self.repaint() def resizeEvent(self, e): """Extend QLabel::resizeEvent to update the elided text afterwards.""" super().resizeEvent(e) self._update_elided_text(e.size().width()) def paintEvent(self, e): """Override QLabel::paintEvent to draw elided text.""" if self._elidemode == Qt.ElideNone: super().paintEvent(e) else: painter = QPainter(self) painter.drawText(0, 0, self.geometry().width(), self.geometry().height(), self.alignment(), self._elided_text) class _Text(TextBase): """Text displayed in the statusbar. Attributes: normaltext: The "permanent" text. Never automatically cleared. temptext: The temporary text. Cleared on a keystroke. errortext: The error text. Cleared on a keystroke. _initializing: True if we're currently in __init__ and no text should be updated yet. The errortext has the highest priority, i.e. it will always be shown when it is set. The temptext is shown when there is no error, and the (permanent) text is shown when there is neither a temporary text nor an error. """ def __init__(self, parent=None): super().__init__(parent) self._initializing = True self.normaltext = '' self.temptext = '' self.errortext = '' self._initializing = False def __setattr__(self, name, val): """Overwrite __setattr__ to call _update_text when needed.""" super().__setattr__(name, val) if not name.startswith('_') and not self._initializing: self._update_text() def _update_text(self): """Update QLabel text when needed. Called from __setattr__ if a text property changed. """ for text in [self.errortext, self.temptext, self.normaltext]: if text: self.setText(text) break else: self.setText('') @pyqtSlot(str) def set_normaltext(self, val): """Setter for normaltext, to be used as Qt slot.""" self.normaltext = val @pyqtSlot(str) def set_temptext(self, val): """Setter for temptext, to be used as Qt slot.""" self.temptext = val class _KeyString(TextBase): """Keychain string displayed in the statusbar.""" pass class _Percentage(TextBase): """Reading percentage displayed in the statusbar.""" def __init__(self, parent=None): """Constructor. Set percentage to 0%.""" super().__init__(parent) self.set_perc(0, 0) @pyqtSlot(int, int) def set_perc(self, _, y): """Setter to be used as a Qt slot. Args: _: The x percentage (int), currently ignored. y: The y percentage (int) """ if y == 0: self.setText('[top]') elif y == 100: self.setText('[bot]') else: self.setText('[{:2}%]'.format(y)) class _Url(TextBase): """URL displayed in the statusbar. Attributes: _old_url: The URL displayed before the hover URL. _old_urltype: The type of the URL displayed before the hover URL. _urltype: The current URL type. One of normal/ok/error/warn/hover. Accessed via the urltype property. STYLESHEET: The stylesheet template. """ STYLESHEET = """ QLabel#Url[urltype="normal"] {{ {color[statusbar.url.fg]} }} QLabel#Url[urltype="success"] {{ {color[statusbar.url.fg.success]} }} QLabel#Url[urltype="error"] {{ {color[statusbar.url.fg.error]} }} QLabel#Url[urltype="warn"] {{ {color[statusbar.url.fg.warn]} }} QLabel#Url[urltype="hover"] {{ {color[statusbar.url.fg.hover]} }} """ def __init__(self, bar, elidemode=Qt.ElideMiddle): """Override TextBase::__init__ to elide in the middle by default. Args: bar: The statusbar (parent) object. elidemode: How to elide the text. """ super().__init__(bar, elidemode) self.setObjectName(self.__class__.__name__) set_register_stylesheet(self) self._urltype = None self._old_urltype = None self._old_url = None @pyqtProperty(str) def urltype(self): """Getter for self.urltype, so it can be used as Qt property.""" # pylint: disable=method-hidden return self._urltype @urltype.setter def urltype(self, val): """Setter for self.urltype, so it can be used as Qt property.""" self._urltype = val self.setStyleSheet(get_stylesheet(self.STYLESHEET)) @pyqtSlot(bool) def on_loading_finished(self, ok): """Slot for cur_loading_finished. Colors the URL according to ok. Args: ok: Whether loading finished successfully (True) or not (False). """ # FIXME: set color to warn if there was an SSL error self.urltype = 'success' if ok else 'error' @pyqtSlot(str) def set_url(self, s): """Setter to be used as a Qt slot. Args: s: The URL to set. """ self.setText(urlstring(s)) self.urltype = 'normal' # pylint: disable=unused-argument @pyqtSlot(str, str, str) def set_hover_url(self, link, title, text): """Setter to be used as a Qt slot. Saves old shown URL in self._old_url and restores it later if a link is "un-hovered" when it gets called with empty parameters. Args: link: The link which was hovered (string) title: The title of the hovered link (string) text: The text of the hovered link (string) """ if link: if self._old_url is None: self._old_url = self.text() if self._old_urltype is None: self._old_urltype = self._urltype self.urltype = 'hover' self.setText(link) else: self.setText(self._old_url) self.urltype = self._old_urltype self._old_url = None self._old_urltype = None