From f8e0a7f17940cb4a275296c413cc4a6c7dae289e Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 12:58:42 +1100
Subject: [PATCH 001/254] More styling shenanigans, fix an export typo bug
---
CHANGELOG.md | 6 +++
bouquin/editor.py | 90 ++++++++++++++++++++++++++++------------
bouquin/main_window.py | 74 +++++++++++++++++++++++++++++++--
bouquin/toolbar.py | 93 ++++++++++++++++++++++++++++++++----------
4 files changed, 213 insertions(+), 50 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f04325b..49634a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# 0.1.9
+
+ * More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be
+ possible to set both H1 and H2 at once)
+ * Fix small bug in export of HTML or arbitrary extension
+
# 0.1.8
* Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed
diff --git a/bouquin/editor.py b/bouquin/editor.py
index 6efbb7a..afcd7e4 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -21,6 +21,7 @@ class Editor(QTextEdit):
_URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
_CODE_BG = QColor(245, 245, 245)
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
+ _HEADING_SIZES = (24.0, 18.0, 14.0)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -40,6 +41,21 @@ class Editor(QTextEdit):
self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True)
+ def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
+ return abs(float(a) - float(b)) <= eps
+
+ def _is_heading_typing(self) -> bool:
+ """Is the current *insertion* format using a heading size?"""
+ s = self.currentCharFormat().fontPointSize() or self.font().pointSizeF()
+ return any(self._approx(s, h) for h in self._HEADING_SIZES)
+
+ def _apply_normal_typing(self):
+ """Switch the *insertion* format to Normal (default size, normal weight)."""
+ nf = QTextCharFormat()
+ nf.setFontPointSize(self.font().pointSizeF())
+ nf.setFontWeight(QFont.Weight.Normal)
+ self.mergeCurrentCharFormat(nf)
+
def _find_code_frame(self, cursor=None):
"""Return the nearest ancestor frame that's one of our code frames, else None."""
if cursor is None:
@@ -139,6 +155,7 @@ class Editor(QTextEdit):
if key in (Qt.Key_Return, Qt.Key_Enter):
c = self.textCursor()
+
# If we're on an empty line inside a code frame, consume Enter and jump out
if c.block().length() == 1:
frame = self._find_code_frame(c)
@@ -149,6 +166,13 @@ class Editor(QTextEdit):
super().insertPlainText("\n") # start a normal paragraph
return
+ # Follow-on style: if we typed a heading and press Enter at end of block,
+ # new paragraph should revert to Normal.
+ if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing():
+ super().keyPressEvent(e) # insert the new paragraph
+ self._apply_normal_typing() # make the *new* paragraph Normal for typing
+ return
+
# otherwise default handling
return super().keyPressEvent(e)
@@ -158,28 +182,32 @@ class Editor(QTextEdit):
self.setCurrentCharFormat(nf)
def _break_anchor_for_next_char(self):
- c = self.textCursor()
- fmt = c.charFormat()
- if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
- # clone, then strip just the link-specific bits so the next char is plain text
- nf = QTextCharFormat(fmt)
- nf.setAnchor(False)
- nf.setFontUnderline(False)
- nf.clearForeground()
- try:
- nf.setAnchorHref("")
- except AttributeError:
- nf.setAnchorNames([])
- self.setCurrentCharFormat(nf)
+ """
+ Ensure the *next* typed character is not part of a hyperlink.
+ Only strips link-specific attributes; leaves bold/italic/underline etc intact.
+ """
+ # What we're about to type with
+ ins_fmt = self.currentCharFormat()
+ # What the cursor is sitting on
+ cur_fmt = self.textCursor().charFormat()
+
+ # Do nothing unless either side indicates we're in/propagating an anchor
+ if not (ins_fmt.isAnchor() or cur_fmt.isAnchor()):
+ return
+
+ nf = QTextCharFormat(ins_fmt)
+ nf.setAnchor(False)
+ nf.setAnchorHref("")
+
+ self.setCurrentCharFormat(nf)
def merge_on_sel(self, fmt):
"""
- Sets the styling on the selected characters.
+ Sets the styling on the selected characters or the insertion position.
"""
cursor = self.textCursor()
- if not cursor.hasSelection():
- cursor.select(cursor.SelectionType.WordUnderCursor)
- cursor.mergeCharFormat(fmt)
+ if cursor.hasSelection():
+ cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
@Slot()
@@ -265,15 +293,25 @@ class Editor(QTextEdit):
c.endEditBlock()
@Slot(int)
- def apply_heading(self, size):
- fmt = QTextCharFormat()
- if size:
- fmt.setFontWeight(QFont.Weight.Bold)
- fmt.setFontPointSize(size)
- else:
- fmt.setFontWeight(QFont.Weight.Normal)
- fmt.setFontPointSize(self.font().pointSizeF())
- self.merge_on_sel(fmt)
+ def apply_heading(self, size: int):
+ """
+ Set heading point size for typing. If there's a selection, also apply bold
+ to that selection (for H1..H3). "Normal" clears bold on the selection.
+ """
+ base_size = size if size else self.font().pointSizeF()
+ c = self.textCursor()
+
+ # Update the typing (insertion) format to be size only, but don't represent
+ # it as if the Bold style has been toggled on
+ ins = QTextCharFormat()
+ ins.setFontPointSize(base_size)
+ self.mergeCurrentCharFormat(ins)
+
+ # If user selected text, style that text visually as a heading
+ if c.hasSelection():
+ sel = QTextCharFormat(ins)
+ sel.setFontWeight(QFont.Weight.Bold if size else QFont.Weight.Normal)
+ c.mergeCharFormat(sel)
def toggle_bullets(self):
c = self.textCursor()
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 7d2fbfc..c73b647 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -4,7 +4,16 @@ import os
import sys
from pathlib import Path
-from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
+from PySide6.QtCore import (
+ QDate,
+ QTimer,
+ Qt,
+ QSettings,
+ Slot,
+ QUrl,
+ QEvent,
+ QSignalBlocker,
+)
from PySide6.QtGui import (
QAction,
QCursor,
@@ -12,6 +21,7 @@ from PySide6.QtGui import (
QFont,
QGuiApplication,
QTextCharFormat,
+ QTextListFormat,
)
from PySide6.QtWidgets import (
QCalendarWidget,
@@ -150,6 +160,9 @@ class MainWindow(QMainWindow):
self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history)
+ self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
+ self.editor.cursorPositionChanged.connect(self._sync_toolbar)
+
split = QSplitter()
split.addWidget(left_panel)
split.addWidget(self.editor)
@@ -315,6 +328,61 @@ class MainWindow(QMainWindow):
pass
# --- UI handlers ---------------------------------------------------------
+
+ def _sync_toolbar(self):
+ fmt = self.editor.currentCharFormat()
+ c = self.editor.textCursor()
+ 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)
+
+ self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
+ self.toolBar.actItalic.setChecked(fmt.fontItalic())
+ self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
+ self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
+
+ # Headings: decide which to check by current point size
+ def _approx(a, b, eps=0.5): # small float tolerance
+ return abs(float(a) - float(b)) <= eps
+
+ cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF()
+
+ bH1 = _approx(cur_size, 24)
+ 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)
+
+ self.toolBar.actH1.setChecked(bH1)
+ self.toolBar.actH2.setChecked(bH2)
+ self.toolBar.actH3.setChecked(bH3)
+ self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3))
+
+ # Lists
+ lst = c.currentList()
+ bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
+ numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
+ QSignalBlocker(self.toolBar.actBullets)
+ QSignalBlocker(self.toolBar.actNumbers)
+ self.toolBar.actBullets.setChecked(bool(bullets_on))
+ self.toolBar.actNumbers.setChecked(bool(numbers_on))
+
+ # Alignment
+ align = bf.alignment() & Qt.AlignHorizontal_Mask
+ QSignalBlocker(self.toolBar.actAlignL)
+ self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
+ QSignalBlocker(self.toolBar.actAlignC)
+ self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
+ QSignalBlocker(self.toolBar.actAlignR)
+ self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
+
def _current_date_iso(self) -> str:
d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
@@ -518,9 +586,9 @@ class MainWindow(QMainWindow):
elif selected_filter.startswith("CSV"):
self.db.export_csv(entries, filename)
elif selected_filter.startswith("HTML"):
- self.bd.export_html(entries, filename)
+ self.db.export_html(entries, filename)
else:
- self.bd.export_by_extension(entries, filename)
+ self.db.export_by_extension(entries, filename)
QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
except Exception as e:
diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py
index 241e15c..2924471 100644
--- a/bouquin/toolbar.py
+++ b/bouquin/toolbar.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from PySide6.QtCore import Signal, Qt
-from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
+from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
from PySide6.QtWidgets import QToolBar
@@ -25,54 +25,81 @@ class ToolBar(QToolBar):
self._apply_toolbar_styles()
def _build_actions(self):
- self.actBold = QAction("Bold", self)
+ self.actBold = QAction("B", self)
+ self.actBold.setToolTip("Bold")
+ self.actBold.setCheckable(True)
self.actBold.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested)
- self.actItalic = QAction("Italic", self)
+ self.actItalic = QAction("I", self)
+ self.actItalic.setToolTip("Italic")
+ self.actItalic.setCheckable(True)
self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested)
- self.actUnderline = QAction("Underline", self)
+ self.actUnderline = QAction("U", self)
+ self.actUnderline.setToolTip("Underline")
+ self.actUnderline.setCheckable(True)
self.actUnderline.setShortcut(QKeySequence.Underline)
self.actUnderline.triggered.connect(self.underlineRequested)
- self.actStrike = QAction("Strikethrough", self)
+ self.actStrike = QAction("S", self)
+ self.actStrike.setToolTip("Strikethrough")
+ self.actStrike.setCheckable(True)
self.actStrike.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested)
- self.actCode = QAction("Inline code", self)
+ self.actCode = QAction(">", self)
+ self.actCode.setToolTip("Code block")
self.actCode.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested)
# Headings
- self.actH1 = QAction("Heading 1", self)
- self.actH2 = QAction("Heading 2", self)
- self.actH3 = QAction("Heading 3", self)
- self.actNormal = QAction("Normal text", self)
+ self.actH1 = QAction("H1", self)
+ self.actH1.setToolTip("Heading 1")
+ self.actH1.setCheckable(True)
self.actH1.setShortcut("Ctrl+1")
- self.actH2.setShortcut("Ctrl+2")
- self.actH3.setShortcut("Ctrl+3")
- self.actNormal.setShortcut("Ctrl+O")
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
+ self.actH2 = QAction("H2", self)
+ self.actH2.setToolTip("Heading 2")
+ self.actH2.setCheckable(True)
+ self.actH2.setShortcut("Ctrl+2")
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
+ self.actH3 = QAction("H3", self)
+ self.actH3.setToolTip("Heading 3")
+ self.actH3.setCheckable(True)
+ self.actH3.setShortcut("Ctrl+3")
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
+ self.actNormal = QAction("N", self)
+ self.actNormal.setToolTip("Normal paragraph text")
+ self.actNormal.setCheckable(True)
+ self.actNormal.setShortcut("Ctrl+O")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
# Lists
- self.actBullets = QAction("Bulleted list", self)
+ self.actBullets = QAction("•", self)
+ self.actBullets.setToolTip("Bulleted list")
+ self.actBullets.setCheckable(True)
self.actBullets.triggered.connect(self.bulletsRequested)
- self.actNumbers = QAction("Numbered list", self)
+ self.actNumbers = QAction("1.", self)
+ self.actNumbers.setToolTip("Numbered list")
+ self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
# Alignment
- self.actAlignL = QAction("Align left", self)
- self.actAlignC = QAction("Align center", self)
- self.actAlignR = QAction("Align right", self)
+ self.actAlignL = QAction("L", self)
+ self.actAlignL.setToolTip("Align Left")
+ self.actAlignL.setCheckable(True)
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
+ self.actAlignC = QAction("C", self)
+ self.actAlignC.setToolTip("Align Center")
+ self.actAlignC.setCheckable(True)
self.actAlignC.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignHCenter)
)
+ self.actAlignR = QAction("R", self)
+ self.actAlignR.setToolTip("Align Right")
+ self.actAlignR.setCheckable(True)
self.actAlignR.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignRight)
)
@@ -81,6 +108,28 @@ class ToolBar(QToolBar):
self.actHistory = QAction("History", self)
self.actHistory.triggered.connect(self.historyRequested)
+ # Set exclusive buttons in QActionGroups
+ self.grpHeadings = QActionGroup(self)
+ self.grpHeadings.setExclusive(True)
+ for a in (
+ self.actBold,
+ self.actItalic,
+ self.actUnderline,
+ self.actStrike,
+ self.actH1,
+ self.actH2,
+ self.actH3,
+ self.actNormal,
+ ):
+ a.setCheckable(True)
+ a.setActionGroup(self.grpHeadings)
+
+ self.grpAlign = QActionGroup(self)
+ self.grpAlign.setExclusive(True)
+ for a in (self.actAlignL, self.actAlignC, self.actAlignR):
+ a.setActionGroup(self.grpAlign)
+
+ # Add actions
self.addActions(
[
self.actBold,
@@ -106,7 +155,6 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actItalic, "I", italic=True)
self._style_letter_button(self.actUnderline, "U", underline=True)
self._style_letter_button(self.actStrike, "S", strike=True)
-
# Monospace look for code; use a fixed font
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._style_letter_button(self.actCode, ">", custom_font=code_font)
@@ -139,11 +187,13 @@ class ToolBar(QToolBar):
underline: bool = False,
strike: bool = False,
custom_font: QFont | None = None,
+ tooltip: str | None = None,
):
btn = self.widgetForAction(action)
if not btn:
return
btn.setText(text)
+
f = custom_font if custom_font is not None else QFont(btn.font())
if custom_font is None:
f.setBold(bold)
@@ -153,5 +203,6 @@ class ToolBar(QToolBar):
btn.setFont(f)
# Keep accessibility/tooltip readable
- btn.setToolTip(action.text())
- btn.setAccessibleName(action.text())
+ if tooltip:
+ btn.setToolTip(tooltip)
+ btn.setAccessibleName(tooltip)
From 27ba33959cb239067eb87f8ff4b17b4a12152ff1 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 14:25:03 +1100
Subject: [PATCH 002/254] Add sql plaintext export and (encrypted) backup
options
---
CHANGELOG.md | 2 ++
bouquin/db.py | 21 ++++++++++-
bouquin/main_window.py | 82 ++++++++++++++++++++++++++++++++++++------
bouquin/toolbar.py | 2 +-
4 files changed, 94 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49634a8..5b622f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@
* More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be
possible to set both H1 and H2 at once)
* Fix small bug in export of HTML or arbitrary extension
+ * Add plaintext SQLite3 Export option
+ * Add Backup option (database remains encrypted with SQLCipher)
# 0.1.8
diff --git a/bouquin/db.py b/bouquin/db.py
index 37b65f1..cfbd5f2 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -409,7 +409,7 @@ class DBManager:
f.write(separator)
def export_html(
- self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
+ self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
) -> None:
parts = [
"",
@@ -430,6 +430,25 @@ class DBManager:
with open(file_path, "w", encoding="utf-8") as f:
f.write("\n".join(parts))
+ def export_sql(self, file_path: str) -> None:
+ """
+ Exports the encrypted database as plaintext SQL.
+ """
+ cur = self.conn.cursor()
+ cur.execute(f"ATTACH DATABASE '{file_path}' AS plaintext KEY '';")
+ cur.execute("SELECT sqlcipher_export('plaintext')")
+ cur.execute("DETACH DATABASE plaintext")
+
+ def export_sqlcipher(self, file_path: str) -> None:
+ """
+ Exports the encrypted database as an encrypted database with the same key.
+ Intended for Bouquin-compatible backups.
+ """
+ cur = self.conn.cursor()
+ cur.execute(f"ATTACH DATABASE '{file_path}' AS backup KEY '{self.cfg.key}'")
+ cur.execute("SELECT sqlcipher_export('backup')")
+ cur.execute("DETACH DATABASE backup")
+
def export_by_extension(self, file_path: str) -> None:
entries = self.get_all_entries()
ext = os.path.splitext(file_path)[1].lower()
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index c73b647..df1726d 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import datetime
import os
import sys
@@ -214,6 +215,10 @@ class MainWindow(QMainWindow):
act_export.setShortcut("Ctrl+E")
act_export.triggered.connect(self._export)
file_menu.addAction(act_export)
+ act_backup = QAction("&Backup", self)
+ act_backup.setShortcut("Ctrl+Shift+B")
+ act_backup.triggered.connect(self._backup)
+ file_menu.addAction(act_backup)
file_menu.addSeparator()
act_quit = QAction("&Quit", self)
act_quit.setShortcut("Ctrl+Q")
@@ -223,21 +228,21 @@ class MainWindow(QMainWindow):
# Navigate menu with next/previous/today
nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self)
- act_prev.setShortcut("Ctrl+P")
+ act_prev.setShortcut("Ctrl+Shift+P")
act_prev.setShortcutContext(Qt.ApplicationShortcut)
act_prev.triggered.connect(lambda: self._adjust_day(-1))
nav_menu.addAction(act_prev)
self.addAction(act_prev)
act_next = QAction("Next Day", self)
- act_next.setShortcut("Ctrl+N")
+ act_next.setShortcut("Ctrl+Shift+N")
act_next.setShortcutContext(Qt.ApplicationShortcut)
act_next.triggered.connect(lambda: self._adjust_day(1))
nav_menu.addAction(act_next)
self.addAction(act_next)
act_today = QAction("Today", self)
- act_today.setShortcut("Ctrl+T")
+ act_today.setShortcut("Ctrl+Shift+T")
act_today.setShortcutContext(Qt.ApplicationShortcut)
act_today.triggered.connect(self._adjust_today)
nav_menu.addAction(act_today)
@@ -552,13 +557,31 @@ class MainWindow(QMainWindow):
# ----------------- Export handler ----------------- #
@Slot()
def _export(self):
- try:
- self.export_dialog()
- except Exception as e:
- QMessageBox.critical(self, "Export failed", str(e))
+ warning_title = "Unencrypted export"
+ warning_message = """
+Exporting the database will be unencrypted!
- def export_dialog(self) -> None:
- filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
+Are you sure you want to continue?
+
+If you want an encrypted backup, choose Backup instead of Export.
+"""
+ dlg = QMessageBox()
+ dlg.setWindowTitle(warning_title)
+ dlg.setText(warning_message)
+ dlg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
+ dlg.setIcon(QMessageBox.Warning)
+ dlg.show()
+ dlg.adjustSize()
+ if dlg.exec() != QMessageBox.Yes:
+ return False
+
+ filters = (
+ "Text (*.txt);;"
+ "JSON (*.json);;"
+ "CSV (*.csv);;"
+ "HTML (*.html);;"
+ "SQL (*.sql);;"
+ )
start_dir = os.path.join(os.path.expanduser("~"), "Documents")
filename, selected_filter = QFileDialog.getSaveFileName(
@@ -572,6 +595,7 @@ class MainWindow(QMainWindow):
"JSON (*.json)": ".json",
"CSV (*.csv)": ".csv",
"HTML (*.html)": ".html",
+ "SQL (*.sql)": ".sql",
}.get(selected_filter, ".txt")
if not Path(filename).suffix:
@@ -587,6 +611,8 @@ class MainWindow(QMainWindow):
self.db.export_csv(entries, filename)
elif selected_filter.startswith("HTML"):
self.db.export_html(entries, filename)
+ elif selected_filter.startswith("SQL"):
+ self.db.export_sql(filename)
else:
self.db.export_by_extension(entries, filename)
@@ -594,6 +620,39 @@ class MainWindow(QMainWindow):
except Exception as e:
QMessageBox.critical(self, "Export failed", str(e))
+ # ----------------- Backup handler ----------------- #
+ @Slot()
+ def _backup(self):
+ filters = "SQLCipher (*.db);;"
+
+ now = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
+ start_dir = os.path.join(
+ os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db"
+ )
+ filename, selected_filter = QFileDialog.getSaveFileName(
+ self, "Backup encrypted notebook", start_dir, filters
+ )
+ if not filename:
+ return # user cancelled
+
+ default_ext = {
+ "SQLCipher (*.db)": ".db",
+ }.get(selected_filter, ".db")
+
+ if not Path(filename).suffix:
+ filename += default_ext
+
+ try:
+ if selected_filter.startswith("SQL"):
+ self.db.export_sqlcipher(filename)
+ QMessageBox.information(
+ self, "Backup complete", f"Saved to:\n{filename}"
+ )
+ except Exception as e:
+ QMessageBox.critical(self, "Backup failed", str(e))
+
+ # ----------------- Help handlers ----------------- #
+
def _open_docs(self):
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
url = QUrl.fromUserInput(url_str)
@@ -610,7 +669,7 @@ class MainWindow(QMainWindow):
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
)
- # Idle handlers
+ # ----------------- Idle handlers ----------------- #
def _apply_idle_minutes(self, minutes: int):
minutes = max(0, int(minutes))
if not hasattr(self, "_idle_timer"):
@@ -668,13 +727,14 @@ class MainWindow(QMainWindow):
tb.setEnabled(True)
self._idle_timer.start()
- # Close app handler - save window position and database
+ # ----------------- Close handlers ----------------- #
def closeEvent(self, event):
try:
# Save window position
self.settings.setValue("main/geometry", self.saveGeometry())
self.settings.setValue("main/windowState", self.saveState())
self.settings.setValue("main/maximized", self.isMaximized())
+
# Ensure we save any last pending edits to the db
self._save_current()
self.db.close()
diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py
index 2924471..e9d2777 100644
--- a/bouquin/toolbar.py
+++ b/bouquin/toolbar.py
@@ -73,7 +73,7 @@ class ToolBar(QToolBar):
self.actNormal = QAction("N", self)
self.actNormal.setToolTip("Normal paragraph text")
self.actNormal.setCheckable(True)
- self.actNormal.setShortcut("Ctrl+O")
+ self.actNormal.setShortcut("Ctrl+N")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
# Lists
From 7548f33de4cff40d0adacafeaeb38868dc8cba34 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 14:55:59 +1100
Subject: [PATCH 003/254] Add ability to compact the database with VACUUM
---
CHANGELOG.md | 1 +
bouquin/db.py | 10 ++++++
bouquin/settings_dialog.py | 74 +++++++++++++++++++++++++++++---------
3 files changed, 69 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b622f2..e5f4397 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
* Fix small bug in export of HTML or arbitrary extension
* Add plaintext SQLite3 Export option
* Add Backup option (database remains encrypted with SQLCipher)
+ * Add ability to run VACUUM (compact) on the database in settings
# 0.1.8
diff --git a/bouquin/db.py b/bouquin/db.py
index cfbd5f2..f75fdbb 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -464,6 +464,16 @@ class DBManager:
else:
raise ValueError(f"Unsupported extension: {ext}")
+ def compact(self) -> None:
+ """
+ Runs VACUUM on the db.
+ """
+ try:
+ cur = self.conn.cursor()
+ cur.execute(f"VACUUM")
+ except Exception as e:
+ print(f"Error: {e}")
+
def close(self) -> None:
if self.conn is not None:
self.conn.close()
diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py
index cd2f13e..947deaa 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -57,7 +57,7 @@ class SettingsDialog(QDialog):
form.addRow("Database path", path_row)
# Encryption settings
- enc_group = QGroupBox("Encryption and Privacy")
+ enc_group = QGroupBox("Encryption")
enc = QVBoxLayout(enc_group)
enc.setContentsMargins(12, 8, 12, 12)
enc.setSpacing(6)
@@ -68,7 +68,7 @@ class SettingsDialog(QDialog):
self.key = current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor)
- self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
+ self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
# Explanation for remembering key
@@ -93,6 +93,21 @@ class SettingsDialog(QDialog):
line.setFrameShadow(QFrame.Sunken)
enc.addWidget(line)
+ # Change key button
+ self.rekey_btn = QPushButton("Change encryption key")
+ self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ self.rekey_btn.clicked.connect(self._change_key)
+
+ enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
+
+ form.addRow(enc_group)
+
+ # Privacy settings
+ priv_group = QGroupBox("Lock screen when idle")
+ priv = QVBoxLayout(priv_group)
+ priv.setContentsMargins(12, 8, 12, 12)
+ priv.setSpacing(6)
+
self.idle_spin = QSpinBox()
self.idle_spin.setRange(0, 240)
self.idle_spin.setSingleStep(1)
@@ -100,7 +115,7 @@ class SettingsDialog(QDialog):
self.idle_spin.setSuffix(" min")
self.idle_spin.setSpecialValueText("Never")
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
- enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
+ priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
# Explanation for idle option (autolock)
self.idle_spin_label = QLabel(
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
@@ -116,22 +131,39 @@ class SettingsDialog(QDialog):
spin_row = QHBoxLayout()
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
spin_row.addWidget(self.idle_spin_label)
- enc.addLayout(spin_row)
+ priv.addLayout(spin_row)
- line2 = QFrame()
- line2.setFrameShape(QFrame.HLine)
- line2.setFrameShadow(QFrame.Sunken)
- enc.addWidget(line2)
+ form.addRow(priv_group)
- # Change key button
- self.rekey_btn = QPushButton("Change encryption key")
- self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
- self.rekey_btn.clicked.connect(self._change_key)
+ # Maintenance settings
+ maint_group = QGroupBox("Database maintenance")
+ maint = QVBoxLayout(maint_group)
+ maint.setContentsMargins(12, 8, 12, 12)
+ maint.setSpacing(6)
- enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
+ self.compact_btn = QPushButton("Compact database")
+ self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ self.compact_btn.clicked.connect(self._compact_btn_clicked)
- # Put the group into the form so it spans the full width nicely
- form.addRow(enc_group)
+ maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
+
+ # Explanation for compating button
+ self.compact_label = QLabel(
+ "Compacting runs VACUUM on the database. This can help reduce its size."
+ )
+ self.compact_label.setWordWrap(True)
+ self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ # make it look secondary
+ cpal = self.compact_label.palette()
+ cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
+ self.compact_label.setPalette(cpal)
+
+ maint_row = QHBoxLayout()
+ maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
+ maint_row.addWidget(self.compact_label)
+ maint.addLayout(maint_row)
+
+ form.addRow(maint_group)
# Buttons
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
@@ -189,7 +221,7 @@ class SettingsDialog(QDialog):
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
@Slot(bool)
- def save_key_btn_clicked(self, checked: bool):
+ def _save_key_btn_clicked(self, checked: bool):
if checked:
if not self.key:
p1 = KeyPrompt(
@@ -204,6 +236,16 @@ class SettingsDialog(QDialog):
else:
self.key = ""
+ @Slot(bool)
+ def _compact_btn_clicked(self):
+ try:
+ self._db.compact()
+ QMessageBox.information(
+ self, "Compact complete", "Database compacted successfully!"
+ )
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
+
@property
def config(self) -> DBConfig:
return self._cfg
From 74a75eadcb3e9ef409d832beecf19d35dd045541 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 15:57:41 +1100
Subject: [PATCH 004/254] Add ability to storage images in the page
---
CHANGELOG.md | 1 +
bouquin/db.py | 2 +-
bouquin/editor.py | 306 ++++++++++++++++++++++++++++++++++++++++-
bouquin/main_window.py | 19 ++-
bouquin/toolbar.py | 8 ++
5 files changed, 331 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5f4397..e69fc13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
* Add plaintext SQLite3 Export option
* Add Backup option (database remains encrypted with SQLCipher)
* Add ability to run VACUUM (compact) on the database in settings
+ * Add ability to store images in the page
# 0.1.8
diff --git a/bouquin/db.py b/bouquin/db.py
index f75fdbb..82b195f 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -470,7 +470,7 @@ class DBManager:
"""
try:
cur = self.conn.cursor()
- cur.execute(f"VACUUM")
+ cur.execute("VACUUM")
except Exception as e:
print(f"Error: {e}")
diff --git a/bouquin/editor.py b/bouquin/editor.py
index afcd7e4..07ef6d3 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -1,17 +1,34 @@
from __future__ import annotations
+from pathlib import Path
+import base64, re
+
from PySide6.QtGui import (
QColor,
QDesktopServices,
QFont,
QFontDatabase,
+ QImage,
+ QImageReader,
+ QPixmap,
QTextCharFormat,
QTextCursor,
QTextFrameFormat,
QTextListFormat,
QTextBlockFormat,
+ QTextImageFormat,
+ QTextDocument,
+)
+from PySide6.QtCore import (
+ Qt,
+ QUrl,
+ Signal,
+ Slot,
+ QRegularExpression,
+ QBuffer,
+ QByteArray,
+ QIODevice,
)
-from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
from PySide6.QtWidgets import QTextEdit
@@ -22,6 +39,8 @@ class Editor(QTextEdit):
_CODE_BG = QColor(245, 245, 245)
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
_HEADING_SIZES = (24.0, 18.0, 14.0)
+ _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):
super().__init__(*args, **kwargs)
@@ -129,6 +148,291 @@ class Editor(QTextEdit):
finally:
self._linkifying = False
+ def _to_qimage(self, obj) -> QImage | None:
+ if isinstance(obj, QImage):
+ return None if obj.isNull() else obj
+ if isinstance(obj, QPixmap):
+ qi = obj.toImage()
+ return None if qi.isNull() else qi
+ if isinstance(obj, (bytes, bytearray)):
+ qi = QImage.fromData(obj)
+ return None if qi.isNull() else qi
+ return None
+
+ def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
+ ba = QByteArray()
+ buf = QBuffer(ba)
+ buf.open(QIODevice.WriteOnly)
+ img.save(buf, fmt.upper())
+ b64 = base64.b64encode(bytes(ba)).decode("ascii")
+ mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
+ return f"data:{mime};base64,{b64}"
+
+ def _image_name_to_qimage(self, name: str) -> QImage | None:
+ res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
+ return res if isinstance(res, QImage) and not res.isNull() else None
+
+ def to_html_with_embedded_images(self) -> str:
+ """
+ Return the document HTML with all image src's replaced by data: URLs,
+ so it is self-contained for storage in the DB.
+ """
+ # 1) Walk the document collecting name -> data: URL
+ name_to_data = {}
+ cur = QTextCursor(self.document())
+ cur.movePosition(QTextCursor.Start)
+ while True:
+ cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
+ fmt = cur.charFormat()
+ if fmt.isImageFormat():
+ imgfmt = QTextImageFormat(fmt)
+ name = imgfmt.name()
+ if name and name not in name_to_data:
+ img = self._image_name_to_qimage(name)
+ if img:
+ name_to_data[name] = self._qimage_to_data_url(img, "PNG")
+ if cur.atEnd():
+ break
+ cur.clearSelection()
+
+ # 2) Serialize and replace names with data URLs
+ html = self.document().toHtml()
+ for old, data_url in name_to_data.items():
+ html = html.replace(f'src="{old}"', f'src="{data_url}"')
+ html = html.replace(f"src='{old}'", f"src='{data_url}'")
+ return html
+
+ def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
+ c = self.textCursor()
+
+ # Don’t drop inside a code frame
+ frame = self._find_code_frame(c)
+ if frame:
+ out = QTextCursor(self.document())
+ out.setPosition(frame.lastPosition())
+ self.setTextCursor(out)
+ c = self.textCursor()
+
+ # Start a fresh paragraph if mid-line
+ if c.positionInBlock() != 0:
+ c.insertBlock()
+
+ if autoscale and self.viewport():
+ max_w = int(self.viewport().width() * 0.92)
+ if img.width() > max_w:
+ img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
+
+ c.insertImage(img)
+ c.insertBlock() # one blank line after the image
+
+ def _image_info_at_cursor(self):
+ """
+ Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
+ """
+ # Try current position (select 1 char forward)
+ tc = QTextCursor(self.textCursor())
+ tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
+ fmt = tc.charFormat()
+ if fmt.isImageFormat():
+ imgfmt = QTextImageFormat(fmt)
+ img = self._resolve_image_resource(imgfmt)
+ return tc, imgfmt, img
+
+ # Try previous char (if caret is just after the image)
+ tc = QTextCursor(self.textCursor())
+ if tc.position() > 0:
+ tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
+ tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
+ fmt = tc.charFormat()
+ if fmt.isImageFormat():
+ imgfmt = QTextImageFormat(fmt)
+ img = self._resolve_image_resource(imgfmt)
+ return tc, imgfmt, img
+
+ return None, None, None
+
+ def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
+ """
+ Fetch the original QImage backing the inline image, if available.
+ """
+ name = imgfmt.name()
+ if name:
+ try:
+ img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
+ if isinstance(img, QImage) and not img.isNull():
+ return img
+ except Exception:
+ pass
+ return None # fallback handled by callers
+
+ def _apply_image_size(
+ self,
+ tc: QTextCursor,
+ imgfmt: QTextImageFormat,
+ new_w: float,
+ orig_img: QImage | None,
+ ):
+ # compute height proportionally
+ if orig_img and orig_img.width() > 0:
+ ratio = new_w / orig_img.width()
+ new_h = max(1.0, orig_img.height() * ratio)
+ else:
+ # fallback: keep current aspect ratio if we have it
+ cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
+ cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
+ ratio = new_w / max(1.0, cur_w)
+ new_h = max(1.0, cur_h * ratio)
+
+ imgfmt.setWidth(max(1.0, new_w))
+ imgfmt.setHeight(max(1.0, new_h))
+ tc.mergeCharFormat(imgfmt)
+
+ def _scale_image_at_cursor(self, factor: float):
+ tc, imgfmt, orig = self._image_info_at_cursor()
+ if not imgfmt:
+ return
+ base_w = imgfmt.width()
+ if base_w <= 0 and orig:
+ base_w = orig.width()
+ if base_w <= 0:
+ return
+ self._apply_image_size(tc, imgfmt, base_w * factor, orig)
+
+ def _fit_image_to_editor_width(self):
+ tc, imgfmt, orig = self._image_info_at_cursor()
+ if not imgfmt:
+ return
+ if not self.viewport():
+ return
+ target = int(self.viewport().width() * 0.92)
+ self._apply_image_size(tc, imgfmt, target, orig)
+
+ def _set_image_width_dialog(self):
+ from PySide6.QtWidgets import QInputDialog
+
+ tc, imgfmt, orig = self._image_info_at_cursor()
+ if not imgfmt:
+ return
+ # propose current display width or original width
+ cur_w = (
+ int(imgfmt.width())
+ if imgfmt.width() > 0
+ else (orig.width() if orig else 400)
+ )
+ w, ok = QInputDialog.getInt(
+ self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
+ )
+ if ok:
+ self._apply_image_size(tc, imgfmt, float(w), orig)
+
+ def _reset_image_size(self):
+ tc, imgfmt, orig = self._image_info_at_cursor()
+ if not imgfmt or not orig:
+ return
+ self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
+
+ def contextMenuEvent(self, e):
+ menu = self.createStandardContextMenu()
+ tc, imgfmt, orig = self._image_info_at_cursor()
+ if imgfmt:
+ menu.addSeparator()
+ sub = menu.addMenu("Image size")
+ sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
+ sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
+ sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
+ sub.addAction("Set width…", self._set_image_width_dialog)
+ sub.addAction("Reset to original", self._reset_image_size)
+ menu.exec(e.globalPos())
+
+ def insertFromMimeData(self, source):
+ # 1) Direct image from clipboard
+ if source.hasImage():
+ img = self._to_qimage(source.imageData())
+ if img is not None:
+ self._insert_qimage_at_cursor(self, img, autoscale=True)
+ return
+
+ # 2) File URLs (drag/drop or paste)
+ if source.hasUrls():
+ paths = []
+ non_local_urls = []
+ for url in source.urls():
+ if url.isLocalFile():
+ path = url.toLocalFile()
+ if path.lower().endswith(self._IMAGE_EXTS):
+ paths.append(path)
+ else:
+ # Non-image file: insert as link
+ self.textCursor().insertHtml(
+ f'{Path(path).name}'
+ )
+ self.textCursor().insertBlock()
+ else:
+ non_local_urls.append(url)
+
+ if paths:
+ self.insert_images(paths)
+
+ for url in non_local_urls:
+ self.textCursor().insertHtml(
+ f'{url.toString()}'
+ )
+ self.textCursor().insertBlock()
+
+ if paths or non_local_urls:
+ return
+
+ # 3) HTML with data: image
+ if source.hasHtml():
+ html = source.html()
+ m = self._DATA_IMG_RX.search(html or "")
+ if m:
+ try:
+ data = base64.b64decode(m.group(1))
+ img = QImage.fromData(data)
+ if not img.isNull():
+ self._insert_qimage_at_cursor(self, img, autoscale=True)
+ return
+ except Exception:
+ pass # fall through
+
+ # 4) Everything else → default behavior
+ super().insertFromMimeData(source)
+
+ @Slot(list)
+ def insert_images(self, paths: list[str], autoscale=True):
+ """
+ Insert one or more images at the cursor. Large images can be auto-scaled
+ to fit the viewport width while preserving aspect ratio.
+ """
+ c = self.textCursor()
+
+ # Avoid dropping images inside a code frame
+ frame = self._find_code_frame(c)
+ if frame:
+ out = QTextCursor(self.document())
+ out.setPosition(frame.lastPosition())
+ self.setTextCursor(out)
+ c = self.textCursor()
+
+ # Ensure there's a paragraph break if we're mid-line
+ if c.positionInBlock() != 0:
+ c.insertBlock()
+
+ for path in paths:
+ reader = QImageReader(path)
+ img = reader.read()
+ if img.isNull():
+ continue
+
+ if autoscale and self.viewport():
+ max_w = int(self.viewport().width() * 0.92) # ~92% of editor width
+ if img.width() > max_w:
+ img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
+
+ c.insertImage(img)
+ c.insertBlock() # put each image on its own line
+
def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
href = self.anchorAt(e.pos())
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index df1726d..cbcb312 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -105,8 +105,8 @@ class _LockOverlay(QWidget):
class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
self.setWindowTitle(APP_NAME)
self.setMinimumSize(1000, 650)
@@ -160,6 +160,7 @@ class MainWindow(QMainWindow):
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history)
+ self.toolBar.insertImageRequested.connect(self._on_insert_image)
self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
self.editor.cursorPositionChanged.connect(self._sync_toolbar)
@@ -446,7 +447,7 @@ class MainWindow(QMainWindow):
"""
if not self._dirty and not explicit:
return
- text = self.editor.toHtml()
+ text = self.editor.to_html_with_embedded_images()
try:
self.db.save_new_version(date_iso, text, note)
except Exception as e:
@@ -489,6 +490,18 @@ class MainWindow(QMainWindow):
self._load_selected_date(date_iso)
self._refresh_calendar_marks()
+ def _on_insert_image(self):
+ # Let the user pick one or many images
+ paths, _ = QFileDialog.getOpenFileNames(
+ self,
+ "Insert image(s)",
+ "",
+ "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
+ )
+ if not paths:
+ return
+ self.editor.insert_images(paths) # call into the editor
+
# ----------- Settings handler ------------#
def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self)
diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py
index e9d2777..5d5c451 100644
--- a/bouquin/toolbar.py
+++ b/bouquin/toolbar.py
@@ -16,6 +16,7 @@ class ToolBar(QToolBar):
numbersRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal()
+ insertImageRequested = Signal()
def __init__(self, parent=None):
super().__init__("Format", parent)
@@ -86,6 +87,12 @@ class ToolBar(QToolBar):
self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
+ # Images
+ self.actInsertImg = QAction("Image", self)
+ self.actInsertImg.setToolTip("Insert image")
+ self.actInsertImg.setShortcut("Ctrl+Shift+I")
+ self.actInsertImg.triggered.connect(self.insertImageRequested)
+
# Alignment
self.actAlignL = QAction("L", self)
self.actAlignL.setToolTip("Align Left")
@@ -143,6 +150,7 @@ class ToolBar(QToolBar):
self.actNormal,
self.actBullets,
self.actNumbers,
+ self.actInsertImg,
self.actAlignL,
self.actAlignC,
self.actAlignR,
From c5e871294e2b1ec1d5328118a899bcd2b15aa1ff Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:15:27 +1100
Subject: [PATCH 005/254] Fix export_by_extension extra parameter bug
---
bouquin/main_window.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index cbcb312..1428384 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -627,7 +627,7 @@ If you want an encrypted backup, choose Backup instead of Export.
elif selected_filter.startswith("SQL"):
self.db.export_sql(filename)
else:
- self.db.export_by_extension(entries, filename)
+ self.db.export_by_extension(filename)
QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
except Exception as e:
From 7971b147cb6b8893cef961c08cefa686878fa1e0 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:15:43 +1100
Subject: [PATCH 006/254] Remove dialogs in history screen
---
bouquin/history_dialog.py | 12 ------------
1 file changed, 12 deletions(-)
diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py
index 9cccda7..5ee404c 100644
--- a/bouquin/history_dialog.py
+++ b/bouquin/history_dialog.py
@@ -167,22 +167,10 @@ class HistoryDialog(QDialog):
return
sel = self._db.get_version(version_id=sel_id)
vno = sel["version_no"]
- # Confirm
- if (
- QMessageBox.question(
- self,
- "Revert",
- f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
- QMessageBox.Yes | QMessageBox.No,
- )
- != QMessageBox.Yes
- ):
- return
# Flip head pointer
try:
self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e:
QMessageBox.critical(self, "Revert failed", str(e))
return
- QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
self.accept() # let the caller refresh the editor
From 358aeb0650715e5f03be5eed9423d24021e0911b Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:15:54 +1100
Subject: [PATCH 007/254] Update test command in README
---
README.md | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index ea1cbdc..3a9bba1 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,10 @@ There is deliberately no network connectivity or syncing intended.
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
+### From PyPi/pip
+
+ * `pip install bouquin`
+
### From source
* Clone this repo or download the tarball from the releases page
@@ -47,14 +51,9 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
* Download the whl and run it
-### From PyPi/pip
-
- * `pip install bouquin`
-
-
## How to run the tests
* Clone the repo
* Ensure you have poetry installed
* Run `poetry install --with test`
- * Run `poetry run pytest -vvv`
+ * Run `poetry run pytest -vvvv --cov=bouquin`
From 16856f403890711b0af838dba31ff698be8cd015 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:16:11 +1100
Subject: [PATCH 008/254] Ignore .coverage
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 8652982..2352872 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
__pycache__
.pytest_cache
dist
+.coverage
From ebb0fd6e11c53e1d682629c265bd55bbd465eee3 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:33:41 +1100
Subject: [PATCH 009/254] Fix self.key bug picked up by tests
---
bouquin/settings_dialog.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py
index 947deaa..0a3dfd8 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -232,7 +232,7 @@ class SettingsDialog(QDialog):
self.save_key_btn.setChecked(False)
self.save_key_btn.blockSignals(False)
return
- self.key = p1.key() or ""
+ self.key = p1.key() or ""
else:
self.key = ""
From a29fc9423edfdd6954eca386c727dd5cff5a8e21 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:33:54 +1100
Subject: [PATCH 010/254] Refactor tests
---
tests/__init__.py | 0
tests/conftest.py | 107 ++++++++-----
tests/qt_helpers.py | 287 ++++++++++++++++++++++++++++++++++
tests/test_e2e_actions.py | 55 +++++++
tests/test_editor.py | 134 ++++++++++++++++
tests/test_export_backup.py | 112 +++++++++++++
tests/test_search_history.py | 110 +++++++++++++
tests/test_settings_dialog.py | 252 +++++++++++++++++++++++++++++
tests/test_toolbar_styles.py | 55 +++++++
tests/test_ui.py | 78 ---------
10 files changed, 1069 insertions(+), 121 deletions(-)
create mode 100644 tests/__init__.py
create mode 100644 tests/qt_helpers.py
create mode 100644 tests/test_e2e_actions.py
create mode 100644 tests/test_editor.py
create mode 100644 tests/test_export_backup.py
create mode 100644 tests/test_search_history.py
create mode 100644 tests/test_settings_dialog.py
create mode 100644 tests/test_toolbar_styles.py
delete mode 100644 tests/test_ui.py
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
index 7f51d8c..1900f40 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,56 +1,77 @@
import os
+import sys
+from pathlib import Path
import pytest
+from PySide6.QtCore import QStandardPaths
+from tests.qt_helpers import AutoResponder
-# Run Qt without a visible display (CI-safe)
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+# Force Qt *non-native* file dialog so we can type a filename programmatically.
+os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0")
+# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env
+# os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-@pytest.fixture
-def fake_key_prompt_cls():
- """A KeyPrompt stand-in that immediately returns Accepted with a fixed key."""
- from PySide6.QtWidgets import QDialog
-
- class FakeKeyPrompt:
- accepted_count = 0
-
- def __init__(self, *a, **k):
- self._key = "sekret"
-
- def exec(self):
- FakeKeyPrompt.accepted_count += 1
- return QDialog.Accepted
-
- def key(self):
- return self._key
-
- return FakeKeyPrompt
+# Make project importable
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
-@pytest.fixture
-def fake_db_cls():
- """In-memory DB fake that mimics the subset of DBManager used by the UI."""
- class FakeDB:
- def __init__(self, cfg):
- self.cfg = cfg
- self.data = {}
- self.connected_key = None
- self.closed = False
+@pytest.fixture(scope="session", autouse=True)
+def enable_qstandardpaths_test_mode():
+ QStandardPaths.setTestModeEnabled(True)
- def connect(self):
- # record the key that UI supplied
- self.connected_key = self.cfg.key
- return True
- def get_entry(self, date_iso: str) -> str:
- return self.data.get(date_iso, "")
+@pytest.fixture()
+def temp_home(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ (home / "Documents").mkdir(parents=True, exist_ok=True)
+ monkeypatch.setenv("HOME", str(home))
+ return home
- def upsert_entry(self, date_iso: str, content: str) -> None:
- self.data[date_iso] = content
- def dates_with_content(self) -> list[str]:
- return [d for d, t in self.data.items() if t.strip()]
+@pytest.fixture()
+def clean_settings():
+ try:
+ from bouquin.settings import APP_NAME, APP_ORG
+ from PySide6.QtCore import QSettings
+ except Exception:
+ yield
+ return
+ s = QSettings(APP_ORG, APP_NAME)
+ s.clear()
+ yield
+ s.clear()
- def close(self) -> None:
- self.closed = True
- return FakeDB
+@pytest.fixture(autouse=True)
+def auto_accept_common_dialogs(qtbot):
+ ar = AutoResponder()
+ ar.start()
+ try:
+ yield
+ finally:
+ ar.stop()
+
+
+@pytest.fixture()
+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()
+ qtbot.addWidget(win)
+ win.show()
+ qtbot.waitExposed(win)
+
+ # Immediately satisfy first-run 'Set key' or 'Unlock' prompts if already visible
+ AutoResponder().prehandle_key_prompts_if_present()
+ return win
+
+
+@pytest.fixture()
+def today_iso():
+ from datetime import date
+
+ d = date.today()
+ return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py
new file mode 100644
index 0000000..1b9b9a3
--- /dev/null
+++ b/tests/qt_helpers.py
@@ -0,0 +1,287 @@
+import time
+from pathlib import Path
+
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtGui import QAction
+from PySide6.QtTest import QTest
+from PySide6.QtWidgets import (
+ QApplication,
+ QWidget,
+ QDialog,
+ QFileDialog,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPushButton,
+ QAbstractButton,
+ QListWidget,
+)
+
+# ---------- robust widget finders ----------
+
+
+def _visible_widgets():
+ for w in QApplication.topLevelWidgets():
+ if w.isVisible():
+ yield w
+ for c in w.findChildren(QWidget):
+ if c.isWindow() and c.isVisible():
+ yield c
+
+
+def wait_for_widget(cls=None, predicate=lambda w: True, timeout_ms: int = 15000):
+ deadline = time.time() + timeout_ms / 1000.0
+ while time.time() < deadline:
+ for w in _visible_widgets():
+ if (cls is None or isinstance(w, cls)) and predicate(w):
+ return w
+ QTest.qWait(25)
+ raise TimeoutError(f"Timed out waiting for {cls} matching predicate")
+
+
+# ---------- generic ui helpers ----------
+
+
+def click_button_by_text(container: QWidget, contains: str) -> bool:
+ """Click any QAbstractButton whose label contains the substring."""
+ target = contains.lower()
+ for btn in container.findChildren(QAbstractButton):
+ text = (btn.text() or "").lower()
+ if target in text:
+ from PySide6.QtTest import QTest
+
+ if not btn.isEnabled():
+ QTest.qWait(50) # give UI a tick to enable
+ QTest.mouseClick(btn, Qt.LeftButton)
+ return True
+ return False
+
+
+def _first_line_edit(dlg: QDialog) -> QLineEdit | None:
+ edits = dlg.findChildren(QLineEdit)
+ return edits[0] if edits else None
+
+
+def fill_first_line_edit_and_accept(dlg: QDialog, text: str | None):
+ le = _first_line_edit(dlg)
+ assert le is not None, "Expected a QLineEdit in the dialog"
+ if text is not None:
+ le.clear()
+ QTest.keyClicks(le, text)
+ # Prefer 'OK'; fallback to Return
+ ok = None
+ for btn in dlg.findChildren(QPushButton):
+ t = btn.text().lower().lstrip("&")
+ if t == "ok" or btn.isDefault():
+ ok = btn
+ break
+ if ok:
+ QTest.mouseClick(ok, Qt.LeftButton)
+ else:
+ QTest.keyClick(le, Qt.Key_Return)
+
+
+def accept_all_message_boxes(limit: int = 5) -> bool:
+ """
+ Accept every visible QMessageBox, preferring Yes/Accept/Ok.
+ Returns True if at least one box was accepted.
+ """
+ accepted_any = False
+ for _ in range(limit):
+ accepted_this_round = False
+ for w in _visible_widgets():
+ if isinstance(w, QMessageBox) and w.isVisible():
+ # Prefer "Yes", then any Accept/Apply role, then Ok, then default/first.
+ btn = (
+ w.button(QMessageBox.Yes)
+ or next(
+ (
+ b
+ for b in w.buttons()
+ if w.buttonRole(b)
+ in (
+ QMessageBox.YesRole,
+ QMessageBox.AcceptRole,
+ QMessageBox.ApplyRole,
+ )
+ ),
+ None,
+ )
+ or w.button(QMessageBox.Ok)
+ or w.defaultButton()
+ or (w.buttons()[0] if w.buttons() else None)
+ )
+ if btn:
+ QTest.mouseClick(btn, Qt.LeftButton)
+ accepted_this_round = True
+ accepted_any = True
+ if not accepted_this_round:
+ break
+ QTest.qWait(30) # give the next box a tick to appear
+ return accepted_any
+
+
+def trigger_menu_action(win, text_contains: str) -> QAction:
+ for act in win.findChildren(QAction):
+ if text_contains in act.text():
+ act.trigger()
+ return act
+ raise AssertionError(f"Action containing '{text_contains}' not found")
+
+
+def find_line_edit_by_placeholder(container: QWidget, needle: str) -> QLineEdit | None:
+ n = needle.lower()
+ for le in container.findChildren(QLineEdit):
+ if n in (le.placeholderText() or "").lower():
+ return le
+ return None
+
+
+class AutoResponder:
+ def __init__(self):
+ self._seen: set[int] = set()
+ self._timer = QTimer()
+ self._timer.setInterval(50)
+ self._timer.timeout.connect(self._tick)
+
+ def start(self):
+ self._timer.start()
+
+ def stop(self):
+ self._timer.stop()
+
+ def prehandle_key_prompts_if_present(self):
+ for w in _visible_widgets():
+ if isinstance(w, QDialog) and (
+ _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w)
+ ):
+ fill_first_line_edit_and_accept(w, "ci-secret-key")
+
+ def _tick(self):
+ if accept_all_message_boxes(limit=3):
+ return
+
+ for w in _visible_widgets():
+ if not isinstance(w, QDialog) or not w.isVisible():
+ continue
+
+ wid = id(w)
+ # Handle first-run / unlock / save-name prompts (your existing branches)
+ 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)
+ continue
+
+ if _looks_like_save_version_dialog(w):
+ fill_first_line_edit_and_accept(w, None)
+ self._seen.add(wid)
+ continue
+
+ if _is_history_dialog(w):
+ # Don't mark as seen until we've actually clicked the button.
+ if _click_revert_in_history(w):
+ accept_all_message_boxes(limit=5)
+ self._seen.add(wid)
+ continue
+
+
+# ---------- dialog classifiers ----------
+
+
+def _looks_like_set_key_dialog(dlg: QDialog) -> bool:
+ labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
+ title = (dlg.windowTitle() or "").lower()
+ has_line = bool(dlg.findChildren(QLineEdit))
+ return has_line and (
+ "set an encryption key" in title
+ or "create a strong passphrase" in labels
+ or "encrypts your data" in labels
+ )
+
+
+def _looks_like_unlock_dialog(dlg: QDialog) -> bool:
+ labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
+ title = (dlg.windowTitle() or "").lower()
+ has_line = bool(dlg.findChildren(QLineEdit))
+ return has_line and ("unlock" in labels or "unlock" in title) and "key" in labels
+
+
+# ---------- version prompt ----------
+def _looks_like_save_version_dialog(dlg: QDialog) -> bool:
+ labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
+ title = (dlg.windowTitle() or "").lower()
+ has_line = bool(dlg.findChildren(QLineEdit))
+ return has_line and (
+ "enter a name" in labels or "name for this version" in labels or "save" in title
+ )
+
+
+# ---------- QFileDialog driver ----------
+
+
+def drive_qfiledialog_save(path: Path, name_filter: str | None = None):
+ dlg = wait_for_widget(QFileDialog, timeout_ms=20000)
+ if name_filter:
+ try:
+ dlg.selectNameFilter(name_filter)
+ except Exception:
+ pass
+
+ # Prefer typing in the filename edit so Save enables on all styles
+ filename_edit = None
+ for le in dlg.findChildren(QLineEdit):
+ if le.echoMode() == QLineEdit.Normal:
+ filename_edit = le
+ break
+
+ if filename_edit is not None:
+ filename_edit.clear()
+ QTest.keyClicks(filename_edit, str(path))
+ # Return usually triggers Save in non-native dialogs
+ QTest.keyClick(filename_edit, Qt.Key_Return)
+ else:
+ dlg.selectFile(str(path))
+ QTimer.singleShot(0, dlg.accept)
+
+ # Some themes still need an explicit Save click
+ _ = click_button_by_text(dlg, "save")
+
+
+def _is_history_dialog(dlg: QDialog) -> bool:
+ if not isinstance(dlg, QDialog) or not dlg.isVisible():
+ return False
+ title = (dlg.windowTitle() or "").lower()
+ if "history" in title:
+ return True
+ return bool(dlg.findChildren(QListWidget))
+
+
+def _click_revert_in_history(dlg: QDialog) -> bool:
+ """
+ Returns True if we successfully clicked an enabled 'Revert' button.
+ Ensures a row is actually clicked first so the button enables.
+ """
+ lists = dlg.findChildren(QListWidget)
+ if not lists:
+ return False
+ versions = max(lists, key=lambda lw: lw.count())
+ if versions.count() < 2:
+ return False
+
+ # Click the older row (index 1); real click so the dialog enables the button.
+ from PySide6.QtTest import QTest
+ from PySide6.QtCore import Qt
+
+ rect = versions.visualItemRect(versions.item(1))
+ QTest.mouseClick(versions.viewport(), Qt.LeftButton, pos=rect.center())
+ QTest.qWait(60)
+
+ # Find any enabled button that looks like "revert"
+ for btn in dlg.findChildren(QAbstractButton):
+ meta = " ".join(
+ [(btn.text() or ""), (btn.toolTip() or ""), (btn.objectName() or "")]
+ ).lower()
+ if "revert" in meta and btn.isEnabled():
+ QTest.mouseClick(btn, Qt.LeftButton)
+ return True
+ return False
diff --git a/tests/test_e2e_actions.py b/tests/test_e2e_actions.py
new file mode 100644
index 0000000..55f7ae5
--- /dev/null
+++ b/tests/test_e2e_actions.py
@@ -0,0 +1,55 @@
+from PySide6.QtCore import QUrl, QObject, Slot
+from PySide6.QtGui import QDesktopServices
+from PySide6.QtTest import QTest
+from tests.qt_helpers import trigger_menu_action
+
+
+def test_launch_write_save_and_navigate(open_window, qtbot, today_iso):
+ win = open_window
+ win.editor.setPlainText("Hello Bouquin")
+ qtbot.waitUntil(lambda: win.editor.toPlainText() == "Hello Bouquin", timeout=15000)
+
+ trigger_menu_action(win, "Save a version") # AutoResponder clicks OK
+
+ versions = win.db.list_versions(today_iso)
+ assert versions and versions[0]["is_current"] == 1
+
+ selected = win.calendar.selectedDate()
+ trigger_menu_action(win, "Next Day")
+ qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected.addDays(1))
+ trigger_menu_action(win, "Previous Day")
+ qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected)
+ win.calendar.setSelectedDate(selected.addDays(3))
+ trigger_menu_action(win, "Today")
+ qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected)
+
+
+def test_help_menu_opens_urls(open_window, qtbot):
+ opened: list[str] = []
+
+ class UrlCatcher(QObject):
+ @Slot(QUrl)
+ def handle(self, url: QUrl):
+ opened.append(url.toString())
+
+ catcher = UrlCatcher()
+ # Qt6/PySide6: setUrlHandler(scheme, receiver, methodName)
+ QDesktopServices.setUrlHandler("https", catcher, "handle")
+ QDesktopServices.setUrlHandler("http", catcher, "handle")
+ try:
+ win = open_window
+ trigger_menu_action(win, "Documentation")
+ trigger_menu_action(win, "Report a bug")
+ QTest.qWait(150)
+ assert len(opened) >= 2
+ finally:
+ QDesktopServices.unsetUrlHandler("https")
+ QDesktopServices.unsetUrlHandler("http")
+
+
+def test_idle_lock_and_unlock(open_window, qtbot):
+ win = open_window
+ win._enter_lock()
+ assert getattr(win, "_locked", False) is True
+ win._on_unlock_clicked() # AutoResponder types 'ci-secret-key'
+ qtbot.waitUntil(lambda: getattr(win, "_locked", True) is False, timeout=15000)
diff --git a/tests/test_editor.py b/tests/test_editor.py
new file mode 100644
index 0000000..cd5855d
--- /dev/null
+++ b/tests/test_editor.py
@@ -0,0 +1,134 @@
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat
+from PySide6.QtTest import QTest
+
+from bouquin.editor import Editor
+
+
+def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
+ c = editor.textCursor()
+ c.movePosition(QTextCursor.Start)
+ while True:
+ c2 = QTextCursor(c)
+ c2.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
+ if c2.position() == c.position():
+ break
+ fmt = c2.charFormat()
+ if fmt.isImageFormat():
+ editor.setTextCursor(c2)
+ return QTextImageFormat(fmt)
+ c.movePosition(QTextCursor.Right)
+ return None
+
+
+def test_embed_qimage_saved_as_data_url(qtbot):
+ e = Editor()
+ e.resize(600, 400)
+ qtbot.addWidget(e)
+ e.show()
+ qtbot.waitExposed(e)
+
+ img = QImage(60, 40, QImage.Format_ARGB32)
+ img.fill(0xFF336699)
+ e._insert_qimage_at_cursor(img, autoscale=False)
+
+ html = e.to_html_with_embedded_images()
+ assert "data:image/png;base64," in html
+
+
+def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
+ # Create a very wide image so autoscale triggers
+ big = QImage(2000, 800, QImage.Format_ARGB32)
+ big.fill(0xFF00FF00)
+ big_path = tmp_path / "big.png"
+ big.save(str(big_path))
+
+ e = Editor()
+ e.resize(420, 300) # known viewport width
+ qtbot.addWidget(e)
+ e.show()
+ qtbot.waitExposed(e)
+
+ e.insert_images([str(big_path)], autoscale=True)
+
+ # Cursor lands after the image + a blank block; helper will select the image char
+ fmt = _move_cursor_to_first_image(e)
+ assert fmt is not None
+
+ # After autoscale, width should be <= ~92% of viewport
+ max_w = int(e.viewport().width() * 0.92)
+ assert fmt.width() <= max_w + 1 # allow off-by-1 from rounding
+
+ # Now exercise "fit to editor width"
+ e._fit_image_to_editor_width()
+ _tc, fmt2, _orig = e._image_info_at_cursor()
+ assert fmt2 is not None
+ assert abs(fmt2.width() - max_w) <= 1
+
+
+def test_linkify_trims_trailing_punctuation(qtbot):
+ e = Editor()
+ qtbot.addWidget(e)
+ e.show()
+ qtbot.waitExposed(e)
+
+ e.setPlainText("See (https://example.com).")
+ # Wait until linkification runs (connected to textChanged)
+ qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
+
+ html = e.document().toHtml()
+ # Anchor should *not* include the closing ')'
+ assert 'href="https://example.com"' in html
+ 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()
+ qtbot.addWidget(e)
+ e.show()
+ qtbot.waitExposed(e)
+
+ e.setPlainText("code")
+ c = e.textCursor()
+ c.select(QTextCursor.BlockUnderCursor)
+ e.setTextCursor(c)
+ e.apply_code()
+
+ # Put caret at end of the code block, then Enter to create an empty line *inside* the frame
+ c = e.textCursor()
+ c.movePosition(QTextCursor.EndOfBlock)
+ e.setTextCursor(c)
+
+ QTest.keyClick(e, Qt.Key_Return)
+ # Ensure we are on an empty block *inside* the code frame
+ qtbot.waitUntil(
+ lambda: e._find_code_frame(e.textCursor()) is not None
+ and e.textCursor().block().length() == 1
+ )
+
+ # 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)
diff --git a/tests/test_export_backup.py b/tests/test_export_backup.py
new file mode 100644
index 0000000..ec000e8
--- /dev/null
+++ b/tests/test_export_backup.py
@@ -0,0 +1,112 @@
+import csv, json, sqlite3
+
+import pytest
+
+from tests.qt_helpers import trigger_menu_action, accept_all_message_boxes
+
+# Export filters used by the app (format is chosen by this name filter, not by extension)
+EXPORT_FILTERS = {
+ ".txt": "Text (*.txt)",
+ ".json": "JSON (*.json)",
+ ".csv": "CSV (*.csv)",
+ ".html": "HTML (*.html)",
+ ".sql": "SQL (*.sql)", # app writes a SQLite DB here
+}
+BACKUP_FILTER = "SQLCipher (*.db)"
+
+
+def _write_sample_entries(win, qtbot):
+ win.editor.setPlainText("alpha bold")
+ win._save_current(explicit=True)
+ d = win.calendar.selectedDate().addDays(1)
+ win.calendar.setSelectedDate(d)
+ win.editor.setPlainText("beta text")
+ win._save_current(explicit=True)
+
+
+@pytest.mark.parametrize(
+ "ext,verifier",
+ [
+ (".txt", lambda p: p.read_text(encoding="utf-8").strip()),
+ (".json", lambda p: json.loads(p.read_text(encoding="utf-8"))),
+ (".csv", lambda p: list(csv.reader(p.open("r", encoding="utf-8-sig")))),
+ (".html", lambda p: p.read_text(encoding="utf-8")),
+ (".sql", lambda p: p),
+ ],
+)
+def test_export_all_formats(open_window, qtbot, tmp_path, ext, verifier, monkeypatch):
+ win = open_window
+ _write_sample_entries(win, qtbot)
+
+ out = tmp_path / f"export_test{ext}"
+
+ # 1) Short-circuit the file dialog so it returns our path + the filter we want.
+ from PySide6.QtWidgets import QFileDialog
+
+ def fake_getSaveFileName(*args, **kwargs):
+ return (str(out), EXPORT_FILTERS[ext])
+
+ monkeypatch.setattr(
+ QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
+ )
+
+ # 2) Kick off the export
+ trigger_menu_action(win, "Export")
+
+ # 3) Click through the "unencrypted export" warning
+ accept_all_message_boxes()
+
+ # 4) Wait for the file to appear (export happens synchronously after the stub)
+ qtbot.waitUntil(out.exists, timeout=5000)
+
+ # 5) Dismiss the "Export complete" info box so it can't block later tests
+ accept_all_message_boxes()
+
+ # 6) Assert as before
+ val = verifier(out)
+ if ext == ".json":
+ assert isinstance(val, list) and all(
+ "date" in d and "content" in d for d in val
+ )
+ elif ext == ".csv":
+ flat = [cell for row in val for cell in row]
+ assert any("alpha" in c for c in flat) and any("beta" in c for c in flat)
+ elif ext == ".html":
+ lower = val.lower()
+ assert " 0:
+ # Click until we land on d2
+ landed = False
+ for i in range(results.count()):
+ item = results.item(i)
+ rect = results.visualItemRect(item)
+ QTest.mouseDClick(results.viewport(), Qt.LeftButton, pos=rect.center())
+ qtbot.wait(120)
+ if win.calendar.selectedDate() == d2:
+ landed = True
+ break
+ assert landed, "Search results did not navigate to the expected date"
+ else:
+ assert "target" in win.editor.toPlainText().lower()
+
+
+def test_history_dialog_revert(open_window, qtbot):
+ win = open_window
+
+ # Create two versions on the current day
+ win.editor.setPlainText("v1 text")
+ win._save_current(explicit=True)
+ win.editor.setPlainText("v2 text")
+ win._save_current(explicit=True)
+
+ # Open the History UI (label varies)
+ try:
+ trigger_menu_action(win, "View History")
+ except AssertionError:
+ trigger_menu_action(win, "History")
+
+ # Find ANY top-level window that looks like the History dialog
+ def _is_history(w: QWidget):
+ if not w.isWindow() or not w.isVisible():
+ return False
+ title = (w.windowTitle() or "").lower()
+ return "history" in title or bool(w.findChildren(QListWidget))
+
+ hist = wait_for_widget(QWidget, predicate=_is_history, timeout_ms=15000)
+
+ # Wait for population and pick the list with the most items
+ chosen = None
+ for _ in range(120): # up to ~3s
+ lists = hist.findChildren(QListWidget)
+ if lists:
+ chosen = max(lists, key=lambda lw: lw.count())
+ if chosen.count() >= 2:
+ break
+ QTest.qWait(25)
+
+ assert (
+ chosen is not None and chosen.count() >= 2
+ ), "History list never populated with 2+ versions"
+
+ # Click the older version row so the Revert button enables
+ idx = 1 # row 0 is most-recent "v2 text", row 1 is "v1 text"
+ rect = chosen.visualItemRect(chosen.item(idx))
+ QTest.mouseClick(chosen.viewport(), Qt.LeftButton, pos=rect.center())
+ QTest.qWait(100)
+
+ # Find any enabled button whose text/tooltip/objectName contains 'revert'
+ revert_btn = None
+ for _ in range(120): # wait until it enables
+ for btn in hist.findChildren(QAbstractButton):
+ meta = " ".join(
+ [btn.text() or "", btn.toolTip() or "", btn.objectName() or ""]
+ ).lower()
+ if "revert" in meta:
+ revert_btn = btn
+ break
+ if revert_btn and revert_btn.isEnabled():
+ break
+ QTest.qWait(25)
+
+ assert (
+ revert_btn is not None and revert_btn.isEnabled()
+ ), "Revert button not found/enabled"
+ QTest.mouseClick(revert_btn, Qt.LeftButton)
+
+ # AutoResponder will accept confirm/success boxes
+ QTest.qWait(150)
+ assert "v1 text" in win.editor.toPlainText()
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
new file mode 100644
index 0000000..f300c6f
--- /dev/null
+++ b/tests/test_settings_dialog.py
@@ -0,0 +1,252 @@
+from pathlib import Path
+
+from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
+
+from bouquin.db import DBConfig
+from bouquin.settings_dialog import SettingsDialog
+
+
+class FakeDB:
+ def __init__(self):
+ self.rekey_called_with = None
+ self.compact_called = False
+ self.fail_compact = False
+
+ def rekey(self, key: str):
+ self.rekey_called_with = key
+
+ def compact(self):
+ if self.fail_compact:
+ raise RuntimeError("boom")
+ self.compact_called = True
+
+
+class AcceptingPrompt:
+ def __init__(self, parent=None, title="", message=""):
+ self._key = ""
+ self._accepted = True
+
+ def set_key(self, k: str):
+ self._key = k
+ return self
+
+ def exec(self):
+ return QDialog.Accepted if self._accepted else QDialog.Rejected
+
+ def key(self):
+ return self._key
+
+
+class RejectingPrompt(AcceptingPrompt):
+ def __init__(self, *a, **k):
+ super().__init__()
+ self._accepted = False
+
+
+def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
+ db = FakeDB()
+ cfg = DBConfig(path=tmp_path / "db.sqlite", key="", idle_minutes=15)
+
+ saved = {}
+
+ def fake_save(cfg2):
+ saved["cfg"] = cfg2
+
+ monkeypatch.setattr("bouquin.settings_dialog.save_db_config", fake_save)
+
+ # Drive the "remember key" checkbox via the prompt (no pre-set key)
+ p = AcceptingPrompt().set_key("sekrit")
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
+
+ dlg = SettingsDialog(cfg, db)
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ # Change fields
+ new_path = tmp_path / "new.sqlite"
+ dlg.path_edit.setText(str(new_path))
+ dlg.idle_spin.setValue(0)
+
+ # User toggles "Remember key" -> stores prompted key
+ dlg.save_key_btn.setChecked(True)
+
+ dlg._save()
+
+ out = saved["cfg"]
+ assert out.path == new_path
+ assert out.idle_minutes == 0
+ assert out.key == "sekrit"
+
+
+def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot):
+ # When toggled on with no key yet, it prompts; cancelling should revert the check
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt)
+
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ assert dlg.key == ""
+ dlg.save_key_btn.click() # toggles True -> triggers prompt which rejects
+ assert dlg.save_key_btn.isChecked() is False
+ assert dlg.key == ""
+
+
+def test_save_key_checkbox_accepts_and_stores_key(monkeypatch, qtbot):
+ # Toggling on with an accepting prompt should store the typed key
+ p = AcceptingPrompt().set_key("remember-me")
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
+
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg.save_key_btn.click()
+ assert dlg.save_key_btn.isChecked() is True
+ assert dlg.key == "remember-me"
+
+
+def test_change_key_success(monkeypatch, qtbot):
+ # Two prompts returning the same non-empty key -> rekey() and info message
+ p1 = AcceptingPrompt().set_key("newkey")
+ p2 = AcceptingPrompt().set_key("newkey")
+ seq = [p1, p2]
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
+
+ shown = {"info": 0}
+ monkeypatch.setattr(
+ QMessageBox,
+ "information",
+ lambda *a, **k: shown.__setitem__("info", shown["info"] + 1),
+ )
+
+ db = FakeDB()
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg._change_key()
+
+ assert db.rekey_called_with == "newkey"
+ assert shown["info"] >= 1
+ assert dlg.key == "newkey"
+
+
+def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot):
+ p1 = AcceptingPrompt().set_key("a")
+ p2 = AcceptingPrompt().set_key("b")
+ seq = [p1, p2]
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
+
+ called = {"warn": 0}
+ monkeypatch.setattr(
+ QMessageBox,
+ "warning",
+ lambda *a, **k: called.__setitem__("warn", called["warn"] + 1),
+ )
+
+ db = FakeDB()
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg._change_key()
+
+ assert db.rekey_called_with is None
+ assert called["warn"] >= 1
+
+
+def test_change_key_empty_shows_warning(monkeypatch, qtbot):
+ p1 = AcceptingPrompt().set_key("")
+ p2 = AcceptingPrompt().set_key("")
+ seq = [p1, p2]
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
+
+ called = {"warn": 0}
+ monkeypatch.setattr(
+ QMessageBox,
+ "warning",
+ lambda *a, **k: called.__setitem__("warn", called["warn"] + 1),
+ )
+
+ db = FakeDB()
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg._change_key()
+
+ assert db.rekey_called_with is None
+ assert called["warn"] >= 1
+
+
+def test_browse_sets_path(monkeypatch, qtbot, tmp_path):
+ def fake_get_save_file_name(*a, **k):
+ return (str(tmp_path / "picked.sqlite"), "")
+
+ monkeypatch.setattr(
+ QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name)
+ )
+
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg._browse()
+ assert dlg.path_edit.text().endswith("picked.sqlite")
+
+
+def test_compact_success_and_failure(monkeypatch, qtbot):
+ shown = {"info": 0, "crit": 0}
+ monkeypatch.setattr(
+ QMessageBox,
+ "information",
+ lambda *a, **k: shown.__setitem__("info", shown["info"] + 1),
+ )
+ monkeypatch.setattr(
+ QMessageBox,
+ "critical",
+ lambda *a, **k: shown.__setitem__("crit", shown["crit"] + 1),
+ )
+
+ db = FakeDB()
+ dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg._compact_btn_clicked()
+ assert db.compact_called is True
+ assert shown["info"] >= 1
+
+ # Failure path
+ db2 = FakeDB()
+ db2.fail_compact = True
+ dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2)
+ qtbot.addWidget(dlg2)
+ dlg2.show()
+ qtbot.waitExposed(dlg2)
+
+ dlg2._compact_btn_clicked()
+ assert shown["crit"] >= 1
+
+
+def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot):
+ p = AcceptingPrompt().set_key("already")
+ monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
+
+ dlg = SettingsDialog(DBConfig(Path("x"), key="already"), FakeDB())
+ qtbot.addWidget(dlg)
+ dlg.show()
+ qtbot.waitExposed(dlg)
+
+ dlg.save_key_btn.setChecked(True)
+ # We should reach here with the original key preserved.
+ assert dlg.key == "already"
diff --git a/tests/test_toolbar_styles.py b/tests/test_toolbar_styles.py
new file mode 100644
index 0000000..7116d21
--- /dev/null
+++ b/tests/test_toolbar_styles.py
@@ -0,0 +1,55 @@
+from PySide6.QtGui import QTextCursor, QFont
+from PySide6.QtCore import Qt
+from PySide6.QtTest import QTest
+
+
+def test_toggle_basic_char_styles(open_window, qtbot):
+ win = open_window
+ win.editor.setPlainText("style")
+ c = win.editor.textCursor()
+ c.select(QTextCursor.Document)
+ win.editor.setTextCursor(c)
+ win.toolBar.actBold.trigger()
+ assert win.editor.currentCharFormat().fontWeight() == QFont.Weight.Bold
+ win.toolBar.actItalic.trigger()
+ assert win.editor.currentCharFormat().fontItalic() is True
+ win.toolBar.actUnderline.trigger()
+ assert win.editor.currentCharFormat().fontUnderline() is True
+ win.toolBar.actStrike.trigger()
+ assert win.editor.currentCharFormat().fontStrikeOut() is True
+
+
+def test_headings_lists_and_alignment(open_window, qtbot):
+ win = open_window
+ win.editor.setPlainText("Heading\nSecond line")
+ c = win.editor.textCursor()
+ c.select(QTextCursor.LineUnderCursor)
+ win.editor.setTextCursor(c)
+
+ sizes = []
+ for attr in ("actH1", "actH2", "actH3"):
+ if hasattr(win.toolBar, attr):
+ getattr(win.toolBar, attr).trigger()
+ QTest.qWait(45) # let the format settle to avoid segfaults on some styles
+ sizes.append(win.editor.currentCharFormat().fontPointSize())
+ assert len(sizes) >= 2 and all(
+ a > b for a, b in zip(sizes, sizes[1:])
+ ), f"Heading sizes not decreasing: {sizes}"
+
+ win.toolBar.actCode.trigger()
+ QTest.qWait(45)
+
+ win.toolBar.actBullets.trigger()
+ QTest.qWait(45)
+ win.toolBar.actNumbers.trigger()
+ QTest.qWait(45)
+
+ win.toolBar.actAlignC.trigger()
+ QTest.qWait(45)
+ assert int(win.editor.alignment()) & int(Qt.AlignHCenter)
+ win.toolBar.actAlignR.trigger()
+ QTest.qWait(45)
+ assert int(win.editor.alignment()) & int(Qt.AlignRight)
+ win.toolBar.actAlignL.trigger()
+ QTest.qWait(45)
+ assert int(win.editor.alignment()) & int(Qt.AlignLeft)
diff --git a/tests/test_ui.py b/tests/test_ui.py
deleted file mode 100644
index 280a01a..0000000
--- a/tests/test_ui.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# tests/test_main_window.py
-import pytest
-
-
-@pytest.fixture
-def patched_main_window(monkeypatch, qtbot, fake_db_cls, fake_key_prompt_cls):
- """Construct MainWindow with faked DB + KeyPrompt so tests are deterministic."""
- mw_mod = pytest.importorskip("bouquin.main_window")
- # Swap DBManager with in-memory fake
- monkeypatch.setattr(mw_mod, "DBManager", fake_db_cls, raising=True)
- # Make the unlock dialog auto-accept with a known key
- monkeypatch.setattr(mw_mod, "KeyPrompt", fake_key_prompt_cls, raising=True)
-
- MainWindow = mw_mod.MainWindow
- win = MainWindow()
- qtbot.addWidget(win)
- win.show()
- return win, mw_mod, fake_db_cls, fake_key_prompt_cls
-
-
-def test_always_prompts_for_key_and_uses_it(patched_main_window):
- win, mw_mod, FakeDB, FakeKP = patched_main_window
- # The fake DB instance is on win.db; it records the key provided by the UI flow
- assert isinstance(win.db, FakeDB)
- assert win.db.connected_key == "sekret"
- assert FakeKP.accepted_count >= 1 # was prompted at startup
-
-
-def test_manual_save_current_day(patched_main_window, qtbot):
- win, *_ = patched_main_window
-
- # Type into the editor and save
- win.editor.setHtml("Test note")
- win._save_current(explicit=True) # call directly to avoid waiting timers
-
- day = win._current_date_iso()
- assert "Test note" in win.db.get_entry(day)
-
-
-def test_switch_day_saves_previous(patched_main_window, qtbot):
- from PySide6.QtCore import QDate
-
- win, *_ = patched_main_window
-
- # Write on Day 1
- d1 = win.calendar.selectedDate()
- d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}"
- win.editor.setHtml("Notes day 1")
-
- # Trigger a day change (this path calls _on_date_changed via signal)
- d2 = d1.addDays(1)
- win.calendar.setSelectedDate(d2)
- # After changing, previous day should be saved; editor now shows day 2 content (empty)
- assert "Notes day 1" in win.db.get_entry(d1_iso)
- assert win.editor.toPlainText() == ""
-
-
-def test_calendar_marks_refresh(patched_main_window, qtbot):
- from PySide6.QtCore import QDate
- from PySide6.QtGui import QTextCharFormat, QFont
-
- win, *_ = patched_main_window
-
- # Put content on two dates and refresh marks
- today = win.calendar.selectedDate()
- win.db.upsert_entry(f"{today.year():04d}-{today.month():02d}-{today.day():02d}", "x")
- another = today.addDays(2)
- win.db.upsert_entry(f"{another.year():04d}-{another.month():02d}-{another.day():02d}", "y")
-
- win._refresh_calendar_marks()
-
- fmt_today = win.calendar.dateTextFormat(today)
- fmt_other = win.calendar.dateTextFormat(another)
-
- # Both should be bold (DemiBold or Bold depending on platform); we just assert non-Normal
- assert fmt_today.fontWeight() != QFont.Weight.Normal
- assert fmt_other.fontWeight() != QFont.Weight.Normal
-
From 98e6a0ebaee7ea9438f62e33b50bc040623f06ba Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:34:04 +1100
Subject: [PATCH 011/254] v0.1.9
---
CHANGELOG.md | 1 +
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e69fc13..c560986 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
* Add Backup option (database remains encrypted with SQLCipher)
* Add ability to run VACUUM (compact) on the database in settings
* Add ability to store images in the page
+ * Lots more tests, over 80% coverage
# 0.1.8
diff --git a/pyproject.toml b/pyproject.toml
index 8e30123..30c541a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
-version = "0.1.8"
+version = "0.1.9"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq "]
readme = "README.md"
From 6fb465c5464b4bca1f57d61567de0eb7a16f6c56 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Tue, 4 Nov 2025 18:36:35 +1100
Subject: [PATCH 012/254] README updates
---
README.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 3a9bba1..0cbac91 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
## Introduction
-Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
+Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -24,12 +24,14 @@ There is deliberately no network connectivity or syncing intended.
* Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert
* Text is HTML with basic styling
+ * Images are supported
* Search
* Automatic periodic saving (or explicitly save)
* Transparent integrity checking of the database when it opens
* Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password)
- * Export the database to json, txt, html or csv
+ * Export the database to json, txt, html, csv or .sql (for sqlite3)
+ * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
## How to install
From 0e3ca64619196313953b1e6fb273cd33946468b8 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Wed, 5 Nov 2025 16:10:48 +1100
Subject: [PATCH 013/254] Improve search results window and highlight in
calendar when there are matches.
---
CHANGELOG.md | 4 ++++
bouquin/main_window.py | 49 +++++++++++++++++++++++++++++++-----------
bouquin/search.py | 24 +++++++++++++++++----
3 files changed, 60 insertions(+), 17 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c560986..cd52a22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+# 0.1.10
+
+ * Improve search results window and highlight in calendar when there are matches.
+
# 0.1.9
* More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 1428384..693456b 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -17,11 +17,12 @@ from PySide6.QtCore import (
)
from PySide6.QtGui import (
QAction,
+ QBrush,
+ QColor,
QCursor,
QDesktopServices,
QFont,
QGuiApplication,
- QTextCharFormat,
QTextListFormat,
)
from PySide6.QtWidgets import (
@@ -132,15 +133,15 @@ class MainWindow(QMainWindow):
self.search = Search(self.db)
self.search.openDateRequested.connect(self._load_selected_date)
+ self.search.resultDatesChanged.connect(self._on_search_dates_changed)
# Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized.
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8)
- left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
- left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
- left_layout.addStretch(1)
+ left_layout.addWidget(self.calendar)
+ left_layout.addWidget(self.search)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# This is the note-taking editor
@@ -313,22 +314,44 @@ class MainWindow(QMainWindow):
if self._try_connect():
return True
+ def _on_search_dates_changed(self, date_strs: list[str]):
+ dates = set()
+ for ds in date_strs or []:
+ qd = QDate.fromString(ds, "yyyy-MM-dd")
+ if qd.isValid():
+ dates.add(qd)
+ self._apply_search_highlights(dates)
+
+ def _apply_search_highlights(self, dates: set):
+ yellow = QBrush(QColor("#fff9c4"))
+ old = getattr(self, "_search_highlighted_dates", set())
+
+ for d in old - dates: # clear removed
+ fmt = self.calendar.dateTextFormat(d)
+ fmt.setBackground(Qt.transparent)
+ self.calendar.setDateTextFormat(d, fmt)
+
+ for d in dates: # apply new/current
+ fmt = self.calendar.dateTextFormat(d)
+ fmt.setBackground(yellow)
+ self.calendar.setDateTextFormat(d, fmt)
+
+ self._search_highlighted_dates = dates
+
def _refresh_calendar_marks(self):
- """
- Sets a bold marker on the day to indicate that text exists
- for that day.
- """
- fmt_bold = QTextCharFormat()
- fmt_bold.setFontWeight(QFont.Weight.Bold)
- # Clear previous marks
+ """Make days with entries bold, but keep any search highlight backgrounds."""
for d in getattr(self, "_marked_dates", set()):
- self.calendar.setDateTextFormat(d, QTextCharFormat())
+ fmt = self.calendar.dateTextFormat(d)
+ fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
+ self.calendar.setDateTextFormat(d, fmt)
self._marked_dates = set()
try:
for date_iso in self.db.dates_with_content():
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
if qd.isValid():
- self.calendar.setDateTextFormat(qd, fmt_bold)
+ fmt = self.calendar.dateTextFormat(qd)
+ fmt.setFontWeight(QFont.Weight.Bold) # add bold only
+ self.calendar.setDateTextFormat(qd, fmt)
self._marked_dates.add(qd)
except Exception:
pass
diff --git a/bouquin/search.py b/bouquin/search.py
index 8cd2fd5..27c7e17 100644
--- a/bouquin/search.py
+++ b/bouquin/search.py
@@ -6,10 +6,12 @@ from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import (
+ QFrame,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
+ QSizePolicy,
QHBoxLayout,
QVBoxLayout,
QWidget,
@@ -23,6 +25,7 @@ class Search(QWidget):
"""Encapsulates the search UI + logic and emits a signal when a result is chosen."""
openDateRequested = Signal(str)
+ resultDatesChanged = Signal(list)
def __init__(self, db, parent: QWidget | None = None):
super().__init__(parent)
@@ -30,17 +33,21 @@ class Search(QWidget):
self.search = QLineEdit()
self.search.setPlaceholderText("Search for notes here")
+ self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.search.textChanged.connect(self._search)
self.results = QListWidget()
self.results.setUniformItemSizes(False)
self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
+ self.results.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
self.results.itemClicked.connect(self._open_selected)
self.results.hide()
+ self.results.setMinimumHeight(250)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
+ lay.setAlignment(Qt.AlignTop)
lay.addWidget(self.search)
lay.addWidget(self.results)
@@ -58,6 +65,7 @@ class Search(QWidget):
if not q:
self.results.clear()
self.results.hide()
+ self.resultDatesChanged.emit([]) # clear highlights
return
try:
@@ -73,8 +81,10 @@ class Search(QWidget):
rows = list(rows)
if not rows:
self.results.hide()
+ self.resultDatesChanged.emit([]) # clear highlights
return
+ self.resultDatesChanged.emit(sorted({d for d, _ in rows}))
self.results.show()
for date_str, content in rows:
@@ -90,12 +100,13 @@ class Search(QWidget):
outer.setSpacing(2)
# Date label (plain text)
- date_lbl = QLabel(date_str)
- date_lbl.setTextFormat(Qt.TextFormat.PlainText)
+ date_lbl = QLabel()
+ date_lbl.setTextFormat(Qt.TextFormat.RichText)
+ date_lbl.setText(f"{date_str}:")
date_f = date_lbl.font()
- date_f.setPointSizeF(date_f.pointSizeF() - 1)
+ date_f.setPointSizeF(date_f.pointSizeF() + 1)
date_lbl.setFont(date_f)
- date_lbl.setStyleSheet("color:#666;")
+ date_lbl.setStyleSheet("color:#000;")
outer.addWidget(date_lbl)
# Preview row with optional ellipses
@@ -127,6 +138,11 @@ class Search(QWidget):
outer.addWidget(row)
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setFrameShadow(QFrame.Sunken)
+ outer.addWidget(line)
+
# ---- Add to list ----
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, date_str)
From 3713cc6c2951c24ae83f32f117544ef155ecfce7 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Wed, 5 Nov 2025 16:11:04 +1100
Subject: [PATCH 014/254] Remove unused vars from history/revert dialog
---
bouquin/history_dialog.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py
index 5ee404c..fee2a4f 100644
--- a/bouquin/history_dialog.py
+++ b/bouquin/history_dialog.py
@@ -165,12 +165,10 @@ class HistoryDialog(QDialog):
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
- sel = self._db.get_version(version_id=sel_id)
- vno = sel["version_no"]
# Flip head pointer
try:
self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e:
QMessageBox.critical(self, "Revert failed", str(e))
return
- self.accept() # let the caller refresh the editor
+ self.accept()
From 19593403b92109692d125e71ef4665d005f8cdcf Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Wed, 5 Nov 2025 16:26:13 +1100
Subject: [PATCH 015/254] Fix styling issue with text that comes after a URL,
so it doesn't appear as part of the URL.
---
CHANGELOG.md | 1 +
bouquin/editor.py | 13 +++++++++--
tests/test_editor.py | 53 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 65 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd52a22..5966eb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
# 0.1.10
* Improve search results window and highlight in calendar when there are matches.
+ * Fix styling issue with text that comes after a URL, so it doesn't appear as part of the URL.
# 0.1.9
diff --git a/bouquin/editor.py b/bouquin/editor.py
index 07ef6d3..b7fc341 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -349,7 +349,7 @@ class Editor(QTextEdit):
if source.hasImage():
img = self._to_qimage(source.imageData())
if img is not None:
- self._insert_qimage_at_cursor(self, img, autoscale=True)
+ self._insert_qimage_at_cursor(img, autoscale=True)
return
# 2) File URLs (drag/drop or paste)
@@ -496,12 +496,21 @@ class Editor(QTextEdit):
cur_fmt = self.textCursor().charFormat()
# Do nothing unless either side indicates we're in/propagating an anchor
- if not (ins_fmt.isAnchor() or cur_fmt.isAnchor()):
+ if not (
+ ins_fmt.isAnchor()
+ or cur_fmt.isAnchor()
+ or ins_fmt.fontUnderline()
+ or ins_fmt.foreground().style() != Qt.NoBrush
+ ):
return
nf = QTextCharFormat(ins_fmt)
+ # stop the link itself
nf.setAnchor(False)
nf.setAnchorHref("")
+ # also stop the link *styling*
+ nf.setFontUnderline(False)
+ nf.clearForeground()
self.setCurrentCharFormat(nf)
diff --git a/tests/test_editor.py b/tests/test_editor.py
index cd5855d..6935143 100644
--- a/tests/test_editor.py
+++ b/tests/test_editor.py
@@ -4,6 +4,8 @@ from PySide6.QtTest import QTest
from bouquin.editor import Editor
+import re
+
def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
c = editor.textCursor()
@@ -21,6 +23,57 @@ def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
return None
+def _fmt_at(editor: Editor, pos: int):
+ c = editor.textCursor()
+ c.setPosition(pos)
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
+ return c.charFormat()
+
+
+def test_space_breaks_link_anchor_and_styling(qtbot):
+ e = Editor()
+ e.resize(600, 300)
+ e.show()
+ qtbot.waitExposed(e)
+
+ # Type a URL, which should be linkified (anchor + underline + blue)
+ url = "https://mig5.net"
+ QTest.keyClicks(e, url)
+ qtbot.waitUntil(lambda: e.toPlainText() == url)
+
+ # Sanity: characters within the URL are anchors
+ for i in range(len(url)):
+ assert _fmt_at(e, i).isAnchor()
+
+ # Hit Space – Editor.keyPressEvent() should call _break_anchor_for_next_char()
+ QTest.keyClick(e, Qt.Key_Space)
+
+ # Type some normal text; it must not inherit the link formatting
+ tail = "this is a test"
+ QTest.keyClicks(e, tail)
+ qtbot.waitUntil(lambda: e.toPlainText().endswith(tail))
+
+ txt = e.toPlainText()
+ # Find where our 'tail' starts
+ start = txt.index(tail)
+ end = start + len(tail)
+
+ # None of the trailing characters should be part of an anchor or visually underlined
+ for i in range(start, end):
+ fmt = _fmt_at(e, i)
+ assert not fmt.isAnchor(), f"char {i} unexpectedly still has an anchor"
+ assert not fmt.fontUnderline(), f"char {i} unexpectedly still underlined"
+
+ # Optional: ensure the HTML only wraps the URL in , not the trailing text
+ html = e.document().toHtml()
+ assert re.search(
+ r']*href="https?://mig5\.net"[^>]*>(?:]*>)?https?://mig5\.net(?:)?\s+this is a test',
+ html,
+ re.S,
+ ), html
+ assert "this is a test" not in html
+
+
def test_embed_qimage_saved_as_data_url(qtbot):
e = Editor()
e.resize(600, 400)
From 76806bca08865e39fb98906429136b005a465737 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Wed, 5 Nov 2025 18:58:38 +1100
Subject: [PATCH 016/254] Add ability to export to Markdown (and fix heading
styles)
---
CHANGELOG.md | 1 +
bouquin/db.py | 24 +++++++++++++++++
bouquin/editor.py | 44 +++++++++++++++++++++++-------
bouquin/main_window.py | 4 +++
poetry.lock | 61 +++++++++++++++++++++++++++++++++++++++++-
pyproject.toml | 1 +
6 files changed, 124 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5966eb0..47c0cc7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
* Improve search results window and highlight in calendar when there are matches.
* 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)
# 0.1.9
diff --git a/bouquin/db.py b/bouquin/db.py
index 82b195f..68f956d 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -6,6 +6,7 @@ import json
import os
from dataclasses import dataclass
+from markdownify import markdownify as md
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple
@@ -430,6 +431,29 @@ class DBManager:
with open(file_path, "w", encoding="utf-8") as f:
f.write("\n".join(parts))
+ def export_markdown(
+ self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
+ ) -> None:
+ parts = [
+ "",
+ '',
+ "",
+ f"{html.escape(title)}
",
+ ]
+ for d, c in entries:
+ parts.append(
+ f""
+ )
+ parts.append("")
+
+ # Convert html to markdown
+ md_items = []
+ for item in parts:
+ md_items.append(md(item, heading_style="ATX"))
+
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write("\n".join(md_items))
+
def export_sql(self, file_path: str) -> None:
"""
Exports the encrypted database as plaintext SQL.
diff --git a/bouquin/editor.py b/bouquin/editor.py
index b7fc341..296ca34 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -65,8 +65,9 @@ class Editor(QTextEdit):
def _is_heading_typing(self) -> bool:
"""Is the current *insertion* format using a heading size?"""
- s = self.currentCharFormat().fontPointSize() or self.font().pointSizeF()
- return any(self._approx(s, h) for h in self._HEADING_SIZES)
+ bf = self.textCursor().blockFormat()
+ if bf.headingLevel() > 0:
+ return True
def _apply_normal_typing(self):
"""Switch the *insertion* format to Normal (default size, normal weight)."""
@@ -611,20 +612,43 @@ class Editor(QTextEdit):
Set heading point size for typing. If there's a selection, also apply bold
to that selection (for H1..H3). "Normal" clears bold on the selection.
"""
- base_size = size if size else self.font().pointSizeF()
+ # Map toolbar's sizes to heading levels
+ level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0
+
c = self.textCursor()
- # Update the typing (insertion) format to be size only, but don't represent
- # it as if the Bold style has been toggled on
+ # On-screen look
ins = QTextCharFormat()
- ins.setFontPointSize(base_size)
+ if size:
+ ins.setFontPointSize(float(size))
+ ins.setFontWeight(QFont.Weight.Bold)
+ else:
+ ins.setFontPointSize(self.font().pointSizeF())
+ ins.setFontWeight(QFont.Weight.Normal)
self.mergeCurrentCharFormat(ins)
- # If user selected text, style that text visually as a heading
+ # Apply heading level to affected block(s)
+ def set_level_for_block(cur):
+ bf = cur.blockFormat()
+ if hasattr(bf, "setHeadingLevel"):
+ bf.setHeadingLevel(level) # 0 clears heading
+ cur.mergeBlockFormat(bf)
+
if c.hasSelection():
- sel = QTextCharFormat(ins)
- sel.setFontWeight(QFont.Weight.Bold if size else QFont.Weight.Normal)
- c.mergeCharFormat(sel)
+ start, end = c.selectionStart(), c.selectionEnd()
+ bc = QTextCursor(self.document())
+ bc.setPosition(start)
+ while True:
+ set_level_for_block(bc)
+ if bc.position() >= end:
+ break
+ bc.movePosition(QTextCursor.EndOfBlock)
+ if bc.position() >= end:
+ break
+ bc.movePosition(QTextCursor.NextBlock)
+ else:
+ bc = QTextCursor(c)
+ set_level_for_block(bc)
def toggle_bullets(self):
c = self.textCursor()
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 693456b..cc01f6d 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -616,6 +616,7 @@ If you want an encrypted backup, choose Backup instead of Export.
"JSON (*.json);;"
"CSV (*.csv);;"
"HTML (*.html);;"
+ "Markdown (*.md);;"
"SQL (*.sql);;"
)
@@ -631,6 +632,7 @@ If you want an encrypted backup, choose Backup instead of Export.
"JSON (*.json)": ".json",
"CSV (*.csv)": ".csv",
"HTML (*.html)": ".html",
+ "Markdown (*.md)": ".md",
"SQL (*.sql)": ".sql",
}.get(selected_filter, ".txt")
@@ -647,6 +649,8 @@ If you want an encrypted backup, choose Backup instead of Export.
self.db.export_csv(entries, filename)
elif selected_filter.startswith("HTML"):
self.db.export_html(entries, filename)
+ elif selected_filter.startswith("Markdown"):
+ self.db.export_markdown(entries, filename)
elif selected_filter.startswith("SQL"):
self.db.export_sql(filename)
else:
diff --git a/poetry.lock b/poetry.lock
index e1c4ed5..87acb50 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,27 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.2"
+description = "Screen-scraping library"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"},
+ {file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"},
+]
+
+[package.dependencies]
+soupsieve = ">1.2"
+typing-extensions = ">=4.0.0"
+
+[package.extras]
+cchardet = ["cchardet"]
+chardet = ["chardet"]
+charset-normalizer = ["charset-normalizer"]
+html5lib = ["html5lib"]
+lxml = ["lxml"]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -158,6 +180,21 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
+[[package]]
+name = "markdownify"
+version = "1.2.0"
+description = "Convert HTML to markdown."
+optional = false
+python-versions = "*"
+files = [
+ {file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"},
+ {file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"},
+]
+
+[package.dependencies]
+beautifulsoup4 = ">=4.9,<5"
+six = ">=1.15,<2"
+
[[package]]
name = "packaging"
version = "25.0"
@@ -345,6 +382,28 @@ files = [
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"},
]
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8"
+description = "A modern CSS selector implementation for Beautiful Soup."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"},
+ {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"},
+]
+
[[package]]
name = "sqlcipher3-wheels"
version = "0.5.5.post0"
@@ -541,4 +600,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<3.14"
-content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4"
+content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d"
diff --git a/pyproject.toml b/pyproject.toml
index 30c541a..bcedf1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ repository = "https://git.mig5.net/mig5/bouquin"
python = ">=3.9,<3.14"
pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0"
+markdownify = "^1.2.0"
[tool.poetry.scripts]
bouquin = "bouquin.__main__:main"
From a7c8cc5dbf70709f0c389184a51f5cdb1b9dab21 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Wed, 5 Nov 2025 21:36:38 +1100
Subject: [PATCH 017/254] Represent in the History diff pane when an image was
the thing that changed
---
CHANGELOG.md | 1 +
bouquin/history_dialog.py | 2 ++
2 files changed, 3 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47c0cc7..425b5f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
* Improve search results window and highlight in calendar when there are matches.
* 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
# 0.1.9
diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py
index fee2a4f..98399b9 100644
--- a/bouquin/history_dialog.py
+++ b/bouquin/history_dialog.py
@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
def _html_to_text(s: str) -> str:
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
+ IMG_RE = re.compile(r"(?is)
]*>")
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?\1>")
COMMENT_RE = re.compile(r"", re.S)
BR_RE = re.compile(r"(?i)
")
@@ -25,6 +26,7 @@ def _html_to_text(s: str) -> str:
TAG_RE = re.compile(r"<[^>]+>")
MULTINL_RE = re.compile(r"\n{3,}")
+ s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
s = STYLE_SCRIPT_RE.sub("", s)
s = COMMENT_RE.sub("", s)
s = BR_RE.sub("\n", s)
From c3b83b0238ee2c3f5874bf784d1b65c9cd7984ed Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Thu, 6 Nov 2025 10:56:20 +1100
Subject: [PATCH 018/254] 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 019/254] 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"
From 773afa54641b8ef15799f6f45829e3725eb2dd54 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Thu, 6 Nov 2025 13:43:52 +1100
Subject: [PATCH 020/254] Support checkboxes, and TODO shortcut
---
CHANGELOG.md | 1 +
bouquin/editor.py | 187 +++++++++++++++++++++++++++++++++++++++-
bouquin/lock_overlay.py | 127 +++++++++++++++++++++++++++
bouquin/main_window.py | 82 +-----------------
bouquin/toolbar.py | 5 ++
5 files changed, 323 insertions(+), 79 deletions(-)
create mode 100644 bouquin/lock_overlay.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e0763e..a27c1c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
* 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)
+ * Add Checkboxes in the editor. Typing 'TODO' at the start of a line will auto-convert into a checkbox.
# 0.1.9
diff --git a/bouquin/editor.py b/bouquin/editor.py
index f68d3c1..05ef128 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -45,6 +45,11 @@ class Editor(QTextEdit):
_HEADING_SIZES = (24.0, 18.0, 14.0)
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
+ # --- Checkbox hack --- #
+ _CHECK_UNCHECKED = "\u2610" # ☐
+ _CHECK_CHECKED = "\u2611" # ☑
+ _CHECK_RX = re.compile(r"^\s*([\u2610\u2611])\s") # ☐/☑ plus a space
+ _CHECKBOX_SCALE = 1.35
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -451,11 +456,52 @@ class Editor(QTextEdit):
self.viewport().setCursor(Qt.IBeamCursor)
super().mouseMoveEvent(e)
+ def mousePressEvent(self, e):
+ if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier):
+ cur = self.cursorForPosition(e.pos())
+ b = cur.block()
+ state, pref = self._checkbox_info_for_block(b)
+ if state is not None:
+ col = cur.position() - b.position()
+ if col <= max(1, pref): # clicked on ☐/☑ (and the following space)
+ self._set_block_checkbox_state(b, not state)
+ return
+ return super().mousePressEvent(e)
+
def keyPressEvent(self, e):
key = e.key()
- # Pre-insert: stop link/format bleed for “word boundary” keys
if key in (Qt.Key_Space, Qt.Key_Tab):
+ c = self.textCursor()
+ b = c.block()
+ pos_in_block = c.position() - b.position()
+
+ if (
+ pos_in_block >= 4
+ and b.text().startswith("TODO")
+ and b.text()[:pos_in_block] == "TODO"
+ and self._checkbox_info_for_block(b)[0] is None
+ ):
+ tcur = QTextCursor(self.document())
+ tcur.setPosition(b.position()) # start of block
+ tcur.setPosition(
+ b.position() + 4, QTextCursor.KeepAnchor
+ ) # select "TODO"
+ tcur.beginEditBlock()
+ tcur.removeSelectedText()
+ tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ "
+ tcur.endEditBlock()
+
+ # visuals: size bump
+ if hasattr(self, "_style_checkbox_glyph"):
+ self._style_checkbox_glyph(b)
+
+ # caret after the inserted prefix; swallow the key (we already added a space)
+ c.setPosition(b.position() + 2)
+ self.setTextCursor(c)
+ return
+
+ # not a TODO-at-start case
self._break_anchor_for_next_char()
return super().keyPressEvent(e)
@@ -472,6 +518,26 @@ class Editor(QTextEdit):
super().insertPlainText("\n") # start a normal paragraph
return
+ # --- CHECKBOX handling: continue on Enter; "escape" on second Enter ---
+ b = c.block()
+ state, pref = self._checkbox_info_for_block(b)
+ if state is not None and not c.hasSelection():
+ text_after = b.text()[pref:].strip()
+ if c.atBlockEnd() and text_after == "":
+ # Empty checkbox item -> remove the prefix and insert a plain new line
+ cur = QTextCursor(self.document())
+ cur.setPosition(b.position())
+ cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
+ cur.removeSelectedText()
+ return super().keyPressEvent(e)
+ else:
+ # Normal continuation: new checkbox on the next line
+ super().keyPressEvent(e) # make the new block
+ super().insertPlainText(self._CHECK_UNCHECKED + " ")
+ if hasattr(self, "_style_checkbox_glyph"):
+ self._style_checkbox_glyph(self.textCursor().block())
+ return
+
# Follow-on style: if we typed a heading and press Enter at end of block,
# new paragraph should revert to Normal.
if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing():
@@ -520,6 +586,125 @@ class Editor(QTextEdit):
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
+ # ====== Checkbox core ======
+ def _base_point_size_for_block(self, block) -> float:
+ # Try the block’s char format, then editor font
+ sz = block.charFormat().fontPointSize()
+ if sz <= 0:
+ sz = self.fontPointSize()
+ if sz <= 0:
+ sz = self.font().pointSizeF() or 12.0
+ return float(sz)
+
+ def _style_checkbox_glyph(self, block):
+ """Apply larger size (and optional symbol font) to the single ☐/☑ char."""
+ state, _ = self._checkbox_info_for_block(block)
+ if state is None:
+ return
+ doc = self.document()
+ c = QTextCursor(doc)
+ c.setPosition(block.position())
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only
+
+ base = self._base_point_size_for_block(block)
+ fmt = QTextCharFormat()
+ fmt.setFontPointSize(base * self._CHECKBOX_SCALE)
+ # keep the glyph centered on the text baseline
+ fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle)
+
+ c.mergeCharFormat(fmt)
+
+ def _checkbox_info_for_block(self, block):
+ """Return (state, prefix_len): state in {None, False, True}, prefix_len in chars."""
+ text = block.text()
+ m = self._CHECK_RX.match(text)
+ if not m:
+ return None, 0
+ ch = m.group(1)
+ state = True if ch == self._CHECK_CHECKED else False
+ return state, m.end()
+
+ def _set_block_checkbox_present(self, block, present: bool):
+ state, pref = self._checkbox_info_for_block(block)
+ doc = self.document()
+ c = QTextCursor(doc)
+ c.setPosition(block.position())
+ c.beginEditBlock()
+ try:
+ if present and state is None:
+ c.insertText(self._CHECK_UNCHECKED + " ")
+ state = False
+ self._style_checkbox_glyph(block)
+ else:
+ if state is not None:
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
+ c.removeSelectedText()
+ state = None
+ finally:
+ c.endEditBlock()
+
+ return state
+
+ def _set_block_checkbox_state(self, block, checked: bool):
+ """Switch ☐/☑ at the start of the block."""
+ state, pref = self._checkbox_info_for_block(block)
+ if state is None:
+ return
+ doc = self.document()
+ c = QTextCursor(doc)
+ c.setPosition(block.position())
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol
+ c.beginEditBlock()
+ try:
+ c.removeSelectedText()
+ c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED)
+ self._style_checkbox_glyph(block)
+ finally:
+ c.endEditBlock()
+
+ # Public API used by toolbar
+ def toggle_checkboxes(self):
+ """
+ Toggle checkbox prefix on/off for the current block(s).
+ If all targeted blocks already have a checkbox, remove them; otherwise add.
+ """
+ c = self.textCursor()
+ doc = self.document()
+
+ if c.hasSelection():
+ start = doc.findBlock(c.selectionStart())
+ end = doc.findBlock(c.selectionEnd() - 1)
+ else:
+ start = end = c.block()
+
+ # Decide intent: add or remove?
+ b = start
+ all_have = True
+ while True:
+ state, _ = self._checkbox_info_for_block(b)
+ if state is None:
+ all_have = False
+ break
+ if b == end:
+ break
+ b = b.next()
+
+ # Apply
+ b = start
+ while True:
+ self._set_block_checkbox_present(b, present=not all_have)
+ if b == end:
+ break
+ b = b.next()
+
+ def toggle_current_checkbox_state(self):
+ """Tick/untick the current line if it starts with a checkbox."""
+ b = self.textCursor().block()
+ state, _ = self._checkbox_info_for_block(b)
+ if state is None:
+ return
+ self._set_block_checkbox_state(b, not state)
+
@Slot()
def apply_weight(self):
cur = self.currentCharFormat()
diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py
new file mode 100644
index 0000000..d019f3b
--- /dev/null
+++ b/bouquin/lock_overlay.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, QEvent
+from PySide6.QtGui import QPalette
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
+
+
+class LockOverlay(QWidget):
+ def __init__(self, parent: QWidget, on_unlock: callable):
+ super().__init__(parent)
+ self.setObjectName("LockOverlay")
+ self.setAttribute(Qt.WA_StyledBackground, True)
+ self.setFocusPolicy(Qt.StrongFocus)
+ self.setGeometry(parent.rect())
+
+ self._styling = False # <-- reentrancy guard
+ self._last_dark: bool | None = None
+
+ lay = QVBoxLayout(self)
+ lay.addStretch(1)
+
+ msg = QLabel("Locked due to inactivity", self)
+ msg.setObjectName("lockLabel")
+ msg.setAlignment(Qt.AlignCenter)
+
+ self._btn = QPushButton("Unlock", self)
+ self._btn.setObjectName("unlockButton")
+ self._btn.setFixedWidth(200)
+ self._btn.setCursor(Qt.PointingHandCursor)
+ self._btn.setAutoDefault(True)
+ self._btn.setDefault(True)
+ self._btn.clicked.connect(on_unlock)
+
+ lay.addWidget(msg, 0, Qt.AlignCenter)
+ lay.addWidget(self._btn, 0, Qt.AlignCenter)
+ lay.addStretch(1)
+
+ self._apply_overlay_style()
+ self.hide()
+
+ def _is_dark(self, pal: QPalette) -> bool:
+ c = pal.color(QPalette.Window)
+ luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
+ return luma < 0.5
+
+ def _apply_overlay_style(self):
+ if self._styling:
+ return
+ dark = self._is_dark(self.palette())
+ if dark == self._last_dark:
+ return
+ self._styling = True
+ try:
+ if dark:
+ link = self.palette().color(QPalette.Link)
+ accent_hex = link.name() # e.g. "#FFA500"
+ r, g, b = link.red(), link.green(), link.blue()
+
+ self.setStyleSheet(
+ f"""
+#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
+#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
+
+#LockOverlay QPushButton#unlockButton {{
+ color: {accent_hex};
+ background-color: rgba({r},{g},{b},0.10);
+ border: 1px solid {accent_hex};
+ border-radius: 8px;
+ padding: 8px 16px;
+}}
+#LockOverlay QPushButton#unlockButton:hover {{
+ background-color: rgba({r},{g},{b},0.16);
+ border-color: {accent_hex};
+}}
+#LockOverlay QPushButton#unlockButton:pressed {{
+ background-color: rgba({r},{g},{b},0.24);
+}}
+#LockOverlay QPushButton#unlockButton:focus {{
+ outline: none;
+ border-color: {accent_hex};
+}}
+ """
+ )
+ else:
+ # (light mode unchanged)
+ self.setStyleSheet(
+ """
+#LockOverlay { background-color: rgba(0,0,0,120); }
+#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
+#LockOverlay QPushButton#unlockButton {
+ color: palette(button-text);
+ background-color: rgba(255,255,255,0.92);
+ border: 1px solid rgba(0,0,0,0.25);
+ border-radius: 8px;
+ padding: 8px 16px;
+}
+#LockOverlay QPushButton#unlockButton:hover {
+ background-color: rgba(255,255,255,1.0);
+ border-color: rgba(0,0,0,0.35);
+}
+#LockOverlay QPushButton#unlockButton:pressed {
+ background-color: rgba(245,245,245,1.0);
+}
+#LockOverlay QPushButton#unlockButton:focus {
+ outline: none;
+ border-color: palette(highlight);
+}
+ """
+ )
+ self._last_dark = dark
+ finally:
+ self._styling = False
+
+ def changeEvent(self, ev):
+ super().changeEvent(ev)
+ # Only re-style on palette flips
+ if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
+ self._apply_overlay_style()
+
+ def eventFilter(self, obj, event):
+ if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
+ self.setGeometry(obj.rect())
+ return False
+
+ def showEvent(self, e):
+ super().showEvent(e)
+ self._btn.setFocus()
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 7b29bbc..7c9d1d0 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -32,10 +32,8 @@ from PySide6.QtWidgets import (
QCalendarWidget,
QDialog,
QFileDialog,
- QLabel,
QMainWindow,
QMessageBox,
- QPushButton,
QSizePolicy,
QSplitter,
QVBoxLayout,
@@ -46,6 +44,7 @@ from .db import DBManager
from .editor import Editor
from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt
+from .lock_overlay import LockOverlay
from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
@@ -54,78 +53,6 @@ from .toolbar import ToolBar
from .theme import Theme, ThemeManager
-class _LockOverlay(QWidget):
- def __init__(self, parent: QWidget, on_unlock: callable):
- super().__init__(parent)
- self.setObjectName("LockOverlay")
- self.setAttribute(Qt.WA_StyledBackground, True)
- self.setFocusPolicy(Qt.StrongFocus)
- self.setGeometry(parent.rect())
-
- lay = QVBoxLayout(self)
- lay.addStretch(1)
-
- msg = QLabel("Locked due to inactivity")
- msg.setAlignment(Qt.AlignCenter)
-
- self._btn = QPushButton("Unlock")
- self._btn.setFixedWidth(200)
- self._btn.setCursor(Qt.PointingHandCursor)
- self._btn.setAutoDefault(True)
- self._btn.setDefault(True)
- self._btn.clicked.connect(on_unlock)
-
- lay.addWidget(msg, 0, Qt.AlignCenter)
- 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):
- self.setGeometry(obj.rect())
- return False
-
- def showEvent(self, e):
- super().showEvent(e)
- self._btn.setFocus()
-
-
class MainWindow(QMainWindow):
def __init__(self, themes: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -182,6 +109,7 @@ class MainWindow(QMainWindow):
self.toolBar.headingRequested.connect(self.editor.apply_heading)
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
+ self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history)
self.toolBar.insertImageRequested.connect(self._on_insert_image)
@@ -207,8 +135,7 @@ class MainWindow(QMainWindow):
self._idle_timer.start()
# 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._lock_overlay = LockOverlay(self.centralWidget(), self._on_unlock_clicked)
self.centralWidget().installEventFilter(self._lock_overlay)
self._locked = False
@@ -375,7 +302,6 @@ class MainWindow(QMainWindow):
pass
try:
- # Apply to the search widget (if it's also a rich-text widget)
self.search.document().setDefaultStyleSheet(css)
except Exception:
pass
@@ -562,7 +488,7 @@ class MainWindow(QMainWindow):
def _on_text_changed(self):
self._dirty = True
- self._save_timer.start(10000) # autosave after idle
+ self._save_timer.start(5000) # autosave after idle
def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous)."""
diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py
index 5d5c451..78c737e 100644
--- a/bouquin/toolbar.py
+++ b/bouquin/toolbar.py
@@ -14,6 +14,7 @@ class ToolBar(QToolBar):
headingRequested = Signal(int)
bulletsRequested = Signal()
numbersRequested = Signal()
+ checkboxesRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal()
insertImageRequested = Signal()
@@ -86,6 +87,9 @@ class ToolBar(QToolBar):
self.actNumbers.setToolTip("Numbered list")
self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
+ self.actCheckboxes = QAction("☐", self)
+ self.actCheckboxes.setToolTip("Toggle checkboxes")
+ self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images
self.actInsertImg = QAction("Image", self)
@@ -150,6 +154,7 @@ class ToolBar(QToolBar):
self.actNormal,
self.actBullets,
self.actNumbers,
+ self.actCheckboxes,
self.actInsertImg,
self.actAlignL,
self.actAlignC,
From f7903c2cd96ff73f40b0b019a49ec086fbcd61f5 Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Thu, 6 Nov 2025 14:29:24 +1100
Subject: [PATCH 021/254] Tests
---
tests.sh | 3 +++
tests/conftest.py | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
create mode 100644 tests.sh
diff --git a/tests.sh b/tests.sh
new file mode 100644
index 0000000..7f50169
--- /dev/null
+++ b/tests.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+poetry run pytest -vvvv --cov=bouquin --cov-report=term-missing --disable-warnings
diff --git a/tests/conftest.py b/tests/conftest.py
index 8d885e6..e949b1a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,7 +8,7 @@ from tests.qt_helpers import AutoResponder
# Force Qt *non-native* file dialog so we can type a filename programmatically.
os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0")
# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env
-# os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# Make project importable
From 58f4f0a0b5a41107034fe0daf9dac9e1f1c1599b Mon Sep 17 00:00:00 2001
From: Miguel Jacq
Date: Thu, 6 Nov 2025 15:45:31 +1100
Subject: [PATCH 022/254] Add option to automatically move yesterday's
unchecked TODOs to today on startup
---
CHANGELOG.md | 1 +
bouquin/db.py | 1 +
bouquin/editor.py | 7 ++++
bouquin/main_window.py | 71 ++++++++++++++++++++++++++++++++++++--
bouquin/settings.py | 6 +++-
bouquin/settings_dialog.py | 15 ++++++++
pyproject.toml | 2 +-
tests.sh | 0
8 files changed, 99 insertions(+), 4 deletions(-)
mode change 100644 => 100755 tests.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a27c1c4..55af76b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
* Represent in the History diff pane when an image was the thing that changed
* Support theme choice in settings (light/dark/system)
* Add Checkboxes in the editor. Typing 'TODO' at the start of a line will auto-convert into a checkbox.
+ * Add option to automatically move yesterday's unchecked TODOs to today on startup
# 0.1.9
diff --git a/bouquin/db.py b/bouquin/db.py
index e8c4903..20261eb 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -20,6 +20,7 @@ class DBConfig:
key: str
idle_minutes: int = 15 # 0 = never lock
theme: str = "system"
+ move_todos: bool = False
class DBManager:
diff --git a/bouquin/editor.py b/bouquin/editor.py
index 05ef128..5abf9b8 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -886,5 +886,12 @@ class Editor(QTextEdit):
def setHtml(self, html: str) -> None:
super().setHtml(html)
+
+ doc = self.document()
+ block = doc.firstBlock()
+ while block.isValid():
+ self._style_checkbox_glyph(block) # Apply checkbox styling to each block
+ block = block.next()
+
# Ensure anchors adopt the palette color on startup
self._retint_anchors_to_palette()
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 7c9d1d0..9243177 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import datetime
import os
import sys
+import re
from pathlib import Path
from PySide6.QtCore import (
@@ -224,7 +225,8 @@ class MainWindow(QMainWindow):
self.editor.textChanged.connect(self._on_text_changed)
# First load + mark dates in calendar with content
- self._load_selected_date()
+ if not self._load_yesterday_todos():
+ self._load_selected_date()
self._refresh_calendar_marks()
# Restore window position from settings
@@ -469,17 +471,31 @@ class MainWindow(QMainWindow):
d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
- def _load_selected_date(self, date_iso=False):
+ def _load_selected_date(self, date_iso=False, extra_data=False):
if not date_iso:
date_iso = self._current_date_iso()
try:
text = self.db.get_entry(date_iso)
+ if extra_data:
+ # Wrap extra_data in a tag for HTML rendering
+ extra_data_html = f"
{extra_data}
"
+
+ # Inject the extra_data before the closing