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