manuskript/manuskript/ui/views/MDEditView.py

631 lines
22 KiB
Python
Raw Normal View History

2017-11-28 03:00:07 +13:00
#!/usr/bin/env python
# --!-- coding: utf8 --!--
import re
from PyQt5.QtCore import QRegExp, Qt, QTimer, QRect, QPoint
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import qApp, QToolTip
2017-11-28 03:00:07 +13:00
from manuskript.ui.views.textEditView import textEditView
from manuskript.ui.highlighters import MarkdownHighlighter
2017-11-28 22:26:43 +13:00
from manuskript import settings
2017-12-01 01:12:55 +13:00
from manuskript.ui.highlighters.markdownEnums import MarkdownState as MS
from manuskript.ui.highlighters.markdownTokenizer import MarkdownTokenizer as MT
from manuskript import functions as F
2017-11-28 03:00:07 +13:00
class MDEditView(textEditView):
2017-12-01 01:21:40 +13:00
blockquoteRegex = QRegExp("^ {0,3}(>\\s*)+")
listRegex = QRegExp("^(\\s*)([+*-]|([0-9a-z])+([.\)]))(\\s+)")
2017-11-28 03:00:07 +13:00
def __init__(self, parent=None, index=None, html=None, spellcheck=True,
highlighting=False, dict="", autoResize=False):
textEditView.__init__(self, parent, index, html, spellcheck,
highlighting=True, dict=dict,
autoResize=autoResize)
# Highlighter
self._textFormat = "md"
self._highlighterClass = MarkdownHighlighter
2017-12-06 11:18:32 +13:00
self._noFocusMode = False
self._lastCursorPosition = None
2017-11-28 09:13:15 +13:00
if index:
# We have to setup things anew, for the highlighter notably
self.setCurrentModelIndex(index)
2017-11-28 22:26:43 +13:00
self.cursorPositionChanged.connect(self.cursorPositionHasChanged)
self.verticalScrollBar().rangeChanged.connect(
self.scrollBarRangeChanged)
# Clickable things
self.clickRects = []
self.textChanged.connect(self.getClickRects)
self.document().documentLayoutChanged.connect(self.getClickRects)
self.setMouseTracking(True)
2017-12-01 01:21:40 +13:00
###########################################################################
# KEYPRESS
###########################################################################
def keyPressEvent(self, event):
k = event.key()
m = event.modifiers()
cursor = self.textCursor()
# RETURN
if k == Qt.Key_Return:
if not cursor.hasSelection():
if m & Qt.ShiftModifier:
# Insert Markdown-style line break
cursor.insertText(" ")
if m & Qt.ControlModifier:
cursor.insertText("\n")
else:
self.handleCarriageReturn()
else:
textEditView.keyPressEvent(self, event)
# TAB
elif k == Qt.Key_Tab:
#self.indentText()
# FIXME
textEditView.keyPressEvent(self, event)
elif k == Qt.Key_Backtab:
#self.unindentText()
# FIXME
textEditView.keyPressEvent(self, event)
else:
textEditView.keyPressEvent(self, event)
# Thanks to GhostWriter, mainly
def handleCarriageReturn(self):
autoInsertText = "";
cursor = self.textCursor()
endList = False
moveBack = False
text = cursor.block().text()
if cursor.positionInBlock() < cursor.block().length() - 1:
autoInsertText = self.getPriorIndentation()
if cursor.positionInBlock() < len(autoInsertText):
autoInsertText = autoInsertText[:cursor.positionInBlock()]
else:
s = cursor.block().userState()
if s in [MS.MarkdownStateNumberedList,
MS.MarkdownStateBulletPointList]:
self.listRegex.indexIn(text)
g = self.listRegex.capturedTexts()
# 0 = " a. " or " * "
# 1 = " " " "
# 2 = "a." "*"
# 3 = "a" ""
# 4 = "." ""
# 5 = " " " "
# If the line of text is an empty list item, end the list.
if len(g[0].strip()) == len(text.strip()):
endList = True
# Else increment the list number
elif g[3]: # Numbered list
try: # digit
i = int(g[3])+1
except: # letter
i = chr(ord(g[3])+1)
autoInsertText = "{}{}{}{}".format(
g[1], i, g[4], g[5])
else: # Bullet list
autoInsertText = g[0]
if text[-2:] == " ":
autoInsertText = " " * len(autoInsertText)
elif s == MS.MarkdownStateBlockquote:
self.blockquoteRegex.indexIn(text)
g = self.blockquoteRegex.capturedTexts()
autoInsertText = g[0]
elif s in [MS.MarkdownStateInGithubCodeFence,
MS.MarkdownStateInPandocCodeFence] and \
cursor.block().previous().userState() != s:
autoInsertText = "\n" + text
moveBack = True
else:
autoInsertText = self.getPriorIndentation()
# Clear the list
if endList:
autoInsertText = self.getPriorIndentation()
cursor.movePosition(QTextCursor.StartOfBlock)
cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
cursor.insertText(autoInsertText)
autoInsertText = ""
# Finally, we insert
cursor.insertText("\n" + autoInsertText)
if moveBack:
cursor.movePosition(QTextCursor.PreviousBlock)
self.setTextCursor(cursor)
self.ensureCursorVisible()
def getPriorIndentation(self):
text = self.textCursor().block().text()
l = len(text) - len(text.lstrip())
return text[:l]
def getPriorMarkdownBlockItemStart(self, itemRegex):
text = self.textCursor().block().text()
if itemRegex.indexIn(text) >= 0:
return text[itemRegex.matchedLength():]
return ""
2017-11-28 22:26:43 +13:00
###########################################################################
# TypeWriterScrolling
###########################################################################
2017-11-29 07:58:23 +13:00
def setCurrentModelIndex(self, index):
textEditView.setCurrentModelIndex(self, index)
self.centerCursor()
2017-11-28 22:26:43 +13:00
def cursorPositionHasChanged(self):
self.centerCursor()
2017-12-06 11:45:16 +13:00
# Focus mode
if self.highlighter and settings.textEditor["focusMode"]:
if self._lastCursorPosition:
block = self.document().findBlock(self._lastCursorPosition)
self.highlighter.rehighlightBlock(block)
self._lastCursorPosition = self.textCursor().position()
block = self.document().findBlock(self._lastCursorPosition)
self.highlighter.rehighlightBlock(block)
2017-11-28 22:26:43 +13:00
def centerCursor(self, force=False):
cursor = self.cursorRect()
2017-11-29 07:58:23 +13:00
scrollbar = self.verticalScrollBar()
2017-11-28 22:26:43 +13:00
viewport = self.viewport().rect()
if (force or settings.textEditor["alwaysCenter"]
or cursor.bottom() >= viewport.bottom()
or cursor.top() <= viewport.top()):
offset = viewport.center() - cursor.center()
scrollbar.setValue(scrollbar.value() - offset.y())
def scrollBarRangeChanged(self, min, max):
"""
Adds viewport height to scrollbar max so that we can center cursor
on screen.
"""
if settings.textEditor["alwaysCenter"]:
self.verticalScrollBar().blockSignals(True)
self.verticalScrollBar().setMaximum(max + self.viewport().height())
self.verticalScrollBar().blockSignals(False)
###########################################################################
2017-12-01 01:12:55 +13:00
# FORMATTING
###########################################################################
def bold(self): self.insertFormattingMarkup("**")
def italic(self): self.insertFormattingMarkup("*")
def strike(self): self.insertFormattingMarkup("~~")
def verbatim(self): self.insertFormattingMarkup("`")
def superscript(self): self.insertFormattingMarkup("^")
def subscript(self): self.insertFormattingMarkup("~")
2017-12-01 01:12:55 +13:00
def blockquote(self): self.lineFormattingMarkup("> ")
def orderedList(self): self.lineFormattingMarkup(" 1. ")
def unorderedList(self): self.lineFormattingMarkup(" - ")
def selectWord(self, cursor):
if cursor.selectedText():
return
end = cursor.selectionEnd()
cursor.movePosition(QTextCursor.StartOfWord)
cursor.setPosition(end, QTextCursor.KeepAnchor)
cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.KeepAnchor)
def selectBlock(self, cursor):
cursor.movePosition(QTextCursor.StartOfBlock)
cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
def comment(self):
cursor = self.textCursor()
# Select begining and end of words
self.selectWord(cursor)
if cursor.hasSelection():
text = cursor.selectedText()
cursor.insertText("<!-- " + text + " -->")
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("<!--\n")
cursor.setPosition(block2.position() + block2.length() - 1)
cursor.insertText("\n-->")
cursor.endEditBlock()
def commentBlock(self, block):
cursor = QTextCursor(block)
text = block.text()
if text[:5] == "<!-- " and \
text[-4:] == " -->":
text2 = text[5:-4]
else:
text2 = "<!-- " + text + " -->"
self.selectBlock(cursor)
cursor.insertText(text2)
2017-12-01 01:12:55 +13:00
def lineFormattingMarkup(self, markup):
"""
2017-12-01 01:21:40 +13:00
Adds `markup` at the begining of block.
2017-12-01 01:12:55 +13:00
"""
cursor = self.textCursor()
cursor.movePosition(cursor.StartOfBlock)
cursor.insertText(markup)
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
2017-12-01 01:12:55 +13:00
("<!--\s*(.*?)\s*-->", "\\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)
###########################################################################
# CLICKABLE THINKS
###########################################################################
def resizeEvent(self, event):
textEditView.resizeEvent(self, event)
self.getClickRects()
def scrollContentsBy(self, dx, dy):
textEditView.scrollContentsBy(self, dx, dy)
self.getClickRects()
def getClickRects(self):
"""
Parses the whole texte to catch clickable things: links and images.
Stores the result so that it can be used elsewhere.
"""
cursor = self.textCursor()
refs = []
text = self.toPlainText()
for rx in [
MT.imageRegex,
MT.automaticLinkRegex,
MT.inlineLinkRegex,
]:
pos = 0
while rx.indexIn(text, pos) != -1:
cursor.setPosition(rx.pos())
r1 = self.cursorRect(cursor)
pos = rx.pos() + rx.matchedLength()
cursor.setPosition(pos)
r2 = self.cursorRect(cursor)
if r1.top() == r2.top():
ct = ClickThing(
QRect(r1.topLeft(), r2.bottomRight()),
rx,
rx.capturedTexts())
refs.append(ct)
else:
r1.setRight(self.viewport().geometry().right())
refs.append(ClickThing(r1, rx, rx.capturedTexts()))
r2.setLeft(self.viewport().geometry().left())
refs.append(ClickThing(r2, rx, rx.capturedTexts()))
# We check for middle lines
cursor.setPosition(rx.pos())
cursor.movePosition(cursor.Down)
while self.cursorRect(cursor).top() != r2.top():
r3 = self.cursorRect(cursor)
r3.setLeft(self.viewport().geometry().left())
r3.setRight(self.viewport().geometry().right())
refs.append(ClickThing(r3, rx, rx.capturedTexts()))
cursor.movePosition(cursor.Down)
self.clickRects = refs
def mouseMoveEvent(self, event):
"""
When mouse moves, we show tooltip when appropriate.
"""
textEditView.mouseMoveEvent(self, event)
onRect = [r for r in self.clickRects if r.rect.contains(event.pos())]
if not onRect:
qApp.restoreOverrideCursor()
QToolTip.hideText()
return
ct = onRect[0]
if not qApp.overrideCursor():
qApp.setOverrideCursor(Qt.PointingHandCursor)
if ct.regex == MT.automaticLinkRegex:
tooltip = ct.texts[2] or ct.texts[4]
elif ct.regex == MT.imageRegex:
tt = ("<p><b>" + ct.texts[1] + "</b></p>"
+"<p><img src='data:image/png;base64,{}'></p>")
tooltip = None
pos = event.pos() + QPoint(0, ct.rect.height())
imageTooltiper.fromUrl(ct.texts[2], pos, self)
elif ct.regex == MT.inlineLinkRegex:
tooltip = ct.texts[1] or ct.texts[2]
if tooltip:
QToolTip.showText(self.mapToGlobal(event.pos()), tooltip)
def mouseReleaseEvent(self, event):
textEditView.mouseReleaseEvent(self, event)
onRect = [r for r in self.clickRects if r.rect.contains(event.pos())]
if onRect and event.modifiers() & Qt.ControlModifier:
ct = onRect[0]
if ct.regex == MT.automaticLinkRegex:
url = ct.texts[2] or ct.texts[4]
elif ct.regex == MT.imageRegex:
url = ct.texts[2]
elif ct.regex == MT.inlineLinkRegex:
url = ct.texts[2]
F.openURL(url)
qApp.restoreOverrideCursor()
# def paintEvent(self, event):
# """
# Only useful for debugging: shows which rects are detected for
# clickable things.
# """
# textEditView.paintEvent(self, event)
#
# # Debug: paint rects
# from PyQt5.QtGui import QPainter
# painter = QPainter(self.viewport())
# painter.setPen(Qt.gray)
# for r in self.clickRects:
# painter.drawRect(r.rect)
def doTooltip(self, pos, message):
QToolTip.showText(self.mapToGlobal(pos), message)
class ClickThing:
"""
A simple class to remember QRect associated with clickable stuff.
"""
def __init__(self, rect, regex, texts):
self.rect = rect
self.regex = regex
self.texts = texts
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager, QNetworkReply
from PyQt5.QtCore import QIODevice, QUrl, QBuffer
from PyQt5.QtGui import QPixmap
class imageTooltiper:
cache = {}
manager = QNetworkAccessManager()
data = {}
def fromUrl(url, pos, editor):
cache = imageTooltiper.cache
imageTooltiper.editor = editor
if url in cache:
if not cache[url][0]: # error, image was not found
imageTooltiper.tooltipError(cache[url][1], pos)
else:
imageTooltiper.tooltip(cache[url][1], pos)
return
try:
imageTooltiper.manager.finished.connect(imageTooltiper.finished, F.AUC)
except:
pass
request = QNetworkRequest(QUrl(url))
imageTooltiper.data[QUrl(url)] = (pos, url)
imageTooltiper.manager.get(request)
def finished(reply):
cache = imageTooltiper.cache
pos, url = imageTooltiper.data[reply.url()]
if reply.error() != QNetworkReply.NoError:
cache[url] = (False, reply.errorString())
imageTooltiper.tooltipError(reply.errorString(), pos)
else:
px = QPixmap()
px.loadFromData(reply.readAll())
px = px.scaled(800, 600, Qt.KeepAspectRatio)
cache[url] = (True, px)
imageTooltiper.tooltip(px, pos)
def tooltipError(message, pos):
imageTooltiper.editor.doTooltip(pos, message)
def tooltip(image, pos):
px = image
buffer = QBuffer()
buffer.open(QIODevice.WriteOnly)
px.save(buffer, "PNG", quality=100)
image = bytes(buffer.data().toBase64()).decode()
tt = "<p><img src='data:image/png;base64,{}'></p>".format(image)
imageTooltiper.editor.doTooltip(pos, tt)