Some code consolidation in editor

This commit is contained in:
Miguel Jacq 2025-11-07 09:38:25 +11:00
parent 71b6ee2651
commit ada1d8ffad
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
3 changed files with 154 additions and 155 deletions

View file

@ -79,46 +79,35 @@ class Editor(QTextEdit):
self.textChanged.connect(self._linkify_document) self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True) self.viewport().setMouseTracking(True)
def _approx(self, a: float, b: float, eps: float = 0.5) -> bool: # ---------------- Helpers ---------------- #
return abs(float(a) - float(b)) <= eps
def _is_heading_typing(self) -> bool: def _iter_frames(self, root=None):
"""Is the current *insertion* format using a heading size?""" """Depth-first traversal of all frames (including root if passed)."""
bf = self.textCursor().blockFormat() doc = self.document()
if bf.headingLevel() > 0: stack = [root or doc.rootFrame()]
return True while stack:
f = stack.pop()
yield f
it = f.begin()
while not it.atEnd():
cf = it.currentFrame()
if cf is not None:
stack.append(cf)
it += 1
def _apply_normal_typing(self): def _is_code_frame(self, frame, tolerant: bool = False) -> bool:
"""Switch the *insertion* format to Normal (default size, normal weight)."""
nf = QTextCharFormat()
nf.setFontPointSize(self.font().pointSizeF())
nf.setFontWeight(QFont.Weight.Normal)
self.mergeCurrentCharFormat(nf)
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 _looks_like_code_frame(self, frame) -> bool:
""" """
Heuristic: treat a frame as 'code' if True if 'frame' is a code frame.
- it still has our property, OR - tolerant=False: require our property marker
- it has the classic light code bg (245,245,245) or our dark bg, OR - tolerant=True: also accept legacy background or non-wrapping heuristic
- most blocks inside are non-wrapping (NonBreakableLines=True),
which we set in apply_code() and which survives HTML round-trip.
""" """
ff = frame.frameFormat() ff = frame.frameFormat()
if ff.property(self._CODE_FRAME_PROP): if ff.property(self._CODE_FRAME_PROP):
return True return True
if not tolerant:
return False
# Background check # Background colour check
bg = ff.background() bg = ff.background()
if bg.style() != Qt.NoBrush: if bg.style() != Qt.NoBrush:
c = bg.color() c = bg.color()
@ -136,7 +125,7 @@ class Editor(QTextEdit):
): ):
return True return True
# Block formatting check (survives toHtml/fromHtml) # Heuristic: mostly non-wrapping blocks
doc = self.document() doc = self.document()
bc = QTextCursor(doc) bc = QTextCursor(doc)
bc.setPosition(frame.firstPosition()) bc.setPosition(frame.firstPosition())
@ -146,12 +135,112 @@ class Editor(QTextEdit):
if not b.isValid(): if not b.isValid():
break break
blocks += 1 blocks += 1
bf = b.blockFormat() if b.blockFormat().nonBreakableLines():
if bf.nonBreakableLines():
codeish += 1 codeish += 1
bc.setPosition(b.position() + b.length()) bc.setPosition(b.position() + b.length())
return blocks > 0 and (codeish / blocks) >= 0.6 return blocks > 0 and (codeish / blocks) >= 0.6
def _nearest_code_frame(self, cursor=None, tolerant: bool = False):
"""Walk up parents from the cursor and return the first code frame."""
if cursor is None:
cursor = self.textCursor()
f = cursor.currentFrame()
while f:
if self._is_code_frame(f, tolerant=tolerant):
return f
f = f.parentFrame()
return None
def _code_block_formats(self, fg: QColor | None = None):
"""(QTextBlockFormat, QTextCharFormat) for code blocks."""
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
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)
if fg is not None:
cf.setForeground(fg)
return bf, cf
def _new_code_frame_format(self, bg: QColor) -> QTextFrameFormat:
"""Standard frame format for code blocks."""
ff = QTextFrameFormat()
ff.setBackground(bg)
ff.setPadding(6)
ff.setBorder(0)
ff.setLeftMargin(0)
ff.setRightMargin(0)
ff.setTopMargin(0)
ff.setBottomMargin(0)
ff.setProperty(self._CODE_FRAME_PROP, True)
return ff
def _retint_code_frame(self, frame, bg: QColor, fg: QColor | None):
"""Apply background to frame and standard code formats to all blocks inside."""
ff = frame.frameFormat()
ff.setBackground(bg)
frame.setFrameFormat(ff)
bf, cf = self._code_block_formats(fg)
doc = self.document()
bc = QTextCursor(doc)
bc.setPosition(frame.firstPosition())
while bc.position() < frame.lastPosition():
bc.select(QTextCursor.BlockUnderCursor)
bc.mergeBlockFormat(bf)
bc.mergeBlockCharFormat(cf)
if not bc.movePosition(QTextCursor.NextBlock):
break
def _safe_block_insertion_cursor(self):
"""
Return a cursor positioned for inserting an inline object (like an image):
- not inside a code frame (moves to after frame if necessary)
- at a fresh paragraph (inserts a block if mid-line)
Also updates the editor's current cursor to that position.
"""
c = QTextCursor(self.textCursor())
frame = self._nearest_code_frame(c, tolerant=False) # strict: our frames only
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
if c.positionInBlock() != 0:
c.insertBlock()
return c
def _scale_to_viewport(self, img: QImage, ratio: float = 0.92) -> QImage:
"""If the image is wider than viewport*ratio, scale it down proportionally."""
if self.viewport():
max_w = int(self.viewport().width() * ratio)
if img.width() > max_w:
return img.scaledToWidth(max_w, Qt.SmoothTransformation)
return img
def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
return abs(float(a) - float(b)) <= eps
def _is_heading_typing(self) -> bool:
"""Is the current *insertion* format using a heading size?"""
bf = self.textCursor().blockFormat()
if bf.headingLevel() > 0:
return True
def _apply_normal_typing(self):
"""Switch the *insertion* format to Normal (default size, normal weight)."""
nf = QTextCharFormat()
nf.setFontPointSize(self.font().pointSizeF())
nf.setFontWeight(QFont.Weight.Normal)
self.mergeCurrentCharFormat(nf)
def _code_theme_colors(self): def _code_theme_colors(self):
"""Return (bg, fg) for code blocks based on the effective palette.""" """Return (bg, fg) for code blocks based on the effective palette."""
pal = QApplication.instance().palette() pal = QApplication.instance().palette()
@ -175,46 +264,9 @@ class Editor(QTextEdit):
cur = QTextCursor(doc) cur = QTextCursor(doc)
cur.beginEditBlock() cur.beginEditBlock()
try: try:
# Traverse all frames reliably (iterator-based, works after reload) for f in self._iter_frames(doc.rootFrame()):
stack = [doc.rootFrame()] if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True):
while stack: self._retint_code_frame(f, bg, fg)
f = stack.pop()
it = f.begin()
while not it.atEnd():
cf = it.currentFrame()
if cf is not None:
stack.append(cf)
it += 1
# Retint frames that look like code
if f is not doc.rootFrame() and self._looks_like_code_frame(f):
ff = f.frameFormat()
ff.setBackground(bg)
f.setFrameFormat(ff)
# Make sure the text inside stays readable and monospaced
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
bc = QTextCursor(doc)
bc.setPosition(f.firstPosition())
while bc.position() < f.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)
cf.setForeground(fg)
bc.mergeBlockFormat(bf)
bc.mergeBlockCharFormat(cf)
if not bc.movePosition(QTextCursor.NextBlock):
break
finally: finally:
cur.endEditBlock() cur.endEditBlock()
self.viewport().update() self.viewport().update()
@ -276,7 +328,7 @@ class Editor(QTextEdit):
fmt.setFontUnderline(True) fmt.setFontUnderline(True)
fmt.setForeground(self.palette().brush(QPalette.Link)) fmt.setForeground(self.palette().brush(QPalette.Link))
cur.mergeCharFormat(fmt) # merge so we dont clobber other styling cur.mergeCharFormat(fmt) # merge so we don't clobber other styling
cur.endEditBlock() cur.endEditBlock()
finally: finally:
@ -336,26 +388,12 @@ class Editor(QTextEdit):
html = html.replace(f"src='{old}'", f"src='{data_url}'") html = html.replace(f"src='{old}'", f"src='{data_url}'")
return html return html
# ---------------- Image insertion & sizing (DRYd) ---------------- #
def _insert_qimage_at_cursor(self, img: QImage, autoscale=True): def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
c = self.textCursor() c = self._safe_block_insertion_cursor()
if autoscale:
# Dont drop inside a code frame img = self._scale_to_viewport(img)
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
# Start a fresh paragraph if mid-line
if c.positionInBlock() != 0:
c.insertBlock()
if autoscale and self.viewport():
max_w = int(self.viewport().width() * 0.92)
if img.width() > max_w:
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
c.insertImage(img) c.insertImage(img)
c.insertBlock() # one blank line after the image c.insertBlock() # one blank line after the image
@ -465,6 +503,8 @@ class Editor(QTextEdit):
return return
self._apply_image_size(tc, imgfmt, float(orig.width()), orig) self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
# ---------------- Context menu ---------------- #
def contextMenuEvent(self, e): def contextMenuEvent(self, e):
menu = self.createStandardContextMenu() menu = self.createStandardContextMenu()
tc, imgfmt, orig = self._image_info_at_cursor() tc, imgfmt, orig = self._image_info_at_cursor()
@ -478,6 +518,8 @@ class Editor(QTextEdit):
sub.addAction("Reset to original", self._reset_image_size) sub.addAction("Reset to original", self._reset_image_size)
menu.exec(e.globalPos()) menu.exec(e.globalPos())
# ---------------- Clipboard / DnD ---------------- #
def insertFromMimeData(self, source): def insertFromMimeData(self, source):
# 1) Direct image from clipboard # 1) Direct image from clipboard
if source.hasImage(): if source.hasImage():
@ -525,7 +567,7 @@ class Editor(QTextEdit):
data = base64.b64decode(m.group(1)) data = base64.b64decode(m.group(1))
img = QImage.fromData(data) img = QImage.fromData(data)
if not img.isNull(): if not img.isNull():
self._insert_qimage_at_cursor(self, img, autoscale=True) self._insert_qimage_at_cursor(img, autoscale=True)
return return
except Exception: except Exception:
pass # fall through pass # fall through
@ -539,19 +581,7 @@ class Editor(QTextEdit):
Insert one or more images at the cursor. Large images can be auto-scaled Insert one or more images at the cursor. Large images can be auto-scaled
to fit the viewport width while preserving aspect ratio. to fit the viewport width while preserving aspect ratio.
""" """
c = self.textCursor() c = self._safe_block_insertion_cursor()
# Avoid dropping images inside a code frame
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
# Ensure there's a paragraph break if we're mid-line
if c.positionInBlock() != 0:
c.insertBlock()
for path in paths: for path in paths:
reader = QImageReader(path) reader = QImageReader(path)
@ -559,14 +589,14 @@ class Editor(QTextEdit):
if img.isNull(): if img.isNull():
continue continue
if autoscale and self.viewport(): if autoscale:
max_w = int(self.viewport().width() * 0.92) # ~92% of editor width img = self._scale_to_viewport(img)
if img.width() > max_w:
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
c.insertImage(img) c.insertImage(img)
c.insertBlock() # put each image on its own line c.insertBlock() # put each image on its own line
# ---------------- Mouse & key handling ---------------- #
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):
href = self.anchorAt(e.pos()) href = self.anchorAt(e.pos())
@ -637,7 +667,7 @@ class Editor(QTextEdit):
# If we're on an empty line inside a code frame, consume Enter and jump out # If we're on an empty line inside a code frame, consume Enter and jump out
if c.block().length() == 1: if c.block().length() == 1:
frame = self._find_code_frame(c) frame = self._nearest_code_frame(c, tolerant=False)
if frame: if frame:
out = QTextCursor(self.document()) out = QTextCursor(self.document())
out.setPosition(frame.lastPosition()) # after the frame's contents out.setPosition(frame.lastPosition()) # after the frame's contents
@ -715,7 +745,7 @@ class Editor(QTextEdit):
# ====== Checkbox core ====== # ====== Checkbox core ======
def _base_point_size_for_block(self, block) -> float: def _base_point_size_for_block(self, block) -> float:
# Try the blocks char format, then editor font # Try the block's char format, then editor font
sz = block.charFormat().fontPointSize() sz = block.charFormat().fontPointSize()
if sz <= 0: if sz <= 0:
sz = self.fontPointSize() sz = self.fontPointSize()
@ -871,46 +901,16 @@ class Editor(QTextEdit):
if not c.hasSelection(): if not c.hasSelection():
c.select(QTextCursor.BlockUnderCursor) c.select(QTextCursor.BlockUnderCursor)
# Wrap the selection in a single frame (no per-block padding/margins). ff = self._new_code_frame_format(self._CODE_BG)
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)
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
c.beginEditBlock() c.beginEditBlock()
try: try:
c.insertFrame(ff) # with a selection, this wraps the selection c.insertFrame(ff) # with a selection, this wraps the selection
# Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping # Format all blocks inside the new frame (keep fg=None on creation)
frame = self._find_code_frame(c) frame = self._nearest_code_frame(c, tolerant=False)
bc = QTextCursor(self.document()) if frame:
bc.setPosition(frame.firstPosition()) self._retint_code_frame(frame, self._CODE_BG, fg=None)
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: finally:
c.endEditBlock() c.endEditBlock()

