1# This file is part of the Frescobaldi project, http://www.frescobaldi.org/
2#
3# Copyright (c) 2008 - 2014 by Wilbert Berendsen
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 2
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
18# See http://www.gnu.org/licenses/ for more information.
19
20"""
21A (Frescobaldi) document.
22
23This contains the text the user can edit in Frescobaldi. In most cases it will
24be a LilyPond source file, but other file types can be used as well.
25
26There are two different document Classes: Document and EditorDocument.
27Both provide a QTextDocument with additional metadata, but the EditorDocument
28provides additional handling of signals that are hooked into the Frescobaldi
29GUI environment. That means: use EditorDocument for documents open in the
30editor, Document for "abstract" documents, for example to pass a generated
31document to a job.lilypond.LilyPondJob without implicitly creating a tab.
32
33"""
34
35
36import os
37
38from PyQt5.QtCore import QUrl
39from PyQt5.QtGui import QTextCursor, QTextDocument
40from PyQt5.QtWidgets import QPlainTextDocumentLayout
41
42import app
43import util
44import variables
45import signals
46
47
48class AbstractDocument(QTextDocument):
49    """Base class for a Frescobaldi document. Not intended to be instantiated.
50
51    Objects of subclasses can be passed to the functions in documentinfo
52    or lilypondinfo etc. for additional meta information.
53
54    """
55
56    @classmethod
57    def load_data(cls, url, encoding=None):
58        """Class method to load document contents from an url.
59
60        This is intended to open a document without instantiating one
61        if loading the contents fails.
62
63        This method returns the text contents of the url as decoded text,
64        thus a unicode string.
65
66        The line separator is always '\\n'.
67
68        """
69        filename = url.toLocalFile()
70
71        # currently, we do not support non-local files
72        if not filename:
73            raise IOError("not a local file")
74        with open(filename, 'rb') as f:
75            data = f.read()
76        text = util.decode(data, encoding)
77        return util.universal_newlines(text)
78
79    @classmethod
80    def new_from_url(cls, url, encoding=None):
81        """Create and return a new document, loaded from url.
82
83        This is intended to open a new Document without instantiating one
84        if loading the contents fails.
85
86        """
87        d = cls(url, encoding)
88        if not url.isEmpty():
89            d.setPlainText(cls.load_data(url, encoding))
90            d.setModified(False)
91        return d
92
93    def __init__(self, url=None, encoding=None):
94        """Create a new Document with url and encoding.
95
96        Does not load the contents, you should use load() for that, or
97        use the new_from_url() constructor to instantiate a new Document
98        with the contents loaded.
99
100        """
101        if url is None:
102            url = QUrl()
103        super(AbstractDocument, self).__init__()
104        self.setDocumentLayout(QPlainTextDocumentLayout(self))
105        self._encoding = encoding
106        self._url = url # avoid urlChanged on init
107        self.setUrl(url)
108
109    def load(self, url=None, encoding=None, keepUndo=False):
110        """Load the specified or current url (if None was specified).
111
112        Currently only local files are supported. An IOError is raised
113        when trying to load a nonlocal URL.
114
115        If loading succeeds and an url was specified, the url is made the
116        current url (by calling setUrl() internally).
117
118        If keepUndo is True, the loading can be undone (with Ctrl-Z).
119
120        """
121        if url is None:
122            url = QUrl()
123        u = url if not url.isEmpty() else self.url()
124        text = self.load_data(u, encoding or self._encoding)
125        if keepUndo:
126            c = QTextCursor(self)
127            c.select(QTextCursor.Document)
128            c.insertText(text)
129        else:
130            self.setPlainText(text)
131        self.setModified(False)
132        if not url.isEmpty():
133            self.setUrl(url)
134
135    def _save(self, url, filename):
136        with open(filename, "wb") as f:
137            f.write(self.encodedText())
138            f.flush()
139            os.fsync(f.fileno())
140        self.setModified(False)
141        if not url.isEmpty():
142            self.setUrl(url)
143
144    def save(self, url=None, encoding=None):
145        """Saves the document to the specified or current url.
146
147        Currently only local files are supported. An IOError is raised
148        when trying to save a nonlocal URL.
149
150        If saving succeeds and an url was specified, the url is made the
151        current url (by calling setUrl() internally).
152
153        This method is never called directly but only from the overriding
154        subclass methods that make further specific use of the modified results.
155
156        """
157        if url is None:
158            url = QUrl()
159        u = url if not url.isEmpty() else self.url()
160        filename = u.toLocalFile()
161        # currently, we do not support non-local files
162        if not filename:
163            raise IOError("not a local file")
164        # keep the url if specified when we didn't have one, even if saving
165        # would fail
166        if self.url().isEmpty() and not url.isEmpty():
167            self.setUrl(url)
168        return url, filename
169
170    def url(self):
171        return self._url
172
173    def setUrl(self, url):
174        """ Change the url for this document. """
175        if url is None:
176            url = QUrl()
177        old, self._url = self._url, url
178        # number for nameless documents
179        if self._url.isEmpty():
180            nums = [0]
181            nums.extend(doc._num for doc in app.documents if doc is not self)
182            self._num = max(nums) + 1
183        else:
184            self._num = 0
185        return old
186
187    def encoding(self):
188        return variables.get(self, "coding") or self._encoding
189
190    def setEncoding(self, encoding):
191        self._encoding = encoding
192
193    def encodedText(self):
194        """Return the text of the document as a bytes string encoded in the
195        correct encoding.
196
197        The line separator is '\\n' on Unix/Linux/Mac OS X, '\\r\\n' on Windows.
198
199        Useful to save to a file.
200
201        """
202        text = util.platform_newlines(self.toPlainText())
203        return util.encode(text, self.encoding())
204
205    def documentName(self):
206        """Return a suitable name for this document.
207
208        This is only to be used for display. If the url of the document is
209        empty, something like "Untitled" or "Untitled (3)" is returned.
210
211        """
212        if self._url.isEmpty():
213            if self._num == 1:
214                return _("Untitled")
215            else:
216                return _("Untitled ({num})").format(num=self._num)
217        else:
218            return os.path.basename(self._url.path())
219
220
221class Document(AbstractDocument):
222    """A Frescobaldi document to be used anywhere except the main editor
223    viewspace (also non-GUI jobs/operations)."""
224
225    def save(self, url=None, encoding=None):
226        url, filename = super().save(url, encoding)
227        self._save(url, filename)
228
229
230class EditorDocument(AbstractDocument):
231    """A Frescobaldi document for use in the main editor view.
232    Basically this is an AbstractDocument with signals added."""
233
234    urlChanged = signals.Signal() # new url, old url
235    closed = signals.Signal()
236    loaded = signals.Signal()
237    saving = signals.SignalContext()
238    saved = signals.Signal()
239
240    @classmethod
241    def new_from_url(cls, url, encoding=None):
242        d = super(EditorDocument, cls).new_from_url(url, encoding)
243        if not url.isEmpty():
244            d.loaded()
245            app.documentLoaded(d)
246        return d
247
248    def __init__(self, url=None, encoding=None):
249        super(EditorDocument, self).__init__(url, encoding)
250        self.modificationChanged.connect(self.slotModificationChanged)
251        app.documents.append(self)
252        app.documentCreated(self)
253
254    def slotModificationChanged(self):
255        app.documentModificationChanged(self)
256
257    def close(self):
258        self.closed()
259        app.documentClosed(self)
260        app.documents.remove(self)
261
262    def load(self, url=None, encoding=None, keepUndo=False):
263        super(EditorDocument, self).load(url, encoding, keepUndo)
264        self.loaded()
265        app.documentLoaded(self)
266
267    def save(self, url=None, encoding=None):
268        url, filename = super().save(url, encoding)
269        with self.saving(), app.documentSaving(self):
270            self._save(url, filename)
271        self.saved()
272        app.documentSaved(self)
273
274    def setUrl(self, url):
275        old = super(EditorDocument, self).setUrl(url)
276        if url != old:
277            self.urlChanged(url, old)
278            app.documentUrlChanged(self, url, old)
279