Fix for multi-line code blocks and href linking

This commit is contained in:
Miguel Jacq 2025-11-03 14:33:51 +11:00
parent ba6417e72f
commit 5fb7597dfd
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
2 changed files with 111 additions and 61 deletions

View file

@ -1,6 +1,8 @@
# 0.1.8 # 0.1.8
* Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed * Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed
* Fixes for multi-line code blocks
* Fix URL href linking
# 0.1.7 # 0.1.7

View file

@ -7,6 +7,7 @@ from PySide6.QtGui import (
QFontDatabase, QFontDatabase,
QTextCharFormat, QTextCharFormat,
QTextCursor, QTextCursor,
QTextFrameFormat,
QTextListFormat, QTextListFormat,
QTextBlockFormat, QTextBlockFormat,
) )
@ -17,7 +18,9 @@ from PySide6.QtWidgets import QTextEdit
class Editor(QTextEdit): class Editor(QTextEdit):
linkActivated = Signal(str) linkActivated = Signal(str)
_URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)") _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
_CODE_BG = QColor(245, 245, 245)
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -37,50 +40,78 @@ class Editor(QTextEdit):
self.textChanged.connect(self._linkify_document) self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True) self.viewport().setMouseTracking(True)
def _find_code_frame(self, cursor=None):
"""Return the nearest ancestor frame that's one of our code frames, else None."""
if cursor is None:
cursor = self.textCursor()
f = cursor.currentFrame()
while f:
if f.frameFormat().property(self._CODE_FRAME_PROP):
return f
f = f.parentFrame()
return None
def _is_code_block(self, block) -> bool:
if not block.isValid():
return False
bf = block.blockFormat()
return bool(
bf.nonBreakableLines()
and bf.background().color().rgb() == self._CODE_BG.rgb()
)
def _trim_url_end(self, url: str) -> str:
# strip common trailing punctuation not part of the URL
trimmed = url.rstrip(".,;:!?\"'")
# drop an unmatched closing ) or ] at the very end
if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
trimmed = trimmed[:-1]
if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
trimmed = trimmed[:-1]
return trimmed
def _linkify_document(self): def _linkify_document(self):
if self._linkifying: if self._linkifying:
return return
self._linkifying = True self._linkifying = True
doc = self.document() try:
cur = QTextCursor(doc) block = self.textCursor().block()
cur.beginEditBlock() start_pos = block.position()
block = doc.begin()
while block.isValid():
text = block.text() text = block.text()
cur = QTextCursor(self.document())
cur.beginEditBlock()
it = self._URL_RX.globalMatch(text) it = self._URL_RX.globalMatch(text)
while it.hasNext(): while it.hasNext():
m = it.next() m = it.next()
start = block.position() + m.capturedStart() s = start_pos + m.capturedStart()
end = start + m.capturedLength() raw = m.captured(0)
url = self._trim_url_end(raw)
cur.setPosition(start) if not url:
cur.setPosition(end, QTextCursor.KeepAnchor)
fmt = cur.charFormat()
if fmt.isAnchor(): # already linkified; skip
continue continue
href = m.captured(0) e = s + len(url)
if href.startswith("www."): cur.setPosition(s)
href = "https://" + href cur.setPosition(e, QTextCursor.KeepAnchor)
if url.startswith("www."):
href = "https://" + url
else:
href = url
fmt = QTextCharFormat()
fmt.setAnchor(True) fmt.setAnchor(True)
# Qt 6: use setAnchorHref; for compatibility, also set names. fmt.setAnchorHref(href) # always refresh to the latest full URL
try:
fmt.setAnchorHref(href)
except AttributeError:
fmt.setAnchorNames([href])
fmt.setFontUnderline(True) fmt.setFontUnderline(True)
fmt.setForeground(Qt.blue) fmt.setForeground(Qt.blue)
cur.setCharFormat(fmt)
block = block.next() cur.mergeCharFormat(fmt) # merge so we dont clobber other styling
cur.endEditBlock() cur.endEditBlock()
self._linkifying = False finally:
self._linkifying = False
def mouseReleaseEvent(self, e): def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
@ -106,18 +137,19 @@ class Editor(QTextEdit):
self._break_anchor_for_next_char() self._break_anchor_for_next_char()
return super().keyPressEvent(e) return super().keyPressEvent(e)
# When pressing Enter/return key, insert first, then neutralise the empty blocks inline format
if key in (Qt.Key_Return, Qt.Key_Enter): 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() c = self.textCursor()
block = c.block() # If we're on an empty line inside a code frame, consume Enter and jump out
if block.length() == 1: if c.block().length() == 1:
self._clear_insertion_char_format() frame = self._find_code_frame(c)
return if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition()) # after the frame's contents
self.setTextCursor(out)
super().insertPlainText("\n") # start a normal paragraph
return
# otherwise default handling
return super().keyPressEvent(e) return super().keyPressEvent(e)
def _clear_insertion_char_format(self): def _clear_insertion_char_format(self):
@ -187,34 +219,50 @@ class Editor(QTextEdit):
def apply_code(self): def apply_code(self):
c = self.textCursor() c = self.textCursor()
if not c.hasSelection(): if not c.hasSelection():
c.select(c.SelectionType.BlockUnderCursor) c.select(QTextCursor.BlockUnderCursor)
bf = QTextBlockFormat() # Wrap the selection in a single frame (no per-block padding/margins).
bf.setLeftMargin(12) ff = QTextFrameFormat()
bf.setRightMargin(12) ff.setBackground(self._CODE_BG)
bf.setTopMargin(6) ff.setPadding(6) # visual padding for the WHOLE block
bf.setBottomMargin(6) ff.setBorder(0)
bf.setBackground(QColor(245, 245, 245)) ff.setLeftMargin(0)
bf.setNonBreakableLines(True) ff.setRightMargin(0)
ff.setTopMargin(0)
ff.setBottomMargin(0)
ff.setProperty(self._CODE_FRAME_PROP, True)
cf = QTextCharFormat() mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
cf.setFont(mono)
cf.setFontFixedPitch(True)
# If the current block already looks like a code block, remove styling c.beginEditBlock()
cur_bf = c.blockFormat() try:
is_code = ( c.insertFrame(ff) # with a selection, this wraps the selection
cur_bf.nonBreakableLines()
and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
)
if is_code:
# clear: margins/background/wrapping
bf = QTextBlockFormat()
cf = QTextCharFormat()
c.mergeBlockFormat(bf) # Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping
c.mergeBlockCharFormat(cf) frame = self._find_code_frame(c)
bc = QTextCursor(self.document())
bc.setPosition(frame.firstPosition())
while bc.position() < frame.lastPosition():
bc.select(QTextCursor.BlockUnderCursor)
bf = QTextBlockFormat()
bf.setTopMargin(0)
bf.setBottomMargin(0)
bf.setLeftMargin(12)
bf.setRightMargin(12)
bf.setNonBreakableLines(True)
cf = QTextCharFormat()
cf.setFont(mono)
cf.setFontFixedPitch(True)
bc.mergeBlockFormat(bf)
bc.mergeBlockCharFormat(cf)
bc.setPosition(bc.block().position() + bc.block().length())
finally:
c.endEditBlock()
@Slot(int) @Slot(int)
def apply_heading(self, size): def apply_heading(self, size):