diff --git a/CHANGELOG.md b/CHANGELOG.md index 7243f00..439e9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,3 @@ -# 0.1.11 - - * Add missing export extensions to export_by_extension - # 0.1.10.2 * Fix for code blocks in dark mode diff --git a/bouquin/db.py b/bouquin/db.py index 54c811c..20261eb 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -480,10 +480,6 @@ class DBManager: self.export_txt(entries, file_path) elif ext in {".html", ".htm"}: self.export_html(entries, file_path) - elif ext in {".sql", ".sqlite"}: - self.export_sql(file_path) - elif ext == ".md": - self.export_markdown(file_path) else: raise ValueError(f"Unsupported extension: {ext}") diff --git a/bouquin/editor.py b/bouquin/editor.py index cb18755..9e7d692 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -79,35 +79,46 @@ class Editor(QTextEdit): self.textChanged.connect(self._linkify_document) self.viewport().setMouseTracking(True) - # ---------------- Helpers ---------------- # + def _approx(self, a: float, b: float, eps: float = 0.5) -> bool: + return abs(float(a) - float(b)) <= eps - def _iter_frames(self, root=None): - """Depth-first traversal of all frames (including root if passed).""" - doc = self.document() - stack = [root or doc.rootFrame()] - 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 _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 _is_code_frame(self, frame, tolerant: bool = False) -> bool: + 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 _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: """ - True if 'frame' is a code frame. - - tolerant=False: require our property marker - - tolerant=True: also accept legacy background or non-wrapping heuristic + Heuristic: treat a frame as 'code' if + - it still has our property, OR + - it has the classic light code bg (≈245,245,245) or our dark bg, OR + - most blocks inside are non-wrapping (NonBreakableLines=True), + which we set in apply_code() and which survives HTML round-trip. """ ff = frame.frameFormat() if ff.property(self._CODE_FRAME_PROP): return True - if not tolerant: - return False - # Background colour check + # Background check bg = ff.background() if bg.style() != Qt.NoBrush: c = bg.color() @@ -125,7 +136,7 @@ class Editor(QTextEdit): ): return True - # Heuristic: mostly non-wrapping blocks + # Block formatting check (survives toHtml/fromHtml) doc = self.document() bc = QTextCursor(doc) bc.setPosition(frame.firstPosition()) @@ -135,112 +146,12 @@ class Editor(QTextEdit): if not b.isValid(): break blocks += 1 - if b.blockFormat().nonBreakableLines(): + bf = b.blockFormat() + if bf.nonBreakableLines(): codeish += 1 bc.setPosition(b.position() + b.length()) 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): """Return (bg, fg) for code blocks based on the effective palette.""" pal = QApplication.instance().palette() @@ -264,9 +175,46 @@ class Editor(QTextEdit): cur = QTextCursor(doc) cur.beginEditBlock() try: - for f in self._iter_frames(doc.rootFrame()): - if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True): - self._retint_code_frame(f, bg, fg) + # Traverse all frames reliably (iterator-based, works after reload) + stack = [doc.rootFrame()] + while stack: + 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: cur.endEditBlock() self.viewport().update() @@ -328,7 +276,7 @@ class Editor(QTextEdit): fmt.setFontUnderline(True) fmt.setForeground(self.palette().brush(QPalette.Link)) - cur.mergeCharFormat(fmt) # merge so we don't clobber other styling + cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling cur.endEditBlock() finally: @@ -388,12 +336,26 @@ class Editor(QTextEdit): html = html.replace(f"src='{old}'", f"src='{data_url}'") return html - # ---------------- Image insertion & sizing (DRY’d) ---------------- # - def _insert_qimage_at_cursor(self, img: QImage, autoscale=True): - c = self._safe_block_insertion_cursor() - if autoscale: - img = self._scale_to_viewport(img) + c = self.textCursor() + + # Don’t drop 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() + + # 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.insertBlock() # one blank line after the image @@ -503,8 +465,6 @@ class Editor(QTextEdit): return self._apply_image_size(tc, imgfmt, float(orig.width()), orig) - # ---------------- Context menu ---------------- # - def contextMenuEvent(self, e): menu = self.createStandardContextMenu() tc, imgfmt, orig = self._image_info_at_cursor() @@ -518,8 +478,6 @@ class Editor(QTextEdit): sub.addAction("Reset to original", self._reset_image_size) menu.exec(e.globalPos()) - # ---------------- Clipboard / DnD ---------------- # - def insertFromMimeData(self, source): # 1) Direct image from clipboard if source.hasImage(): @@ -567,7 +525,7 @@ class Editor(QTextEdit): data = base64.b64decode(m.group(1)) img = QImage.fromData(data) if not img.isNull(): - self._insert_qimage_at_cursor(img, autoscale=True) + self._insert_qimage_at_cursor(self, img, autoscale=True) return except Exception: pass # fall through @@ -581,7 +539,19 @@ class Editor(QTextEdit): Insert one or more images at the cursor. Large images can be auto-scaled to fit the viewport width while preserving aspect ratio. """ - c = self._safe_block_insertion_cursor() + c = self.textCursor() + + # 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: reader = QImageReader(path) @@ -589,14 +559,14 @@ class Editor(QTextEdit): if img.isNull(): continue - if autoscale: - img = self._scale_to_viewport(img) + if autoscale and self.viewport(): + max_w = int(self.viewport().width() * 0.92) # ~92% of editor width + if img.width() > max_w: + img = img.scaledToWidth(max_w, Qt.SmoothTransformation) c.insertImage(img) c.insertBlock() # put each image on its own line - # ---------------- Mouse & key handling ---------------- # - def mouseReleaseEvent(self, e): if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): href = self.anchorAt(e.pos()) @@ -667,7 +637,7 @@ class Editor(QTextEdit): # If we're on an empty line inside a code frame, consume Enter and jump out if c.block().length() == 1: - frame = self._nearest_code_frame(c, tolerant=False) + frame = self._find_code_frame(c) if frame: out = QTextCursor(self.document()) out.setPosition(frame.lastPosition()) # after the frame's contents @@ -745,7 +715,7 @@ class Editor(QTextEdit): # ====== Checkbox core ====== def _base_point_size_for_block(self, block) -> float: - # Try the block's char format, then editor font + # Try the block’s char format, then editor font sz = block.charFormat().fontPointSize() if sz <= 0: sz = self.fontPointSize() @@ -901,16 +871,46 @@ class Editor(QTextEdit): if not c.hasSelection(): c.select(QTextCursor.BlockUnderCursor) - ff = self._new_code_frame_format(self._CODE_BG) + # 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) + + mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) c.beginEditBlock() try: c.insertFrame(ff) # with a selection, this wraps the selection - # Format all blocks inside the new frame (keep fg=None on creation) - frame = self._nearest_code_frame(c, tolerant=False) - if frame: - self._retint_code_frame(frame, self._CODE_BG, fg=None) + # 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() diff --git a/bouquin/main_window.py b/bouquin/main_window.py index f3f1ef5..04c0690 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -707,7 +707,7 @@ class MainWindow(QMainWindow): QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() ) r = screen.availableGeometry() - # Center the window in that screen's available area + # Center the window in that screen’s available area self.move(r.center() - self.rect().center()) # ----------------- Export handler ----------------- # @@ -836,7 +836,7 @@ If you want an encrypted backup, choose Backup instead of Export. return if minutes == 0: self._idle_timer.stop() - # If currently locked, unlock when user disables the timer: + # If you’re currently locked, unlock when user disables the timer: if getattr(self, "_locked", False): try: self._locked = False diff --git a/tests/test_editor.py b/tests/test_editor.py index 520e941..f3a9859 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -169,12 +169,13 @@ def test_code_block_enter_exits_on_empty_line(qtbot): QTest.keyClick(e, Qt.Key_Return) # Ensure we are on an empty block *inside* the code frame qtbot.waitUntil( - lambda: e._nearest_code_frame(e.textCursor(), tolerant=False) is not None + lambda: e._find_code_frame(e.textCursor()) is not None and e.textCursor().block().length() == 1 ) # Second Enter should jump *out* of the frame QTest.keyClick(e, Qt.Key_Return) + # qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None) class DummyMenu: @@ -309,7 +310,7 @@ def test_enter_leaves_code_frame(qtbot): ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier) e.keyPressEvent(ev) # After enter, the cursor should not be inside a code frame - assert e._nearest_code_frame(e.textCursor(), tolerant=False) is None + assert e._find_code_frame(e.textCursor()) is None def test_space_does_not_bleed_anchor_format(qtbot):