1# vim: ts=8:sts=8:sw=8:noexpandtab 2# 3# This file is part of ReText 4# Copyright: 2015-2021 Dmitry Shachnev 5# 6# This program is free software; you can redistribute it and/or modify 7# it under the terms of the GNU General Public License as published by 8# the Free Software Foundation; either version 2 of the License, or 9# (at your option) any later version. 10# 11# This program is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14# GNU General Public License for more details. 15# 16# You should have received a copy of the GNU General Public License 17# along with this program. If not, see <http://www.gnu.org/licenses/>. 18 19from os.path import exists, splitext 20import time 21from markups import get_markup_for_file_name, find_markup_class_by_name 22from markups.common import MODULE_HOME_PAGE 23 24from ReText import app_version, globalSettings, converterprocess 25from ReText.editor import ReTextEdit 26from ReText.highlighter import ReTextHighlighter 27from ReText.preview import ReTextPreview 28 29try: 30 import enchant 31except ImportError: 32 enchant = None 33 34from PyQt5.QtCore import pyqtSignal, Qt, QDir, QFile, QFileInfo, QPoint, QTextStream, QTimer, QUrl 35from PyQt5.QtGui import QPalette, QTextCursor, QTextDocument 36from PyQt5.QtWidgets import QApplication, QTextEdit, QSplitter, QMessageBox 37 38try: 39 from ReText.webkitpreview import ReTextWebKitPreview 40except ImportError: 41 ReTextWebKitPreview = None 42 43try: 44 from ReText.webenginepreview import ReTextWebEnginePreview 45except ImportError: 46 ReTextWebEnginePreview = None 47 48PreviewDisabled, PreviewLive, PreviewNormal = range(3) 49 50class ReTextTab(QSplitter): 51 52 fileNameChanged = pyqtSignal() 53 modificationStateChanged = pyqtSignal() 54 activeMarkupChanged = pyqtSignal() 55 56 # Make _fileName a read-only property to make sure that any 57 # modification happens through the proper functions. These functions 58 # will make sure that the fileNameChanged signal is emitted when 59 # applicable. 60 @property 61 def fileName(self): 62 return self._fileName 63 64 def __init__(self, parent, fileName, previewState=PreviewDisabled): 65 super().__init__(Qt.Orientation.Horizontal, parent=parent) 66 self.p = parent 67 self._fileName = fileName 68 self.editBox = ReTextEdit(self) 69 self.previewBox = self.createPreviewBox(self.editBox) 70 self.activeMarkupClass = None 71 self.markup = None 72 self.converted = None 73 self.previewState = previewState 74 self.previewOutdated = False 75 self.conversionPending = False 76 self.cssFileExists = False 77 78 self.converterProcess = converterprocess.ConverterProcess() 79 self.converterProcess.conversionDone.connect(self.updatePreviewBox) 80 81 textDocument = self.editBox.document() 82 self.highlighter = ReTextHighlighter(textDocument) 83 if enchant is not None and parent.actionEnableSC.isChecked(): 84 self.highlighter.dictionary = enchant.Dict(parent.sl or None) 85 # Rehighlighting is tied to the change in markup class that 86 # happens at the end of this function 87 88 self.editBox.textChanged.connect(self.triggerPreviewUpdate) 89 self.editBox.undoAvailable.connect(parent.actionUndo.setEnabled) 90 self.editBox.redoAvailable.connect(parent.actionRedo.setEnabled) 91 self.editBox.copyAvailable.connect(parent.enableCopy) 92 93 # Give both boxes a minimum size so the minimumSizeHint will be 94 # ignored when splitter.setSizes is called below 95 for widget in self.editBox, self.previewBox: 96 widget.setMinimumWidth(125) 97 self.addWidget(widget) 98 self.setSizes((50, 50)) 99 self.setChildrenCollapsible(False) 100 101 textDocument.modificationChanged.connect(self.handleModificationChanged) 102 103 self.updateActiveMarkupClass() 104 105 def handleModificationChanged(self): 106 self.modificationStateChanged.emit() 107 108 def createPreviewBox(self, editBox): 109 110 # Use closures to avoid a hard reference from ReTextWebKitPreview 111 # to self, which would keep the tab and its resources alive 112 # even after other references to it have disappeared. 113 114 def editorPositionToSourceLine(editorPosition): 115 viewportPosition = editorPosition - editBox.verticalScrollBar().value() 116 sourceLine = editBox.cursorForPosition(QPoint(0,viewportPosition)).blockNumber() 117 return sourceLine 118 119 def sourceLineToEditorPosition(sourceLine): 120 doc = editBox.document() 121 block = doc.findBlockByNumber(sourceLine) 122 rect = doc.documentLayout().blockBoundingRect(block) 123 return rect.top() 124 125 if ReTextWebKitPreview and globalSettings.useWebKit: 126 preview = ReTextWebKitPreview(self, 127 editorPositionToSourceLine, 128 sourceLineToEditorPosition) 129 elif ReTextWebEnginePreview and globalSettings.useWebEngine: 130 preview = ReTextWebEnginePreview(self, 131 editorPositionToSourceLine, 132 sourceLineToEditorPosition) 133 else: 134 preview = ReTextPreview(self) 135 136 return preview 137 138 def getActiveMarkupClass(self): 139 ''' 140 Return the currently active markup class for this tab. 141 No objects should be created of this class, it should 142 only be used to retrieve markup class specific information. 143 ''' 144 return self.activeMarkupClass 145 146 def updateActiveMarkupClass(self): 147 ''' 148 Update the active markup class based on the default class and 149 the current filename. If the active markup class changes, the 150 highlighter is rerun on the input text, the markup object of 151 this tab is replaced with one of the new class and the 152 activeMarkupChanged signal is emitted. 153 ''' 154 previousMarkupClass = self.activeMarkupClass 155 156 self.activeMarkupClass = find_markup_class_by_name(globalSettings.defaultMarkup) 157 158 if self._fileName: 159 markupClass = get_markup_for_file_name( 160 self._fileName, return_class=True) 161 if markupClass: 162 self.activeMarkupClass = markupClass 163 164 if self.activeMarkupClass != previousMarkupClass: 165 self.highlighter.docType = self.activeMarkupClass.name if self.activeMarkupClass else None 166 self.highlighter.rehighlight() 167 168 self.activeMarkupChanged.emit() 169 self.triggerPreviewUpdate() 170 171 def getDocumentTitleFromConverted(self, converted): 172 if converted: 173 try: 174 return converted.get_document_title() 175 except Exception: 176 self.p.printError() 177 178 return self.getBaseName() 179 180 def getBaseName(self): 181 if self._fileName: 182 fileinfo = QFileInfo(self._fileName) 183 basename = fileinfo.completeBaseName() 184 return (basename if basename else fileinfo.fileName()) 185 return self.tr("New document") 186 187 def getHtmlFromConverted(self, converted, includeStyleSheet=True, webenv=False): 188 if converted is None: 189 markupClass = self.getActiveMarkupClass() 190 errMsg = self.tr('Could not parse file contents, check if ' 191 'you have the <a href="%s">necessary module</a> ' 192 'installed!') 193 try: 194 errMsg %= markupClass.attributes[MODULE_HOME_PAGE] 195 except (AttributeError, KeyError): 196 # Remove the link if markupClass doesn't have the needed attribute 197 errMsg = errMsg.replace('<a href="%s">', '').replace('</a>', '') 198 return '<p style="color: red">%s</p>' % errMsg 199 headers = '' 200 if includeStyleSheet and self.p.ss is not None: 201 headers += '<style type="text/css">\n' + self.p.ss + '</style>\n' 202 elif includeStyleSheet: 203 style = 'td, th { border: 1px solid #c3c3c3; padding: 0 3px 0 3px; }\n' 204 style += 'table { border-collapse: collapse; }\n' 205 style += 'img { max-width: 100%; }\n' 206 # QTextDocument seems to use media=screen even for printing 207 if not isinstance(self.previewBox, QTextEdit) and not webenv: 208 # https://github.com/retext-project/retext/pull/187 209 palette = QApplication.palette() 210 fgColor = palette.color(QPalette.ColorRole.Text).name() 211 bgColor = palette.color(QPalette.ColorRole.Base).name() 212 linkColor = palette.color(QPalette.ColorRole.Link).name() 213 visitedLinkColor = palette.color(QPalette.ColorRole.LinkVisited).name() 214 style += ('@media screen {\n' 215 f' html {{ color: {fgColor}; background-color: {bgColor}; }}\n' 216 f' a, a * {{ color: {linkColor}; }}\n' 217 f' a:visited, a:visited * {{ color: {visitedLinkColor}; }}\n' 218 '}\n') 219 # https://github.com/retext-project/retext/issues/408 220 style += '@media print { html { background-color: white; } }\n' 221 headers += '<style type="text/css">\n' + style + '</style>\n' 222 baseName = self.getBaseName() 223 if self.cssFileExists: 224 headers += ('<link rel="stylesheet" type="text/css" href="%s.css">\n' 225 % baseName) 226 headers += ('<meta name="generator" content="ReText %s">\n' % app_version) 227 return converted.get_whole_html( 228 custom_headers=headers, include_stylesheet=includeStyleSheet, 229 fallback_title=baseName, webenv=webenv) 230 231 def getDocumentForExport(self, includeStyleSheet=True, webenv=False): 232 markupClass = self.getActiveMarkupClass() 233 if markupClass and markupClass.available(): 234 exportMarkup = markupClass(filename=self._fileName) 235 236 text = self.editBox.toPlainText() 237 converted = exportMarkup.convert(text) 238 else: 239 converted = None 240 241 return (self.getDocumentTitleFromConverted(converted), 242 self.getHtmlFromConverted(converted, includeStyleSheet=includeStyleSheet, webenv=webenv), 243 self.previewBox) 244 245 def updatePreviewBox(self): 246 self.conversionPending = False 247 248 try: 249 self.converted = self.converterProcess.get_result() 250 except converterprocess.MarkupNotAvailableError: 251 self.converted = None 252 except converterprocess.ConversionError: 253 return self.p.printError() 254 255 if isinstance(self.previewBox, QTextEdit): 256 scrollbar = self.previewBox.verticalScrollBar() 257 scrollbarValue = scrollbar.value() 258 # If scrollbar was not on top, save its distance to bottom so that 259 # it will be restored in previewBox.updateScrollPosition() later. 260 if scrollbarValue: 261 self.previewBox.distToBottom = scrollbar.maximum() - scrollbarValue 262 else: 263 self.previewBox.distToBottom = None 264 try: 265 html = self.getHtmlFromConverted(self.converted) 266 except Exception: 267 return self.p.printError() 268 if isinstance(self.previewBox, QTextEdit): 269 self.previewBox.lastRenderTime = time.time() 270 self.previewBox.setHtml(html) 271 self.previewBox.document().setDefaultFont(globalSettings.font) 272 self.previewBox.updateScrollPosition(scrollbar.minimum(), 273 scrollbar.maximum()) 274 else: 275 self.previewBox.updateFontSettings() 276 277 # Always provide a baseUrl otherwise QWebView will 278 # refuse to show images or other external objects 279 if self._fileName: 280 baseUrl = QUrl.fromLocalFile(self._fileName) 281 else: 282 baseUrl = QUrl.fromLocalFile(QDir.currentPath()) 283 self.previewBox.setHtml(html, baseUrl) 284 285 if self.previewOutdated: 286 self.triggerPreviewUpdate() 287 288 def triggerPreviewUpdate(self): 289 self.previewOutdated = True 290 if self.previewState == PreviewDisabled: 291 return 292 293 if not self.conversionPending: 294 self.conversionPending = True 295 QTimer.singleShot(500, self.startPendingConversion) 296 297 def startPendingConversion(self): 298 self.previewOutdated = False 299 300 requested_extensions = ['ReText.mdx_posmap'] if globalSettings.syncScroll else [] 301 self.converterProcess.start_conversion(self.getActiveMarkupClass().name, 302 self.fileName, 303 requested_extensions, 304 self.editBox.toPlainText(), 305 QDir.currentPath()) 306 307 def updateBoxesVisibility(self): 308 self.editBox.setVisible(self.previewState < PreviewNormal) 309 self.previewBox.setVisible(self.previewState > PreviewDisabled) 310 311 def rebuildPreviewBox(self): 312 self.previewBox.disconnectExternalSignals() 313 self.previewBox.setParent(None) 314 self.previewBox.deleteLater() 315 self.previewBox = self.createPreviewBox(self.editBox) 316 self.previewBox.setMinimumWidth(125) 317 self.addWidget(self.previewBox) 318 self.setSizes((50, 50)) 319 self.triggerPreviewUpdate() 320 self.updateBoxesVisibility() 321 322 def detectFileEncoding(self, fileName): 323 ''' 324 Detect content encoding of specific file. 325 326 It will return None if it can't determine the encoding. 327 ''' 328 try: 329 import chardet 330 except ImportError: 331 return 332 333 with open(fileName, 'rb') as inputFile: 334 raw = inputFile.read(2048) 335 336 result = chardet.detect(raw) 337 if result['confidence'] > 0.9: 338 if result['encoding'].lower() in ('ascii', 'utf-8-sig'): 339 # UTF-8 files can be falsely detected as ASCII files if they 340 # don't contain non-ASCII characters in first 2048 bytes. 341 # We map ASCII to UTF-8 to avoid such situations. 342 # Also map UTF-8-SIG to UTF-8 because Qt does not understand it. 343 return 'utf-8' 344 return result['encoding'] 345 346 def readTextFromFile(self, fileName=None, encoding=None): 347 previousFileName = self._fileName 348 if fileName: 349 self._fileName = fileName 350 351 # Only try to detect encoding if it is not specified 352 if encoding is None and globalSettings.detectEncoding: 353 encoding = self.detectFileEncoding(self._fileName) 354 355 # TODO: why do we open the file twice: for detecting encoding 356 # and for actual read? Can we open it just once? 357 openfile = QFile(self._fileName) 358 openfile.open(QFile.OpenModeFlag.ReadOnly) 359 stream = QTextStream(openfile) 360 encoding = encoding or globalSettings.defaultCodec 361 if encoding: 362 stream.setCodec(encoding) 363 # If encoding is specified or detected, we should save the file with 364 # the same encoding 365 self.editBox.document().setProperty("encoding", encoding) 366 367 text = stream.readAll() 368 openfile.close() 369 370 if previousFileName != self._fileName: 371 self.updateActiveMarkupClass() 372 373 self.editBox.setPlainText(text) 374 self.editBox.document().setModified(False) 375 self.handleModificationChanged() 376 377 cssFileName = self.getBaseName() + '.css' 378 self.cssFileExists = QFile.exists(cssFileName) 379 380 if previousFileName != self._fileName: 381 self.fileNameChanged.emit() 382 383 def writeTextToFile(self, fileName=None): 384 # Just writes the text to file, without any changes to tab object 385 # Used directly for e.g. export extensions 386 387 # Get text from the cursor to avoid tweaking special characters, 388 # see https://bugreports.qt.io/browse/QTBUG-57552 and 389 # https://github.com/retext-project/retext/issues/216 390 cursor = self.editBox.textCursor() 391 cursor.select(QTextCursor.SelectionType.Document) 392 text = cursor.selectedText().replace('\u2029', '\n') 393 394 savefile = QFile(fileName or self._fileName) 395 result = savefile.open(QFile.OpenModeFlag.WriteOnly) 396 if result: 397 savestream = QTextStream(savefile) 398 399 # Save the file with original encoding 400 encoding = self.editBox.document().property("encoding") 401 encoding = encoding or globalSettings.defaultCodec 402 if encoding is not None: 403 savestream.setCodec(encoding) 404 405 savestream << text 406 savefile.close() 407 return result 408 409 def saveTextToFile(self, fileName=None): 410 # Sets fileName as tab fileName and writes the text to that file 411 if self._fileName: 412 self.p.fileSystemWatcher.removePath(self._fileName) 413 result = self.writeTextToFile(fileName) 414 if result: 415 self.editBox.document().setModified(False) 416 self.p.fileSystemWatcher.addPath(fileName or self._fileName) 417 if fileName and self._fileName != fileName: 418 self._fileName = fileName 419 self.updateActiveMarkupClass() 420 self.fileNameChanged.emit() 421 422 return result 423 424 def goToLine(self,line): 425 block = self.editBox.document().findBlockByLineNumber(line) 426 if block.isValid(): 427 newCursor = QTextCursor(block) 428 self.editBox.setTextCursor(newCursor) 429 430 def find(self, text, flags, replaceText=None, wrap=False): 431 if self.previewState == PreviewNormal and replaceText is None: 432 return self.previewBox.findText(text, flags) 433 cursor = self.editBox.textCursor() 434 if wrap and flags & QTextDocument.FindFlag.FindBackward: 435 cursor.movePosition(QTextCursor.MoveOperation.End) 436 elif wrap: 437 cursor.movePosition(QTextCursor.MoveOperation.Start) 438 if replaceText is not None and cursor.selectedText() == text: 439 newCursor = cursor 440 else: 441 newCursor = self.editBox.document().find(text, cursor, flags) 442 if not newCursor.isNull(): 443 if replaceText is not None: 444 newCursor.insertText(replaceText) 445 newCursor.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, len(replaceText)) 446 newCursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(replaceText)) 447 self.editBox.setTextCursor(newCursor) 448 if self.editBox.cursorRect().bottom() >= self.editBox.height() - 3: 449 scrollValue = self.editBox.verticalScrollBar().value() 450 areaHeight = self.editBox.fontMetrics().height() 451 self.editBox.verticalScrollBar().setValue(scrollValue + areaHeight) 452 return True 453 if not wrap: 454 return self.find(text, flags, replaceText, True) 455 return False 456 457 def replaceAll(self, text, replaceText): 458 cursor = self.editBox.textCursor() 459 cursor.beginEditBlock() 460 cursor.movePosition(QTextCursor.MoveOperation.Start) 461 flags = QTextDocument.FindFlags() 462 cursor = lastCursor = self.editBox.document().find(text, cursor, flags) 463 while not cursor.isNull(): 464 cursor.insertText(replaceText) 465 lastCursor = cursor 466 cursor = self.editBox.document().find(text, cursor, flags) 467 if not lastCursor.isNull(): 468 lastCursor.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, len(replaceText)) 469 lastCursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(replaceText)) 470 self.editBox.setTextCursor(lastCursor) 471 self.editBox.textCursor().endEditBlock() 472 return not lastCursor.isNull() 473 474 def openSourceFile(self, linkPath): 475 """Finds and opens the source file for link target fileToOpen. 476 477 When links like [test](test) are clicked, the file test.md is opened. 478 It has to be located next to the current opened file. 479 Relative paths like [test](../test) or [test](folder/test) are also possible. 480 """ 481 482 fileToOpen = self.resolveSourceFile(linkPath) 483 if exists(fileToOpen) and get_markup_for_file_name(fileToOpen, return_class=True): 484 self.p.openFileWrapper(fileToOpen) 485 return fileToOpen 486 if get_markup_for_file_name(fileToOpen, return_class=True): 487 if not QFile.exists(fileToOpen) and QFileInfo(fileToOpen).dir().exists(): 488 if self.promptFileCreation(fileToOpen): 489 self.p.openFileWrapper(fileToOpen) 490 return fileToOpen 491 492 def promptFileCreation(self, fileToCreate): 493 """ 494 Prompt user if a file should be created for the clicked link, 495 and try to create it. Return True on success. 496 """ 497 buttonReply = QMessageBox.question(self, self.tr('Create missing file?'), 498 self.tr("The file '%s' does not exist.\n\nDo you want to create it?") % fileToCreate, 499 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 500 QMessageBox.StandardButton.No) 501 if buttonReply == QMessageBox.StandardButton.Yes: 502 return self.createFile(fileToCreate) 503 elif buttonReply == QMessageBox.StandardButton.No: 504 return False 505 506 def resolveSourceFile(self, linkPath): 507 """ 508 Finds the actual path of the file to open in a new tab. 509 When the link has no extension, eg: [Test](test), the extension of the current file is assumed 510 (eg test.md for a markdown file). 511 When the link is an html file eg: [Test](test.html), the extension of the current file is assumed 512 (eg test.md for a markdown file). 513 Relative paths like [test](../test) or [test](folder/test) are also possible. 514 """ 515 basename, ext = splitext(linkPath) 516 if self.fileName: 517 currentExt = splitext(self.fileName)[1] 518 if ext in ('.html', '') and (exists(basename+currentExt) or not exists(linkPath)): 519 ext = currentExt 520 521 return basename+ext 522 523 def createFile(self, fileToCreate): 524 """Try to create file, return True if successful""" 525 try: 526 # Create file: 527 open(fileToCreate, 'x').close() 528 return True 529 except OSError as err: 530 QMessageBox.warning(self, self.tr("File could not be created"), 531 self.tr("Could not create file '%s': %s") % (fileToCreate, err)) 532 return False 533