diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 093b6f2c6..8c5471ab1 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -977,6 +977,7 @@ How many steps to zoom out. |<>|Move the cursor or selection to the start of previous block. |<>|Open an external editor with the currently selected form field. |<>|Accept the current prompt. +|<>|Shift the focus of the prompt file completion menu to another item. |<>|Immediately open a download. |<>|Repeat the last executed command. |<>|Move back a character. @@ -1229,6 +1230,15 @@ Accept the current prompt. * +'value'+: If given, uses this value instead of the entered one. For boolean prompts, "yes"/"no" are accepted as value. +[[prompt-item-focus]] +=== prompt-item-focus +Syntax: +:prompt-item-focus 'which'+ + +Shift the focus of the prompt file completion menu to another item. + +==== positional arguments +* +'which'+: 'next', 'prev' + [[prompt-open-download]] === prompt-open-download Syntax: +:prompt-open-download ['cmdline']+ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 8a25e2b31..f2dc26588 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -54,6 +54,7 @@ |<>|Use standard JavaScript modal dialog for alert() and confirm() |<>|Hide the window decoration when using wayland (requires restart) |<>|Keychains that shouldn't be shown in the keyhint dialog +|<>|The rounding radius for the edges of prompts. |============== .Quick reference for section ``network'' @@ -213,8 +214,6 @@ |<>|Color of the scrollbar in completion view |<>|Foreground color of the statusbar. |<>|Background color of the statusbar. -|<>|Foreground color of the statusbar if there is a prompt. -|<>|Background color of the statusbar if there is a prompt. |<>|Foreground color of the statusbar in insert mode. |<>|Background color of the statusbar in insert mode. |<>|Foreground color of the statusbar in command mode. @@ -268,6 +267,9 @@ |<>|Foreground color an info message. |<>|Background color of an info message. |<>|Border color of an info message. +|<>|Foreground color for prompts. +|<>|Background color for prompts. +|<>|Background color for the selected item in filename prompts. |============== .Quick reference for section ``fonts'' @@ -296,6 +298,7 @@ |<>|Font used for error messages. |<>|Font used for warning messages. |<>|Font used for info messages. +|<>|Font used for prompts. |============== == general @@ -706,6 +709,12 @@ Globs are supported, so ';*' will blacklist all keychainsstarting with ';'. Use Default: empty +[[ui-prompt-radius]] +=== prompt-radius +The rounding radius for the edges of prompts. + +Default: +pass:[8]+ + == network Settings related to the network. @@ -1831,18 +1840,6 @@ Background color of the statusbar. Default: +pass:[black]+ -[[colors-statusbar.fg.prompt]] -=== statusbar.fg.prompt -Foreground color of the statusbar if there is a prompt. - -Default: +pass:[${statusbar.fg}]+ - -[[colors-statusbar.bg.prompt]] -=== statusbar.bg.prompt -Background color of the statusbar if there is a prompt. - -Default: +pass:[darkblue]+ - [[colors-statusbar.fg.insert]] === statusbar.fg.insert Foreground color of the statusbar in insert mode. @@ -2184,6 +2181,24 @@ Border color of an info message. Default: +pass:[#333333]+ +[[colors-prompts.fg]] +=== prompts.fg +Foreground color for prompts. + +Default: +pass:[white]+ + +[[colors-prompts.bg]] +=== prompts.bg +Background color for prompts. + +Default: +pass:[darkblue]+ + +[[colors-prompts.selected.bg]] +=== prompts.selected.bg +Background color for the selected item in filename prompts. + +Default: +pass:[#308cc6]+ + == fonts Fonts used for the UI, with optional style/weight/size. @@ -2322,3 +2337,9 @@ Default: +pass:[8pt ${_monospace}]+ Font used for info messages. Default: +pass:[8pt ${_monospace}]+ + +[[fonts-prompts]] +=== prompts +Font used for prompts. + +Default: +pass:[8pt sans-serif]+ diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 70588cfed..73485a634 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -48,7 +48,7 @@ from qutebrowser.config import style, config, websettings, configexc from qutebrowser.browser import urlmarks, adblock, history, browsertab from qutebrowser.browser.webkit import cookies, cache, downloads from qutebrowser.browser.webkit.network import networkmanager -from qutebrowser.mainwindow import mainwindow +from qutebrowser.mainwindow import mainwindow, prompt from qutebrowser.misc import (readline, ipc, savemanager, sessions, crashsignal, earlyinit) from qutebrowser.misc import utilcmds # pylint: disable=unused-import @@ -372,6 +372,9 @@ def _init_modules(args, crash_handler): crash_handler: The CrashHandler instance. """ # pylint: disable=too-many-statements + log.init.debug("Initializing prompts...") + prompt.init() + log.init.debug("Initializing save manager...") save_manager = savemanager.SaveManager(qApp) objreg.register('save-manager', save_manager) @@ -643,13 +646,7 @@ class Quitter: session_manager.save(sessions.default, last_window=last_window, load_next_time=True) - deferrer = False - for win_id in objreg.window_registry: - prompter = objreg.get('prompter', None, scope='window', - window=win_id) - if prompter is not None and prompter.shutdown(): - deferrer = True - if deferrer: + if prompt.prompt_queue.shutdown(): # If shutdown was called while we were asking a question, we're in # a still sub-eventloop (which gets quit now) and not in the main # one. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index f1849b0bf..2c0e01b5f 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1137,7 +1137,7 @@ class CommandDispatcher: def quickmark_save(self): """Save the current page as a quickmark.""" quickmark_manager = objreg.get('quickmark-manager') - quickmark_manager.prompt_save(self._win_id, self._current_url()) + quickmark_manager.prompt_save(self._current_url()) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @@ -1355,9 +1355,8 @@ class CommandDispatcher: if dest is None: suggested_fn = self._current_title() + ".mht" suggested_fn = utils.sanitize_filename(suggested_fn) - filename, q = downloads.ask_for_filename( - suggested_fn, self._win_id, parent=tab, - ) + filename, q = downloads.ask_for_filename(suggested_fn, parent=tab, + url=tab.url()) if filename is not None: mhtml.start_download_checked(filename, tab=tab) else: diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index e58684289..4d9407469 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -265,9 +265,8 @@ class HintActions: if text[0] not in modeparsers.STARTCHARS: raise HintingError("Invalid command text '{}'.".format(text)) - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - bridge.set_cmd_text(text) + cmd = objreg.get('status-command', scope='window', window=self._win_id) + cmd.set_cmd_text(text) def download(self, elem, context): """Download a hint URL. diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index 90b2defc0..fc727a284 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -26,6 +26,7 @@ to a file on shutdown, so it makes sense to keep them as strings here. """ import os +import html import os.path import functools import collections @@ -33,7 +34,7 @@ import collections from PyQt5.QtCore import pyqtSignal, QUrl, QObject from qutebrowser.utils import (message, usertypes, qtutils, urlutils, - standarddir, objreg) + standarddir, objreg, log) from qutebrowser.commands import cmdutils from qutebrowser.misc import lineparser @@ -159,11 +160,10 @@ class QuickmarkManager(UrlMarkManager): else: self.marks[key] = url - def prompt_save(self, win_id, url): + def prompt_save(self, url): """Prompt for a new quickmark name to be added and add it. Args: - win_id: The current window ID. url: The quickmark url as a QUrl. """ if not url.isValid(): @@ -171,19 +171,19 @@ class QuickmarkManager(UrlMarkManager): return urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded) message.ask_async( - win_id, "Add quickmark:", usertypes.PromptMode.text, - functools.partial(self.quickmark_add, win_id, urlstr)) + "Add quickmark:", usertypes.PromptMode.text, + functools.partial(self.quickmark_add, urlstr), + text="Please enter a quickmark name for
{}".format( + html.escape(url.toDisplayString()))) @cmdutils.register(instance='quickmark-manager') - @cmdutils.argument('win_id', win_id=True) - def quickmark_add(self, win_id, url, name): + def quickmark_add(self, url, name): """Add a new quickmark. You can view all saved quickmarks on the link:qute://bookmarks[bookmarks page]. Args: - win_id: The window ID to display the errors in. url: The url to add as quickmark. name: The name for the new quickmark. """ @@ -201,10 +201,12 @@ class QuickmarkManager(UrlMarkManager): self.marks[name] = url self.changed.emit() self.added.emit(name, url) + log.misc.debug("Added quickmark {} for {}".format(name, url)) if name in self.marks: message.confirm_async( - win_id, "Override existing quickmark?", set_mark, default=True) + title="Override existing quickmark?", + yes_action=set_mark, default=True) else: set_mark() diff --git a/qutebrowser/browser/webkit/downloads.py b/qutebrowser/browser/webkit/downloads.py index 164440687..d25c363ea 100644 --- a/qutebrowser/browser/webkit/downloads.py +++ b/qutebrowser/browser/webkit/downloads.py @@ -28,6 +28,7 @@ import shutil import functools import tempfile import collections +import html import sip from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QObject, QTimer, @@ -119,7 +120,7 @@ def create_full_filename(basename, filename): return None -def ask_for_filename(suggested_filename, win_id, *, parent=None, +def ask_for_filename(suggested_filename, *, url, parent=None, prompt_download_directory=None): """Prepare a question for a download-path. @@ -133,7 +134,7 @@ def ask_for_filename(suggested_filename, win_id, *, parent=None, Args: suggested_filename: The "default"-name that is pre-entered as path. - win_id: The window where the question will be asked. + url: The URL the download originated from. parent: The parent of the question (a QObject). prompt_download_directory: If this is something else than None, it will overwrite the @@ -150,14 +151,14 @@ def ask_for_filename(suggested_filename, win_id, *, parent=None, suggested_filename = utils.force_encoding(suggested_filename, encoding) q = usertypes.Question(parent) - q.text = "Save file to:" + q.title = "Save file to:" + q.text = "Please enter a location for {}".format( + html.escape(url.toDisplayString())) q.mode = usertypes.PromptMode.text q.completed.connect(q.deleteLater) q.default = _path_suggestion(suggested_filename) - message_bridge = objreg.get('message-bridge', scope='window', - window=win_id) - q.ask = lambda: message_bridge.ask(q, blocking=False) + q.ask = lambda: message.global_bridge.ask(q, blocking=False) return _DownloadPath(filename=None, question=q) @@ -382,20 +383,13 @@ class DownloadItem(QObject): else: self.set_fileobj(fileobj) - def _ask_confirm_question(self, msg): + def _ask_confirm_question(self, title, msg): """Create a Question object to be asked.""" - q = usertypes.Question(self) - q.text = msg - q.mode = usertypes.PromptMode.yesno - q.answered_yes.connect(self._create_fileobj) - q.answered_no.connect(functools.partial(self.cancel, - remove_data=False)) - q.cancelled.connect(functools.partial(self.cancel, remove_data=False)) - self.cancelled.connect(q.abort) - self.error.connect(q.abort) - message_bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - message_bridge.ask(q, blocking=False) + no_action = functools.partial(self.cancel, remove_data=False) + message.confirm_async(title=title, text=msg, + yes_action=self._create_fileobj, + no_action=no_action, cancel_action=no_action, + abort_on=[self.cancelled, self.error]) def _die(self, msg): """Abort the download and emit an error.""" @@ -614,14 +608,15 @@ class DownloadItem(QObject): if os.path.isfile(self._filename): # The file already exists, so ask the user if it should be # overwritten. - txt = self._filename + " already exists. Overwrite?" - self._ask_confirm_question(txt) + txt = "{} already exists. Overwrite?".format( + html.escape(self._filename)) + self._ask_confirm_question("Overwrite existing file?", txt) # FIFO, device node, etc. Make sure we want to do this elif (os.path.exists(self._filename) and not os.path.isdir(self._filename)): - txt = (self._filename + " already exists and is a special file. " - "Write to this?") - self._ask_confirm_question(txt) + txt = ("{} already exists and is a special file. Write to " + "it anyways?".format(html.escape(self._filename))) + self._ask_confirm_question("Overwrite special file?", txt) else: self._create_fileobj() @@ -963,9 +958,9 @@ class DownloadManager(QObject): # Neither filename nor fileobj were given, prepare a question filename, q = ask_for_filename( - suggested_filename, self._win_id, parent=self, + suggested_filename, parent=self, prompt_download_directory=prompt_download_directory, - ) + url=reply.url()) # User doesn't want to be asked, so just use the download_dir if filename is not None: diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index d28bd6be4..7024e7635 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -22,7 +22,9 @@ import os import collections import netrc +import html +import jinja2 from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, QCoreApplication, QUrl, QByteArray) from PyQt5.QtNetwork import (QNetworkAccessManager, QNetworkReply, QSslError, @@ -207,10 +209,11 @@ class NetworkManager(QNetworkAccessManager): self.setCache(cache) cache.setParent(app) - def _ask(self, text, mode, owner=None): + def _ask(self, title, text, mode, owner=None, default=None): """Ask a blocking question in the statusbar. Args: + title: The title to display to the user. text: The text to display to the user. mode: A PromptMode. owner: An object which will abort the question if destroyed, or @@ -219,24 +222,19 @@ class NetworkManager(QNetworkAccessManager): Return: The answer the user gave or None if the prompt was cancelled. """ - q = usertypes.Question() - q.text = text - q.mode = mode - self.shutting_down.connect(q.abort) + abort_on = [self.shutting_down] if owner is not None: - owner.destroyed.connect(q.abort) + abort_on.append(owner.destroyed) # This might be a generic network manager, e.g. one belonging to a # DownloadManager. In this case, just skip the webview thing. if self._tab_id is not None: tab = objreg.get('tab', scope='tab', window=self._win_id, tab=self._tab_id) - tab.load_started.connect(q.abort) - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - bridge.ask(q, blocking=True) - q.deleteLater() - return q.answer + abort_on.append(tab.load_started) + + return message.ask(title=title, text=text, mode=mode, + abort_on=abort_on, default=default) def shutdown(self): """Abort all running requests.""" @@ -283,9 +281,19 @@ class NetworkManager(QNetworkAccessManager): return if ssl_strict == 'ask': - err_string = '\n'.join('- ' + err.errorString() for err in errors) - answer = self._ask('SSL errors - continue?\n{}'.format(err_string), - mode=usertypes.PromptMode.yesno, owner=reply) + err_template = jinja2.Template(""" + Errors while loading {{url.toDisplayString()}}:
+
    + {% for err in errors %} +
  • {{err.errorString()}}
  • + {% endfor %} +
+ """.strip()) + msg = err_template.render(url=reply.url(), errors=errors) + + answer = self._ask('SSL errors - continue?', msg, + mode=usertypes.PromptMode.yesno, owner=reply, + default=False) log.webview.debug("Asked for SSL errors, answer {}".format(answer)) if answer: reply.ignoreSslErrors() @@ -343,8 +351,11 @@ class NetworkManager(QNetworkAccessManager): if user is None: # netrc check failed - answer = self._ask("Username ({}):".format(authenticator.realm()), - mode=usertypes.PromptMode.user_pwd, + msg = '{} says:
{}'.format( + html.escape(reply.url().toDisplayString()), + html.escape(authenticator.realm())) + answer = self._ask("Authentication required", + text=msg, mode=usertypes.PromptMode.user_pwd, owner=reply) if answer is not None: user, password = answer.user, answer.password @@ -361,8 +372,11 @@ class NetworkManager(QNetworkAccessManager): authenticator.setUser(user) authenticator.setPassword(password) else: + msg = '{} says:
{}'.format( + html.escape(proxy.hostName()), + html.escape(authenticator.realm())) answer = self._ask( - "Proxy username ({}):".format(authenticator.realm()), + "Proxy authentication required", msg, mode=usertypes.PromptMode.user_pwd) if answer is not None: authenticator.setUser(answer.user) diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index c34bc5074..00b253766 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -19,6 +19,7 @@ """The main browser widgets.""" +import html import functools from PyQt5.QtCore import pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl, QPoint @@ -93,13 +94,19 @@ class BrowserPage(QWebPage): # of a bug in PyQt. # See http://www.riverbankcomputing.com/pipermail/pyqt/2014-June/034385.html - def javaScriptPrompt(self, _frame, msg, default): + def javaScriptPrompt(self, _frame, js_msg, default): """Override javaScriptPrompt to use the statusbar.""" if (self._is_shutting_down or config.get('content', 'ignore-javascript-prompt')): return (False, "") - answer = self._ask("js: {}".format(msg), usertypes.PromptMode.text, - default) + msg = '{} asks:
{}'.format( + html.escape(self.mainFrame().url().toDisplayString()), + html.escape(js_msg)) + answer = message.ask('Javascript prompt', msg, + mode=usertypes.PromptMode.text, + default=default, + abort_on=[self.loadStarted, + self.shutting_down]) if answer is None: return (False, "") else: @@ -134,11 +141,12 @@ class BrowserPage(QWebPage): # QDesktopServices::openUrl with info.url directly - however it # works when we construct a copy of it. url = QUrl(info.url) - msg = "Open external application for {}-link?\nURL: {}".format( - url.scheme(), url.toDisplayString()) + scheme = url.scheme() message.confirm_async( - self._win_id, msg, - functools.partial(QDesktopServices.openUrl, url)) + title="Open external application for {}-link?".format(scheme), + text="URL: {}".format( + html.escape(url.toDisplayString())), + yes_action=functools.partial(QDesktopServices.openUrl, url)) return True elif (info.domain, info.error) in ignored_errors: log.webview.debug("Ignored error on {}: {} (error domain: {}, " @@ -168,11 +176,11 @@ class BrowserPage(QWebPage): log.webview.debug("Error domain: {}, error code: {}".format( info.domain, info.error)) title = "Error loading page: {}".format(urlstr) - html = jinja.render( + error_html = jinja.render( 'error.html', title=title, url=urlstr, error=error_str, icon='', qutescheme=False) - errpage.content = html.encode('utf-8') + errpage.content = error_html.encode('utf-8') errpage.encoding = 'utf-8' return True @@ -196,29 +204,6 @@ class BrowserPage(QWebPage): suggested_file) return True - def _ask(self, text, mode, default=None): - """Ask a blocking question in the statusbar. - - Args: - text: The text to display to the user. - mode: A PromptMode. - default: The default value to display. - - Return: - The answer the user gave or None if the prompt was cancelled. - """ - q = usertypes.Question() - q.text = text - q.mode = mode - q.default = default - self.loadStarted.connect(q.abort) - self.shutting_down.connect(q.abort) - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - bridge.ask(q, blocking=True) - q.deleteLater() - return q.answer - def _show_pdfjs(self, reply): """Show the reply with pdfjs.""" try: @@ -333,11 +318,6 @@ class BrowserPage(QWebPage): } config_val = config.get(*options[feature]) if config_val == 'ask': - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - q = usertypes.Question(bridge) - q.mode = usertypes.PromptMode.yesno - msgs = { QWebPage.Notifications: 'show notifications', QWebPage.Geolocation: 'access your location', @@ -345,30 +325,28 @@ class BrowserPage(QWebPage): host = frame.url().host() if host: - q.text = "Allow the website at {} to {}?".format( - frame.url().host(), msgs[feature]) + text = "Allow the website at {} to {}?".format( + html.escape(frame.url().toDisplayString()), msgs[feature]) else: - q.text = "Allow the website to {}?".format(msgs[feature]) + text = "Allow the website to {}?".format(msgs[feature]) yes_action = functools.partial( self.setFeaturePermission, frame, feature, QWebPage.PermissionGrantedByUser) - q.answered_yes.connect(yes_action) - no_action = functools.partial( self.setFeaturePermission, frame, feature, QWebPage.PermissionDeniedByUser) - q.answered_no.connect(no_action) - q.cancelled.connect(no_action) - self.shutting_down.connect(q.abort) - q.completed.connect(q.deleteLater) - - self.featurePermissionRequestCanceled.connect(functools.partial( - self.on_feature_permission_cancelled, q, frame, feature)) - self.loadStarted.connect(q.abort) - - bridge.ask(q, blocking=False) + question = message.confirm_async(yes_action=yes_action, + no_action=no_action, + cancel_action=no_action, + abort_on=[self.shutting_down, + self.loadStarted], + title='Permission request', + text=text) + self.featurePermissionRequestCanceled.connect( + functools.partial(self.on_feature_permission_cancelled, + question, frame, feature)) elif config_val: self.setFeaturePermission(frame, feature, QWebPage.PermissionGrantedByUser) @@ -469,27 +447,37 @@ class BrowserPage(QWebPage): return super().extension(ext, opt, out) return handler(opt, out) - def javaScriptAlert(self, frame, msg): + def javaScriptAlert(self, frame, js_msg): """Override javaScriptAlert to use the statusbar.""" - log.js.debug("alert: {}".format(msg)) + log.js.debug("alert: {}".format(js_msg)) if config.get('ui', 'modal-js-dialog'): - return super().javaScriptAlert(frame, msg) + return super().javaScriptAlert(frame, js_msg) if (self._is_shutting_down or config.get('content', 'ignore-javascript-alert')): return - self._ask("[js alert] {}".format(msg), usertypes.PromptMode.alert) - def javaScriptConfirm(self, frame, msg): + msg = 'From {}:
{}'.format( + html.escape(self.mainFrame().url().toDisplayString()), + html.escape(js_msg)) + message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert, + abort_on=[self.loadStarted, self.shutting_down]) + + def javaScriptConfirm(self, frame, js_msg): """Override javaScriptConfirm to use the statusbar.""" - log.js.debug("confirm: {}".format(msg)) + log.js.debug("confirm: {}".format(js_msg)) if config.get('ui', 'modal-js-dialog'): - return super().javaScriptConfirm(frame, msg) + return super().javaScriptConfirm(frame, js_msg) if self._is_shutting_down: return False - ans = self._ask("[js confirm] {}".format(msg), - usertypes.PromptMode.yesno) + + msg = 'From {}:
{}'.format( + html.escape(self.mainFrame().url().toDisplayString()), + html.escape(js_msg)) + ans = message.ask('Javascript confirm', msg, + mode=usertypes.PromptMode.yesno, + abort_on=[self.loadStarted, self.shutting_down]) return bool(ans) def javaScriptConsoleMessage(self, msg, line, source): diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 8ee27918a..7c8ba98c5 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -391,6 +391,8 @@ class ConfigManager(QObject): ('colors', 'statusbar.bg.error'): 'messages.bg.error', ('colors', 'statusbar.fg.warning'): 'messages.fg.warning', ('colors', 'statusbar.bg.warning'): 'messages.bg.warning', + ('colors', 'statusbar.fg.prompt'): 'prompts.fg', + ('colors', 'statusbar.bg.prompt'): 'prompts.bg', } DELETED_OPTIONS = [ ('colors', 'tab.separator'), diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 4575c3f4e..bad6fa51a 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -384,6 +384,10 @@ def data(readonly=False): "Globs are supported, so ';*' will blacklist all keychains" "starting with ';'. Use '*' to disable keyhints"), + ('prompt-radius', + SettingValue(typ.Int(minval=0), '8'), + "The rounding radius for the edges of prompts."), + readonly=readonly )), @@ -1075,14 +1079,6 @@ def data(readonly=False): SettingValue(typ.QssColor(), 'black'), "Background color of the statusbar."), - ('statusbar.fg.prompt', - SettingValue(typ.QssColor(), '${statusbar.fg}'), - "Foreground color of the statusbar if there is a prompt."), - - ('statusbar.bg.prompt', - SettingValue(typ.QssColor(), 'darkblue'), - "Background color of the statusbar if there is a prompt."), - ('statusbar.fg.insert', SettingValue(typ.QssColor(), '${statusbar.fg}'), "Foreground color of the statusbar in insert mode."), @@ -1306,6 +1302,18 @@ def data(readonly=False): SettingValue(typ.QssColor(), '#333333'), "Border color of an info message."), + ('prompts.fg', + SettingValue(typ.QssColor(), 'white'), + "Foreground color for prompts."), + + ('prompts.bg', + SettingValue(typ.QssColor(), 'darkblue'), + "Background color for prompts."), + + ('prompts.selected.bg', + SettingValue(typ.QssColor(), '#308cc6'), + "Background color for the selected item in filename prompts."), + readonly=readonly )), @@ -1407,6 +1415,10 @@ def data(readonly=False): SettingValue(typ.Font(), DEFAULT_FONT_SIZE + ' ${_monospace}'), "Font used for info messages."), + ('prompts', + SettingValue(typ.Font(), DEFAULT_FONT_SIZE + ' sans-serif'), + "Font used for prompts."), + readonly=readonly )), ]) @@ -1672,6 +1684,8 @@ KEY_DATA = collections.OrderedDict([ ('prompt-accept yes', ['y']), ('prompt-accept no', ['n']), ('prompt-open-download', ['']), + ('prompt-item-focus prev', ['', '']), + ('prompt-item-focus next', ['', '']), ])), ('command,prompt', collections.OrderedDict([ diff --git a/qutebrowser/config/style.py b/qutebrowser/config/style.py index 92c893c45..b2697daac 100644 --- a/qutebrowser/config/style.py +++ b/qutebrowser/config/style.py @@ -46,7 +46,7 @@ def get_stylesheet(template_str): config=objreg.get('config')) -def set_register_stylesheet(obj, *, generator=None): +def set_register_stylesheet(obj): """Set the stylesheet for an object based on it's STYLESHEET attribute. Also, register an update when the config is changed. @@ -54,23 +54,20 @@ def set_register_stylesheet(obj, *, generator=None): Args: obj: The object to set the stylesheet for and register. Must have a STYLESHEET attribute. - generator: If set, call the given function to dynamically generate a - stylesheet instead. """ - stylesheet = generator() if generator is not None else obj.STYLESHEET - qss = get_stylesheet(stylesheet) + qss = get_stylesheet(obj.STYLESHEET) log.config.vdebug("stylesheet for {}: {}".format( obj.__class__.__name__, qss)) obj.setStyleSheet(qss) objreg.get('config').changed.connect( - functools.partial(_update_stylesheet, obj, generator=generator)) + functools.partial(_update_stylesheet, obj)) -def _update_stylesheet(obj, *, generator): +def _update_stylesheet(obj): """Update the stylesheet for obj.""" + get_stylesheet.cache_clear() if not sip.isdeleted(obj): - stylesheet = generator() if generator is not None else obj.STYLESHEET - obj.setStyleSheet(get_stylesheet(stylesheet)) + obj.setStyleSheet(get_stylesheet(obj.STYLESHEET)) class ColorDict(collections.UserDict): diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index ebc4f28ad..0e5a5c069 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -24,13 +24,14 @@ import base64 import itertools import functools +import jinja2 from PyQt5.QtCore import pyqtSlot, QRect, QPoint, QTimer, Qt from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QSizePolicy from qutebrowser.commands import runners, cmdutils from qutebrowser.config import config from qutebrowser.utils import message, log, usertypes, qtutils, objreg, utils -from qutebrowser.mainwindow import tabbedbrowser, messageview +from qutebrowser.mainwindow import tabbedbrowser, messageview, prompt from qutebrowser.mainwindow.statusbar import bar from qutebrowser.completion import completionwidget, completer from qutebrowser.keyinput import modeman @@ -175,17 +176,24 @@ class MainWindow(QWidget): self._init_completion() + log.init.debug("Initializing modes...") + modeman.init(self.win_id, self) + self._commandrunner = runners.CommandRunner(self.win_id, partial_match=True) self._keyhint = keyhintwidget.KeyHintView(self.win_id, self) - self._overlays.append((self._keyhint, self._keyhint.update_geometry)) + self._add_overlay(self._keyhint, self._keyhint.update_geometry) self._messageview = messageview.MessageView(parent=self) - self._overlays.append((self._messageview, - self._messageview.update_geometry)) + self._add_overlay(self._messageview, self._messageview.update_geometry) - log.init.debug("Initializing modes...") - modeman.init(self.win_id, self) + self._prompt_container = prompt.PromptContainer(self.win_id, self) + self._add_overlay(self._prompt_container, + self._prompt_container.update_geometry, + centered=True, padding=10) + objreg.register('prompt-container', self._prompt_container, + scope='window', window=self.win_id) + self._prompt_container.hide() if geometry is not None: self._load_geometry(geometry) @@ -206,36 +214,40 @@ class MainWindow(QWidget): objreg.get("app").new_window.emit(self) - def _update_overlay_geometry(self, widget=None): - """Reposition/resize the given overlay. + def _add_overlay(self, widget, signal, *, centered=False, padding=0): + self._overlays.append((widget, signal, centered, padding)) - If no widget is given, reposition/resize all overlays. - """ - if widget is None: - for w, _signal in self._overlays: - self._update_overlay_geometry(w) - return + def _update_overlay_geometries(self): + """Update the size/position of all overlays.""" + for w, _signal, centered, padding in self._overlays: + self._update_overlay_geometry(w, centered, padding) + def _update_overlay_geometry(self, widget, centered, padding): + """Reposition/resize the given overlay.""" if not widget.isVisible(): return size_hint = widget.sizeHint() if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding: - width = self.width() + width = self.width() - 2 * padding + left = padding else: width = size_hint.width() + left = (self.width() - size_hint.width()) / 2 if centered else 0 + height_padding = 20 status_position = config.get('ui', 'status-position') if status_position == 'bottom': top = self.height() - self.status.height() - size_hint.height() top = qtutils.check_overflow(top, 'int', fatal=False) - topleft = QPoint(0, top) - bottomright = QPoint(width, self.status.geometry().top()) + topleft = QPoint(left, max(height_padding, top)) + bottomright = QPoint(left + width, self.status.geometry().top()) elif status_position == 'top': - topleft = self.status.geometry().bottomLeft() + topleft = QPoint(left, self.status.geometry().bottom()) bottom = self.status.height() + size_hint.height() bottom = qtutils.check_overflow(bottom, 'int', fatal=False) - bottomright = QPoint(width, bottom) + bottomright = QPoint(left + width, + min(self.height() - height_padding, bottom)) else: raise ValueError("Invalid position {}!".format(status_position)) @@ -261,8 +273,7 @@ class MainWindow(QWidget): completer_obj.on_selection_changed) objreg.register('completion', self._completion, scope='window', window=self.win_id) - self._overlays.append((self._completion, - self._completion.update_geometry)) + self._add_overlay(self._completion, self._completion.update_geometry) def _init_command_dispatcher(self): dispatcher = commands.CommandDispatcher(self.win_id, @@ -282,12 +293,12 @@ class MainWindow(QWidget): if section != 'ui': return if option == 'statusbar-padding': - self._update_overlay_geometry() + self._update_overlay_geometries() elif option == 'downloads-position': self._add_widgets() elif option == 'status-position': self._add_widgets() - self._update_overlay_geometry() + self._update_overlay_geometries() def _add_widgets(self): """Add or readd all widgets to the VBox.""" @@ -350,10 +361,11 @@ class MainWindow(QWidget): def _connect_overlay_signals(self): """Connect the resize signal and resize everything once.""" - for widget, signal in self._overlays: + for widget, signal, centered, padding in self._overlays: signal.connect( - functools.partial(self._update_overlay_geometry, widget)) - self._update_overlay_geometry(widget) + functools.partial(self._update_overlay_geometry, widget, + centered, padding)) + self._update_overlay_geometry(widget, centered, padding) def _set_default_geometry(self): """Set some sensible default geometry.""" @@ -374,7 +386,6 @@ class MainWindow(QWidget): cmd = self._get_object('status-command') message_bridge = self._get_object('message-bridge') mode_manager = self._get_object('mode-manager') - prompter = self._get_object('prompter') # misc self.tabbed_browser.close_window.connect(self.close) @@ -384,7 +395,7 @@ class MainWindow(QWidget): mode_manager.entered.connect(status.on_mode_entered) mode_manager.left.connect(status.on_mode_left) mode_manager.left.connect(cmd.on_mode_left) - mode_manager.left.connect(prompter.on_mode_left) + mode_manager.left.connect(message.global_bridge.mode_left) # commands keyparsers[usertypes.KeyMode.normal].keystring_updated.connect( @@ -407,9 +418,6 @@ class MainWindow(QWidget): message_bridge.s_set_text.connect(status.set_text) message_bridge.s_maybe_reset_text.connect(status.txt.maybe_reset_text) - message_bridge.s_set_cmd_text.connect(cmd.set_cmd_text) - message_bridge.s_question.connect(prompter.ask_question, - Qt.DirectConnection) # statusbar tabs.current_tab_changed.connect(status.prog.on_tab_changed) @@ -459,7 +467,7 @@ class MainWindow(QWidget): e: The QResizeEvent """ super().resizeEvent(e) - self._update_overlay_geometry() + self._update_overlay_geometries() self._downloadview.updateGeometry() self.tabbed_browser.tabBar().refresh() @@ -507,10 +515,17 @@ class MainWindow(QWidget): "download is" if download_count == 1 else "downloads are")) # Process all quit messages that user must confirm if quit_texts or 'always' in confirm_quit: - text = '\n'.join(['Really quit?'] + quit_texts) - confirmed = message.ask(self.win_id, text, - usertypes.PromptMode.yesno, + msg = jinja2.Template(""" +
    + {% for text in quit_texts %} +
  • {{text}}
  • + {% endfor %} +
