Add url handling in editor and viewer

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