From 6a9d2c4bcc41bf31b6e41654fdb4dc6266e473c9 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 8 Nov 2025 17:29:36 +1100 Subject: [PATCH] convert to markdown --- CHANGELOG.md | 4 + README.md | 2 +- bouquin/db.py | 21 +- bouquin/editor.py | 1015 -------------------- bouquin/history_dialog.py | 52 +- bouquin/main_window.py | 94 +- bouquin/markdown_editor.py | 795 +++++++++++++++ bouquin/search.py | 72 +- bouquin/toolbar.py | 42 - poetry.lock | 61 +- pyproject.toml | 3 +- tests/__init__.py | 0 tests/conftest.py | 148 +-- tests/qt_helpers.py | 287 ------ tests/test_db.py | 127 +++ tests/test_db_migrations_and_versions.py | 117 --- tests/test_db_unit.py | 137 --- tests/test_e2e_actions.py | 55 -- tests/test_editor.py | 339 ------- tests/test_editor_features_more.py | 103 -- tests/test_editor_images_text_states.py | 75 -- tests/test_editor_more.py | 136 --- tests/test_entrypoints.py | 69 -- tests/test_export_backup.py | 112 --- tests/test_find_bar.py | 129 +-- tests/test_history_dialog.py | 19 + tests/test_history_dialog_revert_edges.py | 43 - tests/test_history_dialog_unit.py | 66 -- tests/test_key_prompt.py | 9 + tests/test_lock_overlay.py | 18 + tests/test_main.py | 11 + tests/test_main_module.py | 14 - tests/test_main_window.py | 79 ++ tests/test_main_window_actions.py | 90 -- tests/test_markdown_editor.py | 63 ++ tests/test_misc.py | 113 --- tests/test_save_dialog.py | 8 + tests/test_search.py | 22 + tests/test_search_edgecase.py | 15 - tests/test_search_edges.py | 70 -- tests/test_search_helpers.py | 11 + tests/test_search_history.py | 110 --- tests/test_search_unit.py | 57 -- tests/test_search_windows.py | 37 - tests/test_settings.py | 36 + tests/test_settings_dialog.py | 422 +++----- tests/test_settings_dialog_cancel_paths.py | 111 --- tests/test_settings_module.py | 28 - tests/test_theme.py | 21 + tests/test_theme_integration.py | 19 - tests/test_theme_manager.py | 19 - tests/test_toolbar.py | 44 + tests/test_toolbar_private.py | 23 - tests/test_toolbar_styles.py | 55 -- 54 files changed, 1616 insertions(+), 4012 deletions(-) delete mode 100644 bouquin/editor.py create mode 100644 bouquin/markdown_editor.py delete mode 100644 tests/__init__.py delete mode 100644 tests/qt_helpers.py create mode 100644 tests/test_db.py delete mode 100644 tests/test_db_migrations_and_versions.py delete mode 100644 tests/test_db_unit.py delete mode 100644 tests/test_e2e_actions.py delete mode 100644 tests/test_editor.py delete mode 100644 tests/test_editor_features_more.py delete mode 100644 tests/test_editor_images_text_states.py delete mode 100644 tests/test_editor_more.py delete mode 100644 tests/test_entrypoints.py delete mode 100644 tests/test_export_backup.py create mode 100644 tests/test_history_dialog.py delete mode 100644 tests/test_history_dialog_revert_edges.py delete mode 100644 tests/test_history_dialog_unit.py create mode 100644 tests/test_key_prompt.py create mode 100644 tests/test_lock_overlay.py create mode 100644 tests/test_main.py delete mode 100644 tests/test_main_module.py create mode 100644 tests/test_main_window.py delete mode 100644 tests/test_main_window_actions.py create mode 100644 tests/test_markdown_editor.py delete mode 100644 tests/test_misc.py create mode 100644 tests/test_save_dialog.py create mode 100644 tests/test_search.py delete mode 100644 tests/test_search_edgecase.py delete mode 100644 tests/test_search_edges.py create mode 100644 tests/test_search_helpers.py delete mode 100644 tests/test_search_history.py delete mode 100644 tests/test_search_unit.py delete mode 100644 tests/test_search_windows.py create mode 100644 tests/test_settings.py delete mode 100644 tests/test_settings_dialog_cancel_paths.py delete mode 100644 tests/test_settings_module.py create mode 100644 tests/test_theme.py delete mode 100644 tests/test_theme_integration.py delete mode 100644 tests/test_theme_manager.py create mode 100644 tests/test_toolbar.py delete mode 100644 tests/test_toolbar_private.py delete mode 100644 tests/test_toolbar_styles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 203a3f2..d2024eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.2.0 + + * Switch back to Markdown editor + # 0.1.12.1 * Fix newline after URL keeps URL style formatting diff --git a/README.md b/README.md index a70013d..2cc03ef 100644 --- a/README.md +++ b/README.md @@ -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 * Every 'page' is linked to the calendar day * All changes are version controlled, with ability to view/diff versions and revert - * Text is HTML with basic styling + * Text is Markdown with basic styling * Images are supported * Search * Automatic periodic saving (or explicitly save) diff --git a/bouquin/db.py b/bouquin/db.py index b6c937b..4e1fbf8 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -6,7 +6,6 @@ import json import os from dataclasses import dataclass -from markdownify import markdownify as md from pathlib import Path from sqlcipher3 import dbapi2 as sqlite from typing import List, Sequence, Tuple @@ -401,25 +400,13 @@ class DBManager: Export to HTML, similar to export_html, but then convert to Markdown using markdownify, and finally save to file. """ - parts = [ - "", - '', - "", - f"

{html.escape(title)}

