qutebrowser/qutebrowser/misc/editor.py

204 lines
7.4 KiB
Python
Raw Normal View History

2014-06-19 09:04:37 +02:00
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2017-05-09 21:37:03 +02:00
# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
2014-05-21 15:47:21 +02:00
#
# 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/>.
"""Launcher for an external editor."""
import os
2014-08-26 19:10:14 +02:00
import tempfile
2014-05-21 15:47:21 +02:00
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QProcess
2014-05-21 15:47:21 +02:00
2014-08-26 19:10:14 +02:00
from qutebrowser.config import config
from qutebrowser.utils import message, log
from qutebrowser.misc import guiprocess
2014-05-21 15:47:21 +02:00
class ExternalEditor(QObject):
2014-09-24 22:22:02 +02:00
"""Class to simplify editing a text in an external editor.
Attributes:
_text: The current text before the editor is opened.
_filename: The name of the file to be edited.
_remove_file: Whether the file should be removed when the editor is
closed.
_proc: The GUIProcess of the editor.
2014-09-24 22:22:02 +02:00
"""
2014-05-21 15:47:21 +02:00
editing_finished = pyqtSignal(str)
2016-09-14 20:52:32 +02:00
def __init__(self, parent=None):
2014-05-21 15:47:21 +02:00
super().__init__(parent)
self._filename = None
self._proc = None
self._remove_file = None
2014-05-21 15:47:21 +02:00
def _cleanup(self):
2014-05-21 17:29:09 +02:00
"""Clean up temporary files after the editor closed."""
assert self._remove_file is not None
if self._filename is None or not self._remove_file:
2015-08-19 09:34:44 +02:00
# Could not create initial file.
return
2014-05-21 15:47:21 +02:00
try:
if self._proc.exit_status() != QProcess.CrashExit:
os.remove(self._filename)
except OSError as e:
# NOTE: Do not replace this with "raise CommandError" as it's
# executed async.
2016-09-14 20:52:32 +02:00
message.error("Failed to delete tempfile... ({})".format(e))
2014-05-21 15:47:21 +02:00
2015-09-11 08:32:37 +02:00
@pyqtSlot(int, QProcess.ExitStatus)
2014-05-21 15:47:21 +02:00
def on_proc_closed(self, exitcode, exitstatus):
"""Write the editor text into the form field and clean up tempfile.
Callback for QProcess when the editor was closed.
"""
2014-08-26 20:15:41 +02:00
log.procs.debug("Editor closed")
2014-05-21 15:47:21 +02:00
if exitstatus != QProcess.NormalExit:
# No error/cleanup here, since we already handle this in
# on_proc_error.
2014-05-21 15:47:21 +02:00
return
try:
if exitcode != 0:
return
2017-06-14 19:33:47 +02:00
encoding = config.val.editor.encoding
try:
with open(self._filename, 'r', encoding=encoding) as f:
text = f.read()
except OSError as e:
# NOTE: Do not replace this with "raise CommandError" as it's
# executed async.
2016-09-14 20:52:32 +02:00
message.error("Failed to read back edited file: {}".format(e))
return
2014-08-26 20:15:41 +02:00
log.procs.debug("Read back: {}".format(text))
self.editing_finished.emit(text)
finally:
self._cleanup()
2014-05-21 15:47:21 +02:00
@pyqtSlot(QProcess.ProcessError)
def on_proc_error(self, _err):
2014-05-21 15:47:21 +02:00
self._cleanup()
2017-10-17 22:08:54 +02:00
def edit(self, text, caret_position=0):
2014-05-21 15:47:21 +02:00
"""Edit a given text.
Args:
text: The initial text to edit.
caret_position: The position of the caret in the text.
2014-05-21 15:47:21 +02:00
"""
if self._filename is not None:
2014-05-21 15:47:21 +02:00
raise ValueError("Already editing a file!")
try:
# Close while the external process is running, as otherwise systems
# with exclusive write access (e.g. Windows) may fail to update
# the file from the external editor, see
2017-02-05 00:13:11 +01:00
# https://github.com/qutebrowser/qutebrowser/issues/1767
with tempfile.NamedTemporaryFile(
2017-06-14 19:33:47 +02:00
mode='w', prefix='qutebrowser-editor-',
encoding=config.val.editor.encoding,
delete=False) as fobj:
if text:
fobj.write(text)
self._filename = fobj.name
except OSError as e:
2016-09-14 20:52:32 +02:00
message.error("Failed to create initial file: {}".format(e))
return
self._remove_file = True
# Here we calculate the line and column of the caret based on its
# position and the given text.
#
# NOTE: Both line and column are 1-based indexes, because that's what
# most editors use as line and column starting index.
# By "most" we mean at least vim, nvim, gvim, emacs, atom, sublimetext,
# notepad++, brackets, visual studio, QtCreator and so on.
#
# To find the line we just count how many newlines there are before
# the caret and add 1.
#
# To find the column we calculate the difference between the caret and
# the last newline before the caret.
#
# For example in the text `aaa\nbb|bbb` (| represents the caret):
# caret_position = 6
# text[:caret_position] = `aaa\nbb`
# text[:caret_psotion].count('\n') = 1
# caret_position - text[:caret_position].rfind('\n') = 3
#
# Thus line, column = 2, 3, and the caret is indeed in the second
# line, third column
line = text[:caret_position].count('\n') + 1
column = caret_position - text[:caret_position].rfind('\n')
self._start_editor(line=line, column=column)
def edit_file(self, filename):
"""Edit the file with the given filename."""
self._filename = filename
self._remove_file = False
self._start_editor()
def _start_editor(self, line=1, column=1):
"""Start the editor with the file opened as self._filename.
Args:
caret_position: The position of the caret in the text.
"""
2016-09-14 20:52:32 +02:00
self._proc = guiprocess.GUIProcess(what='editor', parent=self)
2015-06-11 19:10:01 +02:00
self._proc.finished.connect(self.on_proc_closed)
self._proc.error.connect(self.on_proc_error)
2017-06-14 19:33:47 +02:00
editor = config.val.editor.command
2014-05-21 15:47:21 +02:00
executable = editor[0]
args = [self._sub_placeholder(arg, line, column) for arg in editor[1:]]
2014-08-26 20:15:41 +02:00
log.procs.debug("Calling \"{}\" with args {}".format(executable, args))
self._proc.start(executable, args)
def _sub_placeholder(self, possible_placeholder, line, column):
"""Substitute a single placeholder.
2017-10-17 22:35:01 +02:00
If the `possible_placeholder` input to this function is valid it will
be substituted with the appropriate value, otherwise it will be left
unchanged.
Args:
possible_placeholder: an argument of editor.command.
2017-10-17 22:35:01 +02:00
line: the previously-calculated line number for the text caret
column: the previously-calculated column number for the text caret
Return:
The substituted placeholder or the original argument
"""
2017-10-17 22:35:01 +02:00
replacements = {
'{}': self._filename,
'{file}': self._filename,
'{line}': str(line),
'{line0}': str(line-1),
'{column}': str(column),
'{column0}': str(column-1)
}
# The for loop would create a copy anyway, this should be more legible
ph = possible_placeholder
for old, new in replacements.items():
ph = ph.replace(old, new)
return ph