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