", - ] + parts = [] for d, c in entries: - parts.append( - f"
{c}
" - ) - parts.append("") - - # Convert html to markdown - md_items = [] - for item in parts: - md_items.append(md(item, heading_style="ATX")) + parts.append(f"# {d}") + parts.append(c) 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: """ diff --git a/bouquin/editor.py b/bouquin/editor.py deleted file mode 100644 index ee45921..0000000 --- a/bouquin/editor.py +++ /dev/null @@ -1,1015 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import base64, re - -from PySide6.QtGui import ( - QColor, - QDesktopServices, - QFont, - QFontDatabase, - QImage, - QImageReader, - QPalette, - QPixmap, - QTextCharFormat, - QTextCursor, - QTextFrameFormat, - QTextListFormat, - QTextBlockFormat, - QTextImageFormat, - QTextDocument, -) -from PySide6.QtCore import ( - Qt, - QUrl, - Signal, - Slot, - QRegularExpression, - QBuffer, - QByteArray, - QIODevice, - QTimer, -) -from PySide6.QtWidgets import QTextEdit, QApplication - -from .theme import Theme, ThemeManager - - -class Editor(QTextEdit): - linkActivated = Signal(str) - - _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)') - _CODE_BG = QColor(245, 245, 245) - _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames - _HEADING_SIZES = (24.0, 18.0, 14.0) - _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - _DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I) - # --- Checkbox hack --- # - _CHECK_UNCHECKED = "\u2610" # ☐ - _CHECK_CHECKED = "\u2611" # ☑ - _CHECK_RX = re.compile(r"^\s*([\u2610\u2611])\s") # ☐/☑ plus a space - _CHECKBOX_SCALE = 1.35 - - def __init__(self, theme_manager: ThemeManager, *args, **kwargs): - super().__init__(*args, **kwargs) - tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") - self.setTabStopDistance(tab_w) - - self.setTextInteractionFlags( - Qt.TextInteractionFlag.TextEditorInteraction - | Qt.TextInteractionFlag.LinksAccessibleByMouse - | Qt.TextInteractionFlag.LinksAccessibleByKeyboard - ) - - self.setAcceptRichText(True) - - # If older docs have a baked-in color, normalize once: - self._retint_anchors_to_palette() - - self._themes = theme_manager - self._apply_code_theme() # set initial code colors - # Refresh on theme change - self._themes.themeChanged.connect(self._on_theme_changed) - self._themes.themeChanged.connect( - lambda _t: QTimer.singleShot(0, self._apply_code_theme) - ) - - self._linkifying = False - self.textChanged.connect(self._linkify_document) - self.viewport().setMouseTracking(True) - - # ---------------- Helpers ---------------- # - - def _iter_frames(self, root=None): - """Depth-first traversal of all frames (including root if passed).""" - doc = self.document() - stack = [root or doc.rootFrame()] - while stack: - f = stack.pop() - yield f - it = f.begin() - while not it.atEnd(): - cf = it.currentFrame() - if cf is not None: - stack.append(cf) - it += 1 - - def _is_code_frame(self, frame, tolerant: bool = False) -> bool: - """ - True if 'frame' is a code frame. - - tolerant=False: require our property marker - - tolerant=True: also accept legacy background or non-wrapping heuristic - """ - ff = frame.frameFormat() - if ff.property(self._CODE_FRAME_PROP): - return True - if not tolerant: - return False - - # Background colour check - bg = ff.background() - if bg.style() != Qt.NoBrush: - c = bg.color() - if c.isValid(): - if ( - abs(c.red() - 245) <= 2 - and abs(c.green() - 245) <= 2 - and abs(c.blue() - 245) <= 2 - ): - return True - if ( - abs(c.red() - 43) <= 2 - and abs(c.green() - 43) <= 2 - and abs(c.blue() - 43) <= 2 - ): - return True - - # Heuristic: mostly non-wrapping blocks - doc = self.document() - bc = QTextCursor(doc) - bc.setPosition(frame.firstPosition()) - blocks = codeish = 0 - while bc.position() < frame.lastPosition(): - b = bc.block() - if not b.isValid(): - break - blocks += 1 - if b.blockFormat().nonBreakableLines(): - codeish += 1 - bc.setPosition(b.position() + b.length()) - return blocks > 0 and (codeish / blocks) >= 0.6 - - def _nearest_code_frame(self, cursor, tolerant: bool = False): - """Walk up parents from the cursor and return the first code frame.""" - f = cursor.currentFrame() - while f: - if self._is_code_frame(f, tolerant=tolerant): - return f - f = f.parentFrame() - return None - - def _code_block_formats(self, fg: QColor | None = None): - """(QTextBlockFormat, QTextCharFormat) for code blocks.""" - mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) - - bf = QTextBlockFormat() - bf.setTopMargin(0) - bf.setBottomMargin(0) - bf.setLeftMargin(12) - bf.setRightMargin(12) - bf.setNonBreakableLines(True) - - cf = QTextCharFormat() - cf.setFont(mono) - cf.setFontFixedPitch(True) - if fg is not None: - cf.setForeground(fg) - return bf, cf - - def _new_code_frame_format(self, bg: QColor) -> QTextFrameFormat: - """Standard frame format for code blocks.""" - ff = QTextFrameFormat() - ff.setBackground(bg) - ff.setPadding(6) - ff.setBorder(0) - ff.setLeftMargin(0) - ff.setRightMargin(0) - ff.setTopMargin(0) - ff.setBottomMargin(0) - ff.setProperty(self._CODE_FRAME_PROP, True) - return ff - - def _retint_code_frame(self, frame, bg: QColor, fg: QColor | None): - """Apply background to frame and standard code formats to all blocks inside.""" - ff = frame.frameFormat() - ff.setBackground(bg) - frame.setFrameFormat(ff) - - bf, cf = self._code_block_formats(fg) - doc = self.document() - bc = QTextCursor(doc) - bc.setPosition(frame.firstPosition()) - while bc.position() < frame.lastPosition(): - bc.select(QTextCursor.BlockUnderCursor) - bc.mergeBlockFormat(bf) - bc.mergeBlockCharFormat(cf) - if not bc.movePosition(QTextCursor.NextBlock): - break - - def _safe_block_insertion_cursor(self): - """ - Return a cursor positioned for inserting an inline object (like an image): - - not inside a code frame (moves to after frame if necessary) - - at a fresh paragraph (inserts a block if mid-line) - Also updates the editor's current cursor to that position. - """ - c = QTextCursor(self.textCursor()) - frame = self._nearest_code_frame(c, tolerant=False) # strict: our frames only - if frame: - out = QTextCursor(self.document()) - out.setPosition(frame.lastPosition()) - self.setTextCursor(out) - c = self.textCursor() - if c.positionInBlock() != 0: - c.insertBlock() - return c - - def _scale_to_viewport(self, img: QImage, ratio: float = 0.92) -> QImage: - """If the image is wider than viewport*ratio, scale it down proportionally.""" - if self.viewport(): - max_w = int(self.viewport().width() * ratio) - if img.width() > max_w: - return img.scaledToWidth(max_w, Qt.SmoothTransformation) - return img - - def _approx(self, a: float, b: float, eps: float = 0.5) -> bool: - return abs(float(a) - float(b)) <= eps - - def _is_heading_typing(self) -> bool: - """Is the current *insertion* format using a heading size?""" - bf = self.textCursor().blockFormat() - if bf.headingLevel() > 0: - return True - - def _apply_normal_typing(self): - """Switch the *insertion* format to Normal (default size, normal weight).""" - nf = QTextCharFormat() - nf.setFontPointSize(self.font().pointSizeF()) - nf.setFontWeight(QFont.Weight.Normal) - self.mergeCurrentCharFormat(nf) - - def _code_theme_colors(self): - """Return (bg, fg) for code blocks based on the effective palette.""" - pal = QApplication.instance().palette() - # simple luminance check on the window color - win = pal.color(QPalette.Window) - is_dark = win.value() < 128 - if is_dark: - bg = QColor(43, 43, 43) # dark code background - fg = pal.windowText().color() # readable on dark - else: - bg = QColor(245, 245, 245) # light code background - fg = pal.text().color() # readable on light - return bg, fg - - def _apply_code_theme(self): - """Retint all code frames (even those reloaded from HTML) to match the current theme.""" - bg, fg = self._code_theme_colors() - self._CODE_BG = bg # used by future apply_code() calls - - doc = self.document() - cur = QTextCursor(doc) - cur.beginEditBlock() - try: - for f in self._iter_frames(doc.rootFrame()): - if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True): - self._retint_code_frame(f, bg, fg) - finally: - cur.endEditBlock() - self.viewport().update() - - def _trim_url_end(self, url: str) -> str: - # strip common trailing punctuation not part of the URL - trimmed = url.rstrip(".,;:!?\"'") - # drop an unmatched closing ) or ] at the very end - if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"): - trimmed = trimmed[:-1] - if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"): - trimmed = trimmed[:-1] - return trimmed - - def _linkify_document(self): - if self._linkifying: - return - self._linkifying = True - - try: - block = self.textCursor().block() - start_pos = block.position() - text = block.text() - - cur = QTextCursor(self.document()) - cur.beginEditBlock() - - it = self._URL_RX.globalMatch(text) - while it.hasNext(): - m = it.next() - s = start_pos + m.capturedStart() - raw = m.captured(0) - url = self._trim_url_end(raw) - if not url: - continue - - e = s + len(url) - cur.setPosition(s) - cur.setPosition(e, QTextCursor.KeepAnchor) - - if url.startswith("www."): - href = "https://" + url - else: - href = url - - fmt = QTextCharFormat() - fmt.setAnchor(True) - fmt.setAnchorHref(href) # always refresh to the latest full URL - fmt.setFontUnderline(True) - fmt.setForeground(self.palette().brush(QPalette.Link)) - - cur.mergeCharFormat(fmt) # merge so we don't clobber other styling - - cur.endEditBlock() - finally: - self._linkifying = False - - def _to_qimage(self, obj) -> QImage | None: - if isinstance(obj, QImage): - return None if obj.isNull() else obj - if isinstance(obj, QPixmap): - qi = obj.toImage() - return None if qi.isNull() else qi - if isinstance(obj, (bytes, bytearray)): - qi = QImage.fromData(obj) - return None if qi.isNull() else qi - return None - - def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str: - ba = QByteArray() - buf = QBuffer(ba) - buf.open(QIODevice.WriteOnly) - img.save(buf, fmt.upper()) - b64 = base64.b64encode(bytes(ba)).decode("ascii") - mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}" - return f"data:{mime};base64,{b64}" - - def _image_name_to_qimage(self, name: str) -> QImage | None: - res = self.document().resource(QTextDocument.ImageResource, QUrl(name)) - return res if isinstance(res, QImage) and not res.isNull() else None - - def to_html_with_embedded_images(self) -> str: - """ - Return the document HTML with all image src's replaced by data: URLs, - so it is self-contained for storage in the DB. - """ - # 1) Walk the document collecting name -> data: URL - name_to_data = {} - cur = QTextCursor(self.document()) - cur.movePosition(QTextCursor.Start) - while True: - cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - fmt = cur.charFormat() - if fmt.isImageFormat(): - imgfmt = QTextImageFormat(fmt) - name = imgfmt.name() - if name and name not in name_to_data: - img = self._image_name_to_qimage(name) - if img: - name_to_data[name] = self._qimage_to_data_url(img, "PNG") - if cur.atEnd(): - break - cur.clearSelection() - - # 2) Serialize and replace names with data URLs - html = self.document().toHtml() - for old, data_url in name_to_data.items(): - html = html.replace(f'src="{old}"', f'src="{data_url}"') - html = html.replace(f"src='{old}'", f"src='{data_url}'") - return html - - # ---------------- Image insertion & sizing (DRY’d) ---------------- # - - def _insert_qimage_at_cursor(self, img: QImage, autoscale=True): - c = self._safe_block_insertion_cursor() - if autoscale: - img = self._scale_to_viewport(img) - c.insertImage(img) - c.insertBlock() # one blank line after the image - - def _image_info_at_cursor(self): - """ - Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None) - """ - # Try current position (select 1 char forward) - tc = QTextCursor(self.textCursor()) - tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - fmt = tc.charFormat() - if fmt.isImageFormat(): - imgfmt = QTextImageFormat(fmt) - img = self._resolve_image_resource(imgfmt) - return tc, imgfmt, img - - # Try previous char (if caret is just after the image) - tc = QTextCursor(self.textCursor()) - if tc.position() > 0: - tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1) - tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - fmt = tc.charFormat() - if fmt.isImageFormat(): - imgfmt = QTextImageFormat(fmt) - img = self._resolve_image_resource(imgfmt) - return tc, imgfmt, img - - return None, None, None - - def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None: - """ - Fetch the original QImage backing the inline image, if available. - """ - name = imgfmt.name() - if name: - try: - img = self.document().resource(QTextDocument.ImageResource, QUrl(name)) - if isinstance(img, QImage) and not img.isNull(): - return img - except Exception: - pass - return None # fallback handled by callers - - def _apply_image_size( - self, - tc: QTextCursor, - imgfmt: QTextImageFormat, - new_w: float, - orig_img: QImage | None, - ): - # compute height proportionally - if orig_img and orig_img.width() > 0: - ratio = new_w / orig_img.width() - new_h = max(1.0, orig_img.height() * ratio) - else: - # fallback: keep current aspect ratio if we have it - cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w - cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w - ratio = new_w / max(1.0, cur_w) - new_h = max(1.0, cur_h * ratio) - - imgfmt.setWidth(max(1.0, new_w)) - imgfmt.setHeight(max(1.0, new_h)) - tc.mergeCharFormat(imgfmt) - - def _scale_image_at_cursor(self, factor: float): - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt: - return - base_w = imgfmt.width() - if base_w <= 0 and orig: - base_w = orig.width() - if base_w <= 0: - return - self._apply_image_size(tc, imgfmt, base_w * factor, orig) - - def _fit_image_to_editor_width(self): - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt: - return - if not self.viewport(): - return - target = int(self.viewport().width() * 0.92) - self._apply_image_size(tc, imgfmt, target, orig) - - def _set_image_width_dialog(self): - from PySide6.QtWidgets import QInputDialog - - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt: - return - # propose current display width or original width - cur_w = ( - int(imgfmt.width()) - if imgfmt.width() > 0 - else (orig.width() if orig else 400) - ) - w, ok = QInputDialog.getInt( - self, "Set image width", "Width (px):", cur_w, 1, 10000, 10 - ) - if ok: - self._apply_image_size(tc, imgfmt, float(w), orig) - - def _reset_image_size(self): - tc, imgfmt, orig = self._image_info_at_cursor() - if not imgfmt or not orig: - return - self._apply_image_size(tc, imgfmt, float(orig.width()), orig) - - # ---------------- Context menu ---------------- # - - def contextMenuEvent(self, e): - menu = self.createStandardContextMenu() - tc, imgfmt, orig = self._image_info_at_cursor() - if imgfmt: - menu.addSeparator() - sub = menu.addMenu("Image size") - sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9)) - sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1)) - sub.addAction("Fit to editor width", self._fit_image_to_editor_width) - sub.addAction("Set width…", self._set_image_width_dialog) - sub.addAction("Reset to original", self._reset_image_size) - menu.exec(e.globalPos()) - - # ---------------- Clipboard / DnD ---------------- # - - def insertFromMimeData(self, source): - # 1) Direct image from clipboard - if source.hasImage(): - img = self._to_qimage(source.imageData()) - if img is not None: - self._insert_qimage_at_cursor(img, autoscale=True) - return - - # 2) File URLs (drag/drop or paste) - if source.hasUrls(): - paths = [] - non_local_urls = [] - for url in source.urls(): - if url.isLocalFile(): - path = url.toLocalFile() - if path.lower().endswith(self._IMAGE_EXTS): - paths.append(path) - else: - # Non-image file: insert as link - self.textCursor().insertHtml( - f'{Path(path).name}' - ) - self.textCursor().insertBlock() - else: - non_local_urls.append(url) - - if paths: - self.insert_images(paths) - - for url in non_local_urls: - self.textCursor().insertHtml( - f'{url.toString()}' - ) - self.textCursor().insertBlock() - - if paths or non_local_urls: - return - - # 3) HTML with data: image - if source.hasHtml(): - html = source.html() - m = self._DATA_IMG_RX.search(html or "") - if m: - try: - data = base64.b64decode(m.group(1)) - img = QImage.fromData(data) - if not img.isNull(): - self._insert_qimage_at_cursor(img, autoscale=True) - return - except Exception: - pass # fall through - - # 4) Everything else → default behavior - super().insertFromMimeData(source) - - @Slot(list) - def insert_images(self, paths: list[str], autoscale=True): - """ - Insert one or more images at the cursor. Large images can be auto-scaled - to fit the viewport width while preserving aspect ratio. - """ - c = self._safe_block_insertion_cursor() - - for path in paths: - reader = QImageReader(path) - img = reader.read() - if img.isNull(): - continue - - if autoscale: - img = self._scale_to_viewport(img) - - c.insertImage(img) - c.insertBlock() # put each image on its own line - - # ---------------- Mouse & key handling ---------------- # - - def mouseReleaseEvent(self, e): - if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): - href = self.anchorAt(e.pos()) - if href: - QDesktopServices.openUrl(QUrl.fromUserInput(href)) - self.linkActivated.emit(href) - return - super().mouseReleaseEvent(e) - - def mouseMoveEvent(self, e): - if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()): - self.viewport().setCursor(Qt.PointingHandCursor) - else: - self.viewport().setCursor(Qt.IBeamCursor) - super().mouseMoveEvent(e) - - def mousePressEvent(self, e): - if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier): - cur = self.cursorForPosition(e.pos()) - b = cur.block() - state, pref = self._checkbox_info_for_block(b) - if state is not None: - col = cur.position() - b.position() - if col <= max(1, pref): # clicked on ☐/☑ (and the following space) - self._set_block_checkbox_state(b, not state) - return - return super().mousePressEvent(e) - - def keyPressEvent(self, e): - key = e.key() - - if key in (Qt.Key_Space, Qt.Key_Tab): - c = self.textCursor() - b = c.block() - pos_in_block = c.position() - b.position() - - if ( - pos_in_block >= 4 - and b.text().startswith("TODO") - and b.text()[:pos_in_block] == "TODO" - and self._checkbox_info_for_block(b)[0] is None - ): - tcur = QTextCursor(self.document()) - tcur.setPosition(b.position()) # start of block - tcur.setPosition( - b.position() + 4, QTextCursor.KeepAnchor - ) # select "TODO" - tcur.beginEditBlock() - tcur.removeSelectedText() - tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ " - tcur.endEditBlock() - - # visuals: size bump - if hasattr(self, "_style_checkbox_glyph"): - self._style_checkbox_glyph(b) - - # caret after the inserted prefix; swallow the key (we already added a space) - c.setPosition(b.position() + 2) - self.setTextCursor(c) - return - - # not a TODO-at-start case - self._break_anchor_for_next_char() - return super().keyPressEvent(e) - - if key in (Qt.Key_Return, Qt.Key_Enter): - c = self.textCursor() - - # If we're on an empty line inside a code frame, consume Enter and jump out - if c.block().length() == 1: - frame = self._nearest_code_frame(c, tolerant=False) - if frame: - out = QTextCursor(self.document()) - out.setPosition(frame.lastPosition()) # after the frame's contents - self.setTextCursor(out) - super().insertPlainText("\n") # start a normal paragraph - return - - # --- CHECKBOX handling: continue on Enter; "escape" on second Enter --- - b = c.block() - state, pref = self._checkbox_info_for_block(b) - if state is not None and not c.hasSelection(): - text_after = b.text()[pref:].strip() - if c.atBlockEnd() and text_after == "": - # Empty checkbox item -> remove the prefix and insert a plain new line - cur = QTextCursor(self.document()) - cur.setPosition(b.position()) - cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref) - cur.removeSelectedText() - return super().keyPressEvent(e) - else: - # Normal continuation: new checkbox on the next line - super().keyPressEvent(e) # make the new block - super().insertPlainText(self._CHECK_UNCHECKED + " ") - if hasattr(self, "_style_checkbox_glyph"): - self._style_checkbox_glyph(self.textCursor().block()) - return - - # Follow-on style: if we typed a heading and press Enter at end of block, - # new paragraph should revert to Normal. - if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing(): - super().keyPressEvent(e) # insert the new paragraph - self._apply_normal_typing() # make the *new* paragraph Normal for typing - return - - # If we were at end-of-line, make the *new* line plain (don’t keep URL styling) - if not c.hasSelection() and c.atBlockEnd(): - super().keyPressEvent(e) # insert the new paragraph - self._break_anchor_for_next_char() # clear anchor/underline/color for typing - return - - # otherwise default handling - return super().keyPressEvent(e) - - def _break_anchor_for_next_char(self): - """ - Ensure the *next* typed character is not part of a hyperlink. - Only strips link-specific attributes; leaves bold/italic/underline etc intact. - """ - # What we're about to type with - ins_fmt = self.currentCharFormat() - # What the cursor is sitting on - cur_fmt = self.textCursor().charFormat() - - # Do nothing unless either side indicates we're in/propagating an anchor - if not ( - ins_fmt.isAnchor() - or cur_fmt.isAnchor() - or ins_fmt.fontUnderline() - or ins_fmt.foreground().style() != Qt.NoBrush - ): - return - - nf = QTextCharFormat(ins_fmt) - # stop the link itself - nf.setAnchor(False) - nf.setAnchorHref("") - # also stop the link *styling* - nf.setFontUnderline(False) - nf.clearForeground() - - self.setCurrentCharFormat(nf) - - def merge_on_sel(self, fmt): - """ - Sets the styling on the selected characters or the insertion position. - """ - cursor = self.textCursor() - if cursor.hasSelection(): - cursor.mergeCharFormat(fmt) - self.mergeCurrentCharFormat(fmt) - - # ====== Checkbox core ====== - def _base_point_size_for_block(self, block) -> float: - # Try the block's char format, then editor font - sz = block.charFormat().fontPointSize() - if sz <= 0: - sz = self.fontPointSize() - if sz <= 0: - sz = self.font().pointSizeF() or 12.0 - return float(sz) - - def _style_checkbox_glyph(self, block): - """Apply larger size (and optional symbol font) to the single ☐/☑ char.""" - state, _ = self._checkbox_info_for_block(block) - if state is None: - return - doc = self.document() - c = QTextCursor(doc) - c.setPosition(block.position()) - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only - - base = self._base_point_size_for_block(block) - fmt = QTextCharFormat() - fmt.setFontPointSize(base * self._CHECKBOX_SCALE) - # keep the glyph centered on the text baseline - fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle) - - c.mergeCharFormat(fmt) - - def _checkbox_info_for_block(self, block): - """Return (state, prefix_len): state in {None, False, True}, prefix_len in chars.""" - text = block.text() - m = self._CHECK_RX.match(text) - if not m: - return None, 0 - ch = m.group(1) - state = True if ch == self._CHECK_CHECKED else False - return state, m.end() - - def _set_block_checkbox_present(self, block, present: bool): - state, pref = self._checkbox_info_for_block(block) - doc = self.document() - c = QTextCursor(doc) - c.setPosition(block.position()) - c.beginEditBlock() - try: - if present and state is None: - c.insertText(self._CHECK_UNCHECKED + " ") - state = False - self._style_checkbox_glyph(block) - else: - if state is not None: - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref) - c.removeSelectedText() - state = None - finally: - c.endEditBlock() - - return state - - def _set_block_checkbox_state(self, block, checked: bool): - """Switch ☐/☑ at the start of the block.""" - state, pref = self._checkbox_info_for_block(block) - if state is None: - return - doc = self.document() - c = QTextCursor(doc) - c.setPosition(block.position()) - c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol - c.beginEditBlock() - try: - c.removeSelectedText() - c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED) - self._style_checkbox_glyph(block) - finally: - c.endEditBlock() - - # Public API used by toolbar - def toggle_checkboxes(self): - """ - Toggle checkbox prefix on/off for the current block(s). - If all targeted blocks already have a checkbox, remove them; otherwise add. - """ - c = self.textCursor() - doc = self.document() - - if c.hasSelection(): - start = doc.findBlock(c.selectionStart()) - end = doc.findBlock(c.selectionEnd() - 1) - else: - start = end = c.block() - - # Decide intent: add or remove? - b = start - all_have = True - while True: - state, _ = self._checkbox_info_for_block(b) - if state is None: - all_have = False - break - if b == end: - break - b = b.next() - - # Apply - b = start - while True: - self._set_block_checkbox_present(b, present=not all_have) - if b == end: - break - b = b.next() - - @Slot() - def apply_weight(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - weight = ( - QFont.Weight.Normal - if cur.fontWeight() == QFont.Weight.Bold - else QFont.Weight.Bold - ) - fmt.setFontWeight(weight) - self.merge_on_sel(fmt) - - @Slot() - def apply_italic(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - fmt.setFontItalic(not cur.fontItalic()) - self.merge_on_sel(fmt) - - @Slot() - def apply_underline(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - fmt.setFontUnderline(not cur.fontUnderline()) - self.merge_on_sel(fmt) - - @Slot() - def apply_strikethrough(self): - cur = self.currentCharFormat() - fmt = QTextCharFormat() - fmt.setFontStrikeOut(not cur.fontStrikeOut()) - self.merge_on_sel(fmt) - - @Slot() - def apply_code(self): - c = self.textCursor() - if not c.hasSelection(): - c.select(QTextCursor.BlockUnderCursor) - - ff = self._new_code_frame_format(self._CODE_BG) - - c.beginEditBlock() - try: - c.insertFrame(ff) # with a selection, this wraps the selection - - # Format all blocks inside the new frame (keep fg=None on creation) - frame = self._nearest_code_frame(c, tolerant=False) - if frame: - self._retint_code_frame(frame, self._CODE_BG, fg=None) - finally: - c.endEditBlock() - - @Slot(int) - def apply_heading(self, size: int): - """ - Set heading point size for typing. If there's a selection, also apply bold - to that selection (for H1..H3). "Normal" clears bold on the selection. - """ - # Map toolbar's sizes to heading levels - level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0 - - c = self.textCursor() - - # On-screen look - ins = QTextCharFormat() - if size: - ins.setFontPointSize(float(size)) - ins.setFontWeight(QFont.Weight.Bold) - else: - ins.setFontPointSize(self.font().pointSizeF()) - ins.setFontWeight(QFont.Weight.Normal) - self.mergeCurrentCharFormat(ins) - - # Apply heading level to affected block(s) - def set_level_for_block(cur): - bf = cur.blockFormat() - if hasattr(bf, "setHeadingLevel"): - bf.setHeadingLevel(level) # 0 clears heading - cur.mergeBlockFormat(bf) - - if c.hasSelection(): - start, end = c.selectionStart(), c.selectionEnd() - bc = QTextCursor(self.document()) - bc.setPosition(start) - while True: - set_level_for_block(bc) - if bc.position() >= end: - break - bc.movePosition(QTextCursor.EndOfBlock) - if bc.position() >= end: - break - bc.movePosition(QTextCursor.NextBlock) - else: - bc = QTextCursor(c) - set_level_for_block(bc) - - def toggle_bullets(self): - c = self.textCursor() - lst = c.currentList() - if lst and lst.format().style() == QTextListFormat.Style.ListDisc: - lst.remove(c.block()) - return - fmt = QTextListFormat() - fmt.setStyle(QTextListFormat.Style.ListDisc) - c.createList(fmt) - - def toggle_numbers(self): - c = self.textCursor() - lst = c.currentList() - if lst and lst.format().style() == QTextListFormat.Style.ListDecimal: - lst.remove(c.block()) - return - fmt = QTextListFormat() - fmt.setStyle(QTextListFormat.Style.ListDecimal) - c.createList(fmt) - - @Slot(Theme) - def _on_theme_changed(self, _theme: Theme): - # Defer one event-loop tick so widgets have the new palette - QTimer.singleShot(0, self._retint_anchors_to_palette) - QTimer.singleShot(0, self._apply_code_theme) - - @Slot() - def _retint_anchors_to_palette(self, *_): - # Always read from the *application* palette to avoid stale widget palette - app = QApplication.instance() - link_brush = app.palette().brush(QPalette.Link) - doc = self.document() - cur = QTextCursor(doc) - cur.beginEditBlock() - block = doc.firstBlock() - while block.isValid(): - it = block.begin() - while not it.atEnd(): - frag = it.fragment() - if frag.isValid(): - fmt = frag.charFormat() - if fmt.isAnchor(): - new_fmt = QTextCharFormat(fmt) - new_fmt.setForeground(link_brush) # force palette link color - start = frag.position() - cur.setPosition(start) - cur.movePosition( - QTextCursor.NextCharacter, - QTextCursor.KeepAnchor, - frag.length(), - ) # select exactly this fragment - cur.setCharFormat(new_fmt) - it += 1 - block = block.next() - cur.endEditBlock() - self.viewport().update() - - def setHtml(self, html: str) -> None: - super().setHtml(html) - - doc = self.document() - block = doc.firstBlock() - while block.isValid(): - self._style_checkbox_glyph(block) # Apply checkbox styling to each block - block = block.next() - - # Ensure anchors adopt the palette color on startup - self._retint_anchors_to_palette() - self._apply_code_theme() diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 0113ba1..1a4c029 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -16,31 +16,33 @@ from PySide6.QtWidgets import ( ) -def _html_to_text(s: str) -> str: - """Lightweight HTML→text for diff (keeps paragraphs/line breaks).""" - IMG_RE = re.compile(r"(?is)]*>") - STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?") - COMMENT_RE = re.compile(r"", re.S) - BR_RE = re.compile(r"(?i)") - BLOCK_END_RE = re.compile(r"(?i)") - TAG_RE = re.compile(r"<[^>]+>") - MULTINL_RE = re.compile(r"\n{3,}") - - s = IMG_RE.sub("[ Image changed - see Preview pane ]", s) - s = STYLE_SCRIPT_RE.sub("", s) - s = COMMENT_RE.sub("", s) - s = BR_RE.sub("\n", s) - s = BLOCK_END_RE.sub("\n", s) - s = TAG_RE.sub("", s) - s = _html.unescape(s) - s = MULTINL_RE.sub("\n\n", s) +def _markdown_to_text(s: str) -> str: + """Convert markdown to plain text for diff comparison.""" + # Remove images + s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s) + # Remove inline code formatting + s = re.sub(r"`([^`]+)`", r"\1", s) + # Remove bold/italic markers + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + s = re.sub(r"__([^_]+)__", r"\1", s) + s = re.sub(r"\*([^*]+)\*", r"\1", s) + s = re.sub(r"_([^_]+)_", r"\1", s) + # Remove strikethrough + s = re.sub(r"~~([^~]+)~~", r"\1", s) + # Remove heading markers + s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE) + # Remove list markers + s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE) + 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() -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).""" - a = _html_to_text(old_html).splitlines() - b = _html_to_text(new_html).splitlines() + a = _markdown_to_text(old_md).splitlines() + b = _markdown_to_text(new_md).splitlines() ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="") lines = [] for line in ud: @@ -150,9 +152,13 @@ class HistoryDialog(QDialog): self.btn_revert.setEnabled(False) return 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) - 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) cur = self._db.get_version(version_id=self._current_id) self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"])) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 27934d4..234be2b 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -44,7 +44,7 @@ from PySide6.QtWidgets import ( ) from .db import DBManager -from .editor import Editor +from .markdown_editor import MarkdownEditor from .find_bar import FindBar from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt @@ -99,7 +99,7 @@ class MainWindow(QMainWindow): left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) # This is the note-taking editor - self.editor = Editor(self.themes) + self.editor = MarkdownEditor(self.themes) # Toolbar for controlling styling self.toolBar = ToolBar() @@ -107,14 +107,14 @@ class MainWindow(QMainWindow): # Wire toolbar intents to editor methods self.toolBar.boldRequested.connect(self.editor.apply_weight) 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.codeRequested.connect(self.editor.apply_code) self.toolBar.headingRequested.connect(self.editor.apply_heading) self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets) self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes) - self.toolBar.alignRequested.connect(self.editor.setAlignment) + # Note: Markdown doesn't natively support alignment, removing alignRequested self.toolBar.historyRequested.connect(self._open_history) self.toolBar.insertImageRequested.connect(self._on_insert_image) @@ -450,17 +450,14 @@ class MainWindow(QMainWindow): def _sync_toolbar(self): fmt = self.editor.currentCharFormat() c = self.editor.textCursor() - bf = c.blockFormat() # Block signals so setChecked() doesn't re-trigger actions QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actItalic) - QSignalBlocker(self.toolBar.actUnderline) QSignalBlocker(self.toolBar.actStrike) self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actItalic.setChecked(fmt.fontItalic()) - self.toolBar.actUnderline.setChecked(fmt.fontUnderline()) self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) # 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.actNumbers.setChecked(bool(numbers_on)) - # Alignment - align = bf.alignment() & Qt.AlignHorizontal_Mask - QSignalBlocker(self.toolBar.actAlignL) - self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft) - QSignalBlocker(self.toolBar.actAlignC) - self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter) - QSignalBlocker(self.toolBar.actAlignR) - self.toolBar.actAlignR.setChecked(align == Qt.AlignRight) - def _current_date_iso(self) -> str: d = self.calendar.selectedDate() return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" @@ -511,14 +499,12 @@ class MainWindow(QMainWindow): try: text = self.db.get_entry(date_iso) if extra_data: - # Wrap extra_data in a

tag for HTML rendering - extra_data_html = f"

{extra_data}

" - - # Inject the extra_data before the closing - modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) - text = modified + # Append extra data as markdown + if text and not text.endswith("\n"): + text += "\n" + text += extra_data # 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._save_date(date_iso, True) @@ -526,7 +512,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Read Error", str(e)) return - self._set_editor_html_preserve_view(text) + self._set_editor_markdown_preserve_view(text) self._dirty = False # track which date the editor currently represents @@ -556,39 +542,33 @@ class MainWindow(QMainWindow): text = self.db.get_entry(yesterday_str) unchecked_items = [] - # Regex to match the unchecked checkboxes and their associated text - checkbox_pattern = re.compile( - r"]*>(☐)\s*(.*?)

", re.DOTALL - ) + # Split into lines and find unchecked checkbox items + lines = text.split("\n") + remaining_lines = [] - # Find unchecked items and store them - for match in checkbox_pattern.finditer(text): - checkbox = match.group(1) # Either ☐ or ☑ - item_text = match.group(2).strip() # The text after the checkbox - if checkbox == "☐": # If it's an unchecked checkbox (☐) - unchecked_items.append("☐ " + item_text) # Store the unchecked item + for line in lines: + # Check for unchecked markdown checkboxes: - [ ] or - [☐] + if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( + r"^\s*-\s*\[☐\]\s+", line + ): + # 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: - # This regex will find the entire checkbox line and remove it from the HTML content - uncheckbox_pattern = re.compile( - r"]*>☐\s*(.*?)

", re.DOTALL - ) - modified_text = re.sub( - uncheckbox_pattern, "", text - ) # Remove the checkbox lines - - # Save the modified HTML back to the database + modified_text = "\n".join(remaining_lines) self.db.save_new_version( yesterday_str, modified_text, "Unchecked checkbox items moved to next day", ) - # Join unchecked items into a formatted string - unchecked_str = "\n".join( - [f"

{item}

" for item in unchecked_items] - ) + # Join unchecked items into markdown format + unchecked_str = "\n".join(unchecked_items) + "\n" # Load the unchecked items into the current editor self._load_selected_date(False, unchecked_str) @@ -621,7 +601,7 @@ class MainWindow(QMainWindow): """ if not self._dirty and not explicit: return - text = self.editor.to_html_with_embedded_images() + text = self.editor.to_markdown() try: self.db.save_new_version(date_iso, text, note) except Exception as e: @@ -674,7 +654,9 @@ class MainWindow(QMainWindow): ) if not paths: 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 ------------# 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(): 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 # 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 ed.blockSignals(True) - if ed.toHtml() != html: - ed.setHtml(html) + if ed.to_markdown() != markdown: + ed.from_markdown(markdown) ed.blockSignals(False) # Restore scroll first ed.verticalScrollBar().setValue(v) 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.setPosition(old_anchor) mode = ( diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py new file mode 100644 index 0000000..ce73b6f --- /dev/null +++ b/bouquin/markdown_editor.py @@ -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"(? 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"![image]({img_name})" + 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 diff --git a/bouquin/search.py b/bouquin/search.py index bbe5a53..a1bb15c 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -4,7 +4,6 @@ import re from typing import Iterable, Tuple from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument from PySide6.QtWidgets import ( QFrame, QLabel, @@ -149,10 +148,12 @@ class Search(QWidget): self.results.setItemWidget(item, container) # --- Snippet/highlight helpers ----------------------------------------- - def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180): - doc = QTextDocument() - doc.setHtml(html_src) - plain = doc.toPlainText() + def _make_html_snippet( + self, markdown_src: str, query: str, *, radius=60, maxlen=180 + ): + # For markdown, we can work directly with the text + # Strip markdown formatting for display + plain = self._strip_markdown(markdown_src) if not plain: return "", False, False @@ -179,30 +180,45 @@ class Search(QWidget): start = max(0, min(idx - radius, max(0, L - 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: - lower = plain.lower() - fmt = QTextCharFormat() - fmt.setFontWeight(QFont.Weight.Bold) for t in tokens: - t_low = t.lower() - pos = start - while True: - k = lower.find(t_low, pos) - 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) + # Case-insensitive replacement + pattern = re.compile(re.escape(t), re.IGNORECASE) + snippet_html = pattern.sub( + lambda m: f"{m.group(0)}", snippet_html + ) - # Select the window and export as HTML fragment - c = QTextCursor(doc) - c.setPosition(start) - c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) - fragment_html = ( - c.selection().toHtml() - ) # preserves original styles + our bolding + return snippet_html, start > 0, end < L - 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() diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 7b0f248..acf0413 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -8,14 +8,12 @@ from PySide6.QtWidgets import QToolBar class ToolBar(QToolBar): boldRequested = Signal() italicRequested = Signal() - underlineRequested = Signal() strikeRequested = Signal() codeRequested = Signal() headingRequested = Signal(int) bulletsRequested = Signal() numbersRequested = Signal() checkboxesRequested = Signal() - alignRequested = Signal(Qt.AlignmentFlag) historyRequested = Signal() insertImageRequested = Signal() @@ -39,12 +37,6 @@ class ToolBar(QToolBar): self.actItalic.setShortcut(QKeySequence.Italic) 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.setToolTip("Strikethrough") self.actStrike.setCheckable(True) @@ -97,24 +89,6 @@ class ToolBar(QToolBar): self.actInsertImg.setShortcut("Ctrl+Shift+I") 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 self.actHistory = QAction("History", self) self.actHistory.triggered.connect(self.historyRequested) @@ -125,7 +99,6 @@ class ToolBar(QToolBar): for a in ( self.actBold, self.actItalic, - self.actUnderline, self.actStrike, self.actH1, self.actH2, @@ -135,11 +108,6 @@ class ToolBar(QToolBar): a.setCheckable(True) a.setActionGroup(self.grpHeadings) - self.grpAlign = QActionGroup(self) - self.grpAlign.setExclusive(True) - for a in (self.actAlignL, self.actAlignC, self.actAlignR): - a.setActionGroup(self.grpAlign) - self.grpLists = QActionGroup(self) self.grpLists.setExclusive(True) for a in (self.actBullets, self.actNumbers, self.actCheckboxes): @@ -150,7 +118,6 @@ class ToolBar(QToolBar): [ self.actBold, self.actItalic, - self.actUnderline, self.actStrike, self.actCode, self.actH1, @@ -161,9 +128,6 @@ class ToolBar(QToolBar): self.actNumbers, self.actCheckboxes, self.actInsertImg, - self.actAlignL, - self.actAlignC, - self.actAlignR, self.actHistory, ] ) @@ -171,7 +135,6 @@ class ToolBar(QToolBar): def _apply_toolbar_styles(self): self._style_letter_button(self.actBold, "B", bold=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) # Monospace look for code; use a fixed font code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) @@ -187,11 +150,6 @@ class ToolBar(QToolBar): self._style_letter_button(self.actBullets, "•") 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 self._style_letter_button(self.actHistory, "View History") diff --git a/poetry.lock b/poetry.lock index 87acb50..e1c4ed5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,27 +1,5 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -[[package]] -name = "beautifulsoup4" -version = "4.14.2" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"}, - {file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"}, -] - -[package.dependencies] -soupsieve = ">1.2" -typing-extensions = ">=4.0.0" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - [[package]] name = "colorama" version = "0.4.6" @@ -180,21 +158,6 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "markdownify" -version = "1.2.0" -description = "Convert HTML to markdown." -optional = false -python-versions = "*" -files = [ - {file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"}, - {file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"}, -] - -[package.dependencies] -beautifulsoup4 = ">=4.9,<5" -six = ">=1.15,<2" - [[package]] name = "packaging" version = "25.0" @@ -382,28 +345,6 @@ files = [ {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, ] -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "soupsieve" -version = "2.8" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.9" -files = [ - {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"}, - {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"}, -] - [[package]] name = "sqlcipher3-wheels" version = "0.5.5.post0" @@ -600,4 +541,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.14" -content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d" +content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4" diff --git a/pyproject.toml b/pyproject.toml index 11c5a6a..d3e82ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] 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." authors = ["Miguel Jacq "] readme = "README.md" @@ -11,7 +11,6 @@ repository = "https://git.mig5.net/mig5/bouquin" python = ">=3.9,<3.14" pyside6 = ">=6.8.1,<7.0.0" sqlcipher3-wheels = "^0.5.5.post0" -markdownify = "^1.2.0" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py index d9ecc99..c29e6bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,133 +1,51 @@ import os import sys 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. -os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0") -# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env +import pytest +from PySide6.QtWidgets import QApplication + +# 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") -# Make project importable -from PySide6.QtWidgets import QApplication, QWidget -from bouquin.theme import ThemeManager, ThemeConfig, Theme - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +@pytest.fixture(scope="session") +def app(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app @pytest.fixture(scope="session", autouse=True) -def enable_qstandardpaths_test_mode(): - QStandardPaths.setTestModeEnabled(True) - - -@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() +def isolate_qsettings(tmp_path_factory): + cfgdir = tmp_path_factory.mktemp("qt_cfg") + os.environ["XDG_CONFIG_HOME"] = str(cfgdir) 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 -def theme_parent_widget(qtbot): - """A minimal parent that provides .themes.apply(...) like MainWindow.""" - - class _ThemesStub: - def __init__(self): - self.applied = [] - - def apply(self, theme): - self.applied.append(theme) - - class _Parent(QWidget): - def __init__(self): - super().__init__() - self.themes = _ThemesStub() - - parent = _Parent() - qtbot.addWidget(parent) - return parent - - -@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) +def tmp_db_cfg(tmp_path): from bouquin.db import DBConfig + db_path = tmp_path / "notebook.db" + key = "test-secret-key" return DBConfig( - path=Path(temp_db_path), - key="testkey", - idle_minutes=0, - theme="system", - move_todos=True, + path=db_path, key=key, idle_minutes=0, theme="light", 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() diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py deleted file mode 100644 index f228177..0000000 --- a/tests/qt_helpers.py +++ /dev/null @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..7a36dd6 --- /dev/null +++ b/tests/test_db.py @@ -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 "Hello

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

x

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

v1

", note="init") - ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "

v2

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

other

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

A

") - mgr.save_new_version("2025-01-07", "

B

") - - # 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 diff --git a/tests/test_db_unit.py b/tests/test_db_unit.py deleted file mode 100644 index 8c80160..0000000 --- a/tests/test_db_unit.py +++ /dev/null @@ -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", "Hi")] - # Test each exporter writes the file - p = tmp_path / "out.json" - mgr.export_json(entries, str(p)) - assert p.exists() and p.stat().st_size > 0 - p = tmp_path / "out.csv" - mgr.export_csv(entries, str(p)) - assert p.exists() - p = tmp_path / "out.txt" - mgr.export_txt(entries, str(p)) - assert p.exists() - p = tmp_path / "out.html" - mgr.export_html(entries, str(p)) - assert p.exists() - p = tmp_path / "out.md" - mgr.export_markdown(entries, str(p)) - assert p.exists() - # Router - import types - - mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) - for ext in [".json", ".csv", ".txt", ".html", ".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 diff --git a/tests/test_e2e_actions.py b/tests/test_e2e_actions.py deleted file mode 100644 index 55f7ae5..0000000 --- a/tests/test_e2e_actions.py +++ /dev/null @@ -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) diff --git a/tests/test_editor.py b/tests/test_editor.py deleted file mode 100644 index e0951b8..0000000 --- a/tests/test_editor.py +++ /dev/null @@ -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 , not the trailing text - html = e.document().toHtml() - assert re.search( - r']*href="https?://mig5\.net"[^>]*>(?:]*>)?https?://mig5\.net(?:)?\s+this is a test', - html, - re.S, - ), html - assert "this is a test" not in html - - -def test_embed_qimage_saved_as_data_url(qtbot): - e = _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 "' - - 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 diff --git a/tests/test_editor_images_text_states.py b/tests/test_editor_images_text_states.py deleted file mode 100644 index 8cb81d9..0000000 --- a/tests/test_editor_images_text_states.py +++ /dev/null @@ -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 tag - html = ed.toHtml() - assert " 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'

