diff --git a/manuskript/exporter/manuskript/markdown.py b/manuskript/exporter/manuskript/markdown.py index c706650f..f338cb2d 100644 --- a/manuskript/exporter/manuskript/markdown.py +++ b/manuskript/exporter/manuskript/markdown.py @@ -5,7 +5,7 @@ from PyQt5.QtWidgets import QPlainTextEdit, QGroupBox, qApp, QVBoxLayout, QCheck from manuskript.exporter.manuskript.plainText import plainText from manuskript.functions import mainWindow -from manuskript.ui.editors.MMDHighlighter import MMDHighlighter +from manuskript.ui.highlighters import MMDHighlighter from manuskript.ui.exporters.manuskript.plainTextSettings import exporterSettings @@ -72,4 +72,4 @@ class markdownSettings(exporterSettings): self.settings = exporterSettings.getSettings(self) self.settings["Preview"]["MarkdownHighlighter"] = self.chkMarkdownHighlighter.isChecked() - return self.settings \ No newline at end of file + return self.settings diff --git a/manuskript/functions.py b/manuskript/functions.py index 0be46436..0b3451e6 100644 --- a/manuskript/functions.py +++ b/manuskript/functions.py @@ -6,7 +6,7 @@ import re from random import * from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QRegExp, QDir -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, QTimer from PyQt5.QtGui import QBrush, QIcon, QPainter, QColor, QImage, QPixmap from PyQt5.QtGui import QDesktopServices from PyQt5.QtWidgets import qApp, QTextEdit @@ -357,7 +357,9 @@ def statusMessage(message, duration=5000): """ Shows a message in MainWindow's status bar. """ + mainWindow().statusBar().show() mainWindow().statusBar().showMessage(message, duration) + QTimer.singleShot(duration, mainWindow().statusBar().hide) def openURL(url): """ diff --git a/manuskript/mainWindow.py b/manuskript/mainWindow.py index 1615c8d4..a3e12559 100644 --- a/manuskript/mainWindow.py +++ b/manuskript/mainWindow.py @@ -492,9 +492,8 @@ class MainWindow(QMainWindow, Ui_MainWindow): It assumes that the datas have been populated in a different way.""" if loadFromFile and not os.path.exists(project): print(self.tr("The file {} does not exist. Try again.").format(project)) - self.statusBar().showMessage( - self.tr("The file {} does not exist. Try again.").format(project), - 5000) + F.statusMessage( + self.tr("The file {} does not exist. Try again.").format(project)) return if loadFromFile: @@ -712,7 +711,7 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Giving some feedback print(feedback) - self.statusBar().showMessage(feedback, 5000) + F.statusMessage(feedback) def loadEmptyDatas(self): self.mdlFlatData = QStandardItemModel(self) @@ -732,13 +731,13 @@ class MainWindow(QMainWindow, Ui_MainWindow): # Giving some feedback if not errors: print(self.tr("Project {} loaded.").format(project)) - self.statusBar().showMessage( + F.statusMessage( self.tr("Project {} loaded.").format(project), 5000) else: print(self.tr("Project {} loaded with some errors:").format(project)) for e in errors: print(self.tr(" * {} wasn't found in project file.").format(e)) - self.statusBar().showMessage( + F.statusMessage( self.tr("Project {} loaded with some errors.").format(project), 5000) ############################################################################### diff --git a/manuskript/settings.py b/manuskript/settings.py index efb8cef4..8d5044ba 100644 --- a/manuskript/settings.py +++ b/manuskript/settings.py @@ -64,15 +64,15 @@ textEditor = { "misspelled": "#F00", "lineSpacing": 100, "tabWidth": 20, - "indent": True, + "indent": False, "spacingAbove": 5, "spacingBelow": 5, "textAlignment": 0, # 0: left, 1: center, 2: right, 3: justify "cursorWidth": 1, "cursorNotBlinking": False, - "maxWidth": 0, + "maxWidth": 600, "marginsLR": 0, - "marginsTB": 0, + "marginsTB": 20, "backgroundTransparent": False, } @@ -185,6 +185,8 @@ def load(string, fromString=False, protocol=None): #print("Loading:") #pp.pprint(allSettings) + # FIXME: use dict.update(dict) to update settings in newer versions. + if "viewSettings" in allSettings: global viewSettings viewSettings = allSettings["viewSettings"] @@ -267,9 +269,9 @@ def load(string, fromString=False, protocol=None): "textAlignment": 0, # Added in 0.5.0 "cursorWidth": 1, "cursorNotBlinking": False, # Added in 0.6.0 - "maxWidth": 0, + "maxWidth": 600, "marginsLR": 0, - "marginsTB": 0, + "marginsTB": 20, "backgroundTransparent": False, # Added in 0.6.0 } diff --git a/manuskript/ui/editors/editorWidget.py b/manuskript/ui/editors/editorWidget.py index b23d349d..0d00bb8c 100644 --- a/manuskript/ui/editors/editorWidget.py +++ b/manuskript/ui/editors/editorWidget.py @@ -2,12 +2,13 @@ # --!-- coding: utf8 --!-- from PyQt5.QtCore import pyqtSignal, QModelIndex from PyQt5.QtGui import QPalette -from PyQt5.QtWidgets import QWidget, QFrame, QSpacerItem, QSizePolicy, QVBoxLayout +from PyQt5.QtWidgets import QWidget, QFrame, QSpacerItem, QSizePolicy +from PyQt5.QtWidgets import QVBoxLayout, qApp, QStyle from manuskript import settings from manuskript.functions import AUC, mainWindow from manuskript.ui.editors.editorWidget_ui import Ui_editorWidget_ui -from manuskript.ui.views.textEditView import textEditView +from manuskript.ui.views.MDEditView import MDEditView from manuskript.ui.tools.splitDialog import splitDialog @@ -60,10 +61,37 @@ class editorWidget(QWidget, Ui_editorWidget_ui): self._model = None + # Capture textEdit scrollbar, so that we can put it outside the margins. + self.txtEditScrollBar = self.txtRedacText.verticalScrollBar() + self.txtEditScrollBar.setParent(self) + self.stack.currentChanged.connect(self.setScrollBarVisibility) + # def setModel(self, model): # self._model = model # self.setView() + def resizeEvent(self, event): + """ + textEdit's scrollBar has been reparented to self. So we need to + update it's geomtry when self is resized, and put it where we want it + to be. + """ + # Update scrollbar geometry + r = self.geometry() + w = 10 # Cf. style.mainEditorTabSS + r.setWidth(w) + r.moveRight(self.geometry().width()) + self.txtEditScrollBar.setGeometry(r) + + QWidget.resizeEvent(self, event) + + def setScrollBarVisibility(self): + """ + Since the texteEdit scrollBar has been reparented to self, it is not + hidden when stack changes. We have to do it manually. + """ + self.txtEditScrollBar.setVisible(self.stack.currentIndex() == 0) + def setFolderView(self, v): oldV = self.folderView if v == "cork": @@ -150,7 +178,7 @@ class editorWidget(QWidget, Ui_editorWidget_ui): self.updateTabTitle() def addTitle(itm): - edt = textEditView(self, html="{t}".format(l=min(itm.level() + 1, 5), t=itm.title()), + edt = MDEditView(self, html="{t}".format(l=min(itm.level() + 1, 5), t=itm.title()), autoResize=True) edt.setFrameShape(QFrame.NoFrame) self.txtEdits.append(edt) @@ -163,7 +191,7 @@ class editorWidget(QWidget, Ui_editorWidget_ui): l.addWidget(line) def addText(itm): - edt = textEditView(self, + edt = MDEditView(self, index=itm.index(), spellcheck=self.spellcheck, dict=settings.dict, @@ -214,7 +242,12 @@ class editorWidget(QWidget, Ui_editorWidget_ui): w = QWidget() w.setObjectName("editorWidgetFolderText") l = QVBoxLayout(w) - w.setStyleSheet("background: {};".format(settings.textEditor["background"])) + opt = settings.textEditor + background = (opt["background"] if not opt["backgroundTransparent"] + else "transparent") + w.setStyleSheet("background: {};".format(background)) + self.stack.widget(1).setStyleSheet("background: {}" + .format(background)) # self.scroll.setWidgetResizable(False) self.txtEdits = [] diff --git a/manuskript/ui/editors/editorWidget_ui.py b/manuskript/ui/editors/editorWidget_ui.py index 9787c8a2..c0c5faf6 100644 --- a/manuskript/ui/editors/editorWidget_ui.py +++ b/manuskript/ui/editors/editorWidget_ui.py @@ -2,8 +2,7 @@ # Form implementation generated from reading ui file 'manuskript/ui/editors/editorWidget_ui.ui' # -# Created: Fri Apr 8 20:03:08 2016 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -23,7 +22,7 @@ class Ui_editorWidget_ui(object): self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.text) self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0) self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.txtRedacText = textEditView(self.text) + self.txtRedacText = MDEditView(self.text) self.txtRedacText.setFrameShape(QtWidgets.QFrame.NoFrame) self.txtRedacText.setObjectName("txtRedacText") self.horizontalLayout_2.addWidget(self.txtRedacText) @@ -31,8 +30,8 @@ class Ui_editorWidget_ui(object): self.folder = QtWidgets.QWidget() self.folder.setObjectName("folder") self.verticalLayout = QtWidgets.QVBoxLayout(self.folder) - self.verticalLayout.setSpacing(0) self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setSpacing(0) self.verticalLayout.setObjectName("verticalLayout") self.scroll = QtWidgets.QScrollArea(self.folder) self.scroll.setAutoFillBackground(True) @@ -75,6 +74,6 @@ class Ui_editorWidget_ui(object): _translate = QtCore.QCoreApplication.translate editorWidget_ui.setWindowTitle(_translate("editorWidget_ui", "Form")) -from manuskript.ui.views.outlineView import outlineView -from manuskript.ui.views.textEditView import textEditView +from manuskript.ui.views.MDEditView import MDEditView from manuskript.ui.views.corkView import corkView +from manuskript.ui.views.outlineView import outlineView diff --git a/manuskript/ui/editors/editorWidget_ui.ui b/manuskript/ui/editors/editorWidget_ui.ui index 04054d26..b52abce5 100644 --- a/manuskript/ui/editors/editorWidget_ui.ui +++ b/manuskript/ui/editors/editorWidget_ui.ui @@ -46,7 +46,7 @@ 0 - + QFrame::NoFrame @@ -147,12 +147,12 @@ - - textEditView - QTextEdit -
manuskript.ui.views.textEditView.h
-
- + + MDEditView + QTextEdit +
manuskript.ui.views.MDEditView.h
+
+ outlineView QTreeView
manuskript.ui.views.outlineView.h
diff --git a/manuskript/ui/editors/fullScreenEditor.py b/manuskript/ui/editors/fullScreenEditor.py index fe39dcb6..4de5ba80 100644 --- a/manuskript/ui/editors/fullScreenEditor.py +++ b/manuskript/ui/editors/fullScreenEditor.py @@ -13,10 +13,9 @@ from manuskript import settings from manuskript.enums import Outline from manuskript.functions import allPaths, drawProgress from manuskript.ui.editors.locker import locker -from manuskript.ui.editors.textFormat import textFormat from manuskript.ui.editors.themes import findThemePath, generateTheme, setThemeEditorDatas from manuskript.ui.editors.themes import loadThemeDatas -from manuskript.ui.views.textEditView import textEditView +from manuskript.ui.views.MDEditView import MDEditView try: import enchant @@ -35,11 +34,11 @@ class fullScreenEditor(QWidget): self._geometries = {} # Text editor - self.editor = textEditView(self, - index=index, - spellcheck=settings.spellcheck, - highlighting=True, - dict=settings.dict) + self.editor = MDEditView(self, + index=index, + spellcheck=settings.spellcheck, + highlighting=True, + dict=settings.dict) self.editor.setFrameStyle(QFrame.NoFrame) self.editor.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.editor.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -65,11 +64,7 @@ class fullScreenEditor(QWidget): self.topPanel.layout().addStretch(1) - # Formatting - self.textFormat = textFormat(self) - self.topPanel.layout().addWidget(self.textFormat) - self.topPanel.layout().addStretch(1) - + # Close self.btnClose = QPushButton(self) self.btnClose.setIcon(qApp.style().standardIcon(QStyle.SP_DialogCloseButton)) self.btnClose.clicked.connect(self.close) diff --git a/manuskript/ui/editors/mainEditor.py b/manuskript/ui/editors/mainEditor.py index c5a034e3..1633359a 100644 --- a/manuskript/ui/editors/mainEditor.py +++ b/manuskript/ui/editors/mainEditor.py @@ -270,9 +270,6 @@ class mainEditor(QWidget, Ui_mainEditor): else: visible = True - # Hides / show textFormat - self.textFormat.updateFromIndex(index) - self.btnRedacFolderText.setVisible(visible) self.btnRedacFolderCork.setVisible(visible) self.btnRedacFolderOutline.setVisible(visible) diff --git a/manuskript/ui/editors/mainEditor_ui.py b/manuskript/ui/editors/mainEditor_ui.py index 45c8e6ff..d7f94e38 100644 --- a/manuskript/ui/editors/mainEditor_ui.py +++ b/manuskript/ui/editors/mainEditor_ui.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'manuskript/ui/editors/mainEditor_ui.ui' # -# Created by: PyQt5 UI code generator 5.9 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -65,17 +65,6 @@ class Ui_mainEditor(object): self.horizontalLayout_19.addWidget(self.sldCorkSizeFactor) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_19.addItem(spacerItem) - self.textFormat = textFormat(mainEditor) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.textFormat.sizePolicy().hasHeightForWidth()) - self.textFormat.setSizePolicy(sizePolicy) - self.textFormat.setMinimumSize(QtCore.QSize(20, 20)) - self.textFormat.setObjectName("textFormat") - self.horizontalLayout_19.addWidget(self.textFormat) - spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_19.addItem(spacerItem1) self.lblRedacWC = QtWidgets.QLabel(mainEditor) self.lblRedacWC.setMinimumSize(QtCore.QSize(10, 0)) self.lblRedacWC.setText("") @@ -110,4 +99,3 @@ class Ui_mainEditor(object): self.btnRedacFullscreen.setShortcut(_translate("mainEditor", "F11")) from manuskript.ui.editors.tabSplitter import tabSplitter -from manuskript.ui.editors.textFormat import textFormat diff --git a/manuskript/ui/editors/mainEditor_ui.ui b/manuskript/ui/editors/mainEditor_ui.ui index 1110a34f..2950db94 100644 --- a/manuskript/ui/editors/mainEditor_ui.ui +++ b/manuskript/ui/editors/mainEditor_ui.ui @@ -51,8 +51,7 @@ - - + .. Alt+Up @@ -141,35 +140,6 @@
- - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - - 20 - 20 - - - - @@ -237,12 +207,6 @@ - - textFormat - QWidget -
manuskript.ui.editors.textFormat.h
- 1 -
tabSplitter QWidget diff --git a/manuskript/ui/editors/themes.py b/manuskript/ui/editors/themes.py index 47f96f32..6b1222da 100644 --- a/manuskript/ui/editors/themes.py +++ b/manuskript/ui/editors/themes.py @@ -10,7 +10,7 @@ from PyQt5.QtGui import QPixmap, QPainter, QColor, QBrush, QImage, QTextBlockFor from PyQt5.QtWidgets import qApp, QFrame from manuskript.functions import allPaths, appPath, findBackground, findFirstFile -from manuskript.ui.views.textEditView import textEditView +from manuskript.ui.views.MDEditView import MDEditView _thumbCache = {} @@ -89,13 +89,13 @@ def themeTextRect(themeDatas, screenRect): def createThemePreview(theme, screenRect, size=QSize(200, 120)): """ Generates a QPixmap preview for given theme. - + Theme can be either a string containing the filename of the ini file with the theme settings, or it can be a dict with the settings. - + If theme is a filename, the result is cached. """ - + # Checking whether theme is a string or dict if type(theme) == str and os.path.exists(theme): # Theme is the path to an ini file @@ -126,7 +126,7 @@ def createThemePreview(theme, screenRect, size=QSize(200, 120)): painter.setPen(Qt.white) painter.drawRect(QRect(w, h, w * 4, h * 5)) painter.end() - + # If theme is a themefile, we keep it in cache if fromFile: _thumbCache[theme] = [themeDatas, px] @@ -265,11 +265,12 @@ def setThemeEditorDatas(editor, themeDatas, pixmap, screenRect): ) editor._fromTheme = True - + editor._themeData = themeDatas + editor.highlighter.updateColorScheme() def addThemePreviewText(pixmap, themeDatas, screenRect): # Text - previewText = textEditView(highlighting=True) + previewText = MDEditView(highlighting=True) previewText.setFrameStyle(QFrame.NoFrame) previewText.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) previewText.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) diff --git a/manuskript/ui/editors/MMDHighlighter.py b/manuskript/ui/highlighters/MMDHighlighter.py similarity index 92% rename from manuskript/ui/editors/MMDHighlighter.py rename to manuskript/ui/highlighters/MMDHighlighter.py index a6740f3d..cea62555 100644 --- a/manuskript/ui/editors/MMDHighlighter.py +++ b/manuskript/ui/highlighters/MMDHighlighter.py @@ -5,10 +5,10 @@ import re from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCharFormat, QFont, QTextCursor, QFontMetrics -from manuskript.ui.editors.basicHighlighter import basicHighlighter +from manuskript.ui.highlighters import BasicHighlighter -class MMDHighlighter(basicHighlighter): +class MMDHighlighter(BasicHighlighter): MARKDOWN_REGEX = { 'Bold': '(\*\*)(.+?)(\*\*)', @@ -27,7 +27,7 @@ class MMDHighlighter(basicHighlighter): } def __init__(self, editor, style="Default"): - basicHighlighter.__init__(self, editor) + BasicHighlighter.__init__(self, editor) self.editor = editor @@ -35,13 +35,6 @@ class MMDHighlighter(basicHighlighter): for key in self.MARKDOWN_REGEX: self.rules[key] = re.compile(self.MARKDOWN_REGEX[key]) - def highlightBlock(self, text): - basicHighlighter.highlightBlockBefore(self, text) - - self.doHighlightBlock(text) - - basicHighlighter.highlightBlockAfter(self, text) - def doHighlightBlock(self, text): """ A quick-n-dirty very basic highlighter, that fails in most non-trivial cases. And is ugly. diff --git a/manuskript/ui/highlighters/__init__.py b/manuskript/ui/highlighters/__init__.py new file mode 100644 index 00000000..c3b1ed33 --- /dev/null +++ b/manuskript/ui/highlighters/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- + +from manuskript.ui.highlighters.basicHighlighter import BasicHighlighter +from manuskript.ui.highlighters.MMDHighlighter import MMDHighlighter + +# Markdown highlighter +from manuskript.ui.highlighters.markdownEnums import MarkdownState +from manuskript.ui.highlighters.markdownEnums import MarkdownTokenType +from manuskript.ui.highlighters.markdownEnums import BlockquoteStyle +from manuskript.ui.highlighters.markdownTokenizer import MarkdownTokenizer +from manuskript.ui.highlighters.markdownHighlighter import MarkdownHighlighter diff --git a/manuskript/ui/editors/basicHighlighter.py b/manuskript/ui/highlighters/basicHighlighter.py similarity index 55% rename from manuskript/ui/editors/basicHighlighter.py rename to manuskript/ui/highlighters/basicHighlighter.py index a09b53a4..960b7201 100644 --- a/manuskript/ui/editors/basicHighlighter.py +++ b/manuskript/ui/highlighters/basicHighlighter.py @@ -4,12 +4,16 @@ import re from PyQt5.QtCore import Qt -from PyQt5.QtGui import QBrush, QTextCursor, QColor, QFont, QSyntaxHighlighter, QTextBlockFormat, QTextCharFormat +from PyQt5.QtGui import QBrush, QTextCursor, QColor, QFont, QSyntaxHighlighter +from PyQt5.QtGui import QTextBlockFormat, QTextCharFormat import manuskript.models.references as Ref +import manuskript.ui.style as S +from manuskript import settings +from manuskript import functions as F -class basicHighlighter(QSyntaxHighlighter): +class BasicHighlighter(QSyntaxHighlighter): def __init__(self, editor): QSyntaxHighlighter.__init__(self, editor.document()) @@ -17,6 +21,11 @@ class basicHighlighter(QSyntaxHighlighter): self._misspelledColor = Qt.red self._defaultBlockFormat = QTextBlockFormat() self._defaultCharFormat = QTextCharFormat() + self.defaultTextColor = QColor(S.text) + self.backgroundColor = QColor(S.base) + self.markupColor = QColor(S.textLight) + self.linkColor = QColor(S.link) + self.spellingErrorColor = QColor(Qt.red) def setDefaultBlockFormat(self, bf): self._defaultBlockFormat = bf @@ -29,17 +38,63 @@ class basicHighlighter(QSyntaxHighlighter): def setMisspelledColor(self, color): self._misspelledColor = color + def updateColorScheme(self, rehighlight=True): + """ + Generates a base set of colors that will take account of user + preferences, and use system style. + """ + + # Reading user settings + opt = settings.textEditor + + if not self.editor._fromTheme or not self.editor._themeData: + + self.defaultTextColor = QColor(opt["fontColor"]) + self.backgroundColor = (QColor(opt["background"]) + if not opt["backgroundTransparent"] + else QColor(S.window)) + self.markupColor = F.mixColors(self.defaultTextColor, + self.backgroundColor, + .3) + self.linkColor = QColor(S.link) + self.spellingErrorColor = QColor(opt["misspelled"]) + self._defaultCharFormat.setForeground(QBrush(self.defaultTextColor)) + + # FullscreenEditor probably + else: + opt = self.editor._themeData + self.defaultTextColor = QColor(opt["Text/Color"]) + self.backgroundColor = F.mixColors( + QColor(opt["Foreground/Color"]), + QColor(opt["Background/Color"]), + int(opt["Foreground/Opacity"])/100.) + self.markupColor = F.mixColors(self.defaultTextColor, + self.backgroundColor, + .3) + self.linkColor = QColor(S.link) + self.spellingErrorColor = QColor(opt["Text/Misspelled"]) + + if rehighlight: + self.rehighlight() + def highlightBlock(self, text): """Apply syntax highlighting to the given block of text. """ self.highlightBlockBefore(text) + self.doHighlightBlock(text) self.highlightBlockAfter(text) + def doHighlightBlock(self, text): + """ + Virtual funtion to subclass. + """ + pass + def highlightBlockBefore(self, text): """Highlighting to do before anything else. - When subclassing basicHighlighter, you must call highlightBlockBefore - before you do any custom highlighting. + When subclassing BasicHighlighter, you must call highlightBlockBefore + before you do any custom highlighting. Or implement doHighlightBlock. """ #print(">", self.currentBlock().document().availableUndoSteps()) @@ -56,8 +111,8 @@ class basicHighlighter(QSyntaxHighlighter): def highlightBlockAfter(self, text): """Highlighting to do after everything else. - When subclassing basicHighlighter, you must call highlightBlockAfter - after your custom highlighting. + When subclassing BasicHighlighter, you must call highlightBlockAfter + after your custom highlighting. Or implement doHighlightBlock. """ # References @@ -91,13 +146,16 @@ class basicHighlighter(QSyntaxHighlighter): textedText = text + " " # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ - WORDS = '(?iu)([\w\']+)[^\'\w]' # (?iu) means case insensitive and unicode + WORDS = r'(?iu)([\w\']+)[^\'\w]' + # (?iu) means case insensitive and unicode if hasattr(self.editor, "spellcheck") and self.editor.spellcheck: for word_object in re.finditer(WORDS, textedText): - if self.editor._dict and not self.editor._dict.check(word_object.group(1)): + if (self.editor._dict + and not self.editor._dict.check(word_object.group(1))): format = self.format(word_object.start(1)) format.setUnderlineColor(self._misspelledColor) # SpellCheckUnderline fails with some fonts format.setUnderlineStyle(QTextCharFormat.WaveUnderline) self.setFormat(word_object.start(1), - word_object.end(1) - word_object.start(1), format) + word_object.end(1) - word_object.start(1), + format) diff --git a/manuskript/ui/highlighters/markdownEnums.py b/manuskript/ui/highlighters/markdownEnums.py new file mode 100644 index 00000000..76cb32b4 --- /dev/null +++ b/manuskript/ui/highlighters/markdownEnums.py @@ -0,0 +1,100 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +#============================================================================== +# MARKDOWN STATES +#============================================================================== + +class MarkdownState: + MarkdownStateUnknown = -1 + MarkdownStateParagraphBreak = 0 + MarkdownStateListLineBreak = 1 + MarkdownStateParagraph = 2 + MarkdownStateAtxHeading1 = 3 + MarkdownStateAtxHeading2 = 4 + MarkdownStateAtxHeading3 = 5 + MarkdownStateAtxHeading4 = 6 + MarkdownStateAtxHeading5 = 7 + MarkdownStateAtxHeading6 = 8 + MarkdownStateBlockquote = 9 + MarkdownStateCodeBlock = 10 + MarkdownStateInGithubCodeFence = 11 + MarkdownStateInPandocCodeFence = 12 + MarkdownStateCodeFenceEnd = 13 + MarkdownStateComment = 14 + MarkdownStateHorizontalRule = 15 + MarkdownStateNumberedList = 16 + MarkdownStateBulletPointList = 17 + MarkdownStateSetextHeading1Line1 = 18 + MarkdownStateSetextHeading1Line2 = 19 + MarkdownStateSetextHeading2Line1 = 20 + MarkdownStateSetextHeading2Line2 = 21 + MarkdownStatePipeTableHeader = 22 + MarkdownStatePipeTableDivider = 23 + MarkdownStatePipeTableRow = 24 + +#============================================================================== +# MARKDOWN TOKEN TYPE +#============================================================================== + +class MarkdownTokenType: + TokenUnknown = -1 + + # Titles + TokenAtxHeading1 = 0 + TokenAtxHeading2 = 1 + TokenAtxHeading3 = 2 + TokenAtxHeading4 = 3 + TokenAtxHeading5 = 4 + TokenAtxHeading6 = 5 + TokenSetextHeading1Line1 = 6 + TokenSetextHeading1Line2 = 7 + TokenSetextHeading2Line1 = 8 + TokenSetextHeading2Line2 = 9 + + TokenEmphasis = 10 + TokenStrong = 11 + TokenStrikethrough = 12 + TokenVerbatim = 13 + TokenHtmlTag = 14 + TokenHtmlEntity = 15 + TokenAutomaticLink = 16 + TokenInlineLink = 17 + TokenReferenceLink = 18 + TokenReferenceDefinition = 19 + TokenImage = 20 + TokenHtmlComment = 21 + TokenNumberedList = 22 + TokenBulletPointList = 23 + TokenHorizontalRule = 24 + TokenLineBreak = 25 + TokenBlockquote = 26 + TokenCodeBlock = 27 + TokenGithubCodeFence = 28 + TokenPandocCodeFence = 29 + TokenCodeFenceEnd = 30 + TokenMention = 31 + TokenTableHeader = 32 + TokenTableDivider = 33 + TokenTablePipe = 34 + TokenSuperScript = 35 + TokenSubScript = 36 + # CriticMarkup + TokenCMAddition = 37 # {++ ++} + TokenCMDeletion = 38 # {-- --} + TokenCMSubstitution = 39 #{~~ ~> ~~} + TokenCMComment = 40 # {>> <<} + TokenCMHighlight = 41 # {== ==}{>> <<} + TokenLast = 42 + + TITLES = [TokenAtxHeading1, TokenAtxHeading2, TokenAtxHeading3, + TokenAtxHeading4, TokenAtxHeading5, TokenAtxHeading6, + TokenSetextHeading1Line1, TokenSetextHeading1Line2, + TokenSetextHeading2Line1, TokenSetextHeading2Line2] + + + +class BlockquoteStyle: + BlockquoteStylePlain = 0 + BlockquoteStyleItalic = 1 + BlockquoteStyleFancy = 2 diff --git a/manuskript/ui/highlighters/markdownHighlighter.py b/manuskript/ui/highlighters/markdownHighlighter.py new file mode 100644 index 00000000..52b46a2e --- /dev/null +++ b/manuskript/ui/highlighters/markdownHighlighter.py @@ -0,0 +1,665 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +""" +A QSyntaxHighlighter for markdown, using tokenizer. More accurate than simple +regexp, but not yet perfect. +""" + +import re +from PyQt5.QtCore import Qt, pyqtSignal, qWarning, QRegExp +from PyQt5.QtGui import (QSyntaxHighlighter, QTextBlock, QColor, QFont, + QTextCharFormat, QBrush, QPalette) +from PyQt5.QtWidgets import qApp, QStyle + +from manuskript.ui.highlighters import BasicHighlighter +from manuskript.ui.highlighters import MarkdownTokenizer +from manuskript.ui.highlighters import MarkdownState as MS +from manuskript.ui.highlighters import MarkdownTokenType as MTT +from manuskript.ui.highlighters import BlockquoteStyle as BS +from manuskript.ui import style as S +from manuskript import settings +from manuskript import functions as F + +# Un longue ligne. Un longue ligne. Un longue ligne. Un longue ligne.asdasdasda + +GW_FADE_ALPHA = 140 + +# Highlighter based on GhostWriter (http://wereturtle.github.io/ghostwriter/). +# GPLV3+. + +#FIXME: Setext heading don't work anymore + +class MarkdownHighlighter(BasicHighlighter): + + highlightBlockAtPosition = pyqtSignal(int) + headingFound = pyqtSignal(int, str, QTextBlock) + headingRemoved = pyqtSignal(int) + + def __init__(self, editor): + BasicHighlighter.__init__(self, editor) + + #default values + self.editor = editor + self.tokenizer = MarkdownTokenizer() + + self.spellCheckEnabled = False + #self.typingPaused = True + self.inBlockquote = False + self.blockquoteStyle = BS.BlockquoteStyleFancy + + # Settings + self.useUndlerlineForEmphasis = False + self.highlightLineBreaks = True + + self.highlightBlockAtPosition.connect(self.onHighlightBlockAtPosition, + Qt.QueuedConnection) + + self.theme = self.defaultTheme() + self.setupHeadingFontSize(True) + + self.highlightedWords = [] + self.highlightedTags = [] + self.searchExpression = "" + self.searchExpressionRegExp = False + self.searchExpressionCase = False + + #f = self.document().defaultFont() + #f.setFamily("monospace") + #self.document().setDefaultFont(f) + + def doHighlightBlock(self, text): + """ + Note: Never set the QTextBlockFormat for a QTextBlock from within + the highlighter. Depending on how the block format is modified, + a recursive call to the highlighter may be triggered, which will + cause the application to crash. + + Likewise, don't try to set the QTextBlockFormat outside the highlighter + (i.e., from within the text editor). While the application will not + crash, the format change will be added to the undo stack. Attempting + to undo from that point on will cause the undo stack to be virtually + frozen, since undoing the format operation causes the text to be + considered changed, thus triggering the slot that changes the text + formatting to be triggered yet again. + """ + + lastState = self.currentBlockState() + self.setFormat(0, len(text), self._defaultCharFormat) + + if self.tokenizer != None: + self.tokenizer.clear() + block = self.currentBlock() + nextState = MS.MarkdownStateUnknown + previousState = self.previousBlockState() + + if block.next().isValid(): + nextState = block.next().userState() + + self.tokenizer.tokenize(text, lastState, previousState, nextState) + self.setCurrentBlockState(self.tokenizer.getState()) + + self.inBlockquote = self.tokenizer.getState() == MS.MarkdownStateBlockquote + + # STATE FORMATTING + # FIXME: generic + if self.currentBlockState() in [ + MS.MarkdownStatePipeTableHeader, + MS.MarkdownStatePipeTableDivider, + MS.MarkdownStatePipeTableRow]: + fmt = QTextCharFormat() + f = fmt.font() + f.setFamily("Monospace") + fmt.setFont(f) + self.setFormat(0, len(text), fmt) + + # Monospace the blank chars + i = 0 + while i <= len(text)-1 and text[i] in [" ", "\t"]: + fmt = self.format(i) + fmt.setFontFamily("Monospace") + self.setFormat(i, 1, fmt) + i += 1 + + #if self.currentBlockState() == MS.MarkdownStateBlockquote: + #fmt = QTextCharFormat(self._defaultCharFormat) + #fmt.setForeground(Qt.lightGray) + #self.setFormat(0, len(text), fmt) + + tokens = self.tokenizer.getTokens() + + for token in tokens: + if token.type == MTT.TokenUnknown: + qWarning("Highlighter found unknown token type in text block.") + continue + + if token.type in [ + MTT.TokenAtxHeading1, + MTT.TokenAtxHeading2, + MTT.TokenAtxHeading3, + MTT.TokenAtxHeading4, + MTT.TokenAtxHeading5, + MTT.TokenAtxHeading6, + MTT.TokenSetextHeading1Line1, + MTT.TokenSetextHeading2Line1, + ]: + self.storeHeadingData(token, text) + + self.applyFormattingForToken(token, text) + + if self.tokenizer.backtrackRequested(): + previous = self.currentBlock().previous() + self.highlightBlockAtPosition.emit(previous.position()) + + if self.spellCheckEnabled: + self.spellCheck(text) + + # If the block has transitioned from previously being a heading to now + # being a non-heading, signal that the position in the document no + # longer contains a heading. + + if self.isHeadingBlockState(lastState) and \ + not self.isHeadingBlockState(self.currentBlockState()): + self.headingRemoved.emit(self.currentBlock().position()) + + + ########################################################################### + # COLORS & FORMATTING + ########################################################################### + + def updateColorScheme(self, rehighlight=True): + BasicHighlighter.updateColorScheme(self, rehighlight) + self.theme = self.defaultTheme() + self.setEnableLargeHeadingSizes(True) + + def defaultTheme(self): + + # Base Colors + background = self.backgroundColor + text = self.defaultTextColor + highlightedText = QColor(S.highlightedText) + highlightedTextDark = QColor(S.highlightedTextDark) + highlightedTextLight = QColor(S.highlightedTextLight) + highlight = QColor(S.highlight) + link = self.linkColor + linkVisited = QColor(S.linkVisited) + + # titleColor = highlight + titleColor = QColor(S.highlightedTextDark) + + # FullscreenEditor probably + if self.editor._fromTheme and self.editor._themeData: + text = QColor(self.editor._themeData["Text/Color"]) + background = QColor(self.editor._themeData["Background/Color"]) + titleColor = text + + # Compositions + light = F.mixColors(text, background, .75) + markup = F.mixColors(text, background, .5) + veryLight = F.mixColors(text, background, .25) + listToken = F.mixColors(highlight, background, .4) + titleMarkupColor = F.mixColors(titleColor, background, .3) + + + theme = { + "markup": markup} + + #Exemple: + #"color": Qt.red, + #"deltaSize": 10, + #"background": Qt.yellow, + #"monospace": True, + #"bold": True, + #"italic": True, + #"underline": True, + #"overline": True, + #"strike": True, + #"formatMarkup": True, + #"markupBold": True, + #"markupColor": Qt.blue, + #"markupBackground": Qt.green, + #"markupMonospace": True, + #"super":True, + #"sub":True + + for i in MTT.TITLES: + theme[i] = { + "formatMarkup":True, + "bold": True, + # "monospace": True, + "markupColor": titleMarkupColor + } + + theme[MTT.TokenAtxHeading1]["color"] = titleColor + theme[MTT.TokenAtxHeading2]["color"] = F.mixColors(titleColor, + background, .9) + theme[MTT.TokenAtxHeading3]["color"] = F.mixColors(titleColor, + background, .8) + theme[MTT.TokenAtxHeading4]["color"] = F.mixColors(titleColor, + background, .7) + theme[MTT.TokenAtxHeading5]["color"] = F.mixColors(titleColor, + background, .6) + theme[MTT.TokenAtxHeading6]["color"] = F.mixColors(titleColor, + background, .5) + + theme[MTT.TokenSetextHeading1Line1]["color"] = titleColor + theme[MTT.TokenSetextHeading2Line1]["color"] = F.mixColors(titleColor, + background, + .9) + + for i in [MTT.TokenSetextHeading1Line1, MTT.TokenSetextHeading2Line1]: + theme[i]["monospace"] = True + + for i in [MTT.TokenSetextHeading1Line2, MTT.TokenSetextHeading2Line2]: + theme[i] = { + "color": titleMarkupColor, + "monospace":True} + + # Beautifiers + theme[MTT.TokenEmphasis] = { + "italic":True} + theme[MTT.TokenStrong] = { + "bold":True} + theme[MTT.TokenStrikethrough] = { + "strike":True} + theme[MTT.TokenVerbatim] = { + "monospace":True, + "background": veryLight, + "formatMarkup": True, + "markupColor": markup, + "deltaSize": -1} + theme[MTT.TokenSuperScript] = { + "super":True, + "formatMarkup":True} + theme[MTT.TokenSubScript] = { + "sub":True, + "formatMarkup":True} + theme[MTT.TokenHtmlTag] = { + "color": linkVisited} + theme[MTT.TokenHtmlEntity] = { #   + "color": linkVisited} + theme[MTT.TokenAutomaticLink] = { + "color": link} + theme[MTT.TokenInlineLink] = { + "color": link} + theme[MTT.TokenReferenceLink] = { + "color": link} + theme[MTT.TokenReferenceDefinition] = { + "color": link} + theme[MTT.TokenImage] = { + "color": highlightedTextDark} + theme[MTT.TokenHtmlComment] = { + "color": markup} + theme[MTT.TokenNumberedList] = { + "markupColor": listToken, + "markupBold": True, + "markupMonospace": True,} + theme[MTT.TokenBulletPointList] = { + "markupColor": listToken, + "markupBold": True, + "markupMonospace": True,} + theme[MTT.TokenHorizontalRule] = { + "overline": True, + "underline": True, + "monospace": True, + "color": markup} + theme[MTT.TokenLineBreak] = { + "background": markup} + theme[MTT.TokenBlockquote] = { + "color": light, + "markupColor": veryLight, + "markupBackground": veryLight} + theme[MTT.TokenCodeBlock] = { + "color": light, + "markupBackground": veryLight, + "formatMarkup": True, + "monospace":True, + "deltaSize":-1} + theme[MTT.TokenGithubCodeFence] = { + "color": markup} + theme[MTT.TokenPandocCodeFence] = { + "color": markup} + theme[MTT.TokenCodeFenceEnd] = { + "color": markup} + theme[MTT.TokenMention] = {} # FIXME + theme[MTT.TokenTableHeader] = { + "color": light, "monospace":True} + theme[MTT.TokenTableDivider] = { + "color": markup, "monospace":True} + theme[MTT.TokenTablePipe] = { + "color": markup, "monospace":True} + + # CriticMarkup + theme[MTT.TokenCMAddition] = { + "color": QColor("#00bb00"), + "markupColor": QColor(F.mixColors("#00bb00", background, .4)), + "markupMonospace": True,} + theme[MTT.TokenCMDeletion] = { + "color": QColor("#dd0000"), + "markupColor": QColor(F.mixColors("#dd0000", background, .4)), + "markupMonospace": True, + "strike": True} + theme[MTT.TokenCMSubstitution] = { + "color": QColor("#ff8600"), + "markupColor": QColor(F.mixColors("#ff8600", background, .4)), + "markupMonospace": True,} + theme[MTT.TokenCMComment] = { + "color": QColor("#0000bb"), + "markupColor": QColor(F.mixColors("#0000bb", background, .4)), + "markupMonospace": True,} + theme[MTT.TokenCMHighlight] = { + "color": QColor("#aa53a9"), + "background": QColor(F.mixColors("#aa53a9", background, .1)), + "markupBackground": QColor(F.mixColors("#aa53a9", background, .1)), + "markupColor": QColor(F.mixColors("#aa53a9", background, .5)), + "markupMonospace": True,} + + return theme + + ########################################################################### + # ACTUAL FORMATTING + ########################################################################### + + def applyFormattingForToken(self, token, text): + if token.type != MTT.TokenUnknown: + fmt = self.format(token.position + token.openingMarkupLength) + markupFormat = self.format(token.position) + if self.theme.get("markup"): + markupFormat.setForeground(self.theme["markup"]) + + ## Debug + def debug(): + print("{}\n{}{}{}{} (state:{})".format( + text, + " "*token.position, + "^"*token.openingMarkupLength, + str(token.type).center(token.length + - token.openingMarkupLength + - token.closingMarkupLength, "-"), + "^" * token.closingMarkupLength, + self.currentBlockState(),) + ) + + # if token.type in range(6, 10): + # debug() + + theme = self.theme.get(token.type) + if theme: + fmt, markupFormat = self.formatsFromTheme(theme, + fmt, + markupFormat) + + # Format openning Markup + self.setFormat(token.position, token.openingMarkupLength, + markupFormat) + + # Format Text + self.setFormat( + token.position + token.openingMarkupLength, + token.length - token.openingMarkupLength - token.closingMarkupLength, + fmt) + + # Format closing Markup + if token.closingMarkupLength > 0: + self.setFormat( + token.position + token.length - token.closingMarkupLength, + token.closingMarkupLength, + markupFormat) + + else: + qWarning("MarkdownHighlighter.applyFormattingForToken() was passed" + " in a token of unknown type.") + + def formatsFromTheme(self, theme, format=None, + markupFormat=QTextCharFormat()): + # Token + if theme.get("color"): + format.setForeground(theme["color"]) + if theme.get("deltaSize"): + size = self.editor._defaultFontSize + theme["deltaSize"] + if size >= 0: + f = format.font() + f.setPointSize(size) + format.setFont(f) + if theme.get("background"): + format.setBackground(theme["background"]) + if theme.get("monospace"): + format.setFontFamily("Monospace") + if theme.get("bold"): + format.setFontWeight(QFont.Bold) + if theme.get("italic"): + format.setFontItalic(theme["italic"]) + if theme.get("underline"): + format.setFontUnderline(theme["underline"]) + if theme.get("overline"): + format.setFontOverline(theme["overline"]) + if theme.get("strike"): + format.setFontStrikeOut(theme["strike"]) + if theme.get("super"): + format.setVerticalAlignment(QTextCharFormat.AlignSuperScript) + if theme.get("sub"): + format.setVerticalAlignment(QTextCharFormat.AlignSubScript) + + # Markup + if theme.get("formatMarkup"): + c = markupFormat.foreground() + markupFormat = QTextCharFormat(format) + markupFormat.setForeground(c) + if theme.get("markupBold"): + markupFormat.setFontWeight(QFont.Bold) + if theme.get("markupColor"): + markupFormat.setForeground(theme["markupColor"]) + if theme.get("markupBackground"): + markupFormat.setBackground(theme["markupBackground"]) + if theme.get("markupMonospace"): + markupFormat.setFontFamily("Monospace") + + return format, markupFormat + + ########################################################################### + # SETTINGS + ########################################################################### + + def setHighlighted(self, words, tags): + rehighlight = (self.highlightedWords != words + or self.highlightedTags != tags) + self.highlightedWords = words + self.highlightedTags = tags + if rehighlight: + self.rehighlight() + + def setSearched(self, expression, regExp=False, caseSensitivity=False): + """ + Define an expression currently searched, to be highlighted. + Can be regExp. + """ + rehighlight = self.searchExpression != expression or \ + self.searchExpressionRegExp != regExp or \ + self.searchExpressionCase != caseSensitivity + self.searchExpression = expression + self.searchExpressionRegExp = regExp + self.searchExpressionCase = caseSensitivity + if rehighlight: + self.rehighlight() + + def setDictionary(self, dictionary): + self.dictionary = dictionary + if self.spellCheckEnabled: + self.rehighlight() + + def increaseFontSize(self): + self._defaultCharFormat.setFontPointSize( + self._defaultCharFormat.font().pointSize() + 1.0) + self.rehighlight() + + def decreaseFontSize(self): + self._defaultCharFormat.setFontPointSize( + self._defaultCharFormat.font().pointSize() - 1.0) + self.rehighlight() + + def setEnableLargeHeadingSizes(self, enable): + self.setupHeadingFontSize(enable) + self.rehighlight() + + def setupHeadingFontSize(self, useLargeHeadings): + if useLargeHeadings: + self.theme[MTT.TokenSetextHeading1Line1]["deltaSize"] = 7 + self.theme[MTT.TokenSetextHeading2Line1]["deltaSize"] = 5 + self.theme[MTT.TokenSetextHeading1Line2]["deltaSize"] = 7 + self.theme[MTT.TokenSetextHeading2Line2]["deltaSize"] = 5 + self.theme[MTT.TokenAtxHeading1]["deltaSize"] = 7 + self.theme[MTT.TokenAtxHeading2]["deltaSize"] = 5 + self.theme[MTT.TokenAtxHeading3]["deltaSize"] = 3 + self.theme[MTT.TokenAtxHeading4]["deltaSize"] = 2 + self.theme[MTT.TokenAtxHeading5]["deltaSize"] = 1 + self.theme[MTT.TokenAtxHeading6]["deltaSize"] = 0 + + else: + for i in MTT.TITLES: + self.theme[i]["deltaSize"] = 0 + + def setUseUnderlineForEmphasis(self, enable): + self.useUndlerlineForEmphasis = enable + self.rehighlight() + + def setFont(self, fontFamily, fontSize): + font = QFont(family=fontFamily, pointSize=fontSize, + weight=QFont.Normal, italic=False) + self._defaultCharFormat.setFont(font) + self.rehighlight() + + def setSpellCheckEnabled(self, enabled): + self.spellCheckEnabled = enabled + self.rehighlight() + + def setBlockquoteStyle(self, style): + self.blockquoteStyle = style + + if style == BS.BlockquoteStyleItalic: + self.emphasizeToken[MTT.TokenBlockquote] = True + else: + self.emphasizeToken[MTT.TokenBlockquote] = False + + self.rehighlight() + + def setHighlightLineBreaks(self, enable): + self.highlightLineBreaks = enable + self.rehighlight() + + ########################################################################### + # GHOSTWRITER SPECIFIC? + ########################################################################### + + def onTypingResumed(self): + self.typingPaused = False + + def onTypingPaused(self): + self.typingPaused = True + block = self.document().findBlock(self.editor.textCursor().position()) + self.rehighlightBlock(block) + + def onHighlightBlockAtPosition(self, position): + block = self.document().findBlock(position) + self.rehighlightBlock(block) + + def onTextBlockRemoved(self, block): + if self.isHeadingBlockState(block.userState): + self.headingRemoved.emit(block.position()) + + ########################################################################### + # SPELLCHECK + ########################################################################### + + def spellCheck(self, text): + cursorPosition = self.editor.textCursor().position() + cursorPosBlock = self.document().findBlock(cursorPosition) + cursorPosInBlock = -1 + + if self.currentBlock() == cursorPosBlock: + cursorPosInBlock = cursorPosition - cursorPosBlock.position() + + misspelledWord = self.dictionary.check(text, 0) + + while not misspelledWord.isNull(): + startIndex = misspelledWord.position() + length = misspelledWord.length() + + if self.typingPaused or cursorPosInBlock != startIndex + length: + spellingErrorFormat = self.format(startIndex) + spellingErrorFormat.setUnderlineColor(self.spellingErrorColor) + spellingErrorFormat.setUnderlineStyle( + qApp.stlye().styleHint(QStyle.SH_SpellCheckUnderlineStyle)) + + self.setFormat(startIndex, length, spellingErrorFormat) + + startIndex += length + misspelledWord = self.dictionary.check(text, startIndex) + + def storeHeadingData(self, token, text): + if token.type in [ + MTT.TokenAtxHeading1, + MTT.TokenAtxHeading2, + MTT.TokenAtxHeading3, + MTT.TokenAtxHeading4, + MTT.TokenAtxHeading5, + MTT.TokenAtxHeading6]: + level = token.type - MTT.TokenAtxHeading1 + 1 + s = token.position + token.openingMarkupLength + l = (token.length + - token.openingMarkupLength + - token.closingMarkupLength) + headingText = text[s:s+l].strip() + + elif token.type == MTT.TokenSetextHeading1Line1: + level = 1 + headingText = text + + elif token.type == MTT.TokenSetextHeading2Line1: + level = 2 + headingText = text + + else: + qWarning("MarkdownHighlighter.storeHeadingData() encountered" + + " unexpected token: {}".format(token.getType())) + return + + # FIXME: TypeError: could not convert 'TextBlockData' to 'QTextBlockUserData' + # blockData = self.currentBlockUserData() + # if blockData is None: + # blockData = TextBlockData(self.document(), self.currentBlock()) + # + # self.setCurrentBlockUserData(blockData) + self.headingFound.emit(level, headingText, self.currentBlock()) + + def isHeadingBlockState(self, state): + return state in [ + MS.MarkdownStateAtxHeading1, + MS.MarkdownStateAtxHeading2, + MS.MarkdownStateAtxHeading3, + MS.MarkdownStateAtxHeading4, + MS.MarkdownStateAtxHeading5, + MS.MarkdownStateAtxHeading6, + MS.MarkdownStateSetextHeading1Line1, + MS.MarkdownStateSetextHeading2Line1,] + + +def getLuminance(color): + return (0.30 * color.redF()) + \ + (0.59 * color.greenF()) + \ + (0.11 * color.blueF()) + + +def applyAlphaToChannel(foreground, background, alpha): + return (foreground * alpha) + (background * (1.0 - alpha)) + + +def applyAlpha(foreground, background, alpha): + blendedColor = QColor(0, 0, 0) + normalizedAlpha = alpha / 255.0 + blendedColor.setRed(applyAlphaToChannel( + foreground.red(), background.red(), normalizedAlpha)) + blendedColor.setGreen(applyAlphaToChannel( + foreground.green(), background.green(), normalizedAlpha)) + blendedColor.setBlue(applyAlphaToChannel( + foreground.blue(), background.blue(), normalizedAlpha)) + return blendedColor diff --git a/manuskript/ui/highlighters/markdownTokenizer.py b/manuskript/ui/highlighters/markdownTokenizer.py new file mode 100644 index 00000000..ffb4d7ba --- /dev/null +++ b/manuskript/ui/highlighters/markdownTokenizer.py @@ -0,0 +1,902 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import re +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from manuskript.ui.highlighters import MarkdownState as MS +from manuskript.ui.highlighters import MarkdownTokenType as MTT + +# This file is simply a python translation of GhostWriter's Tokenizer. +# http://wereturtle.github.io/ghostwriter/ +# GPLV3+. + +# ============================================================================== +# TOKEN +# ============================================================================== + +class Token: + def __init__(self): + self.type = -1 + self.position = 0 + self.length = 0 + self.openingMarkupLength = 0 + self.closingMarkupLength = 0 + +# ============================================================================== +# HIGHLIGHT TOKENIZER +# ============================================================================== + +class HighlightTokenizer: + def __init__(self): + self.tokens = [] + + def tokenize(text, currentState, previousState, nextState): + # Subclass me + return 0 + + def getTokens(self): + self.tokens = sorted(self.tokens, key=lambda t: t.position) + return self.tokens + + def getState(self): + return self.state + + def backtrackRequested(self): + return self.backtrack + + def clear(self): + self.tokens = [] + self.backtrack = False + self.state = -1 + + def addToken(self, token): + self.tokens.append(token) + + if token.type == -1: + print("Error here", token.position, token.length) + + def setState(self, state): + self.state = state + + def requestBacktrack(self): + self.backtrack = True + + def tokenLessThan(self, t1, t2): + return t1.getPosition() < t2.getPosition() + + +class MarkdownTokenizer(HighlightTokenizer): + + DUMMY_CHAR = "$" + MAX_MARKDOWN_HEADING_LEVEL = 6 + + paragraphBreakRegex = QRegExp("^\\s*$") + heading1SetextRegex = QRegExp("^===+\\s*$") + heading2SetextRegex = QRegExp("^---+\\s*$") + blockquoteRegex = QRegExp("^ {0,3}>.*$") + githubCodeFenceStartRegex = QRegExp("^```+.*$") + githubCodeFenceEndRegex = QRegExp("^```+\\s*$") + pandocCodeFenceStartRegex = QRegExp("^~~~+.*$") + pandocCodeFenceEndRegex = QRegExp("^~~~+\\s*$") + numberedListRegex = QRegExp("^ {0,3}[0-9a-z]+[.)]\\s+.*$") + numberedNestedListRegex = QRegExp("^\\s*[0-9a-z]+[.)]\\s+.*$") + hruleRegex = QRegExp("\\s*(\\*\\s*){3,}|(\\s*(_\\s*){3,})|((\\s*(-\\s*){3,}))") + lineBreakRegex = QRegExp(".*\\s{2,}$") + emphasisRegex = QRegExp("(\\*(?![\\s*]).*[^\\s*]\\*)|_(?![\\s_]).*[^\\s_]_") + emphasisRegex.setMinimal(True) + strongRegex = QRegExp("\\*\\*(?=\\S).*\\S\\*\\*(?!\\*)|__(?=\\S).*\\S__(?!_)") + strongRegex.setMinimal(True) + strikethroughRegex = QRegExp("~~[^\\s]+.*[^\\s]+~~") + strikethroughRegex.setMinimal(True) + superScriptRegex = QRegExp("\^([^\\s]|(\\\\\\s))+\^") # Spaces must be escaped "\ " + superScriptRegex.setMinimal(True) + subScriptRegex = QRegExp("~([^\\s]|(\\\\\\s))+~") # Spaces must be escaped "\ " + subScriptRegex.setMinimal(True) + verbatimRegex = QRegExp("`+") + htmlTagRegex = QRegExp("<[^<>]+>") + htmlTagRegex.setMinimal(True) + htmlEntityRegex = QRegExp("&[a-zA-Z]+;|&#x?[0-9]+;") + automaticLinkRegex = QRegExp("(<[a-zA-Z]+\\:.+>)|(<.+@.+>)") + automaticLinkRegex.setMinimal(True) + inlineLinkRegex = QRegExp("\\[.+\\]\\(.+\\)") + inlineLinkRegex.setMinimal(True) + referenceLinkRegex = QRegExp("\\[(.+)\\]") + referenceLinkRegex.setMinimal(True) + referenceDefinitionRegex = QRegExp("^\\s*\\[.+\\]:") + imageRegex = QRegExp("!\\[.*\\]\\(.+\\)") + imageRegex.setMinimal(True) + htmlInlineCommentRegex = QRegExp("") + htmlInlineCommentRegex.setMinimal(True) + mentionRegex = QRegExp("\\B@\\w+(\\-\\w+)*(/\\w+(\\-\\w+)*)?") + pipeTableDividerRegex = QRegExp("^ {0,3}(\\|[ :]?)?-{3,}([ :]?\\|[ :]?-{3,}([ :]?\\|)?)+\\s*$") + CMAdditionRegex = QRegExp("(\\{\\+\\+.*\\+\\+\\})") + CMAdditionRegex.setMinimal(True) + CMDeletionRegex = QRegExp("(\\{--.*--\\})") + CMDeletionRegex.setMinimal(True) + CMSubstitutionRegex = QRegExp("(\\{~~.*~>.*~~\\})") + CMSubstitutionRegex.setMinimal(True) + CMCommentRegex = QRegExp("(\\{>>.*<<\\})") + CMCommentRegex.setMinimal(True) + CMHighlightRegex = QRegExp("(\\{==.*==\\})") + CMHighlightRegex.setMinimal(True) + + def __init__(self): + HighlightTokenizer.__init__(self) + + def tokenize(self, text, currentState, previousState, nextState): + self.currentState = currentState + self.previousState = previousState + self.nextState = nextState + + if (self.previousState == MS.MarkdownStateInGithubCodeFence or \ + self.previousState == MS.MarkdownStateInPandocCodeFence) and \ + self.tokenizeCodeBlock(text): + # No further tokenizing required + pass + + elif self.previousState != MS.MarkdownStateComment \ + and self.paragraphBreakRegex.exactMatch(text): + + if previousState in [MS.MarkdownStateListLineBreak, + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList]: + self.setState(MS.MarkdownStateListLineBreak) + elif previousState != MS.MarkdownStateCodeBlock or \ + (text[:1] != "\t" and text[-4:] != " "): + self.setState(MS.MarkdownStateParagraphBreak) + + elif self.tokenizeSetextHeadingLine2(text) or \ + self.tokenizeCodeBlock(text) or \ + self.tokenizeMultilineComment(text) or \ + self.tokenizeHorizontalRule(text) or \ + self.tokenizeTableDivider(text): + # No further tokenizing required + pass + + elif self.tokenizeSetextHeadingLine1(text) or \ + self.tokenizeAtxHeading(text) or \ + self.tokenizeBlockquote(text) or \ + self.tokenizeNumberedList(text) or \ + self.tokenizeBulletPointList(text): + self.tokenizeLineBreak(text) + self.tokenizeInline(text) + + else: + if previousState in [MS.MarkdownStateListLineBreak, + MS.MarkdownStateNumberedList, + MS.MarkdownStateNumberedList]: + if not self.tokenizeNumberedList(text) and \ + not self.tokenizeBulletPointList(text) and \ + (text[:1] == "\t" or text[:4] == " "): + self.setState(previousState) + else: + self.setState(MS.MarkdownStateParagraph) + else: + self.setState(MS.MarkdownStateParagraph) + self.tokenizeLineBreak(text) + self.tokenizeInline(text) + + # Make sure that if the second line of a setext heading is removed the + # first line is reprocessed. Otherwise, it will still show up in the + # document as a heading. + if (previousState == MS.MarkdownStateSetextHeading1Line1 and \ + self.getState() != MS.MarkdownStateSetextHeading1Line2) or \ + (previousState == MS.MarkdownStateSetextHeading2Line1 and \ + self.getState() != MS.MarkdownStateSetextHeading2Line2): + self.requestBacktrack() + + def tokenizeSetextHeadingLine1(self, text): + #Check the next line's state to see if this is a setext-style heading. + level = 0 + token = Token() + nextState = self.nextState + + if MS.MarkdownStateSetextHeading1Line2 == nextState: + level = 1 + self.setState(MS.MarkdownStateSetextHeading1Line1) + token.type = MTT.TokenSetextHeading1Line1 + + elif MS.MarkdownStateSetextHeading2Line2 == nextState: + level = 2 + self.setState(MS.MarkdownStateSetextHeading2Line1) + token.type = MTT.TokenSetextHeading2Line1 + + if level > 0: + token.length = len(text) + token.position = 0 + self.addToken(token) + return True + + return False + + def tokenizeSetextHeadingLine2(self, text): + level = 0 + setextMatch = False + token = Token() + previousState = self.previousState + if previousState == MS.MarkdownStateSetextHeading1Line1: + level = 1 + setextMatch = self.heading1SetextRegex.exactMatch(text) + self.setState(MS.MarkdownStateSetextHeading1Line2) + token.type = MTT.TokenSetextHeading1Line2 + + elif previousState == MS.MarkdownStateSetextHeading2Line1: + level = 2 + setextMatch = self.heading2SetextRegex.exactMatch(text) + self.setState(MS.MarkdownStateSetextHeading2Line2) + token.type = MTT.TokenSetextHeading2Line2 + + elif previousState == MS.MarkdownStateParagraph: + h1Line2 = self.heading1SetextRegex.exactMatch(text) + h2Line2 = self.heading2SetextRegex.exactMatch(text) + + if h1Line2 or h2Line2: + # Restart tokenizing on the previous line. + self.requestBacktrack() + token.length = len(text) + token.position = 0 + + if h1Line2: + self.setState(MS.MarkdownStateSetextHeading1Line2) + token.type = MTT.TokenSetextHeading1Line2 + + else: + self.setState(MS.MarkdownStateSetextHeading2Line2) + token.type = MTT.TokenSetextHeading2Line2 + + self.addToken(token) + return True + + if level > 0: + if setextMatch: + token.length = len(text) + token.position = 0 + self.addToken(token) + return True + + else: + # Restart tokenizing on the previous line. + self.requestBacktrack() + False + + return False + + def tokenizeAtxHeading(self, text): + escapedText = self.dummyOutEscapeCharacters(text) + trailingPoundCount = 0 + level = 0 + + #Count the number of pound signs at the front of the string, + #up to the maximum allowed, to determine the heading level. + + while escapedText[level] == "#": + level += 1 + if level >= len(escapedText) or level >= self.MAX_MARKDOWN_HEADING_LEVEL: + break + + if level > 0 and level < len(text): + # Count how many pound signs are at the end of the text. + while escapedText[-trailingPoundCount -1] == "#": + trailingPoundCount += 1 + + token = Token() + token.position = 0 + token.length = len(text) + token.type = MTT.TokenAtxHeading1 + level -1 + token.openingMarkupLength = level + token.closingMarkupLength = trailingPoundCount + self.addToken(token) + self.setState(MS.MarkdownStateAtxHeading1 + level -1) + return True + return False + + def tokenizeNumberedList(self, text): + previousState = self.previousState + if (previousState in [MS.MarkdownStateParagraphBreak, + MS.MarkdownStateUnknown, + MS.MarkdownStateCodeBlock, + MS.MarkdownStateCodeFenceEnd,] and \ + self.numberedListRegex.exactMatch(text)) or \ + (previousState in [MS.MarkdownStateListLineBreak, + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList,] and \ + self.numberedNestedListRegex.exactMatch(text)): + periodIndex = text.find(".") + parenthIndex = text.find(")") + + if periodIndex < 0: + index = parenthIndex + elif parenthIndex < 0: + index = periodIndex + elif parenthIndex > periodIndex: + index = periodIndex + else: + index = parenthIndex + + if index > 0: + token = Token() + token.type = MTT.TokenNumberedList + token.position = 0 + token.length = len(text) + token.openingMarkupLength = index + 2 + self.addToken(token) + self.setState(MS.MarkdownStateNumberedList) + return True + + return False + + return False + + def tokenizeBulletPointList(self, text): + foundBulletChar = False + bulletCharIndex = -1 + spaceCount = 0 + whitespaceFoundAfterBulletChar = False + previousState = self.previousState + + if previousState not in [MS.MarkdownStateUnknown, + MS.MarkdownStateParagraphBreak, + MS.MarkdownStateListLineBreak, + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList, + MS.MarkdownStateCodeBlock, + MS.MarkdownStateCodeFenceEnd]: + return False + + # Search for the bullet point character, which can + # be either a '+', '-', or '*'. + + for i in range(len(text)): + if text[i] == " ": + if foundBulletChar: + # We've confirmed it's a bullet point by the whitespace that + # follows the bullet point character, and can now exit the + # loop. + + whitespaceFoundAfterBulletChar = True + break + + else: + spaceCount += 1 + + # If this list item is the first in the list, ensure the + # number of spaces preceeding the bullet point does not + # exceed three, as that would indicate a code block rather + # than a bullet point list. + + if spaceCount > 3 and previousState not in [ + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList, + MS.MarkdownStateListLineBreak,] and \ + previousState in [ + MS.MarkdownStateParagraphBreak, + MS.MarkdownStateUnknown, + MS.MarkdownStateCodeBlock, + MS.MarkdownStateCodeFenceEnd,]: + return False + + elif text[i] == "\t": + if foundBulletChar: + # We've confirmed it's a bullet point by the whitespace that + # follows the bullet point character, and can now exit the + # loop. + + whitespaceFoundAfterBulletChar = True + break + + elif previousState in [ + MS.MarkdownStateParagraphBreak, + MS.MarkdownStateUnknown]: + + # If this list item is the first in the list, ensure that + # no tab character preceedes the bullet point, as that would + # indicate a code block rather than a bullet point list. + + return False + + elif text[i] in ["+", "-", "*"]: + foundBulletChar = True + bulletCharIndex = i + + else: + return False + + if bulletCharIndex >= 0 and whitespaceFoundAfterBulletChar: + token = Token() + token.type = MTT.TokenBulletPointList + token.position = 0 + token.length = len(text) + token.openingMarkupLength = bulletCharIndex + 2 + self.addToken(token) + self.setState(MS.MarkdownStateBulletPointList) + return True + + return False + + def tokenizeHorizontalRule (self, text): + if self.hruleRegex.exactMatch(text): + token = Token() + token.type = MTT.TokenHorizontalRule + token.position = 0 + token.length = len(text) + self.addToken(token) + self.setState(MS.MarkdownStateHorizontalRule) + return True + + return False + + def tokenizeLineBreak(self, text): + currentState = self.currentState + previousState = self.previousState + nextState = self.nextState + + if currentState in [ + MS.MarkdownStateParagraph, + MS.MarkdownStateBlockquote, + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList,]: + if previousState in [ + MS.MarkdownStateParagraph, + MS.MarkdownStateBlockquote, + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList,]: + self.requestBacktrack() + + if nextState in [ + MS.MarkdownStateParagraph, + MS.MarkdownStateBlockquote, + MS.MarkdownStateNumberedList, + MS.MarkdownStateBulletPointList,]: + self.requestBacktrack() + if self.lineBreakRegex.exactMatch(text): + token = Token() + token.type = MTT.TokenLineBreak + token.position = len(text) - 1 + token.length = 1 + self.addToken(token) + return True + + return False + + def tokenizeBlockquote(self, text): + previousState = self.previousState + if previousState == MS.MarkdownStateBlockquote or \ + self.blockquoteRegex.exactMatch(text): + + # Find any '>' characters at the front of the line. + markupLength = 0 + + for i in range(len(text)): + if text[i] == ">": + markupLength = i + 1 + elif text[i] != " ": + # There are no more '>' characters at the front of the line, + # so stop processing. + break + + token = Token() + token.type = MTT.TokenBlockquote + token.position = 0 + token.length = len(text) + + if markupLength > 0: + token.openingMarkupLength = markupLength + + self.addToken(token) + self.setState(MS.MarkdownStateBlockquote) + return True + return False + + def tokenizeCodeBlock(self, text): + previousState = self.previousState + if previousState in [ + MS.MarkdownStateInGithubCodeFence, + MS.MarkdownStateInPandocCodeFence]: + self.setState(previousState) + + if (previousState == MS.MarkdownStateInGithubCodeFence and \ + self.githubCodeFenceEndRegex.exactMatch(text)) or \ + (previousState == MS.MarkdownStateInPandocCodeFence and \ + self.pandocCodeFenceEndRegex.exactMatch(text)): + token = Token() + token.type = MTT.TokenCodeFenceEnd + token.position = 0 + token.length = len(text) + self.addToken(token) + self.setState(MS.MarkdownStateCodeFenceEnd) + + else: + token = Token() + token.type = MTT.TokenCodeBlock + token.position = 0 + token.length = len(text) + self.addToken(token) + + return True + + elif previousState in [ + MS.MarkdownStateCodeBlock, + MS.MarkdownStateParagraphBreak, + MS.MarkdownStateUnknown,] and \ + (text[:1] == "\t" or text[:4] == " "): + token = Token() + token.type = MTT.TokenCodeBlock + token.position = 0 + token.length = len(text) + token.openingMarkupLength = len(text) - len(text.lstrip()) + self.addToken(token) + self.setState(MS.MarkdownStateCodeBlock) + return True + + elif previousState in [ + MS.MarkdownStateParagraphBreak, + MS.MarkdownStateParagraph, + MS.MarkdownStateUnknown, + MS.MarkdownStateListLineBreak,]: + foundCodeFenceStart = False + token = Token() + if self.githubCodeFenceStartRegex.exactMatch(text): + foundCodeFenceStart = True + token.type = MTT.TokenGithubCodeFence + self.setState(MS.MarkdownStateInGithubCodeFence) + + elif self.pandocCodeFenceStartRegex.exactMatch(text): + foundCodeFenceStart = True + token.type = MTT.TokenPandocCodeFence + self.setState(MS.MarkdownStateInPandocCodeFence) + + if foundCodeFenceStart: + token.position = 0 + token.length = len(text) + self.addToken(token) + return True + + return False + + def tokenizeMultilineComment(self, text): + previousState = self.previousState + + if previousState == MS.MarkdownStateComment: + # Find the end of the comment, if any. + index = text.find("-->") + token = Token() + token.type = MTT.TokenHtmlComment + token.position = 0 + + if index >= 0: + token.length = index + 3 + self.addToken(token) + + # Return false so that the rest of the line that isn't within + # the commented segment can be highlighted as normal paragraph + # text. + + else: + token.length = len(text) + self.addToken(token) + self.setState(MS.MarkdownStateComment) + return True + + return False + + def tokenizeInline(self, text): + escapedText = self.dummyOutEscapeCharacters(text) + + # Check if the line is a reference definition. + if self.referenceDefinitionRegex.exactMatch(text): + colonIndex = escapedText.find(":") + token = Token() + token.type = MTT.TokenReferenceDefinition + token.position = 0 + token.length = colonIndex + 1 + self.addToken(token) + + # Replace the first bracket so that the '[...]:' reference definition + # start doesn't get highlighted as a reference link. + + firstBracketIndex = escapedText.find("[") + if firstBracketIndex >= 0: + i = firstBracketIndex + escapedText = escapedText[:i] + self.DUMMY_CHAR + escapedText[i+1:] + + escapedText = self.tokenizeVerbatim(escapedText) + escapedText = self.tokenizeHtmlComments(escapedText) + escapedText = self.tokenizeTableHeaderRow(escapedText) + escapedText = self.tokenizeTableRow(escapedText) + escapedText = self.tokenizeMatches(MTT.TokenImage, escapedText, self.imageRegex, 0, 0, False, True) + escapedText = self.tokenizeMatches(MTT.TokenInlineLink, escapedText, self.inlineLinkRegex, 0, 0, False, True) + escapedText = self.tokenizeMatches(MTT.TokenReferenceLink, escapedText, self.referenceLinkRegex, 0, 0, False, True) + escapedText = self.tokenizeMatches(MTT.TokenHtmlEntity, escapedText, self.htmlEntityRegex) + escapedText = self.tokenizeMatches(MTT.TokenAutomaticLink, escapedText, self.automaticLinkRegex, 0, 0, False, True) + escapedText = self.tokenizeMatches(MTT.TokenStrong, escapedText, self.strongRegex, 2, 2, True) + escapedText = self.tokenizeMatches(MTT.TokenEmphasis, escapedText, self.emphasisRegex, 1, 1, True) + escapedText = self.tokenizeMatches(MTT.TokenMention, escapedText, self.mentionRegex, 0, 0, False, True) + escapedText = self.tokenizeMatches(MTT.TokenCMAddition, escapedText, self.CMAdditionRegex, 3, 3, True) + escapedText = self.tokenizeMatches(MTT.TokenCMDeletion, escapedText, self.CMDeletionRegex, 3, 3, True) + escapedText = self.tokenizeMatches(MTT.TokenCMSubstitution, escapedText, self.CMSubstitutionRegex, 3, 3, True) + escapedText = self.tokenizeMatches(MTT.TokenCMComment, escapedText, self.CMCommentRegex, 3, 3, True) + escapedText = self.tokenizeMatches(MTT.TokenCMHighlight, escapedText, self.CMHighlightRegex, 3, 3, True) + escapedText = self.tokenizeMatches(MTT.TokenStrikethrough, escapedText, self.strikethroughRegex, 2, 2, True) + escapedText = self.tokenizeMatches(MTT.TokenHtmlTag, escapedText, self.htmlTagRegex) + escapedText = self.tokenizeMatches(MTT.TokenSubScript, escapedText, self.subScriptRegex, 1, 1, True) + escapedText = self.tokenizeMatches(MTT.TokenSuperScript, escapedText, self.superScriptRegex, 1, 1, True) + + return True + + def tokenizeVerbatim(self, text): + index = self.verbatimRegex.indexIn(text) + + while index >= 0: + end = "" + count = self.verbatimRegex.matchedLength() + + # Search for the matching end, which should have the same number + # of back ticks as the start. + for i in range(count): + end += '`' + + endIndex = text.find(end, index + count) + + # If the end was found, add the verbatim token. + if endIndex >= 0: + token = Token() + token.type = MTT.TokenVerbatim + token.position = index + token.length = endIndex + count - index + token.openingMarkupLength = count + token.closingMarkupLength = count + self.addToken(token) + + # Fill out the token match in the string with the dummy + # character so that searches for other Markdown elements + # don't find anything within this token's range in the string. + + for i in range(index, index + token.length): + text = text[:i] + self.DUMMY_CHAR + text[i+1:] + + index += token.length + + # Else start searching again at the very next character. + else: + index += 1 + + index = self.verbatimRegex.indexIn(text, index) + return text + + def tokenizeHtmlComments(self, text): + previousState = self.previousState + + # Check for the end of a multiline comment so that it doesn't get further + # tokenized. Don't bother formatting the comment itself, however, because + # it should have already been tokenized in tokenizeMultilineComment(). + if previousState == MS.MarkdownStateComment: + commentEnd = text.find("-->") + for i in range(commentEnd + 3): + text = text[:i] + self.DUMMY_CHAR + text[i+1:] + + # Now check for inline comments (non-multiline). + commentStart = self.htmlInlineCommentRegex.indexIn(text) + + while commentStart >= 0: + commentLength = self.htmlInlineCommentRegex.matchedLength() + token = Token() + token.type = MTT.TokenHtmlComment + token.position = commentStart + token.length = commentLength + self.addToken(token) + + # Replace comment segment with dummy characters so that it doesn't + # get tokenized again. + + for i in range(commentStart, commentStart + commentLength): + text = text[:i] + self.DUMMY_CHAR + text[i+1:] + + commentStart = self.htmlInlineCommentRegex.indexIn(text, commentStart + commentLength) + + # Find multiline comment start, if any. + commentStart = text.find("") + else: + cursor.insertText("") + cursor.movePosition(QTextCursor.PreviousCharacter, + QTextCursor.MoveAnchor, 4) + self.setTextCursor(cursor) + + def commentLine(self): + cursor = self.textCursor() + + start = cursor.selectionStart() + end = cursor.selectionEnd() + block = self.document().findBlock(start) + block2 = self.document().findBlock(end) + + if True: + # Method 1 + cursor.beginEditBlock() + while block.isValid(): + self.commentBlock(block) + if block == block2: break + block = block.next() + cursor.endEditBlock() + + else: + # Method 2 + cursor.beginEditBlock() + cursor.setPosition(block.position()) + cursor.insertText("") + cursor.endEditBlock() + + def commentBlock(self, block): + cursor = QTextCursor(block) + text = block.text() + if text[:5] == "": + text2 = text[5:-4] + else: + text2 = "" + self.selectBlock(cursor) + cursor.insertText(text2) + + def insertFormattingMarkup(self, markup): + cursor = self.textCursor() + + # Select begining and end of words + self.selectWord(cursor) + + if cursor.hasSelection(): + start = cursor.selectionStart() + end = cursor.selectionEnd() + len(markup) + cursor.beginEditBlock() + cursor.setPosition(start) + cursor.insertText(markup) + cursor.setPosition(end) + cursor.insertText(markup) + cursor.endEditBlock() + cursor.movePosition(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor, len(markup)) + #self.setTextCursor(cursor) + + else: + # Insert markup twice (for opening and closing around the cursor), + # and then move the cursor to be between the pair. + cursor.beginEditBlock() + cursor.insertText(markup) + cursor.insertText(markup) + cursor.movePosition(QTextCursor.PreviousCharacter, + QTextCursor.MoveAnchor, len(markup)) + cursor.endEditBlock() + self.setTextCursor(cursor) + + def clearFormat(self): + cursor = self.textCursor() + text = cursor.selectedText() + if not text: + self.selectBlock(cursor) + text = cursor.selectedText() + text = self.clearedFormat(text) + cursor.insertText(text) + + def clearedFormat(self, text): + # FIXME: clear also block formats + for reg, rep, flags in [ + ("\*\*(.*?)\*\*", "\\1", None), # bold + ("__(.*?)__", "\\1", None), # bold + ("\*(.*?)\*", "\\1", None), # emphasis + ("_(.*?)_", "\\1", None), # emphasis + ("`(.*?)`", "\\1", None), # verbatim + ("~~(.*?)~~", "\\1", None), # strike + ("\^(.*?)\^", "\\1", None), # superscript + ("~(.*?)~", "\\1", None), # subscript + ("", "\\1", re.S), # comments + + + # LINES OR BLOCKS + (r"^#*\s*(.+?)\s*", "\\1", re.M), # ATX + (r"^[=-]*$", "", re.M), # Setext + (r"^`*$", "", re.M), # Code block fenced + (r"^\s*[-+*]\s*(.*?)\s*$", "\\1", re.M), # Bullet List + (r"^\s*[0-9a-z](\.|\))\s*(.*?)\s*$", "\\2", re.M), # Bullet List + (r"\s*[>\s]*(.*?)\s*$", "\\1", re.M), # Code block and blockquote + + ]: + text = re.sub(reg, rep, text, flags if flags else 0) + return text + + def clearedFormatForStats(self, text): + # Remove stuff that musn't be counted + # FIXME: clear also block formats + for reg, rep, flags in [ + ("", "", re.S), # comments + ]: + text = re.sub(reg, rep, text, flags if flags else 0) + return text + + def titleSetext(self, level): + cursor = self.textCursor() + + cursor.beginEditBlock() + # Is it already a Setext header? + if cursor.block().userState() in [ + MS.MarkdownStateSetextHeading1Line2, + MS.MarkdownStateSetextHeading2Line2]: + cursor.movePosition(QTextCursor.PreviousBlock) + + text = cursor.block().text() + + if cursor.block().userState() in [ + MS.MarkdownStateSetextHeading1Line1, + MS.MarkdownStateSetextHeading2Line1]: + # Need to remove line below + c = QTextCursor(cursor.block().next()) + self.selectBlock(c) + c.insertText("") + + char = "=" if level == 1 else "-" + text = re.sub("^#*\s*(.*)\s*#*", "\\1", text) # Removes # + sub = char * len(text) + text = text + "\n" + sub + + self.selectBlock(cursor) + cursor.insertText(text) + cursor.endEditBlock() + + def titleATX(self, level): + cursor = self.textCursor() + text = cursor.block().text() + + # Are we in a Setext Header? + if cursor.block().userState() in [ + MS.MarkdownStateSetextHeading1Line1, + MS.MarkdownStateSetextHeading2Line1]: + # Need to remove line below + cursor.beginEditBlock() + c = QTextCursor(cursor.block().next()) + self.selectBlock(c) + c.insertText("") + + self.selectBlock(cursor) + cursor.insertText(text) + cursor.endEditBlock() + return + + elif cursor.block().userState() in [ + MS.MarkdownStateSetextHeading1Line2, + MS.MarkdownStateSetextHeading2Line2]: + cursor.movePosition(QTextCursor.PreviousBlock) + self.setTextCursor(cursor) + self.titleATX(level) + return + + m = re.match("^(#+)(\s*)(.+)", text) + if m: + pre = m.group(1) + space = m.group(2) + txt = m.group(3) + + if len(pre) == level: + # Remove title + text = txt + else: + text = "#" * level + space + txt + + else: + text = "#" * level + " " + text + + self.selectBlock(cursor) + cursor.insertText(text) diff --git a/manuskript/ui/views/basicItemView_ui.py b/manuskript/ui/views/basicItemView_ui.py index f0594fad..91e09124 100644 --- a/manuskript/ui/views/basicItemView_ui.py +++ b/manuskript/ui/views/basicItemView_ui.py @@ -2,8 +2,7 @@ # Form implementation generated from reading ui file 'manuskript/ui/views/basicItemView_ui.ui' # -# Created: Thu Mar 3 17:26:11 2016 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -51,7 +50,7 @@ class Ui_basicItemView(object): self.label_9 = QtWidgets.QLabel(basicItemView) self.label_9.setObjectName("label_9") self.verticalLayout.addWidget(self.label_9) - self.txtSummaryFull = textEditView(basicItemView) + self.txtSummaryFull = MDEditCompleter(basicItemView) self.txtSummaryFull.setObjectName("txtSummaryFull") self.verticalLayout.addWidget(self.txtSummaryFull) @@ -67,6 +66,6 @@ class Ui_basicItemView(object): self.txtSummarySentence.setPlaceholderText(_translate("basicItemView", "One line summary")) self.label_9.setText(_translate("basicItemView", "Few sentences summary:")) +from manuskript.ui.views.MDEditCompleter import MDEditCompleter from manuskript.ui.views.cmbOutlineCharacterChoser import cmbOutlineCharacterChoser from manuskript.ui.views.lineEditView import lineEditView -from manuskript.ui.views.textEditView import textEditView diff --git a/manuskript/ui/views/basicItemView_ui.ui b/manuskript/ui/views/basicItemView_ui.ui index 6466cc73..efccc995 100644 --- a/manuskript/ui/views/basicItemView_ui.ui +++ b/manuskript/ui/views/basicItemView_ui.ui @@ -101,15 +101,15 @@
- + - textEditView + MDEditCompleter QTextEdit -
manuskript.ui.views.textEditView.h
+
manuskript.ui.views.MDEditCompleter.h
cmbOutlineCharacterChoser diff --git a/manuskript/ui/views/metadataView_ui.py b/manuskript/ui/views/metadataView_ui.py index 2069a06d..ba1b6591 100644 --- a/manuskript/ui/views/metadataView_ui.py +++ b/manuskript/ui/views/metadataView_ui.py @@ -2,8 +2,7 @@ # Form implementation generated from reading ui file 'manuskript/ui/views/metadataView_ui.ui' # -# Created: Fri Apr 8 14:24:47 2016 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -14,8 +13,8 @@ class Ui_metadataView(object): metadataView.setObjectName("metadataView") metadataView.resize(400, 537) self.verticalLayout = QtWidgets.QVBoxLayout(metadataView) - self.verticalLayout.setSpacing(0) self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setSpacing(0) self.verticalLayout.setObjectName("verticalLayout") self.grpProperties = collapsibleGroupBox2(metadataView) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) @@ -27,8 +26,8 @@ class Ui_metadataView(object): self.grpProperties.setCheckable(True) self.grpProperties.setObjectName("grpProperties") self.verticalLayout_28 = QtWidgets.QVBoxLayout(self.grpProperties) - self.verticalLayout_28.setSpacing(0) self.verticalLayout_28.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_28.setSpacing(0) self.verticalLayout_28.setObjectName("verticalLayout_28") self.properties = propertiesView(self.grpProperties) self.properties.setMinimumSize(QtCore.QSize(0, 50)) @@ -40,8 +39,8 @@ class Ui_metadataView(object): self.grpSummary.setCheckable(True) self.grpSummary.setObjectName("grpSummary") self.verticalLayout_22 = QtWidgets.QVBoxLayout(self.grpSummary) - self.verticalLayout_22.setSpacing(0) self.verticalLayout_22.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_22.setSpacing(0) self.verticalLayout_22.setObjectName("verticalLayout_22") self.txtSummarySentence = lineEditView(self.grpSummary) self.txtSummarySentence.setInputMask("") @@ -53,10 +52,9 @@ class Ui_metadataView(object): self.line.setLineWidth(0) self.line.setMidLineWidth(0) self.line.setFrameShape(QtWidgets.QFrame.HLine) - self.line.setFrameShadow(QtWidgets.QFrame.Sunken) self.line.setObjectName("line") self.verticalLayout_22.addWidget(self.line) - self.txtSummaryFull = textEditView(self.grpSummary) + self.txtSummaryFull = MDEditCompleter(self.grpSummary) self.txtSummaryFull.setFrameShape(QtWidgets.QFrame.NoFrame) self.txtSummaryFull.setObjectName("txtSummaryFull") self.verticalLayout_22.addWidget(self.txtSummaryFull) @@ -66,10 +64,10 @@ class Ui_metadataView(object): self.grpNotes.setCheckable(True) self.grpNotes.setObjectName("grpNotes") self.horizontalLayout_29 = QtWidgets.QHBoxLayout(self.grpNotes) - self.horizontalLayout_29.setSpacing(0) self.horizontalLayout_29.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_29.setSpacing(0) self.horizontalLayout_29.setObjectName("horizontalLayout_29") - self.txtNotes = textEditCompleter(self.grpNotes) + self.txtNotes = MDEditCompleter(self.grpNotes) self.txtNotes.setFrameShape(QtWidgets.QFrame.NoFrame) self.txtNotes.setObjectName("txtNotes") self.horizontalLayout_29.addWidget(self.txtNotes) @@ -79,8 +77,8 @@ class Ui_metadataView(object): self.grpRevisions.setCheckable(True) self.grpRevisions.setObjectName("grpRevisions") self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.grpRevisions) - self.verticalLayout_2.setSpacing(0) self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_2.setSpacing(0) self.verticalLayout_2.setObjectName("verticalLayout_2") self.revisions = revisions(self.grpRevisions) self.revisions.setMinimumSize(QtCore.QSize(0, 50)) @@ -103,8 +101,7 @@ class Ui_metadataView(object): self.grpRevisions.setTitle(_translate("metadataView", "Revisions")) from manuskript.ui.collapsibleGroupBox2 import collapsibleGroupBox2 -from manuskript.ui.views.textEditView import textEditView -from manuskript.ui.views.textEditCompleter import textEditCompleter -from manuskript.ui.views.propertiesView import propertiesView -from manuskript.ui.views.lineEditView import lineEditView from manuskript.ui.revisions import revisions +from manuskript.ui.views.MDEditCompleter import MDEditCompleter +from manuskript.ui.views.lineEditView import lineEditView +from manuskript.ui.views.propertiesView import propertiesView diff --git a/manuskript/ui/views/metadataView_ui.ui b/manuskript/ui/views/metadataView_ui.ui index 13b97c1f..1733dadf 100644 --- a/manuskript/ui/views/metadataView_ui.ui +++ b/manuskript/ui/views/metadataView_ui.ui @@ -132,7 +132,7 @@ - + QFrame::NoFrame @@ -172,7 +172,7 @@ 0 - + QFrame::NoFrame @@ -228,9 +228,9 @@ - textEditView + MDEditCompleter QTextEdit -
manuskript.ui.views.textEditView.h
+
manuskript.ui.views.MDEditCompleter.h
lineEditView @@ -249,11 +249,6 @@
manuskript.ui.views.propertiesView.h
1
- - textEditCompleter - QTextEdit -
manuskript.ui.views.textEditCompleter.h
-
revisions QWidget diff --git a/manuskript/ui/views/textEditView.py b/manuskript/ui/views/textEditView.py index 2d10c30f..091f05f0 100644 --- a/manuskript/ui/views/textEditView.py +++ b/manuskript/ui/views/textEditView.py @@ -7,13 +7,10 @@ from PyQt5.QtGui import QTextBlockFormat, QTextCharFormat, QFont, QColor, QIcon, from PyQt5.QtWidgets import QWidget, QTextEdit, qApp, QAction, QMenu from manuskript import settings -from manuskript.enums import Outline +from manuskript.enums import Outline, World, Character, Plot from manuskript import functions as F from manuskript.models.outlineModel import outlineModel -from manuskript.ui.editors.MDFunctions import MDFormatSelection -from manuskript.ui.editors.MMDHighlighter import MMDHighlighter -from manuskript.ui.editors.basicHighlighter import basicHighlighter -from manuskript.ui.editors.textFormat import textFormat +from manuskript.ui.highlighters import BasicHighlighter from manuskript.ui import style as S try: @@ -23,8 +20,8 @@ except ImportError: class textEditView(QTextEdit): - def __init__(self, parent=None, index=None, html=None, spellcheck=True, highlighting=False, dict="", - autoResize=False): + def __init__(self, parent=None, index=None, html=None, spellcheck=True, + highlighting=False, dict="", autoResize=False): QTextEdit.__init__(self, parent) self._column = Outline.text self._index = None @@ -42,16 +39,18 @@ class textEditView(QTextEdit): # position, so we only have it's ID as reference. We store it to # update at the propper time. self._updateIndexFromID = None + self._themeData = None + self._highlighterClass = BasicHighlighter self.spellcheck = spellcheck self.currentDict = dict if dict else settings.dict + self._defaultFontSize = qApp.font().pointSize() self.highlighter = None self.setAutoResize(autoResize) self._defaultBlockFormat = QTextBlockFormat() self._defaultCharFormat = QTextCharFormat() self.highlightWord = "" self.highligtCS = False - self.defaultFontPointSize = qApp.font().pointSize() self._dict = None # self.document().contentsChanged.connect(self.submit, F.AUC) @@ -80,7 +79,8 @@ class textEditView(QTextEdit): # Spellchecking if enchant and self.spellcheck: try: - self._dict = enchant.Dict(self.currentDict if self.currentDict else self.getDefaultLocale()) + self._dict = enchant.Dict(self.currentDict if self.currentDict + else self.getDefaultLocale()) except enchant.errors.DictNotFoundError: self.spellcheck = False @@ -88,7 +88,7 @@ class textEditView(QTextEdit): self.spellcheck = False if self._highlighting and not self.highlighter: - self.highlighter = basicHighlighter(self) + self.highlighter = self._highlighterClass(self) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) def getDefaultLocale(self): @@ -177,27 +177,11 @@ class textEditView(QTextEdit): self.updateText() def setupEditorForIndex(self, index): - # In which model are we editing? - if type(index.model()) != outlineModel: - self._textFormat = "text" - return - - # what type of text are we editing? - if self._column not in [Outline.text, Outline.notes]: - self._textFormat = "text" - - else: - self._textFormat = "md" - # Setting highlighter if self._highlighting: - item = index.internalPointer() - if self._column in [Outline.text, Outline.notes]: - self.highlighter = MMDHighlighter(self) - else: - self.highlighter = basicHighlighter(self) - + self.highlighter = self._highlighterClass(self) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) + self.highlighter.updateColorScheme() def loadFontSettings(self): if self._fromTheme or \ @@ -209,8 +193,10 @@ class textEditView(QTextEdit): opt = settings.textEditor f = QFont() f.fromString(opt["font"]) - background = opt["background"] if not opt["backgroundTransparent"] else "transparent" - foreground = opt["fontColor"] if not opt["backgroundTransparent"] else S.text + background = (opt["background"] if not opt["backgroundTransparent"] + else "transparent") + foreground = opt["fontColor"] # if not opt["backgroundTransparent"] + # else S.text # self.setFont(f) self.setStyleSheet("""QTextEdit{{ background: {bg}; @@ -230,6 +216,7 @@ class textEditView(QTextEdit): maxWidth = "max-width: {}px;".format(opt["maxWidth"]) if opt["maxWidth"] else "", ) ) + self._defaultFontSize = f.pointSize() # We set the parent background to the editor's background in case # there are margins. We check that the parent class is a QWidget because @@ -266,6 +253,7 @@ class textEditView(QTextEdit): self._defaultBlockFormat = bf if self.highlighter: + self.highlighter.updateColorScheme() self.highlighter.setMisspelledColor(QColor(opt["misspelled"])) self.highlighter.setDefaultCharFormat(self._defaultCharFormat) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) @@ -415,7 +403,8 @@ class textEditView(QTextEdit): self.sizeChange() def sizeChange(self): - docHeight = self.document().size().height() + opt = settings.textEditor + docHeight = self.document().size().height() + 2 * opt["marginsTB"] if self.heightMin <= docHeight <= self.heightMax: self.setMinimumHeight(docHeight) @@ -461,6 +450,31 @@ class textEditView(QTextEdit): Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) QTextEdit.mousePressEvent(self, event) + def wheelEvent(self, event): + """ + We catch wheelEvent if key modifier is CTRL to change font size. + Note: this should be in a class specific for main textEditView (#TODO). + """ + if event.modifiers() & Qt.ControlModifier: + # Get the wheel angle. + d = event.angleDelta().y() / 120 + + # Update settings + f = QFont() + f.fromString(settings.textEditor["font"]) + f.setPointSizeF(f.pointSizeF() + d) + settings.textEditor["font"] = f.toString() + + # Update font to all textEditView. Drastically. + for w in F.mainWindow().findChildren(textEditView, QRegExp(".*")): + w.loadFontSettings() + + # We tell the world that we accepted this event + event.accept() + return + + QTextEdit.wheelEvent(self, event) + class SpellAction(QAction): """A special QAction that returns the text in a signal. Used for spellckech.""" @@ -560,32 +574,6 @@ class textEditView(QTextEdit): QTextEdit.focusOutEvent(self, event) self.submit() - def focusInEvent(self, event): - """Finds textFormatter and attach them to that view.""" - QTextEdit.focusInEvent(self, event) - - p = self.parent() - while p.parent(): - p = p.parent() - - if self._index: - for tF in p.findChildren(textFormat, QRegExp(".*"), Qt.FindChildrenRecursively): - tF.updateFromIndex(self._index) - tF.setTextEdit(self) - - def applyFormat(self, _format): - - if self._textFormat == "md": - - if _format == "Bold": - MDFormatSelection(self, 0) - elif _format == "Italic": - MDFormatSelection(self, 1) - elif _format == "Code": - MDFormatSelection(self, 2) - elif _format == "Clear": - MDFormatSelection(self) - ############################################################################### # KEYBOARD SHORTCUTS ############################################################################### @@ -596,7 +584,7 @@ class textEditView(QTextEdit): edit that has focus. So we can pass it the call for documents edits like: duplicate, move up, etc. """ - if self._index and self._column == Outline.text.value: + if self._index and self._column == Outline.text: function = getattr(F.mainWindow().treeRedacOutline, functionName) function()