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