link

' - ) - - 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 diff --git a/tests/test_editor_more.py b/tests/test_editor_more.py deleted file mode 100644 index fd015a9..0000000 --- a/tests/test_editor_more.py +++ /dev/null @@ -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 " 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() diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py deleted file mode 100644 index b6486be..0000000 --- a/tests/test_entrypoints.py +++ /dev/null @@ -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 diff --git a/tests/test_export_backup.py b/tests/test_export_backup.py deleted file mode 100644 index ec000e8..0000000 --- a/tests/test_export_backup.py +++ /dev/null @@ -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 bold") - win._save_current(explicit=True) - d = win.calendar.selectedDate().addDays(1) - win.calendar.setSelectedDate(d) - win.editor.setPlainText("beta text") - win._save_current(explicit=True) - - -@pytest.mark.parametrize( - "ext,verifier", - [ - (".txt", lambda p: p.read_text(encoding="utf-8").strip()), - (".json", lambda p: json.loads(p.read_text(encoding="utf-8"))), - (".csv", lambda p: list(csv.reader(p.open("r", encoding="utf-8-sig")))), - (".html", lambda p: p.read_text(encoding="utf-8")), - (".sql", lambda p: p), - ], -) -def test_export_all_formats(open_window, qtbot, tmp_path, ext, verifier, monkeypatch): - win = open_window - _write_sample_entries(win, qtbot) - - out = tmp_path / f"export_test{ext}" - - # 1) Short-circuit the file dialog so it returns our path + the filter we want. - from PySide6.QtWidgets import QFileDialog - - def fake_getSaveFileName(*args, **kwargs): - return (str(out), EXPORT_FILTERS[ext]) - - monkeypatch.setattr( - QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName) - ) - - # 2) Kick off the export - trigger_menu_action(win, "Export") - - # 3) Click through the "unencrypted export" warning - accept_all_message_boxes() - - # 4) Wait for the file to appear (export happens synchronously after the stub) - qtbot.waitUntil(out.exists, timeout=5000) - - # 5) Dismiss the "Export complete" info box so it can't block later tests - accept_all_message_boxes() - - # 6) Assert as before - val = verifier(out) - if ext == ".json": - assert isinstance(val, list) and all( - "date" in d and "content" in d for d in val - ) - elif ext == ".csv": - flat = [cell for row in val for cell in row] - assert any("alpha" in c for c in flat) and any("beta" in c for c in flat) - elif ext == ".html": - lower = val.lower() - assert " pos1 - # Open the find bar from the menu - trigger_menu_action(win, "Find on page").trigger() - qtbot.waitUntil(lambda: win.findBar.isVisible()) - win.findBar.edit.clear() - QTest.keyClicks(win.findBar.edit, "alpha") + fb.find_prev() + pos3 = editor.textCursor().position() + assert pos3 <= pos2 - # 1) First hit (case-insensitive default) - QTest.keyClick(win.findBar.edit, Qt.Key_Return) - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s0, e0, sel0 = _cursor_info(win.editor) - assert sel0.lower() == "alpha" + fb.case.setChecked(True) + fb.refresh() + fb.hide_bar() - # 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" - trigger_menu_action(win, "Find Next").trigger() - 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 +def test_show_bar_seeds_selection(qtbot, editor): + from PySide6.QtGui import QTextCursor - # 4) Case-sensitive: skip ALPHA and only hit lowercase - win.findBar.case.setChecked(True) - # Put the caret at start to make the next search deterministic - tc = win.editor.textCursor() - tc.setPosition(0) - win.editor.setTextCursor(tc) + editor.from_markdown("alpha beta") + c = editor.textCursor() + c.movePosition(QTextCursor.Start) + c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor) + editor.setTextCursor(c) - win.findBar.find_next() - qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) - s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor) - assert sel_cs1 == "alpha" - - 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()) + fb = FindBar(editor, parent=editor) + qtbot.addWidget(fb) + fb.show_bar() + assert fb.edit.text().lower() == "alpha" + fb.hide_bar() diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py new file mode 100644 index 0000000..ea24c5a --- /dev/null +++ b/tests/test_history_dialog.py @@ -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" diff --git a/tests/test_history_dialog_revert_edges.py b/tests/test_history_dialog_revert_edges.py deleted file mode 100644 index f54e4d8..0000000 --- a/tests/test_history_dialog_revert_edges.py +++ /dev/null @@ -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", "

v1

", note="v1", set_current=True) - db.save_new_version("2025-02-10", "

v2

", 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) diff --git a/tests/test_history_dialog_unit.py b/tests/test_history_dialog_unit.py deleted file mode 100644 index 6689f5c..0000000 --- a/tests/test_history_dialog_unit.py +++ /dev/null @@ -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": "

a

", - }, - { - "id": 2, - "version_no": 2, - "created_at": "2025-01-02T10:00:00Z", - "note": None, - "is_current": True, - "content": "

b

", - }, - ] - - def get_version(self, version_id): - if version_id == 2: - return {"content": "

b

"} - return {"content": "

a

"} - - def revert_to_version(self, date, version_id=None, version_no=None): - if self.fail_revert: - raise RuntimeError("boom") - - -def test_on_select_no_item(qtbot): - dlg = HistoryDialog(FakeDB(), "2025-01-01") - qtbot.addWidget(dlg) - dlg.list.clear() - dlg._on_select() - - -def test_revert_failure_shows_critical(qtbot, monkeypatch): - from PySide6.QtWidgets import QMessageBox - - fake = FakeDB() - fake.fail_revert = True - dlg = HistoryDialog(fake, "2025-01-01") - qtbot.addWidget(dlg) - item = QListWidgetItem("v1") - item.setData(Qt.UserRole, 1) # different from current 2 - dlg.list.addItem(item) - dlg.list.setCurrentItem(item) - msgs = {} - - def fake_crit(parent, title, text): - msgs["t"] = (title, text) - - monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit)) - dlg._revert() - assert "Revert failed" in msgs["t"][0] diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py new file mode 100644 index 0000000..07a0044 --- /dev/null +++ b/tests/test_key_prompt.py @@ -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" diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py new file mode 100644 index 0000000..7d3ebe8 --- /dev/null +++ b/tests/test_lock_overlay.py @@ -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) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..cadfaa2 --- /dev/null +++ b/tests/test_main.py @@ -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") diff --git a/tests/test_main_module.py b/tests/test_main_module.py deleted file mode 100644 index 6d596b3..0000000 --- a/tests/test_main_module.py +++ /dev/null @@ -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 diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 0000000..3942566 --- /dev/null +++ b/tests/test_main_window.py @@ -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 diff --git a/tests/test_main_window_actions.py b/tests/test_main_window_actions.py deleted file mode 100644 index 2630830..0000000 --- a/tests/test_main_window_actions.py +++ /dev/null @@ -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 = ( - "

Unchecked 1

" - "

Checked 1

" - "

Unchecked 2

" - ) - win.db.save_new_version(y, html) - - # Ensure today starts blank - today_iso = QDate.currentDate().toString("yyyy-MM-dd") - win.editor.setHtml("

") - _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("

content

") - 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 diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py new file mode 100644 index 0000000..25cae12 --- /dev/null +++ b/tests/test_markdown_editor.py @@ -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) diff --git a/tests/test_misc.py b/tests/test_misc.py deleted file mode 100644 index 20a3b1c..0000000 --- a/tests/test_misc.py +++ /dev/null @@ -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 "

hi

" - - def get_entries_days(self): - return [] - - -def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path): - # Config with a key so __init__ calls _try_connect immediately - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr) - msgs = {} - monkeypatch.setattr( - QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m)) - ) - w = MainWindow(_themes_light()) # auto-calls _try_connect - qtbot.addWidget(w) - assert "incorrect" in msgs.get("m", "").lower() - - -def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path): - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) - w = MainWindow(_themes_dark()) - qtbot.addWidget(w) - w._apply_link_css() - css = w.editor.document().defaultStyleSheet() - assert "a {" in css - - -def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path): - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) - w = MainWindow(_themes_light()) - qtbot.addWidget(w) - called = {} - - class FakeSettings: - def value(self, key, default=None, type=None): - if key == "main/geometry": - return None - if key == "main/windowState": - return None - if key == "main/maximized": - return False - return default - - w.settings = FakeSettings() - monkeypatch.setattr( - w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True) - ) - w._restore_window_position() - assert called.get("x") is True - - -def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path): - cfg = DBConfig(tmp_path / "db.sqlite", key="x") - (tmp_path / "db.sqlite").write_text("", encoding="utf-8") - monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) - monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) - w = MainWindow(_themes_light()) - qtbot.addWidget(w) - captured = {} - monkeypatch.setattr( - w.editor, "insert_images", lambda paths: captured.setdefault("p", paths) - ) - # Simulate file dialog returning paths - monkeypatch.setattr( - "bouquin.main_window.QFileDialog.getOpenFileNames", - staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")), - ) - w._on_insert_image() - assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"] diff --git a/tests/test_save_dialog.py b/tests/test_save_dialog.py new file mode 100644 index 0000000..255740e --- /dev/null +++ b/tests/test_save_dialog.py @@ -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() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..d8ceb41 --- /dev/null +++ b/tests/test_search.py @@ -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() diff --git a/tests/test_search_edgecase.py b/tests/test_search_edgecase.py deleted file mode 100644 index 712f7e3..0000000 --- a/tests/test_search_edgecase.py +++ /dev/null @@ -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 = "

" + ("x" * 300) + "

" # 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 diff --git a/tests/test_search_edges.py b/tests/test_search_edges.py deleted file mode 100644 index b3a6751..0000000 --- a/tests/test_search_edges.py +++ /dev/null @@ -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", "

Hello world first day

") - db.save_new_version( - "2025-01-02", "

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

" - ) - db.save_new_version( - "2025-01-03", - "

Long content begins " - + ("x" * 200) - + " middle token here " - + ("y" * 200) - + " ends.

", - ) - 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() diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py new file mode 100644 index 0000000..a4e318b --- /dev/null +++ b/tests/test_search_helpers.py @@ -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") diff --git a/tests/test_search_history.py b/tests/test_search_history.py deleted file mode 100644 index 10ff25c..0000000 --- a/tests/test_search_history.py +++ /dev/null @@ -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() diff --git a/tests/test_search_unit.py b/tests/test_search_unit.py deleted file mode 100644 index 13c1ef9..0000000 --- a/tests/test_search_unit.py +++ /dev/null @@ -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("

Hello world

", "world") - assert "world" in frag or "world" in frag diff --git a/tests/test_search_windows.py b/tests/test_search_windows.py deleted file mode 100644 index 5770e73..0000000 --- a/tests/test_search_windows.py +++ /dev/null @@ -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 = "

Alpha beta gamma delta

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

One two three four five six seven eight nine ten eleven twelve

" - # 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 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..254af98 --- /dev/null +++ b/tests/test_settings.py @@ -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 diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index b1962c7..515e769 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,296 +1,180 @@ -from pathlib import Path - -from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget - -from bouquin.db import DBConfig +import pytest 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: - def __init__(self): - self.calls = [] +@pytest.mark.gui +def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path): + # 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): - self.calls.append(t) + dlg.path_edit.setText(str(tmp_path / "alt.db")) + 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 __init__(self): - super().__init__() - self.themes = _ThemeSpy() +def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app): + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication, QMessageBox + 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 __init__(self): - self.rekey_called_with = None - self.compact_called = False - self.fail_compact = False +def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app): + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication, QMessageBox, QWidget + from bouquin.key_prompt import KeyPrompt + from bouquin.db import DBManager, DBConfig + from bouquin.theme import ThemeManager, ThemeConfig, Theme - def rekey(self, key: str): - self.rekey_called_with = key + cfg = DBConfig( + 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): - if self.fail_compact: - raise RuntimeError("boom") - self.compact_called = True - - -class AcceptingPrompt: - def __init__(self, parent=None, title="", message=""): - self._key = "" - self._accepted = True - - def set_key(self, k: str): - self._key = k - return self - - def exec(self): - return QDialog.Accepted if self._accepted else QDialog.Rejected - - def key(self): - return self._key - - -class RejectingPrompt(AcceptingPrompt): - def __init__(self, *a, **k): - super().__init__() - self._accepted = False - - -def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path): - db = FakeDB() - cfg = DBConfig(path=tmp_path / "db.sqlite", key="", idle_minutes=15) - - saved = {} - - def fake_save(cfg2): - saved["cfg"] = cfg2 - - monkeypatch.setattr("bouquin.settings_dialog.save_db_config", fake_save) - - # Drive the "remember key" checkbox via the prompt (no pre-set key) - p = AcceptingPrompt().set_key("sekrit") - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) - - # 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) + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) dlg = SettingsDialog(cfg, db, parent=parent) qtbot.addWidget(dlg) dlg.show() - qtbot.waitExposed(dlg) - # Change fields - new_path = tmp_path / "new.sqlite" - dlg.path_edit.setText(str(new_path)) - dlg.idle_spin.setValue(0) + keys = ["one", "two"] - # User toggles "Remember key" -> stores prompted key - dlg.save_key_btn.setChecked(True) + def _pump_popups(): + 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() - - out = saved["cfg"] - assert out.path == new_path - assert out.idle_minutes == 0 - assert out.key == "sekrit" - assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM + timer = QTimer() + timer.setInterval(10) + timer.timeout.connect(_pump_popups) + timer.start() + try: + dlg._change_key() + 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): - # When toggled on with no key yet, it prompts; cancelling should revert the check - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt) +def test_change_key_success(qtbot, tmp_path, app): + from PySide6.QtCore import QTimer + 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()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - assert dlg.key == "" - dlg.save_key_btn.click() # toggles True -> triggers prompt which rejects - assert dlg.save_key_btn.isChecked() is False - assert dlg.key == "" - - -def test_save_key_checkbox_accepts_and_stores_key(monkeypatch, qtbot): - # Toggling on with an accepting prompt should store the typed key - p = AcceptingPrompt().set_key("remember-me") - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) - - dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg.save_key_btn.click() - assert dlg.save_key_btn.isChecked() is True - assert dlg.key == "remember-me" - - -def test_change_key_success(monkeypatch, qtbot): - # Two prompts returning the same non-empty key -> rekey() and info message - p1 = AcceptingPrompt().set_key("newkey") - p2 = AcceptingPrompt().set_key("newkey") - seq = [p1, p2] - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) - - shown = {"info": 0} - monkeypatch.setattr( - QMessageBox, - "information", - lambda *a, **k: shown.__setitem__("info", shown["info"] + 1), + cfg = DBConfig( + path=tmp_path / "iso2.db", + key="oldkey", + idle_minutes=0, + theme="light", + move_todos=True, ) + db = DBManager(cfg) + assert db.connect() + db.save_new_version("2001-01-01", "seed", "seed") - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + dlg = SettingsDialog(cfg, db, parent=parent) qtbot.addWidget(dlg) dlg.show() - qtbot.waitExposed(dlg) - dlg._change_key() + keys = ["newkey", "newkey"] - assert db.rekey_called_with == "newkey" - assert shown["info"] >= 1 - assert dlg.key == "newkey" + def _pump(): + for w in QApplication.topLevelWidgets(): + 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): - p1 = AcceptingPrompt().set_key("a") - p2 = AcceptingPrompt().set_key("b") - seq = [p1, p2] - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) - - called = {"warn": 0} - monkeypatch.setattr( - QMessageBox, - "warning", - lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), - ) - - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg._change_key() - - assert db.rekey_called_with is None - assert called["warn"] >= 1 - - -def test_change_key_empty_shows_warning(monkeypatch, qtbot): - p1 = AcceptingPrompt().set_key("") - p2 = AcceptingPrompt().set_key("") - seq = [p1, p2] - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) - - called = {"warn": 0} - monkeypatch.setattr( - QMessageBox, - "warning", - lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), - ) - - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg._change_key() - - assert db.rekey_called_with is None - assert called["warn"] >= 1 - - -def test_browse_sets_path(monkeypatch, qtbot, tmp_path): - def fake_get_save_file_name(*a, **k): - return (str(tmp_path / "picked.sqlite"), "") - - monkeypatch.setattr( - QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name) - ) - - dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg._browse() - assert dlg.path_edit.text().endswith("picked.sqlite") - - -def test_compact_success_and_failure(monkeypatch, qtbot): - shown = {"info": 0, "crit": 0} - monkeypatch.setattr( - QMessageBox, - "information", - lambda *a, **k: shown.__setitem__("info", shown["info"] + 1), - ) - monkeypatch.setattr( - QMessageBox, - "critical", - lambda *a, **k: shown.__setitem__("crit", shown["crit"] + 1), - ) - - db = FakeDB() - dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg._compact_btn_clicked() - assert db.compact_called is True - assert shown["info"] >= 1 - - # Failure path - db2 = FakeDB() - db2.fail_compact = True - dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2) - qtbot.addWidget(dlg2) - dlg2.show() - qtbot.waitExposed(dlg2) - - dlg2._compact_btn_clicked() - assert shown["crit"] >= 1 - - -def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot): - p = AcceptingPrompt().set_key("already") - monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) - - dlg = SettingsDialog(DBConfig(Path("x"), key="already"), FakeDB()) - qtbot.addWidget(dlg) - dlg.show() - qtbot.waitExposed(dlg) - - dlg.save_key_btn.setChecked(True) - # We should reach here with the original key preserved. - assert dlg.key == "already" - - -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 + db.close() + cfg.key = "newkey" + db2 = DBManager(cfg) + assert db2.connect() + assert "seed" in db2.get_entry("2001-01-01") + db2.close() diff --git a/tests/test_settings_dialog_cancel_paths.py b/tests/test_settings_dialog_cancel_paths.py deleted file mode 100644 index bd86325..0000000 --- a/tests/test_settings_dialog_cancel_paths.py +++ /dev/null @@ -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()) diff --git a/tests/test_settings_module.py b/tests/test_settings_module.py deleted file mode 100644 index 24a9aac..0000000 --- a/tests/test_settings_module.py +++ /dev/null @@ -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" diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..690f439 --- /dev/null +++ b/tests/test_theme.py @@ -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) diff --git a/tests/test_theme_integration.py b/tests/test_theme_integration.py deleted file mode 100644 index f1949c3..0000000 --- a/tests/test_theme_integration.py +++ /dev/null @@ -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 diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py deleted file mode 100644 index 39121ea..0000000 --- a/tests/test_theme_manager.py +++ /dev/null @@ -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") diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py new file mode 100644 index 0000000..1022172 --- /dev/null +++ b/tests/test_toolbar.py @@ -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() diff --git a/tests/test_toolbar_private.py b/tests/test_toolbar_private.py deleted file mode 100644 index 834f4c2..0000000 --- a/tests/test_toolbar_private.py +++ /dev/null @@ -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" diff --git a/tests/test_toolbar_styles.py b/tests/test_toolbar_styles.py deleted file mode 100644 index 7116d21..0000000 --- a/tests/test_toolbar_styles.py +++ /dev/null @@ -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) -- 2.49.1