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"