Switch to HTML (QTextEdit) and a style toolbar

This commit is contained in:
Miguel Jacq 2025-11-01 16:41:57 +11:00
parent 50fee4ec78
commit e0d7826fe0
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 263 additions and 131 deletions

View file

@ -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 # 0.1.1
* Add ability to change the key * Add ability to change the key

118
bouquin/editor.py Normal file
View file

@ -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)

View file

@ -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)

View file

@ -3,24 +3,28 @@ from __future__ import annotations
import sys import sys
from PySide6.QtCore import QDate, QTimer, Qt 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 ( from PySide6.QtWidgets import (
QDialog,
QCalendarWidget, QCalendarWidget,
QDialog,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPlainTextEdit, QSizePolicy,
QSplitter, QSplitter,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
QSizePolicy,
) )
from .db import DBManager 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 .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 .settings_dialog import SettingsDialog
from .toolbar import ToolBar
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@ -40,6 +44,8 @@ class MainWindow(QMainWindow):
self.calendar.setGridVisible(True) self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed) 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_panel = QWidget()
left_layout = QVBoxLayout(left_panel) left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
@ -47,10 +53,22 @@ class MainWindow(QMainWindow):
left_layout.addStretch(1) left_layout.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
self.editor = QPlainTextEdit() # This is the note-taking editor
tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ") self.editor = Editor()
self.editor.setTabStopDistance(tab_w)
self.highlighter = MarkdownHighlighter(self.editor.document()) # 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 = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
@ -67,13 +85,13 @@ class MainWindow(QMainWindow):
# Menu bar (File) # Menu bar (File)
mb = self.menuBar() mb = self.menuBar()
file_menu = mb.addMenu("&File") file_menu = mb.addMenu("&Application")
act_save = QAction("&Save", self) act_save = QAction("&Save", self)
act_save.setShortcut("Ctrl+S") act_save.setShortcut("Ctrl+S")
act_save.triggered.connect(lambda: self._save_current(explicit=True)) act_save.triggered.connect(lambda: self._save_current(explicit=True))
file_menu.addAction(act_save) file_menu.addAction(act_save)
act_settings = QAction("S&ettings", self) act_settings = QAction("S&ettings", self)
act_save.setShortcut("Ctrl+E") act_settings.setShortcut("Ctrl+E")
act_settings.triggered.connect(self._open_settings) act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings) file_menu.addAction(act_settings)
file_menu.addSeparator() file_menu.addSeparator()
@ -82,7 +100,7 @@ class MainWindow(QMainWindow):
act_quit.triggered.connect(self.close) act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit) file_menu.addAction(act_quit)
# Navigate menu with next/previous day # Navigate menu with next/previous/today
nav_menu = mb.addMenu("&Navigate") nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self) act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P") act_prev.setShortcut("Ctrl+P")
@ -112,12 +130,14 @@ class MainWindow(QMainWindow):
self._save_timer.timeout.connect(self._save_current) self._save_timer.timeout.connect(self._save_current)
self.editor.textChanged.connect(self._on_text_changed) 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._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
# --- DB lifecycle
def _try_connect(self) -> bool: def _try_connect(self) -> bool:
"""
Try to connect to the database.
"""
try: try:
self.db = DBManager(self.cfg) self.db = DBManager(self.cfg)
ok = self.db.connect() ok = self.db.connect()
@ -131,6 +151,9 @@ class MainWindow(QMainWindow):
return ok return ok
def _prompt_for_key_until_valid(self) -> bool: def _prompt_for_key_until_valid(self) -> bool:
"""
Prompt for the SQLCipher key.
"""
while True: while True:
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook") dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
if dlg.exec() != QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
@ -139,8 +162,11 @@ class MainWindow(QMainWindow):
if self._try_connect(): if self._try_connect():
return True return True
# --- Calendar marks to indicate text exists for htat day -----------------
def _refresh_calendar_marks(self): 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 = QTextCharFormat()
fmt_bold.setFontWeight(QFont.Weight.Bold) fmt_bold.setFontWeight(QFont.Weight.Bold)
# Clear previous marks # Clear previous marks
@ -169,7 +195,7 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Read Error", str(e)) QMessageBox.critical(self, "Read Error", str(e))
return return
self.editor.blockSignals(True) self.editor.blockSignals(True)
self.editor.setPlainText(text) self.editor.setHtml(text)
self.editor.blockSignals(False) self.editor.blockSignals(False)
self._dirty = False self._dirty = False
# track which date the editor currently represents # track which date the editor currently represents
@ -212,7 +238,7 @@ class MainWindow(QMainWindow):
""" """
if not self._dirty and not explicit: if not self._dirty and not explicit:
return return
text = self.editor.toPlainText() text = self.editor.toHtml()
try: try:
self.db.upsert_entry(date_iso, text) self.db.upsert_entry(date_iso, text)
except Exception as e: except Exception as e:
@ -249,7 +275,7 @@ class MainWindow(QMainWindow):
self._load_selected_date() self._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
def closeEvent(self, event): # noqa: N802 def closeEvent(self, event):
try: try:
self._save_current() self._save_current()
self.db.close() self.db.close()

95
bouquin/toolbar.py Normal file
View file

@ -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("<code>", 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,
]
)