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/__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/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..7b29bbc 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,90 @@ 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 = Theme.ORANGE_ANCHOR.value + visited = Theme.ORANGE_ANCHOR_VISITED.value + 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: + 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, highlight) + pal.setColor(QPalette.HighlightedText, black) + self.calendar.setPalette(pal) + + # Stylesheet: nav bar + selected-day background + 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: {highlight_css}; + 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 +441,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 +491,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 +511,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 +665,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"
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"