diff --git a/qutebrowser/browser/curcommand.py b/qutebrowser/browser/curcommand.py index 4ae3a9341..d1e122558 100644 --- a/qutebrowser/browser/curcommand.py +++ b/qutebrowser/browser/curcommand.py @@ -17,14 +17,21 @@ """The main tabbed browser widget.""" +import os +import logging +from tempfile import mkstemp +from functools import partial + from PyQt5.QtWidgets import QApplication -from PyQt5.QtCore import pyqtSlot, Qt, QObject +from PyQt5.QtCore import pyqtSlot, Qt, QObject, QProcess from PyQt5.QtGui import QClipboard from PyQt5.QtPrintSupport import QPrinter, QPrintDialog, QPrintPreviewDialog import qutebrowser.utils.url as urlutils import qutebrowser.utils.message as message import qutebrowser.commands.utils as cmdutils +import qutebrowser.utils.webelem as webelem +import qutebrowser.config.config as config class CurCommandDispatcher(QObject): @@ -331,3 +338,51 @@ class CurCommandDispatcher(QObject): """ tab = self._tabs.currentWidget() tab.zoom(-count) + + @cmdutils.register(instance='mainwindow.tabs.cur', modes=['insert'], + name='open_editor', hide=True) + def editor(self): + """Open an external editor with the current form field.""" + frame = self._tabs.currentWidget().page_.currentFrame() + elem = frame.findFirstElement(webelem.SELECTORS['editable_focused']) + if elem.isNull(): + message.error("No editable element focused!") + return + oshandle, filename = mkstemp(text=True) + text = elem.evaluateJavaScript('this.value') + if text: + with open(filename, 'w') as f: + f.write(text) + proc = QProcess(self) + proc.finished.connect(partial(self.on_editor_closed, elem, oshandle, + filename)) + editor = config.get('general', 'editor') + executable = editor[0] + args = [arg.replace('{}', filename) for arg in editor[1:]] + logging.debug("Calling \"{}\" with args {}".format(executable, args)) + proc.start(executable, args) + + def on_editor_closed(self, elem, oshandle, filename, exitcode, + exitstatus): + """Gets called by QProcess when the editor was closed. + + Writes the editor text into the form field. + """ + logging.debug("Editor closed") + if exitcode != 0 or exitstatus != QProcess.NormalExit: + message.error("Editor did quit abnormally (status {})!".format( + exitcode)) + return + if elem.isNull(): + message.error("Element vanished while editing!") + return + with open(filename, 'r') as f: + text = ''.join(f.readlines()) + text = webelem.javascript_escape(text) + logging.debug("Read back: {}".format(text)) + elem.evaluateJavaScript("this.value='{}'".format(text)) + os.close(oshandle) + try: + os.remove(filename) + except PermissionError: + message.error("Failed to delete tempfile...") diff --git a/qutebrowser/config/_conftypes.py b/qutebrowser/config/_conftypes.py index 64fe83e4a..d4c458591 100644 --- a/qutebrowser/config/_conftypes.py +++ b/qutebrowser/config/_conftypes.py @@ -17,6 +17,8 @@ """Setting options used for qutebrowser.""" +import shlex + from PyQt5.QtGui import QColor import qutebrowser.commands.utils as cmdutils @@ -168,6 +170,29 @@ class String(BaseType): self.maxlen)) +class ShellCommand(String): + + """A shellcommand which is split via shlex. + + Attributes: + placeholder: If there should be a placeholder. + """ + + typestr = 'shell-cmd' + + def __init__(self, placeholder=False): + self.placeholder = placeholder + super().__init__() + + def validate(self, value): + super().validate(value) + if self.placeholder and '{}' not in value: + raise ValidationError(value, "needs to contain a {}-placeholder.") + + def transform(self, value): + return shlex.split(value) + + class Bool(BaseType): """Base class for a boolean setting. diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 48ccdaf21..b5af4337f 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -84,7 +84,9 @@ SECTION_DESC = { "Keybindings for insert mode.\n" "Since normal keypresses are passed through, only special keys are " "supported in this mode.\n" - "An useful command to map here is the hidden command leave_mode."), + "Useful hidden commands to map in this section:\n" + " open_editor: Open a texteditor with the focused field.\n" + " leave_mode: Leave the command mode."), 'keybind.hint': ( "Keybindings for hint mode.\n" "Since normal keypresses are passed through, only special keys are " @@ -177,6 +179,11 @@ DATA = OrderedDict([ ('background-tabs', SettingValue(types.Bool(), 'false'), "Whether to open new tabs (middleclick/ctrl+click) in background"), + + ('editor', + SettingValue(types.ShellCommand(placeholder=True), 'gvim -f "{}"'), + "The editor (and arguments) to use for the open_editor binding. " + "Use {} for the filename. Gets split via shutils."), )), ('completion', sect.KeyValue( @@ -468,6 +475,7 @@ DATA = OrderedDict([ ('keybind.insert', sect.ValueList( types.KeyBindingName(), types.KeyBinding(), ('', 'leave_mode'), + ('', 'open_editor'), )), ('keybind.hint', sect.ValueList( diff --git a/qutebrowser/utils/webelem.py b/qutebrowser/utils/webelem.py index 69f09eae0..7847a2f3f 100644 --- a/qutebrowser/utils/webelem.py +++ b/qutebrowser/utils/webelem.py @@ -72,3 +72,24 @@ def is_visible(e, frame=None): # out of screen return False return True + + +def javascript_escape(text): + """Escapes special values in strings. + + This maybe makes them work with QWebElement::evaluateJavaScript. + Maybe. + """ + # This is a list of tuples because order matters, and using OrderedDict + # makes no sense because we don't actually need dict-like properties. + replacements = [ + ('\\', r'\\'), + ('\n', r'\n'), + ('\t', r'\t'), + ("'", r"\'"), + ('"', r'\"'), + ] + text = text.rstrip('\n') + for orig, repl in replacements: + text = text.replace(orig, repl) + return text