From e0d7826fe0f2bdb1af748f64aeb6389029935f90 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 1 Nov 2025 16:41:57 +1100 Subject: [PATCH] Switch to HTML (QTextEdit) and a style toolbar --- CHANGELOG.md | 5 ++ bouquin/editor.py | 118 +++++++++++++++++++++++++++++++++++++++++ bouquin/highlighter.py | 112 -------------------------------------- bouquin/main_window.py | 64 +++++++++++++++------- bouquin/toolbar.py | 95 +++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 131 deletions(-) create mode 100644 bouquin/editor.py delete mode 100644 bouquin/highlighter.py create mode 100644 bouquin/toolbar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07944f2..c54582d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.1.2 + + * Switch from Markdown to HTML via QTextEdit, with a toolbar + * Fix Settings shortcut and change nav menu from 'File' to 'Application' + # 0.1.1 * Add ability to change the key diff --git a/bouquin/editor.py b/bouquin/editor.py new file mode 100644 index 0000000..bf40a6b --- /dev/null +++ b/bouquin/editor.py @@ -0,0 +1,118 @@ +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_window.py b/bouquin/main_window.py index 6b0451c..359481e 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -3,24 +3,28 @@ 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 .settings import APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog +from .toolbar import ToolBar class MainWindow(QMainWindow): @@ -40,6 +44,8 @@ class MainWindow(QMainWindow): self.calendar.setGridVisible(True) self.calendar.selectionChanged.connect(self._on_date_changed) + # 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) @@ -47,10 +53,22 @@ class MainWindow(QMainWindow): 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 +85,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 +100,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 +130,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 +151,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 +162,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 @@ -169,7 +195,7 @@ class MainWindow(QMainWindow): 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 +238,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 +275,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/toolbar.py b/bouquin/toolbar.py new file mode 100644 index 0000000..471fd8e --- /dev/null +++ b/bouquin/toolbar.py @@ -0,0 +1,95 @@ +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, + ] + )