diff --git a/CHANGELOG.md b/CHANGELOG.md index f04325b..0ee1a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,6 @@ # 0.1.8 * 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 - * Render the history version dates in user's local timezone # 0.1.7 diff --git a/bouquin/editor.py b/bouquin/editor.py index 6efbb7a..eb3b664 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -7,7 +7,6 @@ from PySide6.QtGui import ( QFontDatabase, QTextCharFormat, QTextCursor, - QTextFrameFormat, QTextListFormat, QTextBlockFormat, ) @@ -18,9 +17,7 @@ from PySide6.QtWidgets import QTextEdit class Editor(QTextEdit): linkActivated = Signal(str) - _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)') - _CODE_BG = QColor(245, 245, 245) - _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames + _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -40,78 +37,50 @@ class Editor(QTextEdit): self.textChanged.connect(self._linkify_document) 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): if self._linkifying: return self._linkifying = True - try: - block = self.textCursor().block() - start_pos = block.position() + doc = self.document() + cur = QTextCursor(doc) + cur.beginEditBlock() + + block = doc.begin() + while block.isValid(): text = block.text() - - cur = QTextCursor(self.document()) - cur.beginEditBlock() - it = self._URL_RX.globalMatch(text) while it.hasNext(): m = it.next() - s = start_pos + m.capturedStart() - raw = m.captured(0) - url = self._trim_url_end(raw) - if not url: + 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 - e = s + len(url) - cur.setPosition(s) - cur.setPosition(e, QTextCursor.KeepAnchor) + href = m.captured(0) + if href.startswith("www."): + href = "https://" + href - if url.startswith("www."): - href = "https://" + url - else: - href = url - - fmt = QTextCharFormat() fmt.setAnchor(True) - fmt.setAnchorHref(href) # always refresh to the latest full URL + # 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) - cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling + block = block.next() - cur.endEditBlock() - finally: - self._linkifying = False + cur.endEditBlock() + self._linkifying = False def mouseReleaseEvent(self, e): if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): @@ -137,19 +106,18 @@ class Editor(QTextEdit): 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): - c = self.textCursor() - # If we're on an empty line inside a code frame, consume Enter and jump out - if c.block().length() == 1: - frame = self._find_code_frame(c) - 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 + 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 - # otherwise default handling return super().keyPressEvent(e) def _clear_insertion_char_format(self): @@ -219,50 +187,34 @@ class Editor(QTextEdit): def apply_code(self): c = self.textCursor() if not c.hasSelection(): - c.select(QTextCursor.BlockUnderCursor) + c.select(c.SelectionType.BlockUnderCursor) - # Wrap the selection in a single frame (no per-block padding/margins). - ff = QTextFrameFormat() - ff.setBackground(self._CODE_BG) - ff.setPadding(6) # visual padding for the WHOLE block - ff.setBorder(0) - ff.setLeftMargin(0) - ff.setRightMargin(0) - ff.setTopMargin(0) - ff.setBottomMargin(0) - ff.setProperty(self._CODE_FRAME_PROP, True) + bf = QTextBlockFormat() + bf.setLeftMargin(12) + bf.setRightMargin(12) + bf.setTopMargin(6) + bf.setBottomMargin(6) + bf.setBackground(QColor(245, 245, 245)) + bf.setNonBreakableLines(True) - mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) + cf = QTextCharFormat() + mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + cf.setFont(mono) + cf.setFontFixedPitch(True) - c.beginEditBlock() - try: - c.insertFrame(ff) # with a selection, this wraps the selection + # If the current block already looks like a code block, remove styling + cur_bf = c.blockFormat() + is_code = ( + 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() - # Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping - 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() + c.mergeBlockFormat(bf) + c.mergeBlockCharFormat(cf) @Slot(int) def apply_heading(self, size): diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 9cccda7..1c906ac 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -1,7 +1,6 @@ from __future__ import annotations import difflib, re, html as _html -from datetime import datetime from PySide6.QtCore import Qt, Slot from PySide6.QtWidgets import ( QDialog, @@ -105,14 +104,6 @@ class HistoryDialog(QDialog): self._load_versions() # --- Data/UX helpers --- - def _fmt_local(self, iso_utc: str) -> str: - """ - Convert UTC in the database to user's local tz - """ - dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00")) - local = dt.astimezone() - return local.strftime("%Y-%m-%d %H:%M:%S %Z") - def _load_versions(self): self._versions = self._db.list_versions( self._date @@ -122,7 +113,7 @@ class HistoryDialog(QDialog): ) self.list.clear() for v in self._versions: - label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}" + label = f"v{v['version_no']} — {v['created_at']}" if v.get("note"): label += f" · {v['note']}" if v["is_current"]: