Compare commits
No commits in common. "cc4f1cd389c06ab363a99792594b402e85f72f1f" and "ba6417e72f2d2600dfc038c2456c66d87afcae3a" have entirely different histories.
cc4f1cd389
...
ba6417e72f
3 changed files with 63 additions and 123 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 don’t 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 block’s 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):
|
||||||
|
|
|
||||||
|
|
@ -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"]:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue