manuskript/manuskript/ui/highlighters/markdownHighlighter.py
2021-02-21 23:45:34 +01:00

754 lines
28 KiB
Python

#!/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 transparentFormat(self, fmt, alpha=75):
"""
Takes a QTextCharFormat and modify it with colors made transparent
using alpha channel. For focus mode
"""
c = fmt.foreground().color()
c.setAlpha(alpha)
fmt.setForeground(QBrush(c))
b = fmt.background()
if b.style() != Qt.NoBrush:
c = b.color()
c.setAlpha(alpha)
fmt.setBackground(QBrush(b))
def unfocusConditions(self):
"""
Returns:
- True if the text is supposed to be unfocused
- (start, end) if block is supposed to be unfocused except for that part.
"""
if self.editor._noFocusMode or not settings.textEditor["focusMode"]:
return False
if settings.textEditor["focusMode"] == "paragraph":
return not self.currentBlock().contains(
self.editor.textCursor().position())
elif settings.textEditor["focusMode"] == "line":
if self.currentBlock().contains(
self.editor.textCursor().position()):
block = self.currentBlock()
# Position of cursor in block
pos = self.editor.textCursor().position() - block.position()
for i in range(block.layout().lineCount()):
line = block.layout().lineAt(i)
start = line.textStart()
end = line.textStart() + line.textLength()
if start <= pos <= end:
return (start, end)
else:
return True
elif settings.textEditor["focusMode"] == "sentence":
if self.currentBlock().contains(
self.editor.textCursor().position()):
block = self.currentBlock()
# Position of cursor in block
pos = self.editor.textCursor().position() - block.position()
ENDChars = "!.?"
txt = block.text()
start = -1
for i in range(len(txt)):
if start == -1: start = i
if txt[i] in ENDChars:
s = txt[start:i+1]
if start <= pos <= start + len(s):
return start, i+1
start = -1
return (start, len(txt))
else:
return True
return False
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)
# Focus mode
unfocus = self.unfocusConditions()
if unfocus:
fmt = self.format(0)
fmt.setForeground(QBrush(self.defaultTextColor))
self.transparentFormat(fmt)
if type(unfocus) != bool:
start, end = unfocus
self.setFormat(0, start, fmt)
self.setFormat(end, len(text), fmt)
else:
self.setFormat(0, len(text), fmt)
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}
#Example:
#"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] = { # &nbsp;
"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)
# Focus mode
unfocus = self.unfocusConditions()
if unfocus:
if (type(unfocus) == bool
or token.position < unfocus[0]
or unfocus[1] < token.position):
self.transparentFormat(fmt)
self.transparentFormat(markupFormat)
# Format opening 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.style().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 == 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