Compare commits
2 commits
a7c8cc5dbf
...
7c3ec19748
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c3ec19748 | |||
| c3b83b0238 |
23 changed files with 1158 additions and 94 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
* Fix styling issue with text that comes after a URL, so it doesn't appear as part of the URL.
|
* 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)
|
* Add ability to export to Markdown (and fix heading styles)
|
||||||
* Represent in the History diff pane when an image was the thing that changed
|
* 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
|
# 0.1.9
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from .main import main
|
|
||||||
|
|
@ -19,6 +19,7 @@ class DBConfig:
|
||||||
path: Path
|
path: Path
|
||||||
key: str
|
key: str
|
||||||
idle_minutes: int = 15 # 0 = never lock
|
idle_minutes: int = 15 # 0 = never lock
|
||||||
|
theme: str = "system"
|
||||||
|
|
||||||
|
|
||||||
class DBManager:
|
class DBManager:
|
||||||
|
|
@ -160,13 +161,6 @@ class DBManager:
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return row[0] if row else ""
|
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]:
|
def search_entries(self, text: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Search for entries by term. This only works against the latest
|
Search for entries by term. This only works against the latest
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from PySide6.QtGui import (
|
||||||
QFontDatabase,
|
QFontDatabase,
|
||||||
QImage,
|
QImage,
|
||||||
QImageReader,
|
QImageReader,
|
||||||
|
QPalette,
|
||||||
QPixmap,
|
QPixmap,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
QTextCursor,
|
QTextCursor,
|
||||||
|
|
@ -28,8 +29,11 @@ from PySide6.QtCore import (
|
||||||
QBuffer,
|
QBuffer,
|
||||||
QByteArray,
|
QByteArray,
|
||||||
QIODevice,
|
QIODevice,
|
||||||
|
QTimer,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import QTextEdit
|
from PySide6.QtWidgets import QTextEdit, QApplication
|
||||||
|
|
||||||
|
from .theme import Theme, ThemeManager
|
||||||
|
|
||||||
|
|
||||||
class Editor(QTextEdit):
|
class Editor(QTextEdit):
|
||||||
|
|
@ -42,7 +46,7 @@ class Editor(QTextEdit):
|
||||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||||
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
|
_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)
|
super().__init__(*args, **kwargs)
|
||||||
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||||||
self.setTabStopDistance(tab_w)
|
self.setTabStopDistance(tab_w)
|
||||||
|
|
@ -55,7 +59,13 @@ class Editor(QTextEdit):
|
||||||
|
|
||||||
self.setAcceptRichText(True)
|
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._linkifying = False
|
||||||
self.textChanged.connect(self._linkify_document)
|
self.textChanged.connect(self._linkify_document)
|
||||||
self.viewport().setMouseTracking(True)
|
self.viewport().setMouseTracking(True)
|
||||||
|
|
@ -87,15 +97,6 @@ class Editor(QTextEdit):
|
||||||
f = f.parentFrame()
|
f = f.parentFrame()
|
||||||
return None
|
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:
|
def _trim_url_end(self, url: str) -> str:
|
||||||
# strip common trailing punctuation not part of the URL
|
# strip common trailing punctuation not part of the URL
|
||||||
trimmed = url.rstrip(".,;:!?\"'")
|
trimmed = url.rstrip(".,;:!?\"'")
|
||||||
|
|
@ -141,7 +142,7 @@ class Editor(QTextEdit):
|
||||||
fmt.setAnchor(True)
|
fmt.setAnchor(True)
|
||||||
fmt.setAnchorHref(href) # always refresh to the latest full URL
|
fmt.setAnchorHref(href) # always refresh to the latest full URL
|
||||||
fmt.setFontUnderline(True)
|
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
|
cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling
|
||||||
|
|
||||||
|
|
@ -481,11 +482,6 @@ class Editor(QTextEdit):
|
||||||
# otherwise default handling
|
# otherwise default handling
|
||||||
return super().keyPressEvent(e)
|
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):
|
def _break_anchor_for_next_char(self):
|
||||||
"""
|
"""
|
||||||
Ensure the *next* typed character is not part of a hyperlink.
|
Ensure the *next* typed character is not part of a hyperlink.
|
||||||
|
|
@ -669,3 +665,41 @@ class Editor(QTextEdit):
|
||||||
fmt = QTextListFormat()
|
fmt = QTextListFormat()
|
||||||
fmt.setStyle(QTextListFormat.Style.ListDecimal)
|
fmt.setStyle(QTextListFormat.Style.ListDecimal)
|
||||||
c.createList(fmt)
|
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()
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,22 @@ from __future__ import annotations
|
||||||
import sys
|
import sys
|
||||||
from PySide6.QtWidgets import QApplication
|
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 .main_window import MainWindow
|
||||||
|
from .theme import Theme, ThemeConfig, ThemeManager
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName(APP_NAME)
|
app.setApplicationName(APP_NAME)
|
||||||
app.setOrganizationName(APP_ORG)
|
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()
|
win.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,12 @@ from PySide6.QtGui import (
|
||||||
QDesktopServices,
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
QGuiApplication,
|
QGuiApplication,
|
||||||
|
QPalette,
|
||||||
|
QTextCharFormat,
|
||||||
QTextListFormat,
|
QTextListFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
|
@ -48,6 +51,7 @@ from .search import Search
|
||||||
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
||||||
from .settings_dialog import SettingsDialog
|
from .settings_dialog import SettingsDialog
|
||||||
from .toolbar import ToolBar
|
from .toolbar import ToolBar
|
||||||
|
from .theme import Theme, ThemeManager
|
||||||
|
|
||||||
|
|
||||||
class _LockOverlay(QWidget):
|
class _LockOverlay(QWidget):
|
||||||
|
|
@ -58,23 +62,6 @@ class _LockOverlay(QWidget):
|
||||||
self.setFocusPolicy(Qt.StrongFocus)
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
self.setGeometry(parent.rect())
|
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 = QVBoxLayout(self)
|
||||||
lay.addStretch(1)
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
|
@ -92,8 +79,42 @@ class _LockOverlay(QWidget):
|
||||||
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
||||||
lay.addStretch(1)
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
self._apply_overlay_style()
|
||||||
|
|
||||||
self.hide() # start hidden
|
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
|
# keep overlay sized with its parent
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
||||||
|
|
@ -106,11 +127,13 @@ class _LockOverlay(QWidget):
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, themes: ThemeManager, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.setWindowTitle(APP_NAME)
|
self.setWindowTitle(APP_NAME)
|
||||||
self.setMinimumSize(1000, 650)
|
self.setMinimumSize(1000, 650)
|
||||||
|
|
||||||
|
self.themes = themes # Store the themes manager
|
||||||
|
|
||||||
self.cfg = load_db_config()
|
self.cfg = load_db_config()
|
||||||
if not os.path.exists(self.cfg.path):
|
if not os.path.exists(self.cfg.path):
|
||||||
# Fresh database/first time use, so guide the user re: setting a key
|
# 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)
|
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||||
|
|
||||||
# This is the note-taking editor
|
# This is the note-taking editor
|
||||||
self.editor = Editor()
|
self.editor = Editor(self.themes)
|
||||||
|
|
||||||
# Toolbar for controlling styling
|
# Toolbar for controlling styling
|
||||||
self.toolBar = ToolBar()
|
self.toolBar = ToolBar()
|
||||||
|
|
@ -185,6 +208,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# full-window overlay that sits on top of the central widget
|
# full-window overlay that sits on top of the central widget
|
||||||
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
|
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
|
||||||
|
self._lock_overlay._apply_overlay_style()
|
||||||
self.centralWidget().installEventFilter(self._lock_overlay)
|
self.centralWidget().installEventFilter(self._lock_overlay)
|
||||||
|
|
||||||
self._locked = False
|
self._locked = False
|
||||||
|
|
@ -280,6 +304,16 @@ class MainWindow(QMainWindow):
|
||||||
self.settings = QSettings(APP_ORG, APP_NAME)
|
self.settings = QSettings(APP_ORG, APP_NAME)
|
||||||
self._restore_window_position()
|
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:
|
def _try_connect(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Try to connect to the database.
|
Try to connect to the database.
|
||||||
|
|
@ -314,6 +348,90 @@ class MainWindow(QMainWindow):
|
||||||
if self._try_connect():
|
if self._try_connect():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _retheme_overrides(self):
|
||||||
|
if hasattr(self, "_lock_overlay"):
|
||||||
|
self._lock_overlay._apply_overlay_style()
|
||||||
|
self._apply_calendar_text_colors()
|
||||||
|
self._apply_link_css() # Reapply link styles based on the current theme
|
||||||
|
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
||||||
|
self.calendar.update()
|
||||||
|
self.editor.viewport().update()
|
||||||
|
|
||||||
|
def _apply_link_css(self):
|
||||||
|
if self.themes and self.themes.current() == Theme.DARK:
|
||||||
|
anchor = Theme.ORANGE_ANCHOR.value
|
||||||
|
visited = Theme.ORANGE_ANCHOR_VISITED.value
|
||||||
|
css = f"""
|
||||||
|
a {{ color: {anchor}; text-decoration: underline; }}
|
||||||
|
a:visited {{ color: {visited}; }}
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
css = "" # Default to no custom styling for links (system or light theme)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Apply to the editor (QTextEdit or any other relevant widgets)
|
||||||
|
self.editor.document().setDefaultStyleSheet(css)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Apply to the search widget (if it's also a rich-text widget)
|
||||||
|
self.search.document().setDefaultStyleSheet(css)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _apply_calendar_theme(self, theme: Theme):
|
||||||
|
"""Use orange accents on the calendar in dark mode only."""
|
||||||
|
app_pal = QApplication.instance().palette()
|
||||||
|
|
||||||
|
if theme == Theme.DARK:
|
||||||
|
highlight = QColor(Theme.ORANGE_ANCHOR.value)
|
||||||
|
black = QColor(0, 0, 0)
|
||||||
|
|
||||||
|
highlight_css = Theme.ORANGE_ANCHOR.value
|
||||||
|
|
||||||
|
# Per-widget palette: selection color inside the date grid
|
||||||
|
pal = self.calendar.palette()
|
||||||
|
pal.setColor(QPalette.Highlight, highlight)
|
||||||
|
pal.setColor(QPalette.HighlightedText, black)
|
||||||
|
self.calendar.setPalette(pal)
|
||||||
|
|
||||||
|
# Stylesheet: nav bar + selected-day background
|
||||||
|
self.calendar.setStyleSheet(
|
||||||
|
f"""
|
||||||
|
QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
|
||||||
|
QCalendarWidget QToolButton {{ color: black; }}
|
||||||
|
QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }}
|
||||||
|
/* Selected day color in the table view */
|
||||||
|
QCalendarWidget QTableView:enabled {{
|
||||||
|
selection-background-color: {highlight_css};
|
||||||
|
selection-color: black;
|
||||||
|
}}
|
||||||
|
/* Optional: keep weekday header readable */
|
||||||
|
QCalendarWidget QTableView QHeaderView::section {{
|
||||||
|
background: transparent;
|
||||||
|
color: palette(windowText);
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Back to app defaults in light/system
|
||||||
|
self.calendar.setPalette(app_pal)
|
||||||
|
self.calendar.setStyleSheet("")
|
||||||
|
|
||||||
|
# Keep weekend text color in sync with the current palette
|
||||||
|
self._apply_calendar_text_colors()
|
||||||
|
self.calendar.update()
|
||||||
|
|
||||||
|
def _apply_calendar_text_colors(self):
|
||||||
|
pal = self.palette()
|
||||||
|
txt = pal.windowText().color()
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setForeground(txt)
|
||||||
|
# Use normal text color for weekends
|
||||||
|
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
|
||||||
|
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
|
||||||
|
|
||||||
def _on_search_dates_changed(self, date_strs: list[str]):
|
def _on_search_dates_changed(self, date_strs: list[str]):
|
||||||
dates = set()
|
dates = set()
|
||||||
for ds in date_strs or []:
|
for ds in date_strs or []:
|
||||||
|
|
@ -323,7 +441,16 @@ class MainWindow(QMainWindow):
|
||||||
self._apply_search_highlights(dates)
|
self._apply_search_highlights(dates)
|
||||||
|
|
||||||
def _apply_search_highlights(self, dates: set):
|
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())
|
old = getattr(self, "_search_highlighted_dates", set())
|
||||||
|
|
||||||
for d in old - dates: # clear removed
|
for d in old - dates: # clear removed
|
||||||
|
|
@ -364,10 +491,10 @@ class MainWindow(QMainWindow):
|
||||||
bf = c.blockFormat()
|
bf = c.blockFormat()
|
||||||
|
|
||||||
# Block signals so setChecked() doesn't re-trigger actions
|
# Block signals so setChecked() doesn't re-trigger actions
|
||||||
blocker1 = QSignalBlocker(self.toolBar.actBold)
|
QSignalBlocker(self.toolBar.actBold)
|
||||||
blocker2 = QSignalBlocker(self.toolBar.actItalic)
|
QSignalBlocker(self.toolBar.actItalic)
|
||||||
blocker3 = QSignalBlocker(self.toolBar.actUnderline)
|
QSignalBlocker(self.toolBar.actUnderline)
|
||||||
blocker4 = QSignalBlocker(self.toolBar.actStrike)
|
QSignalBlocker(self.toolBar.actStrike)
|
||||||
|
|
||||||
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
|
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
|
||||||
self.toolBar.actItalic.setChecked(fmt.fontItalic())
|
self.toolBar.actItalic.setChecked(fmt.fontItalic())
|
||||||
|
|
@ -384,10 +511,10 @@ class MainWindow(QMainWindow):
|
||||||
bH2 = _approx(cur_size, 18)
|
bH2 = _approx(cur_size, 18)
|
||||||
bH3 = _approx(cur_size, 14)
|
bH3 = _approx(cur_size, 14)
|
||||||
|
|
||||||
b1 = QSignalBlocker(self.toolBar.actH1)
|
QSignalBlocker(self.toolBar.actH1)
|
||||||
b2 = QSignalBlocker(self.toolBar.actH2)
|
QSignalBlocker(self.toolBar.actH2)
|
||||||
b3 = QSignalBlocker(self.toolBar.actH3)
|
QSignalBlocker(self.toolBar.actH3)
|
||||||
bN = QSignalBlocker(self.toolBar.actNormal)
|
QSignalBlocker(self.toolBar.actNormal)
|
||||||
|
|
||||||
self.toolBar.actH1.setChecked(bH1)
|
self.toolBar.actH1.setChecked(bH1)
|
||||||
self.toolBar.actH2.setChecked(bH2)
|
self.toolBar.actH2.setChecked(bH2)
|
||||||
|
|
@ -538,6 +665,7 @@ class MainWindow(QMainWindow):
|
||||||
self.cfg.path = new_cfg.path
|
self.cfg.path = new_cfg.path
|
||||||
self.cfg.key = new_cfg.key
|
self.cfg.key = new_cfg.key
|
||||||
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
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
|
# Persist once
|
||||||
save_db_config(self.cfg)
|
save_db_config(self.cfg)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ from PySide6.QtWidgets import (
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
# type: rows are (date_iso, content)
|
|
||||||
Row = Tuple[str, str]
|
Row = Tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,11 +101,10 @@ class Search(QWidget):
|
||||||
# Date label (plain text)
|
# Date label (plain text)
|
||||||
date_lbl = QLabel()
|
date_lbl = QLabel()
|
||||||
date_lbl.setTextFormat(Qt.TextFormat.RichText)
|
date_lbl.setTextFormat(Qt.TextFormat.RichText)
|
||||||
date_lbl.setText(f"<i>{date_str}</i>:")
|
date_lbl.setText(f"<h3><i>{date_str}</i></h3>")
|
||||||
date_f = date_lbl.font()
|
date_f = date_lbl.font()
|
||||||
date_f.setPointSizeF(date_f.pointSizeF() + 1)
|
date_f.setPointSizeF(date_f.pointSizeF() + 1)
|
||||||
date_lbl.setFont(date_f)
|
date_lbl.setFont(date_f)
|
||||||
date_lbl.setStyleSheet("color:#000;")
|
|
||||||
outer.addWidget(date_lbl)
|
outer.addWidget(date_lbl)
|
||||||
|
|
||||||
# Preview row with optional ellipses
|
# Preview row with optional ellipses
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,14 @@ def load_db_config() -> DBConfig:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
path = Path(s.value("db/path", str(default_db_path())))
|
path = Path(s.value("db/path", str(default_db_path())))
|
||||||
key = s.value("db/key", "")
|
key = s.value("db/key", "")
|
||||||
idle = s.value("db/idle_minutes", 15, type=int)
|
idle = s.value("ui/idle_minutes", 15, type=int)
|
||||||
return DBConfig(path=path, key=key, idle_minutes=idle)
|
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:
|
def save_db_config(cfg: DBConfig) -> None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
s.setValue("db/path", str(cfg.path))
|
s.setValue("db/path", str(cfg.path))
|
||||||
s.setValue("db/key", str(cfg.key))
|
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))
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
|
QRadioButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
|
@ -26,6 +27,7 @@ from PySide6.QtGui import QPalette
|
||||||
|
|
||||||
from .db import DBConfig, DBManager
|
from .db import DBConfig, DBManager
|
||||||
from .settings import load_db_config, save_db_config
|
from .settings import load_db_config, save_db_config
|
||||||
|
from .theme import Theme
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,6 +44,31 @@ class SettingsDialog(QDialog):
|
||||||
self.setMinimumWidth(560)
|
self.setMinimumWidth(560)
|
||||||
self.setSizeGripEnabled(True)
|
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 = QLineEdit(str(self._cfg.path))
|
||||||
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
browse_btn = QPushButton("Browse…")
|
browse_btn = QPushButton("Browse…")
|
||||||
|
|
@ -64,7 +91,6 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
# Checkbox to remember key
|
# Checkbox to remember key
|
||||||
self.save_key_btn = QCheckBox("Remember key")
|
self.save_key_btn = QCheckBox("Remember key")
|
||||||
current_settings = load_db_config()
|
|
||||||
self.key = current_settings.key or ""
|
self.key = current_settings.key or ""
|
||||||
self.save_key_btn.setChecked(bool(self.key))
|
self.save_key_btn.setChecked(bool(self.key))
|
||||||
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
|
@ -188,13 +214,24 @@ class SettingsDialog(QDialog):
|
||||||
self.path_edit.setText(p)
|
self.path_edit.setText(p)
|
||||||
|
|
||||||
def _save(self):
|
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 ""
|
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
||||||
self._cfg = DBConfig(
|
self._cfg = DBConfig(
|
||||||
path=Path(self.path_edit.text()),
|
path=Path(self.path_edit.text()),
|
||||||
key=key_to_save,
|
key=key_to_save,
|
||||||
idle_minutes=self.idle_spin.value(),
|
idle_minutes=self.idle_spin.value(),
|
||||||
|
theme=selected_theme.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
save_db_config(self._cfg)
|
save_db_config(self._cfg)
|
||||||
|
self.parent().themes.apply(selected_theme)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _change_key(self):
|
def _change_key(self):
|
||||||
|
|
@ -222,6 +259,7 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def _save_key_btn_clicked(self, checked: bool):
|
def _save_key_btn_clicked(self, checked: bool):
|
||||||
|
self.key = ""
|
||||||
if checked:
|
if checked:
|
||||||
if not self.key:
|
if not self.key:
|
||||||
p1 = KeyPrompt(
|
p1 = KeyPrompt(
|
||||||
|
|
@ -233,8 +271,6 @@ class SettingsDialog(QDialog):
|
||||||
self.save_key_btn.blockSignals(False)
|
self.save_key_btn.blockSignals(False)
|
||||||
return
|
return
|
||||||
self.key = p1.key() or ""
|
self.key = p1.key() or ""
|
||||||
else:
|
|
||||||
self.key = ""
|
|
||||||
|
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def _compact_btn_clicked(self):
|
def _compact_btn_clicked(self):
|
||||||
|
|
|
||||||
105
bouquin/theme.py
Normal file
105
bouquin/theme.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from PySide6.QtGui import QPalette, QColor, QGuiApplication
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
|
||||||
|
class Theme(Enum):
|
||||||
|
SYSTEM = "system"
|
||||||
|
LIGHT = "light"
|
||||||
|
DARK = "dark"
|
||||||
|
ORANGE_ANCHOR = "#FFA500"
|
||||||
|
ORANGE_ANCHOR_VISITED = "#B38000"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThemeConfig:
|
||||||
|
theme: Theme = Theme.SYSTEM
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeManager(QObject):
|
||||||
|
themeChanged = Signal(Theme)
|
||||||
|
|
||||||
|
def __init__(self, app: QApplication, cfg: ThemeConfig):
|
||||||
|
super().__init__()
|
||||||
|
self._app = app
|
||||||
|
self._cfg = cfg
|
||||||
|
|
||||||
|
# Follow OS if supported (Qt 6+)
|
||||||
|
hints = QGuiApplication.styleHints()
|
||||||
|
if hasattr(hints, "colorSchemeChanged"):
|
||||||
|
hints.colorSchemeChanged.connect(
|
||||||
|
lambda _: (self._cfg.theme == Theme.SYSTEM)
|
||||||
|
and self.apply(self._cfg.theme)
|
||||||
|
)
|
||||||
|
|
||||||
|
def current(self) -> Theme:
|
||||||
|
return self._cfg.theme
|
||||||
|
|
||||||
|
def set(self, theme: Theme):
|
||||||
|
self._cfg.theme = theme
|
||||||
|
self.apply(theme)
|
||||||
|
|
||||||
|
def apply(self, theme: Theme):
|
||||||
|
# Resolve "system"
|
||||||
|
if theme == Theme.SYSTEM:
|
||||||
|
hints = QGuiApplication.styleHints()
|
||||||
|
scheme = getattr(hints, "colorScheme", None)
|
||||||
|
if callable(scheme):
|
||||||
|
scheme = hints.colorScheme()
|
||||||
|
# 0=Light, 1=Dark in newer Qt; fall back to Light
|
||||||
|
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
|
||||||
|
|
||||||
|
# Always use Fusion so palette applies consistently cross-platform
|
||||||
|
self._app.setStyle("Fusion")
|
||||||
|
|
||||||
|
if theme == Theme.DARK:
|
||||||
|
pal = self._dark_palette()
|
||||||
|
self._app.setPalette(pal)
|
||||||
|
# keep stylesheet empty unless you need widget-specific tweaks
|
||||||
|
self._app.setStyleSheet("")
|
||||||
|
else:
|
||||||
|
pal = self._light_palette()
|
||||||
|
self._app.setPalette(pal)
|
||||||
|
self._app.setStyleSheet("")
|
||||||
|
|
||||||
|
self.themeChanged.emit(theme)
|
||||||
|
|
||||||
|
# ----- Palettes -----
|
||||||
|
def _dark_palette(self) -> QPalette:
|
||||||
|
pal = QPalette()
|
||||||
|
base = QColor(35, 35, 35)
|
||||||
|
window = QColor(53, 53, 53)
|
||||||
|
text = QColor(220, 220, 220)
|
||||||
|
disabled = QColor(127, 127, 127)
|
||||||
|
focus = QColor(42, 130, 218)
|
||||||
|
|
||||||
|
pal.setColor(QPalette.Window, window)
|
||||||
|
pal.setColor(QPalette.WindowText, text)
|
||||||
|
pal.setColor(QPalette.Base, base)
|
||||||
|
pal.setColor(QPalette.AlternateBase, window)
|
||||||
|
pal.setColor(QPalette.ToolTipBase, window)
|
||||||
|
pal.setColor(QPalette.ToolTipText, text)
|
||||||
|
pal.setColor(QPalette.Text, text)
|
||||||
|
pal.setColor(QPalette.PlaceholderText, disabled)
|
||||||
|
pal.setColor(QPalette.Button, window)
|
||||||
|
pal.setColor(QPalette.ButtonText, text)
|
||||||
|
pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
|
||||||
|
pal.setColor(QPalette.Highlight, focus)
|
||||||
|
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
|
||||||
|
pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
|
||||||
|
pal.setColor(QPalette.LinkVisited, QColor(Theme.ORANGE_ANCHOR_VISITED.value))
|
||||||
|
|
||||||
|
return pal
|
||||||
|
|
||||||
|
def _light_palette(self) -> QPalette:
|
||||||
|
# Let Qt provide its default light palette, but nudge a couple roles
|
||||||
|
pal = self._app.style().standardPalette()
|
||||||
|
pal.setColor(QPalette.Highlight, QColor(0, 120, 215))
|
||||||
|
pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
|
||||||
|
pal.setColor(
|
||||||
|
QPalette.Link, QColor("#1a73e8")
|
||||||
|
) # Light blue for links in light mode
|
||||||
|
return pal
|
||||||
|
|
@ -12,6 +12,9 @@ os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0")
|
||||||
|
|
||||||
|
|
||||||
# Make project importable
|
# Make project importable
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
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."""
|
"""Launch the app and immediately satisfy first-run/unlock key prompts."""
|
||||||
from bouquin.main_window import MainWindow
|
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)
|
qtbot.addWidget(win)
|
||||||
win.show()
|
win.show()
|
||||||
qtbot.waitExposed(win)
|
qtbot.waitExposed(win)
|
||||||
|
|
@ -75,3 +81,24 @@ def today_iso():
|
||||||
|
|
||||||
d = date.today()
|
d = date.today()
|
||||||
return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
|
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
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ class AutoResponder:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
wid = id(w)
|
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):
|
if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w):
|
||||||
fill_first_line_edit_and_accept(w, "ci-secret-key")
|
fill_first_line_edit_and_accept(w, "ci-secret-key")
|
||||||
self._seen.add(wid)
|
self._seen.add(wid)
|
||||||
|
|
|
||||||
137
tests/test_db_unit.py
Normal file
137
tests/test_db_unit.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import bouquin.db as dbmod
|
||||||
|
from bouquin.db import DBConfig, DBManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __init__(self, rows=None):
|
||||||
|
self._rows = rows or []
|
||||||
|
self.executed = []
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
self.executed.append((sql, tuple(params) if params else None))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def __init__(self, rows=None):
|
||||||
|
self._rows = rows or []
|
||||||
|
self.closed = False
|
||||||
|
self.cursors = []
|
||||||
|
self.row_factory = None
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
c = FakeCursor(rows=self._rows)
|
||||||
|
self.cursors.append(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrity_ok_ok(monkeypatch, tmp_path):
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||||
|
mgr.conn = FakeConn(rows=[])
|
||||||
|
assert mgr._integrity_ok() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrity_ok_raises(monkeypatch, tmp_path):
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||||
|
mgr.conn = FakeConn(rows=[("oops",), (None,)])
|
||||||
|
try:
|
||||||
|
mgr._integrity_ok()
|
||||||
|
except Exception as e:
|
||||||
|
assert isinstance(e, dbmod.sqlite.IntegrityError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path):
|
||||||
|
# Use a non-empty key to avoid SQLCipher complaining before our patch runs
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||||
|
# Make the integrity check raise so connect() takes the failure path
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DBManager,
|
||||||
|
"_integrity_ok",
|
||||||
|
lambda self: (_ for _ in ()).throw(RuntimeError("bad")),
|
||||||
|
)
|
||||||
|
ok = mgr.connect()
|
||||||
|
assert ok is False
|
||||||
|
assert mgr.conn is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rekey_not_connected_raises(tmp_path):
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
|
||||||
|
mgr.conn = None
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
mgr.rekey("new")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rekey_reopen_failure(monkeypatch, tmp_path):
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
|
||||||
|
mgr.conn = FakeConn(rows=[(None,)])
|
||||||
|
monkeypatch.setattr(DBManager, "connect", lambda self: False)
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
mgr.rekey("new")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_by_extension_and_unknown(tmp_path):
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||||
|
entries = [("2025-01-01", "<b>Hi</b>")]
|
||||||
|
# Test each exporter writes the file
|
||||||
|
p = tmp_path / "out.json"
|
||||||
|
mgr.export_json(entries, str(p))
|
||||||
|
assert p.exists() and p.stat().st_size > 0
|
||||||
|
p = tmp_path / "out.csv"
|
||||||
|
mgr.export_csv(entries, str(p))
|
||||||
|
assert p.exists()
|
||||||
|
p = tmp_path / "out.txt"
|
||||||
|
mgr.export_txt(entries, str(p))
|
||||||
|
assert p.exists()
|
||||||
|
p = tmp_path / "out.html"
|
||||||
|
mgr.export_html(entries, str(p))
|
||||||
|
assert p.exists()
|
||||||
|
p = tmp_path / "out.md"
|
||||||
|
mgr.export_markdown(entries, str(p))
|
||||||
|
assert p.exists()
|
||||||
|
# Router
|
||||||
|
import types
|
||||||
|
|
||||||
|
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
|
||||||
|
for ext in [".json", ".csv", ".txt", ".html"]:
|
||||||
|
path = tmp_path / f"route{ext}"
|
||||||
|
mgr.export_by_extension(str(path))
|
||||||
|
assert path.exists()
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr.export_by_extension(str(tmp_path / "x.zzz"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_error_prints(monkeypatch, tmp_path, capsys):
|
||||||
|
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||||
|
|
||||||
|
class BadConn:
|
||||||
|
def cursor(self):
|
||||||
|
raise RuntimeError("no")
|
||||||
|
|
||||||
|
mgr.conn = BadConn()
|
||||||
|
mgr.compact()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Error:" in out
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl
|
||||||
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat
|
from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat
|
||||||
from PySide6.QtTest import QTest
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from bouquin.editor import Editor
|
from bouquin.editor import Editor
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
import re
|
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:
|
def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
|
||||||
c = editor.textCursor()
|
c = editor.textCursor()
|
||||||
c.movePosition(QTextCursor.Start)
|
c.movePosition(QTextCursor.Start)
|
||||||
|
|
@ -31,7 +40,7 @@ def _fmt_at(editor: Editor, pos: int):
|
||||||
|
|
||||||
|
|
||||||
def test_space_breaks_link_anchor_and_styling(qtbot):
|
def test_space_breaks_link_anchor_and_styling(qtbot):
|
||||||
e = Editor()
|
e = _mk_editor()
|
||||||
e.resize(600, 300)
|
e.resize(600, 300)
|
||||||
e.show()
|
e.show()
|
||||||
qtbot.waitExposed(e)
|
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):
|
def test_embed_qimage_saved_as_data_url(qtbot):
|
||||||
e = Editor()
|
e = _mk_editor()
|
||||||
e.resize(600, 400)
|
e.resize(600, 400)
|
||||||
qtbot.addWidget(e)
|
qtbot.addWidget(e)
|
||||||
e.show()
|
e.show()
|
||||||
|
|
@ -96,7 +105,7 @@ def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
|
||||||
big_path = tmp_path / "big.png"
|
big_path = tmp_path / "big.png"
|
||||||
big.save(str(big_path))
|
big.save(str(big_path))
|
||||||
|
|
||||||
e = Editor()
|
e = _mk_editor()
|
||||||
e.resize(420, 300) # known viewport width
|
e.resize(420, 300) # known viewport width
|
||||||
qtbot.addWidget(e)
|
qtbot.addWidget(e)
|
||||||
e.show()
|
e.show()
|
||||||
|
|
@ -120,7 +129,7 @@ def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_linkify_trims_trailing_punctuation(qtbot):
|
def test_linkify_trims_trailing_punctuation(qtbot):
|
||||||
e = Editor()
|
e = _mk_editor()
|
||||||
qtbot.addWidget(e)
|
qtbot.addWidget(e)
|
||||||
e.show()
|
e.show()
|
||||||
qtbot.waitExposed(e)
|
qtbot.waitExposed(e)
|
||||||
|
|
@ -135,31 +144,13 @@ def test_linkify_trims_trailing_punctuation(qtbot):
|
||||||
assert 'href="https://example.com)."' not 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):
|
def test_code_block_enter_exits_on_empty_line(qtbot):
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtGui import QTextCursor
|
from PySide6.QtGui import QTextCursor
|
||||||
from PySide6.QtTest import QTest
|
from PySide6.QtTest import QTest
|
||||||
from bouquin.editor import Editor
|
from bouquin.editor import Editor
|
||||||
|
|
||||||
e = Editor()
|
e = _mk_editor()
|
||||||
qtbot.addWidget(e)
|
qtbot.addWidget(e)
|
||||||
e.show()
|
e.show()
|
||||||
qtbot.waitExposed(e)
|
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
|
# Second Enter should jump *out* of the frame
|
||||||
QTest.keyClick(e, Qt.Key_Return)
|
QTest.keyClick(e, Qt.Key_Return)
|
||||||
# qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None)
|
# qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyMenu:
|
||||||
|
def __init__(self):
|
||||||
|
self.seps = 0
|
||||||
|
self.subs = []
|
||||||
|
self.exec_called = False
|
||||||
|
|
||||||
|
def addSeparator(self):
|
||||||
|
self.seps += 1
|
||||||
|
|
||||||
|
def addMenu(self, title):
|
||||||
|
m = DummyMenu()
|
||||||
|
self.subs.append((title, m))
|
||||||
|
return m
|
||||||
|
|
||||||
|
def addAction(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self, *a, **k):
|
||||||
|
self.exec_called = True
|
||||||
|
|
||||||
|
|
||||||
|
def _themes():
|
||||||
|
app = QApplication.instance()
|
||||||
|
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_menu_adds_image_actions(monkeypatch, qtbot):
|
||||||
|
e = Editor(_themes())
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
# Fake an image at cursor
|
||||||
|
qi = QImage(10, 10, QImage.Format_ARGB32)
|
||||||
|
qi.fill(0xFF00FF00)
|
||||||
|
imgfmt = QTextImageFormat()
|
||||||
|
imgfmt.setName("x")
|
||||||
|
imgfmt.setWidth(10)
|
||||||
|
imgfmt.setHeight(10)
|
||||||
|
tc = e.textCursor()
|
||||||
|
monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi))
|
||||||
|
|
||||||
|
dummy = DummyMenu()
|
||||||
|
monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy)
|
||||||
|
|
||||||
|
class Evt:
|
||||||
|
def globalPos(self):
|
||||||
|
return QPoint(0, 0)
|
||||||
|
|
||||||
|
e.contextMenuEvent(Evt())
|
||||||
|
assert dummy.exec_called
|
||||||
|
assert dummy.seps == 1
|
||||||
|
assert any(t == "Image size" for t, _ in dummy.subs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_from_mime_image_and_urls(tmp_path, qtbot):
|
||||||
|
e = Editor(_themes())
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
# Build a mime with an image
|
||||||
|
mime = QMimeData()
|
||||||
|
img = QImage(6, 6, QImage.Format_ARGB32)
|
||||||
|
img.fill(0xFF0000FF)
|
||||||
|
mime.setImageData(img)
|
||||||
|
e.insertFromMimeData(mime)
|
||||||
|
html = e.document().toHtml()
|
||||||
|
assert "<img" in html
|
||||||
|
|
||||||
|
# Now with urls: local non-image + local image + remote url
|
||||||
|
png = tmp_path / "t.png"
|
||||||
|
img.save(str(png))
|
||||||
|
txt = tmp_path / "x.txt"
|
||||||
|
txt.write_text("hi", encoding="utf-8")
|
||||||
|
mime2 = QMimeData()
|
||||||
|
mime2.setUrls(
|
||||||
|
[
|
||||||
|
QUrl.fromLocalFile(str(txt)),
|
||||||
|
QUrl.fromLocalFile(str(png)),
|
||||||
|
QUrl("https://example.com/file"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
e.insertFromMimeData(mime2)
|
||||||
|
h2 = e.document().toHtml()
|
||||||
|
assert 'href="file://' in h2 # local file link inserted
|
||||||
|
assert "<img" in h2 # image inserted
|
||||||
|
assert 'href="https://example.com/file"' in h2 # remote url link
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_release_ctrl_click_opens(monkeypatch, qtbot):
|
||||||
|
e = Editor(_themes())
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
# Anchor under cursor
|
||||||
|
monkeypatch.setattr(e, "anchorAt", lambda p: "https://example.com")
|
||||||
|
opened = {}
|
||||||
|
from PySide6.QtGui import QDesktopServices as DS
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DS, "openUrl", lambda url: opened.setdefault("u", url.toString())
|
||||||
|
)
|
||||||
|
ev = QMouseEvent(
|
||||||
|
QMouseEvent.MouseButtonRelease,
|
||||||
|
QPoint(1, 1),
|
||||||
|
Qt.LeftButton,
|
||||||
|
Qt.LeftButton,
|
||||||
|
Qt.ControlModifier,
|
||||||
|
)
|
||||||
|
e.mouseReleaseEvent(ev)
|
||||||
|
assert opened.get("u") == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_keypress_space_breaks_anchor(monkeypatch, qtbot):
|
||||||
|
e = Editor(_themes())
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
called = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
e, "_break_anchor_for_next_char", lambda: called.setdefault("x", True)
|
||||||
|
)
|
||||||
|
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Space, Qt.NoModifier, " ")
|
||||||
|
e.keyPressEvent(ev)
|
||||||
|
assert called.get("x") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_leaves_code_frame(qtbot):
|
||||||
|
e = Editor(_themes())
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
e.setPlainText("")
|
||||||
|
# Insert a code block frame
|
||||||
|
e.apply_code()
|
||||||
|
# Place cursor inside the empty code block
|
||||||
|
c = e.textCursor()
|
||||||
|
c.movePosition(QTextCursor.End)
|
||||||
|
e.setTextCursor(c)
|
||||||
|
# Press Enter; should jump outside the frame and start normal paragraph
|
||||||
|
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
|
||||||
|
e.keyPressEvent(ev)
|
||||||
|
# After enter, the cursor should not be inside a code frame
|
||||||
|
assert e._find_code_frame(e.textCursor()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_space_does_not_bleed_anchor_format(qtbot):
|
||||||
|
e = _mk_editor()
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
e.show()
|
||||||
|
qtbot.waitExposed(e)
|
||||||
|
|
||||||
|
e.setPlainText("https://a.example")
|
||||||
|
qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
|
||||||
|
|
||||||
|
c = e.textCursor()
|
||||||
|
c.movePosition(QTextCursor.End)
|
||||||
|
e.setTextCursor(c)
|
||||||
|
|
||||||
|
# Press Space; keyPressEvent should break the anchor for the next char
|
||||||
|
QTest.keyClick(e, Qt.Key_Space)
|
||||||
|
assert e.currentCharFormat().isAnchor() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_small_helpers(qtbot):
|
||||||
|
app = QApplication.instance()
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
e = Editor(themes)
|
||||||
|
qtbot.addWidget(e)
|
||||||
|
# _approx returns True when |a-b| <= eps
|
||||||
|
assert e._approx(1.0, 1.25, eps=0.3) is True
|
||||||
|
assert e._approx(1.0, 1.6, eps=0.3) is False
|
||||||
|
# Exercise helpers
|
||||||
|
_ = e._is_heading_typing()
|
||||||
|
e._apply_normal_typing()
|
||||||
|
|
|
||||||
69
tests/test_entrypoints.py
Normal file
69
tests/test_entrypoints.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def test___main___exports_main():
|
||||||
|
entry_mod = importlib.import_module("bouquin.__main__")
|
||||||
|
main_mod = importlib.import_module("bouquin.main")
|
||||||
|
assert entry_mod.main is main_mod.main
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_entry_initializes_qt(monkeypatch):
|
||||||
|
main_mod = importlib.import_module("bouquin.main")
|
||||||
|
|
||||||
|
# Fakes to avoid real Qt event loop
|
||||||
|
class FakeApp:
|
||||||
|
def __init__(self, argv):
|
||||||
|
self.argv = argv
|
||||||
|
self.name = None
|
||||||
|
self.org = None
|
||||||
|
|
||||||
|
def setApplicationName(self, n):
|
||||||
|
self.name = n
|
||||||
|
|
||||||
|
def setOrganizationName(self, n):
|
||||||
|
self.org = n
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class FakeWin:
|
||||||
|
def __init__(self, themes=None):
|
||||||
|
self.themes = themes
|
||||||
|
self.shown = False
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
self.shown = True
|
||||||
|
|
||||||
|
class FakeThemes:
|
||||||
|
def __init__(self, app, cfg):
|
||||||
|
self._applied = None
|
||||||
|
self.app = app
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
def apply(self, t):
|
||||||
|
self._applied = t
|
||||||
|
|
||||||
|
class FakeSettings:
|
||||||
|
def __init__(self):
|
||||||
|
self._map = {"ui/theme": "dark"}
|
||||||
|
|
||||||
|
def value(self, k, default=None, type=None):
|
||||||
|
return self._map.get(k, default)
|
||||||
|
|
||||||
|
def fake_get_settings():
|
||||||
|
return FakeSettings()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_mod, "QApplication", FakeApp)
|
||||||
|
monkeypatch.setattr(main_mod, "MainWindow", FakeWin)
|
||||||
|
monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes)
|
||||||
|
monkeypatch.setattr(main_mod, "get_settings", fake_get_settings)
|
||||||
|
|
||||||
|
exits = {}
|
||||||
|
|
||||||
|
def fake_exit(code):
|
||||||
|
exits["code"] = code
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_mod.sys, "exit", fake_exit)
|
||||||
|
|
||||||
|
main_mod.main()
|
||||||
|
assert exits.get("code", None) == 0
|
||||||
66
tests/test_history_dialog_unit.py
Normal file
66
tests/test_history_dialog_unit.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from PySide6.QtWidgets import QListWidgetItem
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from bouquin.history_dialog import HistoryDialog
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self.fail_revert = False
|
||||||
|
|
||||||
|
def list_versions(self, date_iso):
|
||||||
|
# Simulate two versions; mark second as current
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"version_no": 1,
|
||||||
|
"created_at": "2025-01-01T10:00:00Z",
|
||||||
|
"note": None,
|
||||||
|
"is_current": False,
|
||||||
|
"content": "<p>a</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"version_no": 2,
|
||||||
|
"created_at": "2025-01-02T10:00:00Z",
|
||||||
|
"note": None,
|
||||||
|
"is_current": True,
|
||||||
|
"content": "<p>b</p>",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_version(self, version_id):
|
||||||
|
if version_id == 2:
|
||||||
|
return {"content": "<p>b</p>"}
|
||||||
|
return {"content": "<p>a</p>"}
|
||||||
|
|
||||||
|
def revert_to_version(self, date, version_id=None, version_no=None):
|
||||||
|
if self.fail_revert:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_select_no_item(qtbot):
|
||||||
|
dlg = HistoryDialog(FakeDB(), "2025-01-01")
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.list.clear()
|
||||||
|
dlg._on_select()
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_failure_shows_critical(qtbot, monkeypatch):
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
fake = FakeDB()
|
||||||
|
fake.fail_revert = True
|
||||||
|
dlg = HistoryDialog(fake, "2025-01-01")
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
item = QListWidgetItem("v1")
|
||||||
|
item.setData(Qt.UserRole, 1) # different from current 2
|
||||||
|
dlg.list.addItem(item)
|
||||||
|
dlg.list.setCurrentItem(item)
|
||||||
|
msgs = {}
|
||||||
|
|
||||||
|
def fake_crit(parent, title, text):
|
||||||
|
msgs["t"] = (title, text)
|
||||||
|
|
||||||
|
monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit))
|
||||||
|
dlg._revert()
|
||||||
|
assert "Revert failed" in msgs["t"][0]
|
||||||
113
tests/test_misc.py
Normal file
113
tests/test_misc.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
from bouquin.main_window import MainWindow
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
from bouquin.db import DBConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _themes_light():
|
||||||
|
app = QApplication.instance()
|
||||||
|
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
||||||
|
|
||||||
|
def _themes_dark():
|
||||||
|
app = QApplication.instance()
|
||||||
|
return ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDBErr:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
raise Exception("file is not a database")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDBOk:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def save_new_version(self, date, text, note):
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
def get_entry(self, date):
|
||||||
|
return "<p>hi</p>"
|
||||||
|
|
||||||
|
def get_entries_days(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path):
|
||||||
|
# Config with a key so __init__ calls _try_connect immediately
|
||||||
|
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||||
|
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr)
|
||||||
|
msgs = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m))
|
||||||
|
)
|
||||||
|
w = MainWindow(_themes_light()) # auto-calls _try_connect
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
assert "incorrect" in msgs.get("m", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path):
|
||||||
|
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||||
|
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
|
||||||
|
w = MainWindow(_themes_dark())
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
w._apply_link_css()
|
||||||
|
css = w.editor.document().defaultStyleSheet()
|
||||||
|
assert "a {" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path):
|
||||||
|
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||||
|
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
|
||||||
|
w = MainWindow(_themes_light())
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
called = {}
|
||||||
|
|
||||||
|
class FakeSettings:
|
||||||
|
def value(self, key, default=None, type=None):
|
||||||
|
if key == "main/geometry":
|
||||||
|
return None
|
||||||
|
if key == "main/windowState":
|
||||||
|
return None
|
||||||
|
if key == "main/maximized":
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
w.settings = FakeSettings()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True)
|
||||||
|
)
|
||||||
|
w._restore_window_position()
|
||||||
|
assert called.get("x") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path):
|
||||||
|
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||||
|
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
|
||||||
|
w = MainWindow(_themes_light())
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
w.editor, "insert_images", lambda paths: captured.setdefault("p", paths)
|
||||||
|
)
|
||||||
|
# Simulate file dialog returning paths
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"bouquin.main_window.QFileDialog.getOpenFileNames",
|
||||||
|
staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")),
|
||||||
|
)
|
||||||
|
w._on_insert_image()
|
||||||
|
assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"]
|
||||||
57
tests/test_search_unit.py
Normal file
57
tests/test_search_unit.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QListWidgetItem
|
||||||
|
|
||||||
|
# The widget class is named `Search` in bouquin.search
|
||||||
|
from bouquin.search import Search as SearchWidget
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
def search_entries(self, q):
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_empty_clears_and_hides(qtbot):
|
||||||
|
w = SearchWidget(db=FakeDB([]))
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
w.show()
|
||||||
|
qtbot.waitExposed(w)
|
||||||
|
dates = []
|
||||||
|
w.resultDatesChanged.connect(lambda ds: dates.extend(ds))
|
||||||
|
w._search(" ")
|
||||||
|
assert w.results.isHidden()
|
||||||
|
assert dates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_populate_empty_hides(qtbot):
|
||||||
|
w = SearchWidget(db=FakeDB([]))
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
w._populate_results("x", [])
|
||||||
|
assert w.results.isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_selected_emits_when_present(qtbot):
|
||||||
|
w = SearchWidget(db=FakeDB([]))
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
got = {}
|
||||||
|
w.openDateRequested.connect(lambda d: got.setdefault("d", d))
|
||||||
|
it = QListWidgetItem("x")
|
||||||
|
it.setData(Qt.ItemDataRole.UserRole, "")
|
||||||
|
w._open_selected(it)
|
||||||
|
assert "d" not in got
|
||||||
|
it.setData(Qt.ItemDataRole.UserRole, "2025-01-02")
|
||||||
|
w._open_selected(it)
|
||||||
|
assert got["d"] == "2025-01-02"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_html_snippet_edge_cases(qtbot):
|
||||||
|
w = SearchWidget(db=FakeDB([]))
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
# Empty HTML -> empty fragment, no ellipses
|
||||||
|
frag, l, r = w._make_html_snippet("", "hello")
|
||||||
|
assert frag == "" and not l and not r
|
||||||
|
# Small doc around token -> should not show ellipses
|
||||||
|
frag, l, r = w._make_html_snippet("<p>Hello world</p>", "world")
|
||||||
|
assert "<b>world</b>" in frag or "world" in frag
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
from pathlib import Path
|
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.db import DBConfig
|
||||||
from bouquin.settings_dialog import SettingsDialog
|
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:
|
class FakeDB:
|
||||||
|
|
@ -58,7 +73,22 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
|
||||||
p = AcceptingPrompt().set_key("sekrit")
|
p = AcceptingPrompt().set_key("sekrit")
|
||||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
|
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)
|
qtbot.addWidget(dlg)
|
||||||
dlg.show()
|
dlg.show()
|
||||||
qtbot.waitExposed(dlg)
|
qtbot.waitExposed(dlg)
|
||||||
|
|
@ -77,6 +107,7 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
|
||||||
assert out.path == new_path
|
assert out.path == new_path
|
||||||
assert out.idle_minutes == 0
|
assert out.idle_minutes == 0
|
||||||
assert out.key == "sekrit"
|
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):
|
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)
|
dlg.save_key_btn.setChecked(True)
|
||||||
# We should reach here with the original key preserved.
|
# We should reach here with the original key preserved.
|
||||||
assert dlg.key == "already"
|
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
|
||||||
|
|
|
||||||
28
tests/test_settings_module.py
Normal file
28
tests/test_settings_module.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from bouquin.db import DBConfig
|
||||||
|
import bouquin.settings as settings
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSettings:
|
||||||
|
def __init__(self):
|
||||||
|
self.store = {}
|
||||||
|
|
||||||
|
def value(self, key, default=None, type=None):
|
||||||
|
return self.store.get(key, default)
|
||||||
|
|
||||||
|
def setValue(self, key, value):
|
||||||
|
self.store[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path):
|
||||||
|
fake = FakeSettings()
|
||||||
|
monkeypatch.setattr(settings, "get_settings", lambda: fake)
|
||||||
|
|
||||||
|
cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark")
|
||||||
|
settings.save_db_config(cfg)
|
||||||
|
|
||||||
|
# Now read back into a new DBConfig
|
||||||
|
cfg2 = settings.load_db_config()
|
||||||
|
assert cfg2.path == cfg.path
|
||||||
|
assert cfg2.key == "k"
|
||||||
|
assert cfg2.idle_minutes == "7"
|
||||||
|
assert cfg2.theme == "dark"
|
||||||
19
tests/test_theme_integration.py
Normal file
19
tests/test_theme_integration.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from bouquin.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_link_css_dark_theme(open_window, qtbot):
|
||||||
|
win = open_window
|
||||||
|
# Switch to dark and apply link CSS
|
||||||
|
win.themes.set(Theme.DARK)
|
||||||
|
win._apply_link_css()
|
||||||
|
css = win.editor.document().defaultStyleSheet()
|
||||||
|
assert "#FFA500" in css and "a:visited" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_link_css_light_theme(open_window, qtbot):
|
||||||
|
win = open_window
|
||||||
|
# Switch to light and apply link CSS
|
||||||
|
win.themes.set(Theme.LIGHT)
|
||||||
|
win._apply_link_css()
|
||||||
|
css = win.editor.document().defaultStyleSheet()
|
||||||
|
assert css == "" or "a {" not in css
|
||||||
19
tests/test_theme_manager.py
Normal file
19
tests/test_theme_manager.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from PySide6.QtGui import QPalette, QColor
|
||||||
|
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_manager_applies_palettes(qtbot):
|
||||||
|
app = QApplication.instance()
|
||||||
|
tm = ThemeManager(app, ThemeConfig())
|
||||||
|
|
||||||
|
# Light palette should set Link to the light blue
|
||||||
|
tm.apply(Theme.LIGHT)
|
||||||
|
pal = app.palette()
|
||||||
|
assert pal.color(QPalette.Link) == QColor("#1a73e8")
|
||||||
|
|
||||||
|
# Dark palette should set Link to lavender-ish
|
||||||
|
tm.apply(Theme.DARK)
|
||||||
|
pal = app.palette()
|
||||||
|
assert pal.color(QPalette.Link) == QColor("#FFA500")
|
||||||
23
tests/test_toolbar_private.py
Normal file
23
tests/test_toolbar_private.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from bouquin.toolbar import ToolBar
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_letter_button_handles_missing_widget(qtbot):
|
||||||
|
tb = ToolBar()
|
||||||
|
qtbot.addWidget(tb)
|
||||||
|
# Create a dummy action detached from toolbar to force widgetForAction->None
|
||||||
|
from PySide6.QtGui import QAction
|
||||||
|
|
||||||
|
act = QAction("X", tb)
|
||||||
|
# No crash and early return
|
||||||
|
tb._style_letter_button(act, "X")
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_letter_button_sets_tooltip_and_accessible(qtbot):
|
||||||
|
tb = ToolBar()
|
||||||
|
qtbot.addWidget(tb)
|
||||||
|
# Use an existing action so widgetForAction returns a button
|
||||||
|
act = tb.actBold
|
||||||
|
tb._style_letter_button(act, "B", bold=True, tooltip="Bold")
|
||||||
|
btn = tb.widgetForAction(act)
|
||||||
|
assert btn.toolTip() == "Bold"
|
||||||
|
assert btn.accessibleName() == "Bold"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue