Clickable URL links
This commit is contained in:
parent
c4f99f9b2b
commit
327e7882b5
3 changed files with 153 additions and 3 deletions
|
|
@ -1,23 +1,147 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
|
QBrush,
|
||||||
QColor,
|
QColor,
|
||||||
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
QFontDatabase,
|
QFontDatabase,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
|
QTextCursor,
|
||||||
QTextListFormat,
|
QTextListFormat,
|
||||||
QTextBlockFormat,
|
QTextBlockFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Slot
|
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
|
||||||
from PySide6.QtWidgets import QTextEdit
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
|
from .url_highlighter import UrlHighlighter
|
||||||
|
|
||||||
class Editor(QTextEdit):
|
class Editor(QTextEdit):
|
||||||
def __init__(self):
|
linkActivated = Signal(str)
|
||||||
super().__init__()
|
|
||||||
|
_URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||||||
self.setTabStopDistance(tab_w)
|
self.setTabStopDistance(tab_w)
|
||||||
|
|
||||||
|
self.setTextInteractionFlags(
|
||||||
|
Qt.TextInteractionFlag.TextEditorInteraction
|
||||||
|
| Qt.TextInteractionFlag.LinksAccessibleByMouse
|
||||||
|
| Qt.TextInteractionFlag.LinksAccessibleByKeyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
self.setAcceptRichText(True)
|
||||||
|
|
||||||
|
# Turn raw URLs into anchors
|
||||||
|
self._linkifying = False
|
||||||
|
self.textChanged.connect(self._linkify_document)
|
||||||
|
self.viewport().setMouseTracking(True)
|
||||||
|
|
||||||
|
def _linkify_document(self):
|
||||||
|
if self._linkifying:
|
||||||
|
return
|
||||||
|
self._linkifying = True
|
||||||
|
|
||||||
|
doc = self.document()
|
||||||
|
cur = QTextCursor(doc)
|
||||||
|
cur.beginEditBlock()
|
||||||
|
|
||||||
|
block = doc.begin()
|
||||||
|
while block.isValid():
|
||||||
|
text = block.text()
|
||||||
|
it = self._URL_RX.globalMatch(text)
|
||||||
|
while it.hasNext():
|
||||||
|
m = it.next()
|
||||||
|
start = block.position() + m.capturedStart()
|
||||||
|
end = start + m.capturedLength()
|
||||||
|
|
||||||
|
cur.setPosition(start)
|
||||||
|
cur.setPosition(end, QTextCursor.KeepAnchor)
|
||||||
|
|
||||||
|
fmt = cur.charFormat()
|
||||||
|
if fmt.isAnchor(): # already linkified; skip
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = m.captured(0)
|
||||||
|
if href.startswith("www."):
|
||||||
|
href = "https://" + href
|
||||||
|
|
||||||
|
fmt.setAnchor(True)
|
||||||
|
# Qt 6: use setAnchorHref; for compatibility, also set names.
|
||||||
|
try:
|
||||||
|
fmt.setAnchorHref(href)
|
||||||
|
except AttributeError:
|
||||||
|
fmt.setAnchorNames([href])
|
||||||
|
|
||||||
|
fmt.setFontUnderline(True)
|
||||||
|
fmt.setForeground(Qt.blue)
|
||||||
|
cur.setCharFormat(fmt)
|
||||||
|
|
||||||
|
block = block.next()
|
||||||
|
|
||||||
|
cur.endEditBlock()
|
||||||
|
self._linkifying = False
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, e):
|
||||||
|
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
|
||||||
|
href = self.anchorAt(e.pos())
|
||||||
|
if href:
|
||||||
|
QDesktopServices.openUrl(QUrl.fromUserInput(href))
|
||||||
|
self.linkActivated.emit(href)
|
||||||
|
return
|
||||||
|
super().mouseReleaseEvent(e)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, e):
|
||||||
|
if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
|
||||||
|
self.viewport().setCursor(Qt.PointingHandCursor)
|
||||||
|
else:
|
||||||
|
self.viewport().setCursor(Qt.IBeamCursor)
|
||||||
|
super().mouseMoveEvent(e)
|
||||||
|
|
||||||
|
def keyPressEvent(self, e):
|
||||||
|
key = e.key()
|
||||||
|
|
||||||
|
# Pre-insert: stop link/format bleed for “word boundary” keys
|
||||||
|
if key in (Qt.Key_Space, Qt.Key_Tab):
|
||||||
|
self._break_anchor_for_next_char()
|
||||||
|
return super().keyPressEvent(e)
|
||||||
|
|
||||||
|
# When pressing Enter/return key, insert first, then neutralise the empty block’s inline format
|
||||||
|
if key in (Qt.Key_Return, Qt.Key_Enter):
|
||||||
|
super().keyPressEvent(e) # create the new (possibly empty) paragraph
|
||||||
|
|
||||||
|
# If we're on an empty block, clear the insertion char format so the
|
||||||
|
# *next* Enter will create another new line (not consume the press to reset formatting).
|
||||||
|
c = self.textCursor()
|
||||||
|
block = c.block()
|
||||||
|
if block.length() == 1:
|
||||||
|
self._clear_insertion_char_format()
|
||||||
|
return
|
||||||
|
|
||||||
|
return super().keyPressEvent(e)
|
||||||
|
|
||||||
|
def _clear_insertion_char_format(self):
|
||||||
|
"""Reset inline typing format (keeps lists, alignment, margins, etc.)."""
|
||||||
|
nf = QTextCharFormat()
|
||||||
|
self.setCurrentCharFormat(nf)
|
||||||
|
|
||||||
|
def _break_anchor_for_next_char(self):
|
||||||
|
c = self.textCursor()
|
||||||
|
fmt = c.charFormat()
|
||||||
|
if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
|
||||||
|
# clone, then strip just the link-specific bits so the next char is plain text
|
||||||
|
nf = QTextCharFormat(fmt)
|
||||||
|
nf.setAnchor(False)
|
||||||
|
nf.setFontUnderline(False)
|
||||||
|
nf.clearForeground()
|
||||||
|
try:
|
||||||
|
nf.setAnchorHref("")
|
||||||
|
except AttributeError:
|
||||||
|
nf.setAnchorNames([])
|
||||||
|
self.setCurrentCharFormat(nf)
|
||||||
|
|
||||||
def merge_on_sel(self, fmt):
|
def merge_on_sel(self, fmt):
|
||||||
"""
|
"""
|
||||||
Sets the styling on the selected characters.
|
Sets the styling on the selected characters.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class ToolBar(QToolBar):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__("Format", parent)
|
super().__init__("Format", parent)
|
||||||
self._build_actions()
|
self._build_actions()
|
||||||
|
self.setObjectName("Format")
|
||||||
|
|
||||||
def _build_actions(self):
|
def _build_actions(self):
|
||||||
# Bold
|
# Bold
|
||||||
|
|
|
||||||
25
bouquin/url_highlighter.py
Normal file
25
bouquin/url_highlighter.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat
|
||||||
|
from PySide6.QtCore import Qt, QRegularExpression
|
||||||
|
|
||||||
|
class UrlHighlighter(QSyntaxHighlighter):
|
||||||
|
def __init__(self, doc):
|
||||||
|
super().__init__(doc)
|
||||||
|
self.rx = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
|
||||||
|
|
||||||
|
def highlightBlock(self, text: str):
|
||||||
|
it = self.rx.globalMatch(text)
|
||||||
|
while it.hasNext():
|
||||||
|
m = it.next()
|
||||||
|
href = m.captured(0)
|
||||||
|
if href.startswith("www."):
|
||||||
|
href = "https://" + href
|
||||||
|
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setAnchor(True)
|
||||||
|
fmt.setAnchorHref(href)
|
||||||
|
fmt.setFontUnderline(True)
|
||||||
|
fmt.setForeground(Qt.blue)
|
||||||
|
self.setFormat(m.capturedStart(0), m.capturedLength(0), fmt)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue