Compare commits

..

No commits in common. "cc4f1cd389c06ab363a99792594b402e85f72f1f" and "ba6417e72f2d2600dfc038c2456c66d87afcae3a" have entirely different histories.

3 changed files with 63 additions and 123 deletions

View file

@ -1,9 +1,6 @@
# 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
* Render the history version dates in user's local timezone
# 0.1.7 # 0.1.7

View file

@ -7,7 +7,6 @@ from PySide6.QtGui import (
QFontDatabase, QFontDatabase,
QTextCharFormat, QTextCharFormat,
QTextCursor, QTextCursor,
QTextFrameFormat,
QTextListFormat, QTextListFormat,
QTextBlockFormat, QTextBlockFormat,
) )
@ -18,9 +17,7 @@ from PySide6.QtWidgets import QTextEdit
class Editor(QTextEdit): class Editor(QTextEdit):
linkActivated = Signal(str) linkActivated = Signal(str)
_URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)') _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|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)
@ -40,78 +37,50 @@ 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
try: doc = self.document()
block = self.textCursor().block() cur = QTextCursor(doc)
start_pos = block.position() cur.beginEditBlock()
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()
s = start_pos + m.capturedStart() start = block.position() + m.capturedStart()
raw = m.captured(0) end = start + m.capturedLength()
url = self._trim_url_end(raw)
if not url: cur.setPosition(start)
cur.setPosition(end, QTextCursor.KeepAnchor)
fmt = cur.charFormat()
if fmt.isAnchor(): # already linkified; skip
continue continue
e = s + len(url) href = m.captured(0)
cur.setPosition(s) if href.startswith("www."):
cur.setPosition(e, QTextCursor.KeepAnchor) href = "https://" + href
if url.startswith("www."):
href = "https://" + url
else:
href = url
fmt = QTextCharFormat()
fmt.setAnchor(True) 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.setFontUnderline(True)
fmt.setForeground(Qt.blue) fmt.setForeground(Qt.blue)
cur.setCharFormat(fmt)
cur.mergeCharFormat(fmt) # merge so we dont clobber other styling block = block.next()
cur.endEditBlock() cur.endEditBlock()
finally: self._linkifying = False
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):
@ -137,19 +106,18 @@ 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):
c = self.textCursor() super().keyPressEvent(e) # create the new (possibly empty) paragraph
# If we're on an empty line inside a code frame, consume Enter and jump out
if c.block().length() == 1: # If we're on an empty block, clear the insertion char format so the
frame = self._find_code_frame(c) # *next* Enter will create another new line (not consume the press to reset formatting).
if frame: c = self.textCursor()
out = QTextCursor(self.document()) block = c.block()
out.setPosition(frame.lastPosition()) # after the frame's contents if block.length() == 1:
self.setTextCursor(out) self._clear_insertion_char_format()
super().insertPlainText("\n") # start a normal paragraph return
return
# otherwise default handling
return super().keyPressEvent(e) return super().keyPressEvent(e)
def _clear_insertion_char_format(self): def _clear_insertion_char_format(self):
@ -219,50 +187,34 @@ 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(QTextCursor.BlockUnderCursor) c.select(c.SelectionType.BlockUnderCursor)
# Wrap the selection in a single frame (no per-block padding/margins). bf = QTextBlockFormat()
ff = QTextFrameFormat() bf.setLeftMargin(12)
ff.setBackground(self._CODE_BG) bf.setRightMargin(12)
ff.setPadding(6) # visual padding for the WHOLE block bf.setTopMargin(6)
ff.setBorder(0) bf.setBottomMargin(6)
ff.setLeftMargin(0) bf.setBackground(QColor(245, 245, 245))
ff.setRightMargin(0) bf.setNonBreakableLines(True)
ff.setTopMargin(0)
ff.setBottomMargin(0)
ff.setProperty(self._CODE_FRAME_PROP, True)
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) cf = QTextCharFormat()
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
cf.setFont(mono)
cf.setFontFixedPitch(True)
c.beginEditBlock() # If the current block already looks like a code block, remove styling
try: cur_bf = c.blockFormat()
c.insertFrame(ff) # with a selection, this wraps the selection 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 c.mergeBlockFormat(bf)
frame = self._find_code_frame(c) c.mergeBlockCharFormat(cf)
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):

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import difflib, re, html as _html import difflib, re, html as _html
from datetime import datetime
from PySide6.QtCore import Qt, Slot from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@ -105,14 +104,6 @@ class HistoryDialog(QDialog):
self._load_versions() self._load_versions()
# --- Data/UX helpers --- # --- 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): def _load_versions(self):
self._versions = self._db.list_versions( self._versions = self._db.list_versions(
self._date self._date
@ -122,7 +113,7 @@ class HistoryDialog(QDialog):
) )
self.list.clear() self.list.clear()
for v in self._versions: 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"): if v.get("note"):
label += f" · {v['note']}" label += f" · {v['note']}"
if v["is_current"]: if v["is_current"]: