Compare commits

...

2 commits

Author SHA1 Message Date
7c3ec19748
Various tweaks to theme, more code coverage 2025-11-06 11:47:00 +11:00
c3b83b0238
Commit working theme changes 2025-11-06 10:56:20 +11:00
23 changed files with 1158 additions and 94 deletions

View file

@ -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

View file

@ -1 +0,0 @@
from .main import main

View file

@ -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

View file

@ -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 dont 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()

View file

@ -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())

View file

@ -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)

View file

@ -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"<i>{date_str}</i>:")
date_lbl.setText(f"<h3><i>{date_str}</i></h3>")
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

View file

@ -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))

View file

@ -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):
@ -222,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(
@ -233,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):

105
bouquin/theme.py Normal file
View file

@ -0,0 +1,105 @@
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"
ORANGE_ANCHOR = "#FFA500"
ORANGE_ANCHOR_VISITED = "#B38000"
@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(Theme.ORANGE_ANCHOR.value))
pal.setColor(QPalette.LinkVisited, QColor(Theme.ORANGE_ANCHOR_VISITED.value))
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

View file

@ -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

View file

@ -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)

137
tests/test_db_unit.py Normal file
View file

@ -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", "<b>Hi</b>")]
# 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

View file

@ -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 "<img" in html
# Now with urls: local non-image + local image + remote url
png = tmp_path / "t.png"
img.save(str(png))
txt = tmp_path / "x.txt"
txt.write_text("hi", encoding="utf-8")
mime2 = QMimeData()
mime2.setUrls(
[
QUrl.fromLocalFile(str(txt)),
QUrl.fromLocalFile(str(png)),
QUrl("https://example.com/file"),
]
)
e.insertFromMimeData(mime2)
h2 = e.document().toHtml()
assert 'href="file://' in h2 # local file link inserted
assert "<img" in h2 # image inserted
assert 'href="https://example.com/file"' in h2 # remote url link
def test_mouse_release_ctrl_click_opens(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Anchor under cursor
monkeypatch.setattr(e, "anchorAt", lambda p: "https://example.com")
opened = {}
from PySide6.QtGui import QDesktopServices as DS
monkeypatch.setattr(
DS, "openUrl", lambda url: opened.setdefault("u", url.toString())
)
ev = QMouseEvent(
QMouseEvent.MouseButtonRelease,
QPoint(1, 1),
Qt.LeftButton,
Qt.LeftButton,
Qt.ControlModifier,
)
e.mouseReleaseEvent(ev)
assert opened.get("u") == "https://example.com"
def test_keypress_space_breaks_anchor(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
called = {}
monkeypatch.setattr(
e, "_break_anchor_for_next_char", lambda: called.setdefault("x", True)
)
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Space, Qt.NoModifier, " ")
e.keyPressEvent(ev)
assert called.get("x") is True
def test_enter_leaves_code_frame(qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
e.setPlainText("")
# Insert a code block frame
e.apply_code()
# Place cursor inside the empty code block
c = e.textCursor()
c.movePosition(QTextCursor.End)
e.setTextCursor(c)
# Press Enter; should jump outside the frame and start normal paragraph
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
e.keyPressEvent(ev)
# After enter, the cursor should not be inside a code frame
assert e._find_code_frame(e.textCursor()) is None
def test_space_does_not_bleed_anchor_format(qtbot):
e = _mk_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_editor_small_helpers(qtbot):
app = QApplication.instance()
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
e = Editor(themes)
qtbot.addWidget(e)
# _approx returns True when |a-b| <= eps
assert e._approx(1.0, 1.25, eps=0.3) is True
assert e._approx(1.0, 1.6, eps=0.3) is False
# Exercise helpers
_ = e._is_heading_typing()
e._apply_normal_typing()

69
tests/test_entrypoints.py Normal file
View file

@ -0,0 +1,69 @@
import importlib
def test___main___exports_main():
entry_mod = importlib.import_module("bouquin.__main__")
main_mod = importlib.import_module("bouquin.main")
assert entry_mod.main is main_mod.main
def test_main_entry_initializes_qt(monkeypatch):
main_mod = importlib.import_module("bouquin.main")
# Fakes to avoid real Qt event loop
class FakeApp:
def __init__(self, argv):
self.argv = argv
self.name = None
self.org = None
def setApplicationName(self, n):
self.name = n
def setOrganizationName(self, n):
self.org = n
def exec(self):
return 0
class FakeWin:
def __init__(self, themes=None):
self.themes = themes
self.shown = False
def show(self):
self.shown = True
class FakeThemes:
def __init__(self, app, cfg):
self._applied = None
self.app = app
self.cfg = cfg
def apply(self, t):
self._applied = t
class FakeSettings:
def __init__(self):
self._map = {"ui/theme": "dark"}
def value(self, k, default=None, type=None):
return self._map.get(k, default)
def fake_get_settings():
return FakeSettings()
monkeypatch.setattr(main_mod, "QApplication", FakeApp)
monkeypatch.setattr(main_mod, "MainWindow", FakeWin)
monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes)
monkeypatch.setattr(main_mod, "get_settings", fake_get_settings)
exits = {}
def fake_exit(code):
exits["code"] = code
monkeypatch.setattr(main_mod.sys, "exit", fake_exit)
main_mod.main()
assert exits.get("code", None) == 0

View file

@ -0,0 +1,66 @@
from PySide6.QtWidgets import QListWidgetItem
from PySide6.QtCore import Qt
from bouquin.history_dialog import HistoryDialog
class FakeDB:
def __init__(self):
self.fail_revert = False
def list_versions(self, date_iso):
# Simulate two versions; mark second as current
return [
{
"id": 1,
"version_no": 1,
"created_at": "2025-01-01T10:00:00Z",
"note": None,
"is_current": False,
"content": "<p>a</p>",
},
{
"id": 2,
"version_no": 2,
"created_at": "2025-01-02T10:00:00Z",
"note": None,
"is_current": True,
"content": "<p>b</p>",
},
]
def get_version(self, version_id):
if version_id == 2:
return {"content": "<p>b</p>"}
return {"content": "<p>a</p>"}
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]

113
tests/test_misc.py Normal file
View file

@ -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 "<p>hi</p>"
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"]

57
tests/test_search_unit.py Normal file
View file

@ -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("<p>Hello world</p>", "world")
assert "<b>world</b>" in frag or "world" in frag

View file

@ -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 MainWindows `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

View file

@ -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"

View file

@ -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

View file

@ -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")

View file

@ -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"