2017-11-28 03:00:07 +13:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# --!-- coding: utf8 --!--
|
|
|
|
|
2017-11-28 08:58:02 +13:00
|
|
|
import re
|
|
|
|
|
2017-12-15 02:55:14 +13:00
|
|
|
from PyQt5.QtCore import QRegExp, Qt, QTimer, QRect, QPoint
|
2017-11-28 08:58:02 +13:00
|
|
|
from PyQt5.QtGui import QTextCursor
|
2017-12-15 02:55:14 +13:00
|
|
|
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
|
2017-12-15 02:55:14 +13:00
|
|
|
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*)+")
|
2018-11-11 07:17:48 +13:00
|
|
|
listRegex = QRegExp(r"^(\s*)([+*-]|([0-9a-z])+([.\)]))(\s+)")
|
2017-12-16 08:12:51 +13:00
|
|
|
inlineLinkRegex = QRegExp("\\[([^\n]+)\\]\\(([^\n]+)\\)")
|
|
|
|
imageRegex = QRegExp("!\\[([^\n]*)\\]\\(([^\n]+)\\)")
|
|
|
|
automaticLinkRegex = QRegExp("(<([a-zA-Z]+\\:[^\n]+)>)|(<([^\n]+@[^\n]+)>)")
|
2017-12-01 01:21:40 +13:00
|
|
|
|
2019-02-22 13:14:55 +13:00
|
|
|
def __init__(self, parent=None, index=None, html=None, spellcheck=None,
|
2017-11-28 03:00:07 +13:00
|
|
|
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
|
2017-12-07 06:41:26 +13:00
|
|
|
self._lastCursorPosition = None
|
2017-11-28 08:58:02 +13:00
|
|
|
|
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)
|
|
|
|
|
2017-12-15 02:55:14 +13:00
|
|
|
# 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 08:58:02 +13:00
|
|
|
|
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
|
2017-12-07 06:41:26 +13:00
|
|
|
if self.highlighter and settings.textEditor["focusMode"]:
|
|
|
|
if self._lastCursorPosition:
|
2017-12-07 06:53:42 +13:00
|
|
|
block = self.document().findBlock(self._lastCursorPosition)
|
|
|
|
self.highlighter.rehighlightBlock(block)
|
2017-12-07 06:41:26 +13:00
|
|
|
self._lastCursorPosition = self.textCursor().position()
|
2017-12-07 06:53:42 +13:00
|
|
|
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-11-28 08:58:02 +13:00
|
|
|
###########################################################################
|
2017-12-01 01:12:55 +13:00
|
|
|
# FORMATTING
|
2017-11-28 08:58:02 +13:00
|
|
|
###########################################################################
|
|
|
|
|
|
|
|
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(" - ")
|
2017-11-28 08:58:02 +13:00
|
|
|
|
|
|
|
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()
|
|
|
|
|
2019-09-18 01:07:08 +12:00
|
|
|
# Select beginning and end of words
|
2017-11-28 08:58:02 +13:00
|
|
|
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):
|
|
|
|
"""
|
2019-09-18 01:07:08 +12:00
|
|
|
Adds `markup` at the beginning of block.
|
2017-12-01 01:12:55 +13:00
|
|
|
"""
|
|
|
|
cursor = self.textCursor()
|
|
|
|
cursor.movePosition(cursor.StartOfBlock)
|
|
|
|
cursor.insertText(markup)
|
|
|
|
|
2017-11-28 08:58:02 +13:00
|
|
|
def insertFormattingMarkup(self, markup):
|
|
|
|
cursor = self.textCursor()
|
|
|
|
|
2019-09-18 01:07:08 +12:00
|
|
|
# Select beginning and end of words
|
2017-11-28 08:58:02 +13:00
|
|
|
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 [
|
2018-11-11 07:17:48 +13:00
|
|
|
(r"\*\*(.*?)\*\*", "\\1", None), # bold
|
2017-11-28 08:58:02 +13:00
|
|
|
("__(.*?)__", "\\1", None), # bold
|
2018-11-11 07:17:48 +13:00
|
|
|
(r"\*(.*?)\*", "\\1", None), # emphasis
|
2017-11-28 08:58:02 +13:00
|
|
|
("_(.*?)_", "\\1", None), # emphasis
|
|
|
|
("`(.*?)`", "\\1", None), # verbatim
|
|
|
|
("~~(.*?)~~", "\\1", None), # strike
|
2018-11-11 07:17:48 +13:00
|
|
|
(r"\^(.*?)\^", "\\1", None), # superscript
|
2017-11-28 08:58:02 +13:00
|
|
|
("~(.*?)~", "\\1", None), # subscript
|
2018-11-11 07:17:48 +13:00
|
|
|
(r"<!--\s*(.*?)\s*-->", "\\1", re.S), # comments
|
2017-11-28 08:58:02 +13:00
|
|
|
|
|
|
|
# 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 "-"
|
2018-11-11 07:17:48 +13:00
|
|
|
text = re.sub(r"^#*\s*(.*)\s*#*", "\\1", text) # Removes #
|
2017-11-28 08:58:02 +13:00
|
|
|
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
|
|
|
|
|
2018-11-11 07:17:48 +13:00
|
|
|
m = re.match(r"^(#+)(\s*)(.+)", text)
|
2017-11-28 08:58:02 +13:00
|
|
|
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)
|
2017-12-15 02:55:14 +13:00
|
|
|
|
|
|
|
###########################################################################
|
|
|
|
# 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 [
|
2017-12-16 08:12:51 +13:00
|
|
|
self.imageRegex,
|
|
|
|
self.automaticLinkRegex,
|
|
|
|
self.inlineLinkRegex,
|
2017-12-15 02:55:14 +13:00
|
|
|
]:
|
|
|
|
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)
|
|
|
|
|
2017-12-16 08:12:51 +13:00
|
|
|
if ct.regex == self.automaticLinkRegex:
|
2017-12-15 02:55:14 +13:00
|
|
|
tooltip = ct.texts[2] or ct.texts[4]
|
|
|
|
|
2017-12-16 08:12:51 +13:00
|
|
|
elif ct.regex == self.imageRegex:
|
2017-12-15 02:55:14 +13:00
|
|
|
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())
|
2019-04-21 22:09:30 +12:00
|
|
|
ImageTooltip.fromUrl(ct.texts[2], pos, self)
|
2017-12-15 02:55:14 +13:00
|
|
|
|
2017-12-16 08:12:51 +13:00
|
|
|
elif ct.regex == self.inlineLinkRegex:
|
2017-12-15 02:55:14 +13:00
|
|
|
tooltip = ct.texts[1] or ct.texts[2]
|
|
|
|
|
|
|
|
if tooltip:
|
2017-12-15 03:06:51 +13:00
|
|
|
tooltip = self.tr("{} (CTRL+Click to open)").format(tooltip)
|
2017-12-15 02:55:14 +13:00
|
|
|
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]
|
|
|
|
|
2017-12-16 08:12:51 +13:00
|
|
|
if ct.regex == self.automaticLinkRegex:
|
2017-12-15 02:55:14 +13:00
|
|
|
url = ct.texts[2] or ct.texts[4]
|
2017-12-16 08:12:51 +13:00
|
|
|
elif ct.regex == self.imageRegex:
|
2017-12-15 02:55:14 +13:00
|
|
|
url = ct.texts[2]
|
2017-12-16 08:12:51 +13:00
|
|
|
elif ct.regex == self.inlineLinkRegex:
|
2017-12-15 02:55:14 +13:00
|
|
|
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
|
|
|
|
|
2019-04-21 22:09:30 +12:00
|
|
|
class ImageTooltip:
|
|
|
|
"""
|
|
|
|
This class handles the retrieving and caching of images in order to display these in tooltips.
|
|
|
|
"""
|
2017-12-15 02:55:14 +13:00
|
|
|
|
|
|
|
cache = {}
|
|
|
|
manager = QNetworkAccessManager()
|
2019-04-21 22:09:30 +12:00
|
|
|
processing = {}
|
2017-12-15 02:55:14 +13:00
|
|
|
|
Fixed & robustified image tooltips (issue #593)
Last time I touched this code, I went in looking for a specific problem,
and came out with a fix specific to that issue. That fix was not wrong,
yet it hardly covered all the problems present in the code once one took
into account issues like:
- local vs remote resources,
- relative vs absolute paths,
- different operating systems behaving differently, and
- Qt being uniquely buggy on different platforms.
The major part of it was fixed by using QUrl.fromUserInput(), which does
the exact kind of auto-detection for the nature of the resource that we
were in need of.
The rest of the issues were fixed by creating a number of test cases and
fixing problems as they popped up. Testing was done in Windows & Ubunty
against the above-mentioned test cases, which can be found in PR #629.
Regarding ImageTooltip.supportedSchemes
When QUrl.fromUserInput() misidentifies the scheme on Linux, it causes
all resemblance between the original request and the reply.request() in
the finished() signal to be lost, which results in this item getting
stuck in the ImageTooltip processing pipeline.
Limiting the supported schemes to the ones most commonly encountered
('file', 'http', 'https' and the schema-less local paths) is the only
reliable method I have found to work around this particular bug in Qt.
2019-09-06 17:55:18 +12:00
|
|
|
supportedSchemes = ("", "file", "http", "https")
|
|
|
|
|
2017-12-15 02:55:14 +13:00
|
|
|
def fromUrl(url, pos, editor):
|
2019-04-21 22:09:30 +12:00
|
|
|
"""
|
|
|
|
Shows the image tooltip for the given url if available, or requests it for future use.
|
|
|
|
"""
|
|
|
|
ImageTooltip.editor = editor
|
2017-12-15 02:55:14 +13:00
|
|
|
|
2019-04-21 22:09:30 +12:00
|
|
|
if ImageTooltip.showTooltip(url, pos):
|
|
|
|
return # the url already exists in the cache
|
2017-12-15 02:55:14 +13:00
|
|
|
|
|
|
|
try:
|
2019-04-21 22:09:30 +12:00
|
|
|
ImageTooltip.manager.finished.connect(ImageTooltip.finished, F.AUC)
|
2017-12-15 02:55:14 +13:00
|
|
|
except:
|
2019-04-21 22:09:30 +12:00
|
|
|
pass # already connected
|
|
|
|
|
Fixed & robustified image tooltips (issue #593)
Last time I touched this code, I went in looking for a specific problem,
and came out with a fix specific to that issue. That fix was not wrong,
yet it hardly covered all the problems present in the code once one took
into account issues like:
- local vs remote resources,
- relative vs absolute paths,
- different operating systems behaving differently, and
- Qt being uniquely buggy on different platforms.
The major part of it was fixed by using QUrl.fromUserInput(), which does
the exact kind of auto-detection for the nature of the resource that we
were in need of.
The rest of the issues were fixed by creating a number of test cases and
fixing problems as they popped up. Testing was done in Windows & Ubunty
against the above-mentioned test cases, which can be found in PR #629.
Regarding ImageTooltip.supportedSchemes
When QUrl.fromUserInput() misidentifies the scheme on Linux, it causes
all resemblance between the original request and the reply.request() in
the finished() signal to be lost, which results in this item getting
stuck in the ImageTooltip processing pipeline.
Limiting the supported schemes to the ones most commonly encountered
('file', 'http', 'https' and the schema-less local paths) is the only
reliable method I have found to work around this particular bug in Qt.
2019-09-06 17:55:18 +12:00
|
|
|
qurl = QUrl.fromUserInput(url)
|
|
|
|
if (qurl == QUrl()):
|
|
|
|
ImageTooltip.cache[url] = (False, ImageTooltip.manager.tr("The image path or URL is incomplete or malformed."))
|
|
|
|
ImageTooltip.showTooltip(url, pos)
|
|
|
|
return # empty QUrl means it failed completely
|
|
|
|
elif (qurl.scheme() not in ImageTooltip.supportedSchemes):
|
|
|
|
# QUrl.fromUserInput() can occasionally deduce an incorrect scheme,
|
|
|
|
# which produces an error message regarding an unknown scheme. (Yay!)
|
|
|
|
# But it also breaks all possible methods to try and associate the
|
|
|
|
# reply with the original request in finished(), since reply.request()
|
|
|
|
# is completely and utterly butchered for all tracking needs. :'(
|
|
|
|
# (The QNetworkRequest, .url() and .originatingObject() can all change.)
|
|
|
|
|
|
|
|
# Test case (Linux): ![image](C:\test_root.jpg)
|
|
|
|
ImageTooltip.cache[url] = (False, ImageTooltip.manager.tr("The protocol \"{}\" is not supported.").format(qurl.scheme()))
|
|
|
|
ImageTooltip.showTooltip(url, pos)
|
|
|
|
return # no more request/reply chaos, please!
|
|
|
|
elif (qurl in ImageTooltip.processing):
|
2019-04-21 22:09:30 +12:00
|
|
|
return # one download is more than enough
|
2017-12-15 02:55:14 +13:00
|
|
|
|
2019-04-21 22:09:30 +12:00
|
|
|
# Request the image for later processing.
|
|
|
|
request = QNetworkRequest(qurl)
|
|
|
|
ImageTooltip.processing[qurl] = (pos, url)
|
Fixed & robustified image tooltips (issue #593)
Last time I touched this code, I went in looking for a specific problem,
and came out with a fix specific to that issue. That fix was not wrong,
yet it hardly covered all the problems present in the code once one took
into account issues like:
- local vs remote resources,
- relative vs absolute paths,
- different operating systems behaving differently, and
- Qt being uniquely buggy on different platforms.
The major part of it was fixed by using QUrl.fromUserInput(), which does
the exact kind of auto-detection for the nature of the resource that we
were in need of.
The rest of the issues were fixed by creating a number of test cases and
fixing problems as they popped up. Testing was done in Windows & Ubunty
against the above-mentioned test cases, which can be found in PR #629.
Regarding ImageTooltip.supportedSchemes
When QUrl.fromUserInput() misidentifies the scheme on Linux, it causes
all resemblance between the original request and the reply.request() in
the finished() signal to be lost, which results in this item getting
stuck in the ImageTooltip processing pipeline.
Limiting the supported schemes to the ones most commonly encountered
('file', 'http', 'https' and the schema-less local paths) is the only
reliable method I have found to work around this particular bug in Qt.
2019-09-06 17:55:18 +12:00
|
|
|
reply = ImageTooltip.manager.get(request)
|
|
|
|
|
|
|
|
# On Linux the finished() signal is not triggered when the url resembles
|
|
|
|
# 'file://X:/...'. But because it completes instantly, we can manually
|
|
|
|
# trigger the code to keep our processing dictionary neat & clean.
|
|
|
|
if reply.error() == 302: # QNetworkReply.ProtocolInvalidOperationError
|
|
|
|
ImageTooltip.finished(reply)
|
2017-12-15 02:55:14 +13:00
|
|
|
|
|
|
|
def finished(reply):
|
2019-04-21 22:09:30 +12:00
|
|
|
"""
|
|
|
|
After retrieving an image, we add it to the cache.
|
|
|
|
"""
|
|
|
|
cache = ImageTooltip.cache
|
Fixed & robustified image tooltips (issue #593)
Last time I touched this code, I went in looking for a specific problem,
and came out with a fix specific to that issue. That fix was not wrong,
yet it hardly covered all the problems present in the code once one took
into account issues like:
- local vs remote resources,
- relative vs absolute paths,
- different operating systems behaving differently, and
- Qt being uniquely buggy on different platforms.
The major part of it was fixed by using QUrl.fromUserInput(), which does
the exact kind of auto-detection for the nature of the resource that we
were in need of.
The rest of the issues were fixed by creating a number of test cases and
fixing problems as they popped up. Testing was done in Windows & Ubunty
against the above-mentioned test cases, which can be found in PR #629.
Regarding ImageTooltip.supportedSchemes
When QUrl.fromUserInput() misidentifies the scheme on Linux, it causes
all resemblance between the original request and the reply.request() in
the finished() signal to be lost, which results in this item getting
stuck in the ImageTooltip processing pipeline.
Limiting the supported schemes to the ones most commonly encountered
('file', 'http', 'https' and the schema-less local paths) is the only
reliable method I have found to work around this particular bug in Qt.
2019-09-06 17:55:18 +12:00
|
|
|
url_key = reply.request().url()
|
|
|
|
pos, url = None, None
|
|
|
|
|
|
|
|
if url_key in ImageTooltip.processing:
|
|
|
|
# Obtain the information associated with this request.
|
|
|
|
pos, url = ImageTooltip.processing[url_key]
|
|
|
|
del ImageTooltip.processing[url_key]
|
|
|
|
elif len(ImageTooltip.processing) == 0:
|
|
|
|
# We are not processing anything. Maybe it is a spurious signal,
|
|
|
|
# or maybe the 'reply.error() == 302' workaround in fromUrl() has
|
|
|
|
# been fixed in Qt. Whatever the reason, we can assume this request
|
|
|
|
# has already been handled, and needs no more work from us.
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
# Somehow we lost track. Log what we can to hopefully figure it out.
|
|
|
|
print("Warning: unable to match fetched data for tooltip to original request.")
|
|
|
|
print("- Completed request:", url_key)
|
|
|
|
print("- Status upon finishing:", reply.error(), reply.errorString())
|
|
|
|
print("- Currently processing:", ImageTooltip.processing)
|
|
|
|
return
|
2019-04-21 22:09:30 +12:00
|
|
|
|
|
|
|
# Update cache with retrieved data.
|
2017-12-15 02:55:14 +13:00
|
|
|
if reply.error() != QNetworkReply.NoError:
|
|
|
|
cache[url] = (False, reply.errorString())
|
|
|
|
else:
|
|
|
|
px = QPixmap()
|
|
|
|
px.loadFromData(reply.readAll())
|
|
|
|
px = px.scaled(800, 600, Qt.KeepAspectRatio)
|
|
|
|
cache[url] = (True, px)
|
2019-04-21 22:09:30 +12:00
|
|
|
|
|
|
|
ImageTooltip.showTooltip(url, pos)
|
|
|
|
|
|
|
|
def showTooltip(url, pos):
|
|
|
|
"""
|
|
|
|
Show a tooltip for the given url based on cached information.
|
|
|
|
"""
|
|
|
|
cache = ImageTooltip.cache
|
|
|
|
|
|
|
|
if url in cache:
|
|
|
|
if not cache[url][0]: # error, image was not found
|
|
|
|
ImageTooltip.tooltipError(cache[url][1], pos)
|
|
|
|
else:
|
|
|
|
ImageTooltip.tooltip(cache[url][1], pos)
|
|
|
|
return True
|
|
|
|
return False
|
2017-12-15 02:55:14 +13:00
|
|
|
|
|
|
|
def tooltipError(message, pos):
|
2019-04-21 22:09:30 +12:00
|
|
|
"""
|
|
|
|
Display a tooltip with an error message at the given position.
|
|
|
|
"""
|
|
|
|
ImageTooltip.editor.doTooltip(pos, message)
|
2017-12-15 02:55:14 +13:00
|
|
|
|
|
|
|
def tooltip(image, pos):
|
2019-04-21 22:09:30 +12:00
|
|
|
"""
|
|
|
|
Display a tooltip with an image at the given position.
|
|
|
|
"""
|
2017-12-15 02:55:14 +13:00
|
|
|
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)
|
2019-04-21 22:09:30 +12:00
|
|
|
ImageTooltip.editor.doTooltip(pos, tt)
|