360 lines
13 KiB
Python
360 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""novelWriter GUI Document Highlighter
|
||
|
||
novelWriter – GUI Document Highlighter
|
||
========================================
|
||
Syntax highlighting for MarkDown
|
||
|
||
File History:
|
||
Created: 2019-04-06 [0.0.1]
|
||
|
||
This file is a part of novelWriter
|
||
Copyright 2018–2020, Veronica Berglyd Olsen
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU General Public License as published by
|
||
the Free Software Foundation, either version 3 of the License, or
|
||
(at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful, but
|
||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||
General Public License for more details.
|
||
|
||
You should have received a copy of the GNU General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
"""
|
||
|
||
import nw
|
||
import logging
|
||
|
||
from PyQt5.QtCore import Qt, QRegularExpression
|
||
from PyQt5.QtGui import (
|
||
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush
|
||
)
|
||
|
||
from nw.constants import nwUnicode, nwRegEx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class GuiDocHighlighter(QSyntaxHighlighter):
|
||
|
||
def __init__(self, theDoc, theParent):
|
||
QSyntaxHighlighter.__init__(self, theDoc)
|
||
|
||
logger.debug("Initialising GuiDocHighlighter ...")
|
||
self.mainConf = nw.CONFIG
|
||
self.theDoc = theDoc
|
||
self.theParent = theParent
|
||
self.theTheme = theParent.theTheme
|
||
self.theIndex = theParent.theIndex
|
||
self.theDict = None
|
||
self.theHandle = None
|
||
self.spellCheck = False
|
||
self.spellRx = None
|
||
self.hRules = []
|
||
self.hStyles = {}
|
||
|
||
self.colHead = QColor(0, 0, 0)
|
||
self.colHeadH = QColor(0, 0, 0)
|
||
self.colEmph = QColor(0, 0, 0)
|
||
self.colDialN = QColor(0, 0, 0)
|
||
self.colDialD = QColor(0, 0, 0)
|
||
self.colDialS = QColor(0, 0, 0)
|
||
self.colComm = QColor(0, 0, 0)
|
||
self.colKey = QColor(0, 0, 0)
|
||
self.colVal = QColor(0, 0, 0)
|
||
self.colSpell = QColor(0, 0, 0)
|
||
self.colTagErr = QColor(0, 0, 0)
|
||
self.colRepTag = QColor(0, 0, 0)
|
||
|
||
self.initHighlighter()
|
||
|
||
logger.debug("GuiDocHighlighter initialisation complete")
|
||
|
||
return
|
||
|
||
def initHighlighter(self):
|
||
"""Initialise the syntax highlighter, setting all the colour
|
||
rules and building the regexes.
|
||
"""
|
||
logger.debug("Setting up highlighting rules")
|
||
|
||
self.colHead = QColor(*self.theTheme.colHead)
|
||
self.colHeadH = QColor(*self.theTheme.colHeadH)
|
||
self.colDialN = QColor(*self.theTheme.colDialN)
|
||
self.colDialD = QColor(*self.theTheme.colDialD)
|
||
self.colDialS = QColor(*self.theTheme.colDialS)
|
||
self.colComm = QColor(*self.theTheme.colComm)
|
||
self.colKey = QColor(*self.theTheme.colKey)
|
||
self.colVal = QColor(*self.theTheme.colVal)
|
||
self.colSpell = QColor(*self.theTheme.colSpell)
|
||
self.colTagErr = QColor(*self.theTheme.colTagErr)
|
||
self.colRepTag = QColor(*self.theTheme.colRepTag)
|
||
self.colMod = QColor(*self.theTheme.colMod)
|
||
self.colTrail = QColor(*self.theTheme.colEmph)
|
||
self.colTrail.setAlpha(64)
|
||
|
||
if self.mainConf.highlightEmph:
|
||
self.colEmph = QColor(*self.theTheme.colEmph)
|
||
else:
|
||
self.colEmph = None
|
||
|
||
self.hStyles = {
|
||
"header1" : self._makeFormat(self.colHead, "bold", 1.8),
|
||
"header2" : self._makeFormat(self.colHead, "bold", 1.6),
|
||
"header3" : self._makeFormat(self.colHead, "bold", 1.4),
|
||
"header4" : self._makeFormat(self.colHead, "bold", 1.2),
|
||
"header1h" : self._makeFormat(self.colHeadH, "bold", 1.8),
|
||
"header2h" : self._makeFormat(self.colHeadH, "bold", 1.6),
|
||
"header3h" : self._makeFormat(self.colHeadH, "bold", 1.4),
|
||
"header4h" : self._makeFormat(self.colHeadH, "bold", 1.2),
|
||
"bold" : self._makeFormat(self.colEmph, "bold"),
|
||
"italic" : self._makeFormat(self.colEmph, "italic"),
|
||
"strike" : self._makeFormat(self.colEmph, "strike"),
|
||
"trailing" : self._makeFormat(self.colTrail, "background"),
|
||
"nobreak" : self._makeFormat(self.colTrail, "background"),
|
||
"dialogue1" : self._makeFormat(self.colDialN),
|
||
"dialogue2" : self._makeFormat(self.colDialD),
|
||
"dialogue3" : self._makeFormat(self.colDialS),
|
||
"replace" : self._makeFormat(self.colRepTag),
|
||
"hidden" : self._makeFormat(self.colComm),
|
||
"keyword" : self._makeFormat(self.colKey),
|
||
"modifier" : self._makeFormat(self.colMod),
|
||
"value" : self._makeFormat(self.colVal, "underline"),
|
||
}
|
||
|
||
self.hRules = []
|
||
|
||
# Trailing Spaces, 2+
|
||
self.hRules.append((
|
||
r"[ ]{2,}$", {
|
||
0 : self.hStyles["trailing"],
|
||
}
|
||
))
|
||
|
||
# Non-Breaking Spaces
|
||
self.hRules.append((
|
||
"[%s%s]+" % (nwUnicode.U_NBSP, nwUnicode.U_THNBSP), {
|
||
0 : self.hStyles["nobreak"],
|
||
}
|
||
))
|
||
|
||
# Quoted Strings
|
||
if self.mainConf.highlightQuotes:
|
||
self.hRules.append((
|
||
"\\B{:s}(.*?){:s}\\B".format('"', '"'), {
|
||
0 : self.hStyles["dialogue1"],
|
||
}
|
||
))
|
||
self.hRules.append((
|
||
"\\B{:s}(.*?){:s}\\B".format(*self.mainConf.fmtDoubleQuotes), {
|
||
0 : self.hStyles["dialogue2"],
|
||
}
|
||
))
|
||
self.hRules.append((
|
||
"\\B{:s}(.*?){:s}\\B".format(*self.mainConf.fmtSingleQuotes), {
|
||
0 : self.hStyles["dialogue3"],
|
||
}
|
||
))
|
||
|
||
# Markdown
|
||
self.hRules.append((
|
||
nwRegEx.FMT_I, {
|
||
1 : self.hStyles["hidden"],
|
||
2 : self.hStyles["italic"],
|
||
3 : self.hStyles["hidden"],
|
||
}
|
||
))
|
||
self.hRules.append((
|
||
nwRegEx.FMT_B, {
|
||
1 : self.hStyles["hidden"],
|
||
2 : self.hStyles["bold"],
|
||
3 : self.hStyles["hidden"],
|
||
}
|
||
))
|
||
self.hRules.append((
|
||
nwRegEx.FMT_ST, {
|
||
1 : self.hStyles["hidden"],
|
||
2 : self.hStyles["strike"],
|
||
3 : self.hStyles["hidden"],
|
||
}
|
||
))
|
||
|
||
# Auto-Replace Tags
|
||
self.hRules.append((
|
||
r"<(\S+?)>", {
|
||
0 : self.hStyles["replace"],
|
||
}
|
||
))
|
||
|
||
# Build a QRegExp for each highlight pattern
|
||
self.rxRules = []
|
||
for regEx, regRules in self.hRules:
|
||
hReg = QRegularExpression(regEx)
|
||
hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
|
||
self.rxRules.append((hReg, regRules))
|
||
|
||
# Build a QRegExp for spell checker
|
||
# Include additional characters that the highlighter should
|
||
# consider to be word separators
|
||
wordSep = r"_\+/"
|
||
wordSep += nwUnicode.U_ENDASH
|
||
wordSep += nwUnicode.U_EMDASH
|
||
self.spellRx = QRegularExpression(r"\b[^\s"+wordSep+r"]+\b")
|
||
self.spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
|
||
|
||
return True
|
||
|
||
##
|
||
# Setters
|
||
##
|
||
|
||
def setDict(self, theDict):
|
||
"""Set the dictionary object for spell check underlines lookup.
|
||
"""
|
||
self.theDict = theDict
|
||
return True
|
||
|
||
def setSpellCheck(self, theMode):
|
||
"""Enable/disable the real time spell checker.
|
||
"""
|
||
self.spellCheck = theMode
|
||
return True
|
||
|
||
def setHandle(self, theHandle):
|
||
"""Set the handle of the currently highlighted document. This is
|
||
needed for the index lookup for validating tags and references.
|
||
"""
|
||
self.theHandle = theHandle
|
||
return True
|
||
|
||
##
|
||
# Highlight Block
|
||
##
|
||
|
||
def highlightBlock(self, theText):
|
||
"""Highlight a single block. Prefer to check first character for
|
||
all formats that are defined by their initial characters. This
|
||
is significantly faster than running the regex checks used for
|
||
text paragraphs.
|
||
"""
|
||
if self.theHandle is None or not theText:
|
||
return
|
||
|
||
if theText.startswith("@"): # Keywords and commands
|
||
tItem = self.theParent.theProject.projTree[self.theHandle]
|
||
isValid, theBits, thePos = self.theIndex.scanThis(theText)
|
||
isGood = self.theIndex.checkThese(theBits, tItem)
|
||
if isValid:
|
||
for n, theBit in enumerate(theBits):
|
||
xPos = thePos[n]
|
||
xLen = len(theBit)
|
||
if isGood[n]:
|
||
if n == 0:
|
||
self.setFormat(xPos, xLen, self.hStyles["keyword"])
|
||
else:
|
||
self.setFormat(xPos, xLen, self.hStyles["value"])
|
||
else:
|
||
kwFmt = self.format(xPos)
|
||
kwFmt.setUnderlineColor(self.colTagErr)
|
||
kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
|
||
self.setFormat(xPos, xLen, kwFmt)
|
||
|
||
# We never want to run the spell checker on keyword/values,
|
||
# so we force a return here
|
||
return
|
||
|
||
elif theText.startswith("# "): # Header 1
|
||
self.setFormat(0, 1, self.hStyles["header1h"])
|
||
self.setFormat(1, len(theText), self.hStyles["header1"])
|
||
|
||
elif theText.startswith("## "): # Header 2
|
||
self.setFormat(0, 2, self.hStyles["header2h"])
|
||
self.setFormat(2, len(theText), self.hStyles["header2"])
|
||
|
||
elif theText.startswith("### "): # Header 3
|
||
self.setFormat(0, 3, self.hStyles["header3h"])
|
||
self.setFormat(3, len(theText), self.hStyles["header3"])
|
||
|
||
elif theText.startswith("#### "): # Header 4
|
||
self.setFormat(0, 4, self.hStyles["header4h"])
|
||
self.setFormat(4, len(theText), self.hStyles["header4"])
|
||
|
||
elif theText.startswith("%"): # Comments
|
||
toCheck = theText[1:].lstrip()
|
||
synTag = toCheck[:9].lower()
|
||
tLen = len(theText)
|
||
cLen = len(toCheck)
|
||
cOff = tLen - cLen
|
||
if synTag == "synopsis:":
|
||
self.setFormat(0, cOff+9, self.hStyles["modifier"])
|
||
self.setFormat(cOff+9, tLen, self.hStyles["hidden"])
|
||
else:
|
||
self.setFormat(0, tLen, self.hStyles["hidden"])
|
||
|
||
else: # Text Paragraph
|
||
for rX, xFmt in self.rxRules:
|
||
rxItt = rX.globalMatch(theText, 0)
|
||
while rxItt.hasNext():
|
||
rxMatch = rxItt.next()
|
||
for xM in xFmt:
|
||
xPos = rxMatch.capturedStart(xM)
|
||
xLen = rxMatch.capturedLength(xM)
|
||
for x in range(xPos, xPos+xLen):
|
||
spFmt = self.format(x)
|
||
if spFmt != self.hStyles["hidden"]:
|
||
spFmt.merge(xFmt[xM])
|
||
self.setFormat(x, 1, spFmt)
|
||
|
||
if self.theDict is None or not self.spellCheck:
|
||
return
|
||
|
||
rxSpell = self.spellRx.globalMatch(theText, 0)
|
||
while rxSpell.hasNext():
|
||
rxMatch = rxSpell.next()
|
||
if not self.theDict.checkWord(rxMatch.captured(0)):
|
||
if rxMatch.captured(0) == rxMatch.captured(0).upper():
|
||
continue
|
||
xPos = rxMatch.capturedStart(0)
|
||
xLen = rxMatch.capturedLength(0)
|
||
for x in range(xPos, xPos+xLen):
|
||
spFmt = self.format(x)
|
||
spFmt.setUnderlineColor(self.colSpell)
|
||
spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
|
||
self.setFormat(x, 1, spFmt)
|
||
|
||
return
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _makeFormat(self, fmtCol=None, fmtStyle=None, fmtSize=None):
|
||
"""Generate a valid character format to be applied to the text
|
||
that is to be highlighted.
|
||
"""
|
||
theFormat = QTextCharFormat()
|
||
|
||
if fmtCol is not None:
|
||
theFormat.setForeground(fmtCol)
|
||
|
||
if fmtStyle is not None:
|
||
if "bold" in fmtStyle:
|
||
theFormat.setFontWeight(QFont.Bold)
|
||
if "italic" in fmtStyle:
|
||
theFormat.setFontItalic(True)
|
||
if "strike" in fmtStyle:
|
||
theFormat.setFontStrikeOut(True)
|
||
if "underline" in fmtStyle:
|
||
theFormat.setFontUnderline(True)
|
||
if "background" in fmtStyle:
|
||
theFormat.setBackground(QBrush(fmtCol, Qt.SolidPattern))
|
||
|
||
if fmtSize is not None:
|
||
theFormat.setFontPointSize(round(fmtSize*self.mainConf.textSize))
|
||
|
||
return theFormat
|
||
|
||
# END Class DocHighlighter
|