convert to markdown #1
54 changed files with 1616 additions and 4012 deletions
|
|
@ -1,3 +1,7 @@
|
||||||
|
# 0.2.0
|
||||||
|
|
||||||
|
* Switch back to Markdown editor
|
||||||
|
|
||||||
# 0.1.12.1
|
# 0.1.12.1
|
||||||
|
|
||||||
* Fix newline after URL keeps URL style formatting
|
* Fix newline after URL keeps URL style formatting
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ There is deliberately no network connectivity or syncing intended.
|
||||||
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
||||||
* Every 'page' is linked to the calendar day
|
* Every 'page' is linked to the calendar day
|
||||||
* All changes are version controlled, with ability to view/diff versions and revert
|
* All changes are version controlled, with ability to view/diff versions and revert
|
||||||
* Text is HTML with basic styling
|
* Text is Markdown with basic styling
|
||||||
* Images are supported
|
* Images are supported
|
||||||
* Search
|
* Search
|
||||||
* Automatic periodic saving (or explicitly save)
|
* Automatic periodic saving (or explicitly save)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from markdownify import markdownify as md
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlcipher3 import dbapi2 as sqlite
|
from sqlcipher3 import dbapi2 as sqlite
|
||||||
from typing import List, Sequence, Tuple
|
from typing import List, Sequence, Tuple
|
||||||
|
|
@ -401,25 +400,13 @@ class DBManager:
|
||||||
Export to HTML, similar to export_html, but then convert to Markdown
|
Export to HTML, similar to export_html, but then convert to Markdown
|
||||||
using markdownify, and finally save to file.
|
using markdownify, and finally save to file.
|
||||||
"""
|
"""
|
||||||
parts = [
|
parts = []
|
||||||
"<!doctype html>",
|
|
||||||
'<html lang="en">',
|
|
||||||
"<body>",
|
|
||||||
f"<h1>{html.escape(title)}</h1>",
|
|
||||||
]
|
|
||||||
for d, c in entries:
|
for d, c in entries:
|
||||||
parts.append(
|
parts.append(f"# {d}")
|
||||||
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
|
parts.append(c)
|
||||||
)
|
|
||||||
parts.append("</body></html>")
|
|
||||||
|
|
||||||
# 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:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(md_items))
|
f.write("\n".join(parts))
|
||||||
|
|
||||||
def export_sql(self, file_path: str) -> None:
|
def export_sql(self, file_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
1015
bouquin/editor.py
1015
bouquin/editor.py
File diff suppressed because it is too large
Load diff
|
|
@ -16,31 +16,33 @@ from PySide6.QtWidgets import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _html_to_text(s: str) -> str:
|
def _markdown_to_text(s: str) -> str:
|
||||||
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
|
"""Convert markdown to plain text for diff comparison."""
|
||||||
IMG_RE = re.compile(r"(?is)<img\b[^>]*>")
|
# Remove images
|
||||||
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
|
||||||
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
# Remove inline code formatting
|
||||||
BR_RE = re.compile(r"(?i)<br\s*/?>")
|
s = re.sub(r"`([^`]+)`", r"\1", s)
|
||||||
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
|
# Remove bold/italic markers
|
||||||
TAG_RE = re.compile(r"<[^>]+>")
|
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
|
||||||
MULTINL_RE = re.compile(r"\n{3,}")
|
s = re.sub(r"__([^_]+)__", r"\1", s)
|
||||||
|
s = re.sub(r"\*([^*]+)\*", r"\1", s)
|
||||||
s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
|
s = re.sub(r"_([^_]+)_", r"\1", s)
|
||||||
s = STYLE_SCRIPT_RE.sub("", s)
|
# Remove strikethrough
|
||||||
s = COMMENT_RE.sub("", s)
|
s = re.sub(r"~~([^~]+)~~", r"\1", s)
|
||||||
s = BR_RE.sub("\n", s)
|
# Remove heading markers
|
||||||
s = BLOCK_END_RE.sub("\n", s)
|
s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
|
||||||
s = TAG_RE.sub("", s)
|
# Remove list markers
|
||||||
s = _html.unescape(s)
|
s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
|
||||||
s = MULTINL_RE.sub("\n\n", s)
|
s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
|
||||||
|
# Remove checkbox markers
|
||||||
|
s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
|
||||||
return s.strip()
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
|
def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
||||||
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
||||||
a = _html_to_text(old_html).splitlines()
|
a = _markdown_to_text(old_md).splitlines()
|
||||||
b = _html_to_text(new_html).splitlines()
|
b = _markdown_to_text(new_md).splitlines()
|
||||||
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
||||||
lines = []
|
lines = []
|
||||||
for line in ud:
|
for line in ud:
|
||||||
|
|
@ -150,9 +152,13 @@ class HistoryDialog(QDialog):
|
||||||
self.btn_revert.setEnabled(False)
|
self.btn_revert.setEnabled(False)
|
||||||
return
|
return
|
||||||
sel_id = item.data(Qt.UserRole)
|
sel_id = item.data(Qt.UserRole)
|
||||||
# Preview selected as HTML
|
# Preview selected as plain text (markdown)
|
||||||
sel = self._db.get_version(version_id=sel_id)
|
sel = self._db.get_version(version_id=sel_id)
|
||||||
self.preview.setHtml(sel["content"])
|
# Show markdown as plain text with monospace font for better readability
|
||||||
|
self.preview.setPlainText(sel["content"])
|
||||||
|
self.preview.setStyleSheet(
|
||||||
|
"font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
|
||||||
|
)
|
||||||
# Diff vs current (textual diff)
|
# Diff vs current (textual diff)
|
||||||
cur = self._db.get_version(version_id=self._current_id)
|
cur = self._db.get_version(version_id=self._current_id)
|
||||||
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ from PySide6.QtWidgets import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .editor import Editor
|
from .markdown_editor import MarkdownEditor
|
||||||
from .find_bar import FindBar
|
from .find_bar import FindBar
|
||||||
from .history_dialog import HistoryDialog
|
from .history_dialog import HistoryDialog
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
|
|
@ -99,7 +99,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.themes)
|
self.editor = MarkdownEditor(self.themes)
|
||||||
|
|
||||||
# Toolbar for controlling styling
|
# Toolbar for controlling styling
|
||||||
self.toolBar = ToolBar()
|
self.toolBar = ToolBar()
|
||||||
|
|
@ -107,14 +107,14 @@ class MainWindow(QMainWindow):
|
||||||
# Wire toolbar intents to editor methods
|
# Wire toolbar intents to editor methods
|
||||||
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
||||||
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
||||||
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
|
# Note: Markdown doesn't support underline, so we skip underlineRequested
|
||||||
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
||||||
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
||||||
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
||||||
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
||||||
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
||||||
self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
|
self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
|
||||||
self.toolBar.alignRequested.connect(self.editor.setAlignment)
|
# Note: Markdown doesn't natively support alignment, removing alignRequested
|
||||||
self.toolBar.historyRequested.connect(self._open_history)
|
self.toolBar.historyRequested.connect(self._open_history)
|
||||||
self.toolBar.insertImageRequested.connect(self._on_insert_image)
|
self.toolBar.insertImageRequested.connect(self._on_insert_image)
|
||||||
|
|
||||||
|
|
@ -450,17 +450,14 @@ class MainWindow(QMainWindow):
|
||||||
def _sync_toolbar(self):
|
def _sync_toolbar(self):
|
||||||
fmt = self.editor.currentCharFormat()
|
fmt = self.editor.currentCharFormat()
|
||||||
c = self.editor.textCursor()
|
c = self.editor.textCursor()
|
||||||
bf = c.blockFormat()
|
|
||||||
|
|
||||||
# Block signals so setChecked() doesn't re-trigger actions
|
# Block signals so setChecked() doesn't re-trigger actions
|
||||||
QSignalBlocker(self.toolBar.actBold)
|
QSignalBlocker(self.toolBar.actBold)
|
||||||
QSignalBlocker(self.toolBar.actItalic)
|
QSignalBlocker(self.toolBar.actItalic)
|
||||||
QSignalBlocker(self.toolBar.actUnderline)
|
|
||||||
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())
|
||||||
self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
|
|
||||||
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
|
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
|
||||||
|
|
||||||
# Headings: decide which to check by current point size
|
# Headings: decide which to check by current point size
|
||||||
|
|
@ -492,15 +489,6 @@ class MainWindow(QMainWindow):
|
||||||
self.toolBar.actBullets.setChecked(bool(bullets_on))
|
self.toolBar.actBullets.setChecked(bool(bullets_on))
|
||||||
self.toolBar.actNumbers.setChecked(bool(numbers_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:
|
def _current_date_iso(self) -> str:
|
||||||
d = self.calendar.selectedDate()
|
d = self.calendar.selectedDate()
|
||||||
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
||||||
|
|
@ -511,14 +499,12 @@ class MainWindow(QMainWindow):
|
||||||
try:
|
try:
|
||||||
text = self.db.get_entry(date_iso)
|
text = self.db.get_entry(date_iso)
|
||||||
if extra_data:
|
if extra_data:
|
||||||
# Wrap extra_data in a <p> tag for HTML rendering
|
# Append extra data as markdown
|
||||||
extra_data_html = f"<p>{extra_data}</p>"
|
if text and not text.endswith("\n"):
|
||||||
|
text += "\n"
|
||||||
# Inject the extra_data before the closing </body></html>
|
text += extra_data
|
||||||
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
|
|
||||||
text = modified
|
|
||||||
# Force a save now so we don't lose it.
|
# Force a save now so we don't lose it.
|
||||||
self._set_editor_html_preserve_view(text)
|
self._set_editor_markdown_preserve_view(text)
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self._save_date(date_iso, True)
|
self._save_date(date_iso, True)
|
||||||
|
|
||||||
|
|
@ -526,7 +512,7 @@ class MainWindow(QMainWindow):
|
||||||
QMessageBox.critical(self, "Read Error", str(e))
|
QMessageBox.critical(self, "Read Error", str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_editor_html_preserve_view(text)
|
self._set_editor_markdown_preserve_view(text)
|
||||||
|
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
# track which date the editor currently represents
|
# track which date the editor currently represents
|
||||||
|
|
@ -556,39 +542,33 @@ class MainWindow(QMainWindow):
|
||||||
text = self.db.get_entry(yesterday_str)
|
text = self.db.get_entry(yesterday_str)
|
||||||
unchecked_items = []
|
unchecked_items = []
|
||||||
|
|
||||||
# Regex to match the unchecked checkboxes and their associated text
|
# Split into lines and find unchecked checkbox items
|
||||||
checkbox_pattern = re.compile(
|
lines = text.split("\n")
|
||||||
r"<span[^>]*>(☐)</span>\s*(.*?)</p>", re.DOTALL
|
remaining_lines = []
|
||||||
)
|
|
||||||
|
|
||||||
# Find unchecked items and store them
|
for line in lines:
|
||||||
for match in checkbox_pattern.finditer(text):
|
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
|
||||||
checkbox = match.group(1) # Either ☐ or ☑
|
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
|
||||||
item_text = match.group(2).strip() # The text after the checkbox
|
r"^\s*-\s*\[☐\]\s+", line
|
||||||
if checkbox == "☐": # If it's an unchecked checkbox (☐)
|
):
|
||||||
unchecked_items.append("☐ " + item_text) # Store the unchecked item
|
# Extract the text after the checkbox
|
||||||
|
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
|
||||||
|
unchecked_items.append(f"- [ ] {item_text}")
|
||||||
|
else:
|
||||||
|
# Keep all other lines
|
||||||
|
remaining_lines.append(line)
|
||||||
|
|
||||||
# Remove the unchecked items from yesterday's HTML content
|
# Save modified content back if we moved items
|
||||||
if unchecked_items:
|
if unchecked_items:
|
||||||
# This regex will find the entire checkbox line and remove it from the HTML content
|
modified_text = "\n".join(remaining_lines)
|
||||||
uncheckbox_pattern = re.compile(
|
|
||||||
r"<span[^>]*>☐</span>\s*(.*?)</p>", re.DOTALL
|
|
||||||
)
|
|
||||||
modified_text = re.sub(
|
|
||||||
uncheckbox_pattern, "", text
|
|
||||||
) # Remove the checkbox lines
|
|
||||||
|
|
||||||
# Save the modified HTML back to the database
|
|
||||||
self.db.save_new_version(
|
self.db.save_new_version(
|
||||||
yesterday_str,
|
yesterday_str,
|
||||||
modified_text,
|
modified_text,
|
||||||
"Unchecked checkbox items moved to next day",
|
"Unchecked checkbox items moved to next day",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Join unchecked items into a formatted string
|
# Join unchecked items into markdown format
|
||||||
unchecked_str = "\n".join(
|
unchecked_str = "\n".join(unchecked_items) + "\n"
|
||||||
[f"<p>{item}</p>" for item in unchecked_items]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load the unchecked items into the current editor
|
# Load the unchecked items into the current editor
|
||||||
self._load_selected_date(False, unchecked_str)
|
self._load_selected_date(False, unchecked_str)
|
||||||
|
|
@ -621,7 +601,7 @@ class MainWindow(QMainWindow):
|
||||||
"""
|
"""
|
||||||
if not self._dirty and not explicit:
|
if not self._dirty and not explicit:
|
||||||
return
|
return
|
||||||
text = self.editor.to_html_with_embedded_images()
|
text = self.editor.to_markdown()
|
||||||
try:
|
try:
|
||||||
self.db.save_new_version(date_iso, text, note)
|
self.db.save_new_version(date_iso, text, note)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -674,7 +654,9 @@ class MainWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
if not paths:
|
if not paths:
|
||||||
return
|
return
|
||||||
self.editor.insert_images(paths) # call into the editor
|
# Insert each image
|
||||||
|
for path_str in paths:
|
||||||
|
self.editor.insert_image_from_path(Path(path_str))
|
||||||
|
|
||||||
# ----------- Settings handler ------------#
|
# ----------- Settings handler ------------#
|
||||||
def _open_settings(self):
|
def _open_settings(self):
|
||||||
|
|
@ -975,7 +957,7 @@ If you want an encrypted backup, choose Backup instead of Export.
|
||||||
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
||||||
QTimer.singleShot(0, self._focus_editor_now)
|
QTimer.singleShot(0, self._focus_editor_now)
|
||||||
|
|
||||||
def _set_editor_html_preserve_view(self, html: str):
|
def _set_editor_markdown_preserve_view(self, markdown: str):
|
||||||
ed = self.editor
|
ed = self.editor
|
||||||
|
|
||||||
# Save caret/selection and scroll
|
# Save caret/selection and scroll
|
||||||
|
|
@ -986,15 +968,19 @@ If you want an encrypted backup, choose Backup instead of Export.
|
||||||
|
|
||||||
# Only touch the doc if it actually changed
|
# Only touch the doc if it actually changed
|
||||||
ed.blockSignals(True)
|
ed.blockSignals(True)
|
||||||
if ed.toHtml() != html:
|
if ed.to_markdown() != markdown:
|
||||||
ed.setHtml(html)
|
ed.from_markdown(markdown)
|
||||||
ed.blockSignals(False)
|
ed.blockSignals(False)
|
||||||
|
|
||||||
# Restore scroll first
|
# Restore scroll first
|
||||||
ed.verticalScrollBar().setValue(v)
|
ed.verticalScrollBar().setValue(v)
|
||||||
ed.horizontalScrollBar().setValue(h)
|
ed.horizontalScrollBar().setValue(h)
|
||||||
|
|
||||||
# Restore caret/selection
|
# Restore caret/selection (bounded to new doc length)
|
||||||
|
doc_length = ed.document().characterCount() - 1
|
||||||
|
old_pos = min(old_pos, doc_length)
|
||||||
|
old_anchor = min(old_anchor, doc_length)
|
||||||
|
|
||||||
cur = ed.textCursor()
|
cur = ed.textCursor()
|
||||||
cur.setPosition(old_anchor)
|
cur.setPosition(old_anchor)
|
||||||
mode = (
|
mode = (
|
||||||
|
|
|
||||||
795
bouquin/markdown_editor.py
Normal file
795
bouquin/markdown_editor.py
Normal file
|
|
@ -0,0 +1,795 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontDatabase,
|
||||||
|
QImage,
|
||||||
|
QPalette,
|
||||||
|
QGuiApplication,
|
||||||
|
QTextCharFormat,
|
||||||
|
QTextCursor,
|
||||||
|
QTextDocument,
|
||||||
|
QSyntaxHighlighter,
|
||||||
|
QTextImageFormat,
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
|
from .theme import ThemeManager, Theme
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||||
|
"""Live syntax highlighter for markdown that applies formatting as you type."""
|
||||||
|
|
||||||
|
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
|
||||||
|
super().__init__(document)
|
||||||
|
self.theme_manager = theme_manager
|
||||||
|
self._setup_formats()
|
||||||
|
# Recompute formats whenever the app theme changes
|
||||||
|
try:
|
||||||
|
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_theme_changed(self, *_):
|
||||||
|
self._setup_formats()
|
||||||
|
self.rehighlight()
|
||||||
|
|
||||||
|
def _setup_formats(self):
|
||||||
|
"""Setup text formats for different markdown elements."""
|
||||||
|
# Bold: **text** or __text__
|
||||||
|
self.bold_format = QTextCharFormat()
|
||||||
|
self.bold_format.setFontWeight(QFont.Weight.Bold)
|
||||||
|
|
||||||
|
# Italic: *text* or _text_
|
||||||
|
self.italic_format = QTextCharFormat()
|
||||||
|
self.italic_format.setFontItalic(True)
|
||||||
|
|
||||||
|
# Strikethrough: ~~text~~
|
||||||
|
self.strike_format = QTextCharFormat()
|
||||||
|
self.strike_format.setFontStrikeOut(True)
|
||||||
|
|
||||||
|
# Code: `code`
|
||||||
|
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
|
self.code_format = QTextCharFormat()
|
||||||
|
self.code_format.setFont(mono)
|
||||||
|
self.code_format.setFontFixedPitch(True)
|
||||||
|
|
||||||
|
# Code block: ```
|
||||||
|
self.code_block_format = QTextCharFormat()
|
||||||
|
self.code_block_format.setFont(mono)
|
||||||
|
self.code_block_format.setFontFixedPitch(True)
|
||||||
|
pal = QGuiApplication.palette()
|
||||||
|
if self.theme_manager.current() == Theme.DARK:
|
||||||
|
# In dark mode, use a darker panel-like background
|
||||||
|
bg = pal.color(QPalette.AlternateBase)
|
||||||
|
fg = pal.color(QPalette.Text)
|
||||||
|
else:
|
||||||
|
# Light mode: keep the existing light gray
|
||||||
|
bg = QColor(245, 245, 245)
|
||||||
|
fg = pal.color(QPalette.Text)
|
||||||
|
self.code_block_format.setBackground(bg)
|
||||||
|
self.code_block_format.setForeground(fg)
|
||||||
|
|
||||||
|
# Headings
|
||||||
|
self.h1_format = QTextCharFormat()
|
||||||
|
self.h1_format.setFontPointSize(24.0)
|
||||||
|
self.h1_format.setFontWeight(QFont.Weight.Bold)
|
||||||
|
|
||||||
|
self.h2_format = QTextCharFormat()
|
||||||
|
self.h2_format.setFontPointSize(18.0)
|
||||||
|
self.h2_format.setFontWeight(QFont.Weight.Bold)
|
||||||
|
|
||||||
|
self.h3_format = QTextCharFormat()
|
||||||
|
self.h3_format.setFontPointSize(14.0)
|
||||||
|
self.h3_format.setFontWeight(QFont.Weight.Bold)
|
||||||
|
|
||||||
|
# Markdown syntax (the markers themselves) - make invisible
|
||||||
|
self.syntax_format = QTextCharFormat()
|
||||||
|
# Make the markers invisible by setting font size to 0.1 points
|
||||||
|
self.syntax_format.setFontPointSize(0.1)
|
||||||
|
# Also make them very faint in case they still show
|
||||||
|
self.syntax_format.setForeground(QColor(250, 250, 250))
|
||||||
|
|
||||||
|
def highlightBlock(self, text: str):
|
||||||
|
"""Apply formatting to a block of text based on markdown syntax."""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Track if we're in a code block (multiline)
|
||||||
|
prev_state = self.previousBlockState()
|
||||||
|
in_code_block = prev_state == 1
|
||||||
|
|
||||||
|
# Check for code block fences
|
||||||
|
if text.strip().startswith("```"):
|
||||||
|
# Toggle code block state
|
||||||
|
in_code_block = not in_code_block
|
||||||
|
self.setCurrentBlockState(1 if in_code_block else 0)
|
||||||
|
# Format the fence markers - but keep them somewhat visible for editing
|
||||||
|
# Use code format instead of syntax format so cursor is visible
|
||||||
|
self.setFormat(0, len(text), self.code_block_format)
|
||||||
|
return
|
||||||
|
|
||||||
|
if in_code_block:
|
||||||
|
# Format entire line as code
|
||||||
|
self.setFormat(0, len(text), self.code_block_format)
|
||||||
|
self.setCurrentBlockState(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.setCurrentBlockState(0)
|
||||||
|
|
||||||
|
# Headings (must be at start of line)
|
||||||
|
heading_match = re.match(r"^(#{1,3})\s+", text)
|
||||||
|
if heading_match:
|
||||||
|
level = len(heading_match.group(1))
|
||||||
|
marker_len = len(heading_match.group(0))
|
||||||
|
|
||||||
|
# Format the # markers
|
||||||
|
self.setFormat(0, marker_len, self.syntax_format)
|
||||||
|
|
||||||
|
# Format the heading text
|
||||||
|
heading_fmt = (
|
||||||
|
self.h1_format
|
||||||
|
if level == 1
|
||||||
|
else self.h2_format if level == 2 else self.h3_format
|
||||||
|
)
|
||||||
|
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Bold: **text** or __text__
|
||||||
|
for match in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text):
|
||||||
|
start, end = match.span()
|
||||||
|
content_start = start + 2
|
||||||
|
content_end = end - 2
|
||||||
|
|
||||||
|
# Gray out the markers
|
||||||
|
self.setFormat(start, 2, self.syntax_format)
|
||||||
|
self.setFormat(end - 2, 2, self.syntax_format)
|
||||||
|
|
||||||
|
# Bold the content
|
||||||
|
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
||||||
|
|
||||||
|
# Italic: *text* or _text_ (but not part of bold)
|
||||||
|
for match in re.finditer(
|
||||||
|
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
||||||
|
):
|
||||||
|
start, end = match.span()
|
||||||
|
# Skip if this is part of a bold pattern
|
||||||
|
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
|
||||||
|
continue
|
||||||
|
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
content_start = start + 1
|
||||||
|
content_end = end - 1
|
||||||
|
|
||||||
|
# Gray out markers
|
||||||
|
self.setFormat(start, 1, self.syntax_format)
|
||||||
|
self.setFormat(end - 1, 1, self.syntax_format)
|
||||||
|
|
||||||
|
# Italicize content
|
||||||
|
self.setFormat(
|
||||||
|
content_start, content_end - content_start, self.italic_format
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strikethrough: ~~text~~
|
||||||
|
for match in re.finditer(r"~~(.+?)~~", text):
|
||||||
|
start, end = match.span()
|
||||||
|
content_start = start + 2
|
||||||
|
content_end = end - 2
|
||||||
|
|
||||||
|
self.setFormat(start, 2, self.syntax_format)
|
||||||
|
self.setFormat(end - 2, 2, self.syntax_format)
|
||||||
|
self.setFormat(
|
||||||
|
content_start, content_end - content_start, self.strike_format
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inline code: `code`
|
||||||
|
for match in re.finditer(r"`([^`]+)`", text):
|
||||||
|
start, end = match.span()
|
||||||
|
content_start = start + 1
|
||||||
|
content_end = end - 1
|
||||||
|
|
||||||
|
self.setFormat(start, 1, self.syntax_format)
|
||||||
|
self.setFormat(end - 1, 1, self.syntax_format)
|
||||||
|
self.setFormat(content_start, content_end - content_start, self.code_format)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownEditor(QTextEdit):
|
||||||
|
"""A QTextEdit that stores/loads markdown and provides live rendering."""
|
||||||
|
|
||||||
|
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||||
|
|
||||||
|
# Checkbox characters (Unicode for display, markdown for storage)
|
||||||
|
_CHECK_UNCHECKED_DISPLAY = "☐"
|
||||||
|
_CHECK_CHECKED_DISPLAY = "☑"
|
||||||
|
_CHECK_UNCHECKED_STORAGE = "[ ]"
|
||||||
|
_CHECK_CHECKED_STORAGE = "[x]"
|
||||||
|
|
||||||
|
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.theme_manager = theme_manager
|
||||||
|
|
||||||
|
# Setup tab width
|
||||||
|
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||||||
|
self.setTabStopDistance(tab_w)
|
||||||
|
|
||||||
|
# We accept plain text, not rich text (markdown is plain text)
|
||||||
|
self.setAcceptRichText(False)
|
||||||
|
|
||||||
|
# Install syntax highlighter
|
||||||
|
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
|
||||||
|
|
||||||
|
# Track current list type for smart enter handling
|
||||||
|
self._last_enter_was_empty = False
|
||||||
|
|
||||||
|
# Track if we're currently updating text programmatically
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
# Connect to text changes for smart formatting
|
||||||
|
self.textChanged.connect(self._on_text_changed)
|
||||||
|
|
||||||
|
# Enable mouse tracking for checkbox clicking
|
||||||
|
self.viewport().setMouseTracking(True)
|
||||||
|
|
||||||
|
def _on_text_changed(self):
|
||||||
|
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
||||||
|
if self._updating:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._updating = True
|
||||||
|
try:
|
||||||
|
# Convert checkbox markdown to Unicode for display
|
||||||
|
cursor = self.textCursor()
|
||||||
|
pos = cursor.position()
|
||||||
|
|
||||||
|
text = self.toPlainText()
|
||||||
|
|
||||||
|
# Convert lines that START with "TODO " into an unchecked checkbox.
|
||||||
|
# Keeps any leading indentation.
|
||||||
|
todo_re = re.compile(r"(?m)^([ \t]*)TODO\s")
|
||||||
|
if todo_re.search(text):
|
||||||
|
modified_text = todo_re.sub(
|
||||||
|
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
modified_text = text
|
||||||
|
|
||||||
|
# Replace checkbox markdown with Unicode (for display only)
|
||||||
|
modified_text = modified_text.replace(
|
||||||
|
"- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
|
||||||
|
)
|
||||||
|
modified_text = modified_text.replace(
|
||||||
|
"- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} "
|
||||||
|
)
|
||||||
|
|
||||||
|
if modified_text != text:
|
||||||
|
# Count replacements before cursor to adjust position
|
||||||
|
text_before = text[:pos]
|
||||||
|
x_count = text_before.count("- [x] ")
|
||||||
|
space_count = text_before.count("- [ ] ")
|
||||||
|
# Each markdown checkbox -> unicode shortens by 2 chars ([x]/[ ] -> ☑/☐)
|
||||||
|
checkbox_delta = (x_count + space_count) * 2
|
||||||
|
# Each "TODO " -> "- ☐ " shortens by 1 char
|
||||||
|
todo_count = len(list(todo_re.finditer(text_before)))
|
||||||
|
todo_delta = todo_count * 1
|
||||||
|
new_pos = pos - checkbox_delta - todo_delta
|
||||||
|
|
||||||
|
# Update the text
|
||||||
|
self.blockSignals(True)
|
||||||
|
self.setPlainText(modified_text)
|
||||||
|
self.blockSignals(False)
|
||||||
|
|
||||||
|
# Restore cursor position
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.setPosition(max(0, min(new_pos, len(modified_text))))
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
def to_markdown(self) -> str:
|
||||||
|
"""Export current content as markdown (convert Unicode checkboxes back to markdown)."""
|
||||||
|
# First, extract any embedded images and convert to markdown
|
||||||
|
text = self._extract_images_to_markdown()
|
||||||
|
|
||||||
|
# Convert Unicode checkboxes back to markdown syntax
|
||||||
|
text = text.replace(f"- {self._CHECK_CHECKED_DISPLAY} ", "- [x] ")
|
||||||
|
text = text.replace(f"- {self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _extract_images_to_markdown(self) -> str:
|
||||||
|
"""Extract embedded images and convert them back to markdown format."""
|
||||||
|
doc = self.document()
|
||||||
|
cursor = QTextCursor(doc)
|
||||||
|
|
||||||
|
# Build the output text with images as markdown
|
||||||
|
result = []
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
||||||
|
|
||||||
|
block = doc.begin()
|
||||||
|
while block.isValid():
|
||||||
|
it = block.begin()
|
||||||
|
block_text = ""
|
||||||
|
|
||||||
|
while not it.atEnd():
|
||||||
|
fragment = it.fragment()
|
||||||
|
if fragment.isValid():
|
||||||
|
if fragment.charFormat().isImageFormat():
|
||||||
|
# This is an image - convert to markdown
|
||||||
|
img_format = fragment.charFormat().toImageFormat()
|
||||||
|
img_name = img_format.name()
|
||||||
|
# The name contains the data URI
|
||||||
|
if img_name.startswith("data:image/"):
|
||||||
|
block_text += f""
|
||||||
|
else:
|
||||||
|
# Regular text
|
||||||
|
block_text += fragment.text()
|
||||||
|
it += 1
|
||||||
|
|
||||||
|
result.append(block_text)
|
||||||
|
block = block.next()
|
||||||
|
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
def from_markdown(self, markdown_text: str):
|
||||||
|
"""Load markdown text into the editor (convert markdown checkboxes to Unicode)."""
|
||||||
|
# Convert markdown checkboxes to Unicode for display
|
||||||
|
display_text = markdown_text.replace(
|
||||||
|
"- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
|
||||||
|
)
|
||||||
|
display_text = display_text.replace(
|
||||||
|
"- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} "
|
||||||
|
)
|
||||||
|
# Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
|
||||||
|
display_text = re.sub(
|
||||||
|
r"(?m)^([ \t]*)TODO\s",
|
||||||
|
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||||
|
display_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._updating = True
|
||||||
|
try:
|
||||||
|
self.setPlainText(display_text)
|
||||||
|
finally:
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
# Render any embedded images
|
||||||
|
self._render_images()
|
||||||
|
|
||||||
|
def _render_images(self):
|
||||||
|
"""Find and render base64 images in the document."""
|
||||||
|
text = self.toPlainText()
|
||||||
|
|
||||||
|
# Pattern for markdown images with base64 data
|
||||||
|
img_pattern = r"!\[([^\]]*)\]\(data:image/([^;]+);base64,([^\)]+)\)"
|
||||||
|
|
||||||
|
matches = list(re.finditer(img_pattern, text))
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process matches in reverse to preserve positions
|
||||||
|
for match in reversed(matches):
|
||||||
|
mime_type = match.group(2)
|
||||||
|
b64_data = match.group(3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode base64 to image
|
||||||
|
img_bytes = base64.b64decode(b64_data)
|
||||||
|
image = QImage.fromData(img_bytes)
|
||||||
|
|
||||||
|
if image.isNull():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use original image size - no scaling
|
||||||
|
original_width = image.width()
|
||||||
|
original_height = image.height()
|
||||||
|
|
||||||
|
# Create image format with original base64
|
||||||
|
img_format = QTextImageFormat()
|
||||||
|
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||||||
|
img_format.setWidth(original_width)
|
||||||
|
img_format.setHeight(original_height)
|
||||||
|
|
||||||
|
# Add image to document resources
|
||||||
|
self.document().addResource(
|
||||||
|
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace markdown with rendered image
|
||||||
|
cursor = QTextCursor(self.document())
|
||||||
|
cursor.setPosition(match.start())
|
||||||
|
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
|
||||||
|
cursor.insertImage(img_format)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# If image fails to render, leave the markdown as-is
|
||||||
|
print(f"Failed to render image: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _get_current_line(self) -> str:
|
||||||
|
"""Get the text of the current line."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||||||
|
return cursor.selectedText()
|
||||||
|
|
||||||
|
def _detect_list_type(self, line: str) -> tuple[str | None, str]:
|
||||||
|
"""
|
||||||
|
Detect if line is a list item. Returns (list_type, prefix).
|
||||||
|
list_type: 'bullet', 'number', 'checkbox', or None
|
||||||
|
prefix: the actual prefix string to use (e.g., '- ', '1. ', '- ☐ ')
|
||||||
|
"""
|
||||||
|
line = line.lstrip()
|
||||||
|
|
||||||
|
# Checkbox list (Unicode display format)
|
||||||
|
if line.startswith(f"- {self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith(
|
||||||
|
f"- {self._CHECK_CHECKED_DISPLAY} "
|
||||||
|
):
|
||||||
|
return ("checkbox", f"- {self._CHECK_UNCHECKED_DISPLAY} ")
|
||||||
|
|
||||||
|
# Bullet list
|
||||||
|
if re.match(r"^[-*+]\s", line):
|
||||||
|
match = re.match(r"^([-*+]\s)", line)
|
||||||
|
return ("bullet", match.group(1))
|
||||||
|
|
||||||
|
# Numbered list
|
||||||
|
if re.match(r"^\d+\.\s", line):
|
||||||
|
# Extract the number and increment
|
||||||
|
match = re.match(r"^(\d+)\.\s", line)
|
||||||
|
num = int(match.group(1))
|
||||||
|
return ("number", f"{num + 1}. ")
|
||||||
|
|
||||||
|
return (None, "")
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
"""Handle special key events for markdown editing."""
|
||||||
|
|
||||||
|
# Handle Enter key for smart list continuation AND code blocks
|
||||||
|
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||||
|
cursor = self.textCursor()
|
||||||
|
current_line = self._get_current_line()
|
||||||
|
|
||||||
|
# Check if we're in a code block
|
||||||
|
current_block = cursor.block()
|
||||||
|
block_state = current_block.userState()
|
||||||
|
|
||||||
|
# If current line is opening code fence, or we're inside a code block
|
||||||
|
if current_line.strip().startswith("```") or block_state == 1:
|
||||||
|
# Just insert a regular newline - the highlighter will format it as code
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for list continuation
|
||||||
|
list_type, prefix = self._detect_list_type(current_line)
|
||||||
|
|
||||||
|
if list_type:
|
||||||
|
# Check if the line is empty (just the prefix)
|
||||||
|
content = current_line.lstrip()
|
||||||
|
is_empty = (
|
||||||
|
content == prefix.strip() or not content.replace(prefix, "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_empty and self._last_enter_was_empty:
|
||||||
|
# Second enter on empty list item - remove the list formatting
|
||||||
|
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||||||
|
cursor.removeSelectedText()
|
||||||
|
cursor.insertText("\n")
|
||||||
|
self._last_enter_was_empty = False
|
||||||
|
return
|
||||||
|
elif is_empty:
|
||||||
|
# First enter on empty list item - remember this
|
||||||
|
self._last_enter_was_empty = True
|
||||||
|
else:
|
||||||
|
# Not empty - continue the list
|
||||||
|
self._last_enter_was_empty = False
|
||||||
|
|
||||||
|
# Insert newline and continue the list
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.insertText(prefix)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self._last_enter_was_empty = False
|
||||||
|
else:
|
||||||
|
# Any other key resets the empty enter flag
|
||||||
|
self._last_enter_was_empty = False
|
||||||
|
|
||||||
|
# Default handling
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
"""Handle mouse clicks - check for checkbox clicking."""
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
cursor = self.cursorForPosition(event.pos())
|
||||||
|
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||||||
|
line = cursor.selectedText()
|
||||||
|
|
||||||
|
# Check if clicking on a checkbox line
|
||||||
|
if (
|
||||||
|
f"- {self._CHECK_UNCHECKED_DISPLAY} " in line
|
||||||
|
or f"- {self._CHECK_CHECKED_DISPLAY} " in line
|
||||||
|
):
|
||||||
|
# Toggle the checkbox
|
||||||
|
if f"- {self._CHECK_UNCHECKED_DISPLAY} " in line:
|
||||||
|
new_line = line.replace(
|
||||||
|
f"- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||||
|
f"- {self._CHECK_CHECKED_DISPLAY} ",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_line = line.replace(
|
||||||
|
f"- {self._CHECK_CHECKED_DISPLAY} ",
|
||||||
|
f"- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.insertText(new_line)
|
||||||
|
# Don't call super() - we handled the click
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default handling for non-checkbox clicks
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
# ------------------------ Toolbar action handlers ------------------------
|
||||||
|
|
||||||
|
def apply_weight(self):
|
||||||
|
"""Toggle bold formatting."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
if cursor.hasSelection():
|
||||||
|
selected = cursor.selectedText()
|
||||||
|
# Check if already bold
|
||||||
|
if selected.startswith("**") and selected.endswith("**"):
|
||||||
|
# Remove bold
|
||||||
|
new_text = selected[2:-2]
|
||||||
|
else:
|
||||||
|
# Add bold
|
||||||
|
new_text = f"**{selected}**"
|
||||||
|
cursor.insertText(new_text)
|
||||||
|
else:
|
||||||
|
# No selection - just insert markers
|
||||||
|
cursor.insertText("****")
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
|
||||||
|
)
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def apply_italic(self):
|
||||||
|
"""Toggle italic formatting."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
if cursor.hasSelection():
|
||||||
|
selected = cursor.selectedText()
|
||||||
|
if (
|
||||||
|
selected.startswith("*")
|
||||||
|
and selected.endswith("*")
|
||||||
|
and not selected.startswith("**")
|
||||||
|
):
|
||||||
|
new_text = selected[1:-1]
|
||||||
|
else:
|
||||||
|
new_text = f"*{selected}*"
|
||||||
|
cursor.insertText(new_text)
|
||||||
|
else:
|
||||||
|
cursor.insertText("**")
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1
|
||||||
|
)
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def apply_strikethrough(self):
|
||||||
|
"""Toggle strikethrough formatting."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
if cursor.hasSelection():
|
||||||
|
selected = cursor.selectedText()
|
||||||
|
if selected.startswith("~~") and selected.endswith("~~"):
|
||||||
|
new_text = selected[2:-2]
|
||||||
|
else:
|
||||||
|
new_text = f"~~{selected}~~"
|
||||||
|
cursor.insertText(new_text)
|
||||||
|
else:
|
||||||
|
cursor.insertText("~~~~")
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
|
||||||
|
)
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def apply_code(self):
|
||||||
|
"""Insert or toggle code block."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
|
||||||
|
if cursor.hasSelection():
|
||||||
|
# Wrap selection in code fence
|
||||||
|
selected = cursor.selectedText()
|
||||||
|
# Note: selectedText() uses Unicode paragraph separator, replace with newline
|
||||||
|
selected = selected.replace("\u2029", "\n")
|
||||||
|
new_text = f"```\n{selected}\n```"
|
||||||
|
cursor.insertText(new_text)
|
||||||
|
else:
|
||||||
|
# Insert code block template
|
||||||
|
cursor.insertText("```\n\n```")
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
|
||||||
|
)
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def apply_heading(self, size: int):
|
||||||
|
"""Apply heading formatting to current line."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
|
||||||
|
# Determine heading level from size
|
||||||
|
if size >= 24:
|
||||||
|
level = 1
|
||||||
|
elif size >= 18:
|
||||||
|
level = 2
|
||||||
|
elif size >= 14:
|
||||||
|
level = 3
|
||||||
|
else:
|
||||||
|
level = 0 # Normal text
|
||||||
|
|
||||||
|
# Get current line
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||||||
|
)
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||||||
|
)
|
||||||
|
line = cursor.selectedText()
|
||||||
|
|
||||||
|
# Remove existing heading markers
|
||||||
|
line = re.sub(r"^#{1,6}\s+", "", line)
|
||||||
|
|
||||||
|
# Add new heading markers if not normal
|
||||||
|
if level > 0:
|
||||||
|
new_line = "#" * level + " " + line
|
||||||
|
else:
|
||||||
|
new_line = line
|
||||||
|
|
||||||
|
cursor.insertText(new_line)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def toggle_bullets(self):
|
||||||
|
"""Toggle bullet list on current line."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||||||
|
)
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||||||
|
)
|
||||||
|
line = cursor.selectedText()
|
||||||
|
|
||||||
|
# Check if already a bullet
|
||||||
|
if line.lstrip().startswith("- ") or line.lstrip().startswith("* "):
|
||||||
|
# Remove bullet
|
||||||
|
new_line = re.sub(r"^\s*[-*]\s+", "", line)
|
||||||
|
else:
|
||||||
|
# Add bullet
|
||||||
|
new_line = "- " + line.lstrip()
|
||||||
|
|
||||||
|
cursor.insertText(new_line)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def toggle_numbers(self):
|
||||||
|
"""Toggle numbered list on current line."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||||||
|
)
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||||||
|
)
|
||||||
|
line = cursor.selectedText()
|
||||||
|
|
||||||
|
# Check if already numbered
|
||||||
|
if re.match(r"^\s*\d+\.\s", line):
|
||||||
|
# Remove number
|
||||||
|
new_line = re.sub(r"^\s*\d+\.\s+", "", line)
|
||||||
|
else:
|
||||||
|
# Add number
|
||||||
|
new_line = "1. " + line.lstrip()
|
||||||
|
|
||||||
|
cursor.insertText(new_line)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def toggle_checkboxes(self):
|
||||||
|
"""Toggle checkbox on current line."""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||||||
|
)
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||||||
|
)
|
||||||
|
line = cursor.selectedText()
|
||||||
|
|
||||||
|
# Check if already has checkbox (Unicode display format)
|
||||||
|
if (
|
||||||
|
f"- {self._CHECK_UNCHECKED_DISPLAY} " in line
|
||||||
|
or f"- {self._CHECK_CHECKED_DISPLAY} " in line
|
||||||
|
):
|
||||||
|
# Remove checkbox - use raw string to avoid escape sequence warning
|
||||||
|
new_line = re.sub(
|
||||||
|
rf"^\s*-\s*[{self._CHECK_UNCHECKED_DISPLAY}{self._CHECK_CHECKED_DISPLAY}]\s+",
|
||||||
|
"",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Add checkbox (Unicode display format)
|
||||||
|
new_line = f"- {self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip()
|
||||||
|
|
||||||
|
cursor.insertText(new_line)
|
||||||
|
|
||||||
|
# Return focus to editor
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def insert_image_from_path(self, path: Path):
|
||||||
|
"""Insert an image as rendered image (but save as base64 markdown)."""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read the ORIGINAL image file bytes for base64 encoding
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
img_data = f.read()
|
||||||
|
|
||||||
|
# Encode ORIGINAL file bytes to base64
|
||||||
|
b64_data = base64.b64encode(img_data).decode("ascii")
|
||||||
|
|
||||||
|
# Determine mime type
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
mime_map = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
mime_type = mime_map.get(ext, "image/png")
|
||||||
|
|
||||||
|
# Load the image
|
||||||
|
image = QImage(str(path))
|
||||||
|
if image.isNull():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use ORIGINAL size - no scaling!
|
||||||
|
original_width = image.width()
|
||||||
|
original_height = image.height()
|
||||||
|
|
||||||
|
# Create image format with original base64
|
||||||
|
img_format = QTextImageFormat()
|
||||||
|
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||||||
|
img_format.setWidth(original_width)
|
||||||
|
img_format.setHeight(original_height)
|
||||||
|
|
||||||
|
# Add ORIGINAL image to document resources
|
||||||
|
self.document().addResource(
|
||||||
|
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert the image at original size
|
||||||
|
cursor = self.textCursor()
|
||||||
|
cursor.insertImage(img_format)
|
||||||
|
cursor.insertText("\n") # Add newline after image
|
||||||
|
|
@ -4,7 +4,6 @@ import re
|
||||||
from typing import Iterable, Tuple
|
from typing import Iterable, Tuple
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
|
@ -149,10 +148,12 @@ class Search(QWidget):
|
||||||
self.results.setItemWidget(item, container)
|
self.results.setItemWidget(item, container)
|
||||||
|
|
||||||
# --- Snippet/highlight helpers -----------------------------------------
|
# --- Snippet/highlight helpers -----------------------------------------
|
||||||
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
|
def _make_html_snippet(
|
||||||
doc = QTextDocument()
|
self, markdown_src: str, query: str, *, radius=60, maxlen=180
|
||||||
doc.setHtml(html_src)
|
):
|
||||||
plain = doc.toPlainText()
|
# For markdown, we can work directly with the text
|
||||||
|
# Strip markdown formatting for display
|
||||||
|
plain = self._strip_markdown(markdown_src)
|
||||||
if not plain:
|
if not plain:
|
||||||
return "", False, False
|
return "", False, False
|
||||||
|
|
||||||
|
|
@ -179,30 +180,45 @@ class Search(QWidget):
|
||||||
start = max(0, min(idx - radius, max(0, L - maxlen)))
|
start = max(0, min(idx - radius, max(0, L - maxlen)))
|
||||||
end = min(L, max(idx + mlen + radius, start + maxlen))
|
end = min(L, max(idx + mlen + radius, start + maxlen))
|
||||||
|
|
||||||
# Bold all token matches that fall inside [start, end)
|
# Extract snippet and highlight matches
|
||||||
|
snippet = plain[start:end]
|
||||||
|
|
||||||
|
# Escape HTML and bold matches
|
||||||
|
import html as _html
|
||||||
|
|
||||||
|
snippet_html = _html.escape(snippet)
|
||||||
if tokens:
|
if tokens:
|
||||||
lower = plain.lower()
|
|
||||||
fmt = QTextCharFormat()
|
|
||||||
fmt.setFontWeight(QFont.Weight.Bold)
|
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
t_low = t.lower()
|
# Case-insensitive replacement
|
||||||
pos = start
|
pattern = re.compile(re.escape(t), re.IGNORECASE)
|
||||||
while True:
|
snippet_html = pattern.sub(
|
||||||
k = lower.find(t_low, pos)
|
lambda m: f"<b>{m.group(0)}</b>", snippet_html
|
||||||
if k == -1 or k >= end:
|
)
|
||||||
break
|
|
||||||
c = QTextCursor(doc)
|
|
||||||
c.setPosition(k)
|
|
||||||
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
|
|
||||||
c.mergeCharFormat(fmt)
|
|
||||||
pos = k + len(t)
|
|
||||||
|
|
||||||
# Select the window and export as HTML fragment
|
return snippet_html, start > 0, end < L
|
||||||
c = QTextCursor(doc)
|
|
||||||
c.setPosition(start)
|
|
||||||
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
|
||||||
fragment_html = (
|
|
||||||
c.selection().toHtml()
|
|
||||||
) # preserves original styles + our bolding
|
|
||||||
|
|
||||||
return fragment_html, start > 0, end < L
|
def _strip_markdown(self, markdown: str) -> str:
|
||||||
|
"""Strip markdown formatting for plain text display."""
|
||||||
|
# Remove images
|
||||||
|
text = re.sub(r"!\[.*?\]\(.*?\)", "[Image]", markdown)
|
||||||
|
# Remove links but keep text
|
||||||
|
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
|
||||||
|
# Remove inline code backticks
|
||||||
|
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||||
|
# Remove bold/italic markers
|
||||||
|
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
||||||
|
text = re.sub(r"__([^_]+)__", r"\1", text)
|
||||||
|
text = re.sub(r"\*([^*]+)\*", r"\1", text)
|
||||||
|
text = re.sub(r"_([^_]+)_", r"\1", text)
|
||||||
|
# Remove strikethrough
|
||||||
|
text = re.sub(r"~~([^~]+)~~", r"\1", text)
|
||||||
|
# Remove heading markers
|
||||||
|
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||||
|
# Remove list markers
|
||||||
|
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
|
||||||
|
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||||
|
# Remove checkbox markers
|
||||||
|
text = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", text, flags=re.MULTILINE)
|
||||||
|
# Remove code block fences
|
||||||
|
text = re.sub(r"```[^\n]*\n", "", text)
|
||||||
|
return text.strip()
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,12 @@ from PySide6.QtWidgets import QToolBar
|
||||||
class ToolBar(QToolBar):
|
class ToolBar(QToolBar):
|
||||||
boldRequested = Signal()
|
boldRequested = Signal()
|
||||||
italicRequested = Signal()
|
italicRequested = Signal()
|
||||||
underlineRequested = Signal()
|
|
||||||
strikeRequested = Signal()
|
strikeRequested = Signal()
|
||||||
codeRequested = Signal()
|
codeRequested = Signal()
|
||||||
headingRequested = Signal(int)
|
headingRequested = Signal(int)
|
||||||
bulletsRequested = Signal()
|
bulletsRequested = Signal()
|
||||||
numbersRequested = Signal()
|
numbersRequested = Signal()
|
||||||
checkboxesRequested = Signal()
|
checkboxesRequested = Signal()
|
||||||
alignRequested = Signal(Qt.AlignmentFlag)
|
|
||||||
historyRequested = Signal()
|
historyRequested = Signal()
|
||||||
insertImageRequested = Signal()
|
insertImageRequested = Signal()
|
||||||
|
|
||||||
|
|
@ -39,12 +37,6 @@ class ToolBar(QToolBar):
|
||||||
self.actItalic.setShortcut(QKeySequence.Italic)
|
self.actItalic.setShortcut(QKeySequence.Italic)
|
||||||
self.actItalic.triggered.connect(self.italicRequested)
|
self.actItalic.triggered.connect(self.italicRequested)
|
||||||
|
|
||||||
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("S", self)
|
self.actStrike = QAction("S", self)
|
||||||
self.actStrike.setToolTip("Strikethrough")
|
self.actStrike.setToolTip("Strikethrough")
|
||||||
self.actStrike.setCheckable(True)
|
self.actStrike.setCheckable(True)
|
||||||
|
|
@ -97,24 +89,6 @@ class ToolBar(QToolBar):
|
||||||
self.actInsertImg.setShortcut("Ctrl+Shift+I")
|
self.actInsertImg.setShortcut("Ctrl+Shift+I")
|
||||||
self.actInsertImg.triggered.connect(self.insertImageRequested)
|
self.actInsertImg.triggered.connect(self.insertImageRequested)
|
||||||
|
|
||||||
# Alignment
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
# History button
|
# History button
|
||||||
self.actHistory = QAction("History", self)
|
self.actHistory = QAction("History", self)
|
||||||
self.actHistory.triggered.connect(self.historyRequested)
|
self.actHistory.triggered.connect(self.historyRequested)
|
||||||
|
|
@ -125,7 +99,6 @@ class ToolBar(QToolBar):
|
||||||
for a in (
|
for a in (
|
||||||
self.actBold,
|
self.actBold,
|
||||||
self.actItalic,
|
self.actItalic,
|
||||||
self.actUnderline,
|
|
||||||
self.actStrike,
|
self.actStrike,
|
||||||
self.actH1,
|
self.actH1,
|
||||||
self.actH2,
|
self.actH2,
|
||||||
|
|
@ -135,11 +108,6 @@ class ToolBar(QToolBar):
|
||||||
a.setCheckable(True)
|
a.setCheckable(True)
|
||||||
a.setActionGroup(self.grpHeadings)
|
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)
|
|
||||||
|
|
||||||
self.grpLists = QActionGroup(self)
|
self.grpLists = QActionGroup(self)
|
||||||
self.grpLists.setExclusive(True)
|
self.grpLists.setExclusive(True)
|
||||||
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
|
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
|
||||||
|
|
@ -150,7 +118,6 @@ class ToolBar(QToolBar):
|
||||||
[
|
[
|
||||||
self.actBold,
|
self.actBold,
|
||||||
self.actItalic,
|
self.actItalic,
|
||||||
self.actUnderline,
|
|
||||||
self.actStrike,
|
self.actStrike,
|
||||||
self.actCode,
|
self.actCode,
|
||||||
self.actH1,
|
self.actH1,
|
||||||
|
|
@ -161,9 +128,6 @@ class ToolBar(QToolBar):
|
||||||
self.actNumbers,
|
self.actNumbers,
|
||||||
self.actCheckboxes,
|
self.actCheckboxes,
|
||||||
self.actInsertImg,
|
self.actInsertImg,
|
||||||
self.actAlignL,
|
|
||||||
self.actAlignC,
|
|
||||||
self.actAlignR,
|
|
||||||
self.actHistory,
|
self.actHistory,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -171,7 +135,6 @@ class ToolBar(QToolBar):
|
||||||
def _apply_toolbar_styles(self):
|
def _apply_toolbar_styles(self):
|
||||||
self._style_letter_button(self.actBold, "B", bold=True)
|
self._style_letter_button(self.actBold, "B", bold=True)
|
||||||
self._style_letter_button(self.actItalic, "I", italic=True)
|
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)
|
self._style_letter_button(self.actStrike, "S", strike=True)
|
||||||
# Monospace look for code; use a fixed font
|
# Monospace look for code; use a fixed font
|
||||||
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
|
|
@ -187,11 +150,6 @@ class ToolBar(QToolBar):
|
||||||
self._style_letter_button(self.actBullets, "•")
|
self._style_letter_button(self.actBullets, "•")
|
||||||
self._style_letter_button(self.actNumbers, "1.")
|
self._style_letter_button(self.actNumbers, "1.")
|
||||||
|
|
||||||
# Alignment
|
|
||||||
self._style_letter_button(self.actAlignL, "L")
|
|
||||||
self._style_letter_button(self.actAlignC, "C")
|
|
||||||
self._style_letter_button(self.actAlignR, "R")
|
|
||||||
|
|
||||||
# History
|
# History
|
||||||
self._style_letter_button(self.actHistory, "View History")
|
self._style_letter_button(self.actHistory, "View History")
|
||||||
|
|
||||||
|
|
|
||||||
61
poetry.lock
generated
61
poetry.lock
generated
|
|
@ -1,27 +1,5 @@
|
||||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
# 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]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
|
|
@ -180,21 +158,6 @@ files = [
|
||||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
{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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "25.0"
|
version = "25.0"
|
||||||
|
|
@ -382,28 +345,6 @@ files = [
|
||||||
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"},
|
{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]]
|
[[package]]
|
||||||
name = "sqlcipher3-wheels"
|
name = "sqlcipher3-wheels"
|
||||||
version = "0.5.5.post0"
|
version = "0.5.5.post0"
|
||||||
|
|
@ -600,4 +541,4 @@ files = [
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = ">=3.9,<3.14"
|
python-versions = ">=3.9,<3.14"
|
||||||
content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d"
|
content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.1.12.1"
|
version = "0.2.0"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
@ -11,7 +11,6 @@ repository = "https://git.mig5.net/mig5/bouquin"
|
||||||
python = ">=3.9,<3.14"
|
python = ">=3.9,<3.14"
|
||||||
pyside6 = ">=6.8.1,<7.0.0"
|
pyside6 = ">=6.8.1,<7.0.0"
|
||||||
sqlcipher3-wheels = "^0.5.5.post0"
|
sqlcipher3-wheels = "^0.5.5.post0"
|
||||||
markdownify = "^1.2.0"
|
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
bouquin = "bouquin.__main__:main"
|
bouquin = "bouquin.__main__:main"
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,51 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
|
||||||
from PySide6.QtCore import QStandardPaths
|
|
||||||
from tests.qt_helpers import AutoResponder
|
|
||||||
|
|
||||||
# Force Qt *non-native* file dialog so we can type a filename programmatically.
|
import pytest
|
||||||
os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0")
|
from PySide6.QtWidgets import QApplication
|
||||||
# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env
|
|
||||||
|
# Ensure the nested package directory (repo_root/bouquin) is on sys.path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PKG_PARENT = PROJECT_ROOT / "bouquin"
|
||||||
|
if str(PKG_PARENT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PKG_PARENT))
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
|
||||||
# Make project importable
|
@pytest.fixture(scope="session")
|
||||||
from PySide6.QtWidgets import QApplication, QWidget
|
def app():
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
app = QApplication([])
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
return app
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def enable_qstandardpaths_test_mode():
|
def isolate_qsettings(tmp_path_factory):
|
||||||
QStandardPaths.setTestModeEnabled(True)
|
cfgdir = tmp_path_factory.mktemp("qt_cfg")
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = str(cfgdir)
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
yield
|
||||||
s.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
app = QApplication.instance()
|
|
||||||
themes = ThemeManager(app, ThemeConfig())
|
|
||||||
themes.apply(Theme.SYSTEM)
|
|
||||||
win = MainWindow(themes=themes)
|
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def theme_parent_widget(qtbot):
|
def tmp_db_cfg(tmp_path):
|
||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def qapp():
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
|
||||||
yield app
|
|
||||||
# do not quit; pytest might still need it
|
|
||||||
# app.quit()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_db_path(tmp_path):
|
|
||||||
return tmp_path / "notebook.db"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def cfg(temp_db_path):
|
|
||||||
# Use the real DBConfig from the app (SQLCipher-backed)
|
|
||||||
from bouquin.db import DBConfig
|
from bouquin.db import DBConfig
|
||||||
|
|
||||||
|
db_path = tmp_path / "notebook.db"
|
||||||
|
key = "test-secret-key"
|
||||||
return DBConfig(
|
return DBConfig(
|
||||||
path=Path(temp_db_path),
|
path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True
|
||||||
key="testkey",
|
|
||||||
idle_minutes=0,
|
|
||||||
theme="system",
|
|
||||||
move_todos=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_db(tmp_db_cfg):
|
||||||
|
from bouquin.db import DBManager
|
||||||
|
|
||||||
|
db = DBManager(tmp_db_cfg)
|
||||||
|
ok = db.connect()
|
||||||
|
assert ok, "DB connect() should succeed"
|
||||||
|
yield db
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
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
|
|
||||||
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
|
|
||||||
127
tests/test_db.py
Normal file
127
tests/test_db.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import json, csv
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
|
def _today():
|
||||||
|
return dt.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _yesterday():
|
||||||
|
return (dt.date.today() - dt.timedelta(days=1)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _tomorrow():
|
||||||
|
return (dt.date.today() + dt.timedelta(days=1)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(text, i=0):
|
||||||
|
return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_integrity_and_schema(fresh_db):
|
||||||
|
d = _today()
|
||||||
|
fresh_db.save_new_version(d, _entry("hello world"), "initial")
|
||||||
|
vlist = fresh_db.list_versions(d)
|
||||||
|
assert vlist
|
||||||
|
v = fresh_db.get_version(version_id=vlist[0]["id"])
|
||||||
|
assert v and "created_at" in v
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_get_entry_versions(fresh_db):
|
||||||
|
d = _today()
|
||||||
|
fresh_db.save_new_version(d, _entry("hello world"), "initial")
|
||||||
|
txt = fresh_db.get_entry(d)
|
||||||
|
assert "hello world" in txt
|
||||||
|
|
||||||
|
fresh_db.save_new_version(d, _entry("hello again"), "second")
|
||||||
|
versions = fresh_db.list_versions(d)
|
||||||
|
assert len(versions) >= 2
|
||||||
|
assert any(v["is_current"] for v in versions)
|
||||||
|
|
||||||
|
first = sorted(versions, key=lambda v: v["version_no"])[0]
|
||||||
|
fresh_db.revert_to_version(d, version_id=first["id"])
|
||||||
|
txt2 = fresh_db.get_entry(d)
|
||||||
|
assert "hello world" in txt2 and "again" not in txt2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dates_with_content_and_search(fresh_db):
|
||||||
|
fresh_db.save_new_version(_today(), _entry("alpha bravo"), "t1")
|
||||||
|
fresh_db.save_new_version(_yesterday(), _entry("bravo charlie"), "t2")
|
||||||
|
fresh_db.save_new_version(_tomorrow(), _entry("delta alpha"), "t3")
|
||||||
|
|
||||||
|
dates = set(fresh_db.dates_with_content())
|
||||||
|
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
|
||||||
|
|
||||||
|
hits = list(fresh_db.search_entries("alpha"))
|
||||||
|
assert any(d == _today() for d, _ in hits)
|
||||||
|
assert any(d == _tomorrow() for d, _ in hits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_entries_and_export_by_extension(fresh_db, tmp_path):
|
||||||
|
for i in range(3):
|
||||||
|
d = (dt.date.today() - dt.timedelta(days=i)).isoformat()
|
||||||
|
fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}")
|
||||||
|
entries = fresh_db.get_all_entries()
|
||||||
|
assert entries and all(len(t) == 2 for t in entries)
|
||||||
|
|
||||||
|
json_path = tmp_path / "export.json"
|
||||||
|
fresh_db.export_json(entries, str(json_path))
|
||||||
|
assert json_path.exists() and json.load(open(json_path)) is not None
|
||||||
|
|
||||||
|
csv_path = tmp_path / "export.csv"
|
||||||
|
fresh_db.export_csv(entries, str(csv_path))
|
||||||
|
assert csv_path.exists() and list(csv.reader(open(csv_path)))
|
||||||
|
|
||||||
|
txt_path = tmp_path / "export.txt"
|
||||||
|
fresh_db.export_txt(entries, str(txt_path))
|
||||||
|
assert txt_path.exists() and txt_path.read_text().strip()
|
||||||
|
|
||||||
|
md_path = tmp_path / "export.md"
|
||||||
|
fresh_db.export_markdown(entries, str(md_path))
|
||||||
|
md_text = md_path.read_text()
|
||||||
|
assert md_path.exists() and entries[0][0] in md_text
|
||||||
|
|
||||||
|
html_path = tmp_path / "export.html"
|
||||||
|
fresh_db.export_html(entries, str(html_path), title="My Notebook")
|
||||||
|
assert html_path.exists() and "<html" in html_path.read_text().lower()
|
||||||
|
|
||||||
|
sql_path = tmp_path / "export.sql"
|
||||||
|
fresh_db.export_sql(str(sql_path))
|
||||||
|
assert sql_path.exists() and sql_path.read_bytes()
|
||||||
|
|
||||||
|
sqlc_path = tmp_path / "export.db"
|
||||||
|
fresh_db.export_sqlcipher(str(sqlc_path))
|
||||||
|
assert sqlc_path.exists() and sqlc_path.read_bytes()
|
||||||
|
|
||||||
|
for path in [json_path, csv_path, txt_path, md_path, html_path, sql_path]:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
fresh_db.export_by_extension(str(path))
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
|
||||||
|
fresh_db.save_new_version(_today(), _entry("secure"), "before rekey")
|
||||||
|
fresh_db.rekey("new-key-123")
|
||||||
|
fresh_db.close()
|
||||||
|
|
||||||
|
from bouquin.db import DBManager
|
||||||
|
|
||||||
|
tmp_db_cfg.key = "new-key-123"
|
||||||
|
db2 = DBManager(tmp_db_cfg)
|
||||||
|
assert db2.connect()
|
||||||
|
assert "secure" in db2.get_entry(_today())
|
||||||
|
db2.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_and_close_dont_crash(fresh_db):
|
||||||
|
fresh_db.compact()
|
||||||
|
fresh_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_by_extension_unsupported(fresh_db, tmp_path):
|
||||||
|
p = tmp_path / "export.xyz"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fresh_db.export_by_extension(str(p))
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bouquin.db import DBManager, DBConfig
|
|
||||||
|
|
||||||
# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs
|
|
||||||
from sqlcipher3 import dbapi2 as sqlite
|
|
||||||
|
|
||||||
|
|
||||||
def connect_raw_sqlcipher(db_path: Path, key: str):
|
|
||||||
conn = sqlite.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite.Row
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(f"PRAGMA key = '{key}';")
|
|
||||||
cur.execute("PRAGMA foreign_keys = ON;")
|
|
||||||
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path):
|
|
||||||
# Prepare a "legacy" DB that has only entries(date, content) and no pages/versions
|
|
||||||
db_path = cfg.path
|
|
||||||
conn = connect_raw_sqlcipher(db_path, cfg.key)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);")
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO entries(date, content) VALUES(?, ?);",
|
|
||||||
("2025-01-02", "<p>Hello</p>"),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Now use the real DBManager, which will run _ensure_schema and migrate
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
assert mgr.connect() is True
|
|
||||||
|
|
||||||
# After migration, legacy table should be gone and content reachable via get_entry
|
|
||||||
text = mgr.get_entry("2025-01-02")
|
|
||||||
assert "Hello" in text
|
|
||||||
|
|
||||||
cur = mgr.conn.cursor()
|
|
||||||
# entries table should be dropped
|
|
||||||
with pytest.raises(sqlite.OperationalError):
|
|
||||||
cur.execute("SELECT count(*) FROM entries;").fetchone()
|
|
||||||
|
|
||||||
# pages & versions exist and head points to v1
|
|
||||||
rows = cur.execute(
|
|
||||||
"SELECT current_version_id FROM pages WHERE date='2025-01-02'"
|
|
||||||
).fetchone()
|
|
||||||
assert rows is not None and rows["current_version_id"] is not None
|
|
||||||
vers = mgr.list_versions("2025-01-02")
|
|
||||||
assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_new_version_requires_connection_raises(cfg: DBConfig):
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
mgr.save_new_version("2025-01-03", "<p>x</p>")
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_db(cfg: DBConfig) -> DBManager:
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
assert mgr.connect() is True
|
|
||||||
return mgr
|
|
||||||
|
|
||||||
|
|
||||||
def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig):
|
|
||||||
mgr = _bootstrap_db(cfg)
|
|
||||||
# Create two versions for the same date
|
|
||||||
ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "<p>v1</p>", note="init")
|
|
||||||
ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
|
|
||||||
assert ver1_no == 1 and ver2_no == 2
|
|
||||||
|
|
||||||
# Revert using version_id
|
|
||||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
|
|
||||||
cur = mgr.conn.cursor()
|
|
||||||
head2 = cur.execute(
|
|
||||||
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
|
|
||||||
).fetchone()[0]
|
|
||||||
assert head2 == ver2_id
|
|
||||||
|
|
||||||
# Error: version_id belongs to a different date
|
|
||||||
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path):
|
|
||||||
mgr = _bootstrap_db(cfg)
|
|
||||||
# Seed a couple of entries
|
|
||||||
mgr.save_new_version("2025-01-06", "<p>A</p>")
|
|
||||||
mgr.save_new_version("2025-01-07", "<p>B</p>")
|
|
||||||
|
|
||||||
# Prepare output files
|
|
||||||
out = tmp_path
|
|
||||||
exts = [
|
|
||||||
".json",
|
|
||||||
".csv",
|
|
||||||
".txt",
|
|
||||||
".html",
|
|
||||||
".sql",
|
|
||||||
] # exclude .md due to different signature
|
|
||||||
for ext in exts:
|
|
||||||
path = out / f"export{ext}"
|
|
||||||
mgr.export_by_extension(str(path))
|
|
||||||
assert path.exists() and path.stat().st_size > 0
|
|
||||||
|
|
||||||
# Markdown export uses a different signature (entries + path)
|
|
||||||
entries = mgr.get_all_entries()
|
|
||||||
md_path = out / "export.md"
|
|
||||||
mgr.export_markdown(entries, str(md_path))
|
|
||||||
assert md_path.exists() and md_path.stat().st_size > 0
|
|
||||||
|
|
||||||
# Run VACUUM path
|
|
||||||
mgr.compact() # should not raise
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
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", ".md"]:
|
|
||||||
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,55 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
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)
|
|
||||||
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 _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 = _mk_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 <a>, not the trailing text
|
|
||||||
html = e.document().toHtml()
|
|
||||||
assert re.search(
|
|
||||||
r'<a [^>]*href="https?://mig5\.net"[^>]*>(?:<span[^>]*>)?https?://mig5\.net(?:</span>)?</a>\s+this is a test',
|
|
||||||
html,
|
|
||||||
re.S,
|
|
||||||
), html
|
|
||||||
assert "this is a test</a>" not in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_embed_qimage_saved_as_data_url(qtbot):
|
|
||||||
e = _mk_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 = _mk_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 = _mk_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_code_block_enter_exits_on_empty_line(qtbot):
|
|
||||||
|
|
||||||
e = _mk_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._nearest_code_frame(e.textCursor(), tolerant=False) is not None
|
|
||||||
and e.textCursor().block().length() == 1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Second Enter should jump *out* of the frame
|
|
||||||
QTest.keyClick(e, Qt.Key_Return)
|
|
||||||
|
|
||||||
|
|
||||||
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._nearest_code_frame(e.textCursor(), tolerant=False) 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()
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import base64
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PySide6.QtCore import Qt, QMimeData, QByteArray
|
|
||||||
from PySide6.QtGui import QImage, QTextCursor
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
|
|
||||||
from bouquin.editor import Editor
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def editor(app, qtbot):
|
|
||||||
themes = ThemeManager(app, ThemeConfig())
|
|
||||||
e = Editor(themes)
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
e.show()
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def test_todo_prefix_converts_to_checkbox_on_space(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("TODO")
|
|
||||||
c = editor.textCursor()
|
|
||||||
c.movePosition(QTextCursor.End)
|
|
||||||
editor.setTextCursor(c)
|
|
||||||
QTest.keyClick(editor, Qt.Key_Space)
|
|
||||||
# Now the line should start with the checkbox glyph and a space
|
|
||||||
assert editor.toPlainText().startswith("☐ ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_enter_inside_empty_code_frame_jumps_out(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("") # single empty block
|
|
||||||
# Apply code block to current line
|
|
||||||
editor.apply_code()
|
|
||||||
# Cursor is inside the code frame. Press Enter on empty block should jump out.
|
|
||||||
QTest.keyClick(editor, Qt.Key_Return)
|
|
||||||
# We expect two blocks: one code block (with a newline inserted) and then a normal block
|
|
||||||
txt = editor.toPlainText()
|
|
||||||
assert "\n" in txt # a normal paragraph created after exiting the frame
|
|
||||||
|
|
||||||
|
|
||||||
def test_insertFromMimeData_with_data_image(editor):
|
|
||||||
# Build an in-memory PNG and embed as data URL inside HTML
|
|
||||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
|
||||||
img.fill(0xFF00FF00) # green
|
|
||||||
ba = QByteArray()
|
|
||||||
from PySide6.QtCore import QBuffer, QIODevice
|
|
||||||
|
|
||||||
buf = QBuffer(ba)
|
|
||||||
buf.open(QIODevice.WriteOnly)
|
|
||||||
img.save(buf, "PNG")
|
|
||||||
data_b64 = base64.b64encode(bytes(ba)).decode("ascii")
|
|
||||||
html = f'<img src="data:image/png;base64,{data_b64}"/>'
|
|
||||||
|
|
||||||
md = QMimeData()
|
|
||||||
md.setHtml(html)
|
|
||||||
editor.insertFromMimeData(md)
|
|
||||||
|
|
||||||
# HTML export with embedded images should contain a data: URL
|
|
||||||
h = editor.to_html_with_embedded_images()
|
|
||||||
assert "data:image/png;base64," in h
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_checkboxes_selection(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("item 1\nitem 2")
|
|
||||||
# Select both lines
|
|
||||||
c = editor.textCursor()
|
|
||||||
c.movePosition(QTextCursor.Start)
|
|
||||||
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
|
||||||
editor.setTextCursor(c)
|
|
||||||
# Toggle on -> inserts ☐
|
|
||||||
editor.toggle_checkboxes()
|
|
||||||
assert editor.toPlainText().startswith("☐ ")
|
|
||||||
# Toggle again -> remove ☐
|
|
||||||
editor.toggle_checkboxes()
|
|
||||||
assert not editor.toPlainText().startswith("☐ ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_heading_then_enter_reverts_to_normal(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("A heading")
|
|
||||||
# Apply H2 via apply_heading(size=18)
|
|
||||||
editor.apply_heading(18)
|
|
||||||
c = editor.textCursor()
|
|
||||||
c.movePosition(QTextCursor.End)
|
|
||||||
editor.setTextCursor(c)
|
|
||||||
# Press Enter -> new block should be Normal (not bold/large)
|
|
||||||
QTest.keyClick(editor, Qt.Key_Return)
|
|
||||||
# The new block exists
|
|
||||||
txt = editor.toPlainText()
|
|
||||||
assert "\n" in txt
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
from PySide6.QtCore import QUrl
|
|
||||||
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
|
|
||||||
from bouquin.theme import ThemeManager
|
|
||||||
from bouquin.editor import Editor
|
|
||||||
|
|
||||||
|
|
||||||
def _mk_editor(qapp, cfg):
|
|
||||||
themes = ThemeManager(qapp, cfg)
|
|
||||||
ed = Editor(themes)
|
|
||||||
ed.resize(400, 300)
|
|
||||||
return ed
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_scale_and_reset(qapp, cfg):
|
|
||||||
ed = _mk_editor(qapp, cfg)
|
|
||||||
|
|
||||||
# Register an image resource and insert it at the cursor
|
|
||||||
img = QImage(20, 10, QImage.Format_ARGB32)
|
|
||||||
img.fill(QColor(200, 0, 0))
|
|
||||||
url = QUrl("test://img")
|
|
||||||
from PySide6.QtGui import QTextDocument
|
|
||||||
|
|
||||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
|
||||||
|
|
||||||
fmt = QTextImageFormat()
|
|
||||||
fmt.setName(url.toString())
|
|
||||||
# No explicit width -> code should use original width
|
|
||||||
tc = ed.textCursor()
|
|
||||||
tc.insertImage(fmt)
|
|
||||||
|
|
||||||
# Place cursor at start (on the image) and scale
|
|
||||||
tc = ed.textCursor()
|
|
||||||
tc.movePosition(QTextCursor.Start)
|
|
||||||
ed.setTextCursor(tc)
|
|
||||||
ed._scale_image_at_cursor(1.5) # increases width
|
|
||||||
ed._reset_image_size() # restores to original width
|
|
||||||
|
|
||||||
# Ensure resulting HTML contains an <img> tag
|
|
||||||
html = ed.toHtml()
|
|
||||||
assert "<img" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_image_size_fallbacks(qapp, cfg):
|
|
||||||
ed = _mk_editor(qapp, cfg)
|
|
||||||
# Create a dummy image format with no width/height -> fallback branch inside _apply_image_size
|
|
||||||
fmt = QTextImageFormat()
|
|
||||||
fmt.setName("") # no resource available
|
|
||||||
tc = ed.textCursor()
|
|
||||||
# Insert a single character to have a valid cursor
|
|
||||||
tc.insertText("x")
|
|
||||||
tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1)
|
|
||||||
ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_html_with_embedded_images_and_link_tint(qapp, cfg):
|
|
||||||
ed = _mk_editor(qapp, cfg)
|
|
||||||
|
|
||||||
# Insert an anchor + image and ensure HTML embedding + retint pass runs
|
|
||||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
|
||||||
img.fill(QColor(0, 200, 0))
|
|
||||||
url = QUrl("test://img2")
|
|
||||||
from PySide6.QtGui import QTextDocument
|
|
||||||
|
|
||||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
|
||||||
|
|
||||||
# Compose HTML with a link and an image referencing our resource
|
|
||||||
ed.setHtml(
|
|
||||||
f'<p><a href="http://example.com">link</a></p><p><img src="{url.toString()}"></p>'
|
|
||||||
)
|
|
||||||
|
|
||||||
html = ed.to_html_with_embedded_images()
|
|
||||||
# Embedded data URL should appear for the image
|
|
||||||
assert "data:image" in html
|
|
||||||
# The link should still be present (retinted internally) without crashing
|
|
||||||
assert "example.com" in html
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot
|
|
||||||
from PySide6.QtGui import QImage, QMouseEvent, QTextCursor
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from bouquin.editor import Editor
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _mk_editor() -> Editor:
|
|
||||||
app = QApplication.instance()
|
|
||||||
tm = ThemeManager(app, ThemeConfig())
|
|
||||||
e = Editor(tm)
|
|
||||||
e.resize(700, 400)
|
|
||||||
e.show()
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def _point_for_char(e: Editor, pos: int):
|
|
||||||
c = e.textCursor()
|
|
||||||
c.setPosition(pos)
|
|
||||||
r = e.cursorRect(c)
|
|
||||||
return r.center()
|
|
||||||
|
|
||||||
|
|
||||||
def test_trim_url_and_linkify_and_ctrl_mouse(qtbot):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
assert e._trim_url_end("https://ex.com)") == "https://ex.com"
|
|
||||||
assert e._trim_url_end("www.mysite.org]") == "www.mysite.org"
|
|
||||||
|
|
||||||
url = "https://example.org/path"
|
|
||||||
QTest.keyClicks(e, url)
|
|
||||||
qtbot.waitUntil(lambda: url in e.toPlainText())
|
|
||||||
|
|
||||||
p = _point_for_char(e, 0)
|
|
||||||
move = QMouseEvent(
|
|
||||||
QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier
|
|
||||||
)
|
|
||||||
e.mouseMoveEvent(move)
|
|
||||||
assert e.viewport().cursor().shape() == Qt.PointingHandCursor
|
|
||||||
|
|
||||||
opened = {}
|
|
||||||
|
|
||||||
class Catcher(QObject):
|
|
||||||
@Slot(QUrl)
|
|
||||||
def handle(self, u: QUrl):
|
|
||||||
opened["u"] = u.toString()
|
|
||||||
|
|
||||||
from PySide6.QtGui import QDesktopServices
|
|
||||||
|
|
||||||
catcher = Catcher()
|
|
||||||
QDesktopServices.setUrlHandler("https", catcher, "handle")
|
|
||||||
try:
|
|
||||||
rel = QMouseEvent(
|
|
||||||
QEvent.MouseButtonRelease,
|
|
||||||
p,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.ControlModifier,
|
|
||||||
)
|
|
||||||
e.mouseReleaseEvent(rel)
|
|
||||||
got_signal = []
|
|
||||||
e.linkActivated.connect(lambda href: got_signal.append(href))
|
|
||||||
e.mouseReleaseEvent(rel)
|
|
||||||
assert opened or got_signal
|
|
||||||
finally:
|
|
||||||
QDesktopServices.unsetUrlHandler("https")
|
|
||||||
|
|
||||||
|
|
||||||
def test_insert_images_and_image_helpers(qtbot, tmp_path):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
|
|
||||||
# No image under cursor yet (412 guard)
|
|
||||||
tc, fmt, orig = e._image_info_at_cursor()
|
|
||||||
assert tc is None and fmt is None and orig is None
|
|
||||||
|
|
||||||
# Insert a real image file (574–584 path)
|
|
||||||
img_path = tmp_path / "tiny.png"
|
|
||||||
img = QImage(4, 4, QImage.Format_ARGB32)
|
|
||||||
img.fill(0xFF336699)
|
|
||||||
assert img.save(str(img_path), "PNG")
|
|
||||||
e.insert_images([str(img_path)], autoscale=False)
|
|
||||||
assert "<img" in e.toHtml()
|
|
||||||
|
|
||||||
# Guards when not on an image (453, 464)
|
|
||||||
e._scale_image_at_cursor(1.1)
|
|
||||||
e._fit_image_to_editor_width()
|
|
||||||
|
|
||||||
|
|
||||||
def test_checkbox_click_and_enter_continuation(qtbot):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
e.setPlainText("☐ task one")
|
|
||||||
|
|
||||||
# Need it visible for mouse coords
|
|
||||||
e.resize(600, 300)
|
|
||||||
e.show()
|
|
||||||
qtbot.waitExposed(e)
|
|
||||||
|
|
||||||
# Click on the checkbox glyph to toggle (605–614)
|
|
||||||
start_point = _point_for_char(e, 0)
|
|
||||||
press = QMouseEvent(
|
|
||||||
QEvent.MouseButtonPress,
|
|
||||||
start_point,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.NoModifier,
|
|
||||||
)
|
|
||||||
e.mousePressEvent(press)
|
|
||||||
assert e.toPlainText().startswith("☑ ")
|
|
||||||
|
|
||||||
# Press Enter at end -> new line with fresh checkbox (680–684)
|
|
||||||
c = e.textCursor()
|
|
||||||
c.movePosition(QTextCursor.End)
|
|
||||||
e.setTextCursor(c)
|
|
||||||
QTest.keyClick(e, Qt.Key_Return)
|
|
||||||
lines = e.toPlainText().splitlines()
|
|
||||||
assert len(lines) >= 2 and lines[1].startswith("☐ ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_heading_and_lists_toggle_remove(qtbot):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
e.setPlainText("para")
|
|
||||||
|
|
||||||
# "Normal" path is size=0 (904…)
|
|
||||||
e.apply_heading(0)
|
|
||||||
|
|
||||||
# bullets twice -> second call removes (945–946)
|
|
||||||
e.toggle_bullets()
|
|
||||||
e.toggle_bullets()
|
|
||||||
# numbers twice -> second call removes (955–956)
|
|
||||||
e.toggle_numbers()
|
|
||||||
e.toggle_numbers()
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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 <b>bold</b>")
|
|
||||||
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 "<html" in lower and ("<article" in lower or "<body" in lower)
|
|
||||||
elif ext == ".txt":
|
|
||||||
assert "alpha" in val and "beta" in val
|
|
||||||
elif ext == ".sql":
|
|
||||||
con = sqlite3.connect(str(out))
|
|
||||||
cur = con.cursor()
|
|
||||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
||||||
names = {r[0] for r in cur.fetchall()}
|
|
||||||
assert {"pages", "versions"} <= names
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_encrypted_database(open_window, qtbot, tmp_path, monkeypatch):
|
|
||||||
win = open_window
|
|
||||||
_write_sample_entries(win, qtbot)
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QFileDialog
|
|
||||||
|
|
||||||
def fake_getSaveFileName(*args, **kwargs):
|
|
||||||
return (str(tmp_path / "backup.db"), BACKUP_FILTER)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
|
|
||||||
)
|
|
||||||
|
|
||||||
trigger_menu_action(win, "Backup")
|
|
||||||
backup = tmp_path / "backup.db"
|
|
||||||
qtbot.waitUntil(backup.exists, timeout=5000)
|
|
||||||
|
|
||||||
# The backup path is now ready; proceed as before...
|
|
||||||
sqlcipher3 = pytest.importorskip("sqlcipher3")
|
|
||||||
con = sqlcipher3.dbapi2.connect(str(backup))
|
|
||||||
cur = con.cursor()
|
|
||||||
cur.execute("PRAGMA key = 'ci-secret-key';")
|
|
||||||
ok = cur.execute("PRAGMA cipher_integrity_check;").fetchall()
|
|
||||||
assert ok == []
|
|
||||||
con.close()
|
|
||||||
|
|
@ -1,100 +1,57 @@
|
||||||
from PySide6.QtCore import Qt
|
import pytest
|
||||||
from PySide6.QtGui import QKeySequence, QTextCursor
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
|
|
||||||
from tests.qt_helpers import trigger_menu_action
|
from PySide6.QtGui import QTextCursor
|
||||||
|
from bouquin.markdown_editor import MarkdownEditor
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
|
|
||||||
def _cursor_info(editor):
|
@pytest.fixture
|
||||||
"""Return (start, end, selectedText) for the current selection."""
|
def editor(app, qtbot):
|
||||||
tc: QTextCursor = editor.textCursor()
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
start = min(tc.anchor(), tc.position())
|
ed = MarkdownEditor(themes)
|
||||||
end = max(tc.anchor(), tc.position())
|
qtbot.addWidget(ed)
|
||||||
return start, end, tc.selectedText()
|
ed.show()
|
||||||
|
return ed
|
||||||
|
|
||||||
|
|
||||||
def test_find_actions_and_shortcuts(open_window, qtbot):
|
from bouquin.find_bar import FindBar
|
||||||
win = open_window
|
|
||||||
|
|
||||||
# Actions should be present under Navigate and advertise canonical shortcuts
|
|
||||||
act_find = trigger_menu_action(win, "Find on page")
|
|
||||||
assert act_find.shortcut().matches(QKeySequence.Find) == QKeySequence.ExactMatch
|
|
||||||
|
|
||||||
act_next = trigger_menu_action(win, "Find Next")
|
|
||||||
assert act_next.shortcut().matches(QKeySequence.FindNext) == QKeySequence.ExactMatch
|
|
||||||
|
|
||||||
act_prev = trigger_menu_action(win, "Find Previous")
|
|
||||||
assert (
|
|
||||||
act_prev.shortcut().matches(QKeySequence.FindPrevious)
|
|
||||||
== QKeySequence.ExactMatch
|
|
||||||
)
|
|
||||||
|
|
||||||
# "Find on page" should open the bar and focus the input
|
|
||||||
act_find.trigger()
|
|
||||||
qtbot.waitUntil(lambda: win.findBar.isVisible())
|
|
||||||
qtbot.waitUntil(lambda: win.findBar.edit.hasFocus())
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_navigate_case_sensitive_and_close_focus(open_window, qtbot):
|
@pytest.mark.gui
|
||||||
win = open_window
|
def test_findbar_basic_navigation(qtbot, editor):
|
||||||
|
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
|
||||||
|
editor.moveCursor(QTextCursor.Start)
|
||||||
|
|
||||||
# Mixed-case content with three matches
|
fb = FindBar(editor, parent=editor)
|
||||||
text = "alpha … ALPHA … alpha"
|
qtbot.addWidget(fb)
|
||||||
win.editor.setPlainText(text)
|
fb.show_bar()
|
||||||
qtbot.waitUntil(lambda: win.editor.toPlainText() == text)
|
fb.edit.setText("alpha")
|
||||||
|
fb.find_next()
|
||||||
|
pos1 = editor.textCursor().position()
|
||||||
|
fb.find_next()
|
||||||
|
pos2 = editor.textCursor().position()
|
||||||
|
assert pos2 > pos1
|
||||||
|
|
||||||
# Open the find bar from the menu
|
fb.find_prev()
|
||||||
trigger_menu_action(win, "Find on page").trigger()
|
pos3 = editor.textCursor().position()
|
||||||
qtbot.waitUntil(lambda: win.findBar.isVisible())
|
assert pos3 <= pos2
|
||||||
win.findBar.edit.clear()
|
|
||||||
QTest.keyClicks(win.findBar.edit, "alpha")
|
|
||||||
|
|
||||||
# 1) First hit (case-insensitive default)
|
fb.case.setChecked(True)
|
||||||
QTest.keyClick(win.findBar.edit, Qt.Key_Return)
|
fb.refresh()
|
||||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
fb.hide_bar()
|
||||||
s0, e0, sel0 = _cursor_info(win.editor)
|
|
||||||
assert sel0.lower() == "alpha"
|
|
||||||
|
|
||||||
# 2) Next → uppercase ALPHA (case-insensitive)
|
|
||||||
trigger_menu_action(win, "Find Next").trigger()
|
|
||||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
|
||||||
s1, e1, sel1 = _cursor_info(win.editor)
|
|
||||||
assert sel1.upper() == "ALPHA"
|
|
||||||
|
|
||||||
# 3) Next → the *other* lowercase "alpha"
|
def test_show_bar_seeds_selection(qtbot, editor):
|
||||||
trigger_menu_action(win, "Find Next").trigger()
|
from PySide6.QtGui import QTextCursor
|
||||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
|
||||||
s2, e2, sel2 = _cursor_info(win.editor)
|
|
||||||
assert sel2.lower() == "alpha"
|
|
||||||
# Ensure we didn't wrap back to the very first "alpha"
|
|
||||||
assert s2 != s0
|
|
||||||
|
|
||||||
# 4) Case-sensitive: skip ALPHA and only hit lowercase
|
editor.from_markdown("alpha beta")
|
||||||
win.findBar.case.setChecked(True)
|
c = editor.textCursor()
|
||||||
# Put the caret at start to make the next search deterministic
|
c.movePosition(QTextCursor.Start)
|
||||||
tc = win.editor.textCursor()
|
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
|
||||||
tc.setPosition(0)
|
editor.setTextCursor(c)
|
||||||
win.editor.setTextCursor(tc)
|
|
||||||
|
|
||||||
win.findBar.find_next()
|
fb = FindBar(editor, parent=editor)
|
||||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
qtbot.addWidget(fb)
|
||||||
s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor)
|
fb.show_bar()
|
||||||
assert sel_cs1 == "alpha"
|
assert fb.edit.text().lower() == "alpha"
|
||||||
|
fb.hide_bar()
|
||||||
win.findBar.find_next()
|
|
||||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
|
||||||
s_cs2, e_cs2, sel_cs2 = _cursor_info(win.editor)
|
|
||||||
assert sel_cs2 == "alpha"
|
|
||||||
assert s_cs2 != s_cs1 # it's the other lowercase match
|
|
||||||
|
|
||||||
# 5) Previous goes back to the earlier lowercase match
|
|
||||||
win.findBar.find_prev()
|
|
||||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
|
||||||
s_prev, e_prev, sel_prev = _cursor_info(win.editor)
|
|
||||||
assert sel_prev == "alpha"
|
|
||||||
assert s_prev == s_cs1
|
|
||||||
|
|
||||||
# 6) Close returns focus to editor
|
|
||||||
win.findBar.closeBtn.click()
|
|
||||||
qtbot.waitUntil(lambda: not win.findBar.isVisible())
|
|
||||||
qtbot.waitUntil(lambda: win.editor.hasFocus())
|
|
||||||
|
|
|
||||||
19
tests/test_history_dialog.py
Normal file
19
tests/test_history_dialog.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from bouquin.history_dialog import HistoryDialog
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
||||||
|
d = "2001-01-01"
|
||||||
|
fresh_db.save_new_version(d, "v1", "first")
|
||||||
|
fresh_db.save_new_version(d, "v2", "second")
|
||||||
|
|
||||||
|
w = QWidget()
|
||||||
|
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
|
||||||
|
dlg.list.setCurrentRow(1)
|
||||||
|
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
|
||||||
|
assert fresh_db.get_entry(d) == "v1"
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import pytest
|
|
||||||
from PySide6.QtWidgets import QApplication, QListWidgetItem
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig, DBManager
|
|
||||||
from bouquin.history_dialog import HistoryDialog
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db(tmp_path):
|
|
||||||
cfg = DBConfig(path=tmp_path / "h.db", key="k")
|
|
||||||
db = DBManager(cfg)
|
|
||||||
assert db.connect()
|
|
||||||
# Seed two versions for a date
|
|
||||||
db.save_new_version("2025-02-10", "<p>v1</p>", note="v1", set_current=True)
|
|
||||||
db.save_new_version("2025-02-10", "<p>v2</p>", note="v2", set_current=True)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
def test_revert_early_returns(app, db, qtbot):
|
|
||||||
dlg = HistoryDialog(db, date_iso="2025-02-10")
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# (1) No current item -> returns immediately
|
|
||||||
dlg.list.setCurrentItem(None)
|
|
||||||
dlg._revert() # should not crash and should not accept
|
|
||||||
|
|
||||||
# (2) Selecting the current item -> still returns early
|
|
||||||
# Build an item with the *current* id as payload
|
|
||||||
cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"])
|
|
||||||
it = QListWidgetItem("current")
|
|
||||||
it.setData(Qt.UserRole, cur_id)
|
|
||||||
dlg.list.addItem(it)
|
|
||||||
dlg.list.setCurrentItem(it)
|
|
||||||
dlg._revert() # should return early (no accept called)
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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]
|
|
||||||
9
tests/test_key_prompt.py
Normal file
9
tests/test_key_prompt.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from bouquin.key_prompt import KeyPrompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_prompt_roundtrip(qtbot):
|
||||||
|
kp = KeyPrompt()
|
||||||
|
qtbot.addWidget(kp)
|
||||||
|
kp.show()
|
||||||
|
kp.edit.setText("swordfish")
|
||||||
|
assert kp.key() == "swordfish"
|
||||||
18
tests/test_lock_overlay.py
Normal file
18
tests/test_lock_overlay.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QEvent
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
from bouquin.lock_overlay import LockOverlay
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_lock_overlay_reacts_to_theme(qtbot):
|
||||||
|
host = QWidget()
|
||||||
|
qtbot.addWidget(host)
|
||||||
|
host.show()
|
||||||
|
|
||||||
|
ol = LockOverlay(host, on_unlock=lambda: None)
|
||||||
|
qtbot.addWidget(ol)
|
||||||
|
ol.show()
|
||||||
|
|
||||||
|
ev = QEvent(QEvent.Type.PaletteChange)
|
||||||
|
ol.changeEvent(ev)
|
||||||
11
tests/test_main.py
Normal file
11
tests/test_main.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_module_has_main():
|
||||||
|
m = importlib.import_module("bouquin.main")
|
||||||
|
assert hasattr(m, "main")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dunder_main_imports_main():
|
||||||
|
m = importlib.import_module("bouquin.__main__")
|
||||||
|
assert hasattr(m, "main")
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import runpy
|
|
||||||
import types
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def test_dunder_main_executes_without_launching_qt(monkeypatch):
|
|
||||||
# Replace bouquin.main with a stub that records invocation and returns immediately
|
|
||||||
calls = {"called": False}
|
|
||||||
mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True))
|
|
||||||
monkeypatch.setitem(sys.modules, "bouquin.main", mod)
|
|
||||||
|
|
||||||
# Running the module as __main__ should call mod.main() but not start a Qt loop
|
|
||||||
runpy.run_module("bouquin.__main__", run_name="__main__")
|
|
||||||
assert calls["called"] is True
|
|
||||||
79
tests/test_main_window.py
Normal file
79
tests/test_main_window.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate
|
||||||
|
|
||||||
|
from bouquin.main_window import MainWindow
|
||||||
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
|
from bouquin.settings import get_settings
|
||||||
|
from bouquin.key_prompt import KeyPrompt
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
|
s = get_settings()
|
||||||
|
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||||
|
s.setValue("db/key", tmp_db_cfg.key)
|
||||||
|
s.setValue("ui/idle_minutes", 0)
|
||||||
|
s.setValue("ui/theme", "light")
|
||||||
|
s.setValue("ui/move_todos", True)
|
||||||
|
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
w = MainWindow(themes=themes)
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
w.show()
|
||||||
|
|
||||||
|
date = QDate.currentDate().toString("yyyy-MM-dd")
|
||||||
|
w._load_selected_date(date)
|
||||||
|
w.editor.from_markdown("hello **world**")
|
||||||
|
w._on_text_changed()
|
||||||
|
qtbot.wait(5500) # let the 5s autosave QTimer fire
|
||||||
|
assert "world" in fresh_db.get_entry(date)
|
||||||
|
|
||||||
|
w.search.search.setText("world")
|
||||||
|
qtbot.wait(50)
|
||||||
|
assert not w.search.results.isHidden()
|
||||||
|
|
||||||
|
w._sync_toolbar()
|
||||||
|
w._adjust_day(-1) # previous day
|
||||||
|
w._adjust_day(+1) # next day
|
||||||
|
|
||||||
|
# Auto-accept the unlock KeyPrompt with the correct key
|
||||||
|
def _auto_accept_keyprompt():
|
||||||
|
for wdg in QApplication.topLevelWidgets():
|
||||||
|
if isinstance(wdg, KeyPrompt):
|
||||||
|
wdg.edit.setText(tmp_db_cfg.key)
|
||||||
|
wdg.accept()
|
||||||
|
|
||||||
|
w._enter_lock()
|
||||||
|
QTimer.singleShot(0, _auto_accept_keyprompt)
|
||||||
|
w._on_unlock_clicked()
|
||||||
|
qtbot.wait(50) # let the nested event loop process the acceptance
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
|
from PySide6.QtCore import QDate
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
from bouquin.settings import get_settings
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||||
|
s.setValue("db/key", tmp_db_cfg.key)
|
||||||
|
s.setValue("ui/move_todos", True)
|
||||||
|
s.setValue("ui/theme", "light")
|
||||||
|
|
||||||
|
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
||||||
|
fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed")
|
||||||
|
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
from bouquin.main_window import MainWindow
|
||||||
|
|
||||||
|
w = MainWindow(themes=themes)
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
w.show()
|
||||||
|
|
||||||
|
w._load_yesterday_todos()
|
||||||
|
|
||||||
|
assert "carry me" in w.editor.to_markdown()
|
||||||
|
y_txt = fresh_db.get_entry(y)
|
||||||
|
assert "carry me" not in y_txt or "- [ ]" not in y_txt
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
from PySide6.QtCore import QDate
|
|
||||||
from bouquin.theme import ThemeManager
|
|
||||||
from bouquin.main_window import MainWindow
|
|
||||||
from bouquin.settings import save_db_config
|
|
||||||
from bouquin.db import DBManager
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_window(qapp, cfg):
|
|
||||||
# Ensure DB exists and key is valid in settings
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
assert mgr.connect() is True
|
|
||||||
save_db_config(cfg)
|
|
||||||
|
|
||||||
themes = ThemeManager(qapp, cfg)
|
|
||||||
win = MainWindow(themes)
|
|
||||||
# Force an initial selected date
|
|
||||||
win.calendar.setSelectedDate(QDate.currentDate())
|
|
||||||
return win
|
|
||||||
|
|
||||||
|
|
||||||
def test_move_todos_copies_unchecked(qapp, cfg, tmp_path):
|
|
||||||
cfg.move_todos = True
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects
|
|
||||||
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
|
||||||
html = (
|
|
||||||
"<p><span>☐</span> Unchecked 1</p>"
|
|
||||||
"<p><span>☑</span> Checked 1</p>"
|
|
||||||
"<p><span>☐</span> Unchecked 2</p>"
|
|
||||||
)
|
|
||||||
win.db.save_new_version(y, html)
|
|
||||||
|
|
||||||
# Ensure today starts blank
|
|
||||||
today_iso = QDate.currentDate().toString("yyyy-MM-dd")
|
|
||||||
win.editor.setHtml("<p></p>")
|
|
||||||
_html = win.editor.toHtml()
|
|
||||||
win.db.save_new_version(today_iso, _html)
|
|
||||||
|
|
||||||
# Invoke the move-todos logic
|
|
||||||
win._load_yesterday_todos()
|
|
||||||
|
|
||||||
# Verify today's entry now contains only the unchecked items
|
|
||||||
txt = win.db.get_entry(today_iso)
|
|
||||||
assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjust_and_save_paths(qapp, cfg):
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Move date selection and jump to today
|
|
||||||
before = win.calendar.selectedDate()
|
|
||||||
win._adjust_day(-1)
|
|
||||||
assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString(
|
|
||||||
"yyyy-MM-dd"
|
|
||||||
)
|
|
||||||
win._adjust_today()
|
|
||||||
assert win.calendar.selectedDate() == QDate.currentDate()
|
|
||||||
|
|
||||||
# Save path exercises success feedback + dirty flag reset
|
|
||||||
win.editor.setHtml("<p>content</p>")
|
|
||||||
win._dirty = True
|
|
||||||
win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True)
|
|
||||||
assert win._dirty is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_window_position(qapp, cfg, tmp_path):
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Save geometry/state into settings and restore it (covers maximize singleShot branch too)
|
|
||||||
geom = win.saveGeometry()
|
|
||||||
state = win.saveState()
|
|
||||||
s = win.settings
|
|
||||||
s.setValue("ui/geometry", geom)
|
|
||||||
s.setValue("ui/window_state", state)
|
|
||||||
s.sync()
|
|
||||||
|
|
||||||
win._restore_window_position() # should restore without error
|
|
||||||
|
|
||||||
|
|
||||||
def test_idle_lock_unlock_flow(qapp, cfg):
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Enter lock
|
|
||||||
win._enter_lock()
|
|
||||||
assert getattr(win, "_locked", False) is True
|
|
||||||
|
|
||||||
# Disabling idle minutes should unlock and hide overlay
|
|
||||||
win._apply_idle_minutes(0)
|
|
||||||
assert getattr(win, "_locked", False) is False
|
|
||||||
63
tests/test_markdown_editor.py
Normal file
63
tests/test_markdown_editor.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from PySide6.QtGui import QImage, QColor
|
||||||
|
from bouquin.markdown_editor import MarkdownEditor
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def editor(app, qtbot):
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
ed = MarkdownEditor(themes)
|
||||||
|
qtbot.addWidget(ed)
|
||||||
|
ed.show()
|
||||||
|
return ed
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_and_to_markdown_roundtrip(editor):
|
||||||
|
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
|
||||||
|
editor.from_markdown(md)
|
||||||
|
out = editor.to_markdown()
|
||||||
|
assert "Title" in out and "task" in out and "code" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_styles_and_headings(editor, qtbot):
|
||||||
|
editor.from_markdown("hello world")
|
||||||
|
editor.selectAll()
|
||||||
|
editor.apply_weight()
|
||||||
|
editor.apply_italic()
|
||||||
|
editor.apply_strikethrough()
|
||||||
|
editor.apply_heading(24)
|
||||||
|
md = editor.to_markdown()
|
||||||
|
assert "**" in md and "*~~~~*" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_lists_and_checkboxes(editor):
|
||||||
|
editor.from_markdown("item one\nitem two\n")
|
||||||
|
editor.toggle_bullets()
|
||||||
|
assert "- " in editor.to_markdown()
|
||||||
|
editor.toggle_numbers()
|
||||||
|
assert "1. " in editor.to_markdown()
|
||||||
|
editor.toggle_checkboxes()
|
||||||
|
md = editor.to_markdown()
|
||||||
|
assert "- [ ]" in md or "- [x]" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_image_from_path(editor, tmp_path):
|
||||||
|
img = tmp_path / "pic.png"
|
||||||
|
qimg = QImage(2, 2, QImage.Format_RGBA8888)
|
||||||
|
qimg.fill(QColor(255, 0, 0))
|
||||||
|
assert qimg.save(str(img)) # ensure a valid PNG on disk
|
||||||
|
|
||||||
|
editor.insert_image_from_path(img)
|
||||||
|
md = editor.to_markdown()
|
||||||
|
# Images are saved as base64 data URIs in markdown
|
||||||
|
assert "data:image/image/png;base64" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_code_inline(editor):
|
||||||
|
editor.from_markdown("alpha beta")
|
||||||
|
editor.selectAll()
|
||||||
|
editor.apply_code()
|
||||||
|
md = editor.to_markdown()
|
||||||
|
assert ("`" in md) or ("```" in md)
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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"]
|
|
||||||
8
tests/test_save_dialog.py
Normal file
8
tests/test_save_dialog.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from bouquin.save_dialog import SaveDialog
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_dialog_note_text(qtbot):
|
||||||
|
dlg = SaveDialog()
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
assert dlg.note_text()
|
||||||
22
tests/test_search.py
Normal file
22
tests/test_search.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from bouquin.search import Search
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_widget_populates_results(qtbot, fresh_db):
|
||||||
|
fresh_db.save_new_version("2024-01-01", "alpha bravo", "seed")
|
||||||
|
fresh_db.save_new_version("2024-01-02", "bravo charlie", "seed")
|
||||||
|
fresh_db.save_new_version("2024-01-03", "delta alpha bravo", "seed")
|
||||||
|
|
||||||
|
s = Search(fresh_db)
|
||||||
|
qtbot.addWidget(s)
|
||||||
|
s.show()
|
||||||
|
|
||||||
|
emitted = []
|
||||||
|
s.resultDatesChanged.connect(lambda ds: emitted.append(tuple(ds)))
|
||||||
|
s.search.setText("alpha")
|
||||||
|
qtbot.wait(50)
|
||||||
|
assert s.results.count() >= 2
|
||||||
|
assert emitted and {"2024-01-01", "2024-01-03"}.issubset(set(emitted[-1]))
|
||||||
|
|
||||||
|
s.search.setText("")
|
||||||
|
qtbot.wait(50)
|
||||||
|
assert s.results.isHidden()
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
from bouquin.search import Search as SearchWidget
|
|
||||||
|
|
||||||
|
|
||||||
class DummyDB:
|
|
||||||
def search_entries(self, q):
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_no_match_triggers_start_window(qtbot):
|
|
||||||
w = SearchWidget(db=DummyDB())
|
|
||||||
qtbot.addWidget(w)
|
|
||||||
html = "<p>" + ("x" * 300) + "</p>" # long text, no token present
|
|
||||||
frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80)
|
|
||||||
assert frag != ""
|
|
||||||
assert left is False and right is True
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig, DBManager
|
|
||||||
from bouquin.search import Search
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
# Ensure a single QApplication exists
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
yield a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fresh_db(tmp_path):
|
|
||||||
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
|
|
||||||
db = DBManager(cfg)
|
|
||||||
assert db.connect() is True
|
|
||||||
# Seed a couple of entries
|
|
||||||
db.save_new_version("2025-01-01", "<p>Hello world first day</p>")
|
|
||||||
db.save_new_version(
|
|
||||||
"2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
|
|
||||||
)
|
|
||||||
db.save_new_version(
|
|
||||||
"2025-01-03",
|
|
||||||
"<p>Long content begins "
|
|
||||||
+ ("x" * 200)
|
|
||||||
+ " middle token here "
|
|
||||||
+ ("y" * 200)
|
|
||||||
+ " ends.</p>",
|
|
||||||
)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot):
|
|
||||||
# Close the DB to provoke an exception inside Search._search
|
|
||||||
fresh_db.close()
|
|
||||||
w = Search(fresh_db)
|
|
||||||
w.show()
|
|
||||||
qtbot.addWidget(w)
|
|
||||||
|
|
||||||
# Typing should not raise; exception path returns empty results
|
|
||||||
w._search("anything")
|
|
||||||
assert w.results.isHidden() # remains hidden because there are no rows
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_ellipses_both_sides(app, fresh_db):
|
|
||||||
w = Search(fresh_db)
|
|
||||||
# Choose a query so that the first match sits well inside a long string,
|
|
||||||
# forcing both left and right ellipses.
|
|
||||||
html = fresh_db.get_entry("2025-01-03")
|
|
||||||
snippet, left_ell, right_ell = w._make_html_snippet(html, "middle")
|
|
||||||
assert snippet # non-empty
|
|
||||||
assert left_ell is True
|
|
||||||
assert right_ell is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_results_middle(app, fresh_db, qtbot):
|
|
||||||
w = Search(fresh_db)
|
|
||||||
w.show()
|
|
||||||
qtbot.addWidget(w)
|
|
||||||
# Choose a query so that the first match sits well inside a long string,
|
|
||||||
# forcing both left and right ellipses.
|
|
||||||
assert fresh_db.connect()
|
|
||||||
|
|
||||||
w._search("middle")
|
|
||||||
assert w.results.isVisible()
|
|
||||||
11
tests/test_search_helpers.py
Normal file
11
tests/test_search_helpers.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from bouquin.search import Search
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||||
|
s = Search(fresh_db)
|
||||||
|
long = (
|
||||||
|
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||||
|
)
|
||||||
|
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||||
|
assert "alpha" in frag
|
||||||
|
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
from PySide6.QtWidgets import QListWidget, QWidget, QAbstractButton
|
|
||||||
|
|
||||||
from tests.qt_helpers import (
|
|
||||||
trigger_menu_action,
|
|
||||||
wait_for_widget,
|
|
||||||
find_line_edit_by_placeholder,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_and_open_date(open_window, qtbot):
|
|
||||||
win = open_window
|
|
||||||
|
|
||||||
win.editor.setPlainText("lorem ipsum target")
|
|
||||||
win._save_current(explicit=True)
|
|
||||||
base = win.calendar.selectedDate()
|
|
||||||
d2 = base.addDays(7)
|
|
||||||
win.calendar.setSelectedDate(d2)
|
|
||||||
win.editor.setPlainText("target appears here, too")
|
|
||||||
win._save_current(explicit=True)
|
|
||||||
|
|
||||||
search_box = find_line_edit_by_placeholder(win, "search")
|
|
||||||
assert search_box is not None, "Search input not found"
|
|
||||||
search_box.setText("target")
|
|
||||||
QTest.qWait(150)
|
|
||||||
|
|
||||||
results = getattr(getattr(win, "search", None), "results", None)
|
|
||||||
if isinstance(results, QListWidget) and results.count() > 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()
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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,37 +0,0 @@
|
||||||
import pytest
|
|
||||||
from bouquin.search import Search
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def search_widget(qapp):
|
|
||||||
# We don't need a real DB for snippet generation – pass None
|
|
||||||
return Search(db=None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_empty(search_widget: Search):
|
|
||||||
html = ""
|
|
||||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
|
||||||
html, "", radius=10, maxlen=20
|
|
||||||
)
|
|
||||||
assert frag == "" and has_prev is False and has_next is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_phrase_preferred(search_widget: Search):
|
|
||||||
html = "<p>Alpha beta gamma delta</p>"
|
|
||||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
|
||||||
html, "beta gamma", radius=1, maxlen=10
|
|
||||||
)
|
|
||||||
# We expect a window that includes the phrase and has previous text
|
|
||||||
assert "beta" in frag and "gamma" in frag
|
|
||||||
assert has_prev is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search):
|
|
||||||
html = "<p>One two three four five six seven eight nine ten eleven twelve</p>"
|
|
||||||
# Use tokens such that the phrase doesn't exist, but individual tokens do
|
|
||||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
|
||||||
html, "eleven two", radius=3, maxlen=20
|
|
||||||
)
|
|
||||||
assert "two" in frag
|
|
||||||
# The snippet should be a slice within the text (has more following content)
|
|
||||||
assert has_next is True
|
|
||||||
36
tests/test_settings.py
Normal file
36
tests/test_settings.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from bouquin.settings import (
|
||||||
|
default_db_path,
|
||||||
|
get_settings,
|
||||||
|
load_db_config,
|
||||||
|
save_db_config,
|
||||||
|
)
|
||||||
|
from bouquin.db import DBConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_db_path_returns_writable_path(app, tmp_path):
|
||||||
|
p = default_db_path()
|
||||||
|
assert isinstance(p, Path)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
||||||
|
s = get_settings()
|
||||||
|
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]:
|
||||||
|
s.remove(k)
|
||||||
|
|
||||||
|
cfg = DBConfig(
|
||||||
|
path=tmp_path / "notes.db",
|
||||||
|
key="abc123",
|
||||||
|
idle_minutes=7,
|
||||||
|
theme="dark",
|
||||||
|
move_todos=True,
|
||||||
|
)
|
||||||
|
save_db_config(cfg)
|
||||||
|
|
||||||
|
loaded = load_db_config()
|
||||||
|
assert loaded.path == cfg.path
|
||||||
|
assert loaded.key == cfg.key
|
||||||
|
assert loaded.idle_minutes == cfg.idle_minutes
|
||||||
|
assert loaded.theme == cfg.theme
|
||||||
|
assert loaded.move_todos == cfg.move_todos
|
||||||
|
|
@ -1,296 +1,180 @@
|
||||||
from pathlib import Path
|
import pytest
|
||||||
|
|
||||||
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig
|
|
||||||
from bouquin.settings_dialog import SettingsDialog
|
from bouquin.settings_dialog import SettingsDialog
|
||||||
from bouquin.theme import Theme
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||||
|
|
||||||
|
|
||||||
class _ThemeSpy:
|
@pytest.mark.gui
|
||||||
def __init__(self):
|
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
|
||||||
self.calls = []
|
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
|
||||||
|
app = QApplication.instance()
|
||||||
|
parent = QWidget()
|
||||||
|
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
|
||||||
def set(self, t):
|
dlg.path_edit.setText(str(tmp_path / "alt.db"))
|
||||||
self.calls.append(t)
|
dlg.idle_spin.setValue(3)
|
||||||
|
dlg.theme_light.setChecked(True)
|
||||||
|
dlg.move_todos.setChecked(True)
|
||||||
|
|
||||||
|
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
|
||||||
|
def _auto_accept_msgbox():
|
||||||
|
for w in QApplication.topLevelWidgets():
|
||||||
|
if isinstance(w, QMessageBox):
|
||||||
|
w.accept()
|
||||||
|
|
||||||
|
QTimer.singleShot(0, _auto_accept_msgbox)
|
||||||
|
dlg._compact_btn_clicked()
|
||||||
|
qtbot.wait(50)
|
||||||
|
|
||||||
|
dlg._save()
|
||||||
|
cfg = dlg.config
|
||||||
|
assert cfg.path.name == "alt.db"
|
||||||
|
assert cfg.idle_minutes == 3
|
||||||
|
assert cfg.theme in ("light", "dark", "system")
|
||||||
|
|
||||||
|
|
||||||
class _Parent(QWidget):
|
def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
|
||||||
def __init__(self):
|
from PySide6.QtCore import QTimer
|
||||||
super().__init__()
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
self.themes = _ThemeSpy()
|
from bouquin.key_prompt import KeyPrompt
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
parent = QWidget()
|
||||||
|
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
||||||
|
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
|
||||||
|
# Ensure a clean starting state (suite may leave settings toggled on)
|
||||||
|
dlg.save_key_btn.setChecked(False)
|
||||||
|
dlg.key = ""
|
||||||
|
|
||||||
|
# Robust popup pump so we never miss late dialogs
|
||||||
|
def _pump():
|
||||||
|
for w in QApplication.topLevelWidgets():
|
||||||
|
if isinstance(w, KeyPrompt):
|
||||||
|
w.edit.setText("supersecret")
|
||||||
|
w.accept()
|
||||||
|
elif isinstance(w, QMessageBox):
|
||||||
|
w.accept()
|
||||||
|
|
||||||
|
timer = QTimer()
|
||||||
|
timer.setInterval(10)
|
||||||
|
timer.timeout.connect(_pump)
|
||||||
|
timer.start()
|
||||||
|
try:
|
||||||
|
dlg.save_key_btn.setChecked(True)
|
||||||
|
qtbot.waitUntil(lambda: dlg.key == "supersecret", timeout=1000)
|
||||||
|
assert dlg.save_key_btn.isChecked()
|
||||||
|
|
||||||
|
dlg.save_key_btn.setChecked(False)
|
||||||
|
qtbot.waitUntil(lambda: dlg.key == "", timeout=1000)
|
||||||
|
assert dlg.key == ""
|
||||||
|
finally:
|
||||||
|
timer.stop()
|
||||||
|
|
||||||
|
|
||||||
class FakeDB:
|
def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
|
||||||
def __init__(self):
|
from PySide6.QtCore import QTimer
|
||||||
self.rekey_called_with = None
|
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||||
self.compact_called = False
|
from bouquin.key_prompt import KeyPrompt
|
||||||
self.fail_compact = False
|
from bouquin.db import DBManager, DBConfig
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
def rekey(self, key: str):
|
cfg = DBConfig(
|
||||||
self.rekey_called_with = key
|
path=tmp_path / "iso.db",
|
||||||
|
key="oldkey",
|
||||||
|
idle_minutes=0,
|
||||||
|
theme="light",
|
||||||
|
move_todos=True,
|
||||||
|
)
|
||||||
|
db = DBManager(cfg)
|
||||||
|
assert db.connect()
|
||||||
|
db.save_new_version("2000-01-01", "seed", "seed")
|
||||||
|
|
||||||
def compact(self):
|
parent = QWidget()
|
||||||
if self.fail_compact:
|
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
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)
|
|
||||||
|
|
||||||
# Provide a lightweight parent that mimics MainWindow’s `themes` API
|
|
||||||
class _ThemeSpy:
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
|
|
||||||
def set(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)
|
dlg = SettingsDialog(cfg, db, parent=parent)
|
||||||
qtbot.addWidget(dlg)
|
qtbot.addWidget(dlg)
|
||||||
dlg.show()
|
dlg.show()
|
||||||
qtbot.waitExposed(dlg)
|
|
||||||
|
|
||||||
# Change fields
|
keys = ["one", "two"]
|
||||||
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
|
def _pump_popups():
|
||||||
dlg.save_key_btn.setChecked(True)
|
for w in QApplication.topLevelWidgets():
|
||||||
|
if isinstance(w, KeyPrompt):
|
||||||
|
w.edit.setText(keys.pop(0) if keys else "zzz")
|
||||||
|
w.accept()
|
||||||
|
elif isinstance(w, QMessageBox):
|
||||||
|
w.accept()
|
||||||
|
|
||||||
dlg._save()
|
timer = QTimer()
|
||||||
|
timer.setInterval(10)
|
||||||
out = saved["cfg"]
|
timer.timeout.connect(_pump_popups)
|
||||||
assert out.path == new_path
|
timer.start()
|
||||||
assert out.idle_minutes == 0
|
try:
|
||||||
assert out.key == "sekrit"
|
dlg._change_key()
|
||||||
assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM
|
finally:
|
||||||
|
timer.stop()
|
||||||
|
db.close()
|
||||||
|
db2 = DBManager(cfg)
|
||||||
|
assert db2.connect()
|
||||||
|
db2.close()
|
||||||
|
|
||||||
|
|
||||||
def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot):
|
def test_change_key_success(qtbot, tmp_path, app):
|
||||||
# When toggled on with no key yet, it prompts; cancelling should revert the check
|
from PySide6.QtCore import QTimer
|
||||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt)
|
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox
|
||||||
|
from bouquin.key_prompt import KeyPrompt
|
||||||
|
from bouquin.db import DBManager, DBConfig
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
|
cfg = DBConfig(
|
||||||
qtbot.addWidget(dlg)
|
path=tmp_path / "iso2.db",
|
||||||
dlg.show()
|
key="oldkey",
|
||||||
qtbot.waitExposed(dlg)
|
idle_minutes=0,
|
||||||
|
theme="light",
|
||||||
assert dlg.key == ""
|
move_todos=True,
|
||||||
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 = DBManager(cfg)
|
||||||
|
assert db.connect()
|
||||||
|
db.save_new_version("2001-01-01", "seed", "seed")
|
||||||
|
|
||||||
db = FakeDB()
|
parent = QWidget()
|
||||||
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
|
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
dlg = SettingsDialog(cfg, db, parent=parent)
|
||||||
qtbot.addWidget(dlg)
|
qtbot.addWidget(dlg)
|
||||||
dlg.show()
|
dlg.show()
|
||||||
qtbot.waitExposed(dlg)
|
|
||||||
|
|
||||||
dlg._change_key()
|
keys = ["newkey", "newkey"]
|
||||||
|
|
||||||
assert db.rekey_called_with == "newkey"
|
def _pump():
|
||||||
assert shown["info"] >= 1
|
for w in QApplication.topLevelWidgets():
|
||||||
assert dlg.key == "newkey"
|
if isinstance(w, KeyPrompt):
|
||||||
|
w.edit.setText(keys.pop(0) if keys else "newkey")
|
||||||
|
w.accept()
|
||||||
|
elif isinstance(w, QMessageBox):
|
||||||
|
w.accept()
|
||||||
|
|
||||||
|
timer = QTimer()
|
||||||
|
timer.setInterval(10)
|
||||||
|
timer.timeout.connect(_pump)
|
||||||
|
timer.start()
|
||||||
|
try:
|
||||||
|
dlg._change_key()
|
||||||
|
finally:
|
||||||
|
timer.stop()
|
||||||
|
qtbot.wait(50)
|
||||||
|
|
||||||
def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot):
|
db.close()
|
||||||
p1 = AcceptingPrompt().set_key("a")
|
cfg.key = "newkey"
|
||||||
p2 = AcceptingPrompt().set_key("b")
|
db2 = DBManager(cfg)
|
||||||
seq = [p1, p2]
|
assert db2.connect()
|
||||||
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
|
assert "seed" in db2.get_entry("2001-01-01")
|
||||||
|
db2.close()
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import pytest
|
|
||||||
from PySide6.QtWidgets import QApplication, QDialog, QWidget
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig, DBManager
|
|
||||||
from bouquin.settings_dialog import SettingsDialog
|
|
||||||
from bouquin.settings import APP_NAME, APP_ORG
|
|
||||||
from bouquin.key_prompt import KeyPrompt
|
|
||||||
from bouquin.theme import Theme, ThemeManager, ThemeConfig
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
a.setApplicationName(APP_NAME)
|
|
||||||
a.setOrganizationName(APP_ORG)
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db(tmp_path):
|
|
||||||
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
|
|
||||||
m = DBManager(cfg)
|
|
||||||
assert m.connect()
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
|
|
||||||
# Dark preselection
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
dlg.theme_dark.setChecked(True)
|
|
||||||
dlg._save()
|
|
||||||
assert dlg.config.theme == Theme.DARK.value
|
|
||||||
|
|
||||||
# Light preselection
|
|
||||||
parent2 = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent2)
|
|
||||||
dlg2 = SettingsDialog(db.cfg, db, parent=parent2)
|
|
||||||
qtbot.addWidget(dlg2)
|
|
||||||
dlg2.theme_light.setChecked(True)
|
|
||||||
dlg2._save()
|
|
||||||
assert dlg2.config.theme == Theme.LIGHT.value
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# First prompt cancelled -> early return
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
|
|
||||||
dlg._change_key() # should just return without altering key
|
|
||||||
assert dlg.key == ""
|
|
||||||
|
|
||||||
# First OK, second cancelled -> early return at the second branch
|
|
||||||
state = {"calls": 0}
|
|
||||||
|
|
||||||
def _exec(self):
|
|
||||||
state["calls"] += 1
|
|
||||||
return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected
|
|
||||||
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", _exec)
|
|
||||||
# Also monkeypatch to control key() values
|
|
||||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret")
|
|
||||||
dlg._change_key()
|
|
||||||
# Because the second prompt was rejected, key should remain unchanged
|
|
||||||
assert dlg.key == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot):
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# Simulate user checking the box, but cancelling the prompt -> code unchecks it again
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
|
|
||||||
dlg.save_key_btn.setChecked(True)
|
|
||||||
# The slot toggled should run and revert it to unchecked
|
|
||||||
assert dlg.save_key_btn.isChecked() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_key_exception_path(app, db, monkeypatch, qtbot):
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# Accept both prompts and supply a key
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted)
|
|
||||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom")
|
|
||||||
|
|
||||||
# Force DB rekey to raise to exercise the except-branch
|
|
||||||
monkeypatch.setattr(
|
|
||||||
db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not raise; error is handled internally
|
|
||||||
dlg._change_key()
|
|
||||||
|
|
||||||
|
|
||||||
class _ParentWithThemes(QWidget):
|
|
||||||
def __init__(self, app):
|
|
||||||
super().__init__()
|
|
||||||
self.themes = ThemeManager(app, ThemeConfig())
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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"
|
|
||||||
21
tests/test_theme.py
Normal file
21
tests/test_theme.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
|
||||||
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_manager_apply_light_and_dark(app):
|
||||||
|
cfg = ThemeConfig(theme=Theme.LIGHT)
|
||||||
|
mgr = ThemeManager(app, cfg)
|
||||||
|
mgr.apply(Theme.LIGHT)
|
||||||
|
assert isinstance(app.palette(), QPalette)
|
||||||
|
|
||||||
|
mgr.set(Theme.DARK)
|
||||||
|
assert isinstance(app.palette(), QPalette)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_theme_manager_system_roundtrip(app, qtbot):
|
||||||
|
cfg = ThemeConfig(theme=Theme.SYSTEM)
|
||||||
|
mgr = ThemeManager(app, cfg)
|
||||||
|
mgr.apply(cfg.theme)
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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")
|
|
||||||
44
tests/test_toolbar.py
Normal file
44
tests/test_toolbar.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
from bouquin.markdown_editor import MarkdownEditor
|
||||||
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def editor(app, qtbot):
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
ed = MarkdownEditor(themes)
|
||||||
|
qtbot.addWidget(ed)
|
||||||
|
ed.show()
|
||||||
|
return ed
|
||||||
|
|
||||||
|
|
||||||
|
from bouquin.toolbar import ToolBar
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_toolbar_signals_and_styling(qtbot, editor):
|
||||||
|
host = QWidget()
|
||||||
|
qtbot.addWidget(host)
|
||||||
|
host.show()
|
||||||
|
|
||||||
|
tb = ToolBar(parent=host)
|
||||||
|
qtbot.addWidget(tb)
|
||||||
|
tb.show()
|
||||||
|
|
||||||
|
tb.boldRequested.connect(editor.apply_weight)
|
||||||
|
tb.italicRequested.connect(editor.apply_italic)
|
||||||
|
tb.strikeRequested.connect(editor.apply_strikethrough)
|
||||||
|
tb.codeRequested.connect(lambda: editor.apply_code())
|
||||||
|
tb.headingRequested.connect(lambda s: editor.apply_heading(s))
|
||||||
|
tb.bulletsRequested.connect(lambda: editor.toggle_bullets())
|
||||||
|
tb.numbersRequested.connect(lambda: editor.toggle_numbers())
|
||||||
|
tb.checkboxesRequested.connect(lambda: editor.toggle_checkboxes())
|
||||||
|
|
||||||
|
editor.from_markdown("hello")
|
||||||
|
editor.selectAll()
|
||||||
|
tb.boldRequested.emit()
|
||||||
|
tb.italicRequested.emit()
|
||||||
|
tb.strikeRequested.emit()
|
||||||
|
tb.headingRequested.emit(24)
|
||||||
|
assert editor.to_markdown()
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue