diff --git a/bouquin/editor.py b/bouquin/editor.py index a5b7643..1f7e132 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -1,23 +1,147 @@ from __future__ import annotations from PySide6.QtGui import ( + QBrush, QColor, + QDesktopServices, QFont, QFontDatabase, QTextCharFormat, + QTextCursor, QTextListFormat, QTextBlockFormat, ) -from PySide6.QtCore import Slot +from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression from PySide6.QtWidgets import QTextEdit +from .url_highlighter import UrlHighlighter class Editor(QTextEdit): - def __init__(self): - super().__init__() + linkActivated = Signal(str) + + _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") 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): """ Sets the styling on the selected characters. diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index a3a8cef..0951f20 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -19,6 +19,7 @@ class ToolBar(QToolBar): def __init__(self, parent=None): super().__init__("Format", parent) self._build_actions() + self.setObjectName("Format") def _build_actions(self): # Bold diff --git a/bouquin/url_highlighter.py b/bouquin/url_highlighter.py new file mode 100644 index 0000000..1fd1af6 --- /dev/null +++ b/bouquin/url_highlighter.py @@ -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)