+ """.strip()).render(quit_texts=quit_texts) + confirmed = message.ask('Really quit?', msg, + mode=usertypes.PromptMode.yesno, default=True) + # Stop asking if the user cancels if not confirmed: log.destroy.debug("Cancelling closing of window {}".format( diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py new file mode 100644 index 000000000..7435a46a7 --- /dev/null +++ b/qutebrowser/mainwindow/prompt.py @@ -0,0 +1,829 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 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 . + +"""Showing prompts above the statusbar.""" + +import os.path +import html +import collections + +import sip +from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex, + QItemSelectionModel, QObject) +from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit, + QLabel, QFileSystemModel, QTreeView, QSizePolicy) + +from qutebrowser.config import style +from qutebrowser.utils import usertypes, log, utils, qtutils, objreg, message +from qutebrowser.keyinput import modeman +from qutebrowser.commands import cmdutils, cmdexc + + +prompt_queue = None + + +AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password']) + + +class Error(Exception): + + """Base class for errors in this module.""" + + +class UnsupportedOperationError(Exception): + + """Raised when the prompt class doesn't support the requested operation.""" + + +class PromptQueue(QObject): + + """Global manager and queue for upcoming prompts. + + The way in which multiple questions are handled deserves some explanation. + + If a question is blocking, we *need* to ask it immediately, and can't wait + for previous questions to finish. We could theoretically ask a blocking + question inside of another blocking one, so in ask_question we simply save + the current question on the stack, let the user answer the *most recent* + question, and then restore the previous state. + + With a non-blocking question, things are a bit easier. We simply add it to + self._queue if we're still busy handling another question, since it can be + answered at any time. + + In either case, as soon as we finished handling a question, we call + _pop_later() which schedules a _pop to ask the next question in _queue. We + schedule it rather than doing it immediately because then the order of how + things happen is clear, e.g. on_mode_left can't happen after we already set + up the *new* question. + + Attributes: + _shutting_down: Whether we're currently shutting down the prompter and + should ignore future questions to avoid segfaults. + _loops: A list of local EventLoops to spin in when blocking. + _queue: A deque of waiting questions. + _question: The current Question object if we're handling a question. + + Signals: + show_prompts: Emitted with a Question object when prompts should be + shown. + """ + + show_prompts = pyqtSignal(usertypes.Question) + + def __init__(self, parent=None): + super().__init__(parent) + self._question = None + self._shutting_down = False + self._loops = [] + self._queue = collections.deque() + message.global_bridge.mode_left.connect(self._on_mode_left) + + def __repr__(self): + return utils.get_repr(self, loops=len(self._loops), + queue=len(self._queue), question=self._question) + + def _pop_later(self): + """Helper to call self._pop as soon as everything else is done.""" + QTimer.singleShot(0, self._pop) + + def _pop(self): + """Pop a question from the queue and ask it, if there are any.""" + log.prompt.debug("Popping from queue {}".format(self._queue)) + if self._queue: + question = self._queue.popleft() + if not sip.isdeleted(question): + # the question could already be deleted, e.g. by a cancelled + # download. See + # https://github.com/The-Compiler/qutebrowser/issues/415 + self.ask_question(question, blocking=False) + + def shutdown(self): + """Cancel all blocking questions. + + Quits and removes all running event loops. + + Return: + True if loops needed to be aborted, + False otherwise. + """ + log.prompt.debug("Shutting down with loops {}".format(self._loops)) + self._shutting_down = True + if self._loops: + for loop in self._loops: + loop.quit() + loop.deleteLater() + return True + else: + return False + + @pyqtSlot(usertypes.Question, bool) + def ask_question(self, question, blocking): + """Display a prompt for a given question. + + Args: + question: The Question object to ask. + blocking: If True, this function blocks and returns the result. + + Return: + The answer of the user when blocking=True. + None if blocking=False. + """ + log.prompt.debug("Asking question {}, blocking {}, loops {}, queue " + "{}".format(question, blocking, self._loops, + self._queue)) + + if self._shutting_down: + # If we're currently shutting down we have to ignore this question + # to avoid segfaults - see + # https://github.com/The-Compiler/qutebrowser/issues/95 + log.prompt.debug("Ignoring question because we're shutting down.") + question.abort() + return None + + if self._question is not None and not blocking: + # We got an async question, but we're already busy with one, so we + # just queue it up for later. + log.prompt.debug("Adding {} to queue.".format(question)) + self._queue.append(question) + return + + if blocking: + # If we're blocking we save the old question on the stack, so we + # can restore it after exec, if exec gets called multiple times. + log.prompt.debug("New question is blocking, saving {}".format( + self._question)) + old_question = self._question + if old_question is not None: + old_question.interrupted = True + + self._question = question + self.show_prompts.emit(question) + + if blocking: + loop = qtutils.EventLoop() + self._loops.append(loop) + loop.destroyed.connect(lambda: self._loops.remove(loop)) + question.completed.connect(loop.quit) + question.completed.connect(loop.deleteLater) + log.prompt.debug("Starting loop.exec_() for {}".format(question)) + loop.exec_() + log.prompt.debug("Ending loop.exec_() for {}".format(question)) + + log.prompt.debug("Restoring old question {}".format(old_question)) + self._question = old_question + self.show_prompts.emit(old_question) + if old_question is None: + # Nothing left to restore, so we can go back to popping async + # questions. + if self._queue: + self._pop_later() + + return question.answer + else: + question.completed.connect(self._pop_later) + + @pyqtSlot(usertypes.KeyMode) + def _on_mode_left(self, mode): + """Abort question when a prompt mode was left.""" + if mode not in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: + return + if self._question is None: + return + + log.prompt.debug("Left mode {}, hiding {}".format( + mode, self._question)) + self.show_prompts.emit(None) + if self._question.answer is None and not self._question.is_aborted: + log.prompt.debug("Cancelling {} because {} was left".format( + self._question, mode)) + self._question.cancel() + self._question = None + + +class PromptContainer(QWidget): + + """Container for prompts to be shown above the statusbar. + + This is a per-window object, however each window shows the same prompt. + + Attributes: + _layout: The layout used to show prompts in. + _win_id: The window ID this object is associated with. + + Signals: + update_geometry: Emitted when the geometry should be updated. + """ + + STYLESHEET = """ + {% set prompt_radius = config.get('ui', 'prompt-radius') %} + QWidget#PromptContainer { + {% if config.get('ui', 'status-position') == 'top' %} + border-bottom-left-radius: {{ prompt_radius }}px; + border-bottom-right-radius: {{ prompt_radius }}px; + {% else %} + border-top-left-radius: {{ prompt_radius }}px; + border-top-right-radius: {{ prompt_radius }}px; + {% endif %} + } + + QWidget { + font: {{ font['prompts'] }}; + color: {{ color['prompts.fg'] }}; + background-color: {{ color['prompts.bg'] }}; + } + + QTreeView { + selection-background-color: {{ color['prompts.selected.bg'] }}; + } + + QTreeView::item:selected, QTreeView::item:selected:hover { + background-color: {{ color['prompts.selected.bg'] }}; + } + """ + update_geometry = pyqtSignal() + + def __init__(self, win_id, parent=None): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(10, 10, 10, 10) + self._win_id = win_id + self._prompt = None + + self.setObjectName('PromptContainer') + self.setAttribute(Qt.WA_StyledBackground, True) + style.set_register_stylesheet(self) + + message.global_bridge.prompt_done.connect(self._on_prompt_done) + prompt_queue.show_prompts.connect(self._on_show_prompts) + message.global_bridge.mode_left.connect(self._on_global_mode_left) + + def __repr__(self): + return utils.get_repr(self, win_id=self._win_id) + + @pyqtSlot(usertypes.Question) + def _on_show_prompts(self, question): + """Show a prompt for the given question. + + Args: + question: A Question object or None. + """ + item = self._layout.takeAt(0) + if item is not None: + widget = item.widget() + log.prompt.debug("Deleting old prompt {}".format(widget)) + widget.hide() + widget.deleteLater() + + if question is None: + log.prompt.debug("No prompts left, hiding prompt container.") + self._prompt = None + self.hide() + return + + classes = { + usertypes.PromptMode.yesno: YesNoPrompt, + usertypes.PromptMode.text: LineEditPrompt, + usertypes.PromptMode.user_pwd: AuthenticationPrompt, + usertypes.PromptMode.download: DownloadFilenamePrompt, + usertypes.PromptMode.alert: AlertPrompt, + } + klass = classes[question.mode] + prompt = klass(question) + + log.prompt.debug("Displaying prompt {}".format(prompt)) + self._prompt = prompt + + if not question.interrupted: + # If this question was interrupted, we already connected the signal + question.aborted.connect( + lambda: modeman.maybe_leave(self._win_id, prompt.KEY_MODE, + 'aborted')) + modeman.enter(self._win_id, prompt.KEY_MODE, 'question asked') + + self.setSizePolicy(prompt.sizePolicy()) + self._layout.addWidget(prompt) + prompt.show() + self.show() + prompt.setFocus() + self.update_geometry.emit() + + @pyqtSlot(usertypes.KeyMode) + def _on_prompt_done(self, key_mode): + """Leave the prompt mode in this window if a question was answered.""" + modeman.maybe_leave(self._win_id, key_mode, ':prompt-accept') + + @pyqtSlot(usertypes.KeyMode) + def _on_global_mode_left(self, mode): + """Leave prompt/yesno mode in this window if it was left elsewhere. + + This ensures no matter where a prompt was answered, we leave the prompt + mode and dispose of the prompt object in every window. + """ + if mode not in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: + return + modeman.maybe_leave(self._win_id, mode, 'left in other window') + item = self._layout.takeAt(0) + if item is not None: + widget = item.widget() + log.prompt.debug("Deleting prompt {}".format(widget)) + widget.hide() + widget.deleteLater() + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt, + usertypes.KeyMode.yesno]) + def prompt_accept(self, value=None): + """Accept the current prompt. + + // + + This executes the next action depending on the question mode, e.g. asks + for the password or leaves the mode. + + Args: + value: If given, uses this value instead of the entered one. + For boolean prompts, "yes"/"no" are accepted as value. + """ + question = self._prompt.question + try: + done = self._prompt.accept(value) + except Error as e: + raise cmdexc.CommandError(str(e)) + if done: + message.global_bridge.prompt_done.emit(self._prompt.KEY_MODE) + question.done() + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.yesno], + deprecated='Use :prompt-accept yes instead!') + def prompt_yes(self): + """Answer yes to a yes/no prompt.""" + self.prompt_accept('yes') + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.yesno], + deprecated='Use :prompt-accept no instead!') + def prompt_no(self): + """Answer no to a yes/no prompt.""" + self.prompt_accept('no') + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt], maxsplit=0) + def prompt_open_download(self, cmdline: str=None): + """Immediately open a download. + + If no specific command is given, this will use the system's default + application to open the file. + + Args: + cmdline: The command which should be used to open the file. A `{}` + is expanded to the temporary file name. If no `{}` is + present, the filename is automatically appended to the + cmdline. + """ + try: + self._prompt.download_open(cmdline) + except UnsupportedOperationError: + pass + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt]) + @cmdutils.argument('which', choices=['next', 'prev']) + def prompt_item_focus(self, which): + """Shift the focus of the prompt file completion menu to another item. + + Args: + which: 'next', 'prev' + """ + try: + self._prompt.item_focus(which) + except UnsupportedOperationError: + pass + + +class LineEdit(QLineEdit): + + """A line edit used in prompts.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setStyleSheet(""" + QLineEdit { + border: 1px solid grey; + background-color: transparent; + } + """) + self.setAttribute(Qt.WA_MacShowFocusRect, False) + + def keyPressEvent(self, e): + """Override keyPressEvent to paste primary selection on Shift + Ins.""" + if e.key() == Qt.Key_Insert and e.modifiers() == Qt.ShiftModifier: + try: + text = utils.get_clipboard(selection=True) + except utils.ClipboardError: # pragma: no cover + pass + else: + e.accept() + self.insert(text) + return + super().keyPressEvent(e) + + def __repr__(self): + return utils.get_repr(self) + + +class _BasePrompt(QWidget): + + """Base class for all prompts.""" + + KEY_MODE = usertypes.KeyMode.prompt + + def __init__(self, question, parent=None): + super().__init__(parent) + self.question = question + self._vbox = QVBoxLayout(self) + self._vbox.setSpacing(15) + self._key_grid = None + + def __repr__(self): + return utils.get_repr(self, question=self.question, constructor=True) + + def _init_texts(self, question): + assert question.title is not None, question + title = '{}'.format( + html.escape(question.title)) + title_label = QLabel(title, self) + self._vbox.addWidget(title_label) + if question.text is not None: + # Not doing any HTML escaping here as the text can be formatted + text_label = QLabel(question.text) + self._vbox.addWidget(text_label) + + def _init_key_label(self): + assert self._key_grid is None, self._key_grid + self._key_grid = QGridLayout() + self._key_grid.setVerticalSpacing(0) + + key_config = objreg.get('key-config') + # The bindings are all in the 'prompt' mode, even for yesno prompts + all_bindings = key_config.get_reverse_bindings_for('prompt') + labels = [] + + for cmd, text in self._allowed_commands(): + bindings = all_bindings.get(cmd, []) + if bindings: + binding = None + preferred = ['', ''] + for pref in preferred: + if pref in bindings: + binding = pref + if binding is None: + binding = bindings[0] + key_label = QLabel('{}'.format(html.escape(binding))) + text_label = QLabel(text) + labels.append((key_label, text_label)) + + for i, (key_label, text_label) in enumerate(labels): + self._key_grid.addWidget(key_label, i, 0) + self._key_grid.addWidget(text_label, i, 1) + + self._vbox.addLayout(self._key_grid) + + def accept(self, value=None): + raise NotImplementedError + + def download_open(self, _cmdline): + """Open the download directly if this is a download prompt.""" + raise UnsupportedOperationError + + def item_focus(self, _which): + """Switch to next file item if this is a filename prompt..""" + raise UnsupportedOperationError + + def _allowed_commands(self): + """Get the commands we could run as response to this message.""" + raise NotImplementedError + + +class LineEditPrompt(_BasePrompt): + + """A prompt for a single text value.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._lineedit = LineEdit(self) + self._init_texts(question) + self._vbox.addWidget(self._lineedit) + if question.default: + self._lineedit.setText(question.default) + self.setFocusProxy(self._lineedit) + self._init_key_label() + + def accept(self, value=None): + text = value if value is not None else self._lineedit.text() + self.question.answer = text + return True + + def _allowed_commands(self): + return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')] + + +class FilenamePrompt(_BasePrompt): + + """A prompt for a filename.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + self._init_fileview() + self._set_fileview_root(question.default) + + self._lineedit = LineEdit(self) + if question.default: + self._lineedit.setText(question.default) + self._lineedit.textEdited.connect(self._set_fileview_root) + self._vbox.addWidget(self._lineedit) + + self.setFocusProxy(self._lineedit) + self._init_key_label() + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + @pyqtSlot(str) + def _set_fileview_root(self, path, *, tabbed=False): + """Set the root path for the file display.""" + separators = os.sep + if os.altsep is not None: + separators += os.altsep + + dirname = os.path.dirname(path) + + try: + if not path: + pass + elif path in separators and os.path.isdir(path): + # Input "/" -> don't strip anything + pass + elif path[-1] in separators and os.path.isdir(path): + # Input like /foo/bar/ -> show /foo/bar/ contents + path = path.rstrip(separators) + elif os.path.isdir(dirname) and not tabbed: + # Input like /foo/ba -> show /foo contents + path = dirname + else: + return + except OSError: + log.prompt.exception("Failed to get directory information") + return + + root = self._file_model.setRootPath(path) + self._file_view.setRootIndex(root) + + @pyqtSlot(QModelIndex) + def _insert_path(self, index, *, clicked=True): + """Handle an element selection. + + Args: + index: The QModelIndex of the selected element. + clicked: Whether the element was clicked. + """ + path = os.path.normpath(self._file_model.filePath(index)) + if clicked: + path += os.sep + else: + # On Windows, when we have C:\foo and tab over .., we get C:\ + path = path.rstrip(os.sep) + + log.prompt.debug('Inserting path {}'.format(path)) + self._lineedit.setText(path) + self._lineedit.setFocus() + self._set_fileview_root(path, tabbed=True) + if clicked: + # Avoid having a ..-subtree highlighted + self._file_view.setCurrentIndex(QModelIndex()) + + def _init_fileview(self): + self._file_view = QTreeView(self) + self._file_model = QFileSystemModel(self) + self._file_view.setModel(self._file_model) + self._file_view.clicked.connect(self._insert_path) + self._vbox.addWidget(self._file_view) + # Only show name + self._file_view.setHeaderHidden(True) + for col in range(1, 4): + self._file_view.setColumnHidden(col, True) + # Nothing selected initially + self._file_view.setCurrentIndex(QModelIndex()) + # The model needs to be sorted so we get the correct first/last index + self._file_model.directoryLoaded.connect( + lambda: self._file_model.sort(0)) + + def accept(self, value=None): + text = value if value is not None else self._lineedit.text() + self.question.answer = text + return True + + def item_focus(self, which): + # This duplicates some completion code, but I don't see a nicer way... + assert which in ['prev', 'next'], which + selmodel = self._file_view.selectionModel() + + parent = self._file_view.rootIndex() + first_index = self._file_model.index(0, 0, parent) + row = self._file_model.rowCount(parent) - 1 + last_index = self._file_model.index(row, 0, parent) + + if not first_index.isValid(): + # No entries + return + + assert last_index.isValid() + + idx = selmodel.currentIndex() + if not idx.isValid(): + # No item selected yet + idx = last_index if which == 'prev' else first_index + elif which == 'prev': + idx = self._file_view.indexAbove(idx) + else: + assert which == 'next', which + idx = self._file_view.indexBelow(idx) + + # wrap around if we arrived at beginning/end + if not idx.isValid(): + idx = last_index if which == 'prev' else first_index + + selmodel.setCurrentIndex( + idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) + self._insert_path(idx, clicked=False) + + def _allowed_commands(self): + return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')] + + +class DownloadFilenamePrompt(FilenamePrompt): + + """A prompt for a filename for downloads.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._file_model.setFilter(QDir.AllDirs | QDir.Drives | QDir.NoDot) + + def accept(self, value=None): + text = value if value is not None else self._lineedit.text() + self.question.answer = usertypes.FileDownloadTarget(text) + return True + + def download_open(self, cmdline): + self.question.answer = usertypes.OpenFileDownloadTarget(cmdline) + self.question.done() + message.global_bridge.prompt_done.emit(self.KEY_MODE) + + def _allowed_commands(self): + cmds = [ + ('prompt-accept', 'Accept'), + ('leave-mode', 'Abort'), + ('prompt-open-download', "Open download"), + ] + return cmds + + +class AuthenticationPrompt(_BasePrompt): + + """A prompt for username/password.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + + user_label = QLabel("Username:", self) + self._user_lineedit = LineEdit(self) + + password_label = QLabel("Password:", self) + self._password_lineedit = LineEdit(self) + self._password_lineedit.setEchoMode(QLineEdit.Password) + + grid = QGridLayout() + grid.addWidget(user_label, 1, 0) + grid.addWidget(self._user_lineedit, 1, 1) + grid.addWidget(password_label, 2, 0) + grid.addWidget(self._password_lineedit, 2, 1) + self._vbox.addLayout(grid) + self._init_key_label() + + assert not question.default, question.default + self.setFocusProxy(self._user_lineedit) + + def accept(self, value=None): + if value is not None: + if ':' not in value: + raise Error("Value needs to be in the format " + "username:password, but {} was given".format( + value)) + username, password = value.split(':', maxsplit=1) + self.question.answer = AuthTuple(username, password) + return True + elif self._user_lineedit.hasFocus(): + # Earlier, tab was bound to :prompt-accept, so to still support + # that we simply switch the focus when tab was pressed. + self._password_lineedit.setFocus() + return False + else: + self.question.answer = AuthTuple(self._user_lineedit.text(), + self._password_lineedit.text()) + return True + + def item_focus(self, which): + """Support switching between fields with tab.""" + assert which in ['prev', 'next'], which + if which == 'next' and self._user_lineedit.hasFocus(): + self._password_lineedit.setFocus() + elif which == 'prev' and self._password_lineedit.hasFocus(): + self._user_lineedit.setFocus() + + def _allowed_commands(self): + return [('prompt-accept', "Accept"), + ('leave-mode', "Abort")] + + +class YesNoPrompt(_BasePrompt): + + """A prompt with yes/no answers.""" + + KEY_MODE = usertypes.KeyMode.yesno + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + self._init_key_label() + + def accept(self, value=None): + if value is None: + if self.question.default is None: + raise Error("No default value was set for this question!") + self.question.answer = self.question.default + elif value == 'yes': + self.question.answer = True + elif value == 'no': + self.question.answer = False + else: + raise Error("Invalid value {} - expected yes/no!".format(value)) + return True + + def _allowed_commands(self): + cmds = [ + ('prompt-accept yes', "Yes"), + ('prompt-accept no', "No"), + ] + + if self.question.default is not None: + assert self.question.default in [True, False] + default = 'yes' if self.question.default else 'no' + cmds.append(('prompt-accept', "Use default ({})".format(default))) + + cmds.append(('leave-mode', "Abort")) + return cmds + + +class AlertPrompt(_BasePrompt): + + """A prompt without any answer possibility.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + self._init_key_label() + + def accept(self, value=None): + if value is not None: + raise Error("No value is permitted with alert prompts!") + # Simply mark prompt as done without setting self.question.answer + return True + + def _allowed_commands(self): + return [('prompt-accept', "Hide")] + + +def init(): + """Initialize global prompt objects.""" + global prompt_queue + prompt_queue = PromptQueue() + objreg.register('prompt-queue', prompt_queue) # for commands + message.global_bridge.ask_question.connect( + prompt_queue.ask_question, Qt.DirectConnection) diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 1645ce583..21e0d46ea 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -25,8 +25,7 @@ from PyQt5.QtWidgets import QWidget, QHBoxLayout, QStackedLayout, QSizePolicy from qutebrowser.config import config, style from qutebrowser.utils import usertypes, log, objreg, utils from qutebrowser.mainwindow.statusbar import (command, progress, keystring, - percentage, url, prompt, - tabindex) + percentage, url, tabindex) from qutebrowser.mainwindow.statusbar import text as textwidget @@ -113,8 +112,8 @@ class StatusBar(QWidget): QWidget#StatusBar[prompt_active="true"], QWidget#StatusBar[prompt_active="true"] QLabel, QWidget#StatusBar[prompt_active="true"] QLineEdit { - color: {{ color['statusbar.fg.prompt'] }}; - background-color: {{ color['statusbar.bg.prompt'] }}; + color: {{ color['prompts.fg'] }}; + background-color: {{ color['prompts.bg'] }}; } QWidget#StatusBar[insert_active="true"], @@ -162,16 +161,9 @@ class StatusBar(QWidget): self.txt = textwidget.Text() self._stack.addWidget(self.txt) - self.prompt = prompt.Prompt(win_id) - self._stack.addWidget(self.prompt) - self.cmd.show_cmd.connect(self._show_cmd_widget) self.cmd.hide_cmd.connect(self._hide_cmd_widget) self._hide_cmd_widget() - prompter = objreg.get('prompter', scope='window', window=self._win_id) - prompter.show_prompt.connect(self._show_prompt_widget) - prompter.hide_prompt.connect(self._hide_prompt_widget) - self._hide_prompt_widget() self.keystring = keystring.KeyString() self._hbox.addWidget(self.keystring) @@ -216,16 +208,6 @@ class StatusBar(QWidget): """Getter for self.prompt_active, so it can be used as Qt property.""" return self._prompt_active - def _set_prompt_active(self, val): - """Setter for self.prompt_active. - - Re-set the stylesheet after setting the value, so everything gets - updated by Qt properly. - """ - log.statusbar.debug("Setting prompt_active to {}".format(val)) - self._prompt_active = val - self.setStyleSheet(style.get_stylesheet(self.STYLESHEET)) - @pyqtProperty(bool) def command_active(self): """Getter for self.command_active, so it can be used as Qt property.""" @@ -253,6 +235,9 @@ class StatusBar(QWidget): if mode == usertypes.KeyMode.command: log.statusbar.debug("Setting command_active to {}".format(val)) self._command_active = val + elif mode in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: + log.statusbar.debug("Setting prompt_active to {}".format(val)) + self._prompt_active = val elif mode == usertypes.KeyMode.caret: tab = objreg.get('tabbed-browser', scope='window', window=self._win_id).currentWidget() @@ -285,21 +270,6 @@ class StatusBar(QWidget): self._stack.setCurrentWidget(self.txt) self.maybe_hide() - def _show_prompt_widget(self): - """Show prompt widget instead of temporary text.""" - if self._stack.currentWidget() is self.prompt: - return - self._set_prompt_active(True) - self._stack.setCurrentWidget(self.prompt) - self.show() - - def _hide_prompt_widget(self): - """Show temporary text instead of prompt widget.""" - self._set_prompt_active(False) - log.statusbar.debug("Hiding prompt widget") - self._stack.setCurrentWidget(self.txt) - self.maybe_hide() - @pyqtSlot(str) def set_text(self, val): """Set a normal (persistent) text in the status bar.""" @@ -314,7 +284,9 @@ class StatusBar(QWidget): self._set_mode_text(mode.name) if mode in [usertypes.KeyMode.insert, usertypes.KeyMode.command, - usertypes.KeyMode.caret]: + usertypes.KeyMode.caret, + usertypes.KeyMode.prompt, + usertypes.KeyMode.yesno]: self.set_mode_active(mode, True) @pyqtSlot(usertypes.KeyMode, usertypes.KeyMode) @@ -329,7 +301,9 @@ class StatusBar(QWidget): self.txt.set_text(self.txt.Text.normal, '') if old_mode in [usertypes.KeyMode.insert, usertypes.KeyMode.command, - usertypes.KeyMode.caret]: + usertypes.KeyMode.caret, + usertypes.KeyMode.prompt, + usertypes.KeyMode.yesno]: self.set_mode_active(old_mode, False) def resizeEvent(self, e): diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py index 79a5f0305..a5abaa290 100644 --- a/qutebrowser/mainwindow/statusbar/command.py +++ b/qutebrowser/mainwindow/statusbar/command.py @@ -77,7 +77,6 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): else: return '' - @pyqtSlot(str) def set_cmd_text(self, text): """Preset the statusbar to some text. diff --git a/qutebrowser/mainwindow/statusbar/prompt.py b/qutebrowser/mainwindow/statusbar/prompt.py deleted file mode 100644 index 2015ac599..000000000 --- a/qutebrowser/mainwindow/statusbar/prompt.py +++ /dev/null @@ -1,84 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2016 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 . - -"""Prompt shown in the statusbar.""" - -import functools - -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QHBoxLayout, QWidget, QLineEdit, QSizePolicy - -from qutebrowser.mainwindow.statusbar import textbase, prompter -from qutebrowser.utils import objreg, utils -from qutebrowser.misc import miscwidgets as misc - - -class PromptLineEdit(misc.MinimalLineEditMixin, QLineEdit): - - """QLineEdit with a minimal stylesheet.""" - - def __init__(self, parent=None): - QLineEdit.__init__(self, parent) - misc.MinimalLineEditMixin.__init__(self) - self.textChanged.connect(self.updateGeometry) - - def sizeHint(self): - """Dynamically calculate the needed size.""" - height = super().sizeHint().height() - text = self.text() - if not text: - text = 'x' - width = self.fontMetrics().width(text) - return QSize(width, height) - - -class Prompt(QWidget): - - """The prompt widget shown in the statusbar. - - Attributes: - txt: The TextBase instance (QLabel) used to display the prompt text. - lineedit: The MinimalLineEdit instance (QLineEdit) used for the input. - _hbox: The QHBoxLayout used to display the text and prompt. - """ - - def __init__(self, win_id, parent=None): - super().__init__(parent) - objreg.register('prompt', self, scope='window', window=win_id) - self._hbox = QHBoxLayout(self) - self._hbox.setContentsMargins(0, 0, 0, 0) - self._hbox.setSpacing(5) - - self.txt = textbase.TextBase() - self._hbox.addWidget(self.txt) - - self.lineedit = PromptLineEdit() - self.lineedit.setSizePolicy(QSizePolicy.MinimumExpanding, - QSizePolicy.Fixed) - self._hbox.addWidget(self.lineedit) - - prompter_obj = prompter.Prompter(win_id) - objreg.register('prompter', prompter_obj, scope='window', - window=win_id) - self.destroyed.connect( - functools.partial(objreg.delete, 'prompter', scope='window', - window=win_id)) - - def __repr__(self): - return utils.get_repr(self) diff --git a/qutebrowser/mainwindow/statusbar/prompter.py b/qutebrowser/mainwindow/statusbar/prompter.py deleted file mode 100644 index c93f5912a..000000000 --- a/qutebrowser/mainwindow/statusbar/prompter.py +++ /dev/null @@ -1,411 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2016 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 . - -"""Manager for questions to be shown in the statusbar.""" - -import sip -import collections - -from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, QObject -from PyQt5.QtWidgets import QLineEdit - -from qutebrowser.keyinput import modeman -from qutebrowser.commands import cmdutils, cmdexc -from qutebrowser.utils import usertypes, log, qtutils, objreg, utils - - -PromptContext = collections.namedtuple('PromptContext', - ['question', 'text', 'input_text', - 'echo_mode', 'input_visible']) -AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password']) - - -class Prompter(QObject): - - """Manager for questions to be shown in the statusbar. - - The way in which multiple questions are handled deserves some explanation. - - If a question is blocking, we *need* to ask it immediately, and can't wait - for previous questions to finish. We could theoretically ask a blocking - question inside of another blocking one, so in ask_question we simply save - the current prompt state on the stack, let the user answer the *most - recent* question, and then restore the previous state. - - With a non-blocking question, things are a bit easier. We simply add it to - self._queue if we're still busy handling another question, since it can be - answered at any time. - - In either case, as soon as we finished handling a question, we call - _pop_later() which schedules a _pop to ask the next question in _queue. We - schedule it rather than doing it immediately because then the order of how - things happen is clear, e.g. on_mode_left can't happen after we already set - up the *new* question. - - Class Attributes: - KEY_MODES: A mapping of PromptModes to KeyModes. - - Attributes: - _shutting_down: Whether we're currently shutting down the prompter and - should ignore future questions to avoid segfaults. - _question: A Question object with the question to be asked to the user. - _loops: A list of local EventLoops to spin in when blocking. - _queue: A deque of waiting questions. - _busy: If we're currently busy with asking a question. - _win_id: The window ID this object is associated with. - - Signals: - show_prompt: Emitted when the prompt widget should be shown. - hide_prompt: Emitted when the prompt widget should be hidden. - """ - - KEY_MODES = { - usertypes.PromptMode.yesno: usertypes.KeyMode.yesno, - usertypes.PromptMode.text: usertypes.KeyMode.prompt, - usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt, - usertypes.PromptMode.alert: usertypes.KeyMode.prompt, - usertypes.PromptMode.download: usertypes.KeyMode.prompt, - } - - show_prompt = pyqtSignal() - hide_prompt = pyqtSignal() - - def __init__(self, win_id, parent=None): - super().__init__(parent) - self._shutting_down = False - self._question = None - self._loops = [] - self._queue = collections.deque() - self._busy = False - self._win_id = win_id - - def __repr__(self): - return utils.get_repr(self, loops=len(self._loops), - question=self._question, queue=len(self._queue), - busy=self._busy) - - def _pop_later(self): - """Helper to call self._pop as soon as everything else is done.""" - QTimer.singleShot(0, self._pop) - - def _pop(self): - """Pop a question from the queue and ask it, if there are any.""" - log.statusbar.debug("Popping from queue {}".format(self._queue)) - if self._queue: - question = self._queue.popleft() - if not sip.isdeleted(question): - # the question could already be deleted, e.g. by a cancelled - # download. See - # https://github.com/The-Compiler/qutebrowser/issues/415 - self.ask_question(question, blocking=False) - - def _get_ctx(self): - """Get a PromptContext based on the current state.""" - if not self._busy: - return None - prompt = objreg.get('prompt', scope='window', window=self._win_id) - ctx = PromptContext(question=self._question, - text=prompt.txt.text(), - input_text=prompt.lineedit.text(), - echo_mode=prompt.lineedit.echoMode(), - input_visible=prompt.lineedit.isVisible()) - return ctx - - def _restore_ctx(self, ctx): - """Restore state from a PromptContext. - - Args: - ctx: A PromptContext previously saved by _get_ctx, or None. - - Return: True if a context was restored, False otherwise. - """ - log.statusbar.debug("Restoring context {}".format(ctx)) - if ctx is None: - self.hide_prompt.emit() - self._busy = False - return False - self._question = ctx.question - prompt = objreg.get('prompt', scope='window', window=self._win_id) - prompt.txt.setText(ctx.text) - prompt.lineedit.setText(ctx.input_text) - prompt.lineedit.setEchoMode(ctx.echo_mode) - prompt.lineedit.setVisible(ctx.input_visible) - self.show_prompt.emit() - mode = self.KEY_MODES[ctx.question.mode] - ctx.question.aborted.connect( - lambda: modeman.maybe_leave(self._win_id, mode, 'aborted')) - modeman.enter(self._win_id, mode, 'question asked') - return True - - def _display_question_yesno(self, prompt): - """Display a yes/no question.""" - if self._question.default is None: - suffix = "" - elif self._question.default: - suffix = " (yes)" - else: - suffix = " (no)" - prompt.txt.setText(self._question.text + suffix) - prompt.lineedit.hide() - - def _display_question_input(self, prompt): - """Display a question with an input.""" - text = self._question.text - if self._question.mode == usertypes.PromptMode.download: - key_mode = self.KEY_MODES[self._question.mode] - key_config = objreg.get('key-config') - all_bindings = key_config.get_reverse_bindings_for(key_mode.name) - bindings = all_bindings.get('prompt-open-download', []) - if bindings: - text += ' ({} to open)'.format(bindings[0]) - prompt.txt.setText(text) - if self._question.default: - prompt.lineedit.setText(self._question.default) - prompt.lineedit.show() - - def _display_question_alert(self, prompt): - """Display a JS alert 'question'.""" - prompt.txt.setText(self._question.text + ' (ok)') - prompt.lineedit.hide() - - def _display_question(self): - """Display the question saved in self._question.""" - prompt = objreg.get('prompt', scope='window', window=self._win_id) - handlers = { - usertypes.PromptMode.yesno: self._display_question_yesno, - usertypes.PromptMode.text: self._display_question_input, - usertypes.PromptMode.user_pwd: self._display_question_input, - usertypes.PromptMode.download: self._display_question_input, - usertypes.PromptMode.alert: self._display_question_alert, - } - handler = handlers[self._question.mode] - handler(prompt) - log.modes.debug("Question asked, focusing {!r}".format( - prompt.lineedit)) - prompt.lineedit.setFocus() - self.show_prompt.emit() - self._busy = True - - def shutdown(self): - """Cancel all blocking questions. - - Quits and removes all running event loops. - - Return: - True if loops needed to be aborted, - False otherwise. - """ - self._shutting_down = True - if self._loops: - for loop in self._loops: - loop.quit() - loop.deleteLater() - return True - else: - return False - - @pyqtSlot(usertypes.KeyMode) - def on_mode_left(self, mode): - """Clear and reset input when the mode was left.""" - prompt = objreg.get('prompt', scope='window', window=self._win_id) - if mode in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: - prompt.txt.setText('') - prompt.lineedit.clear() - prompt.lineedit.setEchoMode(QLineEdit.Normal) - self.hide_prompt.emit() - self._busy = False - if self._question.answer is None and not self._question.is_aborted: - self._question.cancel() - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.prompt, - usertypes.KeyMode.yesno]) - def prompt_accept(self, value=None): - """Accept the current prompt. - - // - - This executes the next action depending on the question mode, e.g. asks - for the password or leaves the mode. - - Args: - value: If given, uses this value instead of the entered one. - For boolean prompts, "yes"/"no" are accepted as value. - """ - prompt = objreg.get('prompt', scope='window', window=self._win_id) - text = value if value is not None else prompt.lineedit.text() - - if (self._question.mode == usertypes.PromptMode.user_pwd and - self._question.user is None): - # User just entered a username - self._question.user = text - prompt.txt.setText("Password:") - prompt.lineedit.clear() - prompt.lineedit.setEchoMode(QLineEdit.Password) - elif self._question.mode == usertypes.PromptMode.user_pwd: - # User just entered a password - self._question.answer = AuthTuple(self._question.user, text) - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'prompt accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.text: - # User just entered text. - self._question.answer = text - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'prompt accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.download: - # User just entered a path for a download. - target = usertypes.FileDownloadTarget(text) - self._question.answer = target - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'prompt accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.yesno: - # User wants to accept the default of a yes/no question. - if value is None: - self._question.answer = self._question.default - elif value == 'yes': - self._question.answer = True - elif value == 'no': - self._question.answer = False - else: - raise cmdexc.CommandError("Invalid value {} - expected " - "yes/no!".format(value)) - modeman.maybe_leave(self._win_id, usertypes.KeyMode.yesno, - 'yesno accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.alert: - if value is not None: - raise cmdexc.CommandError("No value is permitted with alert " - "prompts!") - # User acknowledged an alert - self._question.answer = None - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'alert accept') - self._question.done() - else: - raise ValueError("Invalid question mode!") - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.yesno], - deprecated='Use :prompt-accept yes instead!') - def prompt_yes(self): - """Answer yes to a yes/no prompt.""" - if self._question.mode != usertypes.PromptMode.yesno: - # We just ignore this if we don't have a yes/no question. - return - self._question.answer = True - modeman.maybe_leave(self._win_id, usertypes.KeyMode.yesno, - 'yesno accept') - self._question.done() - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.yesno], - deprecated='Use :prompt-accept no instead!') - def prompt_no(self): - """Answer no to a yes/no prompt.""" - if self._question.mode != usertypes.PromptMode.yesno: - # We just ignore this if we don't have a yes/no question. - return - self._question.answer = False - modeman.maybe_leave(self._win_id, usertypes.KeyMode.yesno, - 'prompt accept') - self._question.done() - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.prompt], maxsplit=0) - def prompt_open_download(self, cmdline: str=None): - """Immediately open a download. - - If no specific command is given, this will use the system's default - application to open the file. - - Args: - cmdline: The command which should be used to open the file. A `{}` - is expanded to the temporary file name. If no `{}` is - present, the filename is automatically appended to the - cmdline. - """ - if self._question.mode != usertypes.PromptMode.download: - # We just ignore this if we don't have a download question. - return - self._question.answer = usertypes.OpenFileDownloadTarget(cmdline) - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'download open') - self._question.done() - - @pyqtSlot(usertypes.Question, bool) - def ask_question(self, question, blocking): - """Display a question in the statusbar. - - Args: - question: The Question object to ask. - blocking: If True, this function blocks and returns the result. - - Return: - The answer of the user when blocking=True. - None if blocking=False. - """ - log.statusbar.debug("Asking question {}, blocking {}, loops {}, queue " - "{}".format(question, blocking, self._loops, - self._queue)) - - if self._shutting_down: - # If we're currently shutting down we have to ignore this question - # to avoid segfaults - see - # https://github.com/The-Compiler/qutebrowser/issues/95 - log.statusbar.debug("Ignoring question because we're shutting " - "down.") - question.abort() - return None - - if self._busy and not blocking: - # We got an async question, but we're already busy with one, so we - # just queue it up for later. - log.statusbar.debug("Adding {} to queue.".format(question)) - self._queue.append(question) - return - - if blocking: - # If we're blocking we save the old state on the stack, so we can - # restore it after exec, if exec gets called multiple times. - context = self._get_ctx() - - self._question = question - self._display_question() - mode = self.KEY_MODES[self._question.mode] - question.aborted.connect( - lambda: modeman.maybe_leave(self._win_id, mode, 'aborted')) - modeman.enter(self._win_id, mode, 'question asked') - if blocking: - loop = qtutils.EventLoop() - self._loops.append(loop) - loop.destroyed.connect(lambda: self._loops.remove(loop)) - question.completed.connect(loop.quit) - question.completed.connect(loop.deleteLater) - loop.exec_() - if not self._restore_ctx(context): - # Nothing left to restore, so we can go back to popping async - # questions. - if self._queue: - self._pop_later() - return self._question.answer - else: - question.completed.connect(self._pop_later) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 667edeb1c..c66712eb9 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -45,6 +45,19 @@ class KeyHintView(QLabel): update_geometry: Emitted when this widget should be resized/positioned. """ + STYLESHEET = """ + QLabel { + font: {{ font['keyhint'] }}; + color: {{ color['keyhint.fg'] }}; + background-color: {{ color['keyhint.bg'] }}; + padding: 6px; + {% if config.get('ui', 'status-position') == 'top' %} + border-bottom-right-radius: 6px; + {% else %} + border-top-right-radius: 6px; + {% endif %} + } + """ update_geometry = pyqtSignal() def __init__(self, win_id, parent=None): @@ -56,8 +69,7 @@ class KeyHintView(QLabel): self._show_timer = usertypes.Timer(self, 'keyhint_show') self._show_timer.setInterval(500) self._show_timer.timeout.connect(self.show) - style.set_register_stylesheet(self, - generator=self._generate_stylesheet) + style.set_register_stylesheet(self) def __repr__(self): return utils.get_repr(self, win_id=self._win_id) @@ -67,22 +79,6 @@ class KeyHintView(QLabel): self.update_geometry.emit() super().showEvent(e) - def _generate_stylesheet(self): - """Generate a stylesheet with the right edge rounded.""" - stylesheet = """ - QLabel { - font: {{ font['keyhint'] }}; - color: {{ color['keyhint.fg'] }}; - background-color: {{ color['keyhint.bg'] }}; - padding: 6px; - border-EDGE-radius: 6px; - } - """ - if config.get('ui', 'status-position') == 'top': - return stylesheet.replace('EDGE', 'bottom-right') - else: - return stylesheet.replace('EDGE', 'top-right') - @pyqtSlot(str) def update_keyhint(self, modename, prefix): """Show hints for the given prefix (or hide if prefix is empty). diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 924929707..1a3db19be 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -94,7 +94,7 @@ LOGGER_NAMES = [ 'commands', 'signals', 'downloads', 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', 'save', 'message', 'config', 'sessions', - 'webelem' + 'webelem', 'prompt' ] @@ -139,6 +139,7 @@ message = logging.getLogger('message') config = logging.getLogger('config') sessions = logging.getLogger('sessions') webelem = logging.getLogger('webelem') +prompt = logging.getLogger('prompt') ram_handler = None @@ -408,6 +409,8 @@ def qt_message_handler(msg_type, context, msg): "QXcbClipboard: SelectionRequest too old", # https://github.com/The-Compiler/qutebrowser/issues/2071 'QXcbWindow: Unhandled client message: ""', + # No idea where this comes from... + "QObject::disconnect: Unexpected null parameter", ] if sys.platform == 'darwin': suppressed_msgs += [ diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index 43c820ad1..368bb8289 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -26,7 +26,7 @@ import traceback from PyQt5.QtCore import pyqtSignal, QObject -from qutebrowser.utils import usertypes, log, objreg, utils +from qutebrowser.utils import usertypes, log, utils def _log_stack(typ, stack): @@ -76,70 +76,83 @@ def info(message): global_bridge.show_message.emit(usertypes.MessageLevel.info, message) -def ask(win_id, message, mode, default=None): +def _build_question(title, text=None, *, mode, default=None, abort_on=()): + """Common function for ask/ask_async.""" + if not isinstance(mode, usertypes.PromptMode): + raise TypeError("Mode {} is no PromptMode member!".format(mode)) + question = usertypes.Question() + question.title = title + question.text = text + question.mode = mode + question.default = default + for sig in abort_on: + sig.connect(question.abort) + return question + + +def ask(*args, **kwargs): """Ask a modular question in the statusbar (blocking). Args: - win_id: The ID of the window which is calling this function. message: The message to display to the user. mode: A PromptMode. default: The default value to display. + text: Additional text to show + abort_on: A list of signals which abort the question if emitted. Return: The answer the user gave or None if the prompt was cancelled. """ - q = usertypes.Question() - q.text = message - q.mode = mode - q.default = default - bridge = objreg.get('message-bridge', scope='window', window=win_id) - bridge.ask(q, blocking=True) - q.deleteLater() - return q.answer + question = _build_question(*args, **kwargs) # pylint: disable=missing-kwoa + global_bridge.ask(question, blocking=True) + answer = question.answer + question.deleteLater() + return answer -def ask_async(win_id, message, mode, handler, default=None): +def ask_async(title, mode, handler, **kwargs): """Ask an async question in the statusbar. Args: - win_id: The ID of the window which is calling this function. message: The message to display to the user. mode: A PromptMode. handler: The function to get called with the answer as argument. default: The default value to display. + text: Additional text to show. """ - if not isinstance(mode, usertypes.PromptMode): - raise TypeError("Mode {} is no PromptMode member!".format(mode)) - q = usertypes.Question() - q.text = message - q.mode = mode - q.default = default - q.answered.connect(handler) - q.completed.connect(q.deleteLater) - bridge = objreg.get('message-bridge', scope='window', window=win_id) - bridge.ask(q, blocking=False) + question = _build_question(title, mode=mode, **kwargs) + question.answered.connect(handler) + question.completed.connect(question.deleteLater) + global_bridge.ask(question, blocking=False) -def confirm_async(win_id, message, yes_action, no_action=None, default=None): +def confirm_async(yes_action, no_action=None, cancel_action=None, + *args, **kwargs): """Ask a yes/no question to the user and execute the given actions. Args: - win_id: The ID of the window which is calling this function. message: The message to display to the user. yes_action: Callable to be called when the user answered yes. no_action: Callable to be called when the user answered no. + cancel_action: Callable to be called when the user cancelled the + question. default: True/False to set a default value, or None. + text: Additional text to show. + + Return: + The question object. """ - q = usertypes.Question() - q.text = message - q.mode = usertypes.PromptMode.yesno - q.default = default - q.answered_yes.connect(yes_action) + kwargs['mode'] = usertypes.PromptMode.yesno + question = _build_question(*args, **kwargs) # pylint: disable=missing-kwoa + question.answered_yes.connect(yes_action) if no_action is not None: - q.answered_no.connect(no_action) - q.completed.connect(q.deleteLater) - bridge = objreg.get('message-bridge', scope='window', window=win_id) - bridge.ask(q, blocking=False) + question.answered_no.connect(no_action) + if cancel_action is not None: + question.cancelled.connect(cancel_action) + + question.completed.connect(question.deleteLater) + global_bridge.ask(question, blocking=False) + return question class GlobalMessageBridge(QObject): @@ -150,9 +163,34 @@ class GlobalMessageBridge(QObject): show_message: Show a message arg 0: A MessageLevel member arg 1: The text to show + prompt_done: Emitted when a prompt was answered somewhere. + ask_question: Ask a question to the user. + arg 0: The Question object to ask. + arg 1: Whether to block (True) or ask async (False). + + IMPORTANT: Slots need to be connected to this signal via + a Qt.DirectConnection! + mode_left: Emitted when a keymode was left in any window. """ show_message = pyqtSignal(usertypes.MessageLevel, str) + prompt_done = pyqtSignal(usertypes.KeyMode) + ask_question = pyqtSignal(usertypes.Question, bool) + mode_left = pyqtSignal(usertypes.KeyMode) + + def ask(self, question, blocking, *, log_stack=False): + """Ask a question to the user. + + Note this method doesn't return the answer, it only blocks. The caller + needs to construct a Question object and get the answer. + + Args: + question: A Question object. + blocking: Whether to return immediately or wait until the + question is answered. + log_stack: ignored + """ + self.ask_question.emit(question, blocking) class MessageBridge(QObject): @@ -164,36 +202,14 @@ class MessageBridge(QObject): arg: The text to set. s_maybe_reset_text: Reset the text if it hasn't been changed yet. arg: The expected text. - s_set_cmd_text: Pre-set a text for the commandline prompt. - arg: The text to set. - - s_question: Ask a question to the user in the statusbar. - arg 0: The Question object to ask. - arg 1: Whether to block (True) or ask async (False). - - IMPORTANT: Slots need to be connected to this signal via a - Qt.DirectConnection! """ s_set_text = pyqtSignal(str) s_maybe_reset_text = pyqtSignal(str) - s_set_cmd_text = pyqtSignal(str) - s_question = pyqtSignal(usertypes.Question, bool) def __repr__(self): return utils.get_repr(self) - def set_cmd_text(self, text, *, log_stack=False): - """Set the command text of the statusbar. - - Args: - text: The text to set. - log_stack: ignored - """ - text = str(text) - log.message.debug(text) - self.s_set_cmd_text.emit(text) - def set_text(self, text, *, log_stack=False): """Set the normal text of the statusbar. @@ -214,19 +230,5 @@ class MessageBridge(QObject): """ self.s_maybe_reset_text.emit(str(text)) - def ask(self, question, blocking, *, log_stack=False): - """Ask a question to the user. - - Note this method doesn't return the answer, it only blocks. The caller - needs to construct a Question object and get the answer. - - Args: - question: A Question object. - blocking: Whether to return immediately or wait until the - question is answered. - log_stack: ignored - """ - self.s_question.emit(question, blocking) - global_bridge = GlobalMessageBridge() diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 6dd3d9674..4e67a2eac 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -335,10 +335,11 @@ class Question(QObject): For yesno, None (no default), True or False. For text, a default text as string. For user_pwd, a default username as string. + title: The question title to show. text: The prompt text to display to the user. - user: The value the user entered as username. answer: The value the user entered (as password for user_pwd). is_aborted: Whether the question was aborted. + interrupted: Whether the question was interrupted by another one. Signals: answered: Emitted when the question has been answered by the user. @@ -364,14 +365,15 @@ class Question(QObject): super().__init__(parent) self._mode = None self.default = None + self.title = None self.text = None - self.user = None self.answer = None self.is_aborted = False + self.interrupted = False def __repr__(self): - return utils.get_repr(self, text=self.text, mode=self._mode, - default=self.default) + return utils.get_repr(self, title=self.title, text=self.text, + mode=self._mode, default=self.default) @property def mode(self): @@ -405,6 +407,9 @@ class Question(QObject): @pyqtSlot() def abort(self): """Abort the question.""" + if self.is_aborted: + log.misc.debug("Question was already aborted") + return self.is_aborted = True try: self.aborted.emit() diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index e9bb675b0..698e04b79 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -115,8 +115,6 @@ PERFECT_FILES = [ 'qutebrowser/mainwindow/statusbar/tabindex.py'), ('tests/unit/mainwindow/statusbar/test_textbase.py', 'qutebrowser/mainwindow/statusbar/textbase.py'), - ('tests/unit/mainwindow/statusbar/test_prompt.py', - 'qutebrowser/mainwindow/statusbar/prompt.py'), ('tests/unit/mainwindow/statusbar/test_url.py', 'qutebrowser/mainwindow/statusbar/url.py'), ('tests/unit/mainwindow/test_messageview.py', diff --git a/tests/end2end/data/prompt/jsprompt.html b/tests/end2end/data/prompt/jsprompt.html index d8c848553..4279fc075 100644 --- a/tests/end2end/data/prompt/jsprompt.html +++ b/tests/end2end/data/prompt/jsprompt.html @@ -3,13 +3,14 @@ - + + diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 3eb4daf0d..9b7a9e2d5 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -188,19 +188,21 @@ def open_path(quteproc, path): do_not_wait_suffix = ' without waiting' as_url_suffix = ' as a URL' - if path.endswith(new_tab_suffix): - path = path[:-len(new_tab_suffix)] - new_tab = True - elif path.endswith(new_window_suffix): - path = path[:-len(new_window_suffix)] - new_window = True - elif path.endswith(as_url_suffix): - path = path[:-len(as_url_suffix)] - as_url = True - - if path.endswith(do_not_wait_suffix): - path = path[:-len(do_not_wait_suffix)] - wait = False + while True: + if path.endswith(new_tab_suffix): + path = path[:-len(new_tab_suffix)] + new_tab = True + elif path.endswith(new_window_suffix): + path = path[:-len(new_window_suffix)] + new_window = True + elif path.endswith(as_url_suffix): + path = path[:-len(as_url_suffix)] + as_url = True + elif path.endswith(do_not_wait_suffix): + path = path[:-len(do_not_wait_suffix)] + wait = False + else: + break quteproc.open_path(path, new_tab=new_tab, new_window=new_window, as_url=as_url, wait=wait) diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 18a65dfbc..9a868cd57 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -63,7 +63,7 @@ Feature: Downloading things from a website. And I set storage -> prompt-download-directory to true And I open data/downloads/issue1243.html And I hint with args "links download" and follow a - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log Then the error "Download error: No handler found for qute://!" should be shown Scenario: Downloading a data: link (issue 1214) @@ -71,7 +71,7 @@ Feature: Downloading things from a website. And I set storage -> prompt-download-directory to true And I open data/downloads/issue1214.html And I hint with args "links download" and follow a - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen @@ -338,7 +338,7 @@ Feature: Downloading things from a website. When I set storage -> prompt-download-directory to true And I open data/downloads/issue1725.html And I run :click-element id long-link - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log And I directly open the download And I wait until the download is finished Then "Opening * with [*python*]" should be logged @@ -484,3 +484,15 @@ Feature: Downloading things from a website. And I run :click-element id download And I wait until the download is finished Then the downloaded file test.pdf should exist + + Scenario: Answering a question for a cancelled download (#415) + When I set storage -> prompt-download-directory to true + And I run :download http://localhost:(port)/data/downloads/download.bin + And I wait for "Asking question text=* title='Save file to:'>, *" in the log + And I run :download http://localhost:(port)/data/downloads/download2.bin + And I wait for "Asking question text=* title='Save file to:'>, *" in the log + And I run :download-cancel with count 2 + And I run :prompt-accept + And I wait until the download is finished + Then the downloaded file download.bin should exist + And the downloaded file download2.bin should not exist diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 50a436680..46e4dc287 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -324,7 +324,7 @@ Feature: Various utility commands. And I open data/misc/test.pdf And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log And I run :jseval document.getElementById("download").click() - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature index 05324061f..fe39a90be 100644 --- a/tests/end2end/features/prompts.feature +++ b/tests/end2end/features/prompts.feature @@ -49,6 +49,14 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: prompt test" should be logged + @pyqt>=5.3.1 + Scenario: Javascript prompt with default + When I open data/prompt/jsprompt.html + And I run :click-element id button-default + And I wait for a prompt + And I run :prompt-accept + Then the javascript message "Prompt reply: default" should be logged + @pyqt>=5.3.1 Scenario: Rejected javascript prompt When I open data/prompt/jsprompt.html @@ -58,6 +66,68 @@ Feature: Prompts And I run :leave-mode Then the javascript message "Prompt reply: null" should be logged + # Multiple prompts + + Scenario: Blocking question interrupted by blocking one + When I set content -> ignore-javascript-alert to false + And I open data/prompt/jsalert.html + And I run :click-element id button + And I wait for a prompt + And I open data/prompt/jsconfirm.html in a new tab + And I run :click-element id button + And I wait for a prompt + # JS confirm + And I run :prompt-accept yes + # JS alert + And I run :prompt-accept + Then the javascript message "confirm reply: true" should be logged + And the javascript message "Alert done" should be logged + + Scenario: Blocking question interrupted by async one + When I set content -> ignore-javascript-alert to false + And I set content -> notifications to ask + And I open data/prompt/jsalert.html + And I run :click-element id button + And I wait for a prompt + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + # JS alert + And I run :prompt-accept + # notification permission + And I run :prompt-accept yes + Then the javascript message "Alert done" should be logged + And the javascript message "notification permission granted" should be logged + + Scenario: Async question interrupted by async one + When I set content -> notifications to ask + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + And I run :quickmark-save + And I wait for a prompt + # notification permission + And I run :prompt-accept yes + # quickmark + And I run :prompt-accept test + Then the javascript message "notification permission granted" should be logged + And "Added quickmark test for *" should be logged + + Scenario: Async question interrupted by blocking one + When I set content -> notifications to ask + And I set content -> ignore-javascript-alert to false + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + And I open data/prompt/jsalert.html in a new tab + And I run :click-element id button + And I wait for a prompt + # JS alert + And I run :prompt-accept + # notification permission + And I run :prompt-accept yes + Then the javascript message "Alert done" should be logged + And the javascript message "notification permission granted" should be logged # Shift-Insert with prompt (issue 1299) @@ -72,6 +142,17 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: insert test" should be logged + @pyqt>=5.3.1 + Scenario: Pasting via shift-insert without it being supported + When selection is not supported + And I put "insert test" into the primary selection + And I open data/prompt/jsprompt.html + And I run :click-element id button + And I wait for a prompt + And I press the keys "" + And I run :prompt-accept + Then the javascript message "Prompt reply: " should be logged + @pyqt>=5.3.1 Scenario: Using content -> ignore-javascript-prompt When I set content -> ignore-javascript-prompt to true @@ -219,6 +300,44 @@ Feature: Prompts "user": "user" } + Scenario: Authentication with :prompt-accept value + When I open about:blank in a new tab + And I open basic-auth/user/password without waiting + And I wait for a prompt + And I run :prompt-accept user:password + And I wait until basic-auth/user/password is loaded + Then the json on the page should be: + { + "authenticated": true, + "user": "user" + } + + Scenario: Authentication with invalid :prompt-accept value + When I open about:blank in a new tab + And I open basic-auth/user/password without waiting + And I wait for a prompt + And I run :prompt-accept foo + And I run :prompt-accept user:password + Then the error "Value needs to be in the format username:password, but foo was given" should be shown + + Scenario: Tabbing between username and password + When I open about:blank in a new tab + And I open basic-auth/user/password without waiting + And I wait for a prompt + And I press the keys "us" + And I run :prompt-item-focus next + And I press the keys "password" + And I run :prompt-item-focus prev + And I press the keys "er" + And I run :prompt-accept + And I run :prompt-accept + And I wait until basic-auth/user/password is loaded + Then the json on the page should be: + { + "authenticated": true, + "user": "user" + } + # :prompt-accept with value argument Scenario: Javascript alert with value @@ -249,3 +368,102 @@ Feature: Prompts And I run :prompt-accept yes Then the javascript message "confirm reply: true" should be logged And the error "Invalid value nope - expected yes/no!" should be shown + + Scenario: Javascript confirm with default value + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :prompt-accept + And I run :prompt-accept yes + Then the javascript message "confirm reply: true" should be logged + And the error "No default value was set for this question!" should be shown + + Scenario: Javascript confirm with deprecated :prompt-yes command + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :prompt-yes + Then the javascript message "confirm reply: true" should be logged + And the warning "prompt-yes is deprecated - Use :prompt-accept yes instead!" should be shown + + Scenario: Javascript confirm with deprecated :prompt-no command + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :prompt-no + Then the javascript message "confirm reply: false" should be logged + And the warning "prompt-no is deprecated - Use :prompt-accept no instead!" should be shown + + # Other + + Scenario: Shutting down with a question + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :quit + Then the javascript message "confirm reply: false" should be logged + And qutebrowser should quit + + Scenario: Using :prompt-open-download with a prompt which does not support it + When I open data/hello.txt + And I run :quickmark-save + And I wait for a prompt + And I run :prompt-open-download + And I run :prompt-accept test-prompt-open-download + Then "Added quickmark test-prompt-open-download for *" should be logged + + Scenario: Using :prompt-item-focus with a prompt which does not support it + When I open data/hello.txt + And I run :quickmark-save + And I wait for a prompt + And I run :prompt-item-focus next + And I run :prompt-accept test-prompt-item-focus + Then "Added quickmark test-prompt-item-focus for *" should be logged + + Scenario: Getting question in command mode + When I open data/hello.txt + And I run :later 500 quickmark-save + And I run :set-cmd-text : + And I wait for a prompt + And I run :prompt-accept prompt-in-command-mode + Then "Added quickmark prompt-in-command-mode for *" should be logged + + # https://github.com/The-Compiler/qutebrowser/issues/1093 + Scenario: Keyboard focus with multiple auth prompts + When I open basic-auth/user1/password1 without waiting + And I open basic-auth/user2/password2 in a new tab without waiting + And I wait for a prompt + And I wait for a prompt + # Second prompt (showed first) + And I press the keys "user2" + And I press the key "" + And I press the keys "password2" + And I press the key "" + And I wait until basic-auth/user2/password2 is loaded + # First prompt + And I press the keys "user1" + And I press the key "" + And I press the keys "password1" + And I press the key "" + And I wait until basic-auth/user1/password1 is loaded + # We're on the second page + Then the json on the page should be: + { + "authenticated": true, + "user": "user2" + } + + # https://github.com/The-Compiler/qutebrowser/issues/1249#issuecomment-175205531 + # https://github.com/The-Compiler/qutebrowser/pull/2054#issuecomment-258285544 + Scenario: Interrupting SSL prompt during a notification prompt + When I set content -> notifications to ask + And I set network -> ssl-strict to ask + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + And I open about:blank in a new tab + And I load an SSL page + And I wait for a prompt + And I run :tab-close + And I run :prompt-accept yes + Then the javascript message "notification permission granted" should be logged diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index 3ce6790bb..ac82aa04f 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -31,8 +31,8 @@ pytestmark = pytest.mark.qtwebengine_todo("Downloads not implemented yet", PROMPT_MSG = ("Asking question " - "text='Save file to:'>, *") + "default={!r} mode= text=* " + "title='Save file to:'>, *") @bdd.given("I set up a temporary download dir") diff --git a/tests/end2end/features/test_prompts_bdd.py b/tests/end2end/features/test_prompts_bdd.py index d98acab9a..8a66880b7 100644 --- a/tests/end2end/features/test_prompts_bdd.py +++ b/tests/end2end/features/test_prompts_bdd.py @@ -39,8 +39,7 @@ def wait_ssl_page_finished_loading(quteproc, ssl_server): @bdd.when("I wait for a prompt") def wait_for_prompt(quteproc): - quteproc.wait_for(message='Entering mode KeyMode.* (reason: question ' - 'asked)') + quteproc.wait_for(message='Asking question *') @bdd.then("no prompt should be shown") diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 313884118..2c5d23e65 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -67,12 +67,12 @@ class Request(testprocess.Line): '/favicon.ico': [http.client.NOT_FOUND], '/does-not-exist': [http.client.NOT_FOUND], '/does-not-exist-2': [http.client.NOT_FOUND], - '/basic-auth/user/password': - [http.client.UNAUTHORIZED, http.client.OK], + '/status/404': [http.client.NOT_FOUND], + '/custom/redirect-later': [http.client.FOUND], '/custom/redirect-self': [http.client.FOUND], '/redirect-to': [http.client.FOUND], - '/status/404': [http.client.NOT_FOUND], + '/cookies/set': [http.client.FOUND], } for i in range(15): @@ -81,6 +81,10 @@ class Request(testprocess.Line): http.client.FOUND] path_to_statuses['/absolute-redirect/{}'.format(i)] = [ http.client.FOUND] + for suffix in ['', '1', '2']: + key = '/basic-auth/user{}/password{}'.format(suffix, suffix) + path_to_statuses[key] = [http.client.UNAUTHORIZED, http.client.OK] + default_statuses = [http.client.OK, http.client.NOT_MODIFIED] sanitized = QUrl('http://localhost' + self.path).path() # Remove ?foo diff --git a/tests/manual/js/jsprompt.html b/tests/manual/js/jsprompt.html index 782dc1622..b767fdd2f 100644 --- a/tests/manual/js/jsprompt.html +++ b/tests/manual/js/jsprompt.html @@ -1,8 +1,8 @@ diff --git a/tests/unit/config/test_style.py b/tests/unit/config/test_style.py index bcb8a6b28..6b55aebae 100644 --- a/tests/unit/config/test_style.py +++ b/tests/unit/config/test_style.py @@ -59,24 +59,6 @@ class Obj(QObject): self.rendered_stylesheet = stylesheet -class GeneratedObj(QObject): - - def __init__(self, parent=None): - super().__init__(parent) - self.rendered_stylesheet = None - self._generated = False - - def setStyleSheet(self, stylesheet): - self.rendered_stylesheet = stylesheet - - def generate(self): - if not self._generated: - self._generated = True - return 'one' - else: - return 'two' - - @pytest.mark.parametrize('delete', [True, False]) def test_set_register_stylesheet(delete, qtbot, config_stub, caplog): config_stub.data = {'fonts': {'foo': 'bar'}, 'colors': {}} @@ -105,15 +87,6 @@ def test_set_register_stylesheet(delete, qtbot, config_stub, caplog): assert obj.rendered_stylesheet == expected -def test_set_register_stylesheet_generator(qtbot, config_stub): - config_stub.data = {'fonts': {}, 'colors': {}} - obj = GeneratedObj() - style.set_register_stylesheet(obj, generator=obj.generate) - assert obj.rendered_stylesheet == 'one' - config_stub.changed.emit('foo', 'bar') - assert obj.rendered_stylesheet == 'two' - - class TestColorDict: @pytest.mark.parametrize('key, expected', [ diff --git a/tests/unit/mainwindow/statusbar/test_prompt.py b/tests/unit/mainwindow/statusbar/test_prompt.py deleted file mode 100644 index 86c6122b1..000000000 --- a/tests/unit/mainwindow/statusbar/test_prompt.py +++ /dev/null @@ -1,57 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2016 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 . - -"""Test Prompt widget.""" - -import sip - -import pytest - -from qutebrowser.mainwindow.statusbar.prompt import Prompt -from qutebrowser.utils import objreg - - -@pytest.fixture -def prompt(qtbot, win_registry): - prompt = Prompt(0) - qtbot.addWidget(prompt) - - yield prompt - - # If we don't clean up here, this test will remove 'prompter' from the - # objreg at some point in the future, which will cause some other test to - # fail. - sip.delete(prompt) - - -def test_prompt(prompt): - prompt.show() - objreg.get('prompt', scope='window', window=0) - objreg.get('prompter', scope='window', window=0) - - -@pytest.mark.xfail(reason="This test is broken and I don't get why") -def test_resizing(fake_statusbar, prompt): - fake_statusbar.hbox.addWidget(prompt) - - prompt.txt.setText("Blah?") - old_width = prompt.lineedit.width() - - prompt.lineedit.setText("Hello World" * 100) - assert prompt.lineedit.width() > old_width diff --git a/tests/unit/mainwindow/test_prompt.py b/tests/unit/mainwindow/test_prompt.py new file mode 100644 index 000000000..1288ccd3f --- /dev/null +++ b/tests/unit/mainwindow/test_prompt.py @@ -0,0 +1,93 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 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 . + +import os + +import pytest +from PyQt5.QtCore import Qt + +from qutebrowser.mainwindow import prompt as promptmod +from qutebrowser.utils import usertypes + + +@pytest.fixture(autouse=True) +def setup(qapp, key_config_stub): + key_config_stub.set_bindings_for('prompt', {}) + + +class TestFileCompletion: + + @pytest.fixture + def get_prompt(self, qtbot): + """Get a function to display a prompt with a path.""" + def _get_prompt_func(path): + question = usertypes.Question() + question.title = "test" + question.default = path + + prompt = promptmod.DownloadFilenamePrompt(question) + qtbot.add_widget(prompt) + with qtbot.wait_signal(prompt._file_model.directoryLoaded): + pass + assert prompt._lineedit.text() == path + + return prompt + return _get_prompt_func + + @pytest.mark.parametrize('steps, where, subfolder', [ + (1, 'next', '..'), + (1, 'prev', 'c'), + (2, 'next', 'a'), + (2, 'prev', 'b'), + ]) + def test_simple_completion(self, tmpdir, get_prompt, steps, where, + subfolder): + """Simply trying to tab through items.""" + for directory in 'abc': + (tmpdir / directory).ensure(dir=True) + + prompt = get_prompt(str(tmpdir) + os.sep) + + for _ in range(steps): + prompt.item_focus(where) + + assert prompt._lineedit.text() == str(tmpdir / subfolder) + + def test_backspacing_path(self, qtbot, tmpdir, get_prompt): + """When we start deleting a path we want to see the subdir.""" + for directory in ['bar', 'foo']: + (tmpdir / directory).ensure(dir=True) + + prompt = get_prompt(str(tmpdir / 'foo') + os.sep) + + # Deleting /f[oo/] + with qtbot.wait_signal(prompt._file_model.directoryLoaded): + for _ in range(3): + qtbot.keyPress(prompt._lineedit, Qt.Key_Backspace) + + # We should now show / again, so tabbing twice gives us .. -> bar + prompt.item_focus('next') + prompt.item_focus('next') + assert prompt._lineedit.text() == str(tmpdir / 'bar') + + @pytest.mark.linux + def test_root_path(self, get_prompt): + """With / as path, show root contents.""" + prompt = get_prompt('/') + assert prompt._file_model.rootPath() == '/' diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index 005cce1a6..a4093382c 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -50,10 +50,13 @@ def keyhint(qtbot, config_stub, key_config_stub): 'colors': { 'keyhint.fg': 'white', 'keyhint.fg.suffix': 'yellow', - 'keyhint.bg': 'black' + 'keyhint.bg': 'black', }, 'fonts': {'keyhint': 'Comic Sans'}, - 'ui': {'keyhint-blacklist': '', 'status-position': 'bottom'}, + 'ui': { + 'keyhint-blacklist': '', + 'status-position': 'bottom', + }, } keyhint = KeyHintView(0, None) qtbot.add_widget(keyhint)