From c3b83b0238ee2c3f5874bf784d1b65c9cd7984ed Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Thu, 6 Nov 2025 10:56:20 +1100
Subject: [PATCH 1/2] Commit working theme changes
---
CHANGELOG.md | 1 +
bouquin/db.py | 8 +-
bouquin/editor.py | 70 +++++++++++----
bouquin/main.py | 12 ++-
bouquin/main_window.py | 180 +++++++++++++++++++++++++++++++------
bouquin/search.py | 4 +-
bouquin/settings.py | 8 +-
bouquin/settings_dialog.py | 39 +++++++-
bouquin/theme.py | 103 +++++++++++++++++++++
9 files changed, 363 insertions(+), 62 deletions(-)
create mode 100644 bouquin/theme.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 425b5f1..0e0763e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
* Fix styling issue with text that comes after a URL, so it doesn't appear as part of the URL.
* Add ability to export to Markdown (and fix heading styles)
* Represent in the History diff pane when an image was the thing that changed
+ * Support theme choice in settings (light/dark/system)
# 0.1.9
diff --git a/bouquin/db.py b/bouquin/db.py
index 68f956d..e8c4903 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -19,6 +19,7 @@ class DBConfig:
path: Path
key: str
idle_minutes: int = 15 # 0 = never lock
+ theme: str = "system"
class DBManager:
@@ -160,13 +161,6 @@ class DBManager:
).fetchone()
return row[0] if row else ""
- def upsert_entry(self, date_iso: str, content: str) -> None:
- """
- Insert or update an entry.
- """
- # Make a new version and set it as current
- self.save_new_version(date_iso, content, note=None, set_current=True)
-
def search_entries(self, text: str) -> list[str]:
"""
Search for entries by term. This only works against the latest
diff --git a/bouquin/editor.py b/bouquin/editor.py
index 296ca34..f68d3c1 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -10,6 +10,7 @@ from PySide6.QtGui import (
QFontDatabase,
QImage,
QImageReader,
+ QPalette,
QPixmap,
QTextCharFormat,
QTextCursor,
@@ -28,8 +29,11 @@ from PySide6.QtCore import (
QBuffer,
QByteArray,
QIODevice,
+ QTimer,
)
-from PySide6.QtWidgets import QTextEdit
+from PySide6.QtWidgets import QTextEdit, QApplication
+
+from .theme import Theme, ThemeManager
class Editor(QTextEdit):
@@ -42,7 +46,7 @@ class Editor(QTextEdit):
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
- def __init__(self, *args, **kwargs):
+ def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
self.setTabStopDistance(tab_w)
@@ -55,7 +59,13 @@ class Editor(QTextEdit):
self.setAcceptRichText(True)
- # Turn raw URLs into anchors
+ # If older docs have a baked-in color, normalize once:
+ self._retint_anchors_to_palette()
+
+ self._themes = theme_manager
+ # Refresh on theme change
+ self._themes.themeChanged.connect(self._on_theme_changed)
+
self._linkifying = False
self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True)
@@ -87,15 +97,6 @@ class Editor(QTextEdit):
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(".,;:!?\"'")
@@ -141,7 +142,7 @@ class Editor(QTextEdit):
fmt.setAnchor(True)
fmt.setAnchorHref(href) # always refresh to the latest full URL
fmt.setFontUnderline(True)
- fmt.setForeground(Qt.blue)
+ fmt.setForeground(self.palette().brush(QPalette.Link))
cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling
@@ -481,11 +482,6 @@ class Editor(QTextEdit):
# otherwise default handling
return super().keyPressEvent(e)
- def _clear_insertion_char_format(self):
- """Reset inline typing format (keeps lists, alignment, margins, etc.)."""
- nf = QTextCharFormat()
- self.setCurrentCharFormat(nf)
-
def _break_anchor_for_next_char(self):
"""
Ensure the *next* typed character is not part of a hyperlink.
@@ -669,3 +665,41 @@ class Editor(QTextEdit):
fmt = QTextListFormat()
fmt.setStyle(QTextListFormat.Style.ListDecimal)
c.createList(fmt)
+
+ @Slot(Theme)
+ 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)
+
+ @Slot()
+ def _retint_anchors_to_palette(self, *_):
+ # Always read from the *application* palette to avoid stale widget palette
+ app = QApplication.instance()
+ link_brush = app.palette().brush(QPalette.Link)
+ doc = self.document()
+ cur = QTextCursor(doc)
+ cur.beginEditBlock()
+ block = doc.firstBlock()
+ while block.isValid():
+ it = block.begin()
+ while not it.atEnd():
+ frag = it.fragment()
+ if frag.isValid():
+ fmt = frag.charFormat()
+ 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
+ )
+ cur.setCharFormat(new_fmt)
+ it += 1
+ block = block.next()
+ cur.endEditBlock()
+ self.viewport().update()
+
+ def setHtml(self, html: str) -> None:
+ super().setHtml(html)
+ # Ensure anchors adopt the palette color on startup
+ self._retint_anchors_to_palette()
diff --git a/bouquin/main.py b/bouquin/main.py
index 3e5f90b..a481480 100644
--- a/bouquin/main.py
+++ b/bouquin/main.py
@@ -3,14 +3,22 @@ from __future__ import annotations
import sys
from PySide6.QtWidgets import QApplication
-from .settings import APP_NAME, APP_ORG
+from .settings import APP_NAME, APP_ORG, get_settings
from .main_window import MainWindow
+from .theme import Theme, ThemeConfig, ThemeManager
def main():
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG)
- win = MainWindow()
+
+ s = get_settings()
+ theme_str = s.value("ui/theme", "system")
+ cfg = ThemeConfig(theme=Theme(theme_str))
+ themes = ThemeManager(app, cfg)
+ themes.apply(cfg.theme)
+
+ win = MainWindow(themes=themes)
win.show()
sys.exit(app.exec())
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index cc01f6d..5f8f5fd 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -23,9 +23,12 @@ from PySide6.QtGui import (
QDesktopServices,
QFont,
QGuiApplication,
+ QPalette,
+ QTextCharFormat,
QTextListFormat,
)
from PySide6.QtWidgets import (
+ QApplication,
QCalendarWidget,
QDialog,
QFileDialog,
@@ -48,6 +51,7 @@ from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .toolbar import ToolBar
+from .theme import Theme, ThemeManager
class _LockOverlay(QWidget):
@@ -58,23 +62,6 @@ class _LockOverlay(QWidget):
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
- self.setStyleSheet(
- """
-#LockOverlay { background-color: #ccc; }
-#LockOverlay QLabel { color: #fff; font-size: 18px; }
-#LockOverlay QPushButton {
- background-color: #f2f2f2;
- color: #000;
- padding: 6px 14px;
- border: 1px solid #808080;
- border-radius: 6px;
- font-size: 14px;
-}
-#LockOverlay QPushButton:hover { background-color: #ffffff; }
-#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
-"""
- )
-
lay = QVBoxLayout(self)
lay.addStretch(1)
@@ -92,8 +79,42 @@ class _LockOverlay(QWidget):
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
+ self._apply_overlay_style()
+
self.hide() # start hidden
+ def _apply_overlay_style(self):
+ pal = self.palette()
+ bg = (
+ pal.window().color().darker(180)
+ if pal.color(QPalette.Window).value() < 128
+ else pal.window().color().lighter(110)
+ )
+ text = pal.windowText().color()
+ btn_bg = pal.button().color()
+ btn_fg = pal.buttonText().color()
+ border = pal.mid().color()
+
+ hover_bg = btn_bg.lighter(106) # +6%
+ press_bg = btn_bg.darker(106) # -6%
+
+ self.setStyleSheet(
+ f"""
+ #LockOverlay {{ background-color: {bg.name()}; }}
+ #LockOverlay QLabel {{ color: {text.name()}; font-size: 18px; }}
+ #LockOverlay QPushButton {{
+ background-color: {btn_bg.name()};
+ color: {btn_fg.name()};
+ padding: 6px 14px;
+ border: 1px solid {border.name()};
+ border-radius: 6px;
+ font-size: 14px;
+ }}
+ #LockOverlay QPushButton:hover {{ background-color: {hover_bg.name()}; }}
+ #LockOverlay QPushButton:pressed {{ background-color: {press_bg.name()}; }}
+ """
+ )
+
# keep overlay sized with its parent
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
@@ -106,11 +127,13 @@ class _LockOverlay(QWidget):
class MainWindow(QMainWindow):
- def __init__(self, *args, **kwargs):
+ def __init__(self, themes: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setWindowTitle(APP_NAME)
self.setMinimumSize(1000, 650)
+ self.themes = themes # Store the themes manager
+
self.cfg = load_db_config()
if not os.path.exists(self.cfg.path):
# Fresh database/first time use, so guide the user re: setting a key
@@ -145,7 +168,7 @@ class MainWindow(QMainWindow):
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# This is the note-taking editor
- self.editor = Editor()
+ self.editor = Editor(self.themes)
# Toolbar for controlling styling
self.toolBar = ToolBar()
@@ -185,6 +208,7 @@ class MainWindow(QMainWindow):
# full-window overlay that sits on top of the central widget
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
+ self._lock_overlay._apply_overlay_style()
self.centralWidget().installEventFilter(self._lock_overlay)
self._locked = False
@@ -280,6 +304,16 @@ class MainWindow(QMainWindow):
self.settings = QSettings(APP_ORG, APP_NAME)
self._restore_window_position()
+ self._apply_link_css() # Apply link color on startup
+ # re-apply all runtime color tweaks when theme changes
+ self.themes.themeChanged.connect(lambda _t: self._retheme_overrides())
+ self.themes.themeChanged.connect(self._apply_calendar_theme)
+ self._apply_calendar_text_colors()
+ self._apply_calendar_theme(self.themes.current())
+
+ # apply once on startup so links / calendar colors are set immediately
+ self._retheme_overrides()
+
def _try_connect(self) -> bool:
"""
Try to connect to the database.
@@ -314,6 +348,86 @@ class MainWindow(QMainWindow):
if self._try_connect():
return True
+ def _retheme_overrides(self):
+ if hasattr(self, "_lock_overlay"):
+ self._lock_overlay._apply_overlay_style()
+ self._apply_calendar_text_colors()
+ self._apply_link_css() # Reapply link styles based on the current theme
+ self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
+ self.calendar.update()
+ self.editor.viewport().update()
+
+ def _apply_link_css(self):
+ if self.themes and self.themes.current() == Theme.DARK:
+ anchor = "#FFA500" # Orange links
+ visited = "#B38000" # Visited links color
+ css = f"""
+ a {{ color: {anchor}; text-decoration: underline; }}
+ a:visited {{ color: {visited}; }}
+ """
+ else:
+ css = "" # Default to no custom styling for links (system or light theme)
+
+ try:
+ # Apply to the editor (QTextEdit or any other relevant widgets)
+ self.editor.document().setDefaultStyleSheet(css)
+ except Exception:
+ pass
+
+ try:
+ # Apply to the search widget (if it's also a rich-text widget)
+ self.search.document().setDefaultStyleSheet(css)
+ except Exception:
+ pass
+
+ def _apply_calendar_theme(self, theme: Theme):
+ """Use orange accents on the calendar in dark mode only."""
+ app_pal = QApplication.instance().palette()
+
+ if theme == Theme.DARK:
+ orange = QColor("#FFA500")
+ black = QColor(0, 0, 0)
+
+ # Per-widget palette: selection color inside the date grid
+ pal = self.calendar.palette()
+ pal.setColor(QPalette.Highlight, orange)
+ pal.setColor(QPalette.HighlightedText, black)
+ self.calendar.setPalette(pal)
+
+ # Stylesheet: nav bar + selected-day background
+ self.calendar.setStyleSheet("""
+ QWidget#qt_calendar_navigationbar { background-color: #FFA500; }
+ QCalendarWidget QToolButton { color: black; }
+ QCalendarWidget QToolButton:hover { background-color: rgba(255,165,0,0.20); }
+ /* Selected day color in the table view */
+ QCalendarWidget QTableView:enabled {
+ selection-background-color: #FFA500;
+ selection-color: black;
+ }
+ /* Optional: keep weekday header readable */
+ QCalendarWidget QTableView QHeaderView::section {
+ background: transparent;
+ color: palette(windowText);
+ }
+ """)
+ else:
+ # Back to app defaults in light/system
+ self.calendar.setPalette(app_pal)
+ self.calendar.setStyleSheet("")
+
+ # Keep weekend text color in sync with the current palette
+ self._apply_calendar_text_colors()
+ self.calendar.update()
+
+ def _apply_calendar_text_colors(self):
+ pal = self.palette()
+ txt = pal.windowText().color()
+ fmt = QTextCharFormat()
+ fmt.setForeground(txt)
+ # Use normal text color for weekends
+ self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
+ self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
+
def _on_search_dates_changed(self, date_strs: list[str]):
dates = set()
for ds in date_strs or []:
@@ -323,7 +437,16 @@ class MainWindow(QMainWindow):
self._apply_search_highlights(dates)
def _apply_search_highlights(self, dates: set):
- yellow = QBrush(QColor("#fff9c4"))
+ pal = self.palette()
+ base = pal.base().color()
+ hi = pal.highlight().color()
+ # Blend highlight with base so it looks soft in both modes
+ blend = QColor(
+ (2 * hi.red() + base.red()) // 3,
+ (2 * hi.green() + base.green()) // 3,
+ (2 * hi.blue() + base.blue()) // 3,
+ )
+ yellow = QBrush(blend)
old = getattr(self, "_search_highlighted_dates", set())
for d in old - dates: # clear removed
@@ -364,10 +487,10 @@ class MainWindow(QMainWindow):
bf = c.blockFormat()
# Block signals so setChecked() doesn't re-trigger actions
- blocker1 = QSignalBlocker(self.toolBar.actBold)
- blocker2 = QSignalBlocker(self.toolBar.actItalic)
- blocker3 = QSignalBlocker(self.toolBar.actUnderline)
- blocker4 = QSignalBlocker(self.toolBar.actStrike)
+ QSignalBlocker(self.toolBar.actBold)
+ QSignalBlocker(self.toolBar.actItalic)
+ QSignalBlocker(self.toolBar.actUnderline)
+ QSignalBlocker(self.toolBar.actStrike)
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
self.toolBar.actItalic.setChecked(fmt.fontItalic())
@@ -384,10 +507,10 @@ class MainWindow(QMainWindow):
bH2 = _approx(cur_size, 18)
bH3 = _approx(cur_size, 14)
- b1 = QSignalBlocker(self.toolBar.actH1)
- b2 = QSignalBlocker(self.toolBar.actH2)
- b3 = QSignalBlocker(self.toolBar.actH3)
- bN = QSignalBlocker(self.toolBar.actNormal)
+ QSignalBlocker(self.toolBar.actH1)
+ QSignalBlocker(self.toolBar.actH2)
+ QSignalBlocker(self.toolBar.actH3)
+ QSignalBlocker(self.toolBar.actNormal)
self.toolBar.actH1.setChecked(bH1)
self.toolBar.actH2.setChecked(bH2)
@@ -538,6 +661,7 @@ class MainWindow(QMainWindow):
self.cfg.path = new_cfg.path
self.cfg.key = new_cfg.key
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
+ self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
# Persist once
save_db_config(self.cfg)
diff --git a/bouquin/search.py b/bouquin/search.py
index 27c7e17..2805e4c 100644
--- a/bouquin/search.py
+++ b/bouquin/search.py
@@ -17,7 +17,6 @@ from PySide6.QtWidgets import (
QWidget,
)
-# type: rows are (date_iso, content)
Row = Tuple[str, str]
@@ -102,11 +101,10 @@ class Search(QWidget):
# Date label (plain text)
date_lbl = QLabel()
date_lbl.setTextFormat(Qt.TextFormat.RichText)
- date_lbl.setText(f"{date_str}:")
+ date_lbl.setText(f"{date_str}
")
date_f = date_lbl.font()
date_f.setPointSizeF(date_f.pointSizeF() + 1)
date_lbl.setFont(date_f)
- date_lbl.setStyleSheet("color:#000;")
outer.addWidget(date_lbl)
# Preview row with optional ellipses
diff --git a/bouquin/settings.py b/bouquin/settings.py
index fc92394..8860ed2 100644
--- a/bouquin/settings.py
+++ b/bouquin/settings.py
@@ -22,12 +22,14 @@ def load_db_config() -> DBConfig:
s = get_settings()
path = Path(s.value("db/path", str(default_db_path())))
key = s.value("db/key", "")
- idle = s.value("db/idle_minutes", 15, type=int)
- return DBConfig(path=path, key=key, idle_minutes=idle)
+ idle = s.value("ui/idle_minutes", 15, type=int)
+ theme = s.value("ui/theme", "system", type=str)
+ return DBConfig(path=path, key=key, idle_minutes=idle, theme=theme)
def save_db_config(cfg: DBConfig) -> None:
s = get_settings()
s.setValue("db/path", str(cfg.path))
s.setValue("db/key", str(cfg.key))
- s.setValue("db/idle_minutes", str(cfg.idle_minutes))
+ s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
+ s.setValue("ui/theme", str(cfg.theme))
diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py
index 0a3dfd8..48acfe6 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QPushButton,
QFileDialog,
QDialogButtonBox,
+ QRadioButton,
QSizePolicy,
QSpinBox,
QMessageBox,
@@ -26,6 +27,7 @@ from PySide6.QtGui import QPalette
from .db import DBConfig, DBManager
from .settings import load_db_config, save_db_config
+from .theme import Theme
from .key_prompt import KeyPrompt
@@ -42,6 +44,31 @@ class SettingsDialog(QDialog):
self.setMinimumWidth(560)
self.setSizeGripEnabled(True)
+ current_settings = load_db_config()
+
+ # Add theme selection
+ theme_group = QGroupBox("Theme")
+ theme_layout = QVBoxLayout(theme_group)
+
+ self.theme_system = QRadioButton("System")
+ self.theme_light = QRadioButton("Light")
+ self.theme_dark = QRadioButton("Dark")
+
+ # Load current theme from settings
+ current_theme = current_settings.theme
+ if current_theme == Theme.DARK.value:
+ self.theme_dark.setChecked(True)
+ elif current_theme == Theme.LIGHT.value:
+ self.theme_light.setChecked(True)
+ else:
+ self.theme_system.setChecked(True)
+
+ theme_layout.addWidget(self.theme_system)
+ theme_layout.addWidget(self.theme_light)
+ theme_layout.addWidget(self.theme_dark)
+
+ form.addRow(theme_group)
+
self.path_edit = QLineEdit(str(self._cfg.path))
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
browse_btn = QPushButton("Browse…")
@@ -64,7 +91,6 @@ class SettingsDialog(QDialog):
# Checkbox to remember key
self.save_key_btn = QCheckBox("Remember key")
- current_settings = load_db_config()
self.key = current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor)
@@ -188,13 +214,24 @@ class SettingsDialog(QDialog):
self.path_edit.setText(p)
def _save(self):
+ # Save the selected theme into QSettings
+ if self.theme_dark.isChecked():
+ selected_theme = Theme.DARK
+ elif self.theme_light.isChecked():
+ selected_theme = Theme.LIGHT
+ else:
+ selected_theme = Theme.SYSTEM
+
key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig(
path=Path(self.path_edit.text()),
key=key_to_save,
idle_minutes=self.idle_spin.value(),
+ theme=selected_theme.value,
)
+
save_db_config(self._cfg)
+ self.parent().themes.apply(selected_theme)
self.accept()
def _change_key(self):
diff --git a/bouquin/theme.py b/bouquin/theme.py
new file mode 100644
index 0000000..61f9458
--- /dev/null
+++ b/bouquin/theme.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+from dataclasses import dataclass
+from enum import Enum
+from PySide6.QtGui import QPalette, QColor, QGuiApplication
+from PySide6.QtWidgets import QApplication
+from PySide6.QtCore import QObject, Signal
+
+
+class Theme(Enum):
+ SYSTEM = "system"
+ LIGHT = "light"
+ DARK = "dark"
+
+
+@dataclass
+class ThemeConfig:
+ theme: Theme = Theme.SYSTEM
+
+
+class ThemeManager(QObject):
+ themeChanged = Signal(Theme)
+
+ def __init__(self, app: QApplication, cfg: ThemeConfig):
+ super().__init__()
+ self._app = app
+ self._cfg = cfg
+
+ # Follow OS if supported (Qt 6+)
+ hints = QGuiApplication.styleHints()
+ if hasattr(hints, "colorSchemeChanged"):
+ hints.colorSchemeChanged.connect(
+ lambda _: (self._cfg.theme == Theme.SYSTEM)
+ and self.apply(self._cfg.theme)
+ )
+
+ def current(self) -> Theme:
+ return self._cfg.theme
+
+ def set(self, theme: Theme):
+ self._cfg.theme = theme
+ self.apply(theme)
+
+ def apply(self, theme: Theme):
+ # Resolve "system"
+ if theme == Theme.SYSTEM:
+ hints = QGuiApplication.styleHints()
+ scheme = getattr(hints, "colorScheme", None)
+ if callable(scheme):
+ scheme = hints.colorScheme()
+ # 0=Light, 1=Dark in newer Qt; fall back to Light
+ theme = Theme.DARK if scheme == 1 else Theme.LIGHT
+
+ # Always use Fusion so palette applies consistently cross-platform
+ self._app.setStyle("Fusion")
+
+ if theme == Theme.DARK:
+ pal = self._dark_palette()
+ self._app.setPalette(pal)
+ # keep stylesheet empty unless you need widget-specific tweaks
+ self._app.setStyleSheet("")
+ else:
+ pal = self._light_palette()
+ self._app.setPalette(pal)
+ self._app.setStyleSheet("")
+
+ self.themeChanged.emit(theme)
+
+ # ----- Palettes -----
+ def _dark_palette(self) -> QPalette:
+ pal = QPalette()
+ base = QColor(35, 35, 35)
+ window = QColor(53, 53, 53)
+ text = QColor(220, 220, 220)
+ disabled = QColor(127, 127, 127)
+ focus = QColor(42, 130, 218)
+
+ pal.setColor(QPalette.Window, window)
+ pal.setColor(QPalette.WindowText, text)
+ pal.setColor(QPalette.Base, base)
+ pal.setColor(QPalette.AlternateBase, window)
+ pal.setColor(QPalette.ToolTipBase, window)
+ pal.setColor(QPalette.ToolTipText, text)
+ pal.setColor(QPalette.Text, text)
+ pal.setColor(QPalette.PlaceholderText, disabled)
+ pal.setColor(QPalette.Button, window)
+ pal.setColor(QPalette.ButtonText, text)
+ pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
+ pal.setColor(QPalette.Highlight, focus)
+ pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
+ pal.setColor(QPalette.Link, QColor("#FFA500"))
+ pal.setColor(QPalette.LinkVisited, QColor("#B38000"))
+
+ return pal
+
+ def _light_palette(self) -> QPalette:
+ # Let Qt provide its default light palette, but nudge a couple roles
+ pal = self._app.style().standardPalette()
+ pal.setColor(QPalette.Highlight, QColor(0, 120, 215))
+ pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
+ pal.setColor(
+ QPalette.Link, QColor("#1a73e8")
+ ) # Light blue for links in light mode
+ return pal
From 7c3ec1974829dc92df23f1c42c4ea8f6b3407c69 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Thu, 6 Nov 2025 11:47:00 +1100
Subject: [PATCH 2/2] Various tweaks to theme, more code coverage
---
bouquin/__init__.py | 1 -
bouquin/main_window.py | 34 ++---
bouquin/settings_dialog.py | 3 +-
bouquin/theme.py | 6 +-
tests/conftest.py | 29 ++++-
tests/qt_helpers.py | 2 +-
tests/test_db_unit.py | 137 ++++++++++++++++++++
tests/test_editor.py | 207 ++++++++++++++++++++++++++----
tests/test_entrypoints.py | 69 ++++++++++
tests/test_history_dialog_unit.py | 66 ++++++++++
tests/test_misc.py | 113 ++++++++++++++++
tests/test_search_unit.py | 57 ++++++++
tests/test_settings_dialog.py | 48 ++++++-
tests/test_settings_module.py | 28 ++++
tests/test_theme_integration.py | 19 +++
tests/test_theme_manager.py | 19 +++
tests/test_toolbar_private.py | 23 ++++
17 files changed, 812 insertions(+), 49 deletions(-)
create mode 100644 tests/test_db_unit.py
create mode 100644 tests/test_entrypoints.py
create mode 100644 tests/test_history_dialog_unit.py
create mode 100644 tests/test_misc.py
create mode 100644 tests/test_search_unit.py
create mode 100644 tests/test_settings_module.py
create mode 100644 tests/test_theme_integration.py
create mode 100644 tests/test_theme_manager.py
create mode 100644 tests/test_toolbar_private.py
diff --git a/bouquin/__init__.py b/bouquin/__init__.py
index c28a133..e69de29 100644
--- a/bouquin/__init__.py
+++ b/bouquin/__init__.py
@@ -1 +0,0 @@
-from .main import main
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 5f8f5fd..7b29bbc 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -359,8 +359,8 @@ class MainWindow(QMainWindow):
def _apply_link_css(self):
if self.themes and self.themes.current() == Theme.DARK:
- anchor = "#FFA500" # Orange links
- visited = "#B38000" # Visited links color
+ anchor = Theme.ORANGE_ANCHOR.value
+ visited = Theme.ORANGE_ANCHOR_VISITED.value
css = f"""
a {{ color: {anchor}; text-decoration: underline; }}
a:visited {{ color: {visited}; }}
@@ -385,31 +385,35 @@ class MainWindow(QMainWindow):
app_pal = QApplication.instance().palette()
if theme == Theme.DARK:
- orange = QColor("#FFA500")
- black = QColor(0, 0, 0)
+ highlight = QColor(Theme.ORANGE_ANCHOR.value)
+ black = QColor(0, 0, 0)
+
+ highlight_css = Theme.ORANGE_ANCHOR.value
# Per-widget palette: selection color inside the date grid
pal = self.calendar.palette()
- pal.setColor(QPalette.Highlight, orange)
+ pal.setColor(QPalette.Highlight, highlight)
pal.setColor(QPalette.HighlightedText, black)
self.calendar.setPalette(pal)
# Stylesheet: nav bar + selected-day background
- self.calendar.setStyleSheet("""
- QWidget#qt_calendar_navigationbar { background-color: #FFA500; }
- QCalendarWidget QToolButton { color: black; }
- QCalendarWidget QToolButton:hover { background-color: rgba(255,165,0,0.20); }
+ self.calendar.setStyleSheet(
+ f"""
+ QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
+ QCalendarWidget QToolButton {{ color: black; }}
+ QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }}
/* Selected day color in the table view */
- QCalendarWidget QTableView:enabled {
- selection-background-color: #FFA500;
+ QCalendarWidget QTableView:enabled {{
+ selection-background-color: {highlight_css};
selection-color: black;
- }
+ }}
/* Optional: keep weekday header readable */
- QCalendarWidget QTableView QHeaderView::section {
+ QCalendarWidget QTableView QHeaderView::section {{
background: transparent;
color: palette(windowText);
- }
- """)
+ }}
+ """
+ )
else:
# Back to app defaults in light/system
self.calendar.setPalette(app_pal)
diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py
index 48acfe6..ac36337 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -259,6 +259,7 @@ class SettingsDialog(QDialog):
@Slot(bool)
def _save_key_btn_clicked(self, checked: bool):
+ self.key = ""
if checked:
if not self.key:
p1 = KeyPrompt(
@@ -270,8 +271,6 @@ class SettingsDialog(QDialog):
self.save_key_btn.blockSignals(False)
return
self.key = p1.key() or ""
- else:
- self.key = ""
@Slot(bool)
def _compact_btn_clicked(self):
diff --git a/bouquin/theme.py b/bouquin/theme.py
index 61f9458..341466e 100644
--- a/bouquin/theme.py
+++ b/bouquin/theme.py
@@ -10,6 +10,8 @@ class Theme(Enum):
SYSTEM = "system"
LIGHT = "light"
DARK = "dark"
+ ORANGE_ANCHOR = "#FFA500"
+ ORANGE_ANCHOR_VISITED = "#B38000"
@dataclass
@@ -87,8 +89,8 @@ class ThemeManager(QObject):
pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
pal.setColor(QPalette.Highlight, focus)
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
- pal.setColor(QPalette.Link, QColor("#FFA500"))
- pal.setColor(QPalette.LinkVisited, QColor("#B38000"))
+ pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
+ pal.setColor(QPalette.LinkVisited, QColor(Theme.ORANGE_ANCHOR_VISITED.value))
return pal
diff --git a/tests/conftest.py b/tests/conftest.py
index 1900f40..8d885e6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -12,6 +12,9 @@ os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0")
# Make project importable
+from PySide6.QtWidgets import QApplication, QWidget
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
@@ -59,7 +62,10 @@ def open_window(qtbot, temp_home, clean_settings):
"""Launch the app and immediately satisfy first-run/unlock key prompts."""
from bouquin.main_window import MainWindow
- win = MainWindow()
+ app = QApplication.instance()
+ themes = ThemeManager(app, ThemeConfig())
+ themes.apply(Theme.SYSTEM)
+ win = MainWindow(themes=themes)
qtbot.addWidget(win)
win.show()
qtbot.waitExposed(win)
@@ -75,3 +81,24 @@ def today_iso():
d = date.today()
return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
+
+
+@pytest.fixture
+def theme_parent_widget(qtbot):
+ """A minimal parent that provides .themes.apply(...) like MainWindow."""
+
+ class _ThemesStub:
+ def __init__(self):
+ self.applied = []
+
+ def apply(self, theme):
+ self.applied.append(theme)
+
+ class _Parent(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.themes = _ThemesStub()
+
+ parent = _Parent()
+ qtbot.addWidget(parent)
+ return parent
diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py
index 1b9b9a3..f228177 100644
--- a/tests/qt_helpers.py
+++ b/tests/qt_helpers.py
@@ -166,7 +166,7 @@ class AutoResponder:
continue
wid = id(w)
- # Handle first-run / unlock / save-name prompts (your existing branches)
+ # Handle first-run / unlock / save-name prompts
if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w):
fill_first_line_edit_and_accept(w, "ci-secret-key")
self._seen.add(wid)
diff --git a/tests/test_db_unit.py b/tests/test_db_unit.py
new file mode 100644
index 0000000..d369abf
--- /dev/null
+++ b/tests/test_db_unit.py
@@ -0,0 +1,137 @@
+import bouquin.db as dbmod
+from bouquin.db import DBConfig, DBManager
+
+
+class FakeCursor:
+ def __init__(self, rows=None):
+ self._rows = rows or []
+ self.executed = []
+
+ def execute(self, sql, params=None):
+ self.executed.append((sql, tuple(params) if params else None))
+ return self
+
+ def fetchall(self):
+ return list(self._rows)
+
+ def fetchone(self):
+ return self._rows[0] if self._rows else None
+
+
+class FakeConn:
+ def __init__(self, rows=None):
+ self._rows = rows or []
+ self.closed = False
+ self.cursors = []
+ self.row_factory = None
+
+ def cursor(self):
+ c = FakeCursor(rows=self._rows)
+ self.cursors.append(c)
+ return c
+
+ def close(self):
+ self.closed = True
+
+ def commit(self):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ pass
+
+
+def test_integrity_ok_ok(monkeypatch, tmp_path):
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
+ mgr.conn = FakeConn(rows=[])
+ assert mgr._integrity_ok() is None
+
+
+def test_integrity_ok_raises(monkeypatch, tmp_path):
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
+ mgr.conn = FakeConn(rows=[("oops",), (None,)])
+ try:
+ mgr._integrity_ok()
+ except Exception as e:
+ assert isinstance(e, dbmod.sqlite.IntegrityError)
+
+
+def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path):
+ # Use a non-empty key to avoid SQLCipher complaining before our patch runs
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
+ # Make the integrity check raise so connect() takes the failure path
+ monkeypatch.setattr(
+ DBManager,
+ "_integrity_ok",
+ lambda self: (_ for _ in ()).throw(RuntimeError("bad")),
+ )
+ ok = mgr.connect()
+ assert ok is False
+ assert mgr.conn is None
+
+
+def test_rekey_not_connected_raises(tmp_path):
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
+ mgr.conn = None
+ import pytest
+
+ with pytest.raises(RuntimeError):
+ mgr.rekey("new")
+
+
+def test_rekey_reopen_failure(monkeypatch, tmp_path):
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
+ mgr.conn = FakeConn(rows=[(None,)])
+ monkeypatch.setattr(DBManager, "connect", lambda self: False)
+ import pytest
+
+ with pytest.raises(Exception):
+ mgr.rekey("new")
+
+
+def test_export_by_extension_and_unknown(tmp_path):
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
+ entries = [("2025-01-01", "Hi")]
+ # Test each exporter writes the file
+ p = tmp_path / "out.json"
+ mgr.export_json(entries, str(p))
+ assert p.exists() and p.stat().st_size > 0
+ p = tmp_path / "out.csv"
+ mgr.export_csv(entries, str(p))
+ assert p.exists()
+ p = tmp_path / "out.txt"
+ mgr.export_txt(entries, str(p))
+ assert p.exists()
+ p = tmp_path / "out.html"
+ mgr.export_html(entries, str(p))
+ assert p.exists()
+ p = tmp_path / "out.md"
+ mgr.export_markdown(entries, str(p))
+ assert p.exists()
+ # Router
+ import types
+
+ mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
+ for ext in [".json", ".csv", ".txt", ".html"]:
+ path = tmp_path / f"route{ext}"
+ mgr.export_by_extension(str(path))
+ assert path.exists()
+ import pytest
+
+ with pytest.raises(ValueError):
+ mgr.export_by_extension(str(tmp_path / "x.zzz"))
+
+
+def test_compact_error_prints(monkeypatch, tmp_path, capsys):
+ mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
+
+ class BadConn:
+ def cursor(self):
+ raise RuntimeError("no")
+
+ mgr.conn = BadConn()
+ mgr.compact()
+ out = capsys.readouterr().out
+ assert "Error:" in out
diff --git a/tests/test_editor.py b/tests/test_editor.py
index 6935143..f3a9859 100644
--- a/tests/test_editor.py
+++ b/tests/test_editor.py
@@ -1,12 +1,21 @@
-from PySide6.QtCore import Qt
-from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat
+from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl
+from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat
from PySide6.QtTest import QTest
+from PySide6.QtWidgets import QApplication
from bouquin.editor import Editor
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
import re
+def _mk_editor() -> Editor:
+ # pytest-qt ensures a QApplication exists
+ app = QApplication.instance()
+ tm = ThemeManager(app, ThemeConfig())
+ return Editor(tm)
+
+
def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
@@ -31,7 +40,7 @@ def _fmt_at(editor: Editor, pos: int):
def test_space_breaks_link_anchor_and_styling(qtbot):
- e = Editor()
+ e = _mk_editor()
e.resize(600, 300)
e.show()
qtbot.waitExposed(e)
@@ -75,7 +84,7 @@ def test_space_breaks_link_anchor_and_styling(qtbot):
def test_embed_qimage_saved_as_data_url(qtbot):
- e = Editor()
+ e = _mk_editor()
e.resize(600, 400)
qtbot.addWidget(e)
e.show()
@@ -96,7 +105,7 @@ def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
big_path = tmp_path / "big.png"
big.save(str(big_path))
- e = Editor()
+ e = _mk_editor()
e.resize(420, 300) # known viewport width
qtbot.addWidget(e)
e.show()
@@ -120,7 +129,7 @@ def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
def test_linkify_trims_trailing_punctuation(qtbot):
- e = Editor()
+ e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
@@ -135,31 +144,13 @@ def test_linkify_trims_trailing_punctuation(qtbot):
assert 'href="https://example.com)."' not in html
-def test_space_does_not_bleed_anchor_format(qtbot):
- e = Editor()
- qtbot.addWidget(e)
- e.show()
- qtbot.waitExposed(e)
-
- e.setPlainText("https://a.example")
- qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
-
- c = e.textCursor()
- c.movePosition(QTextCursor.End)
- e.setTextCursor(c)
-
- # Press Space; keyPressEvent should break the anchor for the next char
- QTest.keyClick(e, Qt.Key_Space)
- assert e.currentCharFormat().isAnchor() is False
-
-
def test_code_block_enter_exits_on_empty_line(qtbot):
from PySide6.QtCore import Qt
from PySide6.QtGui import QTextCursor
from PySide6.QtTest import QTest
from bouquin.editor import Editor
- e = Editor()
+ e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
@@ -185,3 +176,169 @@ def test_code_block_enter_exits_on_empty_line(qtbot):
# 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:
+ def __init__(self):
+ self.seps = 0
+ self.subs = []
+ self.exec_called = False
+
+ def addSeparator(self):
+ self.seps += 1
+
+ def addMenu(self, title):
+ m = DummyMenu()
+ self.subs.append((title, m))
+ return m
+
+ def addAction(self, *a, **k):
+ pass
+
+ def exec(self, *a, **k):
+ self.exec_called = True
+
+
+def _themes():
+ app = QApplication.instance()
+ return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+
+
+def test_context_menu_adds_image_actions(monkeypatch, qtbot):
+ e = Editor(_themes())
+ qtbot.addWidget(e)
+ # Fake an image at cursor
+ qi = QImage(10, 10, QImage.Format_ARGB32)
+ qi.fill(0xFF00FF00)
+ imgfmt = QTextImageFormat()
+ imgfmt.setName("x")
+ imgfmt.setWidth(10)
+ imgfmt.setHeight(10)
+ tc = e.textCursor()
+ monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi))
+
+ dummy = DummyMenu()
+ monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy)
+
+ class Evt:
+ def globalPos(self):
+ return QPoint(0, 0)
+
+ e.contextMenuEvent(Evt())
+ assert dummy.exec_called
+ assert dummy.seps == 1
+ assert any(t == "Image size" for t, _ in dummy.subs)
+
+
+def test_insert_from_mime_image_and_urls(tmp_path, qtbot):
+ e = Editor(_themes())
+ qtbot.addWidget(e)
+ # Build a mime with an image
+ mime = QMimeData()
+ img = QImage(6, 6, QImage.Format_ARGB32)
+ img.fill(0xFF0000FF)
+ mime.setImageData(img)
+ e.insertFromMimeData(mime)
+ html = e.document().toHtml()
+ assert "
a
",
+ },
+ {
+ "id": 2,
+ "version_no": 2,
+ "created_at": "2025-01-02T10:00:00Z",
+ "note": None,
+ "is_current": True,
+ "content": "b
",
+ },
+ ]
+
+ def get_version(self, version_id):
+ if version_id == 2:
+ return {"content": "b
"}
+ return {"content": "a
"}
+
+ def revert_to_version(self, date, version_id=None, version_no=None):
+ if self.fail_revert:
+ raise RuntimeError("boom")
+
+
+def test_on_select_no_item(qtbot):
+ dlg = HistoryDialog(FakeDB(), "2025-01-01")
+ qtbot.addWidget(dlg)
+ dlg.list.clear()
+ dlg._on_select()
+
+
+def test_revert_failure_shows_critical(qtbot, monkeypatch):
+ from PySide6.QtWidgets import QMessageBox
+
+ fake = FakeDB()
+ fake.fail_revert = True
+ dlg = HistoryDialog(fake, "2025-01-01")
+ qtbot.addWidget(dlg)
+ item = QListWidgetItem("v1")
+ item.setData(Qt.UserRole, 1) # different from current 2
+ dlg.list.addItem(item)
+ dlg.list.setCurrentItem(item)
+ msgs = {}
+
+ def fake_crit(parent, title, text):
+ msgs["t"] = (title, text)
+
+ monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit))
+ dlg._revert()
+ assert "Revert failed" in msgs["t"][0]
diff --git a/tests/test_misc.py b/tests/test_misc.py
new file mode 100644
index 0000000..20a3b1c
--- /dev/null
+++ b/tests/test_misc.py
@@ -0,0 +1,113 @@
+from PySide6.QtWidgets import QApplication, QMessageBox
+from bouquin.main_window import MainWindow
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
+from bouquin.db import DBConfig
+
+
+def _themes_light():
+ app = QApplication.instance()
+ return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+
+
+def _themes_dark():
+ app = QApplication.instance()
+ return ThemeManager(app, ThemeConfig(theme=Theme.DARK))
+
+
+class FakeDBErr:
+ def __init__(self, cfg):
+ pass
+
+ def connect(self):
+ raise Exception("file is not a database")
+
+
+class FakeDBOk:
+ def __init__(self, cfg):
+ pass
+
+ def connect(self):
+ return True
+
+ def save_new_version(self, date, text, note):
+ raise RuntimeError("nope")
+
+ def get_entry(self, date):
+ return "hi
"
+
+ def get_entries_days(self):
+ return []
+
+
+def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path):
+ # Config with a key so __init__ calls _try_connect immediately
+ cfg = DBConfig(tmp_path / "db.sqlite", key="x")
+ (tmp_path / "db.sqlite").write_text("", encoding="utf-8")
+ monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
+ monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr)
+ msgs = {}
+ monkeypatch.setattr(
+ QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m))
+ )
+ w = MainWindow(_themes_light()) # auto-calls _try_connect
+ qtbot.addWidget(w)
+ assert "incorrect" in msgs.get("m", "").lower()
+
+
+def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path):
+ cfg = DBConfig(tmp_path / "db.sqlite", key="x")
+ (tmp_path / "db.sqlite").write_text("", encoding="utf-8")
+ monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
+ monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
+ w = MainWindow(_themes_dark())
+ qtbot.addWidget(w)
+ w._apply_link_css()
+ css = w.editor.document().defaultStyleSheet()
+ assert "a {" in css
+
+
+def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path):
+ cfg = DBConfig(tmp_path / "db.sqlite", key="x")
+ (tmp_path / "db.sqlite").write_text("", encoding="utf-8")
+ monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
+ monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
+ w = MainWindow(_themes_light())
+ qtbot.addWidget(w)
+ called = {}
+
+ class FakeSettings:
+ def value(self, key, default=None, type=None):
+ if key == "main/geometry":
+ return None
+ if key == "main/windowState":
+ return None
+ if key == "main/maximized":
+ return False
+ return default
+
+ w.settings = FakeSettings()
+ monkeypatch.setattr(
+ w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True)
+ )
+ w._restore_window_position()
+ assert called.get("x") is True
+
+
+def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path):
+ cfg = DBConfig(tmp_path / "db.sqlite", key="x")
+ (tmp_path / "db.sqlite").write_text("", encoding="utf-8")
+ monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
+ monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
+ w = MainWindow(_themes_light())
+ qtbot.addWidget(w)
+ captured = {}
+ monkeypatch.setattr(
+ w.editor, "insert_images", lambda paths: captured.setdefault("p", paths)
+ )
+ # Simulate file dialog returning paths
+ monkeypatch.setattr(
+ "bouquin.main_window.QFileDialog.getOpenFileNames",
+ staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")),
+ )
+ w._on_insert_image()
+ assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"]
diff --git a/tests/test_search_unit.py b/tests/test_search_unit.py
new file mode 100644
index 0000000..13c1ef9
--- /dev/null
+++ b/tests/test_search_unit.py
@@ -0,0 +1,57 @@
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QListWidgetItem
+
+# The widget class is named `Search` in bouquin.search
+from bouquin.search import Search as SearchWidget
+
+
+class FakeDB:
+ def __init__(self, rows):
+ self.rows = rows
+
+ def search_entries(self, q):
+ return list(self.rows)
+
+
+def test_search_empty_clears_and_hides(qtbot):
+ w = SearchWidget(db=FakeDB([]))
+ qtbot.addWidget(w)
+ w.show()
+ qtbot.waitExposed(w)
+ dates = []
+ w.resultDatesChanged.connect(lambda ds: dates.extend(ds))
+ w._search(" ")
+ assert w.results.isHidden()
+ assert dates == []
+
+
+def test_populate_empty_hides(qtbot):
+ w = SearchWidget(db=FakeDB([]))
+ qtbot.addWidget(w)
+ w._populate_results("x", [])
+ assert w.results.isHidden()
+
+
+def test_open_selected_emits_when_present(qtbot):
+ w = SearchWidget(db=FakeDB([]))
+ qtbot.addWidget(w)
+ got = {}
+ w.openDateRequested.connect(lambda d: got.setdefault("d", d))
+ it = QListWidgetItem("x")
+ it.setData(Qt.ItemDataRole.UserRole, "")
+ w._open_selected(it)
+ assert "d" not in got
+ it.setData(Qt.ItemDataRole.UserRole, "2025-01-02")
+ w._open_selected(it)
+ assert got["d"] == "2025-01-02"
+
+
+def test_make_html_snippet_edge_cases(qtbot):
+ w = SearchWidget(db=FakeDB([]))
+ qtbot.addWidget(w)
+ # Empty HTML -> empty fragment, no ellipses
+ frag, l, r = w._make_html_snippet("", "hello")
+ assert frag == "" and not l and not r
+ # Small doc around token -> should not show ellipses
+ frag, l, r = w._make_html_snippet("Hello world
", "world")
+ assert "world" in frag or "world" in frag
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index f300c6f..906ec2c 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -1,9 +1,24 @@
from pathlib import Path
-from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
+from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget
from bouquin.db import DBConfig
from bouquin.settings_dialog import SettingsDialog
+from bouquin.theme import Theme
+
+
+class _ThemeSpy:
+ def __init__(self):
+ self.calls = []
+
+ def apply(self, t):
+ self.calls.append(t)
+
+
+class _Parent(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.themes = _ThemeSpy()
class FakeDB:
@@ -58,7 +73,22 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
p = AcceptingPrompt().set_key("sekrit")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
- dlg = SettingsDialog(cfg, db)
+ # Provide a lightweight parent that mimics MainWindow’s `themes` API
+ class _ThemeSpy:
+ def __init__(self):
+ self.calls = []
+
+ def apply(self, theme):
+ self.calls.append(theme)
+
+ class _Parent(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.themes = _ThemeSpy()
+
+ parent = _Parent()
+ qtbot.addWidget(parent)
+ dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
@@ -77,6 +107,7 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
assert out.path == new_path
assert out.idle_minutes == 0
assert out.key == "sekrit"
+ assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM
def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot):
@@ -250,3 +281,16 @@ def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot):
dlg.save_key_btn.setChecked(True)
# We should reach here with the original key preserved.
assert dlg.key == "already"
+
+
+def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path):
+ parent = _Parent()
+ qtbot.addWidget(parent)
+ cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5)
+ dlg = SettingsDialog(cfg, FakeDB(), parent=parent)
+ qtbot.addWidget(dlg)
+ dlg.save_key_btn.setChecked(False)
+ # Trigger save
+ dlg._save()
+ assert dlg.config.key == "" # cleared
+ assert parent.themes.calls # applied some theme
diff --git a/tests/test_settings_module.py b/tests/test_settings_module.py
new file mode 100644
index 0000000..24a9aac
--- /dev/null
+++ b/tests/test_settings_module.py
@@ -0,0 +1,28 @@
+from bouquin.db import DBConfig
+import bouquin.settings as settings
+
+
+class FakeSettings:
+ def __init__(self):
+ self.store = {}
+
+ def value(self, key, default=None, type=None):
+ return self.store.get(key, default)
+
+ def setValue(self, key, value):
+ self.store[key] = value
+
+
+def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path):
+ fake = FakeSettings()
+ monkeypatch.setattr(settings, "get_settings", lambda: fake)
+
+ cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark")
+ settings.save_db_config(cfg)
+
+ # Now read back into a new DBConfig
+ cfg2 = settings.load_db_config()
+ assert cfg2.path == cfg.path
+ assert cfg2.key == "k"
+ assert cfg2.idle_minutes == "7"
+ assert cfg2.theme == "dark"
diff --git a/tests/test_theme_integration.py b/tests/test_theme_integration.py
new file mode 100644
index 0000000..f1949c3
--- /dev/null
+++ b/tests/test_theme_integration.py
@@ -0,0 +1,19 @@
+from bouquin.theme import Theme
+
+
+def test_apply_link_css_dark_theme(open_window, qtbot):
+ win = open_window
+ # Switch to dark and apply link CSS
+ win.themes.set(Theme.DARK)
+ win._apply_link_css()
+ css = win.editor.document().defaultStyleSheet()
+ assert "#FFA500" in css and "a:visited" in css
+
+
+def test_apply_link_css_light_theme(open_window, qtbot):
+ win = open_window
+ # Switch to light and apply link CSS
+ win.themes.set(Theme.LIGHT)
+ win._apply_link_css()
+ css = win.editor.document().defaultStyleSheet()
+ assert css == "" or "a {" not in css
diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py
new file mode 100644
index 0000000..39121ea
--- /dev/null
+++ b/tests/test_theme_manager.py
@@ -0,0 +1,19 @@
+from PySide6.QtWidgets import QApplication
+from PySide6.QtGui import QPalette, QColor
+
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
+
+
+def test_theme_manager_applies_palettes(qtbot):
+ app = QApplication.instance()
+ tm = ThemeManager(app, ThemeConfig())
+
+ # Light palette should set Link to the light blue
+ tm.apply(Theme.LIGHT)
+ pal = app.palette()
+ assert pal.color(QPalette.Link) == QColor("#1a73e8")
+
+ # Dark palette should set Link to lavender-ish
+ tm.apply(Theme.DARK)
+ pal = app.palette()
+ assert pal.color(QPalette.Link) == QColor("#FFA500")
diff --git a/tests/test_toolbar_private.py b/tests/test_toolbar_private.py
new file mode 100644
index 0000000..834f4c2
--- /dev/null
+++ b/tests/test_toolbar_private.py
@@ -0,0 +1,23 @@
+from bouquin.toolbar import ToolBar
+
+
+def test_style_letter_button_handles_missing_widget(qtbot):
+ tb = ToolBar()
+ qtbot.addWidget(tb)
+ # Create a dummy action detached from toolbar to force widgetForAction->None
+ from PySide6.QtGui import QAction
+
+ act = QAction("X", tb)
+ # No crash and early return
+ tb._style_letter_button(act, "X")
+
+
+def test_style_letter_button_sets_tooltip_and_accessible(qtbot):
+ tb = ToolBar()
+ qtbot.addWidget(tb)
+ # Use an existing action so widgetForAction returns a button
+ act = tb.actBold
+ tb._style_letter_button(act, "B", bold=True, tooltip="Bold")
+ btn = tb.widgetForAction(act)
+ assert btn.toolTip() == "Bold"
+ assert btn.accessibleName() == "Bold"