Fix for code blocks in dark mode

This commit is contained in:
Miguel Jacq 2025-11-06 18:56:07 +11:00
parent 267e412663
commit b5563c18a4
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 144 additions and 8 deletions

View file

@ -1,3 +1,7 @@
# 0.1.10.2
* Fix for code blocks in dark mode
# 0.1.10.1 # 0.1.10.1
* Small bugfix for a debug message left in * Small bugfix for a debug message left in

View file

@ -68,8 +68,12 @@ class Editor(QTextEdit):
self._retint_anchors_to_palette() self._retint_anchors_to_palette()
self._themes = theme_manager self._themes = theme_manager
self._apply_code_theme() # set initial code colors
# Refresh on theme change # Refresh on theme change
self._themes.themeChanged.connect(self._on_theme_changed) 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._linkifying = False
self.textChanged.connect(self._linkify_document) self.textChanged.connect(self._linkify_document)
@ -102,6 +106,129 @@ class Editor(QTextEdit):
f = f.parentFrame() f = f.parentFrame()
return None 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: def _trim_url_end(self, url: str) -> str:
# strip common trailing punctuation not part of the URL # strip common trailing punctuation not part of the URL
trimmed = url.rstrip(".,;:!?\"'") trimmed = url.rstrip(".,;:!?\"'")
@ -855,6 +982,7 @@ class Editor(QTextEdit):
def _on_theme_changed(self, _theme: Theme): def _on_theme_changed(self, _theme: Theme):
# Defer one event-loop tick so widgets have the new palette # Defer one event-loop tick so widgets have the new palette
QTimer.singleShot(0, self._retint_anchors_to_palette) QTimer.singleShot(0, self._retint_anchors_to_palette)
QTimer.singleShot(0, self._apply_code_theme)
@Slot() @Slot()
def _retint_anchors_to_palette(self, *_): def _retint_anchors_to_palette(self, *_):
@ -874,10 +1002,13 @@ class Editor(QTextEdit):
if fmt.isAnchor(): if fmt.isAnchor():
new_fmt = QTextCharFormat(fmt) new_fmt = QTextCharFormat(fmt)
new_fmt.setForeground(link_brush) # force palette link color new_fmt.setForeground(link_brush) # force palette link color
cur.setPosition(frag.position()) start = frag.position()
cur.setPosition( cur.setPosition(start)
frag.position() + frag.length(), QTextCursor.KeepAnchor cur.movePosition(
) QTextCursor.NextCharacter,
QTextCursor.KeepAnchor,
frag.length(),
) # select exactly this fragment
cur.setCharFormat(new_fmt) cur.setCharFormat(new_fmt)
it += 1 it += 1
block = block.next() block = block.next()
@ -895,3 +1026,4 @@ class Editor(QTextEdit):
# Ensure anchors adopt the palette color on startup # Ensure anchors adopt the palette color on startup
self._retint_anchors_to_palette() self._retint_anchors_to_palette()
self._apply_code_theme()

View file

@ -246,7 +246,7 @@ class SettingsDialog(QDialog):
) )
save_db_config(self._cfg) save_db_config(self._cfg)
self.parent().themes.apply(selected_theme) self.parent().themes.set(selected_theme)
self.accept() self.accept()
def _change_key(self): def _change_key(self):

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" 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." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"

View file

@ -11,7 +11,7 @@ class _ThemeSpy:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
def apply(self, t): def set(self, t):
self.calls.append(t) self.calls.append(t)
@ -78,7 +78,7 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
def apply(self, theme): def set(self, theme):
self.calls.append(theme) self.calls.append(theme)
class _Parent(QWidget): class _Parent(QWidget):