diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f4a05..439e9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.10.2 + + * Fix for code blocks in dark mode + # 0.1.10.1 * Small bugfix for a debug message left in diff --git a/bouquin/editor.py b/bouquin/editor.py index 5abf9b8..9e7d692 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -68,8 +68,12 @@ class Editor(QTextEdit): self._retint_anchors_to_palette() self._themes = theme_manager + self._apply_code_theme() # set initial code colors # Refresh on theme change self._themes.themeChanged.connect(self._on_theme_changed) + self._themes.themeChanged.connect( + lambda _t: QTimer.singleShot(0, self._apply_code_theme) + ) self._linkifying = False self.textChanged.connect(self._linkify_document) @@ -102,6 +106,129 @@ class Editor(QTextEdit): f = f.parentFrame() return None + def _looks_like_code_frame(self, frame) -> bool: + """ + 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 + + # Background check + bg = ff.background() + if bg.style() != Qt.NoBrush: + c = bg.color() + if c.isValid(): + if ( + abs(c.red() - 245) <= 2 + and abs(c.green() - 245) <= 2 + and abs(c.blue() - 245) <= 2 + ): + return True + if ( + abs(c.red() - 43) <= 2 + and abs(c.green() - 43) <= 2 + and abs(c.blue() - 43) <= 2 + ): + return True + + # Block formatting check (survives toHtml/fromHtml) + doc = self.document() + bc = QTextCursor(doc) + bc.setPosition(frame.firstPosition()) + blocks = codeish = 0 + while bc.position() < frame.lastPosition(): + b = bc.block() + if not b.isValid(): + break + blocks += 1 + bf = b.blockFormat() + if bf.nonBreakableLines(): + codeish += 1 + bc.setPosition(b.position() + b.length()) + return blocks > 0 and (codeish / blocks) >= 0.6 + + def _code_theme_colors(self): + """Return (bg, fg) for code blocks based on the effective palette.""" + pal = QApplication.instance().palette() + # simple luminance check on the window color + win = pal.color(QPalette.Window) + is_dark = win.value() < 128 + if is_dark: + bg = QColor(43, 43, 43) # dark code background + fg = pal.windowText().color() # readable on dark + else: + bg = QColor(245, 245, 245) # light code background + fg = pal.text().color() # readable on light + return bg, fg + + def _apply_code_theme(self): + """Retint all code frames (even those reloaded from HTML) to match the current theme.""" + bg, fg = self._code_theme_colors() + self._CODE_BG = bg # used by future apply_code() calls + + doc = self.document() + cur = QTextCursor(doc) + cur.beginEditBlock() + try: + # 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() + + def _safe_select(self, cur: QTextCursor, start: int, end: int): + """Select [start, end] inclusive without exceeding document bounds.""" + doc_max = max(0, self.document().characterCount() - 1) + s = max(0, min(start, doc_max)) + e = max(0, min(end, doc_max)) + if e < s: + s, e = e, s + cur.setPosition(s) + cur.setPosition(e, QTextCursor.KeepAnchor) + def _trim_url_end(self, url: str) -> str: # strip common trailing punctuation not part of the URL trimmed = url.rstrip(".,;:!?\"'") @@ -855,6 +982,7 @@ class Editor(QTextEdit): def _on_theme_changed(self, _theme: Theme): # Defer one event-loop tick so widgets have the new palette QTimer.singleShot(0, self._retint_anchors_to_palette) + QTimer.singleShot(0, self._apply_code_theme) @Slot() def _retint_anchors_to_palette(self, *_): @@ -874,10 +1002,13 @@ class Editor(QTextEdit): if fmt.isAnchor(): new_fmt = QTextCharFormat(fmt) new_fmt.setForeground(link_brush) # force palette link color - cur.setPosition(frag.position()) - cur.setPosition( - frag.position() + frag.length(), QTextCursor.KeepAnchor - ) + start = frag.position() + cur.setPosition(start) + cur.movePosition( + QTextCursor.NextCharacter, + QTextCursor.KeepAnchor, + frag.length(), + ) # select exactly this fragment cur.setCharFormat(new_fmt) it += 1 block = block.next() @@ -895,3 +1026,4 @@ class Editor(QTextEdit): # Ensure anchors adopt the palette color on startup self._retint_anchors_to_palette() + self._apply_code_theme() diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 05f5af2..5b11381 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -246,7 +246,7 @@ class SettingsDialog(QDialog): ) save_db_config(self._cfg) - self.parent().themes.apply(selected_theme) + self.parent().themes.set(selected_theme) self.accept() def _change_key(self): diff --git a/pyproject.toml b/pyproject.toml index 8bf0770..cfa0c64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.1.10.1" +version = "0.1.10.2" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 906ec2c..b1962c7 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -11,7 +11,7 @@ class _ThemeSpy: def __init__(self): self.calls = [] - def apply(self, t): + def set(self, t): self.calls.append(t) @@ -78,7 +78,7 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path): def __init__(self): self.calls = [] - def apply(self, theme): + def set(self, theme): self.calls.append(theme) class _Parent(QWidget):