diff --git a/CHANGELOG.md b/CHANGELOG.md index 07944f2..06ef835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.1.2 + + * Switch from Markdown to HTML via QTextEdit, with a toolbar + * Add search ability + * Fix Settings shortcut and change nav menu from 'File' to 'Application' + # 0.1.1 * Add ability to change the key diff --git a/README.md b/README.md index b874668..3307543 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,15 @@ There is deliberately no network connectivity or syncing intended. ## Features * Every 'page' is linked to the calendar day - * Basic markdown + * Text is HTML with basic styling + * Search * Automatic periodic saving (or explicitly save) - * Navigating from one day to the next automatically saves - * Basic keyboard shortcuts * Transparent integrity checking of the database when it opens + * Rekey the database (change the password) ## Yet to do - * Search * Taxonomy/tagging * Export to other formats (plaintext, json, sql etc) @@ -47,7 +46,7 @@ There is deliberately no network connectivity or syncing intended. * Download the whl and run it -### From PyPi +### From PyPi/pip * `pip install bouquin` diff --git a/bouquin/db.py b/bouquin/db.py index 15cc4c9..c75847e 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -100,6 +100,12 @@ class DBManager: ) self.conn.commit() + def search_entries(self, text: str) -> list[str]: + cur = self.conn.cursor() + pattern = f"%{text}%" + cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)) + return [r for r in cur.fetchall()] + def dates_with_content(self) -> list[str]: cur = self.conn.cursor() cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") diff --git a/bouquin/editor.py b/bouquin/editor.py new file mode 100644 index 0000000..7fe55c0 --- /dev/null +++ b/bouquin/editor.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from PySide6.QtGui import ( + QColor, + QFont, + QFontDatabase, + QTextCharFormat, + QTextListFormat, + QTextBlockFormat, +) +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QTextEdit + + +class Editor(QTextEdit): + def __init__(self): + super().__init__() + tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") + self.setTabStopDistance(tab_w) + + def merge_on_sel(self, fmt): + """ + Sets the styling on the selected characters. + """ + cursor = self.textCursor() + if not cursor.hasSelection(): + cursor.select(cursor.SelectionType.WordUnderCursor) + cursor.mergeCharFormat(fmt) + self.mergeCurrentCharFormat(fmt) + + @Slot(QFont.Weight) + def apply_weight(self, weight): + fmt = QTextCharFormat() + fmt.setFontWeight(weight) + self.merge_on_sel(fmt) + + @Slot() + def apply_italic(self): + cur = self.currentCharFormat() + fmt = QTextCharFormat() + fmt.setFontItalic(not cur.fontItalic()) + self.merge_on_sel(fmt) + + @Slot() + def apply_underline(self): + cur = self.currentCharFormat() + fmt = QTextCharFormat() + fmt.setFontUnderline(not cur.fontUnderline()) + self.merge_on_sel(fmt) + + @Slot() + def apply_strikethrough(self): + cur = self.currentCharFormat() + fmt = QTextCharFormat() + fmt.setFontStrikeOut(not cur.fontStrikeOut()) + self.merge_on_sel(fmt) + + @Slot() + def apply_code(self): + c = self.textCursor() + if not c.hasSelection(): + c.select(c.SelectionType.BlockUnderCursor) + + bf = QTextBlockFormat() + bf.setLeftMargin(12) + bf.setRightMargin(12) + bf.setTopMargin(6) + bf.setBottomMargin(6) + bf.setBackground(QColor(245, 245, 245)) + bf.setNonBreakableLines(True) + + cf = QTextCharFormat() + mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + cf.setFont(mono) + cf.setFontFixedPitch(True) + + # If the current block already looks like a code block, remove styling + cur_bf = c.blockFormat() + is_code = ( + cur_bf.nonBreakableLines() + and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb() + ) + if is_code: + # clear: margins/background/wrapping + bf = QTextBlockFormat() + cf = QTextCharFormat() + + c.mergeBlockFormat(bf) + c.mergeBlockCharFormat(cf) + + @Slot(int) + def apply_heading(self, size): + fmt = QTextCharFormat() + if size: + fmt.setFontWeight(QFont.Weight.Bold) + fmt.setFontPointSize(size) + else: + fmt.setFontWeight(QFont.Weight.Normal) + fmt.setFontPointSize(self.font().pointSizeF()) + self.merge_on_sel(fmt) + + def toggle_bullets(self): + c = self.textCursor() + lst = c.currentList() + if lst and lst.format().style() == QTextListFormat.Style.ListDisc: + lst.remove(c.block()) + return + fmt = QTextListFormat() + fmt.setStyle(QTextListFormat.Style.ListDisc) + c.createList(fmt) + + def toggle_numbers(self): + c = self.textCursor() + lst = c.currentList() + if lst and lst.format().style() == QTextListFormat.Style.ListDecimal: + lst.remove(c.block()) + return + fmt = QTextListFormat() + fmt.setStyle(QTextListFormat.Style.ListDecimal) + c.createList(fmt) diff --git a/bouquin/highlighter.py b/bouquin/highlighter.py deleted file mode 100644 index 456dfa2..0000000 --- a/bouquin/highlighter.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import re -from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor - - -class MarkdownHighlighter(QSyntaxHighlighter): - ST_NORMAL = 0 - ST_CODE = 1 - - FENCE = re.compile(r"^```") - - def __init__(self, document): - super().__init__(document) - - base_size = document.defaultFont().pointSizeF() or 12.0 - - # Monospace for code - self.mono = QFont("Monospace") - self.mono.setStyleHint(QFont.TypeWriter) - - # Light, high-contrast scheme for code - self.col_bg = QColor("#eef2f6") # light code bg - self.col_fg = QColor("#1f2328") # dark text - - # Formats - self.fmt_h = [QTextCharFormat() for _ in range(6)] - for i, f in enumerate(self.fmt_h, start=1): - f.setFontWeight(QFont.Weight.Bold) - f.setFontPointSize(base_size + (7 - i)) - self.fmt_bold = QTextCharFormat() - self.fmt_bold.setFontWeight(QFont.Weight.Bold) - self.fmt_italic = QTextCharFormat() - self.fmt_italic.setFontItalic(True) - self.fmt_quote = QTextCharFormat() - self.fmt_quote.setForeground(QColor("#6a737d")) - self.fmt_link = QTextCharFormat() - self.fmt_link.setFontUnderline(True) - self.fmt_list = QTextCharFormat() - self.fmt_list.setFontWeight(QFont.Weight.DemiBold) - self.fmt_strike = QTextCharFormat() - self.fmt_strike.setFontStrikeOut(True) - - # Uniform code style - self.fmt_code = QTextCharFormat() - self.fmt_code.setFont(self.mono) - self.fmt_code.setFontPointSize(max(6.0, base_size - 1)) - self.fmt_code.setBackground(self.col_bg) - self.fmt_code.setForeground(self.col_fg) - - # Simple patterns - self.re_heading = re.compile(r"^(#{1,6}) +.*$") - self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__") - self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_") - self.re_strike = re.compile(r"~~(.+?)~~") - self.re_inline_code = re.compile(r"`([^`]+)`") - self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") - self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)") - self.re_quote = re.compile(r"^> ?.*$") - - def highlightBlock(self, text: str) -> None: - prev = self.previousBlockState() - in_code = prev == self.ST_CODE - - if in_code: - # Entire line is code - self.setFormat(0, len(text), self.fmt_code) - if self.FENCE.match(text): - self.setCurrentBlockState(self.ST_NORMAL) - else: - self.setCurrentBlockState(self.ST_CODE) - return - - # Starting/ending a fenced block? - if self.FENCE.match(text): - self.setFormat(0, len(text), self.fmt_code) - self.setCurrentBlockState(self.ST_CODE) - return - - # --- Normal markdown styling --- - m = self.re_heading.match(text) - if m: - level = min(len(m.group(1)), 6) - self.setFormat(0, len(text), self.fmt_h[level - 1]) - self.setCurrentBlockState(self.ST_NORMAL) - return - - m = self.re_list.match(text) - if m: - self.setFormat(m.start(), m.end() - m.start(), self.fmt_list) - - if self.re_quote.match(text): - self.setFormat(0, len(text), self.fmt_quote) - - for m in self.re_inline_code.finditer(text): - self.setFormat(m.start(), m.end() - m.start(), self.fmt_code) - - for m in self.re_bold.finditer(text): - self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold) - - for m in self.re_italic.finditer(text): - self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic) - - for m in self.re_strike.finditer(text): - self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike) - - for m in self.re_link.finditer(text): - start = m.start(1) - 1 - length = len(m.group(1)) + 2 - self.setFormat(start, length, self.fmt_link) - - self.setCurrentBlockState(self.ST_NORMAL) diff --git a/bouquin/main.py b/bouquin/main.py index 9beb4d9..3e5f90b 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -11,5 +11,6 @@ def main(): app = QApplication(sys.argv) app.setApplicationName(APP_NAME) app.setOrganizationName(APP_ORG) - win = MainWindow(); win.show() + win = MainWindow() + win.show() sys.exit(app.exec()) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 6b0451c..bceaa8d 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -3,24 +3,29 @@ from __future__ import annotations import sys from PySide6.QtCore import QDate, QTimer, Qt -from PySide6.QtGui import QAction, QFont, QTextCharFormat +from PySide6.QtGui import ( + QAction, + QFont, + QTextCharFormat, +) from PySide6.QtWidgets import ( - QDialog, QCalendarWidget, + QDialog, QMainWindow, QMessageBox, - QPlainTextEdit, + QSizePolicy, QSplitter, QVBoxLayout, QWidget, - QSizePolicy, ) from .db import DBManager -from .settings import APP_NAME, load_db_config, save_db_config +from .editor import Editor from .key_prompt import KeyPrompt -from .highlighter import MarkdownHighlighter +from .search import Search +from .settings import APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog +from .toolbar import ToolBar class MainWindow(QMainWindow): @@ -40,17 +45,35 @@ class MainWindow(QMainWindow): self.calendar.setGridVisible(True) self.calendar.selectionChanged.connect(self._on_date_changed) + self.search = Search(self.db) + self.search.openDateRequested.connect(self._load_selected_date) + + # Lock the calendar to the left panel at the top to stop it stretching + # when the main window is resized. left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(8, 8, 8, 8) left_layout.addWidget(self.calendar, alignment=Qt.AlignTop) + left_layout.addWidget(self.search, alignment=Qt.AlignBottom) left_layout.addStretch(1) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) - self.editor = QPlainTextEdit() - tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ") - self.editor.setTabStopDistance(tab_w) - self.highlighter = MarkdownHighlighter(self.editor.document()) + # This is the note-taking editor + self.editor = Editor() + + # Toolbar for controlling styling + tb = ToolBar() + self.addToolBar(tb) + # Wire toolbar intents to editor methods + tb.boldRequested.connect(self.editor.apply_weight) + tb.italicRequested.connect(self.editor.apply_italic) + tb.underlineRequested.connect(self.editor.apply_underline) + tb.strikeRequested.connect(self.editor.apply_strikethrough) + tb.codeRequested.connect(self.editor.apply_code) + tb.headingRequested.connect(self.editor.apply_heading) + tb.bulletsRequested.connect(self.editor.toggle_bullets) + tb.numbersRequested.connect(self.editor.toggle_numbers) + tb.alignRequested.connect(self.editor.setAlignment) split = QSplitter() split.addWidget(left_panel) @@ -67,13 +90,13 @@ class MainWindow(QMainWindow): # Menu bar (File) mb = self.menuBar() - file_menu = mb.addMenu("&File") + file_menu = mb.addMenu("&Application") act_save = QAction("&Save", self) act_save.setShortcut("Ctrl+S") act_save.triggered.connect(lambda: self._save_current(explicit=True)) file_menu.addAction(act_save) act_settings = QAction("S&ettings", self) - act_save.setShortcut("Ctrl+E") + act_settings.setShortcut("Ctrl+E") act_settings.triggered.connect(self._open_settings) file_menu.addAction(act_settings) file_menu.addSeparator() @@ -82,7 +105,7 @@ class MainWindow(QMainWindow): act_quit.triggered.connect(self.close) file_menu.addAction(act_quit) - # Navigate menu with next/previous day + # Navigate menu with next/previous/today nav_menu = mb.addMenu("&Navigate") act_prev = QAction("Previous Day", self) act_prev.setShortcut("Ctrl+P") @@ -112,12 +135,14 @@ class MainWindow(QMainWindow): self._save_timer.timeout.connect(self._save_current) self.editor.textChanged.connect(self._on_text_changed) - # First load + mark dates with content + # First load + mark dates in calendar with content self._load_selected_date() self._refresh_calendar_marks() - # --- DB lifecycle def _try_connect(self) -> bool: + """ + Try to connect to the database. + """ try: self.db = DBManager(self.cfg) ok = self.db.connect() @@ -131,6 +156,9 @@ class MainWindow(QMainWindow): return ok def _prompt_for_key_until_valid(self) -> bool: + """ + Prompt for the SQLCipher key. + """ while True: dlg = KeyPrompt(self, message="Enter a key to unlock the notebook") if dlg.exec() != QDialog.Accepted: @@ -139,8 +167,11 @@ class MainWindow(QMainWindow): if self._try_connect(): return True - # --- Calendar marks to indicate text exists for htat day ----------------- def _refresh_calendar_marks(self): + """ + Sets a bold marker on the day to indicate that text exists + for that day. + """ fmt_bold = QTextCharFormat() fmt_bold.setFontWeight(QFont.Weight.Bold) # Clear previous marks @@ -161,15 +192,16 @@ class MainWindow(QMainWindow): d = self.calendar.selectedDate() return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" - def _load_selected_date(self): - date_iso = self._current_date_iso() + def _load_selected_date(self, date_iso=False): + if not date_iso: + date_iso = self._current_date_iso() try: text = self.db.get_entry(date_iso) except Exception as e: QMessageBox.critical(self, "Read Error", str(e)) return self.editor.blockSignals(True) - self.editor.setPlainText(text) + self.editor.setHtml(text) self.editor.blockSignals(False) self._dirty = False # track which date the editor currently represents @@ -212,7 +244,7 @@ class MainWindow(QMainWindow): """ if not self._dirty and not explicit: return - text = self.editor.toPlainText() + text = self.editor.toHtml() try: self.db.upsert_entry(date_iso, text) except Exception as e: @@ -249,7 +281,7 @@ class MainWindow(QMainWindow): self._load_selected_date() self._refresh_calendar_marks() - def closeEvent(self, event): # noqa: N802 + def closeEvent(self, event): try: self._save_current() self.db.close() diff --git a/bouquin/search.py b/bouquin/search.py new file mode 100644 index 0000000..8177905 --- /dev/null +++ b/bouquin/search.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import re +from typing import Iterable, Tuple + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument +from PySide6.QtWidgets import ( + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QHBoxLayout, + QVBoxLayout, + QWidget, +) + +# type: rows are (date_iso, content) +Row = Tuple[str, str] + + +class Search(QWidget): + """Encapsulates the search UI + logic and emits a signal when a result is chosen.""" + + openDateRequested = Signal(str) + + def __init__(self, db, parent: QWidget | None = None): + super().__init__(parent) + self._db = db + + self.search = QLineEdit() + self.search.setPlaceholderText("Search for notes here") + self.search.textChanged.connect(self._search) + + self.results = QListWidget() + self.results.setUniformItemSizes(False) + self.results.setSelectionMode(self.results.SelectionMode.SingleSelection) + self.results.itemClicked.connect(self._open_selected) + self.results.hide() + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + lay.addWidget(self.search) + lay.addWidget(self.results) + + def _open_selected(self, item: QListWidgetItem): + date_str = item.data(Qt.ItemDataRole.UserRole) + if date_str: + self.openDateRequested.emit(date_str) + + def _search(self, text: str): + """ + Search for the supplied text in the database. + For all rows found, populate the results widget with a clickable preview. + """ + q = text.strip() + if not q: + self.results.clear() + self.results.hide() + return + + try: + rows: Iterable[Row] = self._db.search_entries(q) + except Exception: + # be quiet on DB errors here; caller can surface if desired + rows = [] + + self._populate_results(q, rows) + + def _populate_results(self, query: str, rows: Iterable[Row]): + self.results.clear() + rows = list(rows) + if not rows: + self.results.hide() + return + + self.results.show() + + for date_str, content in rows: + # Build an HTML fragment around the match and whether to show ellipses + frag_html, left_ell, right_ell = self._make_html_snippet( + content, query, radius=60, maxlen=180 + ) + + # ---- Per-item widget: date on top, preview row below (with ellipses) ---- + container = QWidget() + outer = QVBoxLayout(container) + outer.setContentsMargins(8, 6, 8, 6) + outer.setSpacing(2) + + # Date label (plain text) + date_lbl = QLabel(date_str) + date_lbl.setTextFormat(Qt.TextFormat.PlainText) + date_f = date_lbl.font() + date_f.setPointSizeF(date_f.pointSizeF() - 1) + date_lbl.setFont(date_f) + date_lbl.setStyleSheet("color:#666;") + outer.addWidget(date_lbl) + + # Preview row with optional ellipses + row = QWidget() + h = QHBoxLayout(row) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(4) + + if left_ell: + left = QLabel("…") + left.setStyleSheet("color:#888;") + h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop) + + preview = QLabel() + preview.setTextFormat(Qt.TextFormat.RichText) + preview.setWordWrap(True) + preview.setOpenExternalLinks(True) # keep links in your HTML clickable + preview.setText( + frag_html + if frag_html + else "(no preview)" + ) + h.addWidget(preview, 1) + + if right_ell: + right = QLabel("…") + right.setStyleSheet("color:#888;") + h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom) + + outer.addWidget(row) + + # ---- Add to list ---- + item = QListWidgetItem() + item.setData(Qt.ItemDataRole.UserRole, date_str) + item.setSizeHint(container.sizeHint()) + + self.results.addItem(item) + self.results.setItemWidget(item, container) + + # --- Snippet/highlight helpers ----------------------------------------- + def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180): + doc = QTextDocument() + doc.setHtml(html_src) + plain = doc.toPlainText() + if not plain: + return "", False, False + + tokens = [t for t in re.split(r"\s+", query.strip()) if t] + L = len(plain) + + # Find first occurrence (phrase first, then earliest token) + idx, mlen = -1, 0 + if tokens: + lower = plain.lower() + phrase = " ".join(tokens).lower() + j = lower.find(phrase) + if j >= 0: + idx, mlen = j, len(phrase) + else: + for t in tokens: + tj = lower.find(t.lower()) + if tj >= 0 and (idx < 0 or tj < idx): + idx, mlen = tj, len(t) + # Compute window + if idx < 0: + start, end = 0, min(L, maxlen) + else: + start = max(0, min(idx - radius, max(0, L - maxlen))) + end = min(L, max(idx + mlen + radius, start + maxlen)) + + # Bold all token matches that fall inside [start, end) + if tokens: + lower = plain.lower() + fmt = QTextCharFormat() + fmt.setFontWeight(QFont.Weight.Bold) + for t in tokens: + t_low = t.lower() + pos = start + while True: + k = lower.find(t_low, pos) + if k == -1 or k >= end: + break + c = QTextCursor(doc) + c.setPosition(k) + c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor) + c.mergeCharFormat(fmt) + pos = k + len(t) + + # Select the window and export as HTML fragment + c = QTextCursor(doc) + c.setPosition(start) + c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + fragment_html = ( + c.selection().toHtml() + ) # preserves original styles + our bolding + + return fragment_html, start > 0, end < L diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index ca2514c..a59e1c6 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -91,7 +91,9 @@ class SettingsDialog(QDialog): return try: self._db.rekey(new_key) - QMessageBox.information(self, "Key changed", "The database key was updated.") + QMessageBox.information( + self, "Key changed", "The database key was updated." + ) except Exception as e: QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py new file mode 100644 index 0000000..93c7ee3 --- /dev/null +++ b/bouquin/toolbar.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QFont, QAction +from PySide6.QtWidgets import QToolBar + + +class ToolBar(QToolBar): + boldRequested = Signal(QFont.Weight) + italicRequested = Signal() + underlineRequested = Signal() + strikeRequested = Signal() + codeRequested = Signal() + headingRequested = Signal(int) + bulletsRequested = Signal() + numbersRequested = Signal() + alignRequested = Signal(Qt.AlignmentFlag) + + def __init__(self, parent=None): + super().__init__("Format", parent) + self._build_actions() + + def _build_actions(self): + # Bold + bold = QAction("Bold", self) + bold.setShortcut("Ctrl+B") + bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold)) + + italic = QAction("Italic", self) + italic.setShortcut("Ctrl+I") + italic.triggered.connect(self.italicRequested) + + underline = QAction("Underline", self) + underline.setShortcut("Ctrl+U") + underline.triggered.connect(self.underlineRequested) + + strike = QAction("Strikethrough", self) + strike.setShortcut("Ctrl+-") + strike.triggered.connect(self.strikeRequested) + + code = QAction("", self) + code.setShortcut("Ctrl+`") + code.triggered.connect(self.codeRequested) + + # Headings + h1 = QAction("H1", self) + h1.setShortcut("Ctrl+1") + h2 = QAction("H2", self) + h2.setShortcut("Ctrl+2") + h3 = QAction("H3", self) + h3.setShortcut("Ctrl+3") + normal = QAction("Normal", self) + normal.setShortcut("Ctrl+P") + + h1.triggered.connect(lambda: self.headingRequested.emit(24)) + h2.triggered.connect(lambda: self.headingRequested.emit(18)) + h3.triggered.connect(lambda: self.headingRequested.emit(14)) + normal.triggered.connect(lambda: self.headingRequested.emit(0)) + + # Lists + bullets = QAction("• Bullets", self) + bullets.triggered.connect(self.bulletsRequested) + numbers = QAction("1. Numbered", self) + numbers.triggered.connect(self.numbersRequested) + + # Alignment + left = QAction("Align Left", self) + center = QAction("Align Center", self) + right = QAction("Align Right", self) + + left.triggered.connect( + lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft) + ) + center.triggered.connect( + lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter) + ) + right.triggered.connect( + lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight) + ) + + self.addActions( + [ + bold, + italic, + underline, + strike, + code, + h1, + h2, + h3, + normal, + bullets, + numbers, + left, + center, + right, + ] + ) diff --git a/pyproject.toml b/pyproject.toml index 2be1386..0e7310f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [tool.poetry] name = "bouquin" -version = "0.1.1" +version = "0.1.2" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" license = "GPL-3.0-or-later" +repository = "https://git.mig5.net/mig5/bouquin" [tool.poetry.dependencies] python = ">=3.9,<3.14" diff --git a/screenshot.png b/screenshot.png index a0d47bf..85b8e83 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/tests/test_ui.py b/tests/test_ui.py index 5df04bc..280a01a 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -30,11 +30,11 @@ def test_manual_save_current_day(patched_main_window, qtbot): win, *_ = patched_main_window # Type into the editor and save - win.editor.setPlainText("Test note") + win.editor.setHtml("Test note") win._save_current(explicit=True) # call directly to avoid waiting timers day = win._current_date_iso() - assert win.db.get_entry(day) == "Test note" + assert "Test note" in win.db.get_entry(day) def test_switch_day_saves_previous(patched_main_window, qtbot): @@ -45,13 +45,13 @@ def test_switch_day_saves_previous(patched_main_window, qtbot): # Write on Day 1 d1 = win.calendar.selectedDate() d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}" - win.editor.setPlainText("Notes day 1") + win.editor.setHtml("Notes day 1") # Trigger a day change (this path calls _on_date_changed via signal) d2 = d1.addDays(1) win.calendar.setSelectedDate(d2) # After changing, previous day should be saved; editor now shows day 2 content (empty) - assert win.db.get_entry(d1_iso) == "Notes day 1" + assert "Notes day 1" in win.db.get_entry(d1_iso) assert win.editor.toPlainText() == ""