diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index e6ec14ed..0ede0960 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -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.""" diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index ac222dd4..c06962b8 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -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 ( diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 5b296fa8..13d641fa 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -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") diff --git a/novelwriter/gui/editordocument.py b/novelwriter/gui/editordocument.py index 6e46d12d..01d2a926 100644 --- a/novelwriter/gui/editordocument.py +++ b/novelwriter/gui/editordocument.py @@ -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.