View file

@ -707,7 +707,7 @@ class MainWindow(QMainWindow):
QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
) )
r = screen.availableGeometry() r = screen.availableGeometry()
# Center the window in that screens available area # Center the window in that screen's available area
self.move(r.center() - self.rect().center()) self.move(r.center() - self.rect().center())
# ----------------- Export handler ----------------- # # ----------------- Export handler ----------------- #
@ -836,7 +836,7 @@ If you want an encrypted backup, choose Backup instead of Export.
return return
if minutes == 0: if minutes == 0:
self._idle_timer.stop() self._idle_timer.stop()
# If youre currently locked, unlock when user disables the timer: # If currently locked, unlock when user disables the timer:
if getattr(self, "_locked", False): if getattr(self, "_locked", False):
try: try:
self._locked = False self._locked = False

View file

@ -169,13 +169,12 @@ def test_code_block_enter_exits_on_empty_line(qtbot):
QTest.keyClick(e, Qt.Key_Return) QTest.keyClick(e, Qt.Key_Return)
# Ensure we are on an empty block *inside* the code frame # Ensure we are on an empty block *inside* the code frame
qtbot.waitUntil( qtbot.waitUntil(
lambda: e._find_code_frame(e.textCursor()) is not None lambda: e._nearest_code_frame(e.textCursor(), tolerant=False) is not None
and e.textCursor().block().length() == 1 and e.textCursor().block().length() == 1
) )
# Second Enter should jump *out* of the frame # Second Enter should jump *out* of the frame
QTest.keyClick(e, Qt.Key_Return) QTest.keyClick(e, Qt.Key_Return)
# qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None)
class DummyMenu: class DummyMenu:
@ -310,7 +309,7 @@ def test_enter_leaves_code_frame(qtbot):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier) ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
e.keyPressEvent(ev) e.keyPressEvent(ev)
# After enter, the cursor should not be inside a code frame # After enter, the cursor should not be inside a code frame
assert e._find_code_frame(e.textCursor()) is None assert e._nearest_code_frame(e.textCursor(), tolerant=False) is None
def test_space_does_not_bleed_anchor_format(qtbot): def test_space_does_not_bleed_anchor_format(qtbot):