First prototype of new prompts

This commit is contained in:
Florian Bruhin 2016-09-23 06:18:48 +02:00
parent ced618eccb
commit 903e31efa4
5 changed files with 273 additions and 189 deletions

View File

@ -30,7 +30,7 @@ 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
@ -179,10 +179,14 @@ class MainWindow(QWidget):
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)
self._promptcontainer = prompt.PromptContainer(self)
self._add_overlay(self._promptcontainer,
self._promptcontainer.update_geometry,
centered=True)
self._promptcontainer.hide()
log.init.debug("Initializing modes...")
modeman.init(self.win_id, self)
@ -206,33 +210,40 @@ class MainWindow(QWidget):
objreg.get("app").new_window.emit(self)
def _update_overlay_geometry(self, widget=None):
def _add_overlay(self, widget, signal, *, centered=False):
self._overlays.append((widget, signal, centered))
def _update_overlay_geometry(self, widget=None, centered=None):
"""Reposition/resize the given overlay.
If no widget is given, reposition/resize all overlays.
"""
if widget is None:
for w, _signal in self._overlays:
self._update_overlay_geometry(w)
for w, _signal, centered in self._overlays:
self._update_overlay_geometry(w, centered)
return
assert centered is not None
if not widget.isVisible():
return
size_hint = widget.sizeHint()
if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding:
width = self.width()
left = 0
else:
width = size_hint.width()
left = (self.width() - size_hint.width()) / 2 if centered else 0
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, 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)
@ -261,8 +272,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,
@ -350,10 +360,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 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))
self._update_overlay_geometry(widget, centered)
def _set_default_geometry(self):
"""Set some sensible default geometry."""
@ -374,7 +385,7 @@ 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')
#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(prompter.on_mode_left)
# commands
keyparsers[usertypes.KeyMode.normal].keystring_updated.connect(
@ -408,8 +419,8 @@ 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)
#message_bridge.s_question.connect(prompter.ask_question,
# Qt.DirectConnection)
# statusbar
tabs.current_tab_changed.connect(status.prog.on_tab_changed)

View File

@ -0,0 +1,215 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Showing prompts above the statusbar."""
import collections
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import QWidget, QGridLayout, QVBoxLayout, QLineEdit, QLabel, QSpacerItem
from qutebrowser.config import style, config
from qutebrowser.utils import usertypes
AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password'])
class Error(Exception):
"""Base class for errors in this module."""
class PromptContainer(QWidget):
update_geometry = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName('Prompt')
self.setAttribute(Qt.WA_StyledBackground, True)
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(10, 10, 10, 10)
style.set_register_stylesheet(self,
generator=self._generate_stylesheet)
def _generate_stylesheet(self):
"""Generate a stylesheet with the right edge rounded."""
stylesheet = """
QWidget#Prompt {
border-POSITION-left-radius: 10px;
border-POSITION-right-radius: 10px;
}
QWidget {
/* FIXME
font: {{ font['keyhint'] }};
FIXME
*/
color: {{ color['statusbar.fg.prompt'] }};
background-color: {{ color['statusbar.bg.prompt'] }};
}
QLineEdit {
border: 1px solid grey;
}
"""
position = config.get('ui', 'status-position')
if position == 'top':
return stylesheet.replace('POSITION', 'bottom')
elif position == 'bottom':
return stylesheet.replace('POSITION', 'top')
else:
raise ValueError("Invalid position {}!".format(position))
def _show_prompt(self, prompt):
while True:
# FIXME do we really want to delete children?
child = self._layout.takeAt(0)
if child is None:
break
child.deleteLater()
self._layout.addWidget(prompt)
self.update_geometry.emit()
class _BasePrompt(QWidget):
"""Base class for all prompts."""
def __init__(self, question, parent=None):
super().__init__(parent)
self._question = question
self._layout = QGridLayout(self)
self._layout.setVerticalSpacing(15)
def _init_title(self, title, *, span=1):
label = QLabel('<b>{}</b>'.format(title), self)
self._layout.addWidget(label, 0, 0, 1, span)
def accept(self, value=None):
raise NotImplementedError
class LineEditPrompt(_BasePrompt):
def __init__(self, question, parent=None):
super().__init__(parent)
self._lineedit = QLineEdit(self)
self._layout.addWidget(self._lineedit, 1, 0)
self._init_title(question.text)
if question.default:
self._lineedit.setText(question.default)
def accept(self, value=None):
text = value if value is not None else self._lineedit.text()
self._question.answer = text
class DownloadFilenamePrompt(LineEditPrompt):
# FIXME have a FilenamePrompt
def __init__(self, question, parent=None):
super().__init__(question, parent)
# FIXME show :prompt-open-download keybinding
# 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])
def accept(self, value=None):
text = value if value is not None else self._lineedit.text()
self._question.answer = usertypes.FileDownloadTarget(text)
class AuthenticationPrompt(_BasePrompt):
def __init__(self, question, parent=None):
super().__init__(question, parent)
self._init_title(question.text, span=2)
user_label = QLabel("Username:", self)
self._user_lineedit = QLineEdit(self)
password_label = QLabel("Password:", self)
self._password_lineedit = QLineEdit(self)
self._password_lineedit.setEchoMode(QLineEdit.Password)
self._layout.addWidget(user_label, 1, 0)
self._layout.addWidget(self._user_lineedit, 1, 1)
self._layout.addWidget(password_label, 2, 0)
self._layout.addWidget(self._password_lineedit, 2, 1)
assert not question.default, question.default
spacer = QSpacerItem(0, 10)
self._layout.addItem(spacer, 3, 0)
help_1 = QLabel("<b>Accept:</b> Enter")
help_2 = QLabel("<b>Abort:</b> Escape")
self._layout.addWidget(help_1, 4, 0)
self._layout.addWidget(help_2, 5, 0)
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)
else:
self._question.answer = AuthTuple(self._user_lineedit.text(),
self._password_lineedit.text())
class YesNoPrompt(_BasePrompt):
def __init__(self, question, parent=None):
super().__init__(question, parent)
self._init_title(question.text)
# FIXME
# "Enter/y: yes"
# "n: no"
# (depending on default)
def accept(self, value=None):
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 Error("Invalid value {} - expected yes/no!".format(value))
class AlertPrompt(_BasePrompt):
def __init__(self, question, parent=None):
super().__init__(question, parent)
self._init_title(question.text)
# FIXME
# Enter: acknowledge
def accept(self, value=None):
if value is not None:
raise Error("No value is permitted with alert prompts!")
# Doing nothing otherwise

View File

@ -162,16 +162,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)
@ -285,21 +278,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."""

View File

@ -30,12 +30,6 @@ 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.
@ -67,12 +61,8 @@ class Prompter(QObject):
_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.
_prompt: The current prompt object if we're handling 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 = {
@ -83,22 +73,19 @@ class Prompter(QObject):
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._prompt = None
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)
prompt=self._prompt)
def _pop_later(self):
"""Helper to call self._pop as soon as everything else is done."""
@ -115,78 +102,31 @@ class Prompter(QObject):
# 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.
def _restore_prompt(self, prompt):
"""Restore an old prompt which was interrupted.
Args:
ctx: A PromptContext previously saved by _get_ctx, or None.
prompt: A Prompt object or None.
Return: True if a context was restored, False otherwise.
Return: True if a prompt was restored, False otherwise.
"""
log.statusbar.debug("Restoring context {}".format(ctx))
if ctx is None:
self.hide_prompt.emit()
self._busy = False
log.statusbar.debug("Restoring prompt {}".format(prompt))
if prompt is None:
self._prompt.hide() # FIXME
self._prompt = None
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()
self._prompt = prompt
# FIXME do promptcintainer stuff here??
prompt.show()
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,
@ -199,8 +139,9 @@ class Prompter(QObject):
log.modes.debug("Question asked, focusing {!r}".format(
prompt.lineedit))
prompt.lineedit.setFocus()
self.show_prompt.emit()
self._busy = True
prompt.show()
# FIXME
self._prompt = prompt
def shutdown(self):
"""Cancel all blocking questions.
@ -228,8 +169,8 @@ class Prompter(QObject):
prompt.txt.setText('')
prompt.lineedit.clear()
prompt.lineedit.setEchoMode(QLineEdit.Normal)
self.hide_prompt.emit()
self._busy = False
self._prompt.hide() # FIXME
self._prompt = None
if self._question.answer is None and not self._question.is_aborted:
self._question.cancel()
@ -251,83 +192,24 @@ class Prompter(QObject):
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!")
self._prompt.accept(text)
modeman.maybe_leave(self._win_id, self._question.mode,
':prompt-accept')
self._question.done()
@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()
self.prompt_accept('yes')
@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()
self.prompt_accept('no')
@cmdutils.register(instance='prompter', hide=True, scope='window',
modes=[usertypes.KeyMode.prompt], maxsplit=0)
@ -376,7 +258,7 @@ class Prompter(QObject):
question.abort()
return None
if self._busy and not blocking:
if self._prompt 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.statusbar.debug("Adding {} to queue.".format(question))
@ -386,7 +268,7 @@ class Prompter(QObject):
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()
old_prompt = self._prompt
self._question = question
self._display_question()
@ -401,7 +283,7 @@ class Prompter(QObject):
question.completed.connect(loop.quit)
question.completed.connect(loop.deleteLater)
loop.exec_()
if not self._restore_ctx(context):
if not self._restore_prompt(old_prompt):
# Nothing left to restore, so we can go back to popping async
# questions.
if self._queue:

View File

@ -336,7 +336,6 @@ class Question(QObject):
For text, a default text as string.
For user_pwd, a default username as string.
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.
@ -365,7 +364,6 @@ class Question(QObject):
self._mode = None
self.default = None
self.text = None
self.user = None
self.answer = None
self.is_aborted = False