Add url handling in editor and viewer
This commit is contained in:
@@ -38,12 +38,13 @@ from enum import Enum
|
||||
from time import time
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
|
||||
pyqtSlot
|
||||
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, QUrl,
|
||||
pyqtSignal, pyqtSlot
|
||||
)
|
||||
from PyQt5.QtGui import (
|
||||
QColor, QCursor, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
|
||||
QResizeEvent, QTextBlock, QTextCursor, QTextDocument, QTextOption
|
||||
QColor, QCursor, QDesktopServices, QKeyEvent, QKeySequence, QMouseEvent,
|
||||
QPalette, QPixmap, QResizeEvent, QTextBlock, QTextCursor, QTextDocument,
|
||||
QTextOption
|
||||
)
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction, QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
@@ -985,7 +986,12 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
follow tag function.
|
||||
"""
|
||||
if QApplication.keyboardModifiers() == QtModCtrl:
|
||||
self._processTag(self.cursorForPosition(event.pos()))
|
||||
cursor = self.cursorForPosition(event.pos())
|
||||
mData, mType = self._qDocument.metaDataAtPos(cursor.position())
|
||||
if mData and mType == "url":
|
||||
self._openWebsite(mData)
|
||||
else:
|
||||
self._processTag(cursor)
|
||||
super().mouseReleaseEvent(event)
|
||||
return
|
||||
|
||||
@@ -1116,6 +1122,13 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
action = ctxMenu.addAction(self.tr("Set as Document Name"))
|
||||
action.triggered.connect(lambda: self._emitRenameItem(pBlock))
|
||||
|
||||
# URL
|
||||
(mData, mType) = self._qDocument.metaDataAtPos(pCursor.position())
|
||||
if mData and mType == "url":
|
||||
action = ctxMenu.addAction(self.tr("Open URL"))
|
||||
action.triggered.connect(lambda: self._openWebsite(mData))
|
||||
ctxMenu.addSeparator()
|
||||
|
||||
# Follow
|
||||
status = self._processTag(cursor=pCursor, follow=False)
|
||||
if status == nwTrinary.POSITIVE:
|
||||
@@ -1183,6 +1196,12 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
|
||||
return
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _openWebsite(self, url: str) -> None:
|
||||
"""Open a URL in the system's default browser."""
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def _runDocumentTasks(self) -> None:
|
||||
"""Run timer document tasks."""
|
||||
|
||||
@@ -44,6 +44,7 @@ from novelwriter.text.patterns import REGEX_PATTERNS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RX_URL = REGEX_PATTERNS.url
|
||||
RX_WORDS = REGEX_PATTERNS.wordSplit
|
||||
RX_FMT_SC = REGEX_PATTERNS.shortcodePlain
|
||||
RX_FMT_SV = REGEX_PATTERNS.shortcodeValue
|
||||
@@ -113,10 +114,11 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._addCharFormat("replace", SHARED.theme.colRepTag)
|
||||
self._addCharFormat("hidden", SHARED.theme.colHidden)
|
||||
self._addCharFormat("markup", SHARED.theme.colHidden)
|
||||
self._addCharFormat("link", SHARED.theme.colLink, "u")
|
||||
self._addCharFormat("note", SHARED.theme.colNote)
|
||||
self._addCharFormat("code", SHARED.theme.colCode)
|
||||
self._addCharFormat("keyword", SHARED.theme.colKey)
|
||||
self._addCharFormat("tag", SHARED.theme.colTag)
|
||||
self._addCharFormat("tag", SHARED.theme.colTag, "u")
|
||||
self._addCharFormat("modifier", SHARED.theme.colMod)
|
||||
self._addCharFormat("value", SHARED.theme.colVal)
|
||||
self._addCharFormat("optional", SHARED.theme.colOpt)
|
||||
@@ -231,6 +233,15 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# URLs
|
||||
rxRule = REGEX_PATTERNS.url
|
||||
hlRule = {
|
||||
0: self._hStyles["link"],
|
||||
}
|
||||
self._minRules.append((rxRule, hlRule))
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# Alignment Tags
|
||||
rxRule = re.compile(r"(^>{1,2}|<{1,2}$)", re.UNICODE)
|
||||
hlRule = {
|
||||
@@ -447,6 +458,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
charFormat.setFontWeight(QFont.Weight.Bold)
|
||||
if "i" in styles:
|
||||
charFormat.setFontItalic(True)
|
||||
if "u" in styles:
|
||||
charFormat.setFontUnderline(True)
|
||||
if "s" in styles:
|
||||
charFormat.setFontStrikeOut(True)
|
||||
if "err" in styles:
|
||||
@@ -469,9 +482,15 @@ class TextBlockData(QTextBlockUserData):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._spellErrors: list[tuple[int, int]] = []
|
||||
self._metaData: list[tuple[int, int, str, str]] = []
|
||||
self._spellErrors: list[tuple[int, int,]] = []
|
||||
return
|
||||
|
||||
@property
|
||||
def metaData(self) -> list[tuple[int, int, str, str]]:
|
||||
"""Return meta data from last check."""
|
||||
return self._metaData
|
||||
|
||||
@property
|
||||
def spellErrors(self) -> list[tuple[int, int]]:
|
||||
"""Return spell error data from last check."""
|
||||
@@ -481,6 +500,8 @@ class TextBlockData(QTextBlockUserData):
|
||||
"""Run the spell checker and cache the result, and return the
|
||||
list of spell check errors.
|
||||
"""
|
||||
self._metaData = []
|
||||
self._spellErrors = []
|
||||
if "[" in text:
|
||||
# Strip shortcodes
|
||||
for regEx in [RX_FMT_SC, RX_FMT_SV]:
|
||||
@@ -489,7 +510,14 @@ class TextBlockData(QTextBlockUserData):
|
||||
pad = " "*(e - s)
|
||||
text = f"{text[:s]}{pad}{text[e:]}"
|
||||
|
||||
self._spellErrors = []
|
||||
if "http" in text:
|
||||
# Strip URLs
|
||||
for res in RX_URL.finditer(text, offset):
|
||||
if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0:
|
||||
pad = " "*(e - s)
|
||||
text = f"{text[:s]}{pad}{text[e:]}"
|
||||
self._metaData.append((s, e, res.group(0), "url"))
|
||||
|
||||
checker = SHARED.spelling
|
||||
for res in RX_WORDS.finditer(text.replace("_", " "), offset):
|
||||
if (
|
||||
|
||||
@@ -31,7 +31,7 @@ import logging
|
||||
from enum import Enum
|
||||
|
||||
from PyQt5.QtCore import QPoint, Qt, QUrl, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QCursor, QMouseEvent, QPalette, QResizeEvent, QTextCursor
|
||||
from PyQt5.QtGui import QCursor, QDesktopServices, QMouseEvent, QPalette, QResizeEvent, QTextCursor
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction, QApplication, QFrame, QHBoxLayout, QMenu, QTextBrowser,
|
||||
QToolButton, QWidget
|
||||
@@ -76,6 +76,7 @@ class GuiDocViewer(QTextBrowser):
|
||||
# Settings
|
||||
self.setMinimumWidth(CONFIG.pxInt(300))
|
||||
self.setAutoFillBackground(True)
|
||||
self.setOpenLinks(False)
|
||||
self.setOpenExternalLinks(False)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
@@ -166,6 +167,7 @@ class GuiDocViewer(QTextBrowser):
|
||||
self._docTheme.text = SHARED.theme.colText
|
||||
self._docTheme.highlight = SHARED.theme.colMark
|
||||
self._docTheme.head = SHARED.theme.colHead
|
||||
self._docTheme.link = SHARED.theme.colLink
|
||||
self._docTheme.comment = SHARED.theme.colHidden
|
||||
self._docTheme.note = SHARED.theme.colNote
|
||||
self._docTheme.code = SHARED.theme.colCode
|
||||
@@ -378,8 +380,10 @@ class GuiDocViewer(QTextBrowser):
|
||||
logger.debug("Clicked link: '%s'", link)
|
||||
if (bits := link.partition("_")) and bits[0] == "#tag" and bits[2]:
|
||||
self.loadDocumentTagRequest.emit(bits[2], nwDocMode.VIEW)
|
||||
else:
|
||||
elif link.startswith("#"):
|
||||
self.navigateTo(link)
|
||||
elif link.startswith("http"):
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
return
|
||||
|
||||
@pyqtSlot("QPoint")
|
||||
|
||||
@@ -95,6 +95,21 @@ class GuiTextDocument(QTextDocument):
|
||||
|
||||
return
|
||||
|
||||
def metaDataAtPos(self, pos: int) -> tuple[str, str]:
|
||||
"""Check if there is meta data available at a given position in
|
||||
the document, and if so, return it.
|
||||
"""
|
||||
cursor = QTextCursor(self)
|
||||
cursor.setPosition(pos)
|
||||
block = cursor.block()
|
||||
data = block.userData()
|
||||
if block.isValid() and isinstance(data, TextBlockData):
|
||||
if (check := pos - block.position()) >= 0:
|
||||
for cPos, cEnd, cData, cType in data.metaData:
|
||||
if cPos <= check <= cEnd:
|
||||
return cData, cType
|
||||
return "", ""
|
||||
|
||||
def spellErrorAtPos(self, pos: int) -> tuple[str, int, int, list[str]]:
|
||||
"""Check if there is a misspelled word at a given position in
|
||||
the document, and if so, return it.
|
||||
|
||||
Reference in New Issue
Block a user