From 0a04b25fe55cbead744034cbc485983d189d7363 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 13 Nov 2025 20:37:02 +1100 Subject: [PATCH 1/5] Early work on tags --- bouquin/db.py | 190 ++++++++++++++++++++++++++++++++++++++-- bouquin/locales/en.json | 10 ++- bouquin/main_window.py | 14 +++ bouquin/tags_dialog.py | 134 ++++++++++++++++++++++++++++ bouquin/tags_widget.py | 181 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 520 insertions(+), 9 deletions(-) create mode 100644 bouquin/tags_dialog.py create mode 100644 bouquin/tags_widget.py diff --git a/bouquin/db.py b/bouquin/db.py index 58143b6..fdc6764 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1,18 +1,29 @@ from __future__ import annotations import csv +import hashlib import html import json from dataclasses import dataclass from pathlib import Path from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple +from typing import List, Sequence, Tuple, Iterable + from . import strings Entry = Tuple[str, str] +TagRow = Tuple[int, str, str] +_TAG_COLORS = [ + "#FFB3BA", # soft red + "#FFDFBA", # soft orange + "#FFFFBA", # soft yellow + "#BAFFC9", # soft green + "#BAE1FF", # soft blue + "#E0BAFF", # soft purple +] @dataclass class DBConfig: @@ -82,7 +93,6 @@ class DBManager: # Always keep FKs on cur.execute("PRAGMA foreign_keys = ON;") - # Create new versioned schema if missing (< 0.1.5) cur.executescript( """ CREATE TABLE IF NOT EXISTS pages ( @@ -103,6 +113,24 @@ class DBManager: CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no); CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at); + + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name); + + CREATE TABLE IF NOT EXISTS page_tags ( + page_date TEXT NOT NULL, -- FK to pages.date + tag_id INTEGER NOT NULL, -- FK to tags.id + PRIMARY KEY (page_date, tag_id), + FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id); """ ) self.conn.commit() @@ -142,25 +170,35 @@ class DBManager: def search_entries(self, text: str) -> list[str]: """ - Search for entries by term. This only works against the latest - version of the page. + Search for entries by term or tag name. + This only works against the latest version of the page. """ cur = self.conn.cursor() - pattern = f"%{text}%" + q = text.strip() + pattern = f"%{q.lower()}%" + rows = cur.execute( """ - SELECT p.date, v.content + SELECT DISTINCT p.date, v.content FROM pages AS p JOIN versions AS v ON v.id = p.current_version_id + LEFT JOIN page_tags pt + ON pt.page_date = p.date + LEFT JOIN tags t + ON t.id = pt.tag_id WHERE TRIM(v.content) <> '' - AND v.content LIKE LOWER(?) ESCAPE '\\' + AND ( + LOWER(v.content) LIKE ? + OR LOWER(COALESCE(t.name, '')) LIKE ? + ) ORDER BY p.date DESC; """, - (pattern,), + (pattern, pattern), ).fetchall() return [(r[0], r[1]) for r in rows] + def dates_with_content(self) -> list[str]: """ Find all entries and return the dates of them. @@ -386,6 +424,142 @@ class DBManager: except Exception as e: print(f"{strings._('error')}: {e}") + # -------- Tags: helpers ------------------------------------------- + + def _default_tag_colour(self, name: str) -> str: + """ + Deterministically pick a colour for a tag name from a small palette. + """ + if not name: + return "#CCCCCC" + h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) + return _TAG_COLORS[h % len(_TAG_COLORS)] + + # -------- Tags: per-page ------------------------------------------- + + def get_tags_for_page(self, date_iso: str) -> list[TagRow]: + """ + Return (id, name, color) for all tags attached to this page/date. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT t.id, t.name, t.color + FROM page_tags pt + JOIN tags t ON t.id = pt.tag_id + WHERE pt.page_date = ? + ORDER BY LOWER(t.name); + """, + (date_iso,), + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def set_tags_for_page(self, date_iso: str, tag_names: Sequence[str]) -> None: + """ + Replace the tag set for a page with the given names. + Creates new tags as needed (with auto colours). + """ + # Normalise + dedupe + clean_names = [] + seen = set() + for name in tag_names: + name = name.strip() + if not name: + continue + if name.lower() in seen: + continue + seen.add(name.lower()) + clean_names.append(name) + + with self.conn: + cur = self.conn.cursor() + + # Ensure the page row exists even if there's no content yet + cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,)) + + if not clean_names: + # Just clear all tags for this page + cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) + return + + # Ensure tag rows exist + for name in clean_names: + cur.execute( + """ + INSERT OR IGNORE INTO tags(name, color) + VALUES (?, ?); + """, + (name, self._default_tag_colour(name)), + ) + + # Lookup ids + placeholders = ",".join("?" for _ in clean_names) + rows = cur.execute( + f""" + SELECT id, name + FROM tags + WHERE name IN ({placeholders}); + """, + tuple(clean_names), + ).fetchall() + ids_by_name = {r["name"]: r["id"] for r in rows} + + # Reset page_tags for this page + cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) + for name in clean_names: + tag_id = ids_by_name.get(name) + if tag_id is not None: + cur.execute( + """ + INSERT OR IGNORE INTO page_tags(page_date, tag_id) + VALUES (?, ?); + """, + (date_iso, tag_id), + ) + + # -------- Tags: global management ---------------------------------- + + def list_tags(self) -> list[TagRow]: + """ + Return all tags in the database. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT id, name, color + FROM tags + ORDER BY LOWER(name); + """ + ).fetchall() + return [(r[0], r[1], r[2]) for r in rows] + + def update_tag(self, tag_id: int, name: str, color: str) -> None: + """ + Update a tag's name and colour. + """ + name = name.strip() + color = color.strip() or "#CCCCCC" + + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + UPDATE tags + SET name = ?, color = ? + WHERE id = ?; + """, + (name, color, tag_id), + ) + + def delete_tag(self, tag_id: int) -> None: + """ + Delete a tag entirely (removes it from all pages). + """ + with self.conn: + cur = self.conn.cursor() + cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) + cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) + def close(self) -> None: if self.conn is not None: self.conn.close() diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index dfae41c..c191f8f 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -110,5 +110,13 @@ "toolbar_numbered_list": "Numbered list", "toolbar_code_block": "Code block", "toolbar_heading": "Heading", - "toolbar_toggle_checkboxes": "Toggle checkboxes" + "toolbar_toggle_checkboxes": "Toggle checkboxes", + "tags": "Tags", + "manage_tags": "Manage tags…", + "add_tag_placeholder": "Add tag and press Enter…", + "tag_name": "Tag name", + "tag_color_hex": "Hex colour", + "pick_color": "Pick colour…", + "invalid_color_title": "Invalid colour", + "invalid_color_message": "Please enter a valid hex colour like #RRGGBB." } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 578852f..be8bb7f 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -56,6 +56,7 @@ from .search import Search from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from . import strings +from .tags_widget import PageTagsWidget from .toolbar import ToolBar from .theme import ThemeManager @@ -93,6 +94,8 @@ class MainWindow(QMainWindow): self.search.openDateRequested.connect(self._load_selected_date) self.search.resultDatesChanged.connect(self._on_search_dates_changed) + self.tags = PageTagsWidget(self.db) + # Lock the calendar to the left panel at the top to stop it stretching # when the main window is resized. left_panel = QWidget() @@ -100,6 +103,7 @@ class MainWindow(QMainWindow): left_layout.setContentsMargins(8, 8, 8, 8) left_layout.addWidget(self.calendar) left_layout.addWidget(self.search) + left_layout.addWidget(self.tags) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) # Create tab widget to hold multiple editors @@ -498,6 +502,12 @@ class MainWindow(QMainWindow): with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(editor.current_date) + + # update per-page tags for the active tab + if hasattr(self, "tags"): + date_iso = editor.current_date.toString("yyyy-MM-dd") + self.tags.set_current_date(date_iso) + # Reconnect toolbar to new active editor self._sync_toolbar() @@ -628,6 +638,10 @@ class MainWindow(QMainWindow): # Keep tabs sorted by date self._reorder_tabs_by_date() + # sync tags + if hasattr(self, "tags"): + self.tags.set_current_date(date_iso) + def _load_date_into_editor(self, date: QDate, extra_data=False): """Load a specific date's content into a given editor.""" date_iso = date.toString("yyyy-MM-dd") diff --git a/bouquin/tags_dialog.py b/bouquin/tags_dialog.py new file mode 100644 index 0000000..59c722a --- /dev/null +++ b/bouquin/tags_dialog.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QTableWidget, + QTableWidgetItem, + QPushButton, + QColorDialog, + QMessageBox, +) + +from . import strings +from .db import DBManager + +class TagManagerDialog(QDialog): + def __init__(self, db: DBManager, parent=None): + super().__init__(parent) + self._db = db + self.setWindowTitle(strings._("manage_tags")) + + layout = QVBoxLayout(self) + + self.table = QTableWidget(0, 2, self) + self.table.setHorizontalHeaderLabels( + [strings._("tag_name"), strings._("tag_color_hex")] + ) + self.table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.table) + + btn_row = QHBoxLayout() + self.add_btn = QPushButton(strings._("add")) + self.remove_btn = QPushButton(strings._("remove")) + self.color_btn = QPushButton(strings._("pick_color")) + btn_row.addWidget(self.add_btn) + btn_row.addWidget(self.remove_btn) + btn_row.addWidget(self.color_btn) + btn_row.addStretch(1) + layout.addLayout(btn_row) + + action_row = QHBoxLayout() + ok_btn = QPushButton(strings._("ok")) + cancel_btn = QPushButton(strings._("cancel")) + ok_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + action_row.addStretch(1) + action_row.addWidget(ok_btn) + action_row.addWidget(cancel_btn) + layout.addLayout(action_row) + + self.add_btn.clicked.connect(self._add_row) + self.remove_btn.clicked.connect(self._remove_selected) + self.color_btn.clicked.connect(self._pick_color) + + self._load_tags() + + def _load_tags(self) -> None: + self.table.setRowCount(0) + for tag_id, name, color in self._db.list_tags(): + row = self.table.rowCount() + self.table.insertRow(row) + + name_item = QTableWidgetItem(name) + name_item.setData(Qt.ItemDataRole.UserRole, tag_id) + self.table.setItem(row, 0, name_item) + + color_item = QTableWidgetItem(color) + color_item.setBackground(Qt.GlobalColor.transparent) + self.table.setItem(row, 1, color_item) + + def _add_row(self) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + name_item = QTableWidgetItem("") + name_item.setData(Qt.ItemDataRole.UserRole, 0) # 0 => new tag + self.table.setItem(row, 0, name_item) + self.table.setItem(row, 1, QTableWidgetItem("#CCCCCC")) + + def _remove_selected(self) -> None: + row = self.table.currentRow() + if row < 0: + return + item = self.table.item(row, 0) + tag_id = item.data(Qt.ItemDataRole.UserRole) + if tag_id: + self._db.delete_tag(int(tag_id)) + self.table.removeRow(row) + + def _pick_color(self) -> None: + row = self.table.currentRow() + if row < 0: + return + item = self.table.item(row, 1) + current = item.text() or "#CCCCCC" + color = QColorDialog.getColor() + if color.isValid(): + item.setText(color.name()) + + def accept(self) -> None: + # Persist all rows back to DB + for row in range(self.table.rowCount()): + name_item = self.table.item(row, 0) + color_item = self.table.item(row, 1) + if name_item is None or color_item is None: + continue + + name = name_item.text().strip() + color = color_item.text().strip() or "#CCCCCC" + tag_id = int(name_item.data(Qt.ItemDataRole.UserRole) or 0) + + if not name: + continue # ignore empty rows + + if not color.startswith("#") or len(color) not in (4, 7): + QMessageBox.warning( + self, + strings._("invalid_color_title"), + strings._("invalid_color_message"), + ) + return # keep dialog open + + if tag_id == 0: + # new tag: just rely on set_tags_for_page/create, or you can + # insert here if you like. Easiest is to create via DBManager: + # use a dummy page or do a direct insert + self._db.set_tags_for_page("__dummy__", [name]) + # then delete the dummy row; or instead provide a DBManager + # helper to create a tag explicitly. + else: + self._db.update_tag(tag_id, name, color) + + super().accept() diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py new file mode 100644 index 0000000..506ce88 --- /dev/null +++ b/bouquin/tags_widget.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from typing import Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QVBoxLayout, + QWidget, + QToolButton, + QLabel, + QLineEdit, + QSizePolicy, + QStyle, +) + +from . import strings +from .db import DBManager + +class TagChip(QFrame): + removeRequested = Signal(int) # tag_id + + def __init__(self, tag_id: int, name: str, color: str, parent: QWidget | None = None): + super().__init__(parent) + self._id = tag_id + self.setObjectName("TagChip") + + self.setFrameShape(QFrame.StyledPanel) + self.setFrameShadow(QFrame.Raised) + + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(4) + + color_lbl = QLabel() + color_lbl.setFixedSize(10, 10) + color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 3px;") + layout.addWidget(color_lbl) + + name_lbl = QLabel(name) + layout.addWidget(name_lbl) + + btn = QToolButton() + btn.setText("×") + btn.setAutoRaise(True) + btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) + layout.addWidget(btn) + + @property + def tag_id(self) -> int: + return self._id + + +class PageTagsWidget(QFrame): + """ + Collapsible per-page tag editor shown in the left sidebar. + """ + + def __init__(self, db: DBManager, parent: QWidget | None = None): + super().__init__(parent) + self._db = db + self._current_date: Optional[str] = None + + self.setFrameShape(QFrame.StyledPanel) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + + # Header (toggle + manage button) + self.toggle_btn = QToolButton() + self.toggle_btn.setText(strings._("tags")) + self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.toggle_btn.setCheckable(True) + self.toggle_btn.setChecked(False) + self.toggle_btn.setArrowType(Qt.RightArrow) + self.toggle_btn.clicked.connect(self._on_toggle) + + self.manage_btn = QToolButton() + self.manage_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView)) + self.manage_btn.setToolTip(strings._("manage_tags")) + self.manage_btn.setAutoRaise(True) + self.manage_btn.clicked.connect(self._open_manager) + + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.addWidget(self.toggle_btn) + header.addStretch(1) + header.addWidget(self.manage_btn) + + # Body (chips + add line) + self.body = QWidget() + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 4, 0, 0) + self.body_layout.setSpacing(4) + + # Simple horizontal layout for now; you can swap for a FlowLayout + self.chip_row = QHBoxLayout() + self.chip_row.setContentsMargins(0, 0, 0, 0) + self.chip_row.setSpacing(4) + self.body_layout.addLayout(self.chip_row) + + self.add_edit = QLineEdit() + self.add_edit.setPlaceholderText(strings._("add_tag_placeholder")) + self.add_edit.returnPressed.connect(self._on_add_tag) + self.body_layout.addWidget(self.add_edit) + + self.body.setVisible(False) + + main = QVBoxLayout(self) + main.setContentsMargins(0, 0, 0, 0) + main.addLayout(header) + main.addWidget(self.body) + + # ----- external API ------------------------------------------------ + + def set_current_date(self, date_iso: str) -> None: + self._current_date = date_iso + if self.toggle_btn.isChecked(): + self._reload_tags() + else: + # Keep it cheap while collapsed; reload only when expanded + self._clear_chips() + + # ----- internals --------------------------------------------------- + + def _on_toggle(self, checked: bool) -> None: + self.body.setVisible(checked) + self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) + if checked and self._current_date: + self._reload_tags() + + def _clear_chips(self) -> None: + while self.chip_row.count(): + item = self.chip_row.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _reload_tags(self) -> None: + if not self._current_date: + self._clear_chips() + return + + self._clear_chips() + tags = self._db.get_tags_for_page(self._current_date) + for tag_id, name, color in tags: + chip = TagChip(tag_id, name, color, self) + chip.removeRequested.connect(self._remove_tag) + self.chip_row.addWidget(chip) + self.chip_row.addStretch(1) + + def _on_add_tag(self) -> None: + if not self._current_date: + return + new_tag = self.add_edit.text().strip() + if not new_tag: + return + + # Combine current tags + new one, then write back + existing = [name for _, name, _ in self._db.get_tags_for_page(self._current_date)] + existing.append(new_tag) + self._db.set_tags_for_page(self._current_date, existing) + self.add_edit.clear() + self._reload_tags() + + def _remove_tag(self, tag_id: int) -> None: + if not self._current_date: + return + tags = self._db.get_tags_for_page(self._current_date) + remaining = [name for (tid, name, _color) in tags if tid != tag_id] + self._db.set_tags_for_page(self._current_date, remaining) + self._reload_tags() + + def _open_manager(self) -> None: + from .tags_dialog import TagManagerDialog + + dlg = TagManagerDialog(self._db, self) + if dlg.exec(): + # Names/colours may have changed + if self._current_date: + self._reload_tags() + From 5e283ecf175f3a75901b63ac0a6ffb2889707349 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 14 Nov 2025 13:18:58 +1100 Subject: [PATCH 2/5] WIP --- bouquin/db.py | 24 +++++++++- bouquin/flow_layout.py | 88 +++++++++++++++++++++++++++++++++++ bouquin/locales/en.json | 12 +++-- bouquin/main_window.py | 28 ++++++++--- bouquin/status_tags_widget.py | 63 +++++++++++++++++++++++++ bouquin/tag_browser.py | 60 ++++++++++++++++++++++++ bouquin/tags_dialog.py | 7 +-- bouquin/tags_widget.py | 33 ++++++++++--- 8 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 bouquin/flow_layout.py create mode 100644 bouquin/status_tags_widget.py create mode 100644 bouquin/tag_browser.py diff --git a/bouquin/db.py b/bouquin/db.py index fdc6764..d040149 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -25,6 +25,7 @@ _TAG_COLORS = [ "#E0BAFF", # soft purple ] + @dataclass class DBConfig: path: Path @@ -198,7 +199,6 @@ class DBManager: ).fetchall() return [(r[0], r[1]) for r in rows] - def dates_with_content(self) -> list[str]: """ Find all entries and return the dates of them. @@ -560,6 +560,28 @@ class DBManager: cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) + def get_pages_for_tag(self, tag_name: str) -> list[Entry]: + """ + Return (date, content) for pages that have the given tag. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT p.date, v.content + FROM pages AS p + JOIN versions AS v + ON v.id = p.current_version_id + JOIN page_tags pt + ON pt.page_date = p.date + JOIN tags t + ON t.id = pt.tag_id + WHERE LOWER(t.name) = LOWER(?) + ORDER BY p.date DESC; + """, + (tag_name,), + ).fetchall() + return [(r[0], r[1]) for r in rows] + def close(self) -> None: if self.conn is not None: self.conn.close() diff --git a/bouquin/flow_layout.py b/bouquin/flow_layout.py new file mode 100644 index 0000000..937098d --- /dev/null +++ b/bouquin/flow_layout.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from PySide6.QtCore import QPoint, QRect, QSize, Qt +from PySide6.QtWidgets import QLayout, QSizePolicy + + +class FlowLayout(QLayout): + def __init__( + self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4 + ): + super().__init__(parent) + self._items = [] + self._hspace = hspacing + self._vspace = vspacing + self.setContentsMargins(margin, margin, margin, margin) + + def addItem(self, item): + self._items.append(item) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + + def count(self): + return len(self._items) + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width: int) -> int: + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect: QRect): + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self) -> QSize: + return self.minimumSize() + + def minimumSize(self) -> QSize: + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + left, top, right, bottom = self.getContentsMargins() + size += QSize(left + right, top + bottom) + return size + + def _do_layout(self, rect: QRect, test_only: bool) -> int: + x = rect.x() + y = rect.y() + line_height = 0 + + left, top, right, bottom = self.getContentsMargins() + effective_rect = rect.adjusted(+left, +top, -right, -bottom) + x = effective_rect.x() + y = effective_rect.y() + max_right = effective_rect.right() + + for item in self._items: + wid = item.widget() + if wid is None or not wid.isVisible(): + continue + space_x = self._hspace + space_y = self._vspace + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > max_right and line_height > 0: + # Wrap + x = effective_rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 + + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + bottom diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index c191f8f..74a45df 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -112,11 +112,15 @@ "toolbar_heading": "Heading", "toolbar_toggle_checkboxes": "Toggle checkboxes", "tags": "Tags", - "manage_tags": "Manage tags…", - "add_tag_placeholder": "Add tag and press Enter…", + "manage_tags": "Manage tags on this page", + "add_tag_placeholder": "Add a tag and press Enter", + "tag_browser_title": "Tag Browser", "tag_name": "Tag name", "tag_color_hex": "Hex colour", - "pick_color": "Pick colour…", + "pick_color": "Pick colour", "invalid_color_title": "Invalid colour", - "invalid_color_message": "Please enter a valid hex colour like #RRGGBB." + "invalid_color_message": "Please enter a valid hex colour like #RRGGBB.", + "add": "Add", + "remove": "Remove", + "ok": "OK" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index be8bb7f..6329286 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -57,6 +57,7 @@ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from . import strings from .tags_widget import PageTagsWidget +from .status_tags_widget import StatusBarTagsWidget from .toolbar import ToolBar from .theme import ThemeManager @@ -180,6 +181,11 @@ class MainWindow(QMainWindow): # FindBar will get the current editor dynamically via a callable self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) self.statusBar().addPermanentWidget(self.findBar) + # status-bar tags widget + self.statusTags = StatusBarTagsWidget(self.db, self) + self.statusBar().addPermanentWidget(self.statusTags) + # Clicking a tag in the status bar will open tag pages (next section) + self.statusTags.tagActivated.connect(self._on_tag_activated) # When the findBar closes, put the caret back in the editor self.findBar.closed.connect(self._focus_editor_now) @@ -502,11 +508,8 @@ class MainWindow(QMainWindow): with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(editor.current_date) - # update per-page tags for the active tab - if hasattr(self, "tags"): - date_iso = editor.current_date.toString("yyyy-MM-dd") - self.tags.set_current_date(date_iso) + self._update_tag_views_for_date(date_iso) # Reconnect toolbar to new active editor self._sync_toolbar() @@ -639,8 +642,7 @@ class MainWindow(QMainWindow): self._reorder_tabs_by_date() # sync tags - if hasattr(self, "tags"): - self.tags.set_current_date(date_iso) + self._update_tag_views_for_date(date_iso) def _load_date_into_editor(self, date: QDate, extra_data=False): """Load a specific date's content into a given editor.""" @@ -1005,6 +1007,20 @@ class MainWindow(QMainWindow): for path_str in paths: self.editor.insert_image_from_path(Path(path_str)) + # ----------- Tags handler ----------------# + def _update_tag_views_for_date(self, date_iso: str): + if hasattr(self, "tags"): + self.tags.set_current_date(date_iso) + if hasattr(self, "statusTags"): + self.statusTags.set_current_page(date_iso) + + def _on_tag_activated(self, tag_name: str): + from .tag_browser import TagBrowserDialog + + dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name) + dlg.openDateRequested.connect(self._load_selected_date) + dlg.exec() + # ----------- Settings handler ------------# def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) diff --git a/bouquin/status_tags_widget.py b/bouquin/status_tags_widget.py new file mode 100644 index 0000000..348c123 --- /dev/null +++ b/bouquin/status_tags_widget.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QWidget, + QHBoxLayout, + QLabel, + QSizePolicy, +) + +from .flow_layout import FlowLayout +from .db import DBManager +from .tags_widget import TagChip # reuse, or make a smaller variant if you prefer + + +class StatusBarTagsWidget(QWidget): + tagActivated = Signal(str) # tag name + + def __init__(self, db: DBManager, parent=None): + super().__init__(parent) + self._db = db + self._current_date_iso: str | None = None + + outer = QHBoxLayout(self) + outer.setContentsMargins(4, 0, 4, 0) + outer.setSpacing(4) + + label = QLabel("Tags:") + outer.addWidget(label) + + self.flow = FlowLayout(self, hspacing=2, vspacing=2) + outer.addLayout(self.flow) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + def set_current_page(self, date_iso: str): + self._current_date_iso = date_iso + self._reload() + + def _clear(self): + while self.flow.count(): + item = self.flow.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _reload(self): + self._clear() + if not self._current_date_iso: + return + + tags = self._db.get_tags_for_page(self._current_date_iso) + # Keep it small; maybe only first N tags: + MAX_TAGS = 6 + for i, (tid, name, color) in enumerate(tags): + if i >= MAX_TAGS: + more = QLabel("…") + self.flow.addWidget(more) + break + chip = TagChip(tid, name, color, self) + chip.clicked.connect(self.tagActivated) + # In status bar you might want a smaller style: adjust chip's stylesheet if needed. + self.flow.addWidget(chip) diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py new file mode 100644 index 0000000..3dc50e0 --- /dev/null +++ b/bouquin/tag_browser.py @@ -0,0 +1,60 @@ +# tag_browser.py +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QTreeWidget, + QTreeWidgetItem, + QPushButton, +) + +from .db import DBManager +from . import strings + + +class TagBrowserDialog(QDialog): + openDateRequested = Signal(str) + + def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None): + super().__init__(parent) + self._db = db + self.setWindowTitle(strings._("tag_browser_title")) + + layout = QVBoxLayout(self) + self.tree = QTreeWidget() + self.tree.setHeaderLabels([strings._("tag"), strings._("date")]) + self.tree.itemActivated.connect(self._on_item_activated) + layout.addWidget(self.tree) + + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + layout.addWidget(close_btn) + + self._populate(focus_tag) + + def _populate(self, focus_tag: str | None): + tags = self._db.list_tags() + focus_item = None + for tag_id, name, color in tags: + root = QTreeWidgetItem([name, ""]) + # coloured background or icon: + root.setData(0, Qt.ItemDataRole.UserRole, name) + self.tree.addTopLevelItem(root) + + pages = self._db.get_pages_for_tag(name) + for date_iso, _content in pages: + child = QTreeWidgetItem(["", date_iso]) + child.setData(0, Qt.ItemDataRole.UserRole, date_iso) + root.addChild(child) + + if focus_tag and name.lower() == focus_tag.lower(): + focus_item = root + + if focus_item: + self.tree.expandItem(focus_item) + self.tree.setCurrentItem(focus_item) + + def _on_item_activated(self, item: QTreeWidgetItem, column: int): + date_iso = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(date_iso, str) and date_iso: + self.openDateRequested.emit(date_iso) diff --git a/bouquin/tags_dialog.py b/bouquin/tags_dialog.py index 59c722a..21c8863 100644 --- a/bouquin/tags_dialog.py +++ b/bouquin/tags_dialog.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( from . import strings from .db import DBManager + class TagManagerDialog(QDialog): def __init__(self, db: DBManager, parent=None): super().__init__(parent) @@ -42,12 +43,12 @@ class TagManagerDialog(QDialog): action_row = QHBoxLayout() ok_btn = QPushButton(strings._("ok")) - cancel_btn = QPushButton(strings._("cancel")) + close_btn = QPushButton(strings._("close")) ok_btn.clicked.connect(self.accept) - cancel_btn.clicked.connect(self.reject) + close_btn.clicked.connect(self.reject) action_row.addStretch(1) action_row.addWidget(ok_btn) - action_row.addWidget(cancel_btn) + action_row.addWidget(close_btn) layout.addLayout(action_row) self.add_btn.clicked.connect(self._add_row) diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py index 506ce88..e2a70d0 100644 --- a/bouquin/tags_widget.py +++ b/bouquin/tags_widget.py @@ -17,13 +17,20 @@ from PySide6.QtWidgets import ( from . import strings from .db import DBManager +from .flow_layout import FlowLayout + class TagChip(QFrame): removeRequested = Signal(int) # tag_id + clicked = Signal(str) # tag name - def __init__(self, tag_id: int, name: str, color: str, parent: QWidget | None = None): + def __init__( + self, tag_id: int, name: str, color: str, parent: QWidget | None = None + ): super().__init__(parent) self._id = tag_id + self._name = name + self.setObjectName("TagChip") self.setFrameShape(QFrame.StyledPanel) @@ -45,12 +52,20 @@ class TagChip(QFrame): btn.setText("×") btn.setAutoRaise(True) btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) + + self.setCursor(Qt.PointingHandCursor) + layout.addWidget(btn) @property def tag_id(self) -> int: return self._id + def mouseReleaseEvent(self, ev): + if ev.button() == Qt.LeftButton: + self.clicked.emit(self._name) + super().mouseReleaseEvent(ev) + class PageTagsWidget(QFrame): """ @@ -75,7 +90,9 @@ class PageTagsWidget(QFrame): self.toggle_btn.clicked.connect(self._on_toggle) self.manage_btn = QToolButton() - self.manage_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView)) + self.manage_btn.setIcon( + self.style().standardIcon(QStyle.SP_FileDialogDetailedView) + ) self.manage_btn.setToolTip(strings._("manage_tags")) self.manage_btn.setAutoRaise(True) self.manage_btn.clicked.connect(self._open_manager) @@ -93,9 +110,7 @@ class PageTagsWidget(QFrame): self.body_layout.setSpacing(4) # Simple horizontal layout for now; you can swap for a FlowLayout - self.chip_row = QHBoxLayout() - self.chip_row.setContentsMargins(0, 0, 0, 0) - self.chip_row.setSpacing(4) + self.chip_row = FlowLayout(self.body, hspacing=4, vspacing=4) self.body_layout.addLayout(self.chip_row) self.add_edit = QLineEdit() @@ -145,8 +160,8 @@ class PageTagsWidget(QFrame): for tag_id, name, color in tags: chip = TagChip(tag_id, name, color, self) chip.removeRequested.connect(self._remove_tag) + chip.clicked.connect(self._on_chip_clicked) self.chip_row.addWidget(chip) - self.chip_row.addStretch(1) def _on_add_tag(self) -> None: if not self._current_date: @@ -156,7 +171,9 @@ class PageTagsWidget(QFrame): return # Combine current tags + new one, then write back - existing = [name for _, name, _ in self._db.get_tags_for_page(self._current_date)] + existing = [ + name for _, name, _ in self._db.get_tags_for_page(self._current_date) + ] existing.append(new_tag) self._db.set_tags_for_page(self._current_date, existing) self.add_edit.clear() @@ -179,3 +196,5 @@ class PageTagsWidget(QFrame): if self._current_date: self._reload_tags() + def _on_chip_clicked(self, name: str) -> None: + self.tagActivated.emit(name) From f6e10dccac30a96f1a60e52706af676e09c3e43d Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 14 Nov 2025 14:54:04 +1100 Subject: [PATCH 3/5] Tags working --- bouquin/db.py | 42 +- bouquin/flow_layout.py | 2 +- bouquin/locales/en.json | 12 +- bouquin/locales/fr.json | 22 +- bouquin/locales/it.json | 22 +- bouquin/main_window.py | 61 ++- bouquin/status_tags_widget.py | 63 --- bouquin/tag_browser.py | 167 +++++++- bouquin/tags_dialog.py | 135 ------ bouquin/tags_widget.py | 108 +++-- tests/test_tags.py | 781 ++++++++++++++++++++++++++++++++++ 11 files changed, 1148 insertions(+), 267 deletions(-) delete mode 100644 bouquin/status_tags_widget.py delete mode 100644 bouquin/tags_dialog.py create mode 100644 tests/test_tags.py diff --git a/bouquin/db.py b/bouquin/db.py index d040149..60a58c4 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -8,7 +8,7 @@ import json from dataclasses import dataclass from pathlib import Path from sqlcipher3 import dbapi2 as sqlite -from typing import List, Sequence, Tuple, Iterable +from typing import List, Sequence, Tuple from . import strings @@ -458,8 +458,9 @@ class DBManager: """ Replace the tag set for a page with the given names. Creates new tags as needed (with auto colours). + Tags are case-insensitive - reuses existing tag if found with different case. """ - # Normalise + dedupe + # Normalise + dedupe (case-insensitive) clean_names = [] seen = set() for name in tag_names: @@ -482,31 +483,44 @@ class DBManager: cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) return - # Ensure tag rows exist + # For each tag name, check if it exists with different casing + # If so, reuse that existing tag; otherwise create new + final_tag_names = [] for name in clean_names: - cur.execute( - """ - INSERT OR IGNORE INTO tags(name, color) - VALUES (?, ?); - """, - (name, self._default_tag_colour(name)), - ) + # Look for existing tag (case-insensitive) + existing = cur.execute( + "SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,) + ).fetchone() - # Lookup ids - placeholders = ",".join("?" for _ in clean_names) + if existing: + # Use the existing tag's exact name + final_tag_names.append(existing["name"]) + else: + # Create new tag with the provided casing + cur.execute( + """ + INSERT OR IGNORE INTO tags(name, color) + VALUES (?, ?); + """, + (name, self._default_tag_colour(name)), + ) + final_tag_names.append(name) + + # Lookup ids for the final tag names + placeholders = ",".join("?" for _ in final_tag_names) rows = cur.execute( f""" SELECT id, name FROM tags WHERE name IN ({placeholders}); """, - tuple(clean_names), + tuple(final_tag_names), ).fetchall() ids_by_name = {r["name"]: r["id"] for r in rows} # Reset page_tags for this page cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,)) - for name in clean_names: + for name in final_tag_names: tag_id = ids_by_name.get(name) if tag_id is not None: cur.execute( diff --git a/bouquin/flow_layout.py b/bouquin/flow_layout.py index 937098d..e2a1c5a 100644 --- a/bouquin/flow_layout.py +++ b/bouquin/flow_layout.py @@ -1,7 +1,7 @@ from __future__ import annotations from PySide6.QtCore import QPoint, QRect, QSize, Qt -from PySide6.QtWidgets import QLayout, QSizePolicy +from PySide6.QtWidgets import QLayout class FlowLayout(QLayout): diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 74a45df..e5957ea 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -112,15 +112,23 @@ "toolbar_heading": "Heading", "toolbar_toggle_checkboxes": "Toggle checkboxes", "tags": "Tags", - "manage_tags": "Manage tags on this page", + "manage_tags": "Manage tags", "add_tag_placeholder": "Add a tag and press Enter", "tag_browser_title": "Tag Browser", + "tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.", "tag_name": "Tag name", "tag_color_hex": "Hex colour", + "color_hex": "Color", + "date": "Date", "pick_color": "Pick colour", "invalid_color_title": "Invalid colour", "invalid_color_message": "Please enter a valid hex colour like #RRGGBB.", "add": "Add", "remove": "Remove", - "ok": "OK" + "ok": "OK", + "edit_tag_name": "Edit tag name", + "new_tag_name": "New tag name:", + "change_color": "Change colour", + "delete_tag": "Delete tag", + "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages." } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 427ce2a..2dbe11a 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -110,5 +110,25 @@ "toolbar_numbered_list": "Liste numérotée", "toolbar_code_block": "Bloc de code", "toolbar_heading": "Titre", - "toolbar_toggle_checkboxes": "Cocher/Décocher les cases" + "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", + "tags": "Tags", + "manage_tags": "Gérer les tags", + "add_tag_placeholder": "Ajouter un tag et appuyez sur Entrée", + "tag_browser_title": "Navigateur de tags", + "tag_browser_instructions": "Cliquez sur un tag pour l'étendre et voir toutes les pages avec ce tag. Cliquez sur une date pour l'ouvrir. Sélectionnez un tag pour modifier son nom, changer sa couleur ou le supprimer globalement.", + "tag_name": "Nom du tag", + "tag_color_hex": "Couleur hexadécimale", + "color_hex": "Couleur", + "date": "Date", + "pick_color": "Choisir la couleur", + "invalid_color_title": "Couleur invalide", + "invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.", + "add": "Ajouter", + "remove": "Supprimer", + "ok": "OK", + "edit_tag_name": "Modifier le nom du tag", + "new_tag_name": "Nouveau nom du tag :", + "change_color": "Changer la couleur", + "delete_tag": "Supprimer le tag", + "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer le tag '{name}' ? Cela le supprimera de toutes les pages." } diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json index 5c9f8c0..d6d2018 100644 --- a/bouquin/locales/it.json +++ b/bouquin/locales/it.json @@ -110,5 +110,25 @@ "toolbar_numbered_list": "Elenco numerato", "toolbar_code_block": "Blocco di codice", "toolbar_heading": "Titolo", - "toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo" + "toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo", + "tags": "Tag", + "manage_tags": "Gestisci tag", + "add_tag_placeholder": "Aggiungi un tag e premi Invio", + "tag_browser_title": "Browser dei tag", + "tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.", + "tag_name": "Nome del tag", + "tag_color_hex": "Colore esadecimale", + "color_hex": "Colore", + "date": "Data", + "pick_color": "Scegli colore", + "invalid_color_title": "Colore non valido", + "invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.", + "add": "Aggiungi", + "remove": "Rimuovi", + "ok": "OK", + "edit_tag_name": "Modifica nome tag", + "new_tag_name": "Nuovo nome tag:", + "change_color": "Cambia colore", + "delete_tag": "Elimina tag", + "delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine." } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 605c015..8692ea9 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -57,7 +57,6 @@ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from . import strings from .tags_widget import PageTagsWidget -from .status_tags_widget import StatusBarTagsWidget from .toolbar import ToolBar from .theme import ThemeManager @@ -96,6 +95,8 @@ class MainWindow(QMainWindow): self.search.resultDatesChanged.connect(self._on_search_dates_changed) self.tags = PageTagsWidget(self.db) + self.tags.tagActivated.connect(self._on_tag_activated) + self.tags.tagAdded.connect(self._on_tag_added) # Lock the calendar to the left panel at the top to stop it stretching # when the main window is resized. @@ -181,11 +182,6 @@ class MainWindow(QMainWindow): # FindBar will get the current editor dynamically via a callable self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) self.statusBar().addPermanentWidget(self.findBar) - # status-bar tags widget - self.statusTags = StatusBarTagsWidget(self.db, self) - self.statusBar().addPermanentWidget(self.statusTags) - # Clicking a tag in the status bar will open tag pages (next section) - self.statusTags.tagActivated.connect(self._on_tag_activated) # When the findBar closes, put the caret back in the editor self.findBar.closed.connect(self._focus_editor_now) @@ -213,6 +209,10 @@ class MainWindow(QMainWindow): act_backup.setShortcut("Ctrl+Shift+B") act_backup.triggered.connect(self._backup) file_menu.addAction(act_backup) + act_tags = QAction("&" + strings._("manage_tags"), self) + act_tags.setShortcut("Ctrl+T") + act_tags.triggered.connect(self.tags._open_manager) + file_menu.addAction(act_tags) file_menu.addSeparator() act_quit = QAction("&" + strings._("quit"), self) act_quit.setShortcut("Ctrl+Q") @@ -509,6 +509,7 @@ class MainWindow(QMainWindow): self.calendar.setSelectedDate(editor.current_date) # update per-page tags for the active tab + date_iso = editor.current_date.toString("yyyy-MM-dd") self._update_tag_views_for_date(date_iso) # Reconnect toolbar to new active editor @@ -814,6 +815,10 @@ class MainWindow(QMainWindow): if current_index >= 0: self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd")) + # Update tags for the newly loaded page + date_iso = new_date.toString("yyyy-MM-dd") + self._update_tag_views_for_date(date_iso) + # Keep tabs sorted by date self._reorder_tabs_by_date() @@ -1034,15 +1039,45 @@ class MainWindow(QMainWindow): def _update_tag_views_for_date(self, date_iso: str): if hasattr(self, "tags"): self.tags.set_current_date(date_iso) - if hasattr(self, "statusTags"): - self.statusTags.set_current_page(date_iso) - def _on_tag_activated(self, tag_name: str): - from .tag_browser import TagBrowserDialog + def _on_tag_added(self): + """Called when a tag is added - trigger autosave for current page""" + # Use QTimer to defer the save slightly, avoiding re-entrancy issues + from PySide6.QtCore import QTimer - dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name) - dlg.openDateRequested.connect(self._load_selected_date) - dlg.exec() + QTimer.singleShot(0, self._do_tag_save) + + def _do_tag_save(self): + """Actually perform the save after tag is added""" + if hasattr(self, "editor") and hasattr(self.editor, "current_date"): + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + + # Get current editor content + text = self.editor.to_markdown() + + # Save the content (or blank if page is empty) + # This ensures the page shows up in tag browser + self.db.save_new_version(date_iso, text, note="Tag added") + self._dirty = False + self._refresh_calendar_marks() + from datetime import datetime as _dt + + self.statusBar().showMessage( + strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}", + 2000, + ) + + def _on_tag_activated(self, tag_name_or_date: str): + # If it's a date (YYYY-MM-DD format), load it + if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2: + self._load_selected_date(tag_name_or_date) + else: + # It's a tag name, open the tag browser + from .tag_browser import TagBrowserDialog + + dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date) + dlg.openDateRequested.connect(self._load_selected_date) + dlg.exec() # ----------- Settings handler ------------# def _open_settings(self): diff --git a/bouquin/status_tags_widget.py b/bouquin/status_tags_widget.py deleted file mode 100644 index 348c123..0000000 --- a/bouquin/status_tags_widget.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QWidget, - QHBoxLayout, - QLabel, - QSizePolicy, -) - -from .flow_layout import FlowLayout -from .db import DBManager -from .tags_widget import TagChip # reuse, or make a smaller variant if you prefer - - -class StatusBarTagsWidget(QWidget): - tagActivated = Signal(str) # tag name - - def __init__(self, db: DBManager, parent=None): - super().__init__(parent) - self._db = db - self._current_date_iso: str | None = None - - outer = QHBoxLayout(self) - outer.setContentsMargins(4, 0, 4, 0) - outer.setSpacing(4) - - label = QLabel("Tags:") - outer.addWidget(label) - - self.flow = FlowLayout(self, hspacing=2, vspacing=2) - outer.addLayout(self.flow) - - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - - def set_current_page(self, date_iso: str): - self._current_date_iso = date_iso - self._reload() - - def _clear(self): - while self.flow.count(): - item = self.flow.takeAt(0) - w = item.widget() - if w is not None: - w.deleteLater() - - def _reload(self): - self._clear() - if not self._current_date_iso: - return - - tags = self._db.get_tags_for_page(self._current_date_iso) - # Keep it small; maybe only first N tags: - MAX_TAGS = 6 - for i, (tid, name, color) in enumerate(tags): - if i >= MAX_TAGS: - more = QLabel("…") - self.flow.addWidget(more) - break - chip = TagChip(tid, name, color, self) - chip.clicked.connect(self.tagActivated) - # In status bar you might want a smaller style: adjust chip's stylesheet if needed. - self.flow.addWidget(chip) diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index 3dc50e0..a400c83 100644 --- a/bouquin/tag_browser.py +++ b/bouquin/tag_browser.py @@ -1,11 +1,16 @@ # tag_browser.py from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QDialog, QVBoxLayout, + QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton, + QLabel, + QColorDialog, + QMessageBox, ) from .db import DBManager @@ -18,33 +23,86 @@ class TagBrowserDialog(QDialog): def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None): super().__init__(parent) self._db = db - self.setWindowTitle(strings._("tag_browser_title")) + self.setWindowTitle( + strings._("tag_browser_title") + " / " + strings._("manage_tags") + ) + self.resize(600, 500) layout = QVBoxLayout(self) + + # Instructions + instructions = QLabel(strings._("tag_browser_instructions")) + instructions.setWordWrap(True) + layout.addWidget(instructions) + self.tree = QTreeWidget() - self.tree.setHeaderLabels([strings._("tag"), strings._("date")]) + self.tree.setHeaderLabels( + [strings._("tag"), strings._("color_hex"), strings._("date")] + ) + self.tree.setColumnWidth(0, 200) + self.tree.setColumnWidth(1, 100) self.tree.itemActivated.connect(self._on_item_activated) + self.tree.itemClicked.connect(self._on_item_clicked) layout.addWidget(self.tree) + # Tag management buttons + btn_row = QHBoxLayout() + + self.edit_name_btn = QPushButton(strings._("edit_tag_name")) + self.edit_name_btn.clicked.connect(self._edit_tag_name) + self.edit_name_btn.setEnabled(False) + btn_row.addWidget(self.edit_name_btn) + + self.change_color_btn = QPushButton(strings._("change_color")) + self.change_color_btn.clicked.connect(self._change_tag_color) + self.change_color_btn.setEnabled(False) + btn_row.addWidget(self.change_color_btn) + + self.delete_btn = QPushButton(strings._("delete_tag")) + self.delete_btn.clicked.connect(self._delete_tag) + self.delete_btn.setEnabled(False) + btn_row.addWidget(self.delete_btn) + + btn_row.addStretch(1) + layout.addLayout(btn_row) + + # Close button + close_row = QHBoxLayout() + close_row.addStretch(1) close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) - layout.addWidget(close_btn) + close_row.addWidget(close_btn) + layout.addLayout(close_row) self._populate(focus_tag) def _populate(self, focus_tag: str | None): + self.tree.clear() tags = self._db.list_tags() focus_item = None + for tag_id, name, color in tags: - root = QTreeWidgetItem([name, ""]) - # coloured background or icon: - root.setData(0, Qt.ItemDataRole.UserRole, name) + # Create the tree item + root = QTreeWidgetItem([name, "", ""]) + root.setData( + 0, + Qt.ItemDataRole.UserRole, + {"type": "tag", "id": tag_id, "name": name, "color": color}, + ) + + # Set background color for the second column to show the tag color + root.setBackground(1, QColor(color)) + root.setText(1, color) # Also show the hex code + root.setTextAlignment(1, Qt.AlignCenter) + self.tree.addTopLevelItem(root) pages = self._db.get_pages_for_tag(name) for date_iso, _content in pages: - child = QTreeWidgetItem(["", date_iso]) - child.setData(0, Qt.ItemDataRole.UserRole, date_iso) + child = QTreeWidgetItem(["", "", date_iso]) + child.setData( + 0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso} + ) root.addChild(child) if focus_tag and name.lower() == focus_tag.lower(): @@ -54,7 +112,94 @@ class TagBrowserDialog(QDialog): self.tree.expandItem(focus_item) self.tree.setCurrentItem(focus_item) + def _on_item_clicked(self, item: QTreeWidgetItem, column: int): + """Enable/disable buttons based on selection""" + data = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(data, dict): + if data.get("type") == "tag": + self.edit_name_btn.setEnabled(True) + self.change_color_btn.setEnabled(True) + self.delete_btn.setEnabled(True) + else: + self.edit_name_btn.setEnabled(False) + self.change_color_btn.setEnabled(False) + self.delete_btn.setEnabled(False) + def _on_item_activated(self, item: QTreeWidgetItem, column: int): - date_iso = item.data(0, Qt.ItemDataRole.UserRole) - if isinstance(date_iso, str) and date_iso: - self.openDateRequested.emit(date_iso) + data = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(data, dict): + if data.get("type") == "page": + date_iso = data.get("date") + if date_iso: + self.openDateRequested.emit(date_iso) + self.accept() + + def _edit_tag_name(self): + """Edit the name of the selected tag""" + item = self.tree.currentItem() + if not item: + return + + data = item.data(0, Qt.ItemDataRole.UserRole) + if not isinstance(data, dict) or data.get("type") != "tag": + return + + tag_id = data["id"] + old_name = data["name"] + color = data["color"] + + # Simple input dialog + from PySide6.QtWidgets import QInputDialog + + new_name, ok = QInputDialog.getText( + self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name + ) + + if ok and new_name and new_name != old_name: + self._db.update_tag(tag_id, new_name, color) + self._populate(None) + + def _change_tag_color(self): + """Change the color of the selected tag""" + item = self.tree.currentItem() + if not item: + return + + data = item.data(0, Qt.ItemDataRole.UserRole) + if not isinstance(data, dict) or data.get("type") != "tag": + return + + tag_id = data["id"] + name = data["name"] + current_color = data["color"] + + color = QColorDialog.getColor(QColor(current_color), self) + if color.isValid(): + self._db.update_tag(tag_id, name, color.name()) + self._populate(None) + + def _delete_tag(self): + """Delete the selected tag""" + item = self.tree.currentItem() + if not item: + return + + data = item.data(0, Qt.ItemDataRole.UserRole) + if not isinstance(data, dict) or data.get("type") != "tag": + return + + tag_id = data["id"] + name = data["name"] + + # Confirm deletion + reply = QMessageBox.question( + self, + strings._("delete_tag"), + strings._("delete_tag_confirm").format(name=name), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply == QMessageBox.Yes: + self._db.delete_tag(tag_id) + self._populate(None) diff --git a/bouquin/tags_dialog.py b/bouquin/tags_dialog.py deleted file mode 100644 index 21c8863..0000000 --- a/bouquin/tags_dialog.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QTableWidget, - QTableWidgetItem, - QPushButton, - QColorDialog, - QMessageBox, -) - -from . import strings -from .db import DBManager - - -class TagManagerDialog(QDialog): - def __init__(self, db: DBManager, parent=None): - super().__init__(parent) - self._db = db - self.setWindowTitle(strings._("manage_tags")) - - layout = QVBoxLayout(self) - - self.table = QTableWidget(0, 2, self) - self.table.setHorizontalHeaderLabels( - [strings._("tag_name"), strings._("tag_color_hex")] - ) - self.table.horizontalHeader().setStretchLastSection(True) - layout.addWidget(self.table) - - btn_row = QHBoxLayout() - self.add_btn = QPushButton(strings._("add")) - self.remove_btn = QPushButton(strings._("remove")) - self.color_btn = QPushButton(strings._("pick_color")) - btn_row.addWidget(self.add_btn) - btn_row.addWidget(self.remove_btn) - btn_row.addWidget(self.color_btn) - btn_row.addStretch(1) - layout.addLayout(btn_row) - - action_row = QHBoxLayout() - ok_btn = QPushButton(strings._("ok")) - close_btn = QPushButton(strings._("close")) - ok_btn.clicked.connect(self.accept) - close_btn.clicked.connect(self.reject) - action_row.addStretch(1) - action_row.addWidget(ok_btn) - action_row.addWidget(close_btn) - layout.addLayout(action_row) - - self.add_btn.clicked.connect(self._add_row) - self.remove_btn.clicked.connect(self._remove_selected) - self.color_btn.clicked.connect(self._pick_color) - - self._load_tags() - - def _load_tags(self) -> None: - self.table.setRowCount(0) - for tag_id, name, color in self._db.list_tags(): - row = self.table.rowCount() - self.table.insertRow(row) - - name_item = QTableWidgetItem(name) - name_item.setData(Qt.ItemDataRole.UserRole, tag_id) - self.table.setItem(row, 0, name_item) - - color_item = QTableWidgetItem(color) - color_item.setBackground(Qt.GlobalColor.transparent) - self.table.setItem(row, 1, color_item) - - def _add_row(self) -> None: - row = self.table.rowCount() - self.table.insertRow(row) - name_item = QTableWidgetItem("") - name_item.setData(Qt.ItemDataRole.UserRole, 0) # 0 => new tag - self.table.setItem(row, 0, name_item) - self.table.setItem(row, 1, QTableWidgetItem("#CCCCCC")) - - def _remove_selected(self) -> None: - row = self.table.currentRow() - if row < 0: - return - item = self.table.item(row, 0) - tag_id = item.data(Qt.ItemDataRole.UserRole) - if tag_id: - self._db.delete_tag(int(tag_id)) - self.table.removeRow(row) - - def _pick_color(self) -> None: - row = self.table.currentRow() - if row < 0: - return - item = self.table.item(row, 1) - current = item.text() or "#CCCCCC" - color = QColorDialog.getColor() - if color.isValid(): - item.setText(color.name()) - - def accept(self) -> None: - # Persist all rows back to DB - for row in range(self.table.rowCount()): - name_item = self.table.item(row, 0) - color_item = self.table.item(row, 1) - if name_item is None or color_item is None: - continue - - name = name_item.text().strip() - color = color_item.text().strip() or "#CCCCCC" - tag_id = int(name_item.data(Qt.ItemDataRole.UserRole) or 0) - - if not name: - continue # ignore empty rows - - if not color.startswith("#") or len(color) not in (4, 7): - QMessageBox.warning( - self, - strings._("invalid_color_title"), - strings._("invalid_color_message"), - ) - return # keep dialog open - - if tag_id == 0: - # new tag: just rely on set_tags_for_page/create, or you can - # insert here if you like. Easiest is to create via DBManager: - # use a dummy page or do a direct insert - self._db.set_tags_for_page("__dummy__", [name]) - # then delete the dummy row; or instead provide a DBManager - # helper to create a tag explicitly. - else: - self._db.update_tag(tag_id, name, color) - - super().accept() diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py index e2a70d0..0e50446 100644 --- a/bouquin/tags_widget.py +++ b/bouquin/tags_widget.py @@ -13,6 +13,7 @@ from PySide6.QtWidgets import ( QLineEdit, QSizePolicy, QStyle, + QCompleter, ) from . import strings @@ -25,7 +26,12 @@ class TagChip(QFrame): clicked = Signal(str) # tag name def __init__( - self, tag_id: int, name: str, color: str, parent: QWidget | None = None + self, + tag_id: int, + name: str, + color: str, + parent: QWidget | None = None, + show_remove: bool = True, ): super().__init__(parent) self._id = tag_id @@ -37,26 +43,26 @@ class TagChip(QFrame): self.setFrameShadow(QFrame.Raised) layout = QHBoxLayout(self) - layout.setContentsMargins(4, 0, 4, 0) + layout.setContentsMargins(4, 2, 4, 2) layout.setSpacing(4) color_lbl = QLabel() color_lbl.setFixedSize(10, 10) - color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 3px;") + color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;") layout.addWidget(color_lbl) name_lbl = QLabel(name) layout.addWidget(name_lbl) - btn = QToolButton() - btn.setText("×") - btn.setAutoRaise(True) - btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) + if show_remove: + btn = QToolButton() + btn.setText("×") + btn.setAutoRaise(True) + btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) + layout.addWidget(btn) self.setCursor(Qt.PointingHandCursor) - layout.addWidget(btn) - @property def tag_id(self) -> int: return self._id @@ -70,8 +76,12 @@ class TagChip(QFrame): class PageTagsWidget(QFrame): """ Collapsible per-page tag editor shown in the left sidebar. + Now displays tag chips even when collapsed. """ + tagActivated = Signal(str) # tag name + tagAdded = Signal() # emitted when a tag is added to trigger autosave + def __init__(self, db: DBManager, parent: QWidget | None = None): super().__init__(parent) self._db = db @@ -103,21 +113,25 @@ class PageTagsWidget(QFrame): header.addStretch(1) header.addWidget(self.manage_btn) - # Body (chips + add line) + # Body (chips + add line - only visible when expanded) self.body = QWidget() self.body_layout = QVBoxLayout(self.body) self.body_layout.setContentsMargins(0, 4, 0, 0) self.body_layout.setSpacing(4) - # Simple horizontal layout for now; you can swap for a FlowLayout - self.chip_row = FlowLayout(self.body, hspacing=4, vspacing=4) - self.body_layout.addLayout(self.chip_row) + # Chips container + self.chip_container = QWidget() + self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4) + self.body_layout.addWidget(self.chip_container) self.add_edit = QLineEdit() self.add_edit.setPlaceholderText(strings._("add_tag_placeholder")) self.add_edit.returnPressed.connect(self._on_add_tag) - self.body_layout.addWidget(self.add_edit) + # Setup autocomplete + self._setup_autocomplete() + + self.body_layout.addWidget(self.add_edit) self.body.setVisible(False) main = QVBoxLayout(self) @@ -129,23 +143,33 @@ class PageTagsWidget(QFrame): def set_current_date(self, date_iso: str) -> None: self._current_date = date_iso + # Only reload tags if expanded if self.toggle_btn.isChecked(): self._reload_tags() else: - # Keep it cheap while collapsed; reload only when expanded - self._clear_chips() + self._clear_chips() # Clear chips when collapsed + self._setup_autocomplete() # Update autocomplete with all available tags # ----- internals --------------------------------------------------- + def _setup_autocomplete(self) -> None: + """Setup autocomplete for the tag input with all existing tags""" + all_tags = [name for _, name, _ in self._db.list_tags()] + completer = QCompleter(all_tags, self.add_edit) + completer.setCaseSensitivity(Qt.CaseInsensitive) + self.add_edit.setCompleter(completer) + def _on_toggle(self, checked: bool) -> None: self.body.setVisible(checked) self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) - if checked and self._current_date: - self._reload_tags() + if checked: + if self._current_date: + self._reload_tags() + self.add_edit.setFocus() def _clear_chips(self) -> None: - while self.chip_row.count(): - item = self.chip_row.takeAt(0) + while self.chip_layout.count(): + item = self.chip_layout.takeAt(0) w = item.widget() if w is not None: w.deleteLater() @@ -158,26 +182,56 @@ class PageTagsWidget(QFrame): self._clear_chips() tags = self._db.get_tags_for_page(self._current_date) for tag_id, name, color in tags: - chip = TagChip(tag_id, name, color, self) + # Always show remove button since chips only visible when expanded + chip = TagChip(tag_id, name, color, self, show_remove=True) chip.removeRequested.connect(self._remove_tag) chip.clicked.connect(self._on_chip_clicked) - self.chip_row.addWidget(chip) + self.chip_layout.addWidget(chip) + chip.show() + chip.adjustSize() + + # Force complete layout recalculation + self.chip_layout.invalidate() + self.chip_layout.activate() + self.chip_container.updateGeometry() + self.updateGeometry() + # Process pending events to ensure layout is applied + from PySide6.QtCore import QCoreApplication + + QCoreApplication.processEvents() def _on_add_tag(self) -> None: if not self._current_date: return + + # If the completer popup is visible and user pressed Enter, + # the completer will handle it - don't process it again + if self.add_edit.completer() and self.add_edit.completer().popup().isVisible(): + return + new_tag = self.add_edit.text().strip() if not new_tag: return - # Combine current tags + new one, then write back + # Get existing tags for current page existing = [ name for _, name, _ in self._db.get_tags_for_page(self._current_date) ] + + # Check for duplicates (case-insensitive) + if any(tag.lower() == new_tag.lower() for tag in existing): + self.add_edit.clear() + return + existing.append(new_tag) self._db.set_tags_for_page(self._current_date, existing) + self.add_edit.clear() self._reload_tags() + self._setup_autocomplete() # Update autocomplete list + + # Signal that a tag was added so main window can trigger autosave + self.tagAdded.emit() def _remove_tag(self, tag_id: int) -> None: if not self._current_date: @@ -188,13 +242,15 @@ class PageTagsWidget(QFrame): self._reload_tags() def _open_manager(self) -> None: - from .tags_dialog import TagManagerDialog + from .tag_browser import TagBrowserDialog - dlg = TagManagerDialog(self._db, self) + dlg = TagBrowserDialog(self._db, self) + dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso)) if dlg.exec(): - # Names/colours may have changed + # Reload tags after manager closes to pick up any changes if self._current_date: self._reload_tags() + self._setup_autocomplete() def _on_chip_clicked(self, name: str) -> None: self.tagActivated.emit(name) diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..6e2ce74 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,781 @@ +from bouquin.db import DBManager +from bouquin.tags_widget import PageTagsWidget, TagChip +from bouquin.tag_browser import TagBrowserDialog + + +# ============================================================================ +# DB Layer Tag Tests +# ============================================================================ + + +def test_set_tags_for_page_creates_tags(fresh_db): + """Test that setting tags for a page creates the tags in the database""" + date_iso = "2024-01-15" + tags = ["work", "important", "meeting"] + + fresh_db.set_tags_for_page(date_iso, tags) + + # Verify tags were created + all_tags = fresh_db.list_tags() + tag_names = [name for _, name, _ in all_tags] + + assert "work" in tag_names + assert "important" in tag_names + assert "meeting" in tag_names + + +def test_get_tags_for_page(fresh_db): + """Test retrieving tags for a specific page""" + date_iso = "2024-01-15" + tags = ["work", "important"] + + fresh_db.set_tags_for_page(date_iso, tags) + retrieved_tags = fresh_db.get_tags_for_page(date_iso) + + assert len(retrieved_tags) == 2 + tag_names = [name for _, name, _ in retrieved_tags] + assert "work" in tag_names + assert "important" in tag_names + + +def test_tags_have_colors(fresh_db): + """Test that created tags have default colors assigned""" + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["test"]) + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + tag_id, name, color = tags[0] + + assert color.startswith("#") + assert len(color) in (4, 7) # #RGB or #RRGGBB + + +def test_set_tags_replaces_existing(fresh_db): + """Test that setting tags replaces the existing tag set""" + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["old1", "old2"]) + fresh_db.set_tags_for_page(date_iso, ["new1", "new2"]) + + tags = fresh_db.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + + assert "new1" in tag_names + assert "new2" in tag_names + assert "old1" not in tag_names + assert "old2" not in tag_names + + +def test_set_tags_empty_clears_tags(fresh_db): + """Test that setting empty tag list clears all tags for page""" + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + fresh_db.set_tags_for_page(date_iso, []) + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 0 + + +def test_tags_case_insensitive_deduplication(fresh_db): + """Test that tags are deduplicated case-insensitively""" + date_iso = "2024-01-15" + + # Try to set tags with different cases + fresh_db.set_tags_for_page(date_iso, ["Work", "work", "WORK"]) + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + + +def test_tags_case_insensitive_reuse(fresh_db): + """Test that existing tags are reused regardless of case""" + date1 = "2024-01-15" + date2 = "2024-01-16" + + # Create tag with lowercase + fresh_db.set_tags_for_page(date1, ["work"]) + + # Try to add same tag with different case on different page + fresh_db.set_tags_for_page(date2, ["Work"]) + + # Should reuse the existing tag + all_tags = fresh_db.list_tags() + work_tags = [t for t in all_tags if t[1].lower() == "work"] + assert len(work_tags) == 1 # Only one "work" tag should exist + + +def test_tags_whitespace_normalization(fresh_db): + """Test that tags are trimmed and empty strings ignored""" + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, [" work ", "", " ", "meeting"]) + + tags = fresh_db.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + + assert "work" in tag_names + assert "meeting" in tag_names + assert len(tags) == 2 # Empty strings should be filtered + + +def test_list_all_tags(fresh_db): + """Test listing all tags in the database""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + fresh_db.set_tags_for_page("2024-01-16", ["tag2", "tag3"]) + + all_tags = fresh_db.list_tags() + tag_names = [name for _, name, _ in all_tags] + + assert len(all_tags) == 3 + assert "tag1" in tag_names + assert "tag2" in tag_names + assert "tag3" in tag_names + + +def test_update_tag_name_and_color(fresh_db): + """Test updating a tag's name and color""" + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["oldname"]) + + tags = fresh_db.list_tags() + tag_id = tags[0][0] + + fresh_db.update_tag(tag_id, "newname", "#FF0000") + + updated_tags = fresh_db.list_tags() + assert len(updated_tags) == 1 + assert updated_tags[0][1] == "newname" + assert updated_tags[0][2] == "#FF0000" + + +def test_delete_tag(fresh_db): + """Test deleting a tag removes it globally""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + fresh_db.set_tags_for_page("2024-01-16", ["tag1"]) + + tags = fresh_db.list_tags() + tag1_id = [tid for tid, name, _ in tags if name == "tag1"][0] + + fresh_db.delete_tag(tag1_id) + + # Tag should be removed from all pages + tags_page1 = fresh_db.get_tags_for_page("2024-01-15") + tags_page2 = fresh_db.get_tags_for_page("2024-01-16") + + assert len(tags_page1) == 1 + assert tags_page1[0][1] == "tag2" + assert len(tags_page2) == 0 + + +def test_get_pages_for_tag(fresh_db): + """Test retrieving all pages that have a specific tag""" + fresh_db.save_new_version("2024-01-15", "Content 1", "note") + fresh_db.save_new_version("2024-01-16", "Content 2", "note") + fresh_db.save_new_version("2024-01-17", "Content 3", "note") + + fresh_db.set_tags_for_page("2024-01-15", ["work"]) + fresh_db.set_tags_for_page("2024-01-16", ["work", "meeting"]) + fresh_db.set_tags_for_page("2024-01-17", ["personal"]) + + pages = fresh_db.get_pages_for_tag("work") + dates = [date_iso for date_iso, _ in pages] + + assert "2024-01-15" in dates + assert "2024-01-16" in dates + assert "2024-01-17" not in dates + + +def test_tags_persist_across_reconnect(fresh_db, tmp_db_cfg): + """Test that tags persist when database is closed and reopened""" + date_iso = "2024-01-15" + fresh_db.set_tags_for_page(date_iso, ["persistent", "tag"]) + fresh_db.close() + + # Reopen database + db2 = DBManager(tmp_db_cfg) + assert db2.connect() + + tags = db2.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + + assert "persistent" in tag_names + assert "tag" in tag_names + db2.close() + + +# ============================================================================ +# PageTagsWidget Tests +# ============================================================================ + + +def test_page_tags_widget_creation(app, fresh_db): + """Test that PageTagsWidget can be created""" + widget = PageTagsWidget(fresh_db) + assert widget is not None + assert widget._db == fresh_db + + +def test_page_tags_widget_set_current_date(app, fresh_db): + """Test setting the current date on the widget""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["test"]) + widget.set_current_date(date_iso) + + assert widget._current_date == date_iso + + +def test_page_tags_widget_hidden_when_collapsed(app, fresh_db): + """Test that tag chips are hidden when widget is collapsed""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + widget.set_current_date(date_iso) + + # Body should be hidden when collapsed + assert not widget.body.isVisible() + assert not widget.toggle_btn.isChecked() + + +def test_page_tags_widget_shows_tags_when_expanded(app, fresh_db): + """Test that tags are shown when widget is expanded""" + widget = PageTagsWidget(fresh_db) + widget.show() # Widget needs to be shown for visibility to work + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + widget.set_current_date(date_iso) + + # Expand the widget + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + assert widget.body.isVisible() + assert widget.chip_layout.count() == 2 + + +def test_page_tags_widget_add_tag(app, fresh_db): + """Test adding a tag through the widget""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Simulate adding a tag + widget.add_edit.setText("newtag") + widget._on_add_tag() + + # Verify tag was added + tags = fresh_db.get_tags_for_page(date_iso) + tag_names = [name for _, name, _ in tags] + assert "newtag" in tag_names + + +def test_page_tags_widget_prevents_duplicates(app, fresh_db): + """Test that duplicate tags are prevented""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["existing"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Try to add the same tag + widget.add_edit.setText("existing") + widget._on_add_tag() + + # Should still only have one tag + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + + +def test_page_tags_widget_case_insensitive_duplicates(app, fresh_db): + """Test that duplicate checking is case-insensitive""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["Test"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Try to add with different case + widget.add_edit.setText("test") + widget._on_add_tag() + + # Should still only have one tag + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + + +def test_page_tags_widget_remove_tag(app, fresh_db): + """Test removing a tag through the widget""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Get the first tag's ID + tags = fresh_db.get_tags_for_page(date_iso) + tag_id = tags[0][0] + + # Remove it + widget._remove_tag(tag_id) + + # Verify tag was removed + remaining_tags = fresh_db.get_tags_for_page(date_iso) + assert len(remaining_tags) == 1 + + +def test_page_tags_widget_empty_input_ignored(app, fresh_db): + """Test that empty tag input is ignored""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Try to add empty tag + widget.add_edit.setText("") + widget._on_add_tag() + + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 0 + + +def test_page_tags_widget_whitespace_trimmed(app, fresh_db): + """Test that whitespace is trimmed from tags""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Add tag with whitespace + widget.add_edit.setText(" spaced ") + widget._on_add_tag() + + tags = fresh_db.get_tags_for_page(date_iso) + assert tags[0][1] == "spaced" + + +def test_page_tags_widget_autocomplete_setup(app, fresh_db): + """Test that autocomplete is set up with existing tags""" + widget = PageTagsWidget(fresh_db) + + # Create some tags + fresh_db.set_tags_for_page("2024-01-15", ["alpha", "beta", "gamma"]) + + # Setup autocomplete + widget._setup_autocomplete() + + completer = widget.add_edit.completer() + assert completer is not None + + # Check that model contains the tags + model = completer.model() + items = [model.index(i, 0).data() for i in range(model.rowCount())] + assert "alpha" in items + assert "beta" in items + assert "gamma" in items + + +def test_page_tags_widget_signal_tag_added(app, fresh_db): + """Test that tagAdded signal is emitted when tag is added""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + widget.set_current_date(date_iso) + + signal_emitted = {"emitted": False} + + def on_tag_added(): + signal_emitted["emitted"] = True + + widget.tagAdded.connect(on_tag_added) + + widget.add_edit.setText("testtag") + widget._on_add_tag() + + assert signal_emitted["emitted"] + + +def test_page_tags_widget_signal_tag_activated(app, fresh_db): + """Test that tagActivated signal is emitted when tag is clicked""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["clickable"]) + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + signal_data = {"tag_name": None} + + def on_tag_activated(name): + signal_data["tag_name"] = name + + widget.tagActivated.connect(on_tag_activated) + widget._on_chip_clicked("clickable") + + assert signal_data["tag_name"] == "clickable" + + +# ============================================================================ +# TagChip Tests +# ============================================================================ + + +def test_tag_chip_creation(app): + """Test that TagChip can be created""" + chip = TagChip(1, "test", "#FF0000") + assert chip is not None + assert chip.tag_id == 1 + + +def test_tag_chip_with_remove_button(app): + """Test TagChip with remove button""" + chip = TagChip(1, "test", "#FF0000", show_remove=True) + + # Find the remove button (should be a QToolButton with text "×") + buttons = chip.findChildren(object) + assert any(hasattr(b, "text") and b.text() == "×" for b in buttons) + + +def test_tag_chip_without_remove_button(app): + """Test TagChip without remove button""" + chip = TagChip(1, "test", "#FF0000", show_remove=False) + + # Should not have remove button + buttons = chip.findChildren(object) + assert not any(hasattr(b, "text") and b.text() == "×" for b in buttons) + + +def test_tag_chip_color_display(app): + """Test that TagChip displays the correct color""" + chip = TagChip(1, "test", "#FF0000") + + # Find the color label + labels = chip.findChildren(object) + color_labels = [ + l + for l in labels + if hasattr(l, "styleSheet") and "background-color" in str(l.styleSheet()) + ] + + assert len(color_labels) > 0 + assert ( + "#FF0000" in color_labels[0].styleSheet() + or "rgb(255, 0, 0)" in color_labels[0].styleSheet() + ) + + +def test_tag_chip_remove_signal(app): + """Test that TagChip emits removeRequested signal""" + chip = TagChip(1, "test", "#FF0000", show_remove=True) + + signal_data = {"tag_id": None} + + def on_remove(tag_id): + signal_data["tag_id"] = tag_id + + chip.removeRequested.connect(on_remove) + chip.removeRequested.emit(1) + + assert signal_data["tag_id"] == 1 + + +def test_tag_chip_clicked_signal(app): + """Test that TagChip emits clicked signal""" + chip = TagChip(1, "test", "#FF0000") + + signal_data = {"tag_name": None} + + def on_clicked(name): + signal_data["tag_name"] = name + + chip.clicked.connect(on_clicked) + chip.clicked.emit("test") + + assert signal_data["tag_name"] == "test" + + +# ============================================================================ +# TagBrowserDialog Tests +# ============================================================================ + + +def test_tag_browser_creation(app, fresh_db): + """Test that TagBrowserDialog can be created""" + dialog = TagBrowserDialog(fresh_db) + assert dialog is not None + + +def test_tag_browser_displays_tags(app, fresh_db): + """Test that TagBrowserDialog displays tags in tree""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + fresh_db.save_new_version("2024-01-15", "Content", "note") + + dialog = TagBrowserDialog(fresh_db) + + # Tree should have top-level items for each tag + assert dialog.tree.topLevelItemCount() == 2 + + +def test_tag_browser_tag_with_pages(app, fresh_db): + """Test that TagBrowserDialog shows pages under tags""" + fresh_db.save_new_version("2024-01-15", "Content 1", "note") + fresh_db.save_new_version("2024-01-16", "Content 2", "note") + fresh_db.set_tags_for_page("2024-01-15", ["work"]) + fresh_db.set_tags_for_page("2024-01-16", ["work"]) + + dialog = TagBrowserDialog(fresh_db) + + # Find the "work" tag item + root = dialog.tree.topLevelItem(0) + + # Should have 2 child items (the dates) + assert root.childCount() == 2 + + +def test_tag_browser_focus_tag(app, fresh_db): + """Test that TagBrowserDialog can focus on a specific tag""" + fresh_db.set_tags_for_page("2024-01-15", ["alpha", "beta"]) + + dialog = TagBrowserDialog(fresh_db, focus_tag="beta") + + # The focused tag should be expanded and selected + current_item = dialog.tree.currentItem() + assert current_item is not None + + +def test_tag_browser_color_display(app, fresh_db): + """Test that tags display their colors in the browser""" + fresh_db.set_tags_for_page("2024-01-15", ["colorful"]) + + dialog = TagBrowserDialog(fresh_db) + + root = dialog.tree.topLevelItem(0) + # Color should be shown in column 1 + color_text = root.text(1) + assert color_text.startswith("#") + + +def test_tag_browser_edit_name_button_disabled(app, fresh_db): + """Test that edit button is disabled when no tag selected""" + dialog = TagBrowserDialog(fresh_db) + + assert not dialog.edit_name_btn.isEnabled() + + +def test_tag_browser_edit_name_button_enabled(app, fresh_db): + """Test that edit button is enabled when tag selected""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Select the first item + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + assert dialog.edit_name_btn.isEnabled() + + +def test_tag_browser_delete_button_state(app, fresh_db): + """Test that delete button state changes with selection""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Initially disabled + assert not dialog.delete_btn.isEnabled() + + # Select a tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Should be enabled now + assert dialog.delete_btn.isEnabled() + + +def test_tag_browser_change_color_button_state(app, fresh_db): + """Test that change color button state changes with selection""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Initially disabled + assert not dialog.change_color_btn.isEnabled() + + # Select a tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Should be enabled now + assert dialog.change_color_btn.isEnabled() + + +def test_tag_browser_open_date_signal(app, fresh_db): + """Test that clicking a date emits openDateRequested signal""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + signal_data = {"date": None} + + def on_date_requested(date_iso): + signal_data["date"] = date_iso + + dialog.openDateRequested.connect(on_date_requested) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Get the date child item + date_item = root.child(0) + + # Simulate activation + dialog._on_item_activated(date_item, 0) + + assert signal_data["date"] == "2024-01-15" + + +# ============================================================================ +# Integration Tests +# ============================================================================ + + +def test_tag_workflow_end_to_end(app, fresh_db, tmp_path): + """Test complete tag workflow: create, list, update, delete""" + # Create some entries with tags + dates = ["2024-01-15", "2024-01-16", "2024-01-17"] + for date in dates: + fresh_db.save_new_version(date, f"Entry {date}", "note") + + fresh_db.set_tags_for_page(dates[0], ["work", "urgent"]) + fresh_db.set_tags_for_page(dates[1], ["work", "meeting"]) + fresh_db.set_tags_for_page(dates[2], ["personal"]) + + # List all tags - should be 4 unique tags: work, urgent, meeting, personal + all_tags = fresh_db.list_tags() + assert len(all_tags) == 4 + + # Get pages for "work" tag + work_pages = fresh_db.get_pages_for_tag("work") + work_dates = [d for d, _ in work_pages] + assert dates[0] in work_dates + assert dates[1] in work_dates + assert dates[2] not in work_dates + + # Update a tag + work_tag = [t for t in all_tags if t[1] == "work"][0] + fresh_db.update_tag(work_tag[0], "office", "#0000FF") + + # Verify update + updated_tags = fresh_db.list_tags() + office_tag = [t for t in updated_tags if t[1] == "office"][0] + assert office_tag[2] == "#0000FF" + + # Delete a tag + urgent_tag = [t for t in all_tags if t[1] == "urgent"][0] + fresh_db.delete_tag(urgent_tag[0]) + + # Verify deletion - should have 3 tags now (office, meeting, personal) + final_tags = fresh_db.list_tags() + tag_names = [t[1] for t in final_tags] + assert "urgent" not in tag_names + assert len(final_tags) == 3 + + +def test_tags_with_export(fresh_db, tmp_path): + """Test that tags are preserved during export operations""" + date_iso = "2024-01-15" + fresh_db.save_new_version(date_iso, "Content", "note") + fresh_db.set_tags_for_page(date_iso, ["exported", "preserved"]) + + # Export to JSON + entries = fresh_db.get_all_entries() + json_path = tmp_path / "export.json" + fresh_db.export_json(entries, str(json_path)) + + assert json_path.exists() + + # Tags should still be in database + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 2 + + +def test_tags_survive_rekey(fresh_db, tmp_db_cfg): + """Test that tags persist after database rekey""" + date_iso = "2024-01-15" + fresh_db.save_new_version(date_iso, "Content", "note") + fresh_db.set_tags_for_page(date_iso, ["persistent"]) + + # Rekey the database + fresh_db.rekey("new-key-456") + fresh_db.close() + + # Reopen with new key + tmp_db_cfg.key = "new-key-456" + db2 = DBManager(tmp_db_cfg) + assert db2.connect() + + # Tags should still be there + tags = db2.get_tags_for_page(date_iso) + assert len(tags) == 1 + assert tags[0][1] == "persistent" + db2.close() + + +def test_multiple_pages_same_tags(fresh_db): + """Test multiple pages can share the same tags""" + dates = ["2024-01-15", "2024-01-16", "2024-01-17"] + + for date in dates: + fresh_db.save_new_version(date, f"Content {date}", "note") + fresh_db.set_tags_for_page(date, ["shared", "tag"]) + + # All pages should have the tags + for date in dates: + tags = fresh_db.get_tags_for_page(date) + tag_names = [name for _, name, _ in tags] + assert "shared" in tag_names + assert "tag" in tag_names + + # But there should only be 2 unique tags in the database + all_tags = fresh_db.list_tags() + assert len(all_tags) == 2 + + +def test_tag_page_without_content(fresh_db): + """Test that pages can have tags even without content""" + date_iso = "2024-01-15" + + # Set tags without saving any content + fresh_db.set_tags_for_page(date_iso, ["tagonly"]) + + # Tags should be retrievable + tags = fresh_db.get_tags_for_page(date_iso) + assert len(tags) == 1 + assert tags[0][1] == "tagonly" + + # Page should be created but with no content + content = fresh_db.get_entry(date_iso) + assert content is None or content == "" From 02a60ca6569c0aa4fe2fe2007ec7a0621f7d058a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 14 Nov 2025 16:16:27 +1100 Subject: [PATCH 4/5] Fix tests, add vulture_ignorelist.py, fix markdown_editor highlighter bug --- .forgejo/workflows/lint.yml | 3 +- bouquin/markdown_editor.py | 9 +- pyproject.toml | 3 + tests/test_find_bar.py | 2 - tests/test_lock_overlay.py | 2 - tests/test_main_window.py | 348 +++++++++++- tests/test_markdown_editor.py | 941 +++++++++++++++++++++++++++++++- tests/test_search.py | 3 - tests/test_settings_dialog.py | 4 - tests/test_tabs.py | 6 - tests/test_tags.py | 992 ++++++++++++++++++++++++++++++++++ tests/test_theme.py | 2 - tests/test_toolbar.py | 1 - vulture_ignorelist.py | 22 + 14 files changed, 2277 insertions(+), 61 deletions(-) create mode 100644 vulture_ignorelist.py diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml index 689beb1..53ab3eb 100644 --- a/.forgejo/workflows/lint.yml +++ b/.forgejo/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - black pyflakes3 + black pyflakes3 vulture - name: Run linters run: | @@ -23,3 +23,4 @@ jobs: black --diff --check tests/* pyflakes3 bouquin/* pyflakes3 tests/* + vulture diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index aaddb42..5415509 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -73,9 +73,10 @@ class MarkdownEditor(QTextEdit): def setDocument(self, doc): super().setDocument(doc) - # reattach the highlighter to the new document - if hasattr(self, "highlighter") and self.highlighter: - self.highlighter.setDocument(self.document()) + # Recreate the highlighter for the new document + # (the old one gets deleted with the old document) + if hasattr(self, "highlighter") and hasattr(self, "theme_manager"): + self.highlighter = MarkdownHighlighter(self.document(), self.theme_manager) self._apply_line_spacing() self._apply_code_block_spacing() QTimer.singleShot(0, self._update_code_block_row_backgrounds) @@ -97,7 +98,7 @@ class MarkdownEditor(QTextEdit): line = block.text() pos_in_block = c.position() - block.position() - # Transform markldown checkboxes and 'TODO' to unicode checkboxes + # Transform markdown checkboxes and 'TODO' to unicode checkboxes def transform_line(s: str) -> str: s = s.replace( f"- {self._CHECK_CHECKED_STORAGE} ", diff --git a/pyproject.toml b/pyproject.toml index c71caf9..1bbde99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ pyproject-appimage = "^4.2" script = "bouquin" output = "Bouquin.AppImage" +[tool.vulture] +paths = ["bouquin", "vulture_ignorelist.py"] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py index bccfd39..c0ab938 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -16,7 +16,6 @@ def editor(app, qtbot): return ed -@pytest.mark.gui def test_findbar_basic_navigation(qtbot, editor): editor.from_markdown("alpha\nbeta\nalpha\nGamma\n") editor.moveCursor(QTextCursor.Start) @@ -113,7 +112,6 @@ def test_update_highlight_clear_when_empty(qtbot, editor): assert not editor.extraSelections() -@pytest.mark.gui def test_maybe_hide_and_wrap_prev(qtbot, editor): editor.setPlainText("a a a") fb = FindBar(editor=editor, shortcut_parent=editor) diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py index db6529b..05de5f9 100644 --- a/tests/test_lock_overlay.py +++ b/tests/test_lock_overlay.py @@ -1,11 +1,9 @@ -import pytest from PySide6.QtCore import QEvent from PySide6.QtWidgets import QWidget from bouquin.lock_overlay import LockOverlay from bouquin.theme import ThemeManager, ThemeConfig, Theme -@pytest.mark.gui def test_lock_overlay_reacts_to_theme(app, qtbot): host = QWidget() qtbot.addWidget(host) diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 0dabc89..2962a34 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -10,11 +10,12 @@ from bouquin.settings import get_settings from bouquin.key_prompt import KeyPrompt from bouquin.db import DBConfig, DBManager from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect -from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox +from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent +from unittest.mock import Mock, patch + -@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)) @@ -79,7 +80,6 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): assert "carry me" not in y_txt or "- [ ]" not in y_txt -@pytest.mark.gui def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) @@ -113,7 +113,6 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch) assert called["docs"] and called["bugs"] -@pytest.mark.gui def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): # Seed some content fresh_db.save_new_version("2001-01-01", "alpha", "n1") @@ -190,7 +189,6 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): assert errs["hit"] -@pytest.mark.gui def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -248,7 +246,6 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch): assert str(dest.with_suffix(".db")) in hit["text"] -@pytest.mark.gui def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) from bouquin.settings import get_settings @@ -283,7 +280,6 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch): monkeypatch.delattr(w, "_save_editor_content", raising=False) -@pytest.mark.gui def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) @@ -329,7 +325,6 @@ def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch): assert called["max"] -@pytest.mark.gui def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch): # Seed DB so refresh marks does something fresh_db.save_new_version("2021-08-15", "note", "") @@ -402,7 +397,6 @@ def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch w._show_calendar_context_menu(cal_pos) -@pytest.mark.gui def test_event_filter_keypress_starts_idle_timer(qtbot, app): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) @@ -1027,7 +1021,6 @@ def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch): assert not w._rect_on_any_screen(far) -@pytest.mark.gui def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch): w = _make_main_window(tmp_db_cfg, app, monkeypatch) qtbot.addWidget(w) @@ -1103,7 +1096,6 @@ def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch w._on_tab_changed(1) # should not raise -@pytest.mark.gui def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch): w = _make_main_window(tmp_db_cfg, app, monkeypatch) qtbot.addWidget(w) @@ -1124,7 +1116,6 @@ def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatc assert w.tab_widget.count() == before -@pytest.mark.gui def test_export_cancel_then_empty_filename( qtbot, app, tmp_db_cfg, monkeypatch, tmp_path ): @@ -1187,7 +1178,6 @@ def test_export_cancel_then_empty_filename( w._export() # returns early at filename check -@pytest.mark.gui def test_set_editor_markdown_preserve_view_preserves( qtbot, app, tmp_db_cfg, monkeypatch ): @@ -1212,7 +1202,6 @@ def test_set_editor_markdown_preserve_view_preserves( assert w.editor.to_markdown().endswith("extra\n") -@pytest.mark.gui def test_load_date_into_editor_with_extra_data_forces_save( qtbot, app, tmp_db_cfg, monkeypatch ): @@ -1230,7 +1219,6 @@ def test_load_date_into_editor_with_extra_data_forces_save( assert called["iso"] == "2020-01-01" and called["explicit"] is True -@pytest.mark.gui def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch): """Covers moveTab for both dated and undated buckets.""" w = _make_main_window(tmp_db_cfg, app, monkeypatch) @@ -1324,7 +1312,6 @@ def test_date_from_calendar_no_first_or_last(qtbot, app, tmp_db_cfg, monkeypatch assert w._date_from_calendar_pos(QPoint(5, 5)) is None -@pytest.mark.gui def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch): """Covers DB not connected branch.""" w = _make_main_window(tmp_db_cfg, app, monkeypatch) @@ -1337,7 +1324,6 @@ def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypa w._save_editor_content(w.editor) -@pytest.mark.gui def test_on_date_changed_stops_timer_and_saves_prev_when_dirty( qtbot, app, tmp_db_cfg, monkeypatch ): @@ -1370,7 +1356,6 @@ def test_on_date_changed_stops_timer_and_saves_prev_when_dirty( assert saved["iso"] == "2024-01-01" -@pytest.mark.gui def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch): """Covers early return when toolbar is already bound.""" w = _make_main_window(tmp_db_cfg, app, monkeypatch) @@ -1380,7 +1365,6 @@ def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch): w._bind_toolbar() -@pytest.mark.gui def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch): """Covers the early return when user selects no files.""" w = _make_main_window(tmp_db_cfg, app, monkeypatch) @@ -1420,7 +1404,6 @@ def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeyp assert hit["start"] -@pytest.mark.gui def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch): """ Covers exception swallowing around settings writes & ensures close proceeds @@ -1488,3 +1471,328 @@ def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch): w.closeEvent(ev) assert called["save"] and called["close"] + + +# ============================================================================ +# Tag Save Handler Tests (lines 1050-1068) +# ============================================================================ + + +def test_main_window_do_tag_save_with_editor(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _do_tag_save when editor has current_date""" + # Skip the key prompt + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Set a date on the editor + date = QDate(2024, 1, 15) + window.editor.current_date = date + window.editor.from_markdown("Test content") + + # Call _do_tag_save + window._do_tag_save() + + # Should have saved + fresh_db.get_entry("2024-01-15") + # May or may not have content depending on timing, but should not crash + assert True + + +def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _do_tag_save when editor doesn't have current_date""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Remove current_date attribute + if hasattr(window.editor, "current_date"): + delattr(window.editor, "current_date") + + # Call _do_tag_save - should handle gracefully + window._do_tag_save() + + assert True + + +def test_main_window_on_tag_added_triggers_deferred_save( + app, fresh_db, tmp_db_cfg, monkeypatch +): + """Test that _on_tag_added defers the save (lines 1043-1048)""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Mock QTimer.singleShot + with patch("PySide6.QtCore.QTimer.singleShot") as mock_timer: + window._on_tag_added() + + # Should have called singleShot + mock_timer.assert_called_once() + args = mock_timer.call_args[0] + assert args[0] == 0 # Delay of 0 + assert callable(args[1]) # Callback function + + +# ============================================================================ +# Tag Activation Tests (lines 1070-1080) +# ============================================================================ + + +def test_main_window_on_tag_activated_with_date(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _on_tag_activated when passed a date string""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Mock _load_selected_date + window._load_selected_date = Mock() + + # Call with date format + window._on_tag_activated("2024-01-15") + + # Should have called _load_selected_date + window._load_selected_date.assert_called_once_with("2024-01-15") + + +def test_main_window_on_tag_activated_with_tag_name( + app, fresh_db, tmp_db_cfg, monkeypatch +): + """Test _on_tag_activated when passed a tag name""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Mock the tag browser dialog (it's imported locally in the method) + with patch("bouquin.tag_browser.TagBrowserDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.openDateRequested = Mock() + mock_instance.exec.return_value = QDialog.Accepted + mock_dialog.return_value = mock_instance + + # Call with tag name + window._on_tag_activated("worktag") + + # Should have opened dialog + mock_dialog.assert_called_once() + # Check focus_tag was passed + call_kwargs = mock_dialog.call_args[1] + assert call_kwargs.get("focus_tag") == "worktag" + + +# ============================================================================ +# Settings Path Change Tests (lines 1105-1116) +# ============================================================================ + + +def test_main_window_settings_path_change_success( + app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch +): + """Test changing database path in settings""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + new_path = tmp_path / "new.db" + + # Mock the settings dialog + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Accepted + + # Create a new config with different path + new_cfg = Mock() + new_cfg.path = str(new_path) + new_cfg.key = tmp_db_cfg.key + new_cfg.idle_minutes = 15 + new_cfg.theme = "light" + new_cfg.move_todos = True + new_cfg.locale = "en" + + mock_instance.config = new_cfg + mock_dialog.return_value = mock_instance + + # Mock _prompt_for_key_until_valid to return True + window._prompt_for_key_until_valid = Mock(return_value=True) + # Also mock _load_selected_date and _refresh_calendar_marks since we don't have a real DB connection + window._load_selected_date = Mock() + window._refresh_calendar_marks = Mock() + + # Open settings + window._open_settings() + + # Path should have changed + assert window.cfg.path == str(new_path) + + +def test_main_window_settings_path_change_failure( + app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch +): + """Test failed database path change shows warning (lines 1108-1113)""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + new_path = tmp_path / "new.db" + + # Mock the settings dialog + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Accepted + + new_cfg = Mock() + new_cfg.path = str(new_path) + new_cfg.key = tmp_db_cfg.key + new_cfg.idle_minutes = 15 + new_cfg.theme = "light" + new_cfg.move_todos = True + new_cfg.locale = "en" + + mock_instance.config = new_cfg + mock_dialog.return_value = mock_instance + + # Mock _prompt_for_key_until_valid to return False (failure) + window._prompt_for_key_until_valid = Mock(return_value=False) + + # Mock QMessageBox.warning + with patch.object(QMessageBox, "warning") as mock_warning: + # Open settings + window._open_settings() + + # Warning should have been shown + mock_warning.assert_called_once() + + +def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test settings change without path change (lines 1105 condition False)""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + old_path = window.cfg.path + + # Mock the settings dialog + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Accepted + + # Create config with SAME path + new_cfg = Mock() + new_cfg.path = old_path + new_cfg.key = tmp_db_cfg.key + new_cfg.idle_minutes = 20 # Changed + new_cfg.theme = "dark" # Changed + new_cfg.move_todos = False # Changed + new_cfg.locale = "fr" # Changed + + mock_instance.config = new_cfg + mock_dialog.return_value = mock_instance + + # Open settings + window._open_settings() + + # Settings should be updated but path didn't change + assert window.cfg.idle_minutes == 20 + assert window.cfg.theme == "dark" + assert window.cfg.path == old_path + + +def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test cancelling settings dialog (line 1085-1086)""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + old_theme = window.cfg.theme + + # Mock the settings dialog to be rejected + with patch("bouquin.main_window.SettingsDialog") as mock_dialog: + mock_instance = Mock() + mock_instance.exec.return_value = QDialog.Rejected + mock_dialog.return_value = mock_instance + + # Open settings + window._open_settings() + + # Settings should NOT change + assert window.cfg.theme == old_theme + + +# ============================================================================ +# Update Tag Views Tests (lines 1039-1041) +# ============================================================================ + + +def test_main_window_update_tag_views_for_date(app, fresh_db, tmp_db_cfg, monkeypatch): + """Test _update_tag_views_for_date""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Set tags for a date + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + # Update tag views + window._update_tag_views_for_date("2024-01-15") + + # Tags widget should have been updated + assert window.tags._current_date == "2024-01-15" + + +def test_main_window_update_tag_views_no_tags_widget( + app, fresh_db, tmp_db_cfg, monkeypatch +): + """Test _update_tag_views_for_date when tags widget doesn't exist""" + monkeypatch.setattr( + "bouquin.main_window.KeyPrompt", + lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes) + + # Remove tags widget + delattr(window, "tags") + + # Should handle gracefully + window._update_tag_views_for_date("2024-01-15") + + assert True diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 7118dc6..a8be6a7 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -10,7 +10,7 @@ from PySide6.QtGui import ( QFont, QTextCharFormat, ) -from PySide6.QtWidgets import QTextEdit +from PySide6.QtWidgets import QApplication, QTextEdit from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_highlighter import MarkdownHighlighter @@ -93,7 +93,6 @@ def test_insert_image_from_path(editor, tmp_path): assert "data:image/png;base64" in md or "data:image/image/png;base64" in md -@pytest.mark.gui def test_checkbox_toggle_by_click(editor, qtbot): # Load a markdown checkbox editor.from_markdown("- [ ] task here") @@ -115,7 +114,6 @@ def test_checkbox_toggle_by_click(editor, qtbot): assert "☑" in display2 -@pytest.mark.gui def test_apply_heading_levels(editor, qtbot): editor.setPlainText("hello") editor.selectAll() @@ -132,7 +130,6 @@ def test_apply_heading_levels(editor, qtbot): assert not editor.toPlainText().startswith("#") -@pytest.mark.gui def test_enter_on_nonempty_list_continues(qtbot, editor): qtbot.addWidget(editor) editor.show() @@ -147,7 +144,6 @@ def test_enter_on_nonempty_list_continues(qtbot, editor): assert "\n- " in txt -@pytest.mark.gui def test_enter_on_empty_list_marks_empty(qtbot, editor): qtbot.addWidget(editor) editor.show() @@ -161,7 +157,6 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor): assert editor.toPlainText().startswith("- \n") -@pytest.mark.gui def test_triple_backtick_autoexpands(editor, qtbot): editor.from_markdown("") press_backtick(qtbot, editor, 2) @@ -177,7 +172,6 @@ def test_triple_backtick_autoexpands(editor, qtbot): assert lines_keep(editor)[1] == "" -@pytest.mark.gui def test_toolbar_inserts_block_on_own_lines(editor, qtbot): editor.from_markdown("hello") editor.moveCursor(QTextCursor.End) @@ -193,7 +187,6 @@ def test_toolbar_inserts_block_on_own_lines(editor, qtbot): assert lines_keep(editor)[2] == "" -@pytest.mark.gui def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot): editor.from_markdown("") editor.apply_code() # create a block (caret now on blank line inside) @@ -209,7 +202,6 @@ def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot): assert editor.textCursor().position() == pos_before -@pytest.mark.gui def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot): editor.from_markdown("") editor.apply_code() @@ -224,7 +216,6 @@ def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot): assert lines_keep(editor)[1] == "" -@pytest.mark.gui def test_toolbar_on_closing_fence_jumps_out(editor, qtbot): editor.from_markdown("") editor.apply_code() @@ -243,7 +234,6 @@ def test_toolbar_on_closing_fence_jumps_out(editor, qtbot): assert editor.textCursor().block().previous().text().strip() == "```" -@pytest.mark.gui def test_down_escapes_from_last_code_line(editor, qtbot): editor.from_markdown("```\nLINE\n```\n") # Put caret at end of "LINE" @@ -259,7 +249,6 @@ def test_down_escapes_from_last_code_line(editor, qtbot): assert editor.textCursor().block().previous().text().strip() == "```" -@pytest.mark.gui def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot): editor.from_markdown("```\ncode\n```") # no trailing newline # caret on closing fence line @@ -275,7 +264,6 @@ def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot): assert editor.textCursor().block().previous().text().strip() == "```" -@pytest.mark.gui def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot): editor.from_markdown("") # create a block via typing @@ -457,7 +445,6 @@ def test_end_guard_skips_italic_followed_by_marker(hl_light): assert not f.fontItalic() -@pytest.mark.gui def test_char_rect_at_edges_and_click_checkbox(editor, qtbot): """ Exercises char_rect_at()-style logic and checkbox toggle via click @@ -472,7 +459,6 @@ def test_char_rect_at_edges_and_click_checkbox(editor, qtbot): assert "☑" in editor.toPlainText() -@pytest.mark.gui def test_heading_apply_levels_and_inline_styles(editor): editor.setPlainText("hello") editor.selectAll() @@ -492,7 +478,6 @@ def test_heading_apply_levels_and_inline_styles(editor): assert "**" in md and "*" in md and "~~" in md -@pytest.mark.gui def test_insert_image_and_markdown_roundtrip(editor, tmp_path): img = tmp_path / "p.png" qimg = QImage(2, 2, QImage.Format_RGBA8888) @@ -555,3 +540,927 @@ def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path): editor_hello.insert_image_from_path(bad) # Nothing new added assert editor_hello.toPlainText() == "hello" + + +# ============================================================================ +# setDocument Tests (lines 75-81) +# ============================================================================ + + +def test_markdown_editor_set_document(app): + """Test setting a new document on the editor""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Create a new document + new_doc = QTextDocument() + new_doc.setPlainText("New document content") + + # Set the document + editor.setDocument(new_doc) + + # Verify document was set + assert editor.document() == new_doc + assert "New document content" in editor.toPlainText() + + +def test_markdown_editor_set_document_with_highlighter(app): + """Test setting document preserves highlighter""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Ensure highlighter exists + assert hasattr(editor, "highlighter") + + # Create and set new document + new_doc = QTextDocument() + new_doc.setPlainText("# Heading") + editor.setDocument(new_doc) + + # Highlighter should be attached to new document + assert editor.highlighter.document() == new_doc + + +# ============================================================================ +# showEvent Tests (lines 83-86) +# ============================================================================ + + +def test_markdown_editor_show_event(app, qtbot): + """Test showEvent triggers code block background update""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode\n```") + + # Show the editor + editor.show() + qtbot.waitExposed(editor) + + # Process events to let QTimer.singleShot fire + QApplication.processEvents() + + # Editor should be visible + assert editor.isVisible() + + +# ============================================================================ +# Checkbox Transformation Tests (lines 100-133) +# ============================================================================ + + +def test_markdown_editor_transform_unchecked_checkbox(app, qtbot): + """Test transforming - [ ] to unchecked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type checkbox markdown + editor.insertPlainText("- [ ] Task") + + # Process events to let transformation happen + QApplication.processEvents() + + # Should contain checkbox character + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_checked_checkbox(app, qtbot): + """Test transforming - [x] to checked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type checked checkbox markdown + editor.insertPlainText("- [x] Done") + + # Process events + QApplication.processEvents() + + # Should contain checked checkbox character + text = editor.toPlainText() + assert editor._CHECK_CHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo(app, qtbot): + """Test transforming TODO to unchecked checkbox (lines 110-114)""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type TODO + editor.insertPlainText("TODO: Important task") + + # Process events + QApplication.processEvents() + + # Should contain checkbox and no TODO + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo_with_indent(app, qtbot): + """Test transforming indented TODO""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type indented TODO + editor.insertPlainText(" TODO: Indented task") + + # Process events + QApplication.processEvents() + + # Should handle indented TODO + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo_with_colon(app, qtbot): + """Test transforming TODO: with colon""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type TODO with colon + editor.insertPlainText("TODO: Task with colon") + + # Process events + QApplication.processEvents() + + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_transform_todo_with_dash(app, qtbot): + """Test transforming TODO- with dash""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + qtbot.waitExposed(editor) + + # Type TODO with dash + editor.insertPlainText("TODO- Task with dash") + + # Process events + QApplication.processEvents() + + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +def test_markdown_editor_no_transform_when_updating(app): + """Test that transformation doesn't happen when _updating flag is set""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Set updating flag + editor._updating = True + + # Try to insert checkbox markdown + editor.insertPlainText("- [ ] Task") + + # Should NOT transform since _updating is True + # This tests the early return in _on_text_changed (lines 90-91) + assert editor._updating + + +# ============================================================================ +# Code Block Tests +# ============================================================================ + + +def test_markdown_editor_is_inside_code_block(app): + """Test detecting if cursor is inside code block""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode here\n```\noutside") + + # Move cursor to inside code block + cursor = editor.textCursor() + cursor.setPosition(10) # Inside the code block + editor.setTextCursor(cursor) + + block = cursor.block() + # Test the method exists and can be called + result = editor._is_inside_code_block(block) + assert isinstance(result, bool) + + +def test_markdown_editor_code_block_spacing(app): + """Test code block spacing application""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\nline1\nline2\n```") + + # Apply code block spacing + editor._apply_code_block_spacing() + + # Should complete without error + assert True + + +def test_markdown_editor_update_code_block_backgrounds(app): + """Test updating code block backgrounds""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode\n```") + + # Update backgrounds + editor._update_code_block_row_backgrounds() + + # Should complete without error + assert True + + +# ============================================================================ +# Image Insertion Tests (lines 336-366) +# ============================================================================ + + +def test_markdown_editor_insert_image_from_path(app, tmp_path): + """Test inserting image from file path""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + # Create a real PNG image (1x1 pixel) + # PNG file signature + minimal valid PNG data + png_data = ( + b"\x89PNG\r\n\x1a\n" # PNG signature + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" # IHDR chunk + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n-\xb4" # IDAT chunk + b"\x00\x00\x00\x00IEND\xaeB`\x82" # IEND chunk + ) + image_path = tmp_path / "test.png" + image_path.write_bytes(png_data) + + # Insert image + editor.insert_image_from_path(image_path) + + # Check that document has content (image + newline) + # Images don't show in toPlainText() but affect document structure + doc = editor.document() + assert doc.characterCount() > 1 # Should have image char + newline + + +# ============================================================================ +# Formatting Tests (missing lines in various formatting methods) +# ============================================================================ + + +def test_markdown_editor_toggle_bold_empty_selection(app): + """Test toggling bold with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + # Move cursor to middle of text (no selection) + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + # Toggle bold (inserts ** markers with cursor between them) + editor.apply_weight() + + # Should have inserted bold markers + text = editor.toPlainText() + assert "**" in text + + # Should handle empty selection + assert True + + +def test_markdown_editor_toggle_italic_empty_selection(app): + """Test toggling italic with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + # Move cursor to middle (no selection) + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + # Toggle italic + editor.apply_italic() + + # Should handle empty selection + assert True + + +def test_markdown_editor_toggle_strikethrough_empty_selection(app): + """Test toggling strikethrough with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + editor.apply_strikethrough() + + assert True + + +def test_markdown_editor_toggle_code_empty_selection(app): + """Test toggling code with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("text") + + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + editor.apply_code() + + assert True + + +# ============================================================================ +# Heading Tests (lines 455-459) +# ============================================================================ + + +def test_markdown_editor_set_heading_various_levels(app): + """Test setting different heading levels""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + for level in [14, 18, 24]: + editor.clear() + editor.insertPlainText("Heading text") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Set heading level + editor.apply_heading(level) + + # Should have heading markdown + text = editor.toPlainText() + assert "#" in text + + +def test_markdown_editor_set_heading_zero_removes_heading(app): + """Test setting heading level 0 removes heading""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("# Heading") + + # Select heading + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Set to level 0 (remove heading) + editor.apply_heading(0) + + # Should not have heading markers + text = editor.toPlainText() + assert not text.startswith("#") + + +# ============================================================================ +# List Tests (lines 483-519) +# ============================================================================ + + +def test_markdown_editor_toggle_list_bullet(app): + """Test toggling bullet list""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Item 1\nItem 2") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle bullet list + editor.toggle_bullets() + + # Should have bullet markers + text = editor.toPlainText() + assert "•" in text or "-" in text + + +def test_markdown_editor_toggle_list_ordered(app): + """Test toggling ordered list""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Item 1\nItem 2") + + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + editor.toggle_numbers() + + text = editor.toPlainText() + assert "1" in text or "2" in text + + +# ============================================================================ +# Code Block Tests (lines 540-577) +# ============================================================================ + + +def test_markdown_editor_apply_code_selected_text(app): + """Test toggling code block with selected text""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("def hello():\n print('hi')") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle code block + editor.apply_code() + + # Should have code fence + text = editor.toPlainText() + assert "```" in text + + +def test_markdown_editor_apply_code_remove(app): + """Test removing code block""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("```python\ncode\n```") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle off + editor.apply_code() + + # Code fences should be reduced/removed + editor.toPlainText() + # May still have ``` but different structure + assert True # Just verify no crash + + +# ============================================================================ +# Checkbox Tests (lines 596-600) +# ============================================================================ + + +def test_markdown_editor_insert_checkbox_unchecked(app): + """Test inserting unchecked checkbox""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + editor.toggle_checkboxes() + + text = editor.toPlainText() + assert editor._CHECK_UNCHECKED_DISPLAY in text + + +# ============================================================================ +# Toggle Checkboxes Tests (lines 659-660, 686-691) +# ============================================================================ + + +def test_markdown_editor_toggle_checkboxes_none_selected(app): + """Test toggling checkboxes with no selection""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("☐ Task 1\n☐ Task 2") + + # No selection, just cursor + editor.toggle_checkboxes() + + # Should handle gracefully + assert True + + +def test_markdown_editor_toggle_checkboxes_mixed(app): + """Test toggling mixed checked/unchecked checkboxes""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("☐ Task 1\n☑ Task 2\n☐ Task 3") + + # Select all + cursor = editor.textCursor() + cursor.select(QTextCursor.Document) + editor.setTextCursor(cursor) + + # Toggle + editor.toggle_checkboxes() + + # Should toggle all + text = editor.toPlainText() + assert ( + editor._CHECK_CHECKED_DISPLAY in text or editor._CHECK_UNCHECKED_DISPLAY in text + ) + + +# ============================================================================ +# Markdown Conversion Tests (lines 703, 710-714, 731) +# ============================================================================ + + +def test_markdown_editor_to_markdown_with_checkboxes(app): + """Test converting to markdown preserves checkboxes""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("- [ ] Task 1\n- [x] Task 2") + + md = editor.to_markdown() + + # Should have checkbox markdown + assert "[ ]" in md or "[x]" in md + + +def test_markdown_editor_from_markdown_with_images(app): + """Test loading markdown with images""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + md_with_image = "# Title\n\n![alt text](image.png)\n\nText" + editor.from_markdown(md_with_image) + + # Should load without error + text = editor.toPlainText() + assert "Title" in text + + +def test_markdown_editor_from_markdown_with_links(app): + """Test loading markdown with links""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + + md_with_link = "[Click here](https://example.com)" + editor.from_markdown(md_with_link) + + text = editor.toPlainText() + assert "Click here" in text + + +# ============================================================================ +# Selection and Cursor Tests (lines 747-752) +# ============================================================================ + + +def test_markdown_editor_select_word_under_cursor(app): + """Test selecting word under cursor""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Hello world test") + + # Move cursor to middle of word + cursor = editor.textCursor() + cursor.setPosition(7) # Middle of "world" + editor.setTextCursor(cursor) + + # Select word (via double-click or other mechanism) + cursor.select(QTextCursor.WordUnderCursor) + editor.setTextCursor(cursor) + + assert cursor.hasSelection() + + +def test_markdown_editor_get_selected_blocks(app): + """Test getting selected blocks""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.insertPlainText("Line 1\nLine 2\nLine 3") + + # Select multiple lines + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(14, QTextCursor.KeepAnchor) + editor.setTextCursor(cursor) + + # Should have selection + assert cursor.hasSelection() + + +# ============================================================================ +# Key Event Tests (lines 795, 806-809) +# ============================================================================ + + +def test_markdown_editor_key_press_tab(app): + """Test tab key handling""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.show() + + # Create tab key event + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier) + + # Send event + editor.keyPressEvent(event) + + # Should insert tab or spaces + text = editor.toPlainText() + assert len(text) > 0 or text == "" # Tab or spaces inserted + + +def test_markdown_editor_key_press_return_in_list(app): + """Test return key in list""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("- Item 1") + + # Move cursor to end + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press return + event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier) + editor.keyPressEvent(event) + + # Should create new list item + text = editor.toPlainText() + assert "Item 1" in text + + +# ============================================================================ +# Link Handling Tests (lines 898, 922, 949, 990) +# ============================================================================ + + +def test_markdown_editor_anchor_at_cursor(app): + """Test getting anchor at cursor position""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("[link](https://example.com)") + + # Move cursor over link + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + # Get anchor (if any) + anchor = cursor.charFormat().anchorHref() + + # May or may not have anchor depending on rendering + assert isinstance(anchor, str) + + +def test_markdown_editor_mouse_move_over_link(app): + """Test mouse movement over link""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + editor = MarkdownEditor(themes) + editor.from_markdown("[link](https://example.com)") + editor.show() + + # Simulate mouse move + # This tests viewport event handling + assert True # Just verify no crash + + +# ============================================================================ +# Theme Mode Tests (lines 72-79) +# ============================================================================ + + +def test_markdown_highlighter_light_mode(app): + """Test highlighter in light mode (lines 74-77)""" + doc = QTextDocument() + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + + # Check that light mode colors are set + bg = highlighter.code_block_format.background().color() + assert bg.isValid() + # Check it's a light color (high RGB values, close to 245) + assert bg.red() > 240 and bg.green() > 240 and bg.blue() > 240 + + fg = highlighter.code_block_format.foreground().color() + assert fg.isValid() + # Check it's a dark color for text + assert fg.red() < 50 and fg.green() < 50 and fg.blue() < 50 + + +def test_markdown_highlighter_dark_mode(app): + """Test highlighter in dark mode (lines 70-71)""" + doc = QTextDocument() + themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + highlighter = MarkdownHighlighter(doc, themes) + + # Check that dark mode uses palette colors + bg = highlighter.code_block_format.background().color() + fg = highlighter.code_block_format.foreground().color() + + assert bg.isValid() + assert fg.isValid() + + +# ============================================================================ +# Highlighting Pattern Tests (lines 196, 208, 211, 213) +# ============================================================================ + + +def test_markdown_highlighter_triple_backtick_code(app): + """Test highlighting triple backtick code blocks""" + doc = QTextDocument() + doc.setPlainText("```python\ndef hello():\n pass\n```") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + + # Force rehighlight + highlighter.rehighlight() + + # Should complete without errors + assert True + + +def test_markdown_highlighter_inline_code(app): + """Test highlighting inline code with backticks""" + doc = QTextDocument() + doc.setPlainText("Here is `inline code` in text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_bold_text(app): + """Test highlighting bold text""" + doc = QTextDocument() + doc.setPlainText("This is **bold** text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_italic_text(app): + """Test highlighting italic text""" + doc = QTextDocument() + doc.setPlainText("This is *italic* text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_headings(app): + """Test highlighting various heading levels""" + doc = QTextDocument() + doc.setPlainText("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_links(app): + """Test highlighting markdown links""" + doc = QTextDocument() + doc.setPlainText("[link text](https://example.com)") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_images(app): + """Test highlighting markdown images""" + doc = QTextDocument() + doc.setPlainText("![alt text](image.png)") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_blockquotes(app): + """Test highlighting blockquotes""" + doc = QTextDocument() + doc.setPlainText("> This is a quote\n> Second line") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_lists(app): + """Test highlighting lists""" + doc = QTextDocument() + doc.setPlainText("- Item 1\n- Item 2\n- Item 3") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_ordered_lists(app): + """Test highlighting ordered lists""" + doc = QTextDocument() + doc.setPlainText("1. First\n2. Second\n3. Third") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_horizontal_rules(app): + """Test highlighting horizontal rules""" + doc = QTextDocument() + doc.setPlainText("Text above\n\n---\n\nText below") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_strikethrough(app): + """Test highlighting strikethrough text""" + doc = QTextDocument() + doc.setPlainText("This is ~~strikethrough~~ text") + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_mixed_formatting(app): + """Test highlighting mixed markdown formatting""" + doc = QTextDocument() + doc.setPlainText( + "# Title\n\nThis is **bold** and *italic* with `code`.\n\n- List item\n- Another item" + ) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter = MarkdownHighlighter(doc, themes) + highlighter.rehighlight() + + assert True + + +def test_markdown_highlighter_switch_dark_mode(app): + """Test that dark mode uses different colors than light mode""" + doc = QTextDocument() + doc.setPlainText("# Test") + + # Create light mode highlighter + themes_light = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + highlighter_light = MarkdownHighlighter(doc, themes_light) + light_bg = highlighter_light.code_block_format.background().color() + + # Create dark mode highlighter with new document (to avoid conflicts) + doc2 = QTextDocument() + doc2.setPlainText("# Test") + themes_dark = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + highlighter_dark = MarkdownHighlighter(doc2, themes_dark) + dark_bg = highlighter_dark.code_block_format.background().color() + + # In light mode, background should be light (high RGB values) + # In dark mode, background should be darker (lower RGB values) + # Note: actual values depend on system palette and theme settings + assert light_bg.isValid() + assert dark_bg.isValid() + + # At least one of these should be true (depending on system theme): + # - Light is lighter than dark, OR + # - Both are set to valid colors (if system theme overrides) + is_light_lighter = ( + light_bg.red() + light_bg.green() + light_bg.blue() + > dark_bg.red() + dark_bg.green() + dark_bg.blue() + ) + both_valid = light_bg.isValid() and dark_bg.isValid() + + assert is_light_lighter or both_valid # At least colors are being set diff --git a/tests/test_search.py b/tests/test_search.py index d71a785..6f3ab23 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,4 +1,3 @@ -import pytest from bouquin.search import Search from PySide6.QtWidgets import QListWidgetItem @@ -80,7 +79,6 @@ def test_make_html_snippet_variants(qtbot, fresh_db): assert "delta" in frag -@pytest.mark.gui def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch): s = Search(fresh_db) qtbot.addWidget(s) @@ -92,7 +90,6 @@ def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch): assert frag == "" and not left and not right -@pytest.mark.gui def test_populate_results_shows_both_ellipses(qtbot, fresh_db): s = Search(fresh_db) qtbot.addWidget(s) diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 28eead1..9d7e03a 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,5 +1,3 @@ -import pytest - from bouquin.db import DBManager, DBConfig from bouquin.key_prompt import KeyPrompt import bouquin.settings_dialog as sd @@ -10,7 +8,6 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog -@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() @@ -206,7 +203,6 @@ def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db): assert called["text"] -@pytest.mark.gui def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch): parent = QWidget() parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 5e7a40e..0b7d781 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -1,5 +1,4 @@ import types -import pytest from PySide6.QtWidgets import QFileDialog from PySide6.QtGui import QTextCursor @@ -10,7 +9,6 @@ from bouquin.main_window import MainWindow from bouquin.history_dialog import HistoryDialog -@pytest.mark.gui def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): # point to the temp encrypted DB s = get_settings() @@ -43,7 +41,6 @@ def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): assert w.tab_widget.currentWidget().current_date == date1 -@pytest.mark.gui def test_toolbar_signals_dispatch_once_per_click( qtbot, app, tmp_db_cfg, fresh_db, monkeypatch ): @@ -115,7 +112,6 @@ def test_toolbar_signals_dispatch_once_per_click( assert calls2["bold"] == 1 -@pytest.mark.gui def test_history_and_insert_image_not_duplicated( qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path ): @@ -158,7 +154,6 @@ def test_history_and_insert_image_not_duplicated( assert inserted["count"] == 1 -@pytest.mark.gui def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() s.setValue("db/path", str(tmp_db_cfg.path)) @@ -174,7 +169,6 @@ def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): assert w.editor.highlighter.document() is w.editor.document() -@pytest.mark.gui def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() s.setValue("db/path", str(tmp_db_cfg.path)) diff --git a/tests/test_tags.py b/tests/test_tags.py index 6e2ce74..8022e11 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,6 +1,10 @@ +from PySide6.QtCore import Qt, QPoint, QEvent +from PySide6.QtGui import QMouseEvent +from PySide6.QtWidgets import QApplication, QMessageBox, QInputDialog, QColorDialog from bouquin.db import DBManager from bouquin.tags_widget import PageTagsWidget, TagChip from bouquin.tag_browser import TagBrowserDialog +from bouquin.flow_layout import FlowLayout # ============================================================================ @@ -779,3 +783,991 @@ def test_tag_page_without_content(fresh_db): # Page should be created but with no content content = fresh_db.get_entry(date_iso) assert content is None or content == "" + + +# ============================================================================ +# TagChip Mouse Event Tests (tags_widget.py lines 70-73) +# ============================================================================ + + +def test_tag_chip_mouse_click_emits_signal(app, qtbot): + """Test that clicking a TagChip emits the clicked signal""" + chip = TagChip(1, "clickable", "#FF0000") + chip.show() + qtbot.waitExposed(chip) + + signal_data = {"name": None} + + def on_clicked(name): + signal_data["name"] = name + + chip.clicked.connect(on_clicked) + + # Simulate mouse click + event = QMouseEvent( + QEvent.MouseButtonRelease, + QPoint(5, 5), + Qt.LeftButton, + Qt.LeftButton, + Qt.NoModifier, + ) + chip.mouseReleaseEvent(event) + + assert signal_data["name"] == "clickable" + + +def test_tag_chip_right_click_no_signal(app, qtbot): + """Test that right-clicking a TagChip does not emit clicked signal""" + chip = TagChip(1, "clickable", "#FF0000") + chip.show() + qtbot.waitExposed(chip) + + signal_emitted = {"emitted": False} + + def on_clicked(name): + signal_emitted["emitted"] = True + + chip.clicked.connect(on_clicked) + + # Simulate right click + event = QMouseEvent( + QEvent.MouseButtonRelease, + QPoint(5, 5), + Qt.RightButton, + Qt.RightButton, + Qt.NoModifier, + ) + chip.mouseReleaseEvent(event) + + # Signal should NOT be emitted for right click + assert not signal_emitted["emitted"] + + +# ============================================================================ +# PageTagsWidget Edge Cases (tags_widget.py missing lines) +# ============================================================================ + + +def test_page_tags_widget_add_tag_with_completer_popup_visible(app, fresh_db): + """Test adding tag when completer popup is visible (line 148)""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + # Create some existing tags for autocomplete + fresh_db.set_tags_for_page("2024-01-14", ["existing", "another"]) + fresh_db.set_tags_for_page(date_iso, []) + + widget.set_current_date(date_iso) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Type partial text to trigger completer + widget.add_edit.setText("exi") + + # Show the completer popup + completer = widget.add_edit.completer() + if completer: + completer.complete() + + # If popup is now visible, pressing enter should return early + if completer.popup().isVisible(): + # Call _on_add_tag while popup is visible + widget._on_add_tag() + + # Tag should NOT be added since completer popup was visible + tags = fresh_db.get_tags_for_page(date_iso) + # "exi" should not be added as a tag + tag_names = [name for _, name, _ in tags] + assert "exi" not in tag_names + + +def test_page_tags_widget_no_current_date_add_tag(app, fresh_db): + """Test adding tag when no current date is set (early return)""" + widget = PageTagsWidget(fresh_db) + + # Don't set current date + widget.add_edit.setText("test") + widget._on_add_tag() + + # Should handle gracefully and not crash + assert widget._current_date is None + + +def test_page_tags_widget_no_current_date_remove_tag(app, fresh_db): + """Test removing tag when no current date is set""" + widget = PageTagsWidget(fresh_db) + + # Try to remove tag without setting date + widget._remove_tag(1) + + # Should handle gracefully + assert widget._current_date is None + + +# ============================================================================ +# TagBrowserDialog Interactive Tests (tag_browser.py lines 124-126, 139-205) +# ============================================================================ + + +def test_tag_browser_button_states_with_page_item(app, fresh_db): + """Test that buttons are disabled when clicking a page item (lines 124-126)""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Get the date child item + date_item = root.child(0) + + # Click the date item + dialog.tree.setCurrentItem(date_item) + dialog._on_item_clicked(date_item, 0) + + # Buttons should be disabled for page items + assert not dialog.edit_name_btn.isEnabled() + assert not dialog.change_color_btn.isEnabled() + assert not dialog.delete_btn.isEnabled() + + +def test_tag_browser_edit_tag_name_no_item(app, fresh_db): + """Test editing tag name when no item is selected (lines 139-141)""" + dialog = TagBrowserDialog(fresh_db) + + # Try to edit without selecting anything + dialog._edit_tag_name() + + # Should handle gracefully (no exception) + assert True + + +def test_tag_browser_edit_tag_name_page_item(app, fresh_db): + """Test editing tag name when a page item is selected (lines 143-145)""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Select the date child item (not a tag) + date_item = root.child(0) + dialog.tree.setCurrentItem(date_item) + + # Try to edit - should return early since it's not a tag item + dialog._edit_tag_name() + + # Should handle gracefully + assert True + + +def test_tag_browser_change_color_no_item(app, fresh_db): + """Test changing color when no item is selected (lines 164-166)""" + dialog = TagBrowserDialog(fresh_db) + + # Try to change color without selecting anything + dialog._change_tag_color() + + # Should handle gracefully + assert True + + +def test_tag_browser_change_color_page_item(app, fresh_db): + """Test changing color when a page item is selected (lines 168-170)""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Select the date child item + date_item = root.child(0) + dialog.tree.setCurrentItem(date_item) + + # Try to change color - should return early + dialog._change_tag_color() + + # Should handle gracefully + assert True + + +def test_tag_browser_delete_tag_no_item(app, fresh_db): + """Test deleting tag when no item is selected (lines 183-185)""" + dialog = TagBrowserDialog(fresh_db) + + # Try to delete without selecting anything + dialog._delete_tag() + + # Should handle gracefully + assert True + + +def test_tag_browser_delete_tag_page_item(app, fresh_db): + """Test deleting tag when a page item is selected (lines 187-189)""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Select the date child item + date_item = root.child(0) + dialog.tree.setCurrentItem(date_item) + + # Try to delete - should return early + dialog._delete_tag() + + # Tag should still exist + tags = fresh_db.list_tags() + assert len(tags) == 1 + + +# ============================================================================ +# FlowLayout Edge Case (flow_layout.py line 28) +# ============================================================================ + + +def test_flow_layout_take_at_out_of_bounds(app): + """Test FlowLayout.takeAt with invalid index (line 28)""" + layout = FlowLayout() + + # Try to take item at index that doesn't exist + result = layout.takeAt(999) + + # Should return None + assert result is None + + +def test_flow_layout_take_at_negative(app): + """Test FlowLayout.takeAt with negative index""" + layout = FlowLayout() + + # Try to take item at negative index + result = layout.takeAt(-1) + + # Should return None + assert result is None + + +# ============================================================================ +# DB Edge Case (db.py line 434) +# ============================================================================ + + +def test_db_default_tag_colour_many_tags(fresh_db): + """Test the _default_tag_colour method with many tags""" + # Create many tags to test color assignment logic + tag_names = [f"tag{i}" for i in range(20)] + + for i, name in enumerate(tag_names): + fresh_db.set_tags_for_page(f"2024-01-{i+1:02d}", [name]) + + # Verify all tags have valid colors + tags = fresh_db.list_tags() + for _, name, color in tags: + assert color.startswith("#") + assert len(color) in (4, 7) + + +# ============================================================================ +# Additional PageTagsWidget Coverage +# ============================================================================ + + +def test_page_tags_widget_set_date_while_collapsed(app, fresh_db): + """Test setting date when widget is collapsed""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + # Widget is collapsed by default + assert not widget.toggle_btn.isChecked() + + # Set date while collapsed + widget.set_current_date(date_iso) + + # Chips should not be loaded yet (collapsed) + assert widget.chip_layout.count() == 0 + + +def test_page_tags_widget_expand_then_set_date(app, fresh_db): + """Test expanding widget then setting date""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1"]) + + # Expand first + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Then set date + widget.set_current_date(date_iso) + + # Process events + QApplication.processEvents() + + # Chips should be loaded + assert widget.chip_layout.count() == 1 + + +def test_page_tags_widget_remove_tag_no_date(app, fresh_db): + """Test removing tag when current date is None""" + widget = PageTagsWidget(fresh_db) + + # Current date is None + assert widget._current_date is None + + # Try to remove tag + widget._remove_tag(1) + + # Should handle gracefully + assert True + + +# ============================================================================ +# Signal Connection Tests +# ============================================================================ + + +def test_tag_browser_open_date_signal_works(app, fresh_db): + """Test that openDateRequested signal works properly""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + + received_dates = [] + + def date_handler(date_iso): + received_dates.append(date_iso) + + dialog.openDateRequested.connect(date_handler) + + # Get tag item, expand it, and get child date item + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + date_item = root.child(0) + + # Simulate activation (double-click) + dialog._on_item_activated(date_item, 0) + + assert "2024-01-15" in received_dates + + +def test_page_tags_widget_tag_activated_signal_works(app, fresh_db): + """Test tagActivated signal emission""" + widget = PageTagsWidget(fresh_db) + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["clicktag"]) + widget.set_current_date(date_iso) + + received_data = [] + + def tag_handler(data): + received_data.append(data) + + widget.tagActivated.connect(tag_handler) + + # Directly call the method + widget._on_chip_clicked("clicktag") + + assert "clicktag" in received_data + + +# ============================================================================ +# Additional Edge Cases +# ============================================================================ + + +def test_page_tags_widget_clear_chips_when_no_items(app, fresh_db): + """Test clearing chips when layout is empty""" + widget = PageTagsWidget(fresh_db) + + # Clear when empty + widget._clear_chips() + + # Should handle gracefully + assert widget.chip_layout.count() == 0 + + +def test_tag_browser_populate_with_no_focus_tag(app, fresh_db): + """Test populating browser without focus tag""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1", "tag2"]) + + dialog = TagBrowserDialog(fresh_db, focus_tag=None) + + # Should have both tags + assert dialog.tree.topLevelItemCount() == 2 + + +def test_tag_browser_populate_with_nonexistent_focus_tag(app, fresh_db): + """Test populating browser with focus tag that doesn't exist""" + fresh_db.set_tags_for_page("2024-01-15", ["tag1"]) + + dialog = TagBrowserDialog(fresh_db, focus_tag="nonexistent") + + # Should handle gracefully + assert dialog.tree.topLevelItemCount() == 1 + + +# ============================================================================ +# PageTagsWidget Edge Cases +# ============================================================================ + + +def test_page_tags_widget_reload_without_current_date(app, fresh_db): + """Test _reload_tags with no current date set""" + widget = PageTagsWidget(fresh_db) + widget.show() + + # Try to reload without setting a date + widget._reload_tags() + + # Should handle gracefully + assert widget.chip_layout.count() == 0 + + +def test_page_tags_widget_add_tag_without_current_date(app, fresh_db): + """Test trying to add tag without current date set""" + widget = PageTagsWidget(fresh_db) + widget.show() + + widget.add_edit.setText("shouldnotadd") + widget._on_add_tag() + + # Should not crash, and no tags should be in database + all_tags = fresh_db.list_tags() + assert len(all_tags) == 0 + + +def test_page_tags_widget_completer_popup_visible_skip(app, fresh_db): + """Test that _on_add_tag returns early if completer popup is visible""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + # Create some existing tags for autocomplete + fresh_db.set_tags_for_page(date_iso, ["existing1", "existing2"]) + widget.set_current_date(date_iso) + widget._setup_autocomplete() + + # Make completer popup visible + widget.add_edit.setText("ex") + completer = widget.add_edit.completer() + if completer: + completer.popup().show() + + # Try to add tag while popup is visible + initial_count = len(fresh_db.get_tags_for_page(date_iso)) + widget._on_add_tag() + + # Should return early, not add anything + assert len(fresh_db.get_tags_for_page(date_iso)) == initial_count + + +def test_page_tags_widget_set_date_when_collapsed(app, fresh_db): + """Test setting date when widget is collapsed""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + # Ensure widget is collapsed + widget.toggle_btn.setChecked(False) + + # Set date - should clear chips since collapsed + widget.set_current_date(date_iso) + + assert widget._current_date == date_iso + # Chips should be cleared when collapsed + assert widget.chip_layout.count() == 0 + + +def test_page_tags_widget_set_date_when_expanded(app, fresh_db): + """Test setting date when widget is expanded""" + widget = PageTagsWidget(fresh_db) + widget.show() + date_iso = "2024-01-15" + + fresh_db.set_tags_for_page(date_iso, ["tag1", "tag2"]) + + # Expand widget + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Set date - should reload tags since expanded + widget.set_current_date(date_iso) + + assert widget.chip_layout.count() == 2 + + +def test_page_tags_widget_toggle_without_date(app, fresh_db): + """Test toggling widget without a current date set""" + widget = PageTagsWidget(fresh_db) + widget.show() + + # Try to expand without setting a date + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Should not crash + assert widget.body.isVisible() + + +# ============================================================================ +# TagBrowserDialog User Interaction Tests +# ============================================================================ + + +def test_tag_browser_click_page_item_disables_buttons(app, fresh_db): + """Test that clicking a page item (not tag) disables edit buttons""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Get the tag item and expand it + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + + # Click the page child item + page_item = root.child(0) + dialog.tree.setCurrentItem(page_item) + dialog._on_item_clicked(page_item, 0) + + # Buttons should be disabled for page items + assert not dialog.edit_name_btn.isEnabled() + assert not dialog.change_color_btn.isEnabled() + assert not dialog.delete_btn.isEnabled() + + +def test_tag_browser_edit_name_no_item_selected(app, fresh_db): + """Test _edit_tag_name with no item selected""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Clear selection + dialog.tree.setCurrentItem(None) + + # Try to edit - should return early + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "test" + + +def test_tag_browser_edit_name_page_item_selected(app, fresh_db): + """Test _edit_tag_name with page item selected (should return early)""" + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Select a page item + root = dialog.tree.topLevelItem(0) + dialog.tree.expandItem(root) + page_item = root.child(0) + dialog.tree.setCurrentItem(page_item) + + # Try to edit - should return early + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "test" + + +def test_tag_browser_edit_name_cancelled(app, fresh_db, monkeypatch): + """Test _edit_tag_name when user cancels the dialog""" + fresh_db.set_tags_for_page("2024-01-15", ["oldname"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Select the tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return cancelled (ok=False) + def mock_get_text(*args, **kwargs): + return ("newname", False) # User cancelled + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "oldname" + + +def test_tag_browser_edit_name_empty_name(app, fresh_db, monkeypatch): + """Test _edit_tag_name with empty name""" + fresh_db.set_tags_for_page("2024-01-15", ["oldname"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return empty string + def mock_get_text(*args, **kwargs): + return ("", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "oldname" + + +def test_tag_browser_edit_name_same_name(app, fresh_db, monkeypatch): + """Test _edit_tag_name with same name (no change)""" + fresh_db.set_tags_for_page("2024-01-15", ["samename"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return same name + def mock_get_text(*args, **kwargs): + return ("samename", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be unchanged + tags = fresh_db.list_tags() + assert tags[0][1] == "samename" + + +def test_tag_browser_edit_name_success(app, fresh_db, monkeypatch): + """Test successfully editing a tag name""" + fresh_db.set_tags_for_page("2024-01-15", ["oldname"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QInputDialog.getText to return new name + def mock_get_text(*args, **kwargs): + return ("newname", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._edit_tag_name() + + # Tag should be updated + tags = fresh_db.list_tags() + assert tags[0][1] == "newname" + + +def test_tag_browser_change_color_cancelled(app, fresh_db, monkeypatch): + """Test _change_tag_color when user cancels""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + original_color = fresh_db.list_tags()[0][2] + + # Mock QColorDialog.getColor to return invalid color (cancelled) + from PySide6.QtGui import QColor + + def mock_get_color(*args, **kwargs): + return QColor() # Invalid color + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + dialog._change_tag_color() + + # Color should be unchanged + assert fresh_db.list_tags()[0][2] == original_color + + +def test_tag_browser_change_color_success(app, fresh_db, monkeypatch): + """Test successfully changing tag color""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QColorDialog.getColor to return blue + from PySide6.QtGui import QColor + + def mock_get_color(*args, **kwargs): + return QColor("#0000FF") + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + dialog._change_tag_color() + + # Color should be updated + tags = fresh_db.list_tags() + assert tags[0][2] == "#0000ff" # Qt lowercases hex colors + + +def test_tag_browser_delete_tag_cancelled(app, fresh_db, monkeypatch): + """Test _delete_tag when user cancels confirmation""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QMessageBox.question to return No + def mock_question(*args, **kwargs): + return QMessageBox.No + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_tag() + + # Tag should still exist + assert len(fresh_db.list_tags()) == 1 + + +def test_tag_browser_delete_tag_confirmed(app, fresh_db, monkeypatch): + """Test successfully deleting a tag after confirmation""" + fresh_db.set_tags_for_page("2024-01-15", ["test"]) + + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + # Mock QMessageBox.question to return Yes + def mock_question(*args, **kwargs): + return QMessageBox.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_tag() + + # Tag should be deleted + assert len(fresh_db.list_tags()) == 0 + + +# ============================================================================ +# DB Edge Cases +# ============================================================================ + + +def test_default_tag_colour_empty_name(fresh_db): + """Test _default_tag_colour with empty string""" + color = fresh_db._default_tag_colour("") + assert color == "#CCCCCC" + + +def test_default_tag_colour_none(fresh_db): + """Test _default_tag_colour with None (should handle edge case)""" + # This tests the "if not name:" condition + color = fresh_db._default_tag_colour("") + assert color == "#CCCCCC" + + +# ============================================================================ +# FlowLayout Edge Cases +# ============================================================================ + + +def test_flow_layout_take_at_invalid_index(app): + """Test FlowLayout.takeAt with out-of-bounds index""" + from PySide6.QtWidgets import QWidget, QLabel + + widget = QWidget() + layout = FlowLayout(widget) + + # Add some items + layout.addWidget(QLabel("Item 1")) + layout.addWidget(QLabel("Item 2")) + + # Try to take at invalid negative index + result = layout.takeAt(-1) + assert result is None + + # Try to take at index beyond count + result = layout.takeAt(100) + assert result is None + + # Valid index should work + result = layout.takeAt(0) + assert result is not None + + +def test_flow_layout_take_at_boundary(app): + """Test FlowLayout.takeAt at exact boundary""" + from PySide6.QtWidgets import QWidget, QLabel + + widget = QWidget() + layout = FlowLayout(widget) + + # Add items + layout.addWidget(QLabel("Item 1")) + layout.addWidget(QLabel("Item 2")) + + count = layout.count() + + # Try to take at count (should be out of bounds) + result = layout.takeAt(count) + assert result is None + + # Take at count-1 (should work) + result = layout.takeAt(count - 1) + assert result is not None + + +# ============================================================================ +# Integration Tests for Complete Coverage +# ============================================================================ + + +def test_complete_tag_lifecycle_with_browser(app, fresh_db, monkeypatch): + """Test complete tag lifecycle: create, view in browser, edit, delete""" + # Create a tag + fresh_db.save_new_version("2024-01-15", "Content", "note") + fresh_db.set_tags_for_page("2024-01-15", ["lifecycle"]) + + # Open browser + dialog = TagBrowserDialog(fresh_db) + dialog.show() + + # Select the tag + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Edit name + def mock_get_text(*args, **kwargs): + return ("renamed", True) + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + dialog._edit_tag_name() + + # After _edit_tag_name calls _populate(), need to re-select the item + # as the tree was rebuilt + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + dialog._on_item_clicked(item, 0) + + # Change color + from PySide6.QtGui import QColor + + def mock_get_color(*args, **kwargs): + return QColor("#FF0000") + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + dialog._change_tag_color() + + # Verify changes + tags = fresh_db.list_tags() + assert tags[0][1] == "renamed" + # Qt lowercases hex colors + assert tags[0][2].lower() == "#ff0000" + + # Delete tag - need to re-select after _change_tag_color also calls _populate + item = dialog.tree.topLevelItem(0) + dialog.tree.setCurrentItem(item) + + def mock_question(*args, **kwargs): + return QMessageBox.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + dialog._delete_tag() + + # Tag should be gone + assert len(fresh_db.list_tags()) == 0 + + +def test_tag_widget_with_completer_interaction(app, fresh_db): + """Test tag widget with autocomplete interaction""" + widget = PageTagsWidget(fresh_db) + widget.show() + + # Create some tags + date1 = "2024-01-15" + fresh_db.set_tags_for_page(date1, ["alpha", "beta", "gamma"]) + + # Set up widget with different date + date2 = "2024-01-16" + widget.set_current_date(date2) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Autocomplete should have previous tags + completer = widget.add_edit.completer() + assert completer is not None + + # Add a tag that exists in autocomplete + widget.add_edit.setText("alpha") + widget._on_add_tag() + + # Should be added to current page + tags = fresh_db.get_tags_for_page(date2) + tag_names = [name for _, name, _ in tags] + assert "alpha" in tag_names + + +def test_multiple_widgets_same_database(app, fresh_db): + """Test multiple tag widgets operating on same database""" + widget1 = PageTagsWidget(fresh_db) + widget2 = PageTagsWidget(fresh_db) + + widget1.show() + widget2.show() + + date_iso = "2024-01-15" + + # Widget 1 adds a tag + widget1.set_current_date(date_iso) + widget1.toggle_btn.setChecked(True) + widget1._on_toggle(True) + widget1.add_edit.setText("shared") + widget1._on_add_tag() + + # Widget 2 should see it when set to same date + widget2.set_current_date(date_iso) + widget2.toggle_btn.setChecked(True) + widget2._on_toggle(True) + + assert widget2.chip_layout.count() == 1 diff --git a/tests/test_theme.py b/tests/test_theme.py index 0370300..6f19a62 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,4 +1,3 @@ -import pytest from PySide6.QtGui import QPalette from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget @@ -15,7 +14,6 @@ def test_theme_manager_apply_light_and_dark(app): 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) diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index 0353d91..3794760 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -14,7 +14,6 @@ def editor(app, qtbot): return ed -@pytest.mark.gui def test_toolbar_signals_and_styling(qtbot, editor): host = QWidget() qtbot.addWidget(host) diff --git a/vulture_ignorelist.py b/vulture_ignorelist.py new file mode 100644 index 0000000..bf203c5 --- /dev/null +++ b/vulture_ignorelist.py @@ -0,0 +1,22 @@ +from bouquin.flow_layout import FlowLayout +from bouquin.markdown_editor import MarkdownEditor +from bouquin.markdown_highlighter import MarkdownHighlighter +from bouquin.db import DBManager + +DBManager.row_factory + +FlowLayout.itemAt +FlowLayout.expandingDirections +FlowLayout.hasHeightForWidth +FlowLayout.heightForWidth + +MarkdownEditor.apply_weight +MarkdownEditor.apply_italic +MarkdownEditor.apply_strikethrough +MarkdownEditor.apply_code +MarkdownEditor.apply_heading +MarkdownEditor.toggle_bullets +MarkdownEditor.toggle_numbers +MarkdownEditor.toggle_checkboxes + +MarkdownHighlighter.highlightBlock From 1becb7900ea27881c5e93440973a3a07a5160d32 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 14 Nov 2025 17:30:58 +1100 Subject: [PATCH 5/5] Prevent traceback on trying to edit a tag with the same name as another tag. Various other tweaks. Bump version --- CHANGELOG.md | 9 ++++--- README.md | 4 ++- bouquin/db.py | 40 ++++++++++++++++++++++-------- bouquin/locales/en.json | 6 +++-- bouquin/locales/fr.json | 22 +++++++++-------- bouquin/locales/it.json | 3 ++- bouquin/main_window.py | 9 +++++++ bouquin/markdown_highlighter.py | 2 +- bouquin/tag_browser.py | 40 ++++++++++++++++++++++++++---- bouquin/tags_widget.py | 5 +++- bouquin/theme.py | 2 +- pyproject.toml | 2 +- tests/test_main_window.py | 20 +++++++-------- tests/test_markdown_editor.py | 44 ++++++++++++++++----------------- tests/test_tags.py | 28 ++++++++++----------- 15 files changed, 153 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b02f50b..6c0871b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,14 @@ -# 0.2.1.9 +# 0.3 - * Fix a few small matters identified with tests - * Make locales dynamically detected from the locales dir rather than hardcoded + * Introduce Tags + * Make translations dynamically detected from the locales dir rather than hardcoded + * Add Italian translations (thanks @mdaleo404) * Add version information in the navigation * Increase line spacing between lines (except for code blocks) - * Add Italian translations (thanks @mdaleo404) * Prevent being able to left-click a date and have it load in current tab if it is already open in another tab * Avoid second checkbox/bullet on second newline after first newline * Avoid Home/left arrow jumping to the left side of a list symbol + * Various test additions/fixes # 0.2.1.8 diff --git a/README.md b/README.md index 96bdf58..b904f20 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ There is deliberately no network connectivity or syncing intended. * Tabs are supported - right-click on a date from the calendar to open it in a new tab. * Images are supported * Search all pages, or find text on page (Ctrl+F) + * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours * Automatic periodic saving (or explicitly save) * Transparent integrity checking of the database when it opens * Automatic locking of the app after a period of inactivity (default 15 min) @@ -37,7 +38,8 @@ There is deliberately no network connectivity or syncing intended. * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin) * Dark and light themes * Automatically generate checkboxes when typing 'TODO' - * Optionally automatically move unchecked checkboxes from yesterday to today, on startup + * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup + * English, French and Italian locales provided ## How to install diff --git a/bouquin/db.py b/bouquin/db.py index 60a58c4..c0fdee2 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -23,6 +23,20 @@ _TAG_COLORS = [ "#BAFFC9", # soft green "#BAE1FF", # soft blue "#E0BAFF", # soft purple + "#FFC4B3", # soft coral + "#FFD8B1", # soft peach + "#FFF1BA", # soft light yellow + "#E9FFBA", # soft lime + "#CFFFE5", # soft mint + "#BAFFF5", # soft aqua + "#BAF0FF", # soft cyan + "#C7E9FF", # soft sky blue + "#C7CEFF", # soft periwinkle + "#F0BAFF", # soft lavender pink + "#FFBAF2", # soft magenta + "#FFD1F0", # soft pink + "#EBD5C7", # soft beige + "#EAEAEA", # soft gray ] @@ -554,16 +568,22 @@ class DBManager: name = name.strip() color = color.strip() or "#CCCCCC" - with self.conn: - cur = self.conn.cursor() - cur.execute( - """ - UPDATE tags - SET name = ?, color = ? - WHERE id = ?; - """, - (name, color, tag_id), - ) + try: + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + UPDATE tags + SET name = ?, color = ? + WHERE id = ?; + """, + (name, color, tag_id), + ) + except sqlite.IntegrityError as e: + if "UNIQUE constraint failed: tags.name" in str(e): + raise sqlite.IntegrityError( + strings._("tag_already_exists_with_that_name") + ) from e def delete_tag(self, tag_id: int) -> None: """ diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index e5957ea..a3c9228 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -112,13 +112,14 @@ "toolbar_heading": "Heading", "toolbar_toggle_checkboxes": "Toggle checkboxes", "tags": "Tags", + "tag": "Tag", "manage_tags": "Manage tags", "add_tag_placeholder": "Add a tag and press Enter", "tag_browser_title": "Tag Browser", "tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.", "tag_name": "Tag name", "tag_color_hex": "Hex colour", - "color_hex": "Color", + "color_hex": "Colour", "date": "Date", "pick_color": "Pick colour", "invalid_color_title": "Invalid colour", @@ -130,5 +131,6 @@ "new_tag_name": "New tag name:", "change_color": "Change colour", "delete_tag": "Delete tag", - "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages." + "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.", + "tag_already_exists_with_that_name": "A tag already exists with that name" } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 2dbe11a..4944bf5 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -111,12 +111,13 @@ "toolbar_code_block": "Bloc de code", "toolbar_heading": "Titre", "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", - "tags": "Tags", - "manage_tags": "Gérer les tags", - "add_tag_placeholder": "Ajouter un tag et appuyez sur Entrée", - "tag_browser_title": "Navigateur de tags", - "tag_browser_instructions": "Cliquez sur un tag pour l'étendre et voir toutes les pages avec ce tag. Cliquez sur une date pour l'ouvrir. Sélectionnez un tag pour modifier son nom, changer sa couleur ou le supprimer globalement.", - "tag_name": "Nom du tag", + "tags": "Étiquettes", + "tag": "Étiquette", + "manage_tags": "Gérer les étiquettes", + "add_tag_placeholder": "Ajouter une étiquette et appuyez sur Entrée", + "tag_browser_title": "Navigateur de étiquettes", + "tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.", + "tag_name": "Nom de l'étiquette", "tag_color_hex": "Couleur hexadécimale", "color_hex": "Couleur", "date": "Date", @@ -126,9 +127,10 @@ "add": "Ajouter", "remove": "Supprimer", "ok": "OK", - "edit_tag_name": "Modifier le nom du tag", - "new_tag_name": "Nouveau nom du tag :", + "edit_tag_name": "Modifier le nom de l'étiquette", + "new_tag_name": "Nouveau nom de l'étiquette :", "change_color": "Changer la couleur", - "delete_tag": "Supprimer le tag", - "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer le tag '{name}' ? Cela le supprimera de toutes les pages." + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.", + "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà" } diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json index d6d2018..5e956f0 100644 --- a/bouquin/locales/it.json +++ b/bouquin/locales/it.json @@ -130,5 +130,6 @@ "new_tag_name": "Nuovo nome tag:", "change_color": "Cambia colore", "delete_tag": "Elimina tag", - "delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine." + "delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.", + "tag_already_exists_with_that_name": "Esiste già un tag con questo nome" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 8692ea9..ce008fa 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1077,8 +1077,17 @@ class MainWindow(QMainWindow): dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date) dlg.openDateRequested.connect(self._load_selected_date) + dlg.tagsModified.connect(self._refresh_current_page_tags) dlg.exec() + def _refresh_current_page_tags(self): + """Refresh the tag chips for the current page (after tag browser changes)""" + if hasattr(self, "tags") and hasattr(self.editor, "current_date"): + date_iso = self.editor.current_date.toString("yyyy-MM-dd") + self.tags.set_current_date(date_iso) + if self.tags.toggle_btn.isChecked(): + self.tags._reload_tags() + # ----------- Settings handler ------------# def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index e68f03c..3576d1b 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -199,7 +199,7 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.setFormat(end - 2, 2, self.syntax_format) self.setFormat(content_start, content_end - content_start, self.bold_format) - # --- Italic (*) or (_): skip if it overlaps any triple, keep your guards + # --- Italic (*) or (_): skip if it overlaps any triple for m in re.finditer( r"(? 0.5 else QColor(255, 255, 255) + root.setForeground(1, text_color) root.setText(1, color) # Also show the hex code root.setTextAlignment(1, Qt.AlignCenter) @@ -112,6 +130,9 @@ class TagBrowserDialog(QDialog): self.tree.expandItem(focus_item) self.tree.setCurrentItem(focus_item) + # Re-enable sorting after population + self.tree.setSortingEnabled(was_sorting) + def _on_item_clicked(self, item: QTreeWidgetItem, column: int): """Enable/disable buttons based on selection""" data = item.data(0, Qt.ItemDataRole.UserRole) @@ -156,8 +177,12 @@ class TagBrowserDialog(QDialog): ) if ok and new_name and new_name != old_name: - self._db.update_tag(tag_id, new_name, color) - self._populate(None) + try: + self._db.update_tag(tag_id, new_name, color) + self._populate(None) + self.tagsModified.emit() + except IntegrityError as e: + QMessageBox.critical(self, strings._("db_database_error"), str(e)) def _change_tag_color(self): """Change the color of the selected tag""" @@ -175,8 +200,12 @@ class TagBrowserDialog(QDialog): color = QColorDialog.getColor(QColor(current_color), self) if color.isValid(): - self._db.update_tag(tag_id, name, color.name()) - self._populate(None) + try: + self._db.update_tag(tag_id, name, color.name()) + self._populate(None) + self.tagsModified.emit() + except IntegrityError as e: + QMessageBox.critical(self, strings._("db_database_error"), str(e)) def _delete_tag(self): """Delete the selected tag""" @@ -203,3 +232,4 @@ class TagBrowserDialog(QDialog): if reply == QMessageBox.Yes: self._db.delete_tag(tag_id) self._populate(None) + self.tagsModified.emit() diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py index 0e50446..423bd06 100644 --- a/bouquin/tags_widget.py +++ b/bouquin/tags_widget.py @@ -70,7 +70,10 @@ class TagChip(QFrame): def mouseReleaseEvent(self, ev): if ev.button() == Qt.LeftButton: self.clicked.emit(self._name) - super().mouseReleaseEvent(ev) + try: + super().mouseReleaseEvent(ev) + except RuntimeError: + pass class PageTagsWidget(QFrame): diff --git a/bouquin/theme.py b/bouquin/theme.py index 3846398..305f249 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -204,7 +204,7 @@ class ThemeManager(QObject): ) if is_dark: - # Use the link color as the accent (you set this to ORANGE in dark palette) + # Use the link color as the accent accent = pal.color(QPalette.Link) r, g, b = accent.red(), accent.green(), accent.blue() accent_hex = accent.name() diff --git a/pyproject.toml b/pyproject.toml index 1bbde99..6b057a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.2.1.8" +version = "0.3" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 2962a34..9c83b11 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -435,7 +435,7 @@ def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path): # Avoid accidentaly creating DB by short-circuiting the prompt loop class MW(MainWindow): def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802 - assert first_time is True # hit line 73 path + assert first_time is True return False with pytest.raises(SystemExit): @@ -938,7 +938,7 @@ def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch # remove timer to hit early return delattr(w, "_idle_timer") - w._apply_idle_minutes(5) # no crash => line 1176 branch + w._apply_idle_minutes(5) # no crash # re-create a timer and simulate locking then disabling idle w._idle_timer = QTimer(w) @@ -1474,7 +1474,7 @@ def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch): # ============================================================================ -# Tag Save Handler Tests (lines 1050-1068) +# Tag Save Handler Tests # ============================================================================ @@ -1525,7 +1525,7 @@ def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatc def test_main_window_on_tag_added_triggers_deferred_save( app, fresh_db, tmp_db_cfg, monkeypatch ): - """Test that _on_tag_added defers the save (lines 1043-1048)""" + """Test that _on_tag_added defers the save""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), @@ -1546,7 +1546,7 @@ def test_main_window_on_tag_added_triggers_deferred_save( # ============================================================================ -# Tag Activation Tests (lines 1070-1080) +# Tag Activation Tests # ============================================================================ @@ -1600,7 +1600,7 @@ def test_main_window_on_tag_activated_with_tag_name( # ============================================================================ -# Settings Path Change Tests (lines 1105-1116) +# Settings Path Change Tests # ============================================================================ @@ -1651,7 +1651,7 @@ def test_main_window_settings_path_change_success( def test_main_window_settings_path_change_failure( app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch ): - """Test failed database path change shows warning (lines 1108-1113)""" + """Test failed database path change shows warning""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), @@ -1691,7 +1691,7 @@ def test_main_window_settings_path_change_failure( def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch): - """Test settings change without path change (lines 1105 condition False)""" + """Test settings change without path change""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), @@ -1729,7 +1729,7 @@ def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypa def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch): - """Test cancelling settings dialog (line 1085-1086)""" + """Test cancelling settings dialog""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), @@ -1753,7 +1753,7 @@ def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch): # ============================================================================ -# Update Tag Views Tests (lines 1039-1041) +# Update Tag Views Tests # ============================================================================ diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index a8be6a7..13244f6 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -374,7 +374,7 @@ def test_theme_change_rehighlight(highlighter): @pytest.fixture def hl_light(app): - # Light theme path (covers lines ~74-75 in _on_theme_changed) + # Light theme path tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) doc = QTextDocument() hl = MarkdownHighlighter(doc, tm) @@ -435,7 +435,7 @@ def test_code_block_light_colors(hl_light): def test_end_guard_skips_italic_followed_by_marker(hl_light): """ - Triggers the end-following guard for italic (line ~208), e.g. '*i**'. + Triggers the end-following guard for italic e.g. '*i**'. """ doc, hl = hl_light doc.setPlainText("*i**") @@ -543,7 +543,7 @@ def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path): # ============================================================================ -# setDocument Tests (lines 75-81) +# setDocument Tests # ============================================================================ @@ -582,7 +582,7 @@ def test_markdown_editor_set_document_with_highlighter(app): # ============================================================================ -# showEvent Tests (lines 83-86) +# showEvent Tests # ============================================================================ @@ -604,7 +604,7 @@ def test_markdown_editor_show_event(app, qtbot): # ============================================================================ -# Checkbox Transformation Tests (lines 100-133) +# Checkbox Transformation Tests # ============================================================================ @@ -645,7 +645,7 @@ def test_markdown_editor_transform_checked_checkbox(app, qtbot): def test_markdown_editor_transform_todo(app, qtbot): - """Test transforming TODO to unchecked checkbox (lines 110-114)""" + """Test transforming TODO to unchecked checkbox""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) editor = MarkdownEditor(themes) editor.show() @@ -726,7 +726,7 @@ def test_markdown_editor_no_transform_when_updating(app): editor.insertPlainText("- [ ] Task") # Should NOT transform since _updating is True - # This tests the early return in _on_text_changed (lines 90-91) + # This tests the early return in _on_text_changed assert editor._updating @@ -779,7 +779,7 @@ def test_markdown_editor_update_code_block_backgrounds(app): # ============================================================================ -# Image Insertion Tests (lines 336-366) +# Image Insertion Tests # ============================================================================ @@ -811,7 +811,7 @@ def test_markdown_editor_insert_image_from_path(app, tmp_path): # ============================================================================ -# Formatting Tests (missing lines in various formatting methods) +# Formatting Tests # ============================================================================ @@ -886,7 +886,7 @@ def test_markdown_editor_toggle_code_empty_selection(app): # ============================================================================ -# Heading Tests (lines 455-459) +# Heading Tests # ============================================================================ @@ -932,7 +932,7 @@ def test_markdown_editor_set_heading_zero_removes_heading(app): # ============================================================================ -# List Tests (lines 483-519) +# List Tests # ============================================================================ @@ -972,7 +972,7 @@ def test_markdown_editor_toggle_list_ordered(app): # ============================================================================ -# Code Block Tests (lines 540-577) +# Code Block Tests # ============================================================================ @@ -1016,7 +1016,7 @@ def test_markdown_editor_apply_code_remove(app): # ============================================================================ -# Checkbox Tests (lines 596-600) +# Checkbox Tests # ============================================================================ @@ -1032,7 +1032,7 @@ def test_markdown_editor_insert_checkbox_unchecked(app): # ============================================================================ -# Toggle Checkboxes Tests (lines 659-660, 686-691) +# Toggle Checkboxes Tests # ============================================================================ @@ -1071,7 +1071,7 @@ def test_markdown_editor_toggle_checkboxes_mixed(app): # ============================================================================ -# Markdown Conversion Tests (lines 703, 710-714, 731) +# Markdown Conversion Tests # ============================================================================ @@ -1113,7 +1113,7 @@ def test_markdown_editor_from_markdown_with_links(app): # ============================================================================ -# Selection and Cursor Tests (lines 747-752) +# Selection and Cursor Tests # ============================================================================ @@ -1152,7 +1152,7 @@ def test_markdown_editor_get_selected_blocks(app): # ============================================================================ -# Key Event Tests (lines 795, 806-809) +# Key Event Tests # ============================================================================ @@ -1194,7 +1194,7 @@ def test_markdown_editor_key_press_return_in_list(app): # ============================================================================ -# Link Handling Tests (lines 898, 922, 949, 990) +# Link Handling Tests # ============================================================================ @@ -1229,12 +1229,12 @@ def test_markdown_editor_mouse_move_over_link(app): # ============================================================================ -# Theme Mode Tests (lines 72-79) +# Theme Mode Tests # ============================================================================ def test_markdown_highlighter_light_mode(app): - """Test highlighter in light mode (lines 74-77)""" + """Test highlighter in light mode""" doc = QTextDocument() themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) highlighter = MarkdownHighlighter(doc, themes) @@ -1252,7 +1252,7 @@ def test_markdown_highlighter_light_mode(app): def test_markdown_highlighter_dark_mode(app): - """Test highlighter in dark mode (lines 70-71)""" + """Test highlighter in dark mode""" doc = QTextDocument() themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) highlighter = MarkdownHighlighter(doc, themes) @@ -1266,7 +1266,7 @@ def test_markdown_highlighter_dark_mode(app): # ============================================================================ -# Highlighting Pattern Tests (lines 196, 208, 211, 213) +# Highlighting Pattern Tests # ============================================================================ diff --git a/tests/test_tags.py b/tests/test_tags.py index 8022e11..c8bc804 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -786,7 +786,7 @@ def test_tag_page_without_content(fresh_db): # ============================================================================ -# TagChip Mouse Event Tests (tags_widget.py lines 70-73) +# TagChip Mouse Event Tests # ============================================================================ @@ -844,12 +844,12 @@ def test_tag_chip_right_click_no_signal(app, qtbot): # ============================================================================ -# PageTagsWidget Edge Cases (tags_widget.py missing lines) +# PageTagsWidget Edge Cases # ============================================================================ def test_page_tags_widget_add_tag_with_completer_popup_visible(app, fresh_db): - """Test adding tag when completer popup is visible (line 148)""" + """Test adding tag when completer popup is visible""" widget = PageTagsWidget(fresh_db) widget.show() date_iso = "2024-01-15" @@ -906,12 +906,12 @@ def test_page_tags_widget_no_current_date_remove_tag(app, fresh_db): # ============================================================================ -# TagBrowserDialog Interactive Tests (tag_browser.py lines 124-126, 139-205) +# TagBrowserDialog Interactive Tests # ============================================================================ def test_tag_browser_button_states_with_page_item(app, fresh_db): - """Test that buttons are disabled when clicking a page item (lines 124-126)""" + """Test that buttons are disabled when clicking a page item""" fresh_db.save_new_version("2024-01-15", "Content", "note") fresh_db.set_tags_for_page("2024-01-15", ["test"]) @@ -936,7 +936,7 @@ def test_tag_browser_button_states_with_page_item(app, fresh_db): def test_tag_browser_edit_tag_name_no_item(app, fresh_db): - """Test editing tag name when no item is selected (lines 139-141)""" + """Test editing tag name when no item is selected""" dialog = TagBrowserDialog(fresh_db) # Try to edit without selecting anything @@ -947,7 +947,7 @@ def test_tag_browser_edit_tag_name_no_item(app, fresh_db): def test_tag_browser_edit_tag_name_page_item(app, fresh_db): - """Test editing tag name when a page item is selected (lines 143-145)""" + """Test editing tag name when a page item is selected""" fresh_db.save_new_version("2024-01-15", "Content", "note") fresh_db.set_tags_for_page("2024-01-15", ["test"]) @@ -969,7 +969,7 @@ def test_tag_browser_edit_tag_name_page_item(app, fresh_db): def test_tag_browser_change_color_no_item(app, fresh_db): - """Test changing color when no item is selected (lines 164-166)""" + """Test changing color when no item is selected""" dialog = TagBrowserDialog(fresh_db) # Try to change color without selecting anything @@ -980,7 +980,7 @@ def test_tag_browser_change_color_no_item(app, fresh_db): def test_tag_browser_change_color_page_item(app, fresh_db): - """Test changing color when a page item is selected (lines 168-170)""" + """Test changing color when a page item is selected""" fresh_db.save_new_version("2024-01-15", "Content", "note") fresh_db.set_tags_for_page("2024-01-15", ["test"]) @@ -1002,7 +1002,7 @@ def test_tag_browser_change_color_page_item(app, fresh_db): def test_tag_browser_delete_tag_no_item(app, fresh_db): - """Test deleting tag when no item is selected (lines 183-185)""" + """Test deleting tag when no item is selected""" dialog = TagBrowserDialog(fresh_db) # Try to delete without selecting anything @@ -1013,7 +1013,7 @@ def test_tag_browser_delete_tag_no_item(app, fresh_db): def test_tag_browser_delete_tag_page_item(app, fresh_db): - """Test deleting tag when a page item is selected (lines 187-189)""" + """Test deleting tag when a page item is selected""" fresh_db.save_new_version("2024-01-15", "Content", "note") fresh_db.set_tags_for_page("2024-01-15", ["test"]) @@ -1036,12 +1036,12 @@ def test_tag_browser_delete_tag_page_item(app, fresh_db): # ============================================================================ -# FlowLayout Edge Case (flow_layout.py line 28) +# FlowLayout Edge Case # ============================================================================ def test_flow_layout_take_at_out_of_bounds(app): - """Test FlowLayout.takeAt with invalid index (line 28)""" + """Test FlowLayout.takeAt with invalid index""" layout = FlowLayout() # Try to take item at index that doesn't exist @@ -1063,7 +1063,7 @@ def test_flow_layout_take_at_negative(app): # ============================================================================ -# DB Edge Case (db.py line 434) +# DB Edge Case for tags # ============